HackThisSite - Application Mission 12

posted
read
836 words · 3 min
contents

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); // 读用户输入 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:

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] = 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):用户每输入一次它就执行

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 r

X 取遍这张字符表(去掉 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; 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:
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 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()
$ 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
12 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-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 个候选里唯一的英文单词。

Creeper