第八届西湖论剑初赛IOT-linkon的wp
前言
很久没发过 CTF 的 writeup 了,水一篇 CTF-IOT 的。前几天被学弟喊着看了一道西湖论剑的 IOT 题目,做题的时候忘记看 ASLR
等级了。因此被一个虚假的地址随机化困扰了许久……没有地址随机化之后果然通畅多了🫡。
热身运动
题目给了虚拟机镜像和启动脚本,但为了后续远程调试,再修改一下启动脚本(加一个端口映射hostfwd=tcp::9999-:9999)。
sudo qemu-system-mipsel -M malta -kernel vmlinux-3.2.0-4-4kc-malta -hda debian_wheezy_mipsel_standard.qcow2 -append "root=/dev/sda1 console=tty0" -net user,hostfwd=tcp::80-:80,hostfwd=tcp::2222-:22,hostfwd=tcp::9999-:9999 -net nic -nographic |
如果报错 qemu-system-mipsel: -net user,hostfwd=tcp::80-:80,hostfwd=tcp::2222-:22,hostfwd=tcp::9999-:9999: network backend 'user' is not compiled into this binary
表示不支持 user
网络后端。需要重新编译安装 QEMU
。
系统正常启动后,用户名和密码输入 root
登录。
这个时候在浏览器访问宿主机的 IP
应该会出现以下界面,表示赛题正常启动。
随便输入一个密码登录,抓一个包发现请求的是 /cgi-bin/login.cgi
。
用 find ./ -name "*.cgi"
定位一下 login.cgi
的位置。
发现 login.cgi
是 mips32
小端序,并且保护全关。
开始分析
接下来就是去分析几个 cgi
文件,漏洞其实比较明显就位于 login.cgi
文件。web_get
函数用来解析数据包中的字段,比如在 main
函数中有代码 v36 = (const char *)web_get("page", v3, 0);
,那么 page
字段的值就会被解析到 v36
变量中。当 page
字段为 Goto_chidx
时,那么就通过函数指针调用了 Goto_chidx
函数。
Goto_chidx
函数中的漏洞很明显,从 wlanUrl
中解析的字段没有经过校验就用 sprintf
函数拼接,从而导致了栈溢出。
这里的漏洞验证也非常简单,当 wlanUrl
字段的字符串比较短时,响应数据包的状态码是 302
。
然后把 wlanUrl
字段的字符串写长一些,就返回了 500
表示服务器内部出现错误。
其实定位到漏洞这里用的时间还是很短的,但是因为 sprintf
函数造成的栈溢出会存在 \x00
截断问题导致不能利用 cgi
本身的程序地址进行 ROP
。由于我一直没有意识到 ASLR
是 0
(下意识以为 ASLR
开的是 2
,忘记看 /proc/sys/kernel/randomize_va_space
的值了 ORZ),如果把这道题当做 ASLR
为 2
,在 libc
地址未知的情况下,似乎也没什么办法稳定的 RCE
。
间接命令注入?
于是乎我的思路彻底跑偏,我又去分析了其他函数以及其他文件。在比赛的时候,我发现似乎还有一个间接的命令注入漏洞,就位于 sys_login
函数。虽然 sys_login
函数中对于可能存在命令注入的地方很谨慎,都进行了过滤。但仍然存在下面的代码,从 nvram
中读取的 Password
字段直接拼接到了 v91
,经过 sprintf
拼接后给到 popen
执行。
v115 = (char *)nvram_bufget(0, "Password"); |
我又找了一下可以设置 nvram
中 Password
的代码。当 page
字段为 sysinit
时,可以调用 set_sys_init
函数,代码如下。
v5 = (const char *)web_get("newpass", a1, 0); |
如果这看上面这部分,似乎逻辑也是通顺的,间接控制 nvram
中的 Password
字段进行命令执行。在比赛期间这个地方和溢出漏洞我都看到了,但意味是存在 so
库地址随机化,想着命令注入肯定比溢出漏洞利用稳定,因此我的精力都放在了前者上面😅。
间接命令注入失败的原因是在 set_sys_init
调用 check_valid_user
函数判断了返回值 。check_valid_user
定义在 libwebutil.so
库中,具体逻辑没有仔细看了,但肯定是检查鉴权身份的。不过由于没办法登录路由器(这个 nvram
在系统环境里没有),因此也不存在登录获取凭证了。后面再拐过头看溢出漏洞的时候,其实已经没多少时间了,因为一直没有注意到 ASLR
为 0
,所以比赛结束也没做出来。等到官方 WP
出来,看完才发现 ASLR
为 0
😭。
溢出漏洞利用
ASLR
为 0
代表所有地址都是固定的,因此栈地址和 libc
地址随便用,这些地址都是四个字节不会出现 \x00
截断的问题。在 Goto_chidx
函数返回时,可以控制这些寄存器。
在 libwebutil.so
库中找一下 system
函数,交叉引用后有两处。发现 system
的参数 $a0
寄存器都来自 bss
段,这样参数不太好控制。
所以又看了一下 do_system
函数,发现这个参数 $a0
是由 $s0
寄存器赋值的,而 $s0
寄存器在 Goto_chidx
函数返回的时候就可控了。因此直接跳转到这里就可以,但因为借助 $gp
寄存器最多访问 ±32KB
的数据,之前执行流在 login.cgi
的 $gp
寄存器值会比较小。如果直接将执行流跳转到 libwebutil.so
的代码段,那么会因为 $gp
寄存器异常,从而无法调用里面的任何函数。
所以还需要再找一段 gadget
来恢复 $gp
寄存器(写完之后,才发现在地址 0x7970
就可以顺带修复 $gp
了),我找的片段如下。
.fini:0000A554 lw $gp, 16($sp) |
复原 $gp
寄存器就是找到要跳转的目标函数 do_system
,查看起始汇编代码为 li $gp, (_GLOBAL_OFFSET_TABLE_+0x7FF0)
。按照这个计算方式得到要复原的 $gp
,双击 _GLOBAL_OFFSET_TABLE_
获取地址为 0x55560
。因此 $gp
为 libc_base + _GLOBAL_OFFSET_TABLE_ + 0x7FF0 => 0x77e1e000+0x55560+0x7FF0 = 0x77e7b550
。
可以写出 payload
,但发现打不通。这个时候需要对 cgi
进行调试,因为 cgi
进程是瞬时结束(从接收到数据包触发,不会有阻塞,瞬间运行结束),我选择的是 patch
程序打一个死循环。
cmd_addr_in_stack = 0x7fff6610 #这个栈地址需要调试获取 |
在 set_sys_init
函数开始时,随后找一个汇编指令,直接修改机器码。mips
里面死循环的机器码为 FF FF 00 10
(想不起来的话,自己写个程序编译一下就能看到了)。
改完的伪代码效果如下(记得保存~)。
这个时候把原本的 login.cgi
给替换掉,发送数据包后,因为修改后的 login.cgi
会陷入死循环导致进程一直挂起。这个时候再传入一个 gdbserver
,附加进程调试。需要在 qemu
启动脚本添加一个 9999
的端口映射,否则无法远程调试。
用 gdb-multiarch
连接上后,应该是一直阻塞到死循环了(如下)。
执行一下 set *0x4039c0=0x8fbc0018
把原本修改的死循环机器码用 set
给复原,下面就可以正常调试了。
现在是调试分析一下为什么上面的 payload
打不通,发现在地址 0x77e22c68
开始依次将 $a1 $a2 $a3
赋值到 $sp+0x2c
起始的位置,尴尬的是这个位置(0x7fff65e8+0x2c=0x7fff6614)指向存储执行命令的内存了,导致执行的命令被破坏。
解决这个问题只需要将执行的命令往栈中高地址处布置即可,修改后的 payload
如下。
libc_base=0x77e1e000 |
重新发送 payload
,发现文件成功创建,表示命令执行成功。
因为题目提示说靶机不出网,所以执行的命令可以改成这个 cp /flag /etc_ro/lighttpd/www/flag.txt
,然后访问 /flag.txt
就可以直接看到 flag
了。
在 login.cgi
文件的 main
函数还有一个坑,检查了数据包的长度。如果超过 0x1f3
字节,则会直接 return
。因此无论是 rop
链子的长度,还是数据包中的 body
部分放了其他多余字段,都需要注意,避免过长导致触发不了功能。
v8 = getenv("CONTENT_LENGTH"); |
如果 CONTENT_LENGTH
超过 499
那么响应包如下。
EXP
import requests |