HackThisSite - Application Mission 2

Challenge

Find the Password. (easy)

找出密码(easy)。

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

Solution

Recon:

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#!/usr/bin/env python3
"""Rebuild run-time-built password strings from a Xojo/REALbasic native exe.

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

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

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

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


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


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

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


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


if __name__ == '__main__':
main()

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

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