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.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 字符的常量会被默认长度过滤掉):
1 | $ strings -el -n 3 app9/app9win.exe | sort -u |
- 三个数字常量成对出现:
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):
1 | 4055af: push 0x12c ; dwDuration = 300 |
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 | 405650: mov edx,DWORD PTR [esi+0x34] ; Match1 |
这三个字段在主窗口创建过程 0x4054C0 里被初始化,用
__vbaI4Str(VB6
的字符串转整数)把字符串常量转成数字存进去:
1 | 405519: push 0x40522c ; "200" |
所以三个 Match 按钮实际播的是 200 / 600 / 1100 Hz,而 Play 播的是 100 / 500 / 1000 Hz。
Step 2: Timer 匹配判定
那个常驻循环动作 0x405790 先 __vbaStrCopy
把
abcdefghijklmnopqrstuvwxyz(0x405254)拷进局部变量,然后逐个把按钮频率和字符串常量做浮点比较,相等就把按钮背景刷成浅绿
0x80FF80:
1 | 405915: fild DWORD PTR [esi+0x34] ; Match1 频率 |
三个按钮用的是同样三段模板,比较对象分别换成了
"100"(0x405290)、"500"(0x4052ac)、"1000"(0x4052b8):
1 | 40598c: push 0x4052ac ; Match2 对 "500" |
也就是说:判定要求
[esi+0x34]==100 && [esi+0x38]==500 && [esi+0x3c]==1000(0x405B0F–0x405B65
把三个比较结果 AND 起来,全绿才继续)。而程序写入的是
200/600/1100,该条件不成立。按题目设计先听音、再对齐频率无法通过,必须修改二进制。
Step 3: Patch
把窗口初始化里那三个 UTF-16 字符串常量改成判定要求的频率即可(三个改动长度相同,不会破坏文件布局):
1 | import shutil |
1 | $ cd <hts-workspace> && uv run python patch_app9.py |
补丁后三个 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 配对):
1 | """Extract the rtcMidCharVar (Mid) index sequence used by HTS App 9's password builder.""" |
三段构造(1-based 索引 a=1 … z=26)的实际输出:
1 | $ cd <hts-workspace>/challenges/hts-app && uv run python app9_extract_mid.py |
Vulnerabilities
答案的保护强度取决于逆向者读代码的成本,与界面上的可操作性无关。密码在校验路径上从未以明文出现,但生成算法(字符集、索引、连接顺序)与判定阈值都在客户端二进制里,静态重建即可还原;阈值还与程序实际写入的频率矛盾,正常操作路径无法通过判定,必须修改二进制。要让答案不可恢复,校验应放在服务端、客户端只提交凭据,并让每次校验使用服务端下发的一次性随机挑战;客户端代码里不应同时存在阈值与答案的生成规则。
SoundKing