Challenge
Application Challenge 12 (Windows) — Find the Password. (hard) 目标:从这个 Windows 程序里找出 password。
包内只有一个 app12win.exe。文件头显示这是 VB6(Visual Basic 6)编译的原生程序,导入表全部指向 MSVBVM60.DLL,工程名 PwdCheckProject1。
Solution
file app12win.exe→PE32 executable for MS Windows 4.00 (GUI), Intel i386, 3 sections;导入的全是 VB6 运行时rtc*/__vba*。strings -el app12win.exe(VB6 字符串是 UTF-16LE)能命中界面文字和一张字符表:
$ 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。
Step 1: VB6 事件表定位校验点
VB6 的入口是一个事件分发表:每条记录把消息号减掉一个常量后 jmp 到对应处理函数。用 objdump 看 0x4062D8 处的表:
$ 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),最后才由事件0x47的0x407410做真正的比对。
所以真正的校验逻辑在 FUN_00407410(事件 0x47),Ghidra 无头反编译它即可。
Step 2: 反编译校验逻辑
Ghidra 无头反编译(analyzeHeadless + 一个遍历所有函数的脚本)里,FUN_00407410 的核心路径(只保留校验相关语句):
__vbaStrCopy(); // 复制字符表字面量: "abcdefghijklmnopqrstuvwxyz.!:-"uVar3 = (**(code **)(*piVar6 + 0x308))(piVar6);piVar4 = (int *)__vbaObjSet(&local_34,uVar3);iVar5 = (**(code **)(*piVar4 + 0xa0))(piVar4,&local_28); // 读用户输入 PuVar3 = (**(code **)(*piVar6 + 0x308))(piVar6);piVar4 = (int *)__vbaObjSet(&local_38,uVar3);iVar5 = (**(code **)(*piVar4 + 0xa0))(piVar4,&local_2c); // 再读一份 POrdinal_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:
4077a3: ba 20 67 40 00 mov edx,0x406720 ; -> "abcdefghijklmnopqrstuvwxyz.!:-"__vbaStrCopy 拿的正是它。Ordinal_632(VB6 运行时的 Mid 类索引函数)按 1-based 下标取字符,Ordinal_712 是 Replace,Ordinal_528 是 UpperCase。把下标代入这张 30 字符表:
Mid(Upper(charset), 3, 1)→Upper(charset)[2]=CMid(charset, 18, 1)→charset[17]=rMid(charset, 16, 1)→charset[15]=p- 目标串:
"Cr p r"(7 个字符)
也就是说校验分两步:先把用户密码里第 3 个字符的所有出现替换成空格,再拿结果去跟 "Cr p r" 比较。
Step 3: 空格从哪来
需要注意的问题在编辑框的按键事件 FUN_00406D60(事件 0x4B):用户每输入一次它就执行
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 会把这些钉死位也改成空格),于是合法密码的形状是:
C r X X p X rX 取遍这张字符表(去掉 c/r/p)共有 28 个合法串,程序全部接受。而 HTS 站点只认其中那个真正的单词;把 28 个候选输出即可辨识:Creeper。
Step 4: 逆推脚本与输出
脚本直接从 exe 里读字符表(不写死答案),再复现第 2、3 步的算法暴力枚举所有候选:
"""Recover the HackThisSite App 12 (app12win.exe) password from its own check logic.
Everything below is reconstructed statically from the VB6 native binary; nodynamic run is involved. The relevant routine is the handler registered forevent 0x47 in the VB6 event table at VA 0x4062D8, decompiled by Ghidra asFUN_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:97 collapsed lines
"abcdefghijklmnopqrstuvwxyz.!:-"and the "replacement" character is the single space at VA 0x40667c.
Because the edit-field key handler (FUN_00406d60) removes every space the usertypes (Replace(text, " ", "") whenever the text matches the Like pattern "* *"),the three spaces of the target "Cr p r" must come from step 3: positions3, 4 and 6 of the entered password must all hold the same character C. C mustnot appear anywhere else in the password, and cannot be C/r/p (which are pinnedto positions 1/2/5/7), so the accepted passwords are CrXXpXr for X in thecharset. 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 literalTEXT_BASE_VA = 0x401000 # .text virtual addressTEXT_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()$ cd <hts-workspace> && uv run python challenges/hts-app/app12/recover_password.pycharset : '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 Crllplr12 collapsed lines
Crmmpmr Crnnpnr Croopor Crqqpqr Crsspsr Crttptr Cruupur Crvvpvr Crwwpwr Crxxpxr Cryypyr Crzzpzr Cr..p.r Cr!!p!r Cr::p:r Cr--p-rcheck('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 个候选里唯一的英文单词。
Creeper