Hello Navi

Tech, Security & Personal Notes

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

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.exePE32 executable for MS Windows (GUI), Intel i386,导入表只有 MSVBVM60.DLL,且 strings 里能看到 C:\Program Files\Microsoft Visual Studio\VB98\Projects\Challenge\SoundProject1.vbpDllFunctionCall__vbaStrCopy 一类符号 → VB6 原生编译(native code,非 p-code),所以 objdump -d 的线性反汇编是可信的。
  • 字符串池全部是 UTF-16LE,用 strings -el 可直接列出常量(注意 -n 3,否则 3 字符的常量会被默认长度过滤掉):
1
2
3
4
5
6
7
8
9
10
$ strings -el -n 3 app9/app9win.exe | sort -u
100
1000
1100
200
500
600
abcdefghijklmnopqrstuvwxyz
CDEFGHIJKLMN
HTS Application Challenge Programmed by Magic.
  • 三个数字常量成对出现:200 / 600 / 1100100 / 500 / 1000 同时在文件里。后面会看到,前者是程序实际使用的频率,后者是判定通过时要求的频率,两者对不上。这是本题的关键。
  • 事件处理函数的入口地址:主窗口创建 0x4054C0Play 按钮 0x405570Match1/2/3 分别是 0x405610 / 0x405690 / 0x405710、一个常驻循环动作 0x405790(即 VB6 的 Timer 事件)。以下结论全部来自本地 out/app9.asmobjdump -d app9win.exe 的 477 KB 输出)。

Step 1: Play 与 Match 按钮

Play 按钮在 0x405570,直接调用 kernel32.Beep(经 DllFunctionCall 包装在 sub_40519C):

1
2
3
4
5
6
7
8
9
4055af:	push   0x12c          ; dwDuration = 300
4055b4: push 0x64 ; dwFreq = 100
4055b6: call 0x40519c ; Beep
4055c3: push 0x12c
4055c8: push 0x1f4 ; 500
4055cd: call 0x40519c
4055d4: push 0x12c
4055d9: push 0x3e8 ; 1000
4055de: call 0x40519c

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
2
3
4
5
6
7
405650:	mov    edx,DWORD PTR [esi+0x34]    ; Match1
405653: push 0x12c
405658: push edx
405659: call 0x40519c ; Beep(freq, 300)

4056d0: mov edx,DWORD PTR [esi+0x38] ; Match2
405753: mov edx,DWORD PTR [esi+0x3c] ; Match3

这三个字段在主窗口创建过程 0x4054C0 里被初始化,用 __vbaI4Str(VB6 的字符串转整数)把字符串常量转成数字存进去:

1
2
3
4
5
6
7
8
9
405519:	push   0x40522c           ; "200"
40551e: call edi ; __vbaI4Str
405520: push 0x405238 ; "600"
405525: mov DWORD PTR [esi+0x34],eax ; Match1 频率 = 200
405528: call edi
40552a: push 0x405244 ; "1100"
40552f: mov DWORD PTR [esi+0x38],eax ; Match2 频率 = 600
405532: call edi
405534: mov DWORD PTR [esi+0x3c],eax ; Match3 频率 = 1100

所以三个 Match 按钮实际播的是 200 / 600 / 1100 Hz,而 Play 播的是 100 / 500 / 1000 Hz

Step 2: Timer 匹配判定

那个常驻循环动作 0x405790__vbaStrCopyabcdefghijklmnopqrstuvwxyz0x405254)拷进局部变量,然后逐个把按钮频率和字符串常量做浮点比较,相等就把按钮背景刷成浅绿 0x80FF80

1
2
3
4
5
6
7
405915:	fild   DWORD PTR [esi+0x34]        ; Match1 频率
405918: push 0x405290 ; "100"
405923: call DWORD PTR ds:0x401070 ; 字符串 -> 双精度
405929: fcomp QWORD PTR [ebp-0x3c0]
405931: test ah,0x40 ; C3 = 相等?
405934: je 0x405983 ; 不等就跳过
40594e: push 0x80ff80 ; 相等 -> BackColor = 0x80FF80

三个按钮用的是同样三段模板,比较对象分别换成了 "100"0x405290)、"500"0x4052ac)、"1000"0x4052b8):

1
2
40598c:	push   0x4052ac       ; Match2 对 "500"
4059f2: push 0x4052b8 ; Match3 对 "1000"

也就是说:判定要求 [esi+0x34]==100 && [esi+0x38]==500 && [esi+0x3c]==10000x405B0F0x405B65 把三个比较结果 AND 起来,全绿才继续)。而程序写入的是 200/600/1100,该条件不成立。按题目设计先听音、再对齐频率无法通过,必须修改二进制。

Step 3: Patch

把窗口初始化里那三个 UTF-16 字符串常量改成判定要求的频率即可(三个改动长度相同,不会破坏文件布局):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import shutil

SRC = "app9/app9win.exe"
DST = "app9/app9win_patched.exe"

d = bytearray(open(SRC, "rb").read())


def patch(off, old, new):
assert d[off:off + len(old)] == old, (hex(off), bytes(d[off:off + len(old)]))
d[off:off + len(new)] = new
print("patched file+%#06x: %r -> %r" % (off, old.decode("utf-16-le"), new.decode("utf-16-le")))


# 0x40522c/0x405238/0x405244 是 .text 里的字符串字面量数据,
# 文件偏移 = VA - 0x400000(.text RAW/VMA 都是 0x1000,所以两者相等)
patch(0x522C, "200".encode("utf-16-le"), "100".encode("utf-16-le"))
patch(0x5238, "600".encode("utf-16-le"), "500".encode("utf-16-le"))
patch(0x5244, "1100".encode("utf-16-le"), "1000".encode("utf-16-le"))

open(DST, "wb").write(bytes(d))
print("wrote", DST, len(d), "bytes")
1
2
3
4
5
$ cd <hts-workspace> && uv run python patch_app9.py
patched file+0x522c: '200' -> '100'
patched file+0x5238: '600' -> '500'
patched file+0x5244: '1100' -> '1000'
wrote app9/app9win_patched.exe 36864 bytes

补丁后三个 Match 按钮的频率就等于 Play 的三声,Timer 的三次比较全部成立,程序进入密码构造分支。

Step 4: 密码拼接

密码是判定通过后由 0x405B6B 起的一大段代码拼出来的(strings 搜不到明文)。拼装用两个 VB6 运行时函数交替进行:

  • rtcMidCharVar(IAT 0x401034):从字符集字符串里按索引取一个字符,等价于 Mid$(charset, i, 1)
  • __vbaVarCat(IAT 0x401068):变体字符串连接。

字符集就是 0x405254abcdefghijklmnopqrstuvwxyz,索引是按 ASCII 字母表 1-based 的位置。其余补位字符是单字符常量 'C''!''T'' ''A'':''S''K'(字符串池里一对一对的 02 00 00 00 xx 00 00 00)。字符串池里并列存在的 CDEFGHIJKLMN0x405018)在整个 .text 里没有任何指令引用,是编译遗留的未使用常量,把它当成索引字符集会得到错误结果。

先从反汇编里把每次取字符的索引和语句串起来(脚本读 out/app9.asm,按 push <小立即数> 与紧随其后的 call edi 配对):

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
"""Extract the rtcMidCharVar (Mid) index sequence used by HTS App 9's password builder."""
import re

ASM = "out/app9.asm"
LINE = re.compile(r"^\s*([0-9a-f]{6}):\t([0-9a-f ]+)\t(.*)$")

insns = []
for line in open(ASM, encoding="latin1"):
m = LINE.match(line)
if m:
insns.append((int(m.group(1), 16), m.group(3).strip()))


def mid_indices(lo, hi):
"""For every 'call edi' (rtcMidCharVar) in [lo, hi], walk back to the nearest
small 'push 0xNN' (the charset index) before hitting the previous call."""
out = []
for i, (addr, text) in enumerate(insns):
if not (lo <= addr <= hi):
continue
if text.startswith("call") and text.endswith("edi"):
for j in range(i - 1, -1, -1):
_, t = insns[j]
if t.startswith("call"):
break
if t.startswith("push 0x") and "0x405" not in t:
v = int(t.split("0x")[1], 16)
if v < 0x100:
out.append(v)
break
return out


def decode(idx):
"""1-based index into 'abcdefghijklmnopqrstuvwxyz'."""
return "abcdefghijklmnopqrstuvwxyz"[idx - 1]


for lo, hi, name in ((0x405B9A, 0x405CE0, "label1"),
(0x405EAC, 0x4060ED, "label2"),
(0x40634D, 0x4064ED, "password")):
idx = mid_indices(lo, hi)
print(name, "indices:", " ".join(hex(v) for v in idx))
print(" letters:", "".join(decode(v) for v in idx))

三段构造(1-based 索引 a=1 … z=26)的实际输出:

1
2
3
4
5
6
7
$ cd <hts-workspace>/challenges/hts-app && uv run python app9_extract_mid.py
label1 indices: 0xf 0x12 0x12 0x5 0x3 0x14
letters: orrect
label2 indices: 0x8 0x5 0x17 0xe 0x13 0x5 0x12 0x9 0x13
letters: hewnseris
password indices: 0xf 0x15 0xe 0x4 0x9 0xe 0x7
letters: ounding

Vulnerabilities

答案的保护强度取决于逆向者读代码的成本,与界面上的可操作性无关。密码在校验路径上从未以明文出现,但生成算法(字符集、索引、连接顺序)与判定阈值都在客户端二进制里,静态重建即可还原;阈值还与程序实际写入的频率矛盾,正常操作路径无法通过判定,必须修改二进制。要让答案不可恢复,校验应放在服务端、客户端只提交凭据,并让每次校验使用服务端下发的一次性随机挑战;客户端代码里不应同时存在阈值与答案的生成规则。

SoundKing

HackThisSite 挑战状态总表

位置: /home/kita/code/blog/source/_drafts/HackThisSite/

当前文件状态(以工作树为准,2026-09-13 核对):

  • Writeup 文件:99 个(不含本文件)
  • _drafts/HackThisSite/:67 个
  • _posts/wp/HackThisSite/:32 个
  • 已发布:Basic 01–11、Realistic 01–16、Application 01–05
  • 草稿:Javascript 01–07、ExtBasic 01–14、Forensic 01–03、Steganography 01–17 + overview、Application 06–18、Programming 01–12

说明:当前仓库有其他未提交的移动和文章修改(Realistic 12–16、Application 01–05 已从 _drafts/ 移到 _posts/)。Blog 仓库的 commit/push 由用户处理,本文只记录挑战与 writeup 状态。


挑战解决状态

类别 已解决 未解决 备注
Basic 1–11 11 篇 writeup 已发布
Realistic 1–16 R12–R16 已补完并 live 验证
Extended Basic 1–14 全部 live 通关(须带 Referer)
Forensic 1–2 3 3 卡在 header 加密的 RAR
Application 1–17 18 18 站点端校验损坏
Javascript 1–7 全部 live(客户端 JS 校验)
Steganography 1–10,12–14 11,15,16,17 10=Bacon;16/17 加密 RAR;11/15 待查
Programming 1–3,5–7,9–12 4,8 4 字形切分中;8 站方 bot 阻塞
  • 表内省略的哈希、试过的字典、答案变体都在对应 writeup: hackthissite-forensic-03.md-steg-10.md-steg-16.md-steg-17.md

Writeup 文件清单

类别 文件范围 数量 当前状态
Basic hackthissite-basic-01~11.md 11 已发布,均有 flag
Realistic hackthissite-realistic-01~16.md 16 已发布
Javascript hackthissite-javascript-01~07.md 7 已 live,QA 通过
Extended Basic hackthissite-extbasic-01~14.md 14 已 live;01/02 待重写
Forensic hackthissite-forensic-01~03.md 3 1–2 有 flag;3 未通关
Steganography hackthissite-steg-01~17.md、overview 17 1–10、12–14 有 flag
Application hackthissite-app-01~18.md 18 01–05 已发布;06–18 草稿
Programming hackthissite-prog-01~12.md 12 已 live 10 关;4/8 未通

Realistic 已验证解法

关卡 挑战 当前解法 状态
2 Chicago American Nazi Party 隐藏 update.php + SQLi bypass 已解决
3 FBI Interrogation 路径遍历覆盖 index.html 已解决
4 Fischer's Animal Products UNION SQLi 取 9 邮箱 → SaveTheWhales 已解决
5 Damn Telemarketers robots.txt 备份 hash → MD4 437a1 已解决
6 ToxiCo Industrial Chemicals XECryption 频率分析 → key 762 已解决
7 Guru LFI 读 .htpasswdshadow → admin 已解决
8 United Banks Of America SQLi 找 Gary → 改 cookie 转账 → 清日志 已解决
9 CrappySoft Software simulated XSS 改 cookie → 付薪 → 清日志 已解决
10 smiller smiller:smiller + UA holy_teacher + admin=1 已解决
11 BudgetServ Web Hosting &#124;ls&#124; 注入 → id=0 改密 → src.tar.gz 已解决
12 Heartland School District file:///C:/ 列目录 + 读源码 → clearlist() 已解决
13 Elbonian Republican Party 报错泄露路径 → passwords.fiplogin2.php 已解决
14 Yuppers Internet Solutions moderator.cgi 免密 → account=* 取明文 已解决
15 seculas Ltd. backup.zip bkcrack → shell.php → 溢出 已解决
16 Simple Mail .. 注册 → 改写 config.txtadmin.php 读邮件 已解决

已解备注(2026-09-11 现场验证)

  • R12:入口 /missions/realistic/12/ → meta refresh → cgi-bin/internet.plpage.pl 是服务端 LWP 代理(file:// 可列目录),黑名单只挡 page.pl,直连 HTTP 与 guest.pl?action=read 均绕过。完成页 mission-accomplished.php。旧「页面为空、无法复现」判断作废。
  • R13:哈希对应关系已实测校正:7e40c181… = md5('Speeches') 是受保护目录名,7bc35830… = md5('moni1')passwords.fip 里的用户名哈希(该 URL 404),21232f29… = md5('admin') 是真实登录目录(200,login2.php 字段 user/pass)。旧稿把这些混为一谈。
  • R14news.cgi?story=moderator.cgi%00 的 null-byte 截断在当前节点已失效(服务端把 NUL 原样拼进文件名,回显 moderator.cgi^@.news);但 moderator id isadmin 是硬编码常量,链路照常走通。administrator.cgi 未授权时返回伪 404,是旧稿误判「组件失效」的根因。
  • R15:bkcrack key f23a33d0 106331c0 6fd03c13;校验器源码 admin_area/test/chkuserpass.c.zip 可直接下载;shell 是被裁剪过的 ls-only MyShell(cat/more/pwd 全被拒),最终溢出从 shell 内改成直接 POST viewpatents2.php(username=228×Y)。
  • R16check_email.phpUnauthorized Entry 是顺序问题:必须先把 config.txt 覆盖成 auth_page=config.txt&authed=true& 让服务端 auth 与 Flash 参数一致,之后端点直接放行;完成页 Mission 16 Accomplished!

未完成类别与阻塞点

Application(1–18):本地取证完成,未提交 HTS

18 关全部按「下载官方附件 → 本地取证」重写 writeup。答案与证据等级如下(动态 = 程序自身或真实二进制接受;静态 = GUI 无显示环境下的反汇编/字节级重建):

关卡 答案 / 形态 证据等级 关键机制
1 smashthestate 静态 REALbasic 逐字符拼串
2 liberation 静态 同上;校验走 socket keys 文件
3 fireyourboss 静态 同上(服务端校验已坏)
4 daytona 静态 VB6 Click handler 的 push imm8
5 powertripping 动态 app5unix:4 dword 常量倒序比较
6 magical 动态 MSVC 自解密壳(XOR 0xbeefcabe)
7 caged 动态 app7unix:和=753 → checksum 0xdca
8 9252482644-164-73427 动态 VB6 Mid$+rtcStrReverse;wine 跑通
9 SoundKing 静态 阈值矛盾需 patch;索引链拼密码
10 HiddenSecrets 动态 未触发 event 0x3B;取正文冒号后
11 Search&Destroy 静态 密码印在内嵌 JPEG 像素上
12 Creeper 静态 目标串 "Cr p r";28 串取英文词
13 537-314-137-616 静态 脱壳后 CRC checkpoint 链 + 侧信道
14 ihatethereg 动态 wine-mono 反射调 ParseandDecrypt
15 platform93/4 静态 DBPro 逐像素画字;331 点逐字形读
16 freedom 动态 QBFC 释放 .bat 后明文比较
17 keygen HTS-142A-2129-251E-2A1F-2629 动态 app17unix pty 驱动 → ACCEPTED
18 license 生成器(LIC/1.8/…,448B) 动态 本地生成器正确;站点端校验损坏
  • 2026-09-11 按用户授权逐关提交到 applevelup.phpApplication 1–17 全部通过(服务端回 Congratulations, you have successfully completed application N!),profile 的 Application 列表已出现 17 个,积分 3386 → 4496 → 4651
  • 8 与 10 是当天第二轮定案的:8 的连字符位置被静态推错(见上表),用 Xvfb+wine 实跑读消息框才对;10 的 38 个立即数是"正文 30 + 标题 8",只取正文冒号后内容。
  • 18 是站点侧问题,不是本地方案问题:本地生成器被真实二进制接受,但站点上传恒回 Sorry, your license file is not valid.;nullsecurity 逆向笔记在 app18 章节末尾明确写了 "there are reports that the validation on the website doesn't work well",与实测一致。结论:这关在站点端修好之前无法通过。
  • writeup 规范核查脚本:~/ctf/workspace/challenges/hts-app/qa_writeups.py(H1/H2、表格、spoiler 位置、... 截断、fence、泄露 cookie/账号名、python 块编译)。

Programming(1–12)

  • 12 关都是“限时提交”:GET 实例页生成随机实例,POST 同一页的 index.php(字段 solution,第 9 关是 password)。成功响应含 Good Job, ***, You have successfully completed this mission / CORRECT!,失败是 Sorry: Your answer is wrong
  • 限时:1=30s、2=15s、3=120s、4=120s、5=600s、6=30s、7=180s、9=—、10=45s、11=3s、12=5s ⇒ 每关都要有“fetch→solve→POST 一次跑完”的脚本(工作区 ~/ctf/workspace/challenges/hts-prog/<N>/,公共库 common.py)。
  • 会话按关卡独立:并发操作不同关卡不会互相顶掉实例(实测:取 level2 → 取 level3 → 提交 level2 仍返回 wrong 而非“实例失效”);同一关卡必须串行。
  • 公共资源(词表 prog/1/wordlist.zipprog/2/PNGprog/4/XMLprog/5/corrupted.png.bz2prog/7/BMPprog/3/serials_example.txt)不需要登录态,可匿名下载;prog/6/imageprog/10/image.php?… 与实例本身绑定。
# 关卡 结果 / 机制 状态
1 Unscramble the words 词表按字符排序串建索引查表 ✅ live
2 Analyze the picture (Morse) chr(pos - prev_white) → Morse ✅ live
3 Reverse Encryption 逆向 PHP encryptString(md5 枚举) ✅ live
4 Parse an XML file 去旋转已通;阻塞=字形切分(见下) 🚧 未通
5 Fix a corrupted file ftp ASCII CRLF 修复 → 读图密码 ✅ live
6 Bypass the image captcha JS drawData → 螺旋序 → Chamfer 匹配 ✅ live
7 Unscramble the image lines 行置乱;按行 B 通道中值排序还原 ✅ live
8 Code an IRC bot 站方 bot moo 不在线 bot fail skip
9 One-Time-Pad Encryption 数独 → SHA1 → Blowfish CBC 解密 ✅ live
10 Automated Steganography x 坐标即字节 → base64 → SHA-256 爆破 ✅ live
11 Reverse Ascii Shift % 分隔码逐位减 shift(限 3s) ✅ live
12 String manipulation 质/合数求和相乘 + ASCII+1 前缀 ✅ live

Programming 08 的 IRC 账号(一次性注册,可随时注销)

  • nickkitabot8网络irc.hackthissite.org:6697(SSL;明文 6667 亦可)
  • 注册邮箱:一次性邮箱(不是用户本人的邮箱);尚未确认的邮箱确认码只在会话内使用过,不入库
  • 已完成:NickServ REGISTER → 邮箱确认 → SET AUTOOP ON → 对服务端 sendpass 机器人发 !link <本账号用户名>!link 是 L8 要求的握手步骤)
  • 依赖服务端:L8 要求把 nick moo(站方 bot)!link 到本账号后才能 !perm8;实测 moo 已不在网络(WHOIS moo401 No such nick/channelISON moo 空,官方 Link Page 无此账号)
  • 注销:在已登录该 nick 的会话里 /msg NickServ DROP kitabot8
  • 脚本:~/ctf/workspace/challenges/hts-prog/8/{register_nick.py,link_nick.py,ircbot.py}(纯 socket/TLS;bot 会对 VERSION 回非 mIRC 串,避免触发风控)

Javascript(1–7):2026-09-12 全部 live 通关

  • 提交机制:7 关都是客户端 JS 校验,密码通过 window.location += "?lvl_password=<pw>" 回跳给服务端。必须带 Referer: https://www.hackthissite.org/missions/javascript/<N>/。实测同一 URL 不带 Referer 时服务端不计完成(profile 不变),带上后立即翻牌。
  • 第 2 关特殊:正确做法是禁用 JS / 打断跳转,页内藏着 Click here to win. 链接 /missions/javascript/2/index.php?challengePass=<串>;该串每次加载页面都重新生成,必须“抓页面 → 立刻用同一串提交”。
  • 答案与依据:
# 名称 答案 依据
1 Idiot Test cookies check(x) 明文比较
2 Disable Javascript per-load 串(形如 EK@1%I win 链接在同一页,每次重载
3 Math time! 任意 14 字符 校验 x.length == moo(=14)
4 Tricky moo RawrRawr="moo";拼接行是干扰
5 Escape ilovemoo unescape('%69%6C%6F%76%65…')
6 External script moo pwns checkpass.jsrawr+" "+moo
7 Obfuscation j00w1n 十六进制混淆生成按钮后比较
  • live 证据:profile 的 Javascript 列表出现 1–7;积分 6501 → 6669

Extended Basic / Steganography(playit 型)与 Forensic

  • ExtBasic 与 Stego 都是 playit:关卡页 https://www.hackthissite.org/missions/playit/<cat>/<N>/,表单 POST /missions/{extbasic,stego}/template.php,字段 formkey每次加载都变)、lvlpass必须带 Referer: <该关卡页>,否则服务端回 Invalid Referer 且不计分。答案大小写敏感
  • 完成判据(两个独立 oracle):关卡页变 You have already done this mission.(注意:extbasic 类不会出现 You have already completed this level!)+ profile 对应类别出现 (N)
  • 公共提交器:~/ctf/workspace/challenges/hts-playit/playit_submit.py--dry 打印题面/formkey,--submit "<答案>" [--also-profile])。
  • Forensic 不是 playit:提交字段名逐关不同(第 2 关是 forensic2),POST 到关卡页自身。
  • 进度(2026-09-12):
    • ExtBasic 3 ✅(自造语言:CREATE/DESTROY/TO 都是标识符 → 输出 2)、4 ✅name type as in 语法 → 67)、5 ✅(sed 少全局标志 g)、10 ✅(batch 认证绕过:%INPUT% 无转义拼进 IF "%INPUT%"=="" → 输入 "=="" set passwordvalue=1065435274 && goto :end abc 闭合引号、填入目标哈希并跳过乘质数循环;wine 本地复现 + live 提交,writeup status: verified)、11 ✅(乘法哈希绕不过 → 32 位 SET /A 回绕:求多重素数乘积 ≡ 1065435274 (mod 2**32),最短解 aghilmort;wine 本地复现 + live 提交,writeup status: verified);
    • Forensic 1 ✅(sleuthkit fls/icat 从镜像还原被删的 logins.txt,口令 qPYgbs0w5&?i{8a)、2 ✅(ELA 检出拼接痕迹;完成回执 Congratulations! You've successfully completed Forensic 2!);
    • ExtBasic 1–14 全部 ✅(12:变量变量/$$ 利用;13:vrfy.php 注入构造;14:Java 并发修正,细节见各自 writeup);
    • Stego 4 ✅(GIF89a 单帧;GCT 64 项 + 一个 GCE + 一个 Image Descriptor + LZW 数据后是 trailer 0x3B,其后 64 字节是 ASCII '0'/'1',每 8 位还原成 p68cq1hb;提交后 profile Stego: (4),关卡页回 You have already done this mission.;solver challenges/hts-stego/4/extract_bits.py);
    • Forensic 3Stego 1–3、5–17 正在逐关推进。
  • Steganography 的数据文件都在 https://www.hackthissite.org/missions/stego/lvl/<文件>(页面上的 <img>/<embed>/<a> 里),带 cookie 直接下:
    • 1 1.bmp(“2 null bytes”)|2 2.wav(“did I hear that correctly?”)|3 3.bmp(“obvious, just not at first sight”)|4 stego4.gif(“I am being hexed!”)|5 stego5.bmp|6 stego6.png|7 stego7.zip(“Download the image Here”)|8 stego8.bmp|9 Stego9.zip(“Download the song Here”/“kiss me alone”)|10 Stego10.jpg|11 11.png|12 12.bmp|13 13.bmp|14 stego14.tar.gz(仅 228 人做过,偏难)|15 Stego15.png|16 Stego16.png|17 stego17.jpg

其它类别

  • Extended Basic 1–14、Forensic 1–2、Javascript 1–7 已于 2026-09-12 live 通关并重写 writeup;剩 Forensic 3 与 Steganography 1–17 在推进中。注意 extbasic-01/02 是早期占位稿(关卡早已通关),待补重写。

  • 不在仓库中保存 HackThisSite Cookie、用户名、密码或其他短期认证值。
  • 当前 challenge 会话值应从浏览器当前 session 读取,不要写入 HANDOFF 或 writeup。
  • 复现需记录脱敏后的请求路径、响应状态和失败原因;不要记录完整 Cookie。

下次继续顺序

  1. Programming 04:🚧 未通关。已跑通的部分:取 XML 先加载关卡页(否则 1 字节)、逐字形去旋转(read4.py,字形清晰)、去旋转后用 ocr4.py(tesseract --psm 10)基本读对。唯一剩下的阻塞是字形切分glyphs_of() 在部分颜色上把相邻字符并成一个字形(yellow 只切出 4 个,答案有 10 个),修好切分(按笔画间距/投影分段)后即可自动化并 live 提交;限时 120 秒,需单进程。
  2. Programming 07:✅ 已 live 通关(2026-09-12,父代理现场解:分色带 + B 通道中值排序 + 按“上、下”拼接;solver 见 challenges/hts-prog/7/solve_live.py,答案与证据见 writeup)。
  3. Programming 08:bot fail skip。2026-09-12 复测 moo 仍不在线(WHOIS moo401 No such nick/channel,全网 31 用户/3 服务器);账号信息与脚本见上。
  4. Javascript 01–07:✅ 已全部 live 通关(机制/答案/Referer 坑见上);7 篇 writeup 已按真实证据重写并通过 QA。
  5. Extended Basic 6–14 / Forensic 3 / Steganography 1–17:逐关 live 推进中(机制与公共提交器见上);并发 ≤5,避免 HTS 429。
  6. 未 live 的其它雏形稿:extbasic 旧稿(01/02/06/08/09/12–14)与 stego 各关的 draft 需随对应关卡通关一起重写。
  7. Realistic 12–16:如需发布,按发布流程把草稿移到 _posts/(用户处理)。

交接规则

  • 先检查真实文件状态:

    git status --short

  • 先阅读对应 writeup,再开始 challenge 操作。

  • 已解决只表示当前 challenge 会话得到成功响应;仅有理论解、静态分析或旧流程不算已解决。

  • 新 flag、成功响应和关键解法应先写入对应 writeup,再同步本文件。

  • 不提交 Cookie、API key、HTTP Basic 密码或浏览器导出的认证数据。

Challenge

Application Challenge 8 — Find the 6 digit code. (medium)

找出 6 位数字:程序是个 VB6 写的数字键盘小程序,输入正确的 6 位码后会回显站点密码。

Solution

  • app8win.exeVB6 原生编译程序:导入表只有 MSVBVM60.DLL,且绝大多数条目是 ordinalordinal 632 = rtcMidCharVar,即 VB6 的 Mid$())。
  • 密码/数字都不在字符串里:ASCII 与 UTF-16 搜 password185862 等全部 0 命中;有效信息全在 .text 里的 UTF-16 BSTR 字面量和一串 push imm8 立即数里。VB6 的字符串是 UTF-16LE,必须 strings -el 或直接按 BSTR 结构读。
  • 先做纯静态:解析 PE → 读 BSTR 字面量 → 把校验函数里所有 rtcMidCharVar 调用点的立即数抽出来。静态结果先给出候选,最终结论通过运行程序读取消息框确认

VB6 的 BSTR 字面量结构是 长度 dword + UTF-16 数据,校验值是用 Mid$(源串, n, 1) 逐个字符拼出来的,所以只要拿到源串和那串下标就能还原所有字符串:

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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
#!/usr/bin/env python3
"""Static recon script for app8win.exe (HTS Application Mission 8).

The sample is a Visual Basic 6 program compiled to native x86 code. This
script parses the PE, lists MSVBVM60.DLL imports (name + ordinal), locates the
UTF-16 BSTR string literals VB6 stores in .text, and recovers the immediates
handed to the rtcMidCharVar calls (MSVBVM60.DLL ordinal 632, the runtime
implementation of VB6's Mid$()) inside the digit-check routine.

Run from the directory that holds app8/win/app8win.exe.
"""
import re
import struct

PATH = "app8/win/app8win.exe"
IMAGE_BASE = 0x400000
CHECK_FN = (0x406D40, 0x407740) # the button/verification routine
RTC_MIDCHARVAR_IAT = 0x401040 # IAT slot, called as "call ebx"

def load():
data = open(PATH, "rb").read()
pe = struct.unpack_from("<I", data, 0x3C)[0]
nsec = struct.unpack_from("<H", data, pe + 6)[0]
opt_size = struct.unpack_from("<H", data, pe + 20)[0]
secs = []
for i in range(nsec):
off = pe + 24 + opt_size + i * 40
name = data[off:off + 8].rstrip(b"\0").decode()
vsize, vaddr, rawsize, rawptr = struct.unpack_from("<IIII", data, off + 8)
secs.append((name, vaddr, vsize, rawptr, rawsize))
return data, pe, secs

def rva_of(secs, raw):
for name, vaddr, vsize, rawptr, rawsize in secs:
if rawptr <= raw < rawptr + rawsize:
return vaddr + (raw - rawptr)
raise ValueError(hex(raw))

def raw_of(secs, rva):
for name, vaddr, vsize, rawptr, rawsize in secs:
if vaddr <= rva < vaddr + max(vsize, rawsize):
return rawptr + (rva - vaddr)
raise ValueError(hex(rva))

def va_to_raw(secs, va):
return raw_of(secs, va - IMAGE_BASE)

def imports(data, pe, opt_size):
"""Yield (dll, member, iat_va) for every import."""
d = pe + 24
imp_rva, imp_size = struct.unpack_from("<II", data, d + 96 + 8)
i = 0
while True:
ent = raw_of(SECS, imp_rva) + i * 20
oft, _ts, _fc, name_rva, first = struct.unpack_from("<IIIII", data, ent)
if name_rva == 0:
break
noff = raw_of(SECS, name_rva)
dll = data[noff:data.index(b"\0", noff)].decode()
thunk_rva = oft or first
j = 0
while True:
t = struct.unpack_from("<I", data, raw_of(SECS, thunk_rva + j * 4))[0]
if t == 0:
break
if t & 0x80000000: # by ordinal
member = "ordinal %d" % (t & 0xFFFF)
else:
hint_rva = t & 0x7FFFFFFF
hoff = raw_of(SECS, hint_rva)
member = data[hoff + 2:data.index(b"\0", hoff + 2)].decode()
yield dll, member, IMAGE_BASE + first + j * 4
j += 1
i += 1

def bstr(data, secs, va):
"""Read a VB6 string literal: dword length at va-4, UTF-16 data at va."""
ln = struct.unpack_from("<I", data, va_to_raw(secs, va) - 4)[0]
raw = va_to_raw(secs, va)
return data[raw:raw + ln].decode("utf-16-le")

def mid_picks(data, secs):
"""Immediates of the 'push imm8 ; ... ; call ebx' sites where ebx holds the
rtcMidCharVar (ordinal 632) import. Returns [(call_va, imm, dest_ebp_offset)]
in program order, together with the chain boundary (the VarCat concat block).
"""
lo = va_to_raw(secs, CHECK_FN[0])
hi = va_to_raw(secs, CHECK_FN[1])
body = data[lo:hi]
out = []
# The compiler emits "lea eax,[ebp-..] ; push eax ; push <index>" at the head
# of every Mid$() argument block, and the block ends in "call ebx" where ebx
# was loaded from the rtcMidCharVar IAT slot.
for m in re.finditer(b"\x50\x6a(.)", body):
nxt = body.find(b"\xff\xd3", m.end())
if nxt == -1 or nxt - m.end() > 0x60:
continue
if body.find(b"\x50\x6a", m.end()) not in (-1,) and body.find(b"\x50\x6a", m.end()) < nxt:
continue
out.append((CHECK_FN[0] + m.start(), m.group(1)[0]))
return out

data, pe, SECS = load()
print("file %s: %d bytes, %d sections" % (PATH, len(data), len(SECS)))
for name, vaddr, vsize, rawptr, rawsize in SECS:
print(" %-8s VA %#x vsize %#x raw %#x" % (name, vaddr, vsize, rawptr))
print("\nimports:")
for dll, member, iat in imports(data, pe, pe and 224):
print(" %#x %-14s %s" % (iat, dll, member))

LITERALS = {
"keypad legend": 0x4055E0,
"digit 1": 0x405608, "digit 2": 0x405610, "digit 3": 0x405618,
"digit 4": 0x405620, "digit 5": 0x405628, "digit 6": 0x405630,
"digit 7": 0x405638, "digit 8": 0x405640, "digit 9": 0x405648,
"ok message": 0x405650, "ok title": 0x40569C, "separator": 0x405694,
"label caption": 0x40557C,
}
print("\nBSTR literals:")
for what, va in LITERALS.items():
print(" %#08x %-14s %r" % (va, what, bstr(data, SECS, va)))

print("\nrtcMidCharVar (ordinal 632) call sites in the check routine:")
picks = mid_picks(data, SECS)
for va, idx in picks:
print(" %#08x push %d" % (va, idx))

# The routine builds two concatenations out of Mid$() picks: the first one is
# the value the user input is compared against, the second one feeds the
# "Correct! The Magic Number is: " message. Split at the VarCat block.
CONCAT_START = 0x407088
chain1 = [i for va, i in picks if va < CONCAT_START]
chain2 = [i for va, i in picks if va > CONCAT_START]
print("\nMid$() picks compared against the input : %s" % chain1)
print("Mid$() picks used in the success message: %s" % chain2)

for src in ("123456789", "987654321"):
def pick(seq):
return "".join(src[n - 1] for n in seq)
print("\nsource string %r" % src)
print(" check value %s" % pick(chain1))
print(" success message %s-%s" % (pick(chain2[:6]), pick(chain2[6:])))

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
$ cd <hts-workspace>/challenges/hts-app && uv run python app8_static_dump.py
file app8/win/app8win.exe: 40960 bytes, 4 sections
.text VA 0x1000 vsize 0x8000 raw 0x400
.rdata VA 0x9000 vsize 0x1000 raw 0x8400
.data VA 0xa000 vsize 0x1000 raw 0x8600

BSTR literals:
0x004055e0 keypad legend '123456789' # 运行时先被 rtcStrReverse 翻成 '987654321'
0x00405608 digit 1 '1'
0x00405610 digit 2 '2'
0x00405618 digit 3 '3'
0x00405620 digit 4 '4'
0x00405628 digit 5 '5'
0x00405630 digit 6 '6'
0x00405638 digit 7 '7'
0x00405640 digit 8 '8'
0x00405648 digit 9 '9'
0x00405650 ok message 'Correct! The Magic Number is: '
0x0040569c ok title 'Correct!'
0x00405694 separator '-'
0x0040577c label caption 'HTS Application Challenge Programmed by Magic.'

rtcMidCharVar (ordinal 632) call sites in the check routine:
0x406f28 push 1
0x406f70 push 8
0x406f9d push 5
0x406fd0 push 8
0x407009 push 6
0x407042 push 2
0x407172 push 8
0x4071a3 push 4
0x4071d7 push 6
0x407214 push 6
0x407251 push 9
0x40728e push 4
0x4072cb push 6
0x407308 push 3
0x407345 push 7
0x407382 push 6
0x4073bf push 8
0x4073fc push 3

Mid$() picks compared against the input : [1, 8, 5, 8, 6, 2]
Mid$() picks used in the success message: [8, 4, 6, 6, 9, 4, 6, 3, 7, 6, 8, 3]

source string '123456789' (the raw legend literal as stored)
check value 185862
success message 846694-637683

source string '987654321' (the legend after rtcStrReverse -- the one actually used)
check value 925248
success message 2644-164-73427 # digits 264416473427, '-' inserted after the 4th and 7th digit

校验函数里对 Mid$() 的调用分成两段,中间夹着 __vbaVarCat 拼接块,两段的产物是:

  • 6 位码:由下标 [1, 8, 5, 8, 6, 2] 从键盘图例串按 1-based 下标取出
  • 魔数:由下标 [8, 4, 6, 6, 9, 4, 6, 3, 7, 6, 8, 3] 取出,插入字面量 '-',整段接在 BSTR 模板 'Correct! The Magic Number is: ' 之后

图例字面量在 .data 里存的是 '123456789',运行时会先经 rtcStrReverse(导入表里同时有 rtcStrReversertcMidCharVar)翻成 '987654321' 再交给 Mid$(),所以 1→98→25→58→26→42→8

也就是说:输入 6 位码后,程序弹出的消息框带出这段魔数。

此处存在一个纯静态无法推出的易错点:如果按下标顺序 = 显示顺序、连字符插在第 6 位之后去推,会得到 264416-473427(12 位数字无误,但连字符位置错误)。__vbaVarCat 的拼接顺序与 Mid$() 调用顺序并不一致,连字符实际落在第 4 位和第 7 位之后。连字符的实际位置只有运行程序并读出消息框才能确认

提交到站点校验端点被接受:

1
2
POST /missions/application/applevelup.php  level=8  password=2644-164-73427
-> Congratulations, you have successfully completed application 8!

Vulnerabilities

校验值是运行时用 Mid$() 从图例字符串拼出来的,读出 push 的立即数即可还原。在本地判定的校验无法保守秘密,等同于把答案交给逆向者。修复方向:校验放服务端,客户端只提交不可逆的校验结果;必须本地校验时,不让答案以可还原的形式出现在代码里。

2644-164-73427

Challenge

Find the Password (medium) 附件是一个要求输入密码的控制台程序:它把输入字符求和当 XOR key 来解密 encrypted.enc,只有算出的校验和命中目标值才把解密出的口令回显出来。

附件含 Windows 版 app7win.zipapp7win.exe)和 Linux 版 app7unix.tar.gz(ELF app7unix),以及加密文件 encrypted.enc(480 字节)。程序把输入字符逐字节求和得到 key,用 key XOR encrypted.enc 的前 5 字节、边解边累加校验和;当校验和等于 0xdca 时,用 printf("Congratulations, The password is '%s'") 把解密结果当作口令打印出来。

Solution

Recon:

  • file unix/app7unix win/app7win.exe work/encrypted.encapp7unixELF 64-bit LSB pie executable, x86-64, not strippedapp7win.exePE32 executable for MS Windows 4.00 (console), Intel i386, 3 sectionsencrypted.encdata
  • strings -a unix/app7unix 里有 Please enter the password:encrypted.encFailed to open encrypted.encAn error occuredCongratulations, The password is '%s'Invalid Password,还残留源文件名 app7win.c:Linux 版和 Windows 版来自同一份源码。
  • encrypted.enc 只有头几字节可打印(31 4d 39 35 331M953),后面几乎全是高位字节;480 字节里程序实际只碰前 5 字节,其余是干扰。

Step 1: 定位校验与解密逻辑

符号没去掉,直接反汇编 main。Windows 版逻辑更直白:

1
$ objdump -d -M intel --start-address=0x401000 --stop-address=0x4011d0 win/app7win.exe

关键片段:

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
401047:  movsx  ecx,BYTE PTR [ebp-0x4]   ; c = getchar()
40104b: mov edx,DWORD PTR [ebp-0x1c]
40104e: add edx,ecx ; key += (signed char)c
401050: mov DWORD PTR [ebp-0x1c],edx
401057: cmp eax,0xa ; 读到换行 '\n' 或 '\0' 才停
401062: jne 0x40103f
401093: mov edx,DWORD PTR [ebp-0x24] ; i
401096: and edx,0x4
401099: test edx,edx
40109b: je 0x4010ab ; (i & 4)==0 -> 继续读
40109d: mov eax,DWORD PTR [ebp-0x24]
4010a0: and eax,0x1
4010a3: test eax,eax
4010a5: jne 0x401180 ; (i&4)&&(i&1) -> 退出读取循环
4010d8: mov eax,DWORD PTR [ebp-0x20] ; 刚 fread 到的字节
4010db: and eax,0xff
4010e0: xor eax,DWORD PTR [ebp-0x1c] ; ^ key(完整 32 位)
4010e3: mov ecx,DWORD PTR [ebp-0x18]
4010e6: add ecx,eax
4010e8: mov DWORD PTR [ebp-0x18],ecx ; checksum += (byte ^ key)
4010eb: mov edx,DWORD PTR [ebp-0x20]
4010ee: and edx,0xff
4010f4: xor edx,DWORD PTR [ebp-0x1c]
4010fa: mov BYTE PTR [ebp+eax*1-0x14],dl ; buffer[i] = (byte ^ key) & 0xff
401131: sar eax,1 ; buffer[i] >>= 1
401143: or al,0x80 ; 原值奇数时补回 bit7
401169: add al,0x3 ; buffer[i] += 3
40118c: cmp DWORD PTR [ebp-0x18],0xdca ; checksum == 0xdca ?
401193: jne 0x4011a8 ; 不等 -> "Invalid Password"
401199: push 0x408094 ; "Congratulations, The password is '%s'"

推理:

  • key 是输入所有字符按 signed char 的求和,包含结尾换行 0x0a(先 add 再判换行)。
  • 读取循环的条件是 (i&4) && (i&1)i=0..3i&4==0 继续,i=4i&1==0 继续,处理完第 5 个字节后 i=55&45&1 都非零)退出,所以只读 encrypted.enc 的前 5 字节。
  • 每字节:buffer[i] = (byte ^ key) & 0xff,然后重复 keybuffer[i] = (buffer[i] >> 1) | ((buffer[i] & 1) << 7),最后 buffer[i] += 3
  • checksum 累加的是完整 32 位(byte ^ key),而不是低字节。

Step 2: 解出 key

校验和是 key 的确定函数,方程只有 5 项、原文数(encrypted.enc[:5])已知,直接把 key 搜索出来:

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
#!/usr/bin/env python3
"""Recover the HTS Application 7 password from encrypted.enc.

app7win.exe reads the first 5 bytes of encrypted.enc, XORs each of them with a
32-bit key derived from the login input (key = sum of the signed input bytes,
the trailing newline included), accumulates the *full* 32-bit XOR results into
a checksum, and prints "The password is '%s'" only when that checksum equals
0xdca. Reproducing the checksum lets us solve for the single key that passes
the gate; the same key turns encrypted.enc into the plaintext password.
"""
MASK = 0xffffffff
TARGET = 0xdca # cmp [checksum],0xdca in main
enc = open("encrypted.enc", "rb").read(5)


def checksum(key):
"""Windows build: sum of (byte ^ key) as full 32-bit values."""
total = 0
for b in enc:
total = (total + (b ^ key)) & MASK
return total


def transform(raw, key):
"""Per byte: repeat {buf = buf>>1 | ((buf&1)<<7)} key times, then += 3."""
out = bytearray()
for v in raw:
for _ in range(key):
v = (v >> 1) | (0x80 if v & 1 else 0)
out.append((v + 3) & 0xff)
return bytes(out)


def main():
key = next(k for k in range(1 << 20) if checksum(k) == TARGET)
print(f"checksum {TARGET:#x} reached with key = {key} ({key:#x})")
raw = bytes((b ^ key) & 0xff for b in enc)
print(f"encrypted.enc[:5] ^ key = {raw.hex()}")
pw = transform(raw, key)
print(f"plaintext password = {pw.decode()!r}")
# any login whose signed-byte sum (newline included) equals key passes the gate
print(f"login input needs signed-byte sum {key} (newline adds 10)")


if __name__ == "__main__":
main()
1
2
3
4
5
$ cd <hts-workspace>/challenges/hts-app/app7/work && cp ../solve.py . && uv run python solve.py
checksum 0xdca reached with key = 753 (0x2f1)
encrypted.enc[:5] ^ key = c0bcc8c4c2
plaintext password = 'caged'
login input needs signed-byte sum 753 (newline adds 10)

key 唯一等于 7530x2f1):把 encrypted.enc 前 5 字节 31 4d 39 35 33 与 753 相 XOR 得 c0 bc c8 c4 c2,再套移位循环(753 次,周期 8,等价 1 次)与 +3,还原出 caged

Step 3: 构造登录串并验证

key = 753,结尾换行贡献 10,所以输入字符之和必须是 743。取 7*'a' + '@' = 679 + 64 = 743

1
2
3
$ printf 'aaaaaaa@\n' | wine app7win.exe 2>/dev/null | tr -d '\r'
Please enter the password:
Congratulations, The password is 'caged'

程序在成功路径直接把解密出的口令回显出来,caged 就是提交给 HTS 的密码。

Step 4: Linux 版为什么跑不出结果

在同一目录用同样的输入跑 Linux 版,只得到 Invalid Password

1
2
3
$ printf 'aaaaaaa@\n' | ../unix/app7unix
Please enter the password:
Invalid Password

用 gdb 在校验点(main+0x1c9)断下,dump 运行时的 key / checksum / buffer:

1
2
3
4
5
6
7
$ gdb -q -batch -ex 'set pagination off' -ex 'break main' -ex 'run < /tmp/in2.txt' \
-ex 'break *main+0x1c9' -ex 'continue' -ex 'x/3dw $rbp-0x38' -ex 'x/6bx $rbp-0x20' \
../unix/app7unix
== key / checksum / i ==
0x7fffffffc6b8: 753 -310 5
== buffer ==
0x7fffffffc6d0: 0x02 0x02 0x02 0x02 0x02 0x00

key 同样是 753,但 Linux 版的 checksum-310buffer 也被移位循环饱和成了 0x02。原因在它的反汇编里:

1
2
3
4
5
6
12ff:  mov    eax,DWORD PTR [rbp-0x3c]  ; 读入的字节
1302: xor eax,edx ; ^ key
130b: mov BYTE PTR [rbp+rax*1-0x20],dl ; buffer[i] = (byte ^ key) & 0xff
1314: movzx eax,BYTE PTR [rbp+rax*1-0x20]
1319: movsx eax,al ; 只取低字节并符号扩展
131c: add DWORD PTR [rbp-0x34],eax ; checksum += signed(low byte)
  • Windows 版累加完整 32 位 (byte ^ key);Linux 版先 movzxmovsx,只累加低字节的符号扩展值,单个字节最大 127,5 个字节能到的上限是 5 * 127 = 635 < 0xdca(因为 checksum 实际只依赖 key & 0xff,穷举 key 的 256 种取值也无一命中),任何输入都过不了校验。
  • Linux 版移位用的是 8 位 sar al,1(保留符号位),会把结果饱和到 0xff+3 变成 0x02;Windows 版是零扩展 32 位 sar eax,1,才会得到 caged

所以 app7unix 是一份有偏差的移植,永远只会打印 Invalid Password;正确口令必须用 app7win.exe(Wine)跑,或按上面的算法离线复现。

caged

Challenge

Find the Password (easy) 附件是一个 Windows 控制台程序,要求输入密码并校验;只有把正确口令输入进去,程序才会把 HTS 要提交的密码回显出来。

附件 app6win.zip 里只有一个 app6win.exe。是用 MSVC 编译的原生 PE,入口处有一段自解密壳:先把 .text 改成可写,XOR 还原 204 字节被加密的代码,再跳进去执行真正的 main。密码校验就在这段被还原出来的代码里。

Solution

Recon:

  • file app6win.exePE32 executable for MS Windows 4.00 (console), Intel i386, 3 sections
  • strings -a 能看到明文常量 Please enter the password:Invalid PasswordThe password is %s,还有一个残留的源文件名 main2.exe
  • 直接 strings 搜不到口令,也搜不到校验逻辑对应的字符串,因为真正干活的代码被 XOR 加壳了:入口只解密 204 字节再执行。
  • 静态定位壳:Ghidra 无头反编译 FUN_00401000 就是解密器,参数是 VA 0x4010d3、长度 0x33 个 dword、密钥 0xbeefcabe

Step 1: Ghidra 无头反编译整程序

DecompileAll.java 把每个函数输出为 C:

1
2
3
4
5
6
7
$ cd <hts-workspace>/challenges/hts-app && mkdir -p out
$ /opt/ghidra/support/analyzeHeadless /tmp/ghproj app6 \
-import app6/win/app6win.exe \
-scriptPath <hts-workspace>/challenges/hts-app \
-postScript DecompileAll.java -deleteProject > out/app6_decomp.txt 2>&1
$ grep -c '============' out/app6_decomp.txt
92

-scriptPath 必须给绝对路径,否则 Ghidra 报 Failed to find script in any script directory

Step 2: 读解密壳

入口 entry 最终调用 FUN_00401000,它的反编译结果就是自解密逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void FUN_00401000(void)
{
DWORD local_2c;
_MEMORY_BASIC_INFORMATION local_28;
undefined *local_c;
uint local_8;

VirtualQuery(&DAT_004010d3,&local_28,0x1c);
VirtualProtect(&DAT_004010d3,0xd0,local_28.Protect & 0xffffffdd | 4,&local_2c);
local_c = &DAT_004010d3;
for (local_8 = 0; local_8 < 0x33; local_8 = local_8 + 1) {
*(uint *)(&DAT_004010d3 + local_8 * 4) = *(uint *)(&DAT_004010d3 + local_8 * 4) ^ 0xbeefcabe;
}
func_0x004010d3();
return;
}

VirtualProtect 的保护标志把 local_28.Protect0xffffffdd 相与再或上 4,即把所在内存页加上 PAGE_READWRITE4),然后对 DAT_004010d3 起的 0x33 个 dword 反复 XOR 0xbeefcabe,最后 func_0x004010d3() 直接跳进刚解密的代码。0x33 * 4 = 0xcc 字节,覆盖 VA 0x4010d30x40119e

Step 3: 离线重放 XOR,还原真正的 main

壳的变换是可逆的,直接对文件重放同一个 XOR 即可静态看到明文代码。完整脚本:

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
#!/usr/bin/env python3
"""Decrypt app6win.exe's self-decrypting .text stub and dump the hidden function.

The PE entry (via FUN_00401000) VirtualProtect()s the .text page to RW, then
XORs 0x33 dwords starting at VA 0x4010d3 with 0xbeefcabe before calling into it.
This script replays that XOR so the real main can be disassembled statically.
"""
import struct

PATH = "app6win.exe"
IMAGE_BASE = 0x400000
# columns: name, RVA, virtual size, raw pointer, raw size (from the PE section table)
SECTIONS = [
(".text", 0x1000, 0x4ae6, 0x1000, 0x5000),
(".rdata", 0x6000, 0x087e, 0x6000, 0x1000),
(".data", 0x7000, 0x1e44, 0x7000, 0x1000),
]
DECRYPT_VA = 0x4010d3
XOR_KEY = 0xbeefcabe
N_DWORDS = 0x33


def va_to_offset(va):
rva = va - IMAGE_BASE
for _, rva0, vsize, rawptr, rawsize in SECTIONS:
if rva0 <= rva < rva0 + max(vsize, rawsize):
return rawptr + (rva - rva0)
raise ValueError(f"VA {va:#x} not mapped")


def main():
data = bytearray(open(PATH, "rb").read())
off = va_to_offset(DECRYPT_VA)
for i in range(N_DWORDS):
p = off + i * 4
val = struct.unpack_from("<I", data, p)[0]
struct.pack_into("<I", data, p, val ^ XOR_KEY)
open("app6_plain.exe", "wb").write(data)
print(f"decrypted {N_DWORDS} dwords at VA {DECRYPT_VA:#x} (file off {off:#x})")


if __name__ == "__main__":
main()
1
2
$ python3 decrypt_app6.py
decrypted 51 dwords at VA 0x4010d3 (file off 0x10d3)

用 objdump 反汇编还原后的代码:

1
$ objdump -d -M intel --start-address=0x4010d3 --stop-address=0x4011ef app6_plain.exe
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
004010d3 <.text+0xd3>:
4010d3: 55 push ebp
4010d4: 8b ec mov ebp,esp
4010d6: 83 ec 2c sub esp,0x2c
4010d9: c7 45 f0 63 61 6c 0a mov DWORD PTR [ebp-0x10],0xa6c6163 ; "cal\n"
4010e0: c7 45 f4 6d 61 67 69 mov DWORD PTR [ebp-0xc],0x6967616d ; "magi"
4010e7: 68 40 70 40 00 push 0x407040 ; "Please enter the password:"
4010ec: e8 47 05 00 00 call 0x401638
4010f1: 83 c4 04 add esp,0x4
4010f4: 6a 10 push 0x10
4010f6: 6a 00 push 0x0
4010f8: 8d 45 d8 lea eax,[ebp-0x28]
4010fb: 50 push eax
4010fc: e8 df 04 00 00 call 0x4015e0 ; memset(input,0,16)
401101: 83 c4 0c add esp,0xc
401104: c7 45 ec 00 00 00 00 mov DWORD PTR [ebp-0x14],0x0 ; len = 0
40110b: c7 45 e8 00 00 00 00 mov DWORD PTR [ebp-0x18],0x0
401112: 8b 0d 8c 70 40 00 mov ecx,DWORD PTR ds:0x40708c
401118: 83 e9 01 sub ecx,0x1
40111b: 89 0d 8c 70 40 00 mov DWORD PTR ds:0x40708c,ecx
401121: 83 3d 8c 70 40 00 00 cmp DWORD PTR ds:0x40708c,0x0
401128: 7c 22 jl 0x40114c
40112a: 8b 15 88 70 40 00 mov edx,DWORD PTR ds:0x407088
401130: 0f be 02 movsx eax,BYTE PTR [edx]
401133: 25 ff 00 00 00 and eax,0xff
401138: 89 45 d4 mov DWORD PTR [ebp-0x2c],eax
40113b: 8b 0d 88 70 40 00 mov ecx,DWORD PTR ds:0x407088
401141: 83 c1 01 add ecx,0x1
401144: 89 0d 88 70 40 00 mov DWORD PTR ds:0x407088,ecx
40114a: eb 10 jmp 0x40115c
40114c: 68 88 70 40 00 push 0x407088
401151: e8 e9 02 00 00 call 0x40143f
401156: 83 c4 04 add esp,0x4
401159: 89 45 d4 mov DWORD PTR [ebp-0x2c],eax
40115c: 8a 55 d4 mov dl,BYTE PTR [ebp-0x2c]
40115f: 88 55 fc mov BYTE PTR [ebp-0x4],dl
401162: 8b 45 ec mov eax,DWORD PTR [ebp-0x14]
401165: 8a 4d fc mov cl,BYTE PTR [ebp-0x4]
401168: 88 4c 05 d8 mov BYTE PTR [ebp+eax*1-0x28],cl
40116c: 8b 55 ec mov edx,DWORD PTR [ebp-0x14]
40116f: 83 c2 01 add edx,0x1
401172: 89 55 ec mov DWORD PTR [ebp-0x14],edx ; len++
401175: 0f be 45 fc movsx eax,BYTE PTR [ebp-0x4]
401179: 83 f8 0a cmp eax,0xa
40117c: 74 0e je 0x40118c
40117e: 0f be 4d fc movsx ecx,BYTE PTR [ebp-0x4]
401182: 85 c9 test ecx,ecx
401184: 74 06 je 0x40118c
401186: 83 7d ec 10 cmp DWORD PTR [ebp-0x14],0x10
40118a: 72 86 jb 0x401112 ; 最多 16 字节
40118c: 8d 55 d8 lea edx,[ebp-0x28]
40118f: 89 55 f8 mov DWORD PTR [ebp-0x8],edx ; ptr = input
401192: c7 45 e8 00 00 00 00 mov DWORD PTR [ebp-0x18],0x0
401199: eb 09 jmp 0x4011a4
40119b: 8b 45 e8 mov eax,DWORD PTR [ebp-0x18]
40119e: 83 c0 04 add eax,0x4
4011a1: 89 45 e8 mov DWORD PTR [ebp-0x18],eax
4011a4: 83 7d e8 08 cmp DWORD PTR [ebp-0x18],0x8
4011a8: 73 2e jae 0x4011d8
4011aa: 8b 4d e8 mov ecx,DWORD PTR [ebp-0x18]
4011ad: c1 e9 02 shr ecx,0x2
4011b0: 8b 55 ec mov edx,DWORD PTR [ebp-0x14] ; edx = len
4011b3: 2b 55 e8 sub edx,DWORD PTR [ebp-0x18] ; edx = len - i
4011b6: c1 ea 02 shr edx,0x2 ; edx = (len-i)/4
4011b9: 8b 45 f8 mov eax,DWORD PTR [ebp-0x8]
4011bc: 8b 0c 88 mov ecx,DWORD PTR [eax+ecx*4] ; input_dword[i/4]
4011bf: 3b 4c 95 ec cmp ecx,DWORD PTR [ebp+edx*4-0x14] ; const_dword[(len-i)/4]
4011c3: 74 11 je 0x4011d6
4011c5: 68 5c 70 40 00 push 0x40705c ; "Invalid Password"
4011ca: e8 3f 02 00 00 call 0x40140e
4011cf: 83 c4 04 add esp,0x4
4011d2: 33 c0 xor eax,eax
4011d4: eb 15 jmp 0x4011eb
4011d6: eb c3 jmp 0x40119b
4011d8: 8d 55 d8 lea edx,[ebp-0x28]
4011db: 52 push edx
4011dc: 68 70 70 40 00 push 0x407070 ; "The password is %s"
4011e1: e8 28 02 00 00 call 0x40140e
4011e6: 83 c4 08 add esp,0x8
4011e9: 33 c0 xor eax,eax
4011eb: 8b e5 mov esp,ebp
4011ed: 5d pop ebp
4011ee: c3 ret

Step 4: 逆推比较逻辑

观察 → 推理:

  • 0x4010d90x4010e0 把两个常量 dword 写进栈:[ebp-0x10] = 0xa6c6163(小端字节 63 61 6c 0a"cal\n")、[ebp-0xc] = 0x6967616d6d 61 67 69"magi")。
  • 输入缓冲在 [ebp-0x28],用 memset 清 16 字节;读取循环把每个字符写进去,遇到 \n、NUL 或长度到 0x10 就停,[ebp-0x14] 记录实际长度 len
  • 校验循环 i = 0, 4cmp [ebp-0x18],0x8; jae done 说明只在 i<8 时比较,正好两个 dword):
    • input_dword[i/4]
    • 和栈上 [ebp-0x14 + 4*((len-i)/4)] 比较。
  • [ebp-0x14] 往下正是 len"cal\n""magi" 三个 dword。若输入长度 len = 8
    • i=0:比较 input_dword[0][ebp-0x14 + 4*(8/4)] = [ebp-0xc] = "magi"
    • i=4:比较 input_dword[1][ebp-0x14 + 4*((8-4)/4)] = [ebp-0x10] = "cal\n"

所以输入的第 0 个 dword 要等于 "magi"、第 1 个 dword 要等于 "cal\n",拼起来就是 7 个字母加一个换行。字符串常量在内存里的顺序是反的(cal\n 在前、magi 在后),靠索引 (len-i)/4 倒着取,拼回来才是 magical

Step 5: 本地运行验证

程序是控制台程序,直接在 Wine 里跑即可(无需图形界面):

1
2
3
4
5
6
7
$ printf 'magical\n' | wine app6/win/app6win.exe 2>/dev/null | tr -d '\r'
Please enter the password:
The password is magical

$ printf 'wrong\n' | wine app6/win/app6win.exe 2>/dev/null | tr -d '\r'
Please enter the password:
Invalid Password

程序在成功分支用 printf("The password is %s", input) 把答案回显出来,所以 The password is magical 这一行就是自证的验证结果。

magical

Challenge

An application stores its password on the stack and compares it against user input. 应用程序要求输入密码进行验证,正确密码以常量的形式写在函数内部,运行时复制到栈上,再与用户输入逐字节比较。

附件含两个版本:app5win.zip(Windows 控制台 exe Live_Application_5.exe)和 app5unix.tar.gz(ELF 可执行文件 app5unix)。官方暗示两个平台的密码一致,Linux 版没有 strip,直接从本地 ELF 入手最快。

Solution

Recon:

  • file app5unixELF 32-bit LSB pie executable, Intel i386, not stripped,源码文件名残留在符号表里:app5win.c(Linux 版由同一份 C 源码编译)。
  • strings -a app5unix 里看不到完整密码,但有 4 个可疑短串 powertrippin,以及 Please enter the password:Invalid PasswordThe password is %s
  • powe / rtri / ppin 恰好是 4 字节对齐的 ASCII 片段:它们是被拆成 4 个 dword、以立即数形式 mov 进栈的常量,反汇编后按写入顺序拼回来即可。

Step 1: 反汇编 main,定位常量与比较循环

函数符号没去掉,直接反汇编 main

1
$ objdump -d -M intel app5unix --section=.text | sed -n '/<main>:/,/^$/p'

main 的完整反汇编如下(含 PIE 序言与栈保护样板):

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
124d:  f3 0f 1e fb           endbr32
1251: 8d 4c 24 04 lea ecx,[esp+0x4]
1255: 83 e4 f0 and esp,0xfffffff0
1258: ff 71 fc push DWORD PTR [ecx-0x4]
125b: 55 push ebp
125c: 89 e5 mov ebp,esp
125e: 53 push ebx
125f: 51 push ecx
1260: 83 ec 50 sub esp,0x50
1263: e8 e8 fe ff ff call 1150 <__x86.get_pc_thunk.bx>
1268: 81 c3 60 2d 00 00 add ebx,0x2d60
126e: 89 c8 mov eax,ecx
1270: 8b 40 04 mov eax,DWORD PTR [eax+0x4]
1273: 89 45 b4 mov DWORD PTR [ebp-0x4c],eax
1276: 65 a1 14 00 00 00 mov eax,gs:0x14 ; stack canary
127c: 89 45 f4 mov DWORD PTR [ebp-0xc],eax
127f: 31 c0 xor eax,eax
1281: c7 45 d4 67 0a 00 00 mov DWORD PTR [ebp-0x2c],0xa67 ; [ebp-0x2c] = 0xa67 -> 67 0a 00 00 ('g' '\n' 0 0)
1288: c7 45 d8 70 70 69 6e mov DWORD PTR [ebp-0x28],0x6e697070 ; [ebp-0x28] = "ppin"
128f: c7 45 dc 72 74 72 69 mov DWORD PTR [ebp-0x24],0x69727472 ; [ebp-0x24] = "rtri"
1296: c7 45 e0 70 6f 77 65 mov DWORD PTR [ebp-0x20],0x65776f70 ; [ebp-0x20] = "powe"
129d: 83 ec 0c sub esp,0xc
12a0: 8d 83 40 e0 ff ff lea eax,[ebx-0x1fc0]
12a6: 50 push eax
12a7: e8 34 fe ff ff call 10e0 <puts@plt>
12ac: 83 c4 10 add esp,0x10
12af: 83 ec 04 sub esp,0x4
12b2: 6a 10 push 0x10 ; memset(input, 0, 16), input 在 [ebp-0x1c]
12b4: 6a 00 push 0x0
12b6: 8d 45 e4 lea eax,[ebp-0x1c]
12b9: 50 push eax
12ba: e8 41 fe ff ff call 1100 <memset@plt>
12bf: 83 c4 10 add esp,0x10
12c2: c7 45 cc 00 00 00 00 mov DWORD PTR [ebp-0x34],0x0
12c9: c7 45 c8 00 00 00 00 mov DWORD PTR [ebp-0x38],0x0
12d0: c7 45 c4 00 00 00 00 mov DWORD PTR [ebp-0x3c],0x0
12d7: e8 e4 fd ff ff call 10c0 <getchar@plt> ; c = getchar()
12dc: 88 45 c3 mov BYTE PTR [ebp-0x3d],al
12df: 8b 45 cc mov eax,DWORD PTR [ebp-0x34]
12e2: 8d 50 01 lea edx,[eax+0x1]
12e5: 89 55 cc mov DWORD PTR [ebp-0x34],edx
12e8: 0f b6 55 c3 movzx edx,BYTE PTR [ebp-0x3d]
12ec: 88 54 05 e4 mov BYTE PTR [ebp+eax*1-0x1c],dl
12f0: 80 7d c3 0a cmp BYTE PTR [ebp-0x3d],0xa
12f4: 74 0c je 1302 <main+0xb5>
12f6: 80 7d c3 00 cmp BYTE PTR [ebp-0x3d],0x0
12fa: 74 06 je 1302 <main+0xb5>
12fc: 83 7d cc 0f cmp DWORD PTR [ebp-0x34],0xf ; len < 16 才继续
1300: 76 d5 jbe 12d7 <main+0x8a>
1302: 8d 45 e4 lea eax,[ebp-0x1c]
1305: 89 45 d0 mov DWORD PTR [ebp-0x30],eax
1308: c7 45 c8 00 00 00 00 mov DWORD PTR [ebp-0x38],0x0 ; i = 0
130f: c7 45 c4 03 00 00 00 mov DWORD PTR [ebp-0x3c],0x3 ; j = 3
1316: eb 40 jmp 1358 <main+0x10b>
1318: 8b 45 c8 mov eax,DWORD PTR [ebp-0x38] ; 取 input_dword[i>>2] 与 const_dword[j] 比较
131b: c1 e8 02 shr eax,0x2
131e: 8d 14 85 00 00 00 00 lea edx,[eax*4+0x0]
1325: 8b 45 d0 mov eax,DWORD PTR [ebp-0x30]
1328: 01 d0 add eax,edx
132a: 8b 10 mov edx,DWORD PTR [eax]
132c: 8b 45 c4 mov eax,DWORD PTR [ebp-0x3c]
132f: 8b 44 85 d4 mov eax,DWORD PTR [ebp+eax*4-0x2c]
1333: 39 c2 cmp edx,eax
1335: 74 19 je 1350 <main+0x103>
1337: 83 ec 0c sub esp,0xc ; 不等 -> printf("Invalid Password")
133a: 8d 83 5b e0 ff ff lea eax,[ebx-0x1fa5]
1340: 50 push eax
1341: e8 6a fd ff ff call 10b0 <printf@plt>
1346: 83 c4 10 add esp,0x10
1349: b8 00 00 00 00 mov eax,0x0
134e: eb 29 jmp 1379 <main+0x12c>
1350: 83 45 c8 04 add DWORD PTR [ebp-0x38],0x4 ; i += 4
1354: 83 6d c4 01 sub DWORD PTR [ebp-0x3c],0x1 ; j -= 1
1358: 83 7d c8 0c cmp DWORD PTR [ebp-0x38],0xc ; while (i <= 12)
135c: 76 ba jbe 1318 <main+0xcb>
135e: 83 ec 08 sub esp,0x8 ; 全等 -> printf("The password is %s", input)
1361: 8d 45 e4 lea eax,[ebp-0x1c]
1364: 50 push eax
1365: 8d 83 6c e0 ff ff lea eax,[ebx-0x1f94]
136b: 50 push eax
136c: e8 3f fd ff ff call 10b0 <printf@plt>
1371: 83 c4 10 add esp,0x10
1374: b8 00 00 00 00 mov eax,0x0
1379: 8b 4d f4 mov ecx,DWORD PTR [ebp-0xc]
137c: 65 33 0d 14 00 00 00 xor ecx,DWORD PTR gs:0x14
1383: 74 05 je 138a <main+0x13d>
1385: e8 96 00 00 00 call 1420 <__stack_chk_fail_local>
138a: 8d 65 f8 lea esp,[ebp-0x8]
138d: 59 pop ecx
138e: 5b pop ebx
138f: 5d pop ebp
1390: 8d 61 fc lea esp,[ecx-0x4]
1393: c3 ret

0x1281~0x1296 把 16 字节的正确密码常量分 4 个 dword 写进 [ebp-0x2c]..[ebp-0x20]0x12d7 起是一个带长度上限的 getchar 循环,把每个字符写进 [ebp-0x1c + i],遇到 \n0xa)、NUL 或长度超过 15 就停;0x1318~0x135c 是比较循环,i 从 0 每次 +4、j 从 3 每次 -1。

Step 2: 从校验循环逆推密码

观察 → 推理:

  • 常量区在栈上是 [ebp-0x2c] = 0xa67[ebp-0x28] = "ppin"[ebp-0x24] = "rtri"[ebp-0x20] = "powe"
  • 循环让 i 从 0 递增、j 从 3 递减,比较的是 input_dword[i/4] == const_dword[j]。也就是说输入的第 0/1/2/3 个 dword 要分别等于常量里第 3/2/1/0 个 dword。常量在内存里是倒序存的。
  • 把 4 个 dword 按内存顺序还原成字节:67 0a 00 00 | 70 70 69 6e | 72 74 72 69 | 70 6f 77 65 → 反过来按 dword 拼接(powe + rtri + ppin + g)得到 powertripping
  • 那个 0x0a 正是结尾换行:getchar 循环会连同 \n 一起读进 input[13],第 4 个 dword 比较时也把它纳入了匹配,所以输入 powertripping\n 能对上。

用 gdb 在常量写完、比较开始前断下,直接 dump 栈内存验证推导:

1
2
3
4
5
6
$ gdb -q -batch -ex 'set pagination off' \
-ex 'break *main+0x50' -ex 'run' \
-ex 'x/16bx $ebp-0x2c' ./app5unix
Breakpoint 1, 0x5655629d in main ()
0xffffbafc: 0x67 0x0a 0x00 0x00 0x70 0x70 0x69 0x6e
0xffffbb04: 0x72 0x74 0x72 0x69 0x70 0x6f 0x77 0x65

0xffffbafc 起 16 字节即 g \n \0 \0 p p i n r t r i p o w e,按 dword 反向读就是 powe rtri ppin g\n

Step 3: 本地运行验证

把推导出的口令喂给程序,它会自己把密码打印出来:

1
2
3
4
5
6
7
$ printf 'powertripping\n' | ./app5unix
Please enter the password:
The password is powertripping

$ printf 'wrongpass\n' | ./app5unix
Please enter the password:
Invalid Password
powertripping

Challenge

Press the Button. (easy)

按下按钮(easy)。

包内只有一个 app4win.exe(24 KB)。界面上有两个按钮,鼠标指到哪个,哪个就被置灰、另一个恢复可用,所以两个按钮都点不到。

Solution

Recon:

  • file app4win.exePE32 executable for MS Windows 4.00 (GUI), Intel i386, 3 sections。导入表里只有 MSVBVM60.DLL 一个 DLL,代码在 .text(VA 0x401000,大小 0x2af8),没有 overlay。这是 VB6 编译成 native code 的程序,不是 p-code。
  • 导入表里直接能看到密码是怎么被拼出来的痕迹,即调用 rtcVarBstrFromAnsi(把 ANSI 字符转成 BSTR)和 __vbaVarCat(拼接 Variant 字符串):
1
2
3
4
5
6
7
8
9
10
11
$ objdump -x -M intel app4win.exe | sed -n '/The Import Tables/,$p'
The Import Tables (interpreted .text section contents)
00003814 0000383c ffffffff ffffffff 000038d4 00001000

DLL Name: MSVBVM60.DLL
vma: Ordinal Hint Member-Name Bound-To
00001054 608 <none> <none> 660e544f ; rtcVarBstrFromAnsi
0000105c <none> 0000 __vbaVarCat 660ea219
00001064 <none> 0000 __vbaNew2 6601c28c
0000108c <none> 0000 __vbaFreeObj 66024fd4
00001090 <none> 0000 __vbaFreeStr 660246fb
  • 版本信息资源还留着编译时的工程名,确认真的是 VB6:
1
2
3
4
5
$ strings -el app4win.exe | grep -iE 'challenge|project|app'
@*\AC:\Program Files\Microsoft Visual Studio\VB98\Projects\Challenge\c5Project1.vbp
c5Project1
AppChallenge
AppChallenge.exe
  • 密码不在字符串表里grep -a -c daytona app4win.exe0,连 Password 这个词都搜不到。和同批 app1 一样,是运行时逐字符生成的,必须反汇编重建。

Step 1: VB6 事件分发表

VB6 native 程序给每个控件事件编一个 id,进入事件时用一长串 sub dword ptr [esp+4], id + jmp handler 分发。把这套 81 6c 24 04 <id> e9 <rel32> 模式扫出来:

1
$ objdump -d -M intel app4win.exe > out/app4.asm

核心分发在这几行(VA 0x40247c 起):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
40247c: 81 6c 24 04 33 00 00 00   sub    DWORD PTR [esp+0x4],0x33
402484: e9 07 01 00 00 jmp 0x402590 ; 建主窗口 (Form_Load)
402489: 81 6c 24 04 37 00 00 00 sub DWORD PTR [esp+0x4],0x37
402491: e9 ba 02 00 00 jmp 0x402750
402496: 81 6c 24 04 37 00 00 00 sub DWORD PTR [esp+0x4],0x37
40249e: e9 6d 03 00 00 jmp 0x402810 ; 上按钮 MouseMove
4024a3: 81 6c 24 04 3b 00 00 00 sub DWORD PTR [esp+0x4],0x3b
4024ab: e9 60 04 00 00 jmp 0x402910
4024b0: 81 6c 24 04 3b 00 00 00 sub DWORD PTR [esp+0x4],0x3b
4024b8: e9 13 05 00 00 jmp 0x4029d0 ; 下按钮 MouseMove
4024bd: 81 6c 24 04 37 00 00 00 sub DWORD PTR [esp+0x4],0x37
4024c5: e9 06 06 00 00 jmp 0x402ad0 ; 上按钮 Click
4024ca: 81 6c 24 04 3b 00 00 00 sub DWORD PTR [esp+0x4],0x3b
4024d2: e9 99 0c 00 00 jmp 0x403170 ; 下按钮 Click

event id 0x33 是建窗口,0x37 指上按钮、0x3b 指下按钮;每个按钮各有一对「MouseMove / Click」处理函数。

Step 2: 确认「点不到」的原因

上按钮的 MouseMove 处理函数 0x402810 里,第一步就是对这个按钮自己调 EnableWindow(hwnd, FALSE)

1
2
3
4
5
40286d: 8b f8                mov    edi,eax
40286f: 6a 00 push 0x0 ; FALSE
402871: 57 push edi ; hwnd = 上按钮
402872: 8b 0f mov ecx,DWORD PTR [edi]
402874: ff 91 8c 00 00 00 call DWORD PTR [ecx+0x8c] ; EnableWindow 包装

0x4029d0(下按钮 MouseMove)是镜像逻辑:禁用下按钮、启用上按钮。于是鼠标一进入某个按钮它就自我禁用,Click 永远触发不了。真正的密码逻辑在 Click 处理函数 0x402ad0 / 0x403170,不是被禁用的那一边。

Step 3: 从 Click 处理函数重建密码

0x402ad0 逐字符调用 rtcVarBstrFromAnsi 生成单字符 BSTR:

1
2
3
4
5
6
7
8
9
10
11
12
13
402b12: 8b 3d 54 10 40 00    mov    edi,DWORD PTR ds:0x401054   ; rtcVarBstrFromAnsi
402b1d: 6a 50 push 0x50 ; 'P'
402b1f: 50 push eax
; 中间是 42 条 Variant 槽位零初始化 mov DWORD PTR [ebp-0x..],esi,与字符重建无关
402c19: ff d7 call edi
402c1b: 8d 4d c8 lea ecx,[ebp-0x38]
402c1e: 6a 61 push 0x61 ; 'a'
402c20: 51 push ecx
402c21: ff d7 call edi
; 中间是 's' 's' 'w' 'o' 'r' 'd' ' ' 'i' 's' ' ' 这 10 个字符各一组 lea/push/call 三连,模式与上同
402ce9: 6a 27 push 0x27 ; '\''
402ceb: 52 push edx
402cec: ff d7 call edi

每个字符的 immediate 按地址顺序连起来就是消息串。

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
#!/usr/bin/env python3
"""Recover the site password built by HackThisSite app4win.exe (VB6 native).

The exe is compiled to VB6 *native code*, so the password is not a plain
string constant. Each character is turned into a BSTR by the runtime
``rtcVarBstrFromAnsi`` and the pieces are glued together by ``__vbaVarCat``.
The code that does this lives inside the button click handlers:

mov edi, ds:rtcVarBstrFromAnsi ; 8b 3d 54 10 40 00
; (此处插入 Variant 槽位零初始化指令,把 push 0x50 与首次 call 隔开)
push 0x50 ; 'P' (deliberately spaced away from
... ; the first call by the VB
call edi ; variant zero-initialisation)
lea ecx, [ebp-0x38]
push 0x61 ; 'a'
push ecx
call edi
; (其后为 's' 's' 'w' 'o' 'r' 'd' ... 各字符同模式的 lea/push/call 序列)

Both the button-click handler at 0x402AD0 and the one at 0x403170 build the
same message. This script walks every ``rtcVarBstrFromAnsi`` chain and prints
the immediates in address order, which is exactly the string handed to the
message box.

usage: ./extract_app4_password.py <exe>
"""
import struct
import sys

RTC = bytes.fromhex('8b3d54104000') # mov edi, ds:[0x401054] rtcVarBstrFromAnsi
CAT = bytes.fromhex('8b3d5c104000') # mov edi, ds:[0x40105c] __vbaVarCat
CALL_EDI = bytes.fromhex('ffd7') # call edi
DISPATCH = bytes.fromhex('816c2404') # sub dword ptr [esp+4], imm32
JMP_REL = 0xe9


def text(data):
""".text starts at VA 0x401000 / file offset 0x1000 (fixed for this exe)."""
return data, 0x1000, 0x401000


def chains(data, base_file, base_va):
"""Yield (va, [chars]) for every rtcVarBstrFromAnsi character chain."""
pos = 0
while True:
start = data.find(RTC, pos)
if start < 0:
break
end = data.find(CAT, start)
if end < 0:
end = len(data)
chain_start = start
chars = []
for _ in range(64):
call = data.find(CALL_EDI, start, end)
if call < 0:
break
# the character immediate sits just before the two pushes that
# feed the call; scan back for a `push imm8` (6a XX)
off = call
while off > start:
if data[off] == 0x6a:
val = data[off + 1]
if 0x20 <= val <= 0x7e:
chars.append((base_va + (off - base_file), val))
break
off -= 1
start = call + 2
if chars:
yield base_va + (chain_start - base_file), chars
pos = end + 6


def dispatch(data, base_file, base_va):
"""Print the event-id -> handler switch that drives the buttons."""
pos = 0
out = []
while True:
i = data.find(DISPATCH, pos)
if i < 0:
break
ev = struct.unpack_from('<I', data, i + 4)[0]
if data[i + 8] == JMP_REL:
rel = struct.unpack_from('<i', data, i + 9)[0]
target = base_va + (i - base_file) + 13 + rel
out.append((base_va + (i - base_file), ev, target))
pos = i + 13
return out


def main():
path = sys.argv[1]
data = open(path, 'rb').read()
data, base_file, base_va = text(data)

print(f'[*] {path}')
print('[*] event dispatch (event id -> handler)')
for va, ev, target in dispatch(data, base_file, base_va):
print(f' {va:#08x} id {ev:#04x} -> {target:#08x}')

for va, chars in chains(data, base_file, base_va):
s = ''.join(chr(v) for _, v in chars)
print(f'\n[*] rtcVarBstrFromAnsi chain at {va:#08x}: {len(chars)} chars')
for cva, val in chars:
print(f' {cva:#08x} push {val:#04x} {chr(val)!r}')
print(f' => {s!r}')


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
$ cd <hts-workspace> && uv run python challenges/hts-app/app4/extract_app4_password.py challenges/hts-app/app4/win/app4win.exe
[*] challenges/hts-app/app4/win/app4win.exe
[*] event dispatch (event id -> handler)
0x40247c id 0x33 -> 0x402590
0x402489 id 0x37 -> 0x402750
0x402496 id 0x37 -> 0x402810
0x4024a3 id 0x3b -> 0x402910
0x4024b0 id 0x3b -> 0x4029d0
0x4024bd id 0x37 -> 0x402ad0
0x4024ca id 0x3b -> 0x403170

[*] rtcVarBstrFromAnsi chain at 0x402b12: 21 chars
0x402b1d push 0x50 'P'
0x402c1e push 0x61 'a'
0x402c26 push 0x73 's'
0x402c2e push 0x73 's'
0x402c39 push 0x77 'w'
0x402c44 push 0x6f 'o'
0x402c4f push 0x72 'r'
0x402c5a push 0x64 'd'
0x402c65 push 0x20 ' '
0x402c70 push 0x69 'i'
0x402c7b push 0x73 's'
0x402c86 push 0x20 ' '
0x402c91 push 0x27 "'"
0x402c9c push 0x64 'd'
0x402ca7 push 0x61 'a'
0x402cb2 push 0x79 'y'
0x402cbd push 0x74 't'
0x402cc8 push 0x6f 'o'
0x402cd3 push 0x6e 'n'
0x402cde push 0x61 'a'
0x402ce9 push 0x27 "'"
=> "Password is 'daytona'"

下按钮的 Click 处理函数 0x403170 建的是同一个串(push 0x50push 0x27,21 个字符),两条链互相印证。0x402cee 起的 __vbaVarCat 序列把这段 21 字符的串接起来,最后 __vbaNew20x401064)造出弹窗对象显示:

1
2
3
4
5
402d00: ff 15 64 10 40 00    call   DWORD PTR ds:0x401064   ; __vbaNew2
402d50: 8b 3d 5c 10 40 00 mov edi,DWORD PTR ds:0x40105c ; __vbaVarCat
402d8c: ff d7 call edi
402d97: ff d7 call edi
; 其后是一长串同模式的 lea/push/call edi,把 21 个字符 BSTR 逐段拼成完整消息串

Step 4: Patch 让鼠标「按下」上按钮

既然上按钮的 MouseMove(0x402810)会自我禁用,把分发表里指向它的那个 jmp 改成指向真正的 Click 处理函数 0x402ad0 即可,鼠标一进入按钮即等价于按下该按钮。要改的指令在文件偏移 0x249e(VA 0x40249e):

1
2
$ xxd -s 0x2496 -l 16 app4win.exe
00002496: 816c 2404 3700 0000 e96d 0300 0081 6c24 .l$.7....m....l$

e9 6d 03 00 00jmp 0x402810rel32 相对指令末尾 0x4024a3 计算,新目标 0x402ad0 对应 rel = 0x402ad0 - 0x4024a3 = 0x62d

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
#!/usr/bin/env python3
"""Patch app4win.exe so the upper button's MouseMove jumps straight to the
click handler that builds the password.

The event dispatcher uses one shared stub per event id; the button events are:

file off 0x247C sub [esp+4], 0x33 jmp 0x402590 Form_Load / create
file off 0x2489 sub [esp+4], 0x37 jmp 0x402750
file off 0x2496 sub [esp+4], 0x37 jmp 0x402810 upper Button MouseMove
file off 0x24A3 sub [esp+4], 0x3B jmp 0x402910
file off 0x24B0 sub [esp+4], 0x3B jmp 0x4029D0 lower Button MouseMove
file off 0x24BD sub [esp+4], 0x37 jmp 0x402AD0 upper Button Click
file off 0x24CA sub [esp+4], 0x3B jmp 0x403170 lower Button Click

Every time the pointer enters the upper button the MouseMove stub at 0x402810
disables that very button (EnableWindow(hwnd, FALSE)), so it can never be
clicked. Redirecting its `jmp` (VA 0x40249E, file offset 0x249E) from
0x402810 to the real click handler 0x402AD0 makes the mouse simply "press" it
and the password message box pops up.

usage: ./patch_app4.py <in.exe> <out.exe>
"""
import shutil
import struct
import sys

JMP_FILE_OFF = 0x249E # `e9 6d 03 00 00` jmp 0x402810
JMP_END_VA = 0x4024A3 # end of the instruction; rel32 is relative to this
NEW_TARGET = 0x402AD0 # loc_402AD0: the upper button's click handler


def main():
src, dst = sys.argv[1], sys.argv[2]
shutil.copyfile(src, dst)
with open(dst, 'r+b') as f:
f.seek(JMP_FILE_OFF)
old = f.read(5)
assert old[0] == 0xE9, f'expected a jmp rel32, got {old.hex()}'
rel = NEW_TARGET - JMP_END_VA
f.seek(JMP_FILE_OFF)
f.write(b'\xE9' + struct.pack('<i', rel))
print(f'{src} -> {dst}')
new = b'\xE9' + struct.pack('<i', rel)
print(f' {JMP_FILE_OFF:#06x}: jmp 0x402810 => jmp {NEW_TARGET:#x} ({old.hex()} -> {new.hex()})')


if __name__ == '__main__':
main()
1
2
3
$ cd <hts-workspace> && uv run python challenges/hts-app/app4/patch_app4.py challenges/hts-app/app4/win/app4win.exe challenges/hts-app/app4/out/app4win_patched.exe
challenges/hts-app/app4/win/app4win.exe -> challenges/hts-app/app4/out/app4win_patched.exe
0x249e: jmp 0x402810 => jmp 0x402ad0 (e96d030000 -> e92d060000)

反汇编验证补丁生效:

1
2
3
4
$ objdump -d -M intel --start-address=0x402496 --stop-address=0x4024a3 app4win_patched.exe
402496: 81 6c 24 04 37 00 00 sub DWORD PTR [esp+0x4],0x37
40249d: 00
40249e: e9 2d 06 00 00 jmp 0x402ad0
daytona

Challenge

Find the Password. (easy)

找出密码(easy)。

包内只有一个 app3win.exe(约 1.4 MB)。

Solution

Recon:

  • file app3win.exePE32 executable for MS Windows 4.00 (GUI), Intel i386;导入表里只有 MSVBVM60/KERNEL32 级别的系统 DLL,用户代码与字符串表不在节区里。
  • 这是 REALbasic(Xojo 前身) 原生编译的程序:它不把密码作为明文字符串常量保存,而是在运行时逐字符拼出来,用 mov reg, 0x73 / push reg / call StringDBCSChr 生成单字符,再 call RuntimeAddString 追加到消息串。所以 strings 搜不到密码本身,能搜到的只有格式串:
1
2
3
4
$ grep -a -o -E 'Contratulations[^\x00]{0,50}' app3win.exe
Contratulations! The password to this level is '
$ grep -a -o -E 'incorrect serial[^\x00]{0,30}' app3win.exe
incorrect serial number. Please re-enter.
  • 用户代码和字符链在文件 overlay 里:从 file 偏移 0x153800 开始,运行时映射到 VA 0x55E000。Ghidra 只把 PE 节区载入到 .reloc0x55d000 结束),overlay 不在映射范围,所以 analyzeHeadless + DecompileAll.java 执行完毕得到 0 个函数。反编译器这条路对本题无效,必须直接扫 overlay 的指令模式。

Step 1: 扫 overlay 指令模式,重建字符链

字符链的形态是固定的 mov r32, imm32push r32call rel32,每次调用生成一个字符。按地址顺序把 immediate 读出来就是被拼出来的字符串:

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
#!/usr/bin/env python3
"""Rebuild run-time-built password strings from a Xojo/REALbasic native exe.

REALbasic never stores the level password as a plain string constant. It
builds it one character at a time at run time:

mov ecx, 73h ; 's'
push ecx
call StringDBCSChr ; -> "s"
; 每个字符重复 mov/push/call 这三步
call RuntimeAddString ; append to the message

so the password is invisible to `strings`. This script finds the
`mov reg,imm32 ; push reg ; call rel32` idiom, groups call sites by call
target, and prints the immediate characters in address order for every
target fed printable bytes. The really long runs are the built strings.

usage: ./rebuild_schr.py <exe> [min_run_len]
"""
import struct
import sys
from collections import defaultdict


def load(path):
"""Return (image_base, sections, overlay_file_offset, overlay_bytes)."""
data = open(path, 'rb').read()
e_lfanew = struct.unpack_from('<I', data, 0x3C)[0]
assert data[e_lfanew:e_lfanew + 4] == b'PE\0\0'
coff = e_lfanew + 4
nsec = struct.unpack_from('<H', data, coff + 2)[0]
opt_size = struct.unpack_from('<H', data, coff + 16)[0]
opt = coff + 20
magic = struct.unpack_from('<H', data, opt)[0]
image_base = (struct.unpack_from('<I', data, opt + 28)[0] if magic == 0x10b
else struct.unpack_from('<Q', data, opt + 24)[0])
sec = opt + opt_size
sections = []
end = 0
for i in range(nsec):
s = sec + i * 40
name = data[s:s + 8].rstrip(b'\0').decode('latin1')
vsize, va, rawsize, rawptr = struct.unpack_from('<IIII', data, s + 8)
sections.append((name, image_base + va, vsize, rawptr, rawsize))
end = max(end, rawptr + rawsize)
# REALbasic appends its own object code + string table as a file overlay
return image_base, sections, end, data[end:]


def char_chains(overlay, base_va, gap=0x120):
# one chain element is ~0x9c bytes: mov imm32 + push + call SChr + setup +
# call RuntimeAddString, so the SChr sites of one string sit ~150 bytes apart
"""Find `mov r32,imm ; push r32 ; call rel32` sites and chain their chars."""
by_target = defaultdict(list)
i = 0
while True:
i = overlay.find(b'\xe8', i) # call rel32
if i < 0 or i + 5 > len(overlay):
break
if i >= 2 and 0x50 <= overlay[i - 1] <= 0x57: # push eax..edi
r = overlay[i - 1] - 0x50
if i >= 7 and overlay[i - 6] == 0xB8 + r: # mov reg, imm32
val = struct.unpack_from('<I', overlay, i - 5)[0]
if val <= 0xff:
rel = struct.unpack_from('<i', overlay, i + 1)[0]
by_target[i + 5 + rel].append((i - 6, val))
i += 1

# per-call-site thunks: every SChr call goes through its own stub, so the
# chain must be rebuilt from *all* char sites in address order, not grouped
# by call target.
all_sites = sorted(s for sites in by_target.values() for s in sites)
runs = []
cur, prev = [], None
for off, val in all_sites:
if prev is not None and off - prev > gap:
if cur:
runs.append((0, cur))
cur = []
cur.append((off, val))
prev = off
if cur:
runs.append((0, cur))
return runs


def main():
path = sys.argv[1]
min_len = int(sys.argv[2]) if len(sys.argv) > 2 else 8
image_base, sections, ov_off, overlay = load(path)
base_va = 0x55E000 # where REALbasic maps its overlay
print(f'[*] {path}')
print(f'[*] overlay at file 0x{ov_off:x}, {len(overlay)} bytes, mapped at 0x{base_va:x}')
for target, run in sorted(char_chains(overlay, base_va), key=lambda r: -len(r[1])):
if len(run) < min_len:
continue
s = ''.join(chr(v) for _, v in run)
print(f'\n=== run of {len(run)} chars -> {s!r} ===')
for off, val in run:
print(f' file {ov_off + off:#08x} overlay+{off:#06x} mov reg, {val:#04x} {chr(val)!r}')


if __name__ == '__main__':
main()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ cd <hts-workspace> && uv run python challenges/hts-app/rebuild_schr.py challenges/hts-app/app3/win/app3win.exe
[*] challenges/hts-app/app3/win/app3win.exe
[*] overlay at file 0x153800, 81146 bytes, mapped at 0x55e000

=== run of 12 chars -> 'fireyourboss' ===
file 0x15d475 overlay+0x9c75 mov reg, 0x66 'f'
file 0x15d50c overlay+0x9d0c mov reg, 0x69 'i'
file 0x15d5a4 overlay+0x9da4 mov reg, 0x72 'r'
file 0x15d63c overlay+0x9e3c mov reg, 0x65 'e'
file 0x15d6d4 overlay+0x9ed4 mov reg, 0x79 'y'
file 0x15d76c overlay+0x9f6c mov reg, 0x6f 'o'
file 0x15d804 overlay+0xa004 mov reg, 0x75 'u'
file 0x15d89c overlay+0xa09c mov reg, 0x72 'r'
file 0x15d934 overlay+0xa134 mov reg, 0x62 'b'
file 0x15d9cc overlay+0xa1cc mov reg, 0x6f 'o'
file 0x15da64 overlay+0xa264 mov reg, 0x73 's'
file 0x15dafc overlay+0xa2fc mov reg, 0x73 's'

Step 2: 校验链路

app3 同样是"远端提供 keys"的型号:程序把服务器返回的 true/false 直接当校验结果。公开逆向笔记里的做法是把程序里的 true 覆盖成 false,让任意输入都通过。

1
2
3
$ grep -a -o -E 'Contratulations[^\x00]{0,40}|incorrect serial[^\x00]{0,30}' app3win.exe
Contratulations! The password to this level is '
incorrect serial number. Please re-enter.
fireyourboss

Challenge

Find the Password. (easy)

找出密码(easy)。

包内只有一个 app2win.exe(约 1.4 MB)。

Solution

Recon:

  • file app2win.exePE32 executable for MS Windows 4.00 (GUI), Intel i386;导入表里只有 MSVBVM60/KERNEL32 级别的系统 DLL,用户代码与字符串表不在节区里。
  • 这是 REALbasic(Xojo 前身) 原生编译的程序:它不把密码作为明文字符串常量保存,而是在运行时逐字符拼出来,用 mov reg, 0x73 / push reg / call StringDBCSChr 生成单字符,再 call RuntimeAddString 追加到消息串。所以 strings 搜不到密码本身,能搜到的只有格式串:
1
2
3
4
$ grep -a -o -E 'Contratulations[^\x00]{0,50}' app2win.exe
Contratulations! The password to this level is '
$ grep -a -o -E 'incorrect serial[^\x00]{0,30}' app2win.exe
incorrect serial number. Please re-enter.
  • 用户代码和字符链在文件 overlay 里:从 file 偏移 0x153800 开始,运行时映射到 VA 0x55E000。Ghidra 只把 PE 节区载入到 .reloc0x55d000 结束),overlay 不在映射范围,所以 analyzeHeadless + DecompileAll.java 执行完毕得到 0 个函数。反编译器这条路对本题无效,必须直接扫 overlay 的指令模式。

Step 1: 扫 overlay 指令模式,重建字符链

字符链的形态是固定的 mov r32, imm32push r32call rel32,每次调用生成一个字符。按地址顺序把 immediate 读出来就是被拼出来的字符串:

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
#!/usr/bin/env python3
"""Rebuild run-time-built password strings from a Xojo/REALbasic native exe.

REALbasic never stores the level password as a plain string constant. It
builds it one character at a time at run time:

mov ecx, 73h ; 's'
push ecx
call StringDBCSChr ; -> "s"
; 每个字符重复 mov/push/call 这三步
call RuntimeAddString ; append to the message

so the password is invisible to `strings`. This script finds the
`mov reg,imm32 ; push reg ; call rel32` idiom, groups call sites by call
target, and prints the immediate characters in address order for every
target fed printable bytes. The really long runs are the built strings.

usage: ./rebuild_schr.py <exe> [min_run_len]
"""
import struct
import sys
from collections import defaultdict


def load(path):
"""Return (image_base, sections, overlay_file_offset, overlay_bytes)."""
data = open(path, 'rb').read()
e_lfanew = struct.unpack_from('<I', data, 0x3C)[0]
assert data[e_lfanew:e_lfanew + 4] == b'PE\0\0'
coff = e_lfanew + 4
nsec = struct.unpack_from('<H', data, coff + 2)[0]
opt_size = struct.unpack_from('<H', data, coff + 16)[0]
opt = coff + 20
magic = struct.unpack_from('<H', data, opt)[0]
image_base = (struct.unpack_from('<I', data, opt + 28)[0] if magic == 0x10b
else struct.unpack_from('<Q', data, opt + 24)[0])
sec = opt + opt_size
sections = []
end = 0
for i in range(nsec):
s = sec + i * 40
name = data[s:s + 8].rstrip(b'\0').decode('latin1')
vsize, va, rawsize, rawptr = struct.unpack_from('<IIII', data, s + 8)
sections.append((name, image_base + va, vsize, rawptr, rawsize))
end = max(end, rawptr + rawsize)
# REALbasic appends its own object code + string table as a file overlay
return image_base, sections, end, data[end:]


def char_chains(overlay, base_va, gap=0x120):
# one chain element is ~0x9c bytes: mov imm32 + push + call SChr + setup +
# call RuntimeAddString, so the SChr sites of one string sit ~150 bytes apart
"""Find `mov r32,imm ; push r32 ; call rel32` sites and chain their chars."""
by_target = defaultdict(list)
i = 0
while True:
i = overlay.find(b'\xe8', i) # call rel32
if i < 0 or i + 5 > len(overlay):
break
if i >= 2 and 0x50 <= overlay[i - 1] <= 0x57: # push eax..edi
r = overlay[i - 1] - 0x50
if i >= 7 and overlay[i - 6] == 0xB8 + r: # mov reg, imm32
val = struct.unpack_from('<I', overlay, i - 5)[0]
if val <= 0xff:
rel = struct.unpack_from('<i', overlay, i + 1)[0]
by_target[i + 5 + rel].append((i - 6, val))
i += 1

# per-call-site thunks: every SChr call goes through its own stub, so the
# chain must be rebuilt from *all* char sites in address order, not grouped
# by call target.
all_sites = sorted(s for sites in by_target.values() for s in sites)
runs = []
cur, prev = [], None
for off, val in all_sites:
if prev is not None and off - prev > gap:
if cur:
runs.append((0, cur))
cur = []
cur.append((off, val))
prev = off
if cur:
runs.append((0, cur))
return runs


def main():
path = sys.argv[1]
min_len = int(sys.argv[2]) if len(sys.argv) > 2 else 8
image_base, sections, ov_off, overlay = load(path)
base_va = 0x55E000 # where REALbasic maps its overlay
print(f'[*] {path}')
print(f'[*] overlay at file 0x{ov_off:x}, {len(overlay)} bytes, mapped at 0x{base_va:x}')
for target, run in sorted(char_chains(overlay, base_va), key=lambda r: -len(r[1])):
if len(run) < min_len:
continue
s = ''.join(chr(v) for _, v in run)
print(f'\n=== run of {len(run)} chars -> {s!r} ===')
for off, val in run:
print(f' file {ov_off + off:#08x} overlay+{off:#06x} mov reg, {val:#04x} {chr(val)!r}')


if __name__ == '__main__':
main()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ cd <hts-workspace> && uv run python challenges/hts-app/rebuild_schr.py challenges/hts-app/app2/win/app2win.exe
[*] challenges/hts-app/app2/win/app2win.exe
[*] overlay at file 0x153800, 80323 bytes, mapped at 0x55e000

=== run of 10 chars -> 'liberation' ===
file 0x15d6e8 overlay+0x9ee8 mov reg, 0x6c 'l'
file 0x15d77f overlay+0x9f7f mov reg, 0x69 'i'
file 0x15d817 overlay+0xa017 mov reg, 0x62 'b'
file 0x15d8af overlay+0xa0af mov reg, 0x65 'e'
file 0x15d947 overlay+0xa147 mov reg, 0x72 'r'
file 0x15d9df overlay+0xa1df mov reg, 0x61 'a'
file 0x15da77 overlay+0xa277 mov reg, 0x74 't'
file 0x15db0f overlay+0xa30f mov reg, 0x69 'i'
file 0x15dba7 overlay+0xa3a7 mov reg, 0x6f 'o'
file 0x15dc3f overlay+0xa43f mov reg, 0x6e 'n'
liberation