WeChall - The Last Hope

Challenge

WeChall 上的 The Last Hope(Linux, Cracking),由一个 32-bit ELF 二进制构成。

反调试绕过

二进制有 5 层反调试,必须全部 patch 掉才能正常运行或调试。

先用 rabin2 获取基本信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ rabin2 -I bsd_thelasthope.elf
arch x86
binsz 10569
bintype elf
bits 32
canary false
class ELF32
compiler GCC: (Ubuntu 4.3.3-5ubuntu4) 4.3.3
nx true
os linux
pic false
relocs false
static false
stripped false
subsys linux

Layer 1: .ctors 构造函数

main() 之前,.ctors 段中的 anti_ptrace 就会被调用:

1
2
$ r2 -q -c 'pf. S .ctors' bsd_thelasthope.elf
0x0804af00 ffffffff 5b8c0408 00000000 ....[.......

0x08048c5banti_ptrace。将其 patch 为 0xffffffff(sentinel 值),让反向遍历提前终止:

1
2
# .ctors entry @ VA 0x0804af04 → 文件偏移 = VA - 0x08049000 = 0x1f04
printf '\xff\xff\xff\xff' | dd of=bsd_thelasthope.elf bs=1 seek=$((0x1f04)) conv=notrunc

Layer 2 & 3: main 中的 anti_ptrace 调用 + SIGTRAP handler

用 r2 查看 main 的 disassembly:

1
$ r2 -A -q -c 'pdf @ main' bsd_thelasthope.elf

关键片段:

1
2
3
4
0x08048d59: call anti_ptrace
0x08048d5e: mov dword [esp+4], handler ; signal handler
0x08048d66: mov dword [esp], 5 ; SIGTRAP
0x08048d6d: call signal

Patch 这两处为 NOP:

1
2
# VA 0x08048d59 → 文件偏移 0x0d59: 5 bytes → NOP
# VA 0x08048d6d → 文件偏移 0x0d6d: 5 bytes → NOP

Layer 4: INT3 + breakpoint 检测

1
2
3
4
5
6
7
8
0x08048d72: int3                    ; SIGTRAP → handler 吞掉
0x08048d73: mov eax, 0x8048bd1 ; pw_check 地址
0x08048d78: add eax, 3
0x08048d7b: mov eax, [eax]
0x08048d7d: and eax, 0xff
0x08048d82: cmp eax, 0xcc ; 检查 pw_check+3 是否被设了 0xCC (int3)
0x08048d87: jne ...
; 如果发现 0xCC → 打印 "no,no breakpoints" → exit

Patch 方案:把 int3 (VA 0x08048d72 → 文件偏移 0x0d72) NOP 掉,把 jne (VA 0x08048d87 → 0x0d87) 改为 jmp

Layer 5: ptrace(PTRACE_TRACEME)

1
2
3
0x08048dc0: call ptrace             ; ptrace(PTRACE_TRACEME, 0, 1, 0)
0x08048dc5: test eax, eax
0x08048dc7: jns ... ; ≥0 → 正常; <0 → "oh oh DEBUGGING... Bye"

由于 Layer 1 的 fork 子进程已经 ptrace(ATTACH) 了父进程,PTRACE_TRACEME 必然失败(一个进程只能被一个 tracer 追踪)。改为 xor eax, eax

1
# VA 0x08048dc0 → 文件偏移 0x0dc0: 5 bytes → xor eax,eax; nop; nop; nop (31 c0 90 90 90)

完整 patch 脚本见文末。

Username 逆向 (user_check)

函数调用链

用 r2 列出关键函数:

1
2
3
4
$ r2 -q -c 'afl~check' bsd_thelasthope.elf
0x08048863 user_check
0x08048bd1 pw_check
0x080487a6 length_check

main 中的处理流程:

  1. fgets(username, 15, stdin) — 读入包含换行符(如 "whoami\n"strlen=7
  2. lc(username, len) — 检查前 len-1 个字符不含大写字母
  3. uc(username, len) — 将前 len-1 个字符转为大写(原地修改,内部调用 toupper()
  4. user_check(username, len) — 逐字符验证

由于 fgets 保留换行符,strlen("whoami\n")=7,循环 i=0..5 覆盖全部 6 个有效字符。换行符在 i=6 处被排除。

约束条件(应用于大写 ASCII 值)

pwntools 快速提取符号和关键常量:

1
2
3
4
5
6
7
8
from pwn import *

elf = ELF('bsd_thelasthope.elf')
print(f"user_check @ {elf.symbols['user_check']:#x}")

# 读取加密目标字符串(位于 .rodata 0x08049050)
target = elf.read(0x08049050, 10)
print(f"encrypted target: {target}") # b'Oxw|n]nfog'

user_check 对每个位置 i 执行不同的检查,将满足条件的值累加到 accumulator,最终与 0xcd5 (3285) 比较:

  • 0, 1, 4: 6c±1 双 Fermat + 硬编码,累加 +13c。f0=6c-1, f1=6c+1 均须为基-2 伪素数
  • 2, 5: 单 Fermat (c 自身),累加 +c。2^(c-1) mod c == 1
  • 3: 整除性,累加 +c。c % 5 == 0

硬编码检查(在 fermat 通过后执行):

  • U[0]: 2×c == 0xae (174) → c = 87 = W
  • U[1]: 2×c == 0x90 (144) → c = 72 = H
  • U[4]: 2×c == 0x9a (154) → c = 77 = M

U[0], U[1], U[4] 被固定为 W, H, M。同时要求 6c±1 为素数对(twin primes 模式):

  • W=87: 6×87-1=521(✓), 6×87+1=523(✓)
  • H=72: 6×72-1=431(✓), 6×72+1=433(✓)
  • M=77: 6×77-1=461(✓), 6×77+1=463(✓)

注意:positions 0,1,4 的 fermat 检查后有一条看似多余的 c == floor(6c/5) 比较,实际上编译器 magic constant 0x2aaaaaab 做的是除以 6(而非除以 5)。floor(6c/6) == c 恒成立,实为 no-op。

求解

已知 13×(87+72+77) = 3068,剩余:3285 - 3068 = 217。

在 A-Z (65-90) 范围内筛选:

  • U[2], U[5] 须为 Fermat 伪素数: C, G, I, O, S, Y
  • U[3] 须被 5 整除: A, F, K, P, U, Z

求 U[2] + U[3] + U[5] = 217 的解:

1
2
3
U[2]=G(71), U[3]=K(75), U[5]=G(71) → WHGKMG (whgkmg)
U[2]=I(73), U[3]=A(65), U[5]=O(79) → WHIAMO (whiamo)
U[2]=O(79), U[3]=A(65), U[5]=I(73) → WHOAMI (whoami)

唯一形成有意义单词的是 WHOAMI,对应输入 whoami(程序通过 uc() 自动转大写)。

z3 求解(替代方案)

此题约束本质是 CSP:6 个变量、有限域(A-Z)、线性方程 + 素性谓词。z3 的 table-driven 模式很适合——预计算 26 个字符中哪些满足各位置约束,交给 z3 解线性部分:

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

def is_fermat_prime(n):
if n < 2: return False
return pow(2, n-1, n) == 1

# 预计算:26 个字符中哪些满足各位置的素性约束
valid_014 = [c for c in range(65,91)
if is_fermat_prime(6*c-1) and is_fermat_prime(6*c+1)]
valid_25 = [c for c in range(65,91) if is_fermat_prime(c)]
valid_3 = [c for c in range(65,91) if c % 5 == 0]

# Bool table:每个字符是否满足对应约束
table_014 = {c: Bool(f"twin_{c}") for c in range(65, 91)}
table_25 = {c: Bool(f"prime_{c}") for c in range(65, 91)}
table_3 = {c: Bool(f"mod5_{c}") for c in range(65, 91)}

s = Solver()
for c in range(65, 91):
s.add(table_014[c] == (c in valid_014))
s.add(table_25[c] == (c in valid_25))
s.add(table_3[c] == (c % 5 == 0))

U = [BitVec(f'U{i}', 16) for i in range(6)]
for i in range(6):
s.add(U[i] >= 65, U[i] <= 90)

# Per-position constraints via table lookup
s.add(Or([And(U[0] == c, table_014[c]) for c in range(65, 91)]))
s.add(Or([And(U[1] == c, table_014[c]) for c in range(65, 91)]))
s.add(Or([And(U[4] == c, table_014[c]) for c in range(65, 91)]))
s.add(Or([And(U[2] == c, table_25[c]) for c in range(65, 91)]))
s.add(Or([And(U[5] == c, table_25[c]) for c in range(65, 91)]))
s.add(Or([And(U[3] == c, table_3[c]) for c in range(65, 91)]))

# Hardcoded checks (from assembly)
s.add(2 * U[0] == 174) # 0xae → W
s.add(2 * U[1] == 144) # 0x90 → H
s.add(2 * U[4] == 154) # 0x9a → M

# Accumulator sum
s.add(13 * (U[0] + U[1] + U[4]) + U[2] + U[3] + U[5] == 3285)

while s.check() == sat:
m = s.model()
name = ''.join(chr(m[U[i]].as_long()) for i in range(6))
print(f"{name}{name.lower()}")
s.add(Or([U[i] != m[U[i]] for i in range(6)]))

# Output:
# WHGKMG → whgkmg
# WHOAMI → whoami
# WHIAMO → whiamo

为什么不用 angr? 此题有 x87 浮点指令(fprem/fmod 做 Fermat 检验)+ fork/ptrace 反调试 + INT3 断点检测。angr 的 VEX IR 对 x87 浮点栈支持弱,fork 会直接 concretize,且 patch 5 层反调试后 angr 能做的也只是验证已知路径——不如直接 z3 解约束。

Password 逆向 (pw_check)

XOR 加密

用 r2 反编译 encrypt 函数:

1
$ r2 -A -q -c 'pdf @ sym.encrypt' bsd_thelasthope.elf

main 中定义了 15 字节 XOR key(位于 [ebp-0x44]):

1
2
[0x1f, 0x0a, 0x1e, 0x11, 0x0b, 0x09, 0x19, 0x0f, 0x01, 0x14,
0x16, 0x0c, 0x06, 0x0d, 0x65]

encrypt() 函数对密码逐字节 XOR:

1
2
for (i = 0; i < len; i++)
password[i] ^= key[i % 15];

然后 chomp() 去掉末尾换行符,pw_check() 将结果与硬编码字符串比较。

用 pwntools 直接读取目标字符串:

1
2
3
4
from pwn import *
elf = ELF('bsd_thelasthope.elf')
target = elf.read(0x08049050, 10).decode()
print(f"target: {target}") # Oxw|n]nfog

或直接用 r2:

1
2
$ r2 -q -c 'ps @ 0x08049050' bsd_thelasthope.elf
Oxw|n]nfog

求解

直接逆向 XOR,key 和密文等长(均为 10 字节):

1
2
3
4
encrypted = b"Oxw|n]nfog"
key = [0x1f, 0x0a, 0x1e, 0x11, 0x0b, 0x09, 0x19, 0x0f, 0x01, 0x14]
password = ''.join(chr(e ^ key[i]) for i, e in enumerate(encrypted))
# → "PrimeTwins"

验证

1
2
3
4
5
6
7
8
9
10
O=0x4f ^ 0x1f = 0x50 = P
x=0x78 ^ 0x0a = 0x72 = r
w=0x77 ^ 0x1e = 0x69 = i
|=0x7c ^ 0x11 = 0x6d = m
n=0x6e ^ 0x0b = 0x65 = e
]=0x5d ^ 0x09 = 0x54 = T
n=0x6e ^ 0x19 = 0x77 = w
f=0x66 ^ 0x0f = 0x69 = i
o=0x6f ^ 0x01 = 0x6e = n
g=0x67 ^ 0x14 = 0x73 = s

完整 Patch 脚本

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
#!/usr/bin/env python3
"""Patch anti-debug measures in bsd_thelasthope.elf

ELF segment layout:
text: file 0x000000 → VA 0x08048000 (file_off = VA - 0x08048000)
data: file 0x001f00 → VA 0x0804af00 (file_off = VA - 0x08049000)
"""
import sys

def patch(filepath):
with open(filepath, 'r+b') as f:
# Layer 1: .ctors → nuke anti_ptrace entry (VA 0x0804af04, data seg)
f.seek(0x1f04)
f.write(b'\xff\xff\xff\xff')

# Layer 2: NOP call anti_ptrace @ VA 0x08048d59 (5 bytes, text seg)
f.seek(0x0d59)
f.write(b'\x90' * 5)

# Layer 3: NOP call signal @ VA 0x08048d6d (5 bytes)
f.seek(0x0d6d)
f.write(b'\x90' * 5)

# Layer 4a: NOP int3 @ VA 0x08048d72 (1 byte)
f.seek(0x0d72)
f.write(b'\x90')

# Layer 4b: bypass 0xCC check — jne→jmp @ VA 0x08048d87 (1 byte)
f.seek(0x0d87)
f.write(b'\xeb') # 0x75(jne) → 0xeb(jmp)

# Layer 5: call ptrace → xor eax,eax @ VA 0x08048dc0 (5 bytes)
f.seek(0x0dc0)
f.write(b'\x31\xc0\x90\x90\x90') # xor eax,eax; nop; nop; nop

print(f"Patched: {filepath}")

if __name__ == '__main__':
patch(sys.argv[1] if len(sys.argv) > 1 else 'bsd_thelasthope.elf')

Patch 后运行:

1
2
3
4
5
6
$ python3 patch.py bsd_thelasthope.elf
$ chmod +x bsd_thelasthope.elf
$ ./bsd_thelasthope.elf
User: whoami
Password: PrimeTwins
Correct !! The solution is username_password
whoami_PrimeTwins