Challenge
Find the Password. (easy)
找出密码(easy)。
包内只有一个 app2win.exe(约 1.4 MB)。
Solution
Recon:
file app2win.exe→PE32 executable for MS Windows 4.00 (GUI), Intel i386;导入表里只有MSVBVM60/KERNEL32级别的系统 DLL,用户代码与字符串表不在节区里。- 这是 REALbasic(Xojo 前身) 原生编译的程序:它不把密码作为明文字符串常量保存,而是在运行时逐字符拼出来,用
mov reg, 0x73/push reg/call StringDBCSChr生成单字符,再call RuntimeAddString追加到消息串。所以strings搜不到密码本身,能搜到的只有格式串:
$ grep -a -o -E 'Contratulations[^\x00]{0,50}' app2win.exeContratulations! The password to this level is '$ grep -a -o -E 'incorrect serial[^\x00]{0,30}' app2win.exeincorrect serial number. Please re-enter.- 用户代码和字符链在文件 overlay 里:从 file 偏移
0x153800开始,运行时映射到 VA0x55E000。Ghidra 只把 PE 节区载入到.reloc(0x55d000结束),overlay 不在映射范围,所以analyzeHeadless+DecompileAll.java执行完毕得到 0 个函数。反编译器这条路对本题无效,必须直接扫 overlay 的指令模式。
Step 1: 扫 overlay 指令模式,重建字符链
字符链的形态是固定的 mov r32, imm32 → push r32 → call rel32,每次调用生成一个字符。按地址顺序把 immediate 读出来就是被拼出来的字符串:
#!/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. Itbuilds 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 calltarget, and prints the immediate characters in address order for everytarget fed printable bytes. The really long runs are the built strings.
usage: ./rebuild_schr.py <exe> [min_run_len]"""import struct76 collapsed lines
import sysfrom 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()$ 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