HackThisSite - Application Mission 9

Challenge

Application Challenge 9 (Windows) — Match the beeps to the 'Play' button (medium) 目标:让三个 Match 按钮放出的提示音与 Play 按钮播放的音序一致,程序会用密码回馈。

包里只有一个 app9win.exe(36 KB)。它是一个 VB6 写的小窗口程序:Play 按钮播放一段三声的蜂鸣音序,Match1 / Match2 / Match3 各播放一声固定频率的蜂鸣。把频率对上以后,程序在界面上显示密码。

Solution

  • app9win.exePE32 executable for MS Windows (GUI), Intel i386,导入表只有 MSVBVM60.DLL,且 strings 里能看到 C:\Program Files\Microsoft Visual Studio\VB98\Projects\Challenge\SoundProject1.vbpDllFunctionCall__vbaStrCopy 一类符号 → VB6 原生编译(native code,非 p-code),所以 objdump -d 的线性反汇编是可信的。
  • 字符串池全部是 UTF-16LE,用 strings -el 可直接列出常量(注意 -n 3,否则 3 字符的常量会被默认长度过滤掉):
1
2
3
4
5
6
7
8
9
10
$ strings -el -n 3 app9/app9win.exe | sort -u
100
1000
1100
200
500
600
abcdefghijklmnopqrstuvwxyz
CDEFGHIJKLMN
HTS Application Challenge Programmed by Magic.
  • 三个数字常量成对出现:200 / 600 / 1100100 / 500 / 1000 同时在文件里。后面会看到,前者是程序实际使用的频率,后者是判定通过时要求的频率,两者对不上。这是本题的关键。
  • 事件处理函数的入口地址:主窗口创建 0x4054C0Play 按钮 0x405570Match1/2/3 分别是 0x405610 / 0x405690 / 0x405710、一个常驻循环动作 0x405790(即 VB6 的 Timer 事件)。以下结论全部来自本地 out/app9.asmobjdump -d app9win.exe 的 477 KB 输出)。

Step 1: Play 与 Match 按钮

Play 按钮在 0x405570,直接调用 kernel32.Beep(经 DllFunctionCall 包装在 sub_40519C):

1
2
3
4
5
6
7
8
9
4055af:	push   0x12c          ; dwDuration = 300
4055b4: push 0x64 ; dwFreq = 100
4055b6: call 0x40519c ; Beep
4055c3: push 0x12c
4055c8: push 0x1f4 ; 500
4055cd: call 0x40519c
4055d4: push 0x12c
4055d9: push 0x3e8 ; 1000
4055de: call 0x40519c

stdcall 从右往左压栈,所以最后一次 push 是第一个参数:Beep(dwFreq, dwDuration)。三声依次是 100 Hz / 500 Hz / 1000 Hz,每声 300 ms。

参数压栈顺序与按钮行为是这一步的两个判据:dwDuration 先压、dwFreq 后压,按 push 的出现顺序直读会把两个参数读反;三个 Match 按钮只调用一次 Beep(频率取自对象偏移),并不写回 [esi+0x34][esi+0x3c],点击按钮不会改变 Timer 后面要比较的字段。

Match1 / Match2 / Match3 各自只播一声,频率从对象偏移里读出来:

1
2
3
4
5
6
7
405650:	mov    edx,DWORD PTR [esi+0x34]    ; Match1
405653: push 0x12c
405658: push edx
405659: call 0x40519c ; Beep(freq, 300)

4056d0: mov edx,DWORD PTR [esi+0x38] ; Match2
405753: mov edx,DWORD PTR [esi+0x3c] ; Match3

这三个字段在主窗口创建过程 0x4054C0 里被初始化,用 __vbaI4Str(VB6 的字符串转整数)把字符串常量转成数字存进去:

1
2
3
4
5
6
7
8
9
405519:	push   0x40522c           ; "200"
40551e: call edi ; __vbaI4Str
405520: push 0x405238 ; "600"
405525: mov DWORD PTR [esi+0x34],eax ; Match1 频率 = 200
405528: call edi
40552a: push 0x405244 ; "1100"
40552f: mov DWORD PTR [esi+0x38],eax ; Match2 频率 = 600
405532: call edi
405534: mov DWORD PTR [esi+0x3c],eax ; Match3 频率 = 1100

所以三个 Match 按钮实际播的是 200 / 600 / 1100 Hz,而 Play 播的是 100 / 500 / 1000 Hz

Step 2: Timer 匹配判定

那个常驻循环动作 0x405790__vbaStrCopyabcdefghijklmnopqrstuvwxyz0x405254)拷进局部变量,然后逐个把按钮频率和字符串常量做浮点比较,相等就把按钮背景刷成浅绿 0x80FF80

1
2
3
4
5
6
7
405915:	fild   DWORD PTR [esi+0x34]        ; Match1 频率
405918: push 0x405290 ; "100"
405923: call DWORD PTR ds:0x401070 ; 字符串 -> 双精度
405929: fcomp QWORD PTR [ebp-0x3c0]
405931: test ah,0x40 ; C3 = 相等?
405934: je 0x405983 ; 不等就跳过
40594e: push 0x80ff80 ; 相等 -> BackColor = 0x80FF80

三个按钮用的是同样三段模板,比较对象分别换成了 "100"0x405290)、"500"0x4052ac)、"1000"0x4052b8):

1
2
40598c:	push   0x4052ac       ; Match2 对 "500"
4059f2: push 0x4052b8 ; Match3 对 "1000"

也就是说:判定要求 [esi+0x34]==100 && [esi+0x38]==500 && [esi+0x3c]==10000x405B0F0x405B65 把三个比较结果 AND 起来,全绿才继续)。而程序写入的是 200/600/1100,该条件不成立。按题目设计先听音、再对齐频率无法通过,必须修改二进制。

Step 3: Patch

把窗口初始化里那三个 UTF-16 字符串常量改成判定要求的频率即可(三个改动长度相同,不会破坏文件布局):

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

SRC = "app9/app9win.exe"
DST = "app9/app9win_patched.exe"

d = bytearray(open(SRC, "rb").read())


def patch(off, old, new):
assert d[off:off + len(old)] == old, (hex(off), bytes(d[off:off + len(old)]))
d[off:off + len(new)] = new
print("patched file+%#06x: %r -> %r" % (off, old.decode("utf-16-le"), new.decode("utf-16-le")))


# 0x40522c/0x405238/0x405244 是 .text 里的字符串字面量数据,
# 文件偏移 = VA - 0x400000(.text RAW/VMA 都是 0x1000,所以两者相等)
patch(0x522C, "200".encode("utf-16-le"), "100".encode("utf-16-le"))
patch(0x5238, "600".encode("utf-16-le"), "500".encode("utf-16-le"))
patch(0x5244, "1100".encode("utf-16-le"), "1000".encode("utf-16-le"))

open(DST, "wb").write(bytes(d))
print("wrote", DST, len(d), "bytes")
1
2
3
4
5
$ cd <hts-workspace> && uv run python patch_app9.py
patched file+0x522c: '200' -> '100'
patched file+0x5238: '600' -> '500'
patched file+0x5244: '1100' -> '1000'
wrote app9/app9win_patched.exe 36864 bytes

补丁后三个 Match 按钮的频率就等于 Play 的三声,Timer 的三次比较全部成立,程序进入密码构造分支。

Step 4: 密码拼接

密码是判定通过后由 0x405B6B 起的一大段代码拼出来的(strings 搜不到明文)。拼装用两个 VB6 运行时函数交替进行:

  • rtcMidCharVar(IAT 0x401034):从字符集字符串里按索引取一个字符,等价于 Mid$(charset, i, 1)
  • __vbaVarCat(IAT 0x401068):变体字符串连接。

字符集就是 0x405254abcdefghijklmnopqrstuvwxyz,索引是按 ASCII 字母表 1-based 的位置。其余补位字符是单字符常量 'C''!''T'' ''A'':''S''K'(字符串池里一对一对的 02 00 00 00 xx 00 00 00)。字符串池里并列存在的 CDEFGHIJKLMN0x405018)在整个 .text 里没有任何指令引用,是编译遗留的未使用常量,把它当成索引字符集会得到错误结果。

先从反汇编里把每次取字符的索引和语句串起来(脚本读 out/app9.asm,按 push <小立即数> 与紧随其后的 call edi 配对):

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
"""Extract the rtcMidCharVar (Mid) index sequence used by HTS App 9's password builder."""
import re

ASM = "out/app9.asm"
LINE = re.compile(r"^\s*([0-9a-f]{6}):\t([0-9a-f ]+)\t(.*)$")

insns = []
for line in open(ASM, encoding="latin1"):
m = LINE.match(line)
if m:
insns.append((int(m.group(1), 16), m.group(3).strip()))


def mid_indices(lo, hi):
"""For every 'call edi' (rtcMidCharVar) in [lo, hi], walk back to the nearest
small 'push 0xNN' (the charset index) before hitting the previous call."""
out = []
for i, (addr, text) in enumerate(insns):
if not (lo <= addr <= hi):
continue
if text.startswith("call") and text.endswith("edi"):
for j in range(i - 1, -1, -1):
_, t = insns[j]
if t.startswith("call"):
break
if t.startswith("push 0x") and "0x405" not in t:
v = int(t.split("0x")[1], 16)
if v < 0x100:
out.append(v)
break
return out


def decode(idx):
"""1-based index into 'abcdefghijklmnopqrstuvwxyz'."""
return "abcdefghijklmnopqrstuvwxyz"[idx - 1]


for lo, hi, name in ((0x405B9A, 0x405CE0, "label1"),
(0x405EAC, 0x4060ED, "label2"),
(0x40634D, 0x4064ED, "password")):
idx = mid_indices(lo, hi)
print(name, "indices:", " ".join(hex(v) for v in idx))
print(" letters:", "".join(decode(v) for v in idx))

三段构造(1-based 索引 a=1 … z=26)的实际输出:

1
2
3
4
5
6
7
$ cd <hts-workspace>/challenges/hts-app && uv run python app9_extract_mid.py
label1 indices: 0xf 0x12 0x12 0x5 0x3 0x14
letters: orrect
label2 indices: 0x8 0x5 0x17 0xe 0x13 0x5 0x12 0x9 0x13
letters: hewnseris
password indices: 0xf 0x15 0xe 0x4 0x9 0xe 0x7
letters: ounding

Vulnerabilities

答案的保护强度取决于逆向者读代码的成本,与界面上的可操作性无关。密码在校验路径上从未以明文出现,但生成算法(字符集、索引、连接顺序)与判定阈值都在客户端二进制里,静态重建即可还原;阈值还与程序实际写入的频率矛盾,正常操作路径无法通过判定,必须修改二进制。要让答案不可恢复,校验应放在服务端、客户端只提交凭据,并让每次校验使用服务端下发的一次性随机挑战;客户端代码里不应同时存在阈值与答案的生成规则。

SoundKing