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 画面把约束和设计缺陷的提示写出来了:
$ 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! htmlSolution
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)。文件里的代码段是密文。- 运行行为:参数个数不对或超出 时打印提示并等按键;四个数都在范围内但不对时瞬间退出。所以程序唯一泄漏出来的信号是它跑了多久。
Step 1: 前缀短路
Hint 里的 smart card power consumption 是个比喻:验证器把四个数字一个接一个检查,碰到错的立刻 _exit(0),于是前几位都对直接反映成运行时长的台阶。
$ 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)" done111 222 333 444 -> .425007683537 111 333 444 -> .434509897537 314 111 444 -> .449426607537 314 137 616 -> .512802465四个全对的那组明显最慢。把观察做成多点平均的脚本:
#!/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 soonas 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 subprocessimport timeimport osimport 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 = []26 collapsed lines
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]}")$ cd <hts-workspace> && uv run python challenges/hts-app/app13/verify_app13.pybaseline 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 轮:
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,改内存或下断点反而会破坏结果:
// 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 函数本身是:
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 是否命中:
#!/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) andsignal_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}
48 collapsed lines
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")$ cd <hts-workspace> && uv run python challenges/hts-app/app13/verify_crc.pycandidate [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 => FAILEDStep 4: 假校验
main() 里还有一段表面上有效的校验:循环 255 次 CRC(CRC_sum, v8*pt2 + v8*pt1 - v8*pt3 - v8*pt4) 后比较 CRC_sum == 0x435F2C82。按定义式复现只会得到 0xDF493F04,任意输入都无法满足该比较;真正决定结果的是 timer 回调里的校验链。
Step 5: 动态复现的局限
四个数字都对时,用 wine 跑仍然一行不输出:
$ WINEDEBUG=-all wine app13win.exe 537 314 137 616$ echo $?0这套校验挂在 CreateWaitableTimer + SetWaitableTimer 的完成例程(APC)上,wine 下 timer 完成例程的触发链路不完整,程序执行完 main() 的 SleepEx 循环后直接返回,永远打印不出成功信息。所以本题的定案不是输对数字看到密码:主要证据是 CRC checkpoint 链复现,计时侧信道只作旁证。