Table of Contents
Team: IsloverRank: 2/7?Time: 2025/12/20 ~ 2025/12/22隊友超級 Carry,我算是躺好躺滿了,感謝 WIFI、Younglee、Win。
Reverse
bored
題目一開始在敘述有給型號:Luminary Micro Stellaris LM3S6965EVB 上網搜尋可以發現他是 ARM 架構並且是 Cortex-M3 core 這題的解題關鍵就是拖進 IDA 之後要選好 ARM 的架構並且選擇 ARMv7-M 這樣 IDA 就可以正確分析這個 binary 檔案了。
首先把開頭的地方變成 DCD (doubleword 的形式) 就可以在 0x4 的位址找到 reset handler 也可以當作程式要從哪裡開始跑,但因為 Cortex-M3 的核心是跑 THUMB 指令集所以最後一個 bit 會拿來記錄 THUMB 狀態,所以要把最後一個 bit 清掉,代表真正的程式開頭點是 0x350 。
這邊開始比較偷懶有使用 AI 幫忙分析其實很快就得知他做的是 RC4 的操作,同時我們除了這個 firmware.bin 以外還有一個 signal.vcd 的檔案,是用來記錄訊號跟時間的,所以可以先簡單請 AI 大神寫一個把裡面訊號解成 key 的 script。
import sys
def parse_vcd(filename): """ Parse VCD and return list of (time_ns, value) Only parse lines like '0d' or '1d' """ events = [] cur_time = 0
with open(filename, "r") as f: for line in f: line = line.strip() if not line: continue
if line.startswith("#"): cur_time = int(line[1:]) elif line.endswith("d") and (line[0] == '0' or line[0] == '1'): # Only parse '0d' or '1d' val = int(line[0]) events.append((cur_time, val)) else: # Ignore $end, $var, etc. continue
return events
def estimate_bit_time(events): """ Estimate UART bit time by finding shortest stable pulse """ durations = [] for i in range(1, len(events)): dt = events[i][0] - events[i - 1][0] if dt > 0: durations.append(dt)
durations.sort() short = durations[:10] # shortest 10 transitions bit_time = sum(short) // len(short) return bit_time
def decode_uart(events, bit_time): """ Decode UART frames from sampled events """ decoded = [] idx = 0 cur_val = events[0][1]
def sample(t): nonlocal idx, cur_val while idx + 1 < len(events) and events[idx + 1][0] <= t: idx += 1 cur_val = events[idx][1] return cur_val
t = events[0][0] end_time = events[-1][0]
while t < end_time: # look for start bit (falling edge) if sample(t) == 1 and sample(t + bit_time // 2) == 0: t_start = t + bit_time
byte = 0 for i in range(8): bit = sample(t_start + i * bit_time + bit_time // 2) byte |= (bit << i)
decoded.append(byte)
# skip stop bit t = t_start + 9 * bit_time else: t += bit_time // 4
return decoded
def main(): if len(sys.argv) != 2: print("Usage: python vcd_uart_decode.py signal.vcd") sys.exit(1)
filename = sys.argv[1] events = parse_vcd(filename) if not events: print("No events found in VCD!") sys.exit(1)
bit_time = estimate_bit_time(events) baud = int(1e9 / bit_time) print(f"[+] Estimated bit time: {bit_time} ns") print(f"[+] Estimated baud rate: {baud}")
data = decode_uart(events, bit_time)
print("\n[+] UART bytes (hex):") print(" ".join(f"{b:02x}" for b in data))
print("\n[+] ASCII:") for b in data: if 32 <= b <= 126: print(chr(b), end="") else: print(".", end="") print()
if __name__ == "__main__": main()這樣就可以得到 key 是:b4r3MEt41 有了這些資料就可以得知整個流程是原始的 firmware.bin 在 sub_2A0 要的 Input 是這段 key 並且會用這段 key 初始化 RC4 狀態生成 Keystream 接著他會把 Keystream 跟寫死在 binary 的 byte_394 進行 xor 運算,接著就一樣偷懶請 AI 大神搓一個解密 script 這題就解了。
def rc4_decrypt(key, ciphertext): # 1. KSA (Key Scheduling Algorithm) S = list(range(256)) j = 0 key_bytes = [ord(c) for c in key] key_len = len(key_bytes)
for i in range(256): j = (j + S[i] + key_bytes[i % key_len]) % 256 S[i], S[j] = S[j], S[i]
# 2. PRGA (Pseudo-Random Generation Algorithm) & XOR i = 0 j = 0 plaintext = []
for char_code in ciphertext: i = (i + 1) % 256 j = (j + S[i]) % 256 S[i], S[j] = S[j], S[i]
k = S[(S[i] + S[j]) % 256] plaintext.append(chr(char_code ^ k))
return "".join(plaintext)
# 題目數據key = "b4r3MEt41"# 從 IDA 中 byte_394 提取的數據cipher_data = [ 0xA2, 0xC3, 0x9E, 0xCC, 0x60, 0x35, 0xEE, 0xBF, 0xF5, 0x7D, 0x78, 0x5A, 0xCD, 0xD5, 0xC8, 0x52, 0x80, 0xAE, 0xC6, 0x19, 0x56, 0xF2, 0xA7, 0xCB, 0xD5, 0x0B, 0xE1, 0x61, 0xB9, 0x14]
flag = rc4_decrypt(key, cipher_data)print(f"Flag: {flag}")ポケモン GO
strings & DIE
首先先丟 DIE 可以看到 .text 區段的 entropy 超級高。
Y_^[Y__^[H:\comp_topic\Pack\PE64shell\PE64shell\Release\PE64shell.exeStub.dllg_stcParam順著這個 PE64shell 可以找到一個開源的加殼工具。
並且程式執行的行為跟加過這個殼的行為很像,會先請你輸入一把 key 如果輸入錯就直接跳掉了,稍微翻了一下他的 github 基本上就是用這把 key 對 .text 段做 xor 的加解密操作,可以在 Stub.cpp 裡翻到:
void encryptCode(){ PBYTE pBase = (PBYTE)((ULONGLONG)g_stcParam.dwImageBase + g_stcParam.lpStartVA); BYTE j; for (DWORD i = 0; i < g_stcParam.dwCodeSize; i++) { j = (BYTE)g_stcParam.pass[i % g_stcParam.passlen] + (BYTE)i; pBase[i] ^= j; }}Find Key & 解殼
有了以上的初步分析我們需要先把 key 拿到才可以繼續分析,把 PokemonGo.exe 執行起來候用 x32dbg attach 這支 process 並且可以從呼叫堆疊裡看到現在正在 ReadConsoleA 最後會回到 0x42A69A 的位址繼續執行。
有了這樣的資訊就可以回到 IDA 跳到 0x42A69A 這個地方把資料重新解成 instruction 這樣也可以推測出 0x42A694 會去呼叫 ReadConsoleA 也就是在這邊讀取 key 的。所以接著把斷點下在 0x42A69A 接著動態去觀察輸入 key 之後做甚麼事。
往下追一下之後就可以看到有個一大串在對 key 做一堆算術操作,基本上就是一大段算完之後比對一個值,如果錯了就跳掉,這邊有點偷懶直接請 AI 生成一個解回 key 的 script。
#!/usr/bin/env python3# -*- coding: utf-8 -*-
import re
# pip install z3-solverfrom z3 import ( BitVec, BitVecVal, ZeroExt, Solver, UGE, ULE, simplify, is_bv_value, sat)
BUF = object() # sentinel: "this register holds key buffer base ptr"
def strip_comment(line: str) -> str: return line.split(";", 1)[0].rstrip()
def parse_imm(tok: str) -> int: tok = tok.strip().rstrip(",") # hex like 0E9h / 1E8126h / 70h ... if tok.endswith(("h", "H")): return int(tok[:-1], 16) if tok.startswith(("0x", "0X")): return int(tok, 16) return int(tok, 10)
def bv32(n: int): return BitVecVal(n & 0xFFFFFFFF, 32)
def as_const_u32(expr): expr_s = simplify(expr) if is_bv_value(expr_s): return expr_s.as_long() return None
def extract_inst(line: str): line = strip_comment(line) if not line: return None
m = re.search(r"\b(movzx|mov|imul|shl|add|cmp|lea|nop)\b", line) if not m: return None
mnemonic = m.group(1) rest = line[m.end():].strip() ops = [o.strip() for o in rest.split(",")] if rest else [] return mnemonic, ops
def parse_mem_byte(operand: str): # e.g. "byte ptr [ecx+eax]" / "[edx+ecx]" op = operand.replace(" ", "") m = re.search(r"\[(\w+)\+(\w+)\]", op) if not m: return None return m.group(1).lower(), m.group(2).lower()
def solve_from_asm(asm_text: str, printable=True): regs = {"eax": bv32(0), "ecx": bv32(0), "edx": bv32(0)} byte_vars = {} # idx -> BitVec8 constraints = [] # list[BoolRef] length = None # from "cmp al, 0E9h"
for raw_line in asm_text.splitlines(): inst = extract_inst(raw_line) if not inst: continue
mnem, ops = inst
if mnem == "nop": continue
if mnem == "lea": # treat lea reg, [ebp-80h] as "reg = BUF" if len(ops) >= 1 and ops[0].lower() in regs: regs[ops[0].lower()] = BUF continue
if mnem == "mov": if len(ops) != 2: continue dst, src = ops[0].lower(), ops[1].strip()
# ignore stores like "mov [ebp-8Ch], ecx" if dst.startswith("["): continue if dst not in regs: continue
src_l = src.lower() if src_l.startswith("[ebp-8ch]"): regs[dst] = BUF elif src_l in regs: regs[dst] = regs[src_l] else: # immediate try: regs[dst] = bv32(parse_imm(src)) except ValueError: pass continue
if mnem == "movzx": if len(ops) != 2: continue dst = ops[0].lower() if dst not in regs: continue
mem = parse_mem_byte(ops[1]) if not mem: continue
base, idx_reg = mem if base not in regs or idx_reg not in regs: continue if regs[base] is not BUF: continue if regs[idx_reg] is BUF: continue
idx = as_const_u32(regs[idx_reg]) if idx is None: raise RuntimeError(f"Non-constant index: {raw_line}")
if idx not in byte_vars: byte_vars[idx] = BitVec(f"k_{idx:02X}", 8)
regs[dst] = ZeroExt(24, byte_vars[idx]) continue
if mnem == "imul": # handle 3-operand imul: imul dst, src, imm if len(ops) != 3: continue dst, src, imm_s = ops[0].lower(), ops[1].lower(), ops[2] if dst not in regs or src not in regs: continue if regs[src] is BUF: raise RuntimeError(f"imul source is BUF? {raw_line}") imm = parse_imm(imm_s) regs[dst] = simplify(regs[src] * bv32(imm)) continue
if mnem == "shl": if len(ops) != 2: continue dst = ops[0].lower() if dst not in regs: continue if regs[dst] is BUF: raise RuntimeError(f"shl on BUF? {raw_line}") sh = parse_imm(ops[1]) regs[dst] = simplify(regs[dst] << sh) continue
if mnem == "add": if len(ops) != 2: continue dst, src = ops[0].lower(), ops[1].lower() if dst not in regs or src not in regs: continue if regs[dst] is BUF or regs[src] is BUF: raise RuntimeError(f"add with BUF? {raw_line}") regs[dst] = simplify(regs[dst] + regs[src]) continue
if mnem == "cmp": if len(ops) != 2: continue left, right = ops[0].lower(), ops[1] try: imm = parse_imm(right) except ValueError: continue
if left == "al": length = imm continue
if left in regs and regs[left] is not BUF: constraints.append(regs[left] == bv32(imm)) continue
s = Solver()
# optional domain: printable ASCII for _, b in byte_vars.items(): if printable: s.add(UGE(b, 0x20), ULE(b, 0x7E))
for c in constraints: s.add(c)
res = s.check() if res != sat: return length, None, byte_vars
m = s.model() recovered = {idx: m.eval(var, model_completion=True).as_long() for idx, var in byte_vars.items()} return length, recovered, byte_vars
def escaped_ascii(bs: bytes) -> str: out = [] for x in bs: if 0x20 <= x <= 0x7E: out.append(chr(x)) else: out.append(f"\\x{x:02x}") return "".join(out)
if __name__ == "__main__": ASM_TEXT = r""" Hacker:0042A752 mov edx, 1 Hacker:0042A757 imul eax, edx, 0Dh Hacker:0042A75A mov ecx, [ebp-8Ch] Hacker:0042A760 movzx edx, byte ptr [ecx+eax] 把剩下的方程式全部貼進來 """
length, recovered, byte_vars = solve_from_asm(ASM_TEXT, printable=True)
if recovered is None: print("[!] UNSAT / 解析失敗。你可以試試 printable=False,或確認 ASM_TEXT 是否完整。") raise SystemExit(1)
print(f"[+] Found length (from cmp al, imm): {length} (hex: {length:#x})") print(f"[+] Solved {len(recovered)} byte positions: {sorted(recovered.keys())}")
# dump solved bytes for idx in sorted(recovered): v = recovered[idx] ch = chr(v) if 0x20 <= v <= 0x7E else "." print(f" key[{idx:02X}] = 0x{v:02X} ({ch})")
# build full candidate key if length is None: # fallback if you removed cmp al length = max(recovered.keys()) + 1
key = bytearray(b"A" * length) for idx, v in recovered.items(): if 0 <= idx < length: key[idx] = v
print("\n[+] Candidate key (escaped):") print(escaped_ascii(bytes(key))) print("\n[+] Candidate key (hex):") print(key.hex())並且繼續往下追就可以在 0x42CA50 的地方找到 OEP。
Bypass Anti-debug
現在已經進入解殼完後的 .text 區段執行,但是我發現每次都會在 IDA 執行不久後被強制中斷,所以我猜這邊應該有埋 Anti-debug 稍微追了一下後發現在呼叫 sub_40A441() 我的 IDA 就會跳掉,稍微動態追了一下邏輯,發現在這個 function 在做的事情是在 0x40A450 把一個記憶體位址放到 edi 之後在 0x40A45E 呼叫剛剛放入 edi 的記憶體位址。
.text:0040A441 mov edi, edi.text:0040A443 push ebp.text:0040A444 mov ebp, esp.text:0040A446 push esi.text:0040A447 mov esi, [ebp+arg_0].text:0040A44A cmp esi, [ebp+arg_4].text:0040A44D jz short loc_40A469.text:0040A44F push edi.text:0040A450.text:0040A450 loc_40A450: ; CODE XREF: sub_40A441+25↓j.text:0040A450 mov edi, [esi].text:0040A452 test edi, edi.text:0040A454 jz short loc_40A460.text:0040A456 mov ecx, edi.text:0040A458 call ds:___guard_check_icall_fptr.text:0040A45E call edi.text:0040A460.text:0040A460 loc_40A460: ; CODE XREF: sub_40A441+13↑j.text:0040A460 add esi, 4.text:0040A463 cmp esi, [ebp+arg_4].text:0040A466 jnz short loc_40A450繼續動態分析發現每次 call edi 是 0x401050 的時候就會跳掉,所以也追進去看一下發現他會一個字一個字的把要呼叫的 library 跟 function 解回來之後呼叫,並且這個 0x401050 最後會呼叫 Ntdll 裡面的 zwsetinformationthread 所以我就想說前面 edi 執行 0x401010 沒事那就直接把 0x401050 patch 成 0x401010 然後就不會再被擋了,不過這邊沒有仔細去看 0x401010 在做甚麼,而且後續在分析有時候還是會跳掉並且會被改 byte 我猜還有我沒發現的,只是這部分我就每次發生我就開一個新的檔案去分析。
開始解 FLAG
在把 key 輸入進去之後可以看到畫面跳出一個皮卡丘,然後就甚麼都沒發生。
所以這邊輸入完 key 一樣使用 x32dbg 觀察,發現他最後會回到 0x4121CA,知道卡在哪裡後就可以再跳回 IDA 觀察可以看到 0x4121C4 這邊有個 function call 會去呼叫 kernel32_ReadFile 其實也是在讀取使用者的輸入。
.text:004121C4 call ds:off_417160 ;kernel32_ReadFile.text:004121CA test eax, eax.text:004121CC jz short loc_41222A讀完使用者的輸入之後繼續往下追可以看到有一塊在處理使用者輸入的地方,總共會讀 0x2A 的長度,並且依序把使用者的輸入放到 byte_4216B0 裡面。
.text:00404554 loc_404554: ; CODE XREF: .text:00404571↓j.text:00404554 mov ecx, dword ptr unk_FFFFFFFC[ebp].text:00404557 add ecx, 1.text:0040455A mov dword ptr unk_FFFFFFFC[ebp], ecx.text:0040455D cmp dword ptr unk_FFFFFFFC[ebp], 2Ah ; '*'.text:00404561 jge short loc_404573.text:00404563 call near ptr dword_4095D4+102h.text:00404568 mov edx, dword ptr unk_FFFFFFFC[ebp].text:0040456B mov byte_4216B0[edx], al.text:00404571 jmp short loc_404554.text:00404573 ; ---------------------------------------------------------------------------.text:00404573.text:00404573 loc_404573: ; CODE XREF: .text:00404561↑j.text:00404573 jmp loc_40457E全部放進 byte_4216B0 之後程式執行往下一點點的地方就可以看到程式在 0x40458E 做了一個 function call 並且帶入的參數有 byte_4216B0 也就是使用者的輸入,這個 function 執行後可以很明顯發現 byte_4216B0 的值全部都變了,可以很確定這邊做了某些加密邏輯,不過實在是看到有點累了,所以這邊 AI 幫忙分析這個 0x403990 (dword_403800+190h) 的 function call。
.text:00404587 loc_404587: ; CODE XREF: .text:00404580↑j.text:00404587 push 0Bh.text:00404589 push offset byte_4216B0.text:0040458E call near ptr dword_403800+190h.text:00404593 add esp, 8.text:00404596 xor edi, edi.text:00404598 jz near ptr loc_40459E+2.text:0040459E.text:0040459E loc_40459E: ; CODE XREF: .text:00404598↑j.text:0040459E call near ptr 103678A3h.text:004045A3 test [edx], al那為了證實 AI 是不是在亂說我請他寫一個 python script 實作這段流程,並且給相同的 input 去比對 PokemonGo 在 0x40458E function call 之後的 byte_4216B0 跟 python 出來的結果有沒有一樣,直接說結論看起來確實是一樣的。確定好加密的地方後繼續往下看可以在 0x4045CE 呼叫了 0x401110。
.text:004045C9 loc_4045C9: ; CODE XREF: .text:004045C2↑j.text:004045C9 push offset byte_4216B0.text:004045CE call near ptr dword_401000+110h.text:004045D3 add esp, 4.text:004045D6 cmp eax, 2Ch ; ','.text:004045D9 jnz loc_40467D.text:004045DF xor ebx, ebx.text:004045E1 jz near ptr loc_4045E7+2接下來進去 0x401110 發現一跳進去先卡到一個 int 3 然後就會開始把剛剛計算完的 XXTEA 的 byte_4216B0 比對 byte_420978 這個 table。
.text:00401110 int 3 ; CODE XREF: .text:004045CE↓p.text:00401110 ; Trap to Debugger.text:00401111 mov ebp, esp.text:00401113 sub esp, 8.text:00401116 mov dword_FFFFFFF8[ebp], 0.text:0040111D mov dword_FFFFFFFC[ebp], 0.text:00401124 jmp short loc_40112F.text:00401126 ; ---------------------------------------------------------------------------.text:00401126.text:00401126 loc_401126: ; CODE XREF: .text:loc_401155↓j.text:00401126 mov eax, dword_FFFFFFFC[ebp].text:00401129 add eax, 1.text:0040112C mov dword_FFFFFFFC[ebp], eax.text:0040112F.text:0040112F loc_40112F: ; CODE XREF: .text:00401124↑j.text:0040112F cmp dword_FFFFFFFC[ebp], 2Ch ; ','.text:00401133 jge short loc_401157.text:00401135 mov ecx, [ebp+8].text:00401138 add ecx, dword_FFFFFFFC[ebp].text:0040113B movzx edx, byte ptr [ecx].text:0040113E mov eax, dword_FFFFFFFC[ebp].text:00401141 movzx ecx, byte_420978[eax].text:00401148 cmp edx, ecx.text:0040114A jnz short loc_401155.text:0040114C mov edx, dword_FFFFFFF8[ebp].text:0040114F add edx, 1.text:00401152 mov dword_FFFFFFF8[ebp], edx.text:00401155.text:00401155 loc_401155: ; CODE XREF: .text:0040114A↑j.text:00401155 jmp short loc_401126.text:00401157 ; ---------------------------------------------------------------------------.text:00401157.text:00401157 loc_401157: ; CODE XREF: .text:00401133↑j.text:00401157 mov eax, dword_FFFFFFF8[ebp].text:0040115A mov esp, ebp.text:0040115C pop ebp.text:0040115D retn就當我以為應該差不多解的時候我發現我跑動態準備比對之前我的 byte_4216B0 這個 table 裡面的值又突然都被改過一輪,我就想說這應該就是當初提示說有個大坑的地方,我就在 byte_4216B0 這個 table 跑完 XXTEA 準備 call 0x401110 之前下了硬體斷點 (Read / Write),結果發現剛進入這個比對 function 沒多久這個 bp 就跳起來了,也讓我發現他在比對之前還做了一次 xor 0xE9 我只能說非常過分,全部都 xor 之後才會真正開始比對這個 byte_420978 table。
Solve
#!/usr/bin/env python3import struct
DELTA = 0x213B6EA8N_WORDS = 0x0B # 11 dwords => 44 bytes
# key table @ .data:004209DC (16 bytes = 4 dwords), little-endianKEY_BYTES = bytes([ 0xA6, 0xB7, 0xB6, 0x0E, 0x1E, 0x1D, 0x1E, 0x30, 0xCD, 0xE2, 0x67, 0x1A, 0xA6, 0xAC, 0x99, 0xC1,])
def xxtea_variant_decrypt(ct: bytes, n_words: int = N_WORDS) -> bytes: size = n_words * 4 ct = ct.ljust(size, b"\x00")[:size]
v = list(struct.unpack("<%dI" % n_words, ct)) k = list(struct.unpack("<4I", KEY_BYTES))
n = n_words if n < 2: return ct
q = 6 + (52 // n) s = (-q * DELTA) & 0xFFFFFFFF # reverse start
while q > 0: e = (s >> 2) & 3 y = v[0]
# p = n-1 .. 1 for p in range(n - 1, 0, -1): z = v[p - 1] mx = (((z >> 5) ^ ((y << 2) & 0xFFFFFFFF)) + ((y >> 3) ^ ((z << 4) & 0xFFFFFFFF))) & 0xFFFFFFFF mx ^= ((s ^ y) + (k[(p & 3) ^ e] ^ z)) & 0xFFFFFFFF
v[p] = (v[p] - mx) & 0xFFFFFFFF y = v[p]
# p = 0 (wrap uses z = v[n-1]) z = v[n - 1] p = 0 mx = (((z >> 5) ^ ((y << 2) & 0xFFFFFFFF)) + ((y >> 3) ^ ((z << 4) & 0xFFFFFFFF))) & 0xFFFFFFFF mx ^= ((s ^ y) + (k[(p & 3) ^ e] ^ z)) & 0xFFFFFFFF v[0] = (v[0] - mx) & 0xFFFFFFFF
s = (s + DELTA) & 0xFFFFFFFF q -= 1
return struct.pack("<%dI" % n_words, *v)
def xor_bytes(data: bytes, x: int) -> bytes: return bytes(b ^ x for b in data)
if __name__ == "__main__": #hex_ct = "ef32cc75d3ebfaa4dfac21fcfdf3aa4f1e50ea14c0ee9873590495a36fbd6f9deaa7477bbcdcaf28a70f8bff" hex_ct = "05d442859c21ac962450db8ebf32cb3d347eb752c39b39dbac8c093e8bcad5e1751c3e50b5e67ecb31125867"
ct = bytes.fromhex(hex_ct)
# ✅ pre-xor each byte with 0xE9 before XXTEA decrypt ct = xor_bytes(ct, 0xE9)
pt = xxtea_variant_decrypt(ct) print("PT (raw) :", pt)解完這題的狀態: