HackThisSite - Application Mission 12

Challenge

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

包内只有一个 app12win.exe。文件头显示这是 VB6(Visual Basic 6)编译的原生程序,导入表全部指向 MSVBVM60.DLL,工程名 PwdCheckProject1。密码不在明文里,必须从字符串比较 / 字符变换逻辑里推出来。

Solution

  • file app12win.exePE32 executable for MS Windows 4.00 (GUI), Intel i386, 3 sections;导入的全是 VB6 运行时 rtc* / __vba*
  • strings -el app12win.exe(VB6 字符串是 UTF-16LE)能命中界面文字和一张字符表:
1
2
3
4
5
6
7
$ strings -el -t x app12/win/app12win.exe | grep -iE 'password|abcde'
65ac Enter Password Here
65d8 Please Enter a Password
6634 Verifying Password
6684 .Verifying Password.
66b4 ..Verifying Password..
6720 abcdefghijklmnopqrstuvwxyz.!:-

abcdefghijklmnopqrstuvwxyz.!:- 有 30 个字符,可视为一张字符索引表:所有该从程序里拼出来的字符都用 Mid() 按 1-based 下标从这张表里取,而下标本身不写死成 ASCII。这就是这题标 hard 的原因:没有可读的明文比较,只有一个下标表加若干次字符串拼接。

Step 1: VB6 事件表定位校验点

VB6 的入口是一个事件分发表:每条记录把消息号减掉一个常量后 jmp 到对应处理函数。用 objdump 看 0x4062D8 处的表:

1
2
3
4
5
6
7
$ objdump -d -M intel --start-address=0x4062d8 --stop-address=0x40632a app12/win/app12win.exe
4062d8: sub DWORD PTR [esp+0x4],0x3b ; 0x3B -> 主窗口创建 -> 0x406950
4062e5: sub DWORD PTR [esp+0x4],0x33 ; 0x33 -> Check Password 按钮 -> 0x406A70
4062f2: sub DWORD PTR [esp+0x4],0x4b ; 0x4B -> 编辑框变动事件 -> 0x406D60
4062ff: sub DWORD PTR [esp+0x4],0x4b ; 0x4B -> 另一个编辑框事件 -> 0x406FC0(无人引用)
40630c: sub DWORD PTR [esp+0x4],0x3f ; 0x3F -> "Verifying Password" 动画 -> 0x407100
406319: sub DWORD PTR [esp+0x4],0x47 ; 0x47 -> 最终阶段(显示结果) -> 0x407410

两个反直觉的点:

  • Check Password 按钮(0x406A70根本不比较密码。它只做两件事:把输入框内容与占位串 "Enter Password Here" 比较一次,相等就弹 "Please Enter a Password";否则清屏并把状态栏文字设成 "Verifying Password"
  • 紧接着状态栏会循环 ".Verifying Password.""..Verifying Password.." 以及首尾各三个点的更长变体(事件 0x3F,函数 0x407100),最后才由事件 0x470x407410 做真正的比对。

所以真正的校验逻辑在 FUN_00407410(事件 0x47),Ghidra 无头反编译它即可。

Step 2: 反编译校验逻辑

Ghidra 无头反编译(analyzeHeadless + 一个遍历所有函数的脚本)里,FUN_00407410 的核心路径(精简,只保留校验相关语句):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
__vbaStrCopy();                                   // 复制字符表字面量: "abcdefghijklmnopqrstuvwxyz.!:-"
uVar3 = (**(code **)(*piVar6 + 0x308))(piVar6);
piVar4 = (int *)__vbaObjSet(&local_34,uVar3);
iVar5 = (**(code **)(*piVar4 + 0xa0))(piVar4,&local_28); // 读用户输入 P
uVar3 = (**(code **)(*piVar6 + 0x308))(piVar6);
piVar4 = (int *)__vbaObjSet(&local_38,uVar3);
iVar5 = (**(code **)(*piVar4 + 0xa0))(piVar4,&local_2c); // 再读一份 P
Ordinal_632(local_68,local_48,3,local_58); // Mid(P, 3, 1) -> P 的第 3 个字符
uVar3 = __vbaStrVarVal(&local_30,local_68,&DAT_0040667c,1,0xffffffff,0);
Ordinal_712(local_28,uVar3); // Replace(P, 第3个字符, ' ') 全部替换成空格
Ordinal_528(local_48,local_638); // Upper(charset) -> 大写字符表
Ordinal_632(local_68, local_48, 3, local_58); // Mid(Upper, 3, 1) = 'C'
Ordinal_632(local_88, local_658,0x12, local_78); // Mid(lower, 18, 1) = 'r'
Ordinal_632(local_d8, local_698,0x10, local_c8); // Mid(lower, 16, 1) = 'p'
Ordinal_632(local_118,local_6c8,0x12, local_108); // Mid(lower, 18, 1) = 'r'
uVar3 = __vbaVarCat(local_98, local_88, local_68); // 拼 "Cr"
uVar3 = __vbaVarCat(local_a8, local_678,uVar3); // 追加 ' '
uVar3 = __vbaVarCat(local_b8, local_688,uVar3); // 追加 ' '
uVar3 = __vbaVarCat(local_e8, local_d8, uVar3); // 追加 'p'
uVar3 = __vbaVarCat(local_f8, local_6b8, uVar3); // 追加 ' '
uVar3 = __vbaVarCat(local_128,local_118,uVar3); // 追加 'r'
sVar2 = __vbaVarTstEq(uVar3); // 比较!目标是 "Cr p r"

用到的那张字符表就是 VA 0x406720 的宽字符串,代码里唯一一处引用在 0x4077A3

1
4077a3: ba 20 67 40 00   mov  edx,0x406720      ; -> "abcdefghijklmnopqrstuvwxyz.!:-"

__vbaStrCopy 拿的正是它。Ordinal_632(VB6 运行时的 Mid 类索引函数)按 1-based 下标取字符,Ordinal_712ReplaceOrdinal_528UpperCase。把下标代入这张 30 字符表:

  • Mid(Upper(charset), 3, 1)Upper(charset)[2] = C
  • Mid(charset, 18, 1)charset[17] = r
  • Mid(charset, 16, 1)charset[15] = p
  • 目标串:"Cr p r"(7 个字符)

也就是说校验分两步:先把用户密码里第 3 个字符的所有出现替换成空格,再拿结果去跟 "Cr p r" 比较。

Step 3: 空格从哪来

需要注意的问题在编辑框的按键事件 FUN_00406D60(事件 0x4B):用户每输入一次它就执行

1
2
3
sVar1 = __vbaStrLike(&DAT_00406670,local_1c);   // DAT_00406670 = "* *"
// 命中 "* *" 才继续往下走
Ordinal_712(local_1c,&DAT_0040667c,&DAT_0040661c,1,0xffffffff,0); // Replace(text, " ", "")

也就是输入框里一出现空格(匹配 Like "* *"),所有空格就被删掉。所以用户无法输入空格,目标串 "Cr p r" 里的三个空格只能由第 2 步的替换产生,即密码的第 3、4、6 位必须都是同一个字符 X。再看第 1、2、5、7 位被钉死为 C r p r,且 X 不能是 c/r/p(否则 Replace 会把这些钉死位也改成空格),于是合法密码的形状是:

1
C r X X p X r

X 取遍这张字符表(去掉 c/r/p)共有 28 个合法串,程序全部接受。而 HTS 站点只认其中那个真正的单词;把 28 个候选输出即可辨识:Creeper

Step 4: 逆推脚本与输出

脚本直接从 exe 里读字符表(不写死答案),再复现第 2、3 步的算法暴力枚举所有候选:

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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
"""Recover the HackThisSite App 12 (app12win.exe) password from its own check logic.

Everything below is reconstructed statically from the VB6 native binary; no
dynamic run is involved. The relevant routine is the handler registered for
event 0x47 in the VB6 event table at VA 0x4062D8, decompiled by Ghidra as
FUN_00407410 ("final stage"):

__vbaStrCopy(dst, &charset_literal) ; copy the 30-char charset literal
Ordinal_632(local_68, local_48, 3) ; Mid(userpwd, 3, 1)
uVar3 = __vbaStrVarVal(dst) ; -> that 3rd character
Ordinal_712(local_28, uVar3) ; Replace(userpwd, that_char, ' ')
Ordinal_528(local_48, local_638) ; Upper(charset)
Ordinal_632(local_68, local_48, 3) ; Mid(Upper(charset), 3, 1) = 'C'
Ordinal_632(local_88, local_658, 0x12) ; Mid(charset, 18, 1) = 'r'
Ordinal_632(local_d8, local_698, 0x10) ; Mid(charset, 16, 1) = 'p'
Ordinal_632(local_118,local_6c8, 0x12) ; Mid(charset, 18, 1) = 'r'
__vbaVarCat x6 with three embedded ' ' literals ; -> "Cr p r"
__vbaVarTstEq(target) ; compare built target vs user

The string literal used by __vbaStrCopy is the wide literal at VA 0x406720:
"abcdefghijklmnopqrstuvwxyz.!:-"
and the "replacement" character is the single space at VA 0x40667c.

Because the edit-field key handler (FUN_00406d60) removes every space the user
types (Replace(text, " ", "") whenever the text matches the Like pattern "* *"),
the three spaces of the target "Cr p r" must come from step 3: positions
3, 4 and 6 of the entered password must all hold the same character C. C must
not appear anywhere else in the password, and cannot be C/r/p (which are pinned
to positions 1/2/5/7), so the accepted passwords are CrXXpXr for X in the
charset. The natural-language one is Creeper.
"""

import re

EXE = "app12/app12win.exe"
CHARSET_VA = 0x406720 # VA of the charset literal referenced by `mov edx,0x406720`
SPACE_VA = 0x40667C # VA of the single-space literal
TEXT_BASE_VA = 0x401000 # .text virtual address
TEXT_BASE_OFF = 0x1000 # .text raw file offset


def va_to_offset(va):
"""Map a virtual address inside .text to a raw file offset."""
return TEXT_BASE_OFF + (va - TEXT_BASE_VA)


def read_wide_string(path, va):
"""Read a NUL-terminated UTF-16LE string at a virtual address."""
with open(path, "rb") as fh:
fh.seek(va_to_offset(va))
out = []
while True:
pair = fh.read(2)
if len(pair) < 2 or pair == b"\x00\x00":
break
out.append(pair.decode("utf-16le"))
return "".join(out)


def extract_charset(path):
"""Pull the charset and replacement char straight out of the binary."""
charset = read_wide_string(path, CHARSET_VA)
space = read_wide_string(path, SPACE_VA)
return charset, space


def mid(charset, index):
"""VB6 Mid(s, n, 1) is 1-based."""
return charset[index - 1]


def build_target(charset):
"""Reproduce the six VarCat calls that assemble the expected value."""
upper = charset.upper()
parts = [
mid(upper, 0x3), # 'C'
mid(charset, 0x12), # 'r'
" ",
" ",
mid(charset, 0x10), # 'p'
" ",
mid(charset, 0x12), # 'r'
]
return "".join(parts)


def check(user_password, charset):
"""Emulate FUN_00407410 up to __vbaVarTstEq; returns (matched, modified)."""
if len(user_password) < 3:
return False, user_password
subst = user_password[2] # Mid(userpwd, 3, 1)
modified = user_password.replace(subst, " ") # Replace(user_password, subst, " ")
return modified == build_target(charset), modified


def main():
charset, space = extract_charset(EXE)
target = build_target(charset)
print("charset :", repr(charset), "(%d chars)" % len(charset))
print("replacement ch :", repr(space))
print("Mid(UPPER,3) :", repr(mid(charset.upper(), 0x3)))
print("Mid(lower,0x12):", repr(mid(charset, 0x12)))
print("Mid(lower,0x10):", repr(mid(charset, 0x10)))
print("target string :", repr(target))

# Brute force every candidate of the accepted shape CrXXpXr.
candidates = []
for x in charset:
cand = "Cr%s%sp%sr" % (x, x, x)
if check(cand, charset)[0] and cand not in candidates:
candidates.append(cand)

print("accepted count :", len(candidates))
for cand in candidates:
mark = " <-- real word" if cand == "Creeper" else ""
print(" ", cand, mark)

# show that a plain wrong attempt is rejected
for probe in ("Creeper", "CreeperX", "Cr11p1r", "password"):
ok, modified = check(probe, charset)
print("check(%r) -> %s (modified=%r)" % (probe, ok, modified))


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
35
36
37
38
39
40
$ cd <hts-workspace> && uv run python challenges/hts-app/app12/recover_password.py
charset : 'abcdefghijklmnopqrstuvwxyz.!:-' (30 chars)
replacement ch : ' '
Mid(UPPER,3) : 'C'
Mid(lower,0x12): 'r'
Mid(lower,0x10): 'p'
target string : 'Cr p r'
accepted count : 28
Craapar
Crbbpbr
Crccpcr
Crddpdr
Creeper <-- real word
Crffpfr
Crggpgr
Crhhphr
Criipir
Crjjpjr
Crkkpkr
Crllplr
Crmmpmr
Crnnpnr
Croopor
Crqqpqr
Crsspsr
Crttptr
Cruupur
Crvvpvr
Crwwpwr
Crxxpxr
Cryypyr
Crzzpzr
Cr..p.r
Cr!!p!r
Cr::p:r
Cr--p-r
check('Creeper') -> True (modified='Cr p r')
check('CreeperX') -> False (modified='Cr p rX')
check('Cr11p1r') -> True (modified='Cr p r')
check('password') -> False (modified='pa word')

脚本从二进制里读出的字符表、下标对应的字符、拼出来的目标串 "Cr p r" 全部与反编译结果一致;Creeper 经同一段 check() 判定为通过,且是 28 个候选里唯一的英文单词。

Evidence:

  • 全程 static:未运行程序并点击按钮验证;结论来自 Ghidra/objdump 的反汇编与反编译,加上一个独立复现该算法的脚本。
  • partially verified:程序层面这 28 个串它都接受(脚本按逆向出的逻辑复现,自洽);真正密码靠站点只认真实单词推得,无法在本地对 HTS 验证(未提交答案)。
  • Ordinal_632 / 712 / 528 是按 MSVBVM60 的序号导入,本地没有该 DLL 去核对符号名;名称(Mid / Replace / UpperCase)是依据调用形态与行为判定的,行为本身由反编译输出直接证实。

Vulnerabilities

密码校验完全在客户端完成,校验逻辑只是取第 3 个字符替换后与常量串比较,而该常量串由客户端自带的字符表按硬编码下标拼出。本地校验不构成访问控制:读出这段逻辑即可枚举所有被接受的输入。修复方向:校验放在服务端,客户端只提交凭据;确需本地校验时应比对不可逆的哈希值,不在客户端现场拼出明文目标串。

Creeper