HackThisSite - Application Mission 13

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
28
29
30
$ 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

  • filePE32 executable for MS Windows 4.00 (console), Intel i386 (stripped to external PDB), 8 sectionsobjdump -h 显示 8 个 section 全都没有名字,import 只剩 Kernel32.dll,是典型加壳特征(作者用 Yoda's Crypter)。文件里的代码段是密文,objdump 把密文当指令反汇编得到的是 jmp/call 满天飞的垃圾,strings 也只有乱码,手工抠密文不可行。
  • 运行行为:参数个数不对或超出 0 < n < 1000 时打印提示并等按键;四个数都在范围内但不对时一行都不输出、瞬间退出。所以程序唯一泄漏出来的信号是它跑了多久。

Step 1: 前缀短路

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

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

四个 32 位 checkpoint 全中、改动任意一位全崩,这串数字基本就是唯一解。

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 链复现,计时侧信道只作旁证。

537-314-137-616