ISCC2026 WriteUp
Reverse-北极星混淆
解题思路
### 1. 文件基础分析
拿到题目压缩包,解压得到一个 ELF 文件:
64-bit ELF,开 PIE,符号表已去除。用 `pyelftools` 解析内部结构:
| 段名 | 起始 VA | 大小 |
| —— | ———- | ———- |
| .text | `0x40e0` | `0x14954b` |
| .rodata| `0x14e000` | `0x33a64` |
| .data | `0x184000` | `0xc40` |
| .bss | `0x184c40` | `0x33770` |
导入表很小,总共 12 个外部符号,说明大多数逻辑被静态嵌入或混淆在二进制内部:
__libc_start_main, __cxa_finalize, __errno_location
write, read, open, close, memset, memcpy
ptrace, getppid, clock_gettime
入口点 `0x40e0` 通过 `__libc_start_main` 调用实际主逻辑,其第一个参数 RDI 即 main 地址:`0x41d0`。
### 2. 反调试与执行环境搭建
导入表中 `ptrace`、`getppid`、`clock_gettime` 这几个函数表明程序有反调试机制。跟踪发现它会:
调用 `ptrace(PTRACE_TRACEME, …)` 检测是否被调试;
打开并读取 `/proc/self/status`,检查 `TracerPid` 字段;
读 `/proc/<ppid>/comm` 检测父进程名;
用 `clock_gettime` 做时间反调试。
如果直接在 WSL 中跑错误输入,输出 `no`,说明反调试某步触发了错误分支。解决方案是在 Unicorn 模拟器中为关键系统调用设置桩:
ptrace → 返回 0
getppid → 返回 1234
open 读取 status → 返回虚拟 fd,内容中 TracerPid 置为 0
open 读取 comm → 返回虚拟 fd,内容为 "bash\\n"
clock_gettime → 返回固定时间戳
通过这些桩函数,程序在模拟环境中稳定地进入验证逻辑。
### 3. 控制流混淆模式分析
二进制的主体充满了类似下面的混淆模式:
call some_gadget
; 这里返回到的地址并非 call 的下一条指令
; 因为 some_gadget 内部修改了栈上的返回地址
典型 gadget 位于 `0x1b42c`:
0x1b42c: ... ; 一些指令
0x1b46b: add qword [rax+0x58], 0x29 ; 修改栈上的返回地址
0x1b481: ret ; 跳到跳过垃圾指令的位置
这使得静态线性反汇编看到的指令流杂乱无章,但实际执行路径是连续有效的。因此本题不适合纯静态硬读,更有效的方式是用 Unicorn 动态跟踪,在关键点 Hook,记录真实执行轨迹。
### 4. 定位关键辅助函数
尽管混淆严重,仍然能从 `.text` 中识别出几个结构规整的辅助函数:
| 地址 | 功能 |
| ——— | —————————– |
| `0x21870` | 去除输入行尾 `\n \r 空格 \t` |
| `0xcf500` | 文件读取(open→read→close) |
| `0xcf5b0` | 字符串子串匹配 |
| `0xcf680` | 获取时间(clock_gettime) |
| `0xcf6c0` | 32 位循环左移 rol32 |
| `0xcf710` | 白盒表初始化 |
其中 `0xcf6c0` 的 `rol32` 实现非常典型:
mov eax, esi
and eax, 0x1f
; ...
shl eax, cl
shr edx, cl
or eax, ecx
### 5. 白盒表解码
表初始化函数 `0xcf710` 调用 `0x14cff0` 五次,从 `.rodata` 中的加密数据生成五张工作表:
| 表 | 稀疏源表地址 | 解码后地址 | 大小 | 解码种子 |
| —- | ———— | ———- | ——– | —————— |
| 目标 | `0x183bc0` | `0x184c80` | `0x30` | `0x61f29c0d87a453b1` |
| A | `0x183bf0` | `0x184cc0` | `0xa000` | `0xd3b5407c19ea8f26` |
| B | `0x183c50` | `0x18ecc0` | `0x24000`| `0x7a8ce153d604b92f` |
| C | `0x183cc0` | `0x1b2cc0` | `0x1000` | `0xc94e2f81a73d650b` |
| D | `0x183d00` | `0x1b3cc0` | `0x4000` | `0x2f6dba904c18e357` |
解码函数 `0x14cff0` 的核心流程:
1. 将种子、表长度、flag 混合生成初始状态;
2. 计算一个与表长度互素的遍历步长;
3. 利用 SplitMix64(地址 `0x14d250`)生成伪随机序列;
4. 从稀疏指针表取字节,与随机数 XOR,按置换顺序写入目标缓冲区。
SplitMix64 的魔数常量在反汇编中非常容易识别:
0x9e3779b97f4a7c15 (golden ratio φ)
0xbf58476d1ce4e5b9
0x94d049bb133111eb
第一张表(48 字节)解码结果即为最终的比较目标密文:
c6bae55d0275ac4f4a5da18fda143fa9
486079b58b82039a59c160821547e384
25af09ae621b3fe309e45f929f64002c
### 6. 动态 Hook 定位比较点
Hook `write` 系统调用,观察到错误输出路径:
write(1, "no\\n", 3)
沿调用栈回溯,找到最终的差异累积点位于 `0xc3d8d`:
0xc3d8d: mov [rsi+0x57], rcx ; 写入比较差异值
0xc3d95: lea rdi, [rbp-0x890]
0xc3d9c: ret
这里每一轮会将 `encrypt(in[i]) ^ target[i]` 写入 `RCX`。在 Hook 中提取 `RCX & 0xff` 并清零,即可逐字节收集非累积差异。
对探针输入 `ISCC{AAAA…AA}`(40字节),得到 48 字节差异:
72 b6 fa 81 0b f0 4a 44 b9 6e 4e 51 e3 5f b2 89
1c 05 ab db 1d bb b7 9a 67 49 f7 27 15 ef 53 64
0d 1b 4d 1f 65 b7 53 1e b3 6b 6d 1d 84 d3 a8 13
### 7. 分组模式与 Padding 确认
通过变动不同输入字节观察差异变化范围:
输入第 0~15 字节仅影响差异第 0~15 字节;
输入第 16~31 字节仅影响差异第 16~31 字节;
输入第 32~39 字节影响差异第 32~47 字节。
证明算法是 16 字节 ECB 分组模式。
输入 40 字节,输出 48 字节密文,符合 PKCS#7 padding:`40 + pad(8) → 48`,padding 字节均为 `0x08`。
### 8. 白盒网络结构还原
通过 Hook 各表的访问序列,绘制出一轮内的查表模式:
A 表: 16 次单字节查表(输入编码/S-Box 编码)
B 表: 64 次单字节查表(T-Box / MixColumns 合并表)
9 轮结构完全一致,最后一轮略有不同:
轮 0~8: A → B(含 Mix) → 下一轮
轮 9: A → C(末轮输出编码) → 与目标比较
对照 AES 标准 ShiftRows 排列,反推出列分组关系:
group[0] = [0, 5, 10, 15]
group[1] = [4, 9, 14, 3]
group[2] = [8, 13, 2, 7]
group[3] = [12, 1, 6, 11]
单轮加密逻辑还原为:
for each round r (0..8):
for each byte i:
enc[i] = A_table[(r*16 + i)*256 + state[i]]
for each column col:
for each row in 0..3:
out_pos = col*4 + row
next[out_pos] = XOR over k in 0..3:
B_table[(r*64 + out_pos*4 + k)*256 + enc[group[col][k]]]
末轮也需要应用 ShiftRows 排列才能得到正确的输出。
### 9. Meet-in-the-Middle 解密策略
解密即求 `encrypt(明文) = 目标密文`,采用逐块逆推。
逆末轮:每字节输出为单表置换,直接建反表查回。
逆混合层:每列 4 字节输出由 4 个输入字节 XOR 得到:
Y[row] = T0[x0] \^ T1[x1] \^ T2[x2] \^ T3[x3]
暴力枚举 4 字节为 `256^4 = 2^32`,开销太大。采用 MITM 降复杂度:
1. 枚举 `(x0, x1)`,算出对 4 字节输出的贡献,存入哈希表;
2. 枚举 `(x2, x3)`,计算 `Y ^ T2[x2] ^ T3[x3]` 并在哈希表中查找;
3. 命中即得完整 4 元组。
复杂度降为 `256^2 × 2 ≈ 1.3 × 10^5` 次查表,完全可行。每轮 4 列,逆推 9 轮即可还原一整块 16 字节明文。
### 10. 解密得 Flag
对 48 字节目标密文分 3 块分别解密:
块 0: c6bae55d0275ac4f 4a5da18fda143fa9
块 1: 486079b58b82039a 59c160821547e384
块 2: 25af09ae621b3fe3 09e45f929f64002c
解密得到:
49 53 43 43 7b 72 65 61 6c 5f 77 68 69 74 65 62 ISCC{real_whiteb
6f 78 5f 41 45 53 5f 77 69 74 68 5f 70 6f 6c 61 ox_AES_with_pola
72 69 73 5f 6d 69 72 7d 08 08 08 08 08 08 08 08 ris_mir}........
去掉尾部的 8 个 `0x08` padding 字节:
ISCC{real_whitebox_AES_with_polaris_mir}
### 11. 结果验证
在 Unicorn 中加载反调试桩函数,向程序输入解出的 flag:
输入: ISCC{real_whitebox_AES_with_polaris_mir}\\n
输出: ok
比对结果: iszero 参数 = 0
验证通过。
Exp
from elftools.elf.elffile import ELFFile
import struct, math
ELF_PATH = "re"
M64 = (1 << 64) - 1
def rd_u64(data):
return struct.unpack("<Q", data)[0]
class BinImg:
def __init__(self, path):
self._raw = open(path, "rb").read()
with open(path, "rb") as fh:
elf = ELFFile(fh)
self._maps = [
(s["p_vaddr"], s["p_offset"], s["p_filesz"])
for s in elf.iter_segments()
if s["p_type"] == "PT_LOAD"
]
def _off(self, va):
for vaddr, offset, fsize in self._maps:
if vaddr <= va < vaddr + fsize:
return offset + va - vaddr
raise LookupError(f"VA {va:\#x} 不在文件映射范围内")
def get(self, va, size):
o = self._off(va)
return self._raw[o:o + size]
def get64(self, va):
return rd_u64(self.get(va, 8))
def sm64(state):
"""SplitMix64 单步迭代"""
s = (state + 0x9E3779B97F4A7C15) & M64
z = s
z = ((z \^ (z >> 30)) * 0xBF58476D1CE4E5B9) & M64
z = ((z \^ (z >> 27)) * 0x94D049BB133111EB) & M64
return s, (z \^ (z >> 31)) & M64
def rand_seed(orig_seed, length, flag=0):
"""种子混合"""
v = (orig_seed
\^ ((length * 0xD1342543DE82EF95) & M64)
\^ ((flag * 0x6A09E667F3BCC909) & M64)) & M64
a = 0
for _ in range(4):
v, r = sm64(v \^ a)
a = (a \^ r) & M64
return a
def find_step(n, seed):
"""计算与 n 互素的遍历步长"""
s = ((seed >> 17) | 1) % n
if s == 0:
s = 1
while math.gcd(s, n) != 1:
s = (s + 2) % n
if s == 0:
s = 1
return s
def ptr_pick(img, base, width, seed, idx):
"""从稀疏指针表中取一个字节"""
step = find_step(width, seed)
off = (seed >> 23) % width
pi = (idx * step + off) % width
ptr = img.get64(base + pi * 8)
return img.get(ptr + idx // width, 1)[0]
def table_decode(img, src, length, seed, width, flag=0):
"""解码一张白盒表"""
rng = rand_seed(seed, length, flag)
step = find_step(length, rng)
r2 = (rand_seed(0xA5A5A5A55A5A5A5A \^ rng, length, 0) >> 19) % length
st = rng
buf = bytearray(length)
for i in range(length):
dst = (i * step + r2) % length
st, rv = sm64(st)
b = (rv >> ((i & 7) * 8)) & 0xFF
b \^= (dst * 0xA7 + i * 0x3D) & 0xFF
b \^= ptr_pick(img, src, width, seed, i)
buf[dst] = b
return bytes(buf)
def load_all_tables(img):
"""加载全部五张白盒表"""
tgt = table_decode(img, 0x183BC0, 0x30, 0x61F29C0D87A453B1, 5)
enc = table_decode(img, 0x183BF0, 0xA000, 0xD3B5407C19EA8F26, 11)
mix = table_decode(img, 0x183C50, 0x24000, 0x7A8CE153D604B92F, 13)
fout = table_decode(img, 0x183CC0, 0x1000, 0xC94E2F81A73D650B, 7)
aux = table_decode(img, 0x183D00, 0x4000, 0x2F6DBA904C18E357, 9)
return tgt, enc, mix, fout
# ShiftRows 列分组
COLS = [
(0, 5, 10, 15),
(4, 9, 14, 3),
(8, 13, 2, 7),
(12, 1, 6, 11),
]
# 末轮也需 ShiftRows
LAST_PERM = [p for g in COLS for p in g]
def wb_enc_one(blk, enc_t, mix_t, fout_t):
"""白盒AES加密单块(16字节 → 16字节)"""
s = list(blk)
for rnd in range(9):
e = [enc_t[(rnd * 16 + i) * 256 + s[i]] for i in range(16)]
nxt = [0] * 16
for ci, grp in enumerate(COLS):
for ri in range(4):
pos = ci * 4 + ri
v = 0
for ki, sp in enumerate(grp):
tid = rnd * 64 + pos * 4 + ki
v \^= mix_t[tid * 256 + e[sp]]
nxt[pos] = v
s = nxt
e = [enc_t[(9 * 16 + i) * 256 + s[i]] for i in range(16)]
return bytes(fout_t[j * 256 + e[LAST_PERM[j]]] for j in range(16))
def make_last_inv(enc_t, fout_t):
"""构建末轮逆映射"""
invs = []
for op, sp in enumerate(LAST_PERM):
m = {}
for x in range(256):
y = fout_t[op * 256 + enc_t[(9 * 16 + sp) * 256 + x]]
m[y] = x
if len(m) != 256:
raise RuntimeError(f"末轮位置 {op} 不是双射")
invs.append((sp, m))
return invs
def inv_col(rnd, ci, target4, enc_t, mix_t, memo):
"""MITM 逆解一列(4 字节梯度)"""
k = (rnd, ci, target4)
if k in memo:
return memo[k]
grp = COLS[ci]
base = rnd * 64 + ci * 4
# 预计算每行贡献
contrib = [[], [], [], []]
for ki, sid in enumerate(grp):
for x in range(256):
val = enc_t[(rnd * 16 + sid) * 256 + x]
contrib[ki].append([
mix_t[(base * 4 + ri * 4 + ki) * 256 + val]
for ri in range(4)
])
# LHS: x0 ⊕ x1
lhs = {}
for x0 in range(256):
for x1 in range(256):
lhs[bytes(contrib[0][x0][r] \^ contrib[1][x1][r] for r in range(4))] = (x0, x1)
# RHS: 搜索 x2 ⊕ x3 = target4 ⊕ lhs
for x2 in range(256):
for x3 in range(256):
need = bytes(target4[r] \^ contrib[2][x2][r] \^ contrib[3][x3][r] for r in range(4))
if need in lhs:
ans = lhs[need] + (x2, x3)
memo[k] = ans
return ans
raise RuntimeError(f"轮 {rnd} 列 {ci} 无解")
def wb_dec_one(blk, enc_t, mix_t, fout_t):
"""白盒AES解密单块"""
last_inv = make_last_inv(enc_t, fout_t)
s = [0] * 16
for op, b in enumerate(blk):
sp, m = last_inv[op]
s[sp] = m[b]
memo = {}
for rnd in range(8, -1, -1):
prev = [0] * 16
for ci, grp in enumerate(COLS):
y4 = bytes(s[ci * 4:ci * 4 + 4])
vals = inv_col(rnd, ci, y4, enc_t, mix_t, memo)
for ki, sid in enumerate(grp):
prev[sid] = vals[ki]
s = prev
return bytes(s)
def strip_pkcs7(data):
"""去除 PKCS\#7 padding"""
n = data[-1]
if not (1 <= n <= 16) or data[-n:] != bytes([n]) * n:
raise ValueError("padding 校验失败")
return data[:-n]
def main():
img = BinImg(ELF_PATH)
tgt, enc, mix, fout = load_all_tables(img)
# 分块解密
pt = b"".join(
wb_dec_one(tgt[i:i + 16], enc, mix, fout)
for i in range(0, len(tgt), 16)
)
flag = strip_pkcs7(pt).decode()
# 结果验证:用探针输入确认加密网络正确
probe = b"ISCC" + b"A" * 35
pad = probe + b"\\x08" * 8
ct = b"".join(
wb_enc_one(pad[i:i + 16], enc, mix, fout)
for i in range(0, len(pad), 16)
)
ref = bytes.fromhex(
"72b6fa810bf04a44b96e4e51e35fb289"
"1c05abdb1dbbb79a6749f72715ef5364"
"0d1b4d1f65b7531eb36b6d1d84d3a813"
)
assert bytes(a \^ b for a, b in zip(ct, tgt)) == ref, "加密网络验证失败"
print("验证通过 ✓")
print(flag)
if __name__ == "__main__":
main()
最终 flag:
ISCC{real_whitebox_AES_with_polaris_mir}
评论