SUCTF-EzRount
本来想整个大的, 但无奈水平有限只能尽力做到这个程度 QwQ
本来想设计一个以IPC为主的后端模式, 需要攻击者将cgi与mainproc中的功能通过猜测联系在一起
前端越权
通过抓包登录的报文, 我们可以发现如果先是随便输入一对账密
会抓到一个发向http的包
1 | GET /www/http?auth=0&action=login HTTP/1.1 |
可以看到有一个参数auth=0
此时如果放行报文就会登录失败, 但是将auth的值改成1就可以登录成功
1 | GET /control.html HTTP/1.1 |
二进制分析
架构分析
首先可以来到固件与备份, 将这个项目下载得到二进制文件
1 | ├── http |
首先可以分析得到请求的传输流程html -> /cgi-bin/*.cgi
对http进行分析, 可以发现这个文件:
- 将请求转发给
/cgi-bin/*.cgi - 处理静态资源, 并对除了login.html的静态资源进行鉴权
- 接收
login.cgi的重定向请求, 并为auth=1的会话设置cookie
接下来我们可以结合html页面和cgi综合分析每个业务逻辑的链路
业务逻辑

除了重启按钮以外, 几乎每一个接口都有对应的cgi
我们用IDA打开可以发现, 每一个cgi的结尾几乎都有一个函数CFG_SET
而且没有对这些报文的具体处理, 这就涉及到一个在iot中重要的概念 : IPC(进程间通信)
以下给出源码, 位于libutils.so中
1 | int CFG_SET(long type_id, const void *data, size_t len) { |
可以看到SET函数用于将数据流投入内核中的消息队列
对应的, GET函数负责将内容从消息队列中取出放入当前进程的内存中
综合以上结论我们可以得出 : 整个环境中必然存在一个实现GET功能的进程
我们可以从两个方向找到这个文件

可以发现一个奇怪的二进制文件mainproc,大概可以猜到这就是负责GET的文件
或者直接从docker启动脚本中发现
1 | #!/bin/bash |
起docker的时候顺手将mainproc拉起放置在后台
mainproc
现在就该重点分析mainproc了
首先可以发现这个文件中的init_array存在一个函数指针
1 | __attribute__((constructor)) void Init() { |
获取了堆的基地址, 并为其添加了x(可执行权限)<本意是想模拟iot固件中总是不开NX保护导致堆可执行, 但感觉有点刻意了:_( >
在main函数可以发现在不停监听消息队列, 只要消息队列中有内容便投入dispatch_action中
1 | int main(int argc, char *argv[]) { |
接着在dispatch_action函数中可以发现一个巨大的switch-case结构(IDA的反编译会变成if-else结构)
1 | switch (msg->mtype) { |
根据不同的魔数, 调用不同功能的函数, 从cgi中提取不同的功能可以整理出接口与处理函数的对应关系(其实从名字就能看出来)
接下来应该梳理不同结构体, 结构体从IDA静态分析不是很容易, 推荐通过gdb调试描绘结构体轮廓
**黑白名单 : **
1 | struct __attribute__((packed)) mac_req { |
**wifi设置 : **
1 | struct wifi_req { |
这两种结构体只会在堆上创建两种不同大小的堆块, 没有具体的作用
**vpn : **
在vpn.cgi中
1 | struct __attribute__((packed)) vpn_recv { |
在mainproc中
1 | struct vpn_config_req { |
可以发现vpn结构体在两个进程中的结构差异很大, 且在mainproc中存在函数和内存两种指针
不同的处理函数的逻辑很简单, 包括vpn也是, 从cgi结构体中将同名成员复制到mainproc结构体中
但是注意, 这里使用了不安全的strcpy且没有做保护
1 | void Set_VPN(struct router_msgbuf *msg) { |
我们知道这些成员都是从json中取出来的, 一般的json都会在字段的结构加上’\0’
但如果我们前往.so审计
1 | void extract_json_string(const char *json, const char *key, char *out, size_t max_len) { |
发现当字段的预定长度被充满后, 就不会在末尾加上null戳
结合strcpy我们就可以实现off-by-null
从结构体结构上看, 最有溢出价值的字段就是cert和pass字段, 可以修改两种指针的末尾
但是在IDA中审计发现, 如果把default_vpn_apply的末尾改成null, 会跳转到一个导致进程段错误的地址
所以可以利用的字段只剩下了pass字段, 可以修改custom字段的末尾, 而且custom指向的是堆地址
我们可以用堆风水的手段, 让custom能指向一个能够修改函数指针的地址
1 | size_t decode_base64_in_place(char *str) { |
同时可以发现在vpn.cgi中, 当字段以B64开头, 整个字段会被base64编码后使用json传输, 这解决了json对不可见字符传递的局限性
现在可以考虑如何进行堆风水了, 既然想把custom指向函数指针, 我们就要让末尾被置零后的地址小于等于函数指针
1 | void (*apply_cb)(struct vpn_config_req *); |
可以看到这两个地址间的offset为8+0x20+0x20+0x20+0x30+0x20+0x20=0xd8
最好的解决方法是让void (*apply_cb)(struct vpn_config_req *)的最低字节为00
通过简单的计算可以得到, 我们只需要进行wifi*1 + list*7边可以将函数指针挤到末字节为00的地址
1 | pwndbg> heap |
此时从pass字段溢出null, 就可以让custom指向04:0020│ 0x555555559500 —▸ 0x55555555540d (default_vpn_apply) ◂— endbr64
我们便可以修改函数指针
gadget选择
修改gadget, 需要我们观察default_vpn_apply的函数签名, 可以发现这个函数
1 | void default_vpn_apply(struct vpn_config_req *req) { |
这个函数有一个一参
使用ROPgadget --binary mainproc > gadget提取全部gadget
发现了一个特殊的gadget : jmp rdi
如果函数指针被覆盖为这个 , 我们便可以跳转到req上, 具体来说是vpn结构体的第一个字段 : uint16_t custom_len;
将这个长度作为机器码执行
此时的我们便可以任意执行两个字节, 这肯定是不够的
常见的作法就是将这两个字节写作跳转指令, 方便我们跳转更高地址的堆上执行代码

处理得当我们就能得到funtion_ptr->length->cert->custom的jop链, 最终跳转到custom上, 几乎无限长度地执行shellcode
我选择是是先在length处jmp $+9, 然后再cert中jmp $+offset进行一段较长跳转, 最后在custom上做nop滑梯2shellcode
这个过程中因为PIE保护, 需要进行1/16概率的爆破, 才能将jmp rdi写入函数指针
shellcode
由于mainproc没有回显, 我们可以加载将flag写入/www/下的html文件的方法获得flag
因为http可以代理所有/www/*.html文件
EXP
综上所述, 将攻击手法转化为http报文后, exp如下
1 | from pwn import * |