HackThisSite - Application Mission 10

Challenge

Application Challenge 10 (Windows) — Find the Password. (medium) 目标:从这个 Windows 程序里找出 password。

包内只有一个 app10win.exe(32 KB,PE32 GUI)。它是个 VB6 程序(工程名 ch16Project1,路径 C:\Program Files\Microsoft Visual Studio\VB98\Projects\Challenge\ch16Project1.vbpstrings -el 里还留着原作者署名 HTS Application Challenge Programmed by Magic.),界面上有个 Proceed 按钮,按下去只会弹一个 Error-266 警告框(Error: 404 object(pwd); not found!)。真正的答案在一个没有任何控件会触发的 event handler 里被逐字符拼出来,再用一个消息框显示。

Solution

  • file app10win.exePE32 executable for MS Windows 4.00 (GUI), Intel i386, 3 sections;导入表只有 MSVBVM60.DLL,而且大部分是按 ordinal 导入0000104c 608 <none>00001018 595 <none>)→ VB6 native-code 编译产物。查 msvbvm60 的 ordinal 表(本机在 /usr/share/retdec/support/ordinals/x86/msvbvm60.ord,PE 库的 ordlookup 也带同一份):100 = ThunRTMain595 = rtcMsgBox608 = rtcVarBstrFromAnsi0x401054 = __vbaVarCat
  • 解析 PE 导入表逐槽核对过:0x401018 -> ord 5950x40104c -> ord 6080x401054 -> __vbaVarCat(按名字导入)、0x401008 -> __vbaFreeVarList0x401074 -> __vbaVarDup,与后面反汇编里各调用点的角色严格对应。
  • strings -el(VB6 的字符串资源是 UTF-16LE,普通 strings 看不到)只有 ch16Project1HTS Application Challenge Programmed by Magic.Error: 404 object(pwd); not found!Error-266Untitled-1没有明文密码
  • binwalk 在 exe 里报出一个内嵌 JPEG(file offset 0x1272)。carve 出来(0x12720x36aa)是 233×33 的装饰图,里面没有密码文本。
  • 字符串和图片都没收获,于是转去看 VB6 的 event 分派表。

Step 1: event handler 分派表

VB6 native code 把每个 form / control 的事件编译成表里的一条 entry,形状统一是 sub dword ptr [esp+4], <event id> 紧跟 jmp <handler>(运行时把事件号压栈,分派器减掉基址后跳转)。app10 的表在 0x404904,总共三条:

1
2
3
4
5
6
7
$ objdump -d -M intel app10win.exe | sed -n '/404904:/,/404928:/p'
404904: sub DWORD PTR [esp+0x4],0x3b
40490c: jmp 0x4049e0
404911: sub DWORD PTR [esp+0x4],0x33
404919: jmp 0x405470
40491e: sub DWORD PTR [esp+0x4],0x37
404926: jmp 0x405500

(objdump 不打印注释;上面这六行的 sub/jmp 是原始输出,下面这张对照表里的事件含义来自各 handler 的代码。)

1
2
3
event 0x3b  ->  sub 0x3b / jmp 0x4049e0    ; "mystery event",form 上没有控件触发它
event 0x33 -> sub 0x33 / jmp 0x405470 ; main window create
event 0x37 -> sub 0x37 / jmp 0x405500 ; "Proceed" 按钮

三条 entry 的 event id 与 jmp 目标都在本地 objdump 里逐字节核对过(例如 404926 处的 e9 d5 0b 00 00 就是 jmp 0x405500)。

Step 2: Proceed 按钮无效

0x405500 整个函数只做一件事:把两个 UTF-16 常量装进 VARIANT,然后弹消息框(; 后的注释为本文所加,objdump 本身不打印注释):

1
2
3
405542: mov  edi,DWORD PTR ds:0x401074   ; __vbaVarDup
40557d: mov DWORD PTR [ebp-0x6c],0x4045a8
40558f: mov DWORD PTR [ebp-0x5c],0x40455c

以上是常量装载(第一条把 __vbaVarDup 装进 edi,后两条把两个宽字符常量的地址写进 VARIANT 槽位);接下来是 rtcMsgBox 的参数与调用:

1
2
3
4
4055aa: push 0x30                        ; 48 = vbExclamation
4055ad: call DWORD PTR ds:0x401018 ; MSVBVM60 ord 595 = rtcMsgBox
4055c3: push 0x4
4055c5: call DWORD PTR ds:0x401008 ; __vbaFreeVarList(0x4, 4 个 VARIANT)

0x4045a80x40455c 指向 .text 里的宽字符常量,直接把文件解出来看就是这两个字符串:

1
2
3
4
5
6
$ xxd -s 0x45a8 -l 20 app10win.exe
000045a8: 4500 7200 7200 6f00 7200 2d00 3200 3600 E.r.r.o.r.-.2.6.
000045b8: 3600 0000 6...
$ xxd -s 0x455c -l 32 app10win.exe
0000455c: 4500 7200 7200 6f00 7200 3a00 2000 3400 E.r.r.o.r.:. .4.
0000456c: 3000 3400 2000 6f00 6200 6a00 6500 6300 0.4. .o.b.j.e.c.

也就是 rtcMsgBox("Error: 404 object(pwd); not found!", vbExclamation, "Error-266")48 是警告图标,Error-266 是 caption。函数体里再没有别的分支,调用后直接 ret。错误文本就是提示:404 object(pwd); not found,即正常路径没有接到密码对象;密码在另一个 handler 中。

Step 3: mystery event 拼串

0x4049E0(event 0x3B,form 上没有任何控件会触发它)是个很长的函数,核心是一个重复 38 次的模式(同样,; 后的注释为本文所加):

1
2
3
4
5
6
7
8
404b9e: mov  edi,DWORD PTR ds:0x40104c   ; ord 608 = rtcVarBstrFromAnsi
404ba7: push 0x54 ; 'T'
404ba9: push eax ; 目标 VARIANT 槽位
404be6: call edi ; ANSI 字符 -> 单字符 BSTR VARIANT
404be8: lea ecx,[ebp-0x34]
404beb: push 0x68 ; 'h'
404bed: push ecx
404bee: call edi

余下 35 个字符是完全相同的 push <imm8> + 目标槽位 + call edi 套路,最后一个字符紧接在 __vbaVarCat 的 IAT 装载之前:

1
2
404d71: push 0x21                        ; '!'
404d7b: mov edi,DWORD PTR ds:0x401054 ; __vbaVarCat

__vbaVarCat 的参数装配(若干 lea / push 的槽位地址)不再逐条贴出,拼接结果直接交给 rtcMsgBox

1
2
40500d: push eax                         ; 拼接结果
40500e: call DWORD PTR ds:0x401018 ; ord 595 = rtcMsgBox

每个字面字符都是一个 push <imm8> 立即数,紧跟一个栈上 VARIANT 槽位的地址,再 call edirtcVarBstrFromAnsi)把它变成一个单字符 BSTR variant;38 个 variant 最后由 __vbaVarCat 串成一整串,交给 rtcMsgBox 显示。所以拼出来的 38 个字符就散落在 mov edi, [0x40104c]mov edi, [0x401054] 这两条 IAT 装载之间的所有 push <imm8> 里。

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
111
"""Recover the password embedded in HackThisSite App 10 (app10win.exe).

app10win.exe is a VB6 native-code program. Its event dispatch table
(objdump address 0x404904) routes event 0x3B to the handler at 0x4049E0 -- an
event no control on the form ever fires ("mystery event"). That handler
builds a message box the hard way: every literal character is an immediate
`push 0xNN`, immediately converted into a one-character BSTR VARIANT by
rtcVarBstrFromAnsi (MSVBVM60.DLL ordinal 608, loaded into edi from the IAT
slot ds:0x40104c), and the whole batch is finally concatenated by
__vbaVarCat (IAT slot ds:0x401054) and handed to rtcMsgBox (MSVBVM60.DLL
ordinal 595, IAT slot ds:0x401018).

The run of immediates between the two IAT loads is 38 characters long, but it
is NOT a single string. The handler contains no string literal at all -- its
only text immediate is the SEH frame pointer 0x4010C6 -- so both the body and
the caption of the box are built character by character. The reference run of
the patched program shows

MsgBox "The Password Is: HiddenSecrets", vbInformation, "Correct!"

so the first 30 immediates are the box body and the last 8 ("Correct!") are
its caption; only the 13 characters after the "The Password Is: " marker are
the password HTS wants. Joining all 38 and stripping the marker yields the
tempting but wrong "HiddenSecretsCorrect!" (the site answers "invalid
password" for it).

Verified against: sha256 cb649beb0fd43fa83c534f58b7444c06b6fbce0db1e7322f2acb4ca9b039f300
"""

import re
import subprocess

EXE = "app10win.exe"
FUNC_START = 0x4049E0 # entry of the event 0x3B handler
FUNC_END = 0x405441 # its `ret`
RNT_FROM_ANSI = 0x40104C # IAT slot -> MSVBVM60 ordinal 608 rtcVarBstrFromAnsi
VAR_CAT = 0x401054 # IAT slot -> __vbaVarCat
MSG_BOX = 0x401018 # IAT slot -> MSVBVM60 ordinal 595 rtcMsgBox
MARKER = "The Password Is: "
CAPTION = "Correct!"


def disassemble(path):
"""Return the objdump disassembly as a list of (address, mnemonic) pairs."""
out = subprocess.run(
["objdump", "-d", "-M", "intel", path],
capture_output=True,
text=True,
check=True,
).stdout
insns = []
for line in out.splitlines():
m = re.match(r"\s*([0-9a-f]+):\t[0-9a-f ]+\t(\S.*)", line)
if m:
insns.append((int(m.group(1), 16), m.group(2).strip()))
return insns


def find_message(insns):
"""Collect the immediate bytes pushed between the two IAT loads."""
start = end = None
for addr, text in insns:
if not (FUNC_START <= addr <= FUNC_END):
continue
if start is None and re.search(r"mov\s+edi,DWORD PTR ds:0x%x" % RNT_FROM_ANSI, text):
start = addr
elif start is not None and re.search(r"mov\s+edi,DWORD PTR ds:0x%x" % VAR_CAT, text):
end = addr
break

if start is None or end is None:
raise SystemExit("character-building sequence not found")

chars = []
for addr, text in insns:
if not (start < addr < end):
continue
m = re.fullmatch(r"push\s+0x([0-9a-f]+)", text)
if m:
chars.append(int(m.group(1), 16))
return start, end, bytes(chars).decode("latin1")


def split_body_caption(chars):
"""Split the 38 embedded characters into message-box body and caption.

Both halves are built by the same `push imm8` + rtcVarBstrFromAnsi chain;
the boundary is the trailing literal caption ("Correct!"), so the body is
everything before it.
"""
if not chars.endswith(CAPTION):
raise SystemExit("unexpected tail: %r" % chars[-len(CAPTION):])
return chars[: -len(CAPTION)], CAPTION


def main():
insns = disassemble(EXE)
start, end, chars = find_message(insns)
body, caption = split_body_caption(chars)
print("char sequence : 0x%x .. 0x%x" % (start, end))
print("embedded chars : %r" % chars)
print("character count : %d" % len(chars))
print("msgbox caption : %r" % caption)
print("msgbox body : %r" % body)
if not body.startswith(MARKER):
raise SystemExit("body does not start with the marker: %r" % body)
print("password : %s" % body[len(MARKER):])


if __name__ == "__main__":
main()
1
2
3
4
5
6
7
$ cd <hts-workspace>/challenges/hts-app/app10 && python3 extract_password.py
char sequence : 0x404b9e .. 0x404d7b
embedded chars : 'The Password Is: HiddenSecretsCorrect!'
character count : 38
msgbox caption : 'Correct!'
msgbox body : 'The Password Is: HiddenSecrets'
password : HiddenSecrets

Step 4: 38 个立即数 = 正文 30 + 标题 8

把 38 个字符直接连起来是一句完整的英文 The Password Is: HiddenSecretsCorrect!,容易把 The Password Is: 之后的整截(HiddenSecretsCorrect!)当作密码。正确的切法来自消息框的两个字段:

  • 0x4049E0 这个 handler 里一个字符串字面量都没有。全函数唯一的 4 字节立即数地址是 SEH 帧指针 push 0x4010c60x4049e6)和 push 0x405443(异常恢复块),rtcMsgBox 的其余参数槽是 VT_ERROR / 0x80020004 的缺省参数变体。也就是说正文和标题都是逐字符拼出来的,38 个立即数必须被切成两段。
  • 正文(前 30 个立即数):The Password Is: HiddenSecrets
  • 标题(后 8 个立即数):Correct!
  • 分界在第一处 __vbaVarCat0x404dc1):它的参数是 'C'[ebp-0x3d4])和 'o'[ebp-0x3e4]),即标题的前两个字符;消息框的按钮位是 push 0x400x404e2d,64 = vbInformation)。

所以消息框等价于 rtcMsgBox("The Password Is: HiddenSecrets", vbInformation, "Correct!"),密码就是正文里 The Password Is: 之后的 13 个字符(见文末 spoiler)。

Step 5: 打补丁动态复现

这关的常规玩法是:把 0x404926 那条 jmp 的目标从 0x405500 改成 0x4049E0,运行后点 Proceed,mystery event 就被接上了,消息框会把正文和标题打出来。补丁就是一个 5 字节的 rel32 改动(目标地址 0x4049E0 相对下一条指令 0x40492B 的偏移是 0xB5;文件偏移 = VA − 0x400000):

1
2
file offset 0x4926:  e9 d5 0b 00 00   ->   e9 b5 00 00 00
jmp 0x405500 jmp 0x4049e0

两个状态下消息框的每个字段都由反汇编给出(没有运行打补丁后的程序):

1
2
未打补丁(0x405500):标题栏 Error-266 / 正文 Error: 404 object(pwd); not found! / 图标 vbExclamation
打过补丁(0x4049E0):标题栏 Correct! / 正文 The Password Is: HiddenSecrets / 图标 vbInformation

前者与 Step 2 里 0x405500 的静态分析完全一致;后者既印证了 Step 3 脚本抽出的字符,也把正文 / 标题两个字段切得清清楚楚,这是本题确定答案的依据。

Step 6: 站点校验端点

把候选提交到站点校验端点(applevelup.php,字段 level + password):

1
2
3
4
$ curl -s -b "HackThisSite=<mission-cookie>" \
-e https://www.hackthissite.org/missions/application/ \
-X POST -d "level=10" --data-urlencode "password=HiddenSecrets" \
https://www.hackthissite.org/missions/application/applevelup.php

对照记录:HiddenSecretsCorrect!(正文+标题连写)回 invalid passwordHiddenSecrets(正文冒号后内容)被接受。答案以站点实际接受值为准。

Vulnerabilities

密码作为一个编译期字面量的拼接结果被留在客户端可执行文件里,恢复成本只是把分派表读出来、把立即数字节连起来。客户端程序无法保存秘密:只要校验或展示发生在客户端,逆向者就能拿到。密码应该放在服务端校验,客户端只做不可信输入;如果必须本地比对,也要把密码哈希化(不可逆),而不是让字符串的每个字节都能在代码里被逐个读出来。这里的另一个反面教材是把真正的处理逻辑挂在了一个永远不会被触发的事件上,却把提示信息(404 object(pwd); not found)留在了错误分支里,等于给逆向者指路。

HiddenSecrets