Hello Navi

Tech, Security & Personal Notes

Challenge

It's Windows only for a reason.

只在 Windows 上运行是有原因的。

app14.exe 是个 .NET(VB.NET)WinForms 程序,启动时创建并使用注册表键 HKEY_CURRENT_USER\valid,验证逻辑挂在 Validate 按钮的点击事件里。

Solution

  • file app14.exe → PE32 executable for MS Windows 4.00 (GUI), Intel i386 Mono/.Net assembly, 4 sections:是 .NET 程序集,托管代码可以直接反编译,不用脱壳。
  • 用 ikdasm 导出 IL,只有两个类:app14.goes(窗体,按钮名 vla、文本框 txt)和 app14.Encrypt(加 / 解密)。
  • 字符串堆里直接摆着全部线索:HKEY_CURRENT_USER\valid、somerandomvl、585mfg9gf、fm`{f}kpwrn、nope、sorry。
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
$ cd <hts-workspace> && uv run python challenges/hts-app/app14/analyze_app14.py
=== #US heap (UTF-16LE literals) ===
'Property can only be set to Nothing'
'WinForms_RecursiveFormCreate'
'WinForms_SeeInnerException'
'txt'
'vla'
'Validate'
'goes'
'HKEY_CURRENT_USER\\valid'
'somerandomvl'
'ydXX!if not txt is blah youfailed'
'585mfg9gf'
'nope'
'sorry'
'fm`{f}kpwrn'
'valid'
'app14.Resources'

=== raw scan for candidate literals ===
b'fm`{f}kpwrn' -> NOT FOUND
b'ydXX!' -> NOT FOUND
b'somerandomvl' -> NOT FOUND
b'585mfg9gf' -> NOT FOUND
b'nope' -> NOT FOUND
b'sorry' -> NOT FOUND
b'valid' -> 0x32c2

(#US 堆里的字符串是 UTF-16LE,所以按 ASCII 字节搜 fm`{f}kpwrn 搜不到。)

Step 1: 反编译 vla_Click

1
$ ikdasm app14.exe > app14_ildasm.txt

按钮 vla 的点击处理是 app14.goes::vla_Click,IL 全文:

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
.method private instance void  vla_Click(object sender,
[mscorlib]System.EventArgs e) cil managed
{
// Code size 344 (0x158)
.maxstack 4
.locals init (int32[] V_0,
[mscorlib]System.Decimal V_1,
[Microsoft.VisualBasic]Microsoft.VisualBasic.VariantType V_2,
int32 V_3)
IL_0000: ldc.i4.s 12
IL_0002: newarr [mscorlib]System.Int32
IL_0007: stloc.0
IL_0008: call class app14.My.MyComputer app14.My.MyProject::get_Computer()
IL_000d: callvirt instance [Microsoft.VisualBasic]Microsoft.VisualBasic.MyServices.RegistryProxy [Microsoft.VisualBasic]Microsoft.VisualBasic.Devices.ServerComputer::get_Registry()
IL_0012: ldstr "HKEY_CURRENT_USER\\valid"
IL_0017: ldstr ""
IL_001c: ldc.i4.0
IL_001d: box [mscorlib]System.Int32
IL_0022: callvirt instance object [Microsoft.VisualBasic]Microsoft.VisualBasic.MyServices.RegistryProxy::GetValue(string,
string,
object)
IL_0027: call int32 [Microsoft.VisualBasic]Microsoft.VisualBasic.CompilerServices.Conversions::ToInteger(object)
IL_002c: stloc.2
IL_002d: ldloc.2
IL_002e: conv.r8
IL_002f: ldc.r8 1.5
IL_0038: add
IL_0039: ldloc.2
IL_003a: conv.r8
IL_003b: mul
IL_003c: ldc.r8 0.025
IL_0045: add
IL_0046: newobj instance void [mscorlib]System.Decimal::.ctor(float64)
IL_004b: stloc.1
IL_004c: ldarg.0
IL_004d: callvirt instance [System.Windows.Forms]System.Windows.Forms.TextBox app14.goes::get_txt()
IL_0052: callvirt instance string [System.Windows.Forms]System.Windows.Forms.TextBox::get_Text()
IL_0057: ldstr "somerandomvl"
IL_005c: ldc.i4.0
IL_005d: call int32 [Microsoft.VisualBasic]Microsoft.VisualBasic.CompilerServices.Operators::CompareString(string,
string,
bool)
IL_0062: ldc.i4.0
IL_0063: bne.un.s IL_008d

IL_0065: ldstr "ydXX!if not txt is blah youfailed"
IL_006a: call object app14.Encrypt::ParseandEncrypt(string)
IL_006f: call string [Microsoft.VisualBasic]Microsoft.VisualBasic.CompilerServices.Conversions::ToString(object)
IL_0074: call object app14.Encrypt::ParseandDecrypt(string)
IL_0079: call object [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::GetObjectValue(object)
IL_007e: ldc.i4.s 48
IL_0080: ldstr "585mfg9gf"
IL_0085: call [Microsoft.VisualBasic]Microsoft.VisualBasic.MsgBoxResult [Microsoft.VisualBasic]Microsoft.VisualBasic.Interaction::MsgBox(object,
[Microsoft.VisualBasic]Microsoft.VisualBasic.MsgBoxStyle,
object)
IL_008a: pop
IL_008b: br.s IL_00a9

IL_008d: ldstr "ydXX!if not txt is blah youfailed"
IL_0092: call object app14.Encrypt::ParseandDecrypt(string)
IL_0097: call object [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::GetObjectValue(object)
IL_009c: ldc.i4.s 48
IL_009e: ldstr "585mfg9gf"
IL_00a3: call [Microsoft.VisualBasic]Microsoft.VisualBasic.MsgBoxResult [Microsoft.VisualBasic]Microsoft.VisualBasic.Interaction::MsgBox(object,
[Microsoft.VisualBasic]Microsoft.VisualBasic.MsgBoxStyle,
object)
IL_00a8: pop
IL_00a9: ldloc.1
IL_00aa: call float64 [mscorlib]System.Convert::ToDouble([mscorlib]System.Decimal)
IL_00af: ldloc.2
IL_00b0: conv.r8
IL_00b1: ldc.r8 1.5
IL_00ba: add
IL_00bb: ldloc.2
IL_00bc: conv.r8
IL_00bd: mul
IL_00be: ldc.r8 0.025
IL_00c7: add
IL_00c8: bne.un IL_0157

IL_00cd: ldloc.2
IL_00ce: ldc.i4.0
IL_00cf: ceq
IL_00d1: call class app14.My.MyApplication app14.My.MyProject::get_Application()
IL_00d6: callvirt instance [Microsoft.VisualBasic]Microsoft.VisualBasic.ApplicationServices.AssemblyInfo [Microsoft.VisualBasic]Microsoft.VisualBasic.ApplicationServices.ApplicationBase::get_Info()
IL_00db: callvirt instance string [Microsoft.VisualBasic]Microsoft.VisualBasic.ApplicationServices.AssemblyInfo::get_DirectoryPath()
IL_00e0: callvirt instance int32 [mscorlib]System.String::get_Length()
IL_00e5: stloc.3
IL_00e6: ldloca.s V_3
IL_00e8: call instance string [mscorlib]System.Int32::ToString()
IL_00ed: callvirt instance int32 [mscorlib]System.String::get_Length()
IL_00f2: ldc.i4 0x1ca
IL_00f7: ceq
IL_00f9: or
IL_00fa: brfalse.s IL_0110

IL_00fc: ldstr "nope"
IL_0101: ldc.i4.s 64
IL_0103: ldstr "sorry"
IL_0108: call [Microsoft.VisualBasic]Microsoft.VisualBasic.MsgBoxResult [Microsoft.VisualBasic]Microsoft.VisualBasic.Interaction::MsgBox(object,
[Microsoft.VisualBasic]Microsoft.VisualBasic.MsgBoxStyle,
object)
IL_010d: pop
IL_010e: br.s IL_0157

IL_0110: ldarg.0
IL_0111: callvirt instance [System.Windows.Forms]System.Windows.Forms.TextBox app14.goes::get_txt()
IL_0116: callvirt instance string [System.Windows.Forms]System.Windows.Forms.TextBox::get_Text()
IL_011b: ldarg.0
IL_011c: callvirt instance [System.Windows.Forms]System.Windows.Forms.TextBox app14.goes::get_txt()
IL_0121: callvirt instance string [System.Windows.Forms]System.Windows.Forms.TextBox::get_Text()
IL_0126: ldc.i4.0
IL_0127: call int32 [Microsoft.VisualBasic]Microsoft.VisualBasic.CompilerServices.Operators::CompareString(string,
string,
bool)
IL_012c: ldc.i4.0
IL_012d: ceq
IL_012f: ldc.i4.0
IL_0130: ceq
IL_0132: ldc.i4.0
IL_0133: and
IL_0134: ldc.i4.0
IL_0135: or
IL_0136: ldloc.2
IL_0137: ldc.i4.1
IL_0138: ceq
IL_013a: or
IL_013b: brfalse.s IL_0157

IL_013d: ldarg.0
IL_013e: callvirt instance [System.Windows.Forms]System.Windows.Forms.TextBox app14.goes::get_txt()
IL_0143: ldstr "fm`{f}kpwrn"
IL_0148: call object app14.Encrypt::ParseandDecrypt(string)
IL_014d: call string [Microsoft.VisualBasic]Microsoft.VisualBasic.CompilerServices.Conversions::ToString(object)
IL_0152: callvirt instance void [System.Windows.Forms]System.Windows.Forms.TextBox::set_Text(string)
IL_0157: ret
} // end of method goes::vla_Click

读法:

  • IL_0008–IL_002c:从注册表 HKEY_CURRENT_USER\valid 取值(默认 0)存进 valid(V_2)。
  • IL_004c–IL_0063:把文本框内容和 "somerandomvl" 比较。相等走 IL_0065(把 "ydXX!if not txt is blah youfailed" 先加密再解密,原地打转,等于原样弹出来),不等走 IL_008d(直接解密这句密文,弹出乱码)。两条路都只是干扰信息。
  • IL_00c8 的 bne.un 永远不跳(比较的是同一个表达式 (valid+1.5)*valid+0.025),直接进 IL_00cd。
  • IL_00cd–IL_00fa:(valid == 0) | (目录路径长度转字符串再取长度的结果 == 0x1ca) 为真就弹 nope/sorry。正常目录长度不是 0x1ca,所以这一支要求 valid != 0。
  • IL_0110–IL_013b:CompareString(txt, txt) 恒等,再加 and 0 / or 0 与 0 的比较恒假,整段串起来等价于 valid == 1。
  • IL_013d–IL_0152:txt.Text = ParseandDecrypt("fm`{f}kpwrn")。

也就是说 valid 注册表值必须是 1,程序才会把解密后的密码填进文本框。这个值由 goes_Load 在窗体加载时创建并写成 0:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
IL_0000:  call       class app14.My.MyComputer app14.My.MyProject::get_Computer()
IL_0005: callvirt instance [Microsoft.VisualBasic]Microsoft.VisualBasic.MyServices.RegistryProxy [Microsoft.VisualBasic]Microsoft.VisualBasic.Devices.ServerComputer::get_Registry()
IL_000a: callvirt instance [mscorlib]Microsoft.Win32.RegistryKey [Microsoft.VisualBasic]Microsoft.VisualBasic.MyServices.RegistryProxy::get_CurrentUser()
IL_000f: ldstr "valid"
IL_0014: callvirt instance [mscorlib]Microsoft.Win32.RegistryKey [mscorlib]Microsoft.Win32.RegistryKey::CreateSubKey(string)
IL_0019: pop
IL_001a: call class app14.My.MyComputer app14.My.MyProject::get_Computer()
IL_001f: callvirt instance [Microsoft.VisualBasic]Microsoft.VisualBasic.MyServices.RegistryProxy [Microsoft.VisualBasic]Microsoft.VisualBasic.Devices.ServerComputer::get_Registry()
IL_0024: ldstr "HKEY_CURRENT_USER\\valid"
IL_0029: ldstr ""
IL_002e: ldstr "0"
IL_0033: callvirt instance void [Microsoft.VisualBasic]Microsoft.VisualBasic.MyServices.RegistryProxy::SetValue(string,
string,
object)
IL_0038: ret

Windows only for a reason 就是这个注册表依赖。

Step 2: Encrypt.ParseandDecrypt

app14.Encrypt::ParseandDecrypt 的 IL:

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
.method public static object  ParseandDecrypt(string stringd) cil managed
{
// Code size 150 (0x96)
.maxstack 4
.locals init (string V_0,
int32 V_1,
int32[] V_2,
int32 V_3,
string V_4,
object V_5,
int32 V_6,
int32 V_7)
IL_0000: ldc.i4 0x80
IL_0005: newarr [mscorlib]System.Int32
IL_000a: stloc.2
IL_000b: ldc.i4.0
IL_000c: stloc.s V_6
IL_000e: ldloc.s V_6
IL_0010: ldc.i4.2
IL_0011: rem
IL_0012: ldc.i4.0
IL_0013: bne.un.s IL_001d

IL_0015: ldloc.2
IL_0016: ldloc.s V_6
IL_0018: ldloc.s V_6
IL_001a: ldc.i4.3
IL_001b: sub.ovf
IL_001c: stelem.i4
IL_001d: ldloc.s V_6
IL_001f: ldc.i4.2
IL_0020: rem
IL_0021: ldc.i4.1
IL_0022: bne.un.s IL_002c

IL_0024: ldloc.2
IL_0025: ldloc.s V_6
IL_0027: ldloc.s V_6
IL_0029: ldc.i4.4
IL_002a: add.ovf
IL_002b: stelem.i4
IL_002c: ldloc.s V_6
IL_002e: ldc.i4.1
IL_002f: add.ovf
IL_0030: stloc.s V_6
IL_0032: ldloc.s V_6
IL_0034: ldc.i4.s 127
IL_0036: ble.s IL_000e

IL_0038: ldc.i4.0
IL_0039: stloc.s V_6
IL_003b: ldstr ""
IL_0040: stloc.s V_4
IL_0042: ldc.i4.1
IL_0043: ldarg.0
IL_0044: callvirt instance int32 [mscorlib]System.String::get_Length()
IL_0049: stloc.s V_7
IL_004b: stloc.1
IL_004c: br.s IL_008a

IL_004e: ldarg.0
IL_004f: ldloc.1
IL_0050: ldc.i4.1
IL_0051: call string [Microsoft.VisualBasic]Microsoft.VisualBasic.Strings::Mid(string,
int32,
int32)
IL_0056: stloc.0
IL_0057: ldloc.0
IL_0058: call int32 [Microsoft.VisualBasic]Microsoft.VisualBasic.Strings::Asc(string)
IL_005d: ldloc.2
IL_005e: ldloc.s V_6
IL_0060: ldelem.i4
IL_0061: sub.ovf
IL_0062: stloc.3
IL_0063: ldloc.s V_4
IL_0065: ldloc.3
IL_0066: call char [Microsoft.VisualBasic]Microsoft.VisualBasic.Strings::Chr(int32)
IL_006b: call string [Microsoft.VisualBasic]Microsoft.VisualBasic.CompilerServices.Conversions::ToString(char)
IL_0070: call string [mscorlib]System.String::Concat(string,
string)
IL_0075: stloc.s V_4
IL_0077: ldloc.s V_6
IL_0079: ldc.i4.1
IL_007a: add.ovf
IL_007b: stloc.s V_6
IL_007d: ldloc.s V_6
IL_007f: ldc.i4.s 127
IL_0081: ble.s IL_0086

IL_0083: ldc.i4.0
IL_0084: stloc.s V_6
IL_0086: ldloc.1
IL_0087: ldc.i4.1
IL_0088: add.ovf
IL_0089: stloc.1
IL_008a: ldloc.1
IL_008b: ldloc.s V_7
IL_008d: ble.s IL_004e

IL_008f: ldloc.s V_4
IL_0091: stloc.s V_5
IL_0093: ldloc.s V_5
IL_0095: ret
} // end of method Encrypt::ParseandDecrypt

算法很直白:建一张 128 项的移位表,偶数下标 arr[i] = i - 3,奇数下标 arr[i] = i + 4;然后对每个字符 out = Chr(Asc(ch) - arr[j]),j 从 0 数到 127 后归零。ParseandEncrypt 与之逐字节相同,只把 IL_0061 的 sub.ovf 换成 add.ovf。

移植成 Python:

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
"""Decrypt HTS app14's hard-coded password (port of Encrypt.ParseandDecrypt).

From the IL of app14.Encrypt::ParseandDecrypt: build a 128-entry shift table
where arr[i] = i - 3 for even i and arr[i] = i + 4 for odd i, then subtract
arr[j] (j cycling 0..127) from each character code.
"""
def make_table():
arr = [0] * 128
for i in range(128):
if i % 2 == 0:
arr[i] = i - 3
else:
arr[i] = i + 4
return arr


def parse_and_decrypt(s):
arr = make_table()
out = []
j = 0
for ch in s:
out.append(chr(ord(ch) - arr[j]))
j += 1
if j > 127:
j = 0
return "".join(out)


def parse_and_encrypt(s):
arr = make_table()
out = []
j = 0
for ch in s:
out.append(chr(ord(ch) + arr[j]))
j += 1
if j > 127:
j = 0
return "".join(out)


if __name__ == "__main__":
enc = "fm`{f}kpwrn"
print("ciphertext :", enc)
print("password :", parse_and_decrypt(enc))
print("round-trip check:", parse_and_encrypt(parse_and_decrypt(enc)))
1
2
3
4
$ cd <hts-workspace> && uv run python challenges/hts-app/app14/app14_decrypt.py
ciphertext : fm`{f}kpwrn
password : ihatethereg
round-trip check: fm`{f}kpwrn

Step 3: 动态验证

让 wine 加载真正的 app14.exe 程序集,用反射调用它自己的 Encrypt.ParseandDecrypt。wine 自带 .NET(wine-mono),直接可跑编译好的 driver(原生 mono driver.exe 加载不到版本匹配的 Microsoft.VisualBasic 8.0.0.0 程序集,会直接挂掉):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using System;
using System.Reflection;

class Driver {
static void Main() {
var asm = Assembly.LoadFrom("app14.exe");
var t = asm.GetType("app14.Encrypt");
var dec = t.GetMethod("ParseandDecrypt");
var enc = t.GetMethod("ParseandEncrypt");
Console.WriteLine("ParseandDecrypt(\"fm`{f}kpwrn\") = [" + dec.Invoke(null, new object[]{"fm`{f}kpwrn"}) + "]");
Console.WriteLine("ParseandDecrypt(\"ydXX!if not txt is blah youfailed\") = [" + dec.Invoke(null, new object[]{"ydXX!if not txt is blah youfailed"}) + "]");
Console.WriteLine("ParseandEncrypt(\"ihatethereg\") = [" + enc.Invoke(null, new object[]{"ihatethereg"}) + "]");
}
}
1
2
3
4
5
$ mcs driver.cs
$ WINEDEBUG=-all wine driver.exe
ParseandDecrypt("fm`{f}kpwrn") = [ihatethereg]
ParseandDecrypt("ydXX!if not txt is blah youfailed") = [|_YQ `c?ibm?kgi?\\^?K[HU?dR^GHHQBG]
ParseandEncrypt("ihatethereg") = [fm`{f}kpwrn]

Challenge

Find the numbers. The program takes four numbers on the command line and only prints the final password when all four are correct; any wrong number makes it quit without a message.

找出四个数字。程序从命令行接收四个数字,只有全部正确时才打印最终密码,否则无提示退出。

程序自己的 usage 画面把约束和设计缺陷的提示写出来了:

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
$ wine app13win.exe

usage:
======
app13win.exe <num1> <num2> <num3> <num4>
Example: app13win.exe 123 289 673 98



All numbers must be in the range 0 < number < 1000
If all numbers are correct, the program will give you
the final password, otherwise it quits without any message.

This app was designed to be as hard to debug as possible.
It uses several methods to detect if a debugger is present,
some of the code that validates the user - input is called
by timer interrupt service - routines and some of the code
is self modifying. So debugging is (almost) impossible
and not the way to beat this app. But there _is_ a design
problem you can use to find the correct input numbers.
Think a bit different ;-)
By the way, did you know that some hackers cracked smart
cards by monitoring their electrical power consumption
while trying different passwords?

Happy cracking!
html

Solution

  • file → PE32 executable for MS Windows 4.00 (console), Intel i386 (stripped to external PDB), 8 sections;objdump -h 显示 8 个 section 全都没有名字,import 只剩 Kernel32.dll,是典型加壳特征(作者用 Yoda's Crypter)。文件里的代码段是密文。
  • 运行行为:参数个数不对或超出 0 < n < 1000 时打印提示并等按键;四个数都在范围内但不对时瞬间退出。所以程序唯一泄漏出来的信号是它跑了多久。

Step 1: 前缀短路

Hint 里的 smart card power consumption 是个比喻:验证器把四个数字一个接一个检查,碰到错的立刻 _exit(0),于是前几位都对直接反映成运行时长的台阶。

1
2
3
4
5
6
7
8
$ for a in "537 314 137 616" "111 222 333 444" "537 111 333 444" "537 314 111 444"; do
start=$(date +%s.%N); wine app13win.exe $a >/dev/null 2>&1; end=$(date +%s.%N)
echo "$a -> $(echo "$end - $start" | bc)"
done
111 222 333 444 -> .425007683
537 111 333 444 -> .434509897
537 314 111 444 -> .449426607
537 314 137 616 -> .512802465

四个全对的那组明显最慢。把观察做成多点平均的脚本:

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
#!/usr/bin/env python3
"""Verify HTS app13 numbers with the documented timing side-channel.

The validator checks the four parts sequentially and calls _exit(0) as soon
as one part is wrong, so a longer runtime means more of the prefix passed.
Wrong parts exit early (~0.43s under wine), all four correct run longest.
"""
import subprocess
import time
import os
import statistics

WORKDIR = "<hts-workspace>/challenges/hts-app/app13"
EXE = "app13win.exe"
ENV = {**os.environ, "WINEDEBUG": "-all"}
CANDIDATE = [537, 314, 137, 616]


def measure(nums, reps=5):
times = []
for _ in range(reps):
t0 = time.perf_counter()
subprocess.run(["wine", EXE, *map(str, nums)], cwd=WORKDIR, env=ENV,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=120)
times.append(time.perf_counter() - t0)
return statistics.median(times)


if __name__ == "__main__":
print("baseline warm-up")
measure([111, 222, 333, 444], reps=3)

print("\nall four candidates that should differ by exactly one part:")
for label, nums in [
("candidate ", CANDIDATE),
("pt1 wrong (-1) ", [536, 314, 137, 616]),
("pt2 wrong (-1) ", [537, 313, 137, 616]),
("pt3 wrong (-1) ", [537, 314, 136, 616]),
("pt4 wrong (-1) ", [537, 314, 137, 615]),
("all wrong ", [111, 222, 333, 444]),
]:
print(f" {label} {nums} -> {measure(nums):.4f}s")

print("\nlocal peak scan around each correct part (rest fixed correct):")
for pos in range(4):
row = []
for delta in (-2, -1, 0, 1, 2):
nums = list(CANDIDATE)
nums[pos] += delta
row.append((nums[pos], measure(nums)))
best = max(row, key=lambda x: x[1])
cells = " ".join(f"{v}:{t:.4f}" for v, t in row)
print(f" pt{pos+1}: {cells} peak={best[0]}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cd <hts-workspace> && uv run python challenges/hts-app/app13/verify_app13.py
baseline warm-up

all four candidates that should differ by exactly one part:
candidate [537, 314, 137, 616] -> 0.5147s
pt1 wrong (-1) [536, 314, 137, 616] -> 0.4146s
pt2 wrong (-1) [537, 313, 137, 616] -> 0.4646s
pt3 wrong (-1) [537, 314, 136, 616] -> 0.4646s
pt4 wrong (-1) [537, 314, 137, 615] -> 0.4646s
all wrong [111, 222, 333, 444] -> 0.4146s

local peak scan around each correct part (rest fixed correct):
pt1: 535:0.4646 536:0.4646 537:0.5146 538:0.4145 539:0.4146 peak=537
pt2: 312:0.4647 313:0.4646 314:0.4647 315:0.4647 316:0.4646 peak=314
pt3: 135:0.4646 136:0.4646 137:0.5147 138:0.4646 139:0.4647 peak=137
pt4: 614:0.4646 615:0.5155 616:0.5147 617:0.5147 618:0.6186 peak=618

时间被 wine 的调度量化成几个台阶(0.415 / 0.465 / 0.515…),第 1、3 位能干净地定位到 537、137,第 2、4 位噪声较大。

Step 2: 脱壳与校验逻辑

剥掉 Yoda's Crypter 后载入反汇编,核心结构如下(按语义重写的干净版本)。timer_complete3 是真正校验的地方,每 5ms 触发一次,共 16 轮:

1
2
3
4
5
6
7
8
9
raise(8);                                  // signal_B -> data2_proc(current_part)
CRC_sum_final = CRC(CRC_sum_final, dword_40F0BC + 7);
raise(4); // signal_A -> data1_proc(current_part)
switch (dword_40F0BC) {
case 4: if (CRC_sum_final != 0x98F52A54) _exit(0); break; // 切到 pt2
case 8: if (CRC_sum_final != 0x7023AE57) _exit(0); break; // 切到 pt3
case 12: if (CRC_sum_final != 0x8986EE55) _exit(0); break; // 切到 pt4
}
if (dword_40F0BC == 16 && CRC_sum_final != 0xD9D9886E) _exit(0);

dword_40F0BC 从 1 起、每轮加 1,切换待校验数字的时机是 case 4/8/12 这三个点,和循环下标本身并不相同;判定也只在 dword_40F0BC 等于 4/8/12/16 时发生。

data1_proc / data2_proc 还带自校验(把内存里运行中的代码和磁盘拷贝逐字节相减,用来发现断点),无调试器时差值为 0,两函数化简为;IsDebuggerPresent 与基于 GetTickCount 的 10 秒超时还会各让 CRC_sum_final 多累加 1,改内存或下断点反而会破坏结果:

1
2
3
// data2_proc(a1)                      // data1_proc(a1)
CRC_sum_final += 3; // CRC_sum_final = (a1 + CRC_sum_final) >> 1;
CRC_sum_final = (a1 + CRC_sum_final) >> 1;

CRC 函数本身是:

1
2
3
4
5
6
7
DWORD CRC(DWORD crc, DWORD sum) {
char data[MAX_PATH];
sprintf(data, "%u", crc); // 当前 crc 当作十进制字符串
for (DWORD i = 0; i < strlen(data); i++)
data[i] = (data[i] + sum) % 0xFF;
return crc32(data, strlen(data)); // 标准 CRC32
}

四个硬编码 checkpoint 把四个数字唯一确定了。

Step 3: 脚本验证算法自洽

把上面这条链原样移植成 Python,输入候选四元组,看四个 checkpoint 是否命中:

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
#!/usr/bin/env python3
"""Reimplement HTS app13's validator chain and check the checkpoint CRCs.

Recovered algorithm (see writeup):
CRC(crc, sum):
data = decimal-string of crc
for each byte: data[i] = (data[i] + sum) % 0xFF
return crc32(data) # standard CRC-32

Main loop, 16 iterations, interleaving signal_A (data1_proc, +0) and
signal_B (data2_proc, +3); current_part is pt1..pt4. Expected checkpoints:
after iteration where dword_40F0BC == 4 -> 0x98F52A54 (pt2 becomes active)
after iteration where dword_40F0BC == 8 -> 0x7023AE57 (pt3 becomes active)
after iteration where dword_40F0BC == 12 -> 0x8986EE55 (pt4 becomes active)
final (dword_40F0BC == 16) -> 0xD9D9886E
"""
import zlib

EXPECT = {4: 0x98F52A54, 8: 0x7023AE57, 12: 0x8986EE55, 16: 0xD9D9886E}


def crc(crc_in, sum_):
data = bytearray(str(crc_in & 0xFFFFFFFF).encode())
for i in range(len(data)):
data[i] = (data[i] + sum_) % 0xFF
return zlib.crc32(bytes(data)) & 0xFFFFFFFF


def run(parts):
crc_sum = 0
a = b = bc = 1
cp = parts[0]
marks = {}
for _ in range(17):
# signal_B / data2_proc(current_part)
crc_sum = crc((cp + crc_sum + 3) >> 1, b + 0xD)
b += 1
crc_sum = crc(crc_sum, bc + 7)
# signal_A / data1_proc(current_part)
crc_sum = crc((cp + crc_sum) >> 1, a)
a += 1
if bc in (4, 8, 12):
if bc == 4:
cp = parts[1]
elif bc == 8:
cp = parts[2]
elif bc == 12:
cp = parts[3]
marks[bc] = crc_sum
# signal_B again
crc_sum = crc((cp + crc_sum + 3) >> 1, b + 0xD)
b += 1
if bc == 16:
marks[16] = crc_sum
break
bc += 1
# signal_A again
crc_sum = crc((cp + crc_sum) >> 1, a)
a += 1
return marks


if __name__ == "__main__":
for label, parts in [("candidate", [537, 314, 137, 616]),
("wrong ", [536, 314, 137, 616])]:
marks = run(parts)
print(f"{label} {parts}")
ok = True
for k in (4, 8, 12, 16):
got = marks.get(k)
exp = EXPECT[k]
m = "OK" if got == exp else "MISMATCH"
if got != exp:
ok = False
print(f" bc={k:2d} got=0x{got:08X} expect=0x{exp:08X} {m}")
print(f" => {'ALL MATCH' if ok else 'FAILED'}\n")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ cd <hts-workspace> && uv run python challenges/hts-app/app13/verify_crc.py
candidate [537, 314, 137, 616]
bc= 4 got=0x98F52A54 expect=0x98F52A54 OK
bc= 8 got=0x7023AE57 expect=0x7023AE57 OK
bc=12 got=0x8986EE55 expect=0x8986EE55 OK
bc=16 got=0xD9D9886E expect=0xD9D9886E OK
=> ALL MATCH

wrong [536, 314, 137, 616]
bc= 4 got=0x2B4AA581 expect=0x98F52A54 MISMATCH
bc= 8 got=0xAF080FC3 expect=0x7023AE57 MISMATCH
bc=12 got=0xAF2E0846 expect=0x8986EE55 MISMATCH
bc=16 got=0xF77C4DD8 expect=0xD9D9886E MISMATCH
=> FAILED

Step 4: 假校验

main() 里还有一段表面上有效的校验:循环 255 次 CRC(CRC_sum, v8*pt2 + v8*pt1 - v8*pt3 - v8*pt4) 后比较 CRC_sum == 0x435F2C82。按定义式复现只会得到 0xDF493F04,任意输入都无法满足该比较;真正决定结果的是 timer 回调里的校验链。

Step 5: 动态复现的局限

四个数字都对时,用 wine 跑仍然一行不输出:

1
2
3
$ WINEDEBUG=-all wine app13win.exe 537 314 137 616
$ echo $?
0

这套校验挂在 CreateWaitableTimer + SetWaitableTimer 的完成例程(APC)上,wine 下 timer 完成例程的触发链路不完整,程序执行完 main() 的 SleepEx 循环后直接返回,永远打印不出成功信息。所以本题的定案不是输对数字看到密码:主要证据是 CRC checkpoint 链复现,计时侧信道只作旁证。

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)能命中界面文字和一张字符表:
1
2
3
4
5
6
7
$ strings -el -t x app12/win/app12win.exe | grep -iE 'password|abcde'
65ac Enter Password Here
65d8 Please Enter a Password
6634 Verifying Password
6684 .Verifying Password.
66b4 ..Verifying Password..
6720 abcdefghijklmnopqrstuvwxyz.!:-

abcdefghijklmnopqrstuvwxyz.!:- 有 30 个字符,可视为一张字符索引表:所有该从程序里拼出来的字符都用 Mid() 按 1-based 下标从这张表里取,而下标本身不写死成 ASCII。

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

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

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

两个反直觉的点:

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

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

Step 2: 反编译校验逻辑

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

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

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

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

__vbaStrCopy 拿的正是它。Ordinal_632(VB6 运行时的 Mid 类索引函数)按 1-based 下标取字符,Ordinal_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):用户每输入一次它就执行

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

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

1
C r X X p X r

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

Step 4: 逆推脚本与输出

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
"""Recover the HackThisSite App 12 (app12win.exe) password from its own check logic.

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

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

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

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

import re

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


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


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


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


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


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


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


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

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

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

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


if __name__ == "__main__":
main()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
$ cd <hts-workspace> && uv run python challenges/hts-app/app12/recover_password.py
charset : 'abcdefghijklmnopqrstuvwxyz.!:-' (30 chars)
replacement ch : ' '
Mid(UPPER,3) : 'C'
Mid(lower,0x12): 'r'
Mid(lower,0x10): 'p'
target string : 'Cr p r'
accepted count : 28
Craapar
Crbbpbr
Crccpcr
Crddpdr
Creeper <-- real word
Crffpfr
Crggpgr
Crhhphr
Criipir
Crjjpjr
Crkkpkr
Crllplr
Crmmpmr
Crnnpnr
Croopor
Crqqpqr
Crsspsr
Crttptr
Cruupur
Crvvpvr
Crwwpwr
Crxxpxr
Cryypyr
Crzzpzr
Cr..p.r
Cr!!p!r
Cr::p:r
Cr--p-r
check('Creeper') -> True (modified='Cr p r')
check('CreeperX') -> False (modified='Cr p rX')
check('Cr11p1r') -> True (modified='Cr p r')
check('password') -> False (modified='pa word')

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

Creeper

Challenge

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

包内只有一个 app11win.exe(40 KB,PE32 GUI)。程序是个 Visual Basic 6 写的窗口,界面上有一个 Text1 输入框和一个标题为 Check 的按钮:典型的输入密码点确认形态。真正的东西不在代码里,而在程序随身带着的一张图片里:password 被画成文字印在内嵌的 JPEG 上。

Solution

  • file app11win.exe → PE32 executable for MS Windows 4.00 (GUI), Intel i386, 3 sections;导入表只有 MSVBVM60.DLL → VB6 编译的程序。
  • strings 里能看到控件名和工程信息:Form1、Command1、Check、Text1、App Challenge、challenge5Project1;.text 段偏移 0x54BC 处有 VB 工程头魔数 VB5!。入口点把 &DAT_004054bc(就是这个工程头)交给 VB 运行时初始化,是标准的 VB6 启动流程。
  • 二进制里 搜不到任何明文答案:Search / Destroy / Awnser / Answer / password 在 ASCII 和 UTF-16 两种编码下命中数都是 0。密码是一组像素。
  • binwalk app11win.exe 直接报出 JPEG:
1
2
3
4
5
$ binwalk app11win.exe
DECIMAL HEXADECIMAL DESCRIPTION
------------------------------------------------------------------------
0 0x0 Windows PE binary, machine type: Intel x86
4766 0x129E JPEG image, total size: 4536 bytes

Step 1: 提取内嵌图片

binwalk 只给出了一个 blob,但那实际上是两个叠加在一起的 JPEG。手工遍历一遍 JPEG marker 才能看清:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
--- walk from 0x129E ---
0x129E: FFD8
0x12A0: FFE0 len=16 JFIF
0x12B2: FFE1 len=1710 Exif <- 内含一张 160 字节的 Exif 缩略图
0x1962: FFED len=2920 Photoshop <- 2300 字节的 APP13,里面塞了一整张独立 JPEG
0x24CC: FFE1 len=4680 XMP <- APP1 XMP 元数据
0x3716: FFEE len=14 APP14 <- Adobe 标记
0x3726: FFDB len=132 DQT <- 量化表
0x37AC: FFC0 len=17 b'\x08\x00:\x00\xe9\x03...' <- SOF0: 高 0x3A=58, 宽 0xE9=233
0x3969: FFDA len=12 SOS
...scan data then EOI at 0x4B35

--- walk from 0x1ED6 ---
0x1ED6: FFD8
0x1ED8: FFE0 len=16 JFIF
0x1F8E: FFC0 len=17 b'\x08\x00 \x00\x80\x03...' <- SOF0: 高 0x20=32, 宽 0x80=128
0x20E8: FFDA len=12 SOS
...scan data then EOI at 0x2454

结论:

  • image A:从 0x129E 到 0x4B35,233×58,14489 字节。它不能按第一个 FF D9 截断:A 的 APP1 Exif 段里那张缩略图的 EOI(0x1960)会先命中,截出来的图打不开(identify 直接报错),脚本因此先定位 SOS 再找它后面第一个真正的 EOI。
  • image B:从 0x1ED6 到 0x2454,128×32,1408 字节;它整张位于 A 的 APP13(Photoshop IRB)段内,本质是 A 的缩略图(把 A 缩到 128×32 与 B 逐像素比,平均绝对差只有 5.2/255,内容一致);它只有 1408 字节,像素量不足以读出可靠文本,读数只能以 image A 为依据。

完整脚本:

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
"""Carve the two real JPEGs out of app11win.exe.

Marker walk shows:
* JPEG A at 0x129E: SOI, JFIF, APP1 Exif(1710, embeds a 160-byte thumbnail),
APP13 Photoshop(2920, embeds the whole of JPEG B), APP1 XMP, ... SOF0 233x58,
SOS, scan data, EOI at 0x4B35.
* JPEG B at 0x1ED6: complete 128x32 baseline JPEG living inside A's APP13.

The naive "first FF D9" carve truncates A at the Exif thumbnail's EOI, so we
locate SOS explicitly and take everything up to the first real EOI after it.
"""
import struct

data = open("app11win.exe", "rb").read()


def carve(soi):
"""Return (start, end) of a full JPEG beginning at `soi`."""
p = soi + 2
while p < len(data) - 1:
while data[p] != 0xFF:
p += 1
m = data[p + 1]
if m == 0xDA: # SOS -> skip header, then scan for EOI
ln = struct.unpack_from(">H", data, p + 2)[0]
p = p + 2 + ln
break
if m in (0xD8, 0xD9) or 0xD0 <= m <= 0xD7:
p += 2
continue
if m == 0xFF:
p += 1
continue
ln = struct.unpack_from(">H", data, p + 2)[0]
p = p + 2 + ln
# scan entropy-coded data for EOI (FF D9)
q = p
while q < len(data) - 1:
if data[q] == 0xFF and data[q + 1] == 0xD9:
return soi, q + 2
q += 1
return soi, len(data)


for name, off in (("image_a.jpg", 0x129E), ("image_b.jpg", 0x1ED6)):
s, e = carve(off)
blob = data[s:e]
open("extracted/" + name, "wb").write(blob)
print(f"{name}: 0x{s:X}..0x{e:X} size={len(blob)}")
1
2
3
4
5
6
7
$ cd <hts-workspace>/challenges/hts-app/app11 && uv run python carve_jpegs.py
image_a.jpg: 0x129E..0x4B37 size=14489
image_b.jpg: 0x1ED6..0x2456 size=1408

$ file extracted/image_a.jpg extracted/image_b.jpg
extracted/image_a.jpg: JPEG image data, JFIF ... baseline, precision 8, 233x58, components 3
extracted/image_b.jpg: JPEG image data, JFIF ... baseline, precision 8, 128x32, components 3

直接读,得到 The Awnser is: Search&Destroy

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.vbp,strings -el 里还留着原作者署名 HTS Application Challenge Programmed by Magic.),界面上有个 Proceed 按钮,按下去只会弹一个 Error-266 警告框(Error: 404 object(pwd); not found!)。真正的答案在一个没有任何控件会触发的 event handler 里被逐字符拼出来,再用一个消息框显示。

Solution

  • file app10win.exe → PE32 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 = ThunRTMain、595 = rtcMsgBox、608 = rtcVarBstrFromAnsi、0x401054 = __vbaVarCat。
  • 解析 PE 导入表逐槽核对过:0x401018 -> ord 595、0x40104c -> ord 608、0x401054 -> __vbaVarCat(按名字导入)、0x401008 -> __vbaFreeVarList、0x401074 -> __vbaVarDup,与后面反汇编里各调用点的角色严格对应。
  • strings -el(VB6 的字符串资源是 UTF-16LE,普通 strings 看不到)只有 ch16Project1、HTS Application Challenge Programmed by Magic.、Error: 404 object(pwd); not found!、Error-266、Untitled-1,没有明文密码。
  • binwalk 在 exe 里报出一个内嵌 JPEG(file offset 0x1272)。carve 出来(0x1272–0x36aa)是 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)

0x4045a8 和 0x40455c 指向 .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 edi(rtcVarBstrFromAnsi)把它变成一个单字符 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 0x4010c6(0x4049e6)和 push 0x405443(异常恢复块),rtcMsgBox 的其余参数槽是 VT_ERROR / 0x80020004 的缺省参数变体。也就是说正文和标题都是逐字符拼出来的,38 个立即数必须被切成两段。
  • 正文(前 30 个立即数):The Password Is: HiddenSecrets
  • 标题(后 8 个立即数):Correct!
  • 分界在第一处 __vbaVarCat(0x404dc1):它的参数是 'C'([ebp-0x3d4])和 'o'([ebp-0x3e4]),即标题的前两个字符;消息框的按钮位是 push 0x40(0x404e2d,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

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.exe 是 PE32 executable for MS Windows (GUI), Intel i386,导入表只有 MSVBVM60.DLL,且 strings 里能看到 C:\Program Files\Microsoft Visual Studio\VB98\Projects\Challenge\SoundProject1.vbp、DllFunctionCall、__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 / 1100 与 100 / 500 / 1000 同时在文件里。后面会看到,前者是程序实际使用的频率,后者是判定通过时要求的频率,两者对不上。这是本题的关键。
  • 事件处理函数的入口地址:主窗口创建 0x4054C0、Play 按钮 0x405570、Match1/2/3 分别是 0x405610 / 0x405690 / 0x405710、一个常驻循环动作 0x405790(即 VB6 的 Timer 事件)。以下结论全部来自本地 out/app9.asm(objdump -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 先 __vbaStrCopy 把 abcdefghijklmnopqrstuvwxyz(0x405254)拷进局部变量,然后逐个把按钮频率和字符串常量做浮点比较,相等就把按钮背景刷成浅绿 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]==1000(0x405B0F–0x405B65 把三个比较结果 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):变体字符串连接。

字符集就是 0x405254 的 abcdefghijklmnopqrstuvwxyz,索引是按 ASCII 字母表 1-based 的位置。其余补位字符是单字符常量 'C'、'!'、'T'、' '、'A'、':'、'S'、'K'(字符串池里一对一对的 02 00 00 00 xx 00 00 00)。字符串池里并列存在的 CDEFGHIJKLMN(0x405018)在整个 .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

Challenge

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

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

Solution

  • app8win.exe 是 VB6 原生编译程序:导入表只有 MSVBVM60.DLL,且绝大多数条目是 ordinal(ordinal 632 = rtcMidCharVar,即 VB6 的 Mid$())。
  • 密码/数字都不在字符串里:ASCII 与 UTF-16 搜 password、185862 等全部 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(导入表里同时有 rtcStrReverse 和 rtcMidCharVar)翻成 '987654321' 再交给 Mid$(),所以 1→9、8→2、5→5、8→2、6→4、2→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.zip(app7win.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.enc → app7unix 是 ELF 64-bit LSB pie executable, x86-64, not stripped,app7win.exe 是 PE32 executable for MS Windows 4.00 (console), Intel i386, 3 sections,encrypted.enc 是 data。
  • strings -a unix/app7unix 里有 Please enter the password:、encrypted.enc、Failed to open encrypted.enc、An error occured、Congratulations, The password is '%s'、Invalid Password,还残留源文件名 app7win.c:Linux 版和 Windows 版来自同一份源码。
  • encrypted.enc 只有头几字节可打印(31 4d 39 35 33 → 1M953),后面几乎全是高位字节;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..3 时 i&4==0 继续,i=4 时 i&1==0 继续,处理完第 5 个字节后 i=5(5&4 与 5&1 都非零)退出,所以只读 encrypted.enc 的前 5 字节。
  • 每字节:buffer[i] = (byte ^ key) & 0xff,然后重复 key 次 buffer[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 唯一等于 753(0x2f1):把 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 是 -310,buffer 也被移位循环饱和成了 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 版先 movzx 再 movsx,只累加低字节的符号扩展值,单个字节最大 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.exe → PE32 executable for MS Windows 4.00 (console), Intel i386, 3 sections
  • strings -a 能看到明文常量 Please enter the password:、Invalid Password、The 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.Protect 与 0xffffffdd 相与再或上 4,即把所在内存页加上 PAGE_READWRITE(4),然后对 DAT_004010d3 起的 0x33 个 dword 反复 XOR 0xbeefcabe,最后 func_0x004010d3() 直接跳进刚解密的代码。0x33 * 4 = 0xcc 字节,覆盖 VA 0x4010d3 到 0x40119e。

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: 逆推比较逻辑

观察 → 推理:

  • 0x4010d9 和 0x4010e0 把两个常量 dword 写进栈:[ebp-0x10] = 0xa6c6163(小端字节 63 61 6c 0a → "cal\n")、[ebp-0xc] = 0x6967616d(6d 61 67 69 → "magi")。
  • 输入缓冲在 [ebp-0x28],用 memset 清 16 字节;读取循环把每个字符写进去,遇到 \n、NUL 或长度到 0x10 就停,[ebp-0x14] 记录实际长度 len。
  • 校验循环 i = 0, 4(cmp [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 app5unix → ELF 32-bit LSB pie executable, Intel i386, not stripped,源码文件名残留在符号表里:app5win.c(Linux 版由同一份 C 源码编译)。
  • strings -a app5unix 里看不到完整密码,但有 4 个可疑短串 powe、rtri、ppin,以及 Please enter the password:、Invalid Password、The 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],遇到 \n(0xa)、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