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.exe是PE32 executable for MS Windows (GUI), Intel i386,导入表只有MSVBVM60.DLL,且strings里能看到C:\Program Files\Microsoft Visual Studio\VB98\Projects\Challenge\SoundProject1.vbp、DllFunctionCall、__vbaStrCopy一类符号 → VB6 原生编译(native code,非 p-code),所以objdump -d的线性反汇编是可信的。- 字符串池全部是 UTF-16LE,用
strings -el可直接列出常量(注意-n 3,否则 3 字符的常量会被默认长度过滤掉):
$ strings -el -n 3 app9/app9win.exe | sort -u10010001100200500600abcdefghijklmnopqrstuvwxyzCDEFGHIJKLMNHTS Application Challenge Programmed by Magic.- 三个数字常量成对出现:
200/600/1100与100/500/1000同时在文件里。后面会看到,前者是程序实际使用的频率,后者是判定通过时要求的频率,两者对不上。这是本题的关键。 - 事件处理函数的入口地址:主窗口创建
0x4054C0、Play按钮0x405570、Match1/2/3分别是0x405610/0x405690/0x405710、一个常驻循环动作0x405790(即 VB6 的 Timer 事件)。以下结论全部来自本地out/app9.asm(objdump -d app9win.exe的 477 KB 输出)。
Step 1: Play 与 Match 按钮
Play 按钮在 0x405570,直接调用 kernel32.Beep(经 DllFunctionCall 包装在 sub_40519C):
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 0x40519cstdcall 从右往左压栈,所以最后一次 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 各自只播一声,频率从对象偏移里读出来:
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 的字符串转整数)把字符串常量转成数字存进去:
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 先 __vbaStrCopy 把 abcdefghijklmnopqrstuvwxyz(0x405254)拷进局部变量,然后逐个把按钮频率和字符串常量做浮点比较,相等就把按钮背景刷成浅绿 0x80FF80:
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):
40598c: push 0x4052ac ; Match2 对 "500" 4059f2: push 0x4052b8 ; Match3 对 "1000"也就是说:判定要求 [esi+0x34]==100 && [esi+0x38]==500 && [esi+0x3c]==1000(0x405B0F–0x405B65 把三个比较结果 AND 起来,全绿才继续)。而程序写入的是 200/600/1100,该条件不成立。按题目设计先听音、再对齐频率无法通过,必须修改二进制。
Step 3: Patch
把窗口初始化里那三个 UTF-16 字符串常量改成判定要求的频率即可(三个改动长度相同,不会破坏文件布局):
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")$ cd <hts-workspace> && uv run python patch_app9.pypatched 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(IAT0x401034):从字符集字符串里按索引取一个字符,等价于Mid$(charset, i, 1);__vbaVarCat(IAT0x401068):变体字符串连接。
字符集就是 0x405254 的 abcdefghijklmnopqrstuvwxyz,索引是按 ASCII 字母表 1-based 的位置。其余补位字符是单字符常量 'C'、'!'、'T'、' '、'A'、':'、'S'、'K'(字符串池里一对一对的 02 00 00 00 xx 00 00 00)。字符串池里并列存在的 CDEFGHIJKLMN(0x405018)在整个 .text 里没有任何指令引用,是编译遗留的未使用常量,把它当成索引字符集会得到错误结果。
先从反汇编里把每次取字符的索引和语句串起来(脚本读 out/app9.asm,按 push <小立即数> 与紧随其后的 call edi 配对):
"""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): continue16 collapsed lines
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)的实际输出:
$ cd <hts-workspace>/challenges/hts-app && uv run python app9_extract_mid.pylabel1 indices: 0xf 0x12 0x12 0x5 0x3 0x14 letters: orrectlabel2 indices: 0x8 0x5 0x17 0xe 0x13 0x5 0x12 0x9 0x13 letters: hewnserispassword indices: 0xf 0x15 0xe 0x4 0x9 0xe 0x7 letters: oundingVulnerabilities
答案的保护强度取决于逆向者读代码的成本,与界面上的可操作性无关。密码在校验路径上从未以明文出现,但生成算法(字符集、索引、连接顺序)与判定阈值都在客户端二进制里,静态重建即可还原;阈值还与程序实际写入的频率矛盾,正常操作路径无法通过判定,必须修改二进制。要让答案不可恢复,校验应放在服务端、客户端只提交凭据,并让每次校验使用服务端下发的一次性随机挑战;客户端代码里不应同时存在阈值与答案的生成规则。
SoundKing