第八届西湖论剑初赛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 |