REVERSE-北极星混淆
ISCC2026 WriteUp 提交模板
REVERSE-北极星混淆
解题思路
样本运行后只会根据输入走 ok 或 no 分支,字符串区也没有直接暴露 flag,因此不能走“搜字符串”这一条简单路径。继续看导入表,能看到 ptrace、clock_gettime 之类的反分析噪音,但真正和校验相关的部分并不在这些导入上,而在程序运行时展开出来的一批查表数据。
继续跟输入处理和数据段引用,可以看到 data.rel.ro 附近保存了多组指针。IDA 中比较关键的几段代码是:
sub_CF710:统一展开运行时表sub_14CFF0:按固定种子和行指针重建一张完整表sub_14D120:把seed / size / mark折叠成初始状态sub_14D1D0:为表重排生成与size互素的步长sub_14D250:更新 64 位伪随机状态sub_14D2D0:按计算出的行序和列偏移取原始字节
因此这题的核心不是正面硬看混淆控制流,而是把这些运行时表恢复出来,再把整条白盒风格的变换逆回去。
已知程序输入错误时只会走失败分支,而且字符串区没有 flag、密钥或明显的异或密文,所以可以先排除“明文常量比较”和“简单编码还原”这两类思路。
观察导入和校验路径后,可以看到样本更像是把完整算法塞进本地,然后只输出成功或失败结果,因此下一步应该围绕输入经过的查表变换来复现校验逻辑。
这一步的结论是:目标不是找一个已经写在文件里的 flag,而是找出“程序期待的输入如何被映射成目标结果”。

继续跟 data.rel.ro 的引用,可以看到这里不是单一常量,而是一批指针数组。sub_CF710 会连续 5 次调用 sub_14CFF0,把这些指针数组展开到内存中的目标缓冲区。前四次调用分别是:
off_183BC0 -> unk_184C80,大小0x30,seed0x61F29C0D87A453B1off_183BF0 -> unk_184CC0,大小0xA000,seed0xD3B5407C19EA8F26off_183C50 -> unk_18ECC0,大小0x24000,seed0x7A8CE153D604B92Foff_183CC0 -> unk_1B2CC0,大小0x1000,seed0xC94E2F81A73D650B




已知 sub_14CFF0 内部会先调用 sub_14D120 算初始状态,再调用 sub_14D1D0 生成互素步长,然后用 sub_14D250 迭代伪随机值,最后通过 sub_14D2D0 从原始行指针中按规则取字节,所以可以直接按相同逻辑把运行时表复现出来。
这里有一个关键点:这些指针都是虚拟地址,脚本不能直接拿来切文件,必须先把虚拟地址映射回 ELF 文件偏移,再按运行时逻辑去读数据。把这一步补齐之后,就可以把真正参与校验的表拆成四类:
target:最终要命中的目标密文t1:每一轮、每个位置的字节替换表,大小0xA000 = 10 * 16 * 256t2:中间混合层使用的大表,大小0x24000 = 9 * 16 * 0x400t3:最后一轮输出替换表,大小0x1000 = 16 * 256
这几个尺寸本身已经很像白盒 AES:10 轮字节替换、9 轮混合层、最后一轮单独输出替换,再加上 48 字节目标密文,基本可以确定思路应当是“逆查表恢复明文”,而不是继续纠缠外层控制流混淆。
已知最后一轮只做位置相关的替换,所以最容易下手的是先把 t3 反过来。接着,每一轮的 t1 也是逐字节替换,因此同样可以逐位置构造逆表。真正麻烦的是中间的混合层:从 t2 的布局可以看出,它不是单字节映射,而是按列把四个字节混在一起,不能逐字节独立逆回去。
如果直接爆破一列四字节,需要 2^32 级别的尝试,成本过高。已知混合层是四个字节共同参与的查表异或,所以可以把一列拆成前两个字节和后两个字节两半来做 meet-in-the-middle:
- 先枚举前两个字节,记录它们对四个输出字节的贡献
- 再枚举后两个字节,去匹配与目标状态的差值
这样每一列只需要两次 2^16 规模的枚举,复杂度可以落到可接受范围。按轮次从后往前把状态逆回去,最后得到的是带 PKCS#7 padding 的明文;去掉 padding 之后就是最终输入,也就是 flag。
把恢复出的字符串重新喂给原程序,可以进入 ok 分支,说明逆向结果正确。

Exp
#!/usr/bin/env python3
from __future__ import annotations
import math
import struct
from pathlib import Path
from typing import Callable
from elftools.elf.elffile import ELFFile
MASK64 = (1 << 64) - 1
SHIFT_ROWS = (
(0, 5, 10, 15),
(4, 9, 14, 3),
(8, 13, 2, 7),
(12, 1, 6, 11),
)
TARGET_ADDR = 0x183BC0
T1_ADDR = 0x183BF0
T2_ADDR = 0x183C50
T3_ADDR = 0x183CC0
TARGET_SEED = 0x61F29C0D87A453B1
T1_SEED = 0xD3B5407C19EA8F26
T2_SEED = 0x7A8CE153D604B92F
T3_SEED = 0xC94E2F81A73D650B
TARGET_ROWS = 5
T1_ROWS = 11
T2_ROWS = 13
T3_ROWS = 7
ReadBytesAtVa = Callable[[int, int], bytes]
ReadQwordsAtVa = Callable[[int, int], tuple[int, ...]]
def open_elf_image(image_path: Path) -> tuple[ReadBytesAtVa, ReadQwordsAtVa]:
image_blob = image_path.read_bytes()
with image_path.open("rb") as fp:
load_segments = [seg for seg in ELFFile(fp).iter_segments() if seg["p_type"] == "PT_LOAD"]
def va_to_offset(virtual_address: int) -> int:
for segment in load_segments:
segment_start = segment["p_vaddr"]
segment_end = segment_start + segment["p_filesz"]
if segment_start <= virtual_address < segment_end:
return segment["p_offset"] + virtual_address - segment_start
raise ValueError(f"address not mapped: 0x{virtual_address:x}")
def read_bytes_at_va(virtual_address: int, size: int) -> bytes:
file_offset = va_to_offset(virtual_address)
return image_blob[file_offset : file_offset + size]
def read_qwords_at_va(virtual_address: int, count: int) -> tuple[int, ...]:
raw = read_bytes_at_va(virtual_address, count * 8)
return struct.unpack("<" + "Q" * count, raw)
return read_bytes_at_va, read_qwords_at_va
def splitmix64_step(state: int) -> tuple[int, int]:
next_state = (state + 0x9E3779B97F4A7C15) & MASK64
mixed = next_state
mixed = ((mixed ^ (mixed >> 30)) * 0xBF58476D1CE4E5B9) & MASK64
mixed = ((mixed ^ (mixed >> 27)) * 0x94D049BB133111EB) & MASK64
return next_state, (mixed ^ (mixed >> 31)) & MASK64
def derive_seed_state(seed_value: int, table_size: int, marker: int = 0) -> int:
state = (seed_value ^ (table_size * 0xD1342543DE82EF95) ^ (marker * 0x6A09E667F3BCC909)) & MASK64
accumulator = 0
for _ in range(4):
state, value = splitmix64_step(state ^ accumulator)
accumulator ^= value
return accumulator
def choose_coprime_step(modulus: int, seed_value: int) -> int:
step = ((seed_value >> 17) | 1) % modulus or 1
while math.gcd(step, modulus) != 1:
step = (step + 2) % modulus or 1
return step
def expand_runtime_table(
read_bytes_at_va: ReadBytesAtVa,
read_qwords_at_va: ReadQwordsAtVa,
pointer_table_va: int,
table_size: int,
seed_value: int,
row_count: int,
) -> bytes:
row_pointers = read_qwords_at_va(pointer_table_va, row_count)
initial_state = derive_seed_state(seed_value, table_size)
write_step = choose_coprime_step(table_size, initial_state)
write_base = (derive_seed_state(0xA5A5A5A55A5A5A5A ^ initial_state, table_size) >> 19) % table_size
expanded = bytearray(table_size)
rng_state = initial_state
row_step = choose_coprime_step(row_count, seed_value)
row_base = (seed_value >> 23) % row_count
for byte_index in range(table_size):
dst_index = (byte_index * write_step + write_base) % table_size
rng_state, random_word = splitmix64_step(rng_state)
row_index = (byte_index * row_step + row_base) % row_count
row_offset = byte_index // row_count
random_byte = (random_word >> ((byte_index & 7) * 8)) & 0xFF
position_mask = (dst_index * 0xA7 + byte_index * 0x3D) & 0xFF
source_byte = read_bytes_at_va(row_pointers[row_index] + row_offset, 1)[0]
expanded[dst_index] = random_byte ^ position_mask ^ source_byte
return bytes(expanded)
def build_round_inverse_tables(round_table_bytes: bytes, round_count: int) -> list[list[list[int]]]:
inverse_rounds = []
for round_index in range(round_count):
inverse_rows = []
for pos in range(16):
inverse_lookup = [0] * 256
base = round_index * 0x1000 + pos * 0x100
for value in range(256):
inverse_lookup[round_table_bytes[base + value]] = value
inverse_rows.append(inverse_lookup)
inverse_rounds.append(inverse_rows)
return inverse_rounds
def build_last_round_inverse_table(last_round_table_bytes: bytes) -> list[list[int]]:
inverse_rows = []
for pos in range(16):
inverse_lookup = [0] * 256
base = pos * 0x100
for value in range(256):
inverse_lookup[last_round_table_bytes[base + value]] = value
inverse_rows.append(inverse_lookup)
return inverse_rows
def recover_column_bytes(
mix_table_bytes: bytes,
state_bytes: list[int],
round_index: int,
column_index: int,
) -> tuple[int, int, int, int]:
target_column = tuple(state_bytes[4 * column_index + i] for i in range(4))
partial_matches: dict[tuple[int, int, int, int], tuple[int, int]] = {}
round_base = round_index * 0x4000
for a in range(256):
for b in range(256):
forward_half = tuple(
mix_table_bytes[round_base + (4 * column_index + i) * 0x400 + a]
^ mix_table_bytes[round_base + (4 * column_index + i) * 0x400 + 0x100 + b]
for i in range(4)
)
partial_matches[forward_half] = (a, b)
for c in range(256):
for d in range(256):
backward_half = tuple(
mix_table_bytes[round_base + (4 * column_index + i) * 0x400 + 0x200 + c]
^ mix_table_bytes[round_base + (4 * column_index + i) * 0x400 + 0x300 + d]
^ target_column[i]
for i in range(4)
)
if backward_half in partial_matches:
a, b = partial_matches[backward_half]
return a, b, c, d
raise RuntimeError(f"column recovery failed: round={round_index} col={column_index}")
def decrypt_target_block(
cipher_block: bytes,
inverse_round_tables: list[list[list[int]]],
inverse_last_round_table: list[list[int]],
mix_table_bytes: bytes,
) -> bytes:
permuted_positions = [index for group in SHIFT_ROWS for index in group]
last_round_state = [0] * 16
for output_index, byte_value in enumerate(cipher_block):
last_round_state[permuted_positions[output_index]] = inverse_last_round_table[output_index][byte_value]
state_bytes = [inverse_round_tables[9][pos][last_round_state[pos]] for pos in range(16)]
for round_index in range(8, -1, -1):
mixed_state = [0] * 16
for column_index, positions in enumerate(SHIFT_ROWS):
recovered_bytes = recover_column_bytes(mix_table_bytes, state_bytes, round_index, column_index)
for pos, value in zip(positions, recovered_bytes):
mixed_state[pos] = value
state_bytes = [inverse_round_tables[round_index][pos][mixed_state[pos]] for pos in range(16)]
return bytes(state_bytes)
def solve_flag(binary_path: Path) -> str:
read_bytes_at_va, read_qwords_at_va = open_elf_image(binary_path)
target_bytes = expand_runtime_table(read_bytes_at_va, read_qwords_at_va, TARGET_ADDR, 0x30, TARGET_SEED, TARGET_ROWS)
round_table_bytes = expand_runtime_table(read_bytes_at_va, read_qwords_at_va, T1_ADDR, 0xA000, T1_SEED, T1_ROWS)
mix_table_bytes = expand_runtime_table(read_bytes_at_va, read_qwords_at_va, T2_ADDR, 0x24000, T2_SEED, T2_ROWS)
last_round_table_bytes = expand_runtime_table(read_bytes_at_va, read_qwords_at_va, T3_ADDR, 0x1000, T3_SEED, T3_ROWS)
inverse_round_tables = build_round_inverse_tables(round_table_bytes, 10)
inverse_last_round_table = build_last_round_inverse_table(last_round_table_bytes)
plaintext = b"".join(
decrypt_target_block(
target_bytes[block_start : block_start + 16],
inverse_round_tables,
inverse_last_round_table,
mix_table_bytes,
)
for block_start in range(0, len(target_bytes), 16)
)
padding_size = plaintext[-1]
if 0 < padding_size <= 16 and plaintext.endswith(bytes([padding_size]) * padding_size):
plaintext = plaintext[:-padding_size]
return plaintext.decode()
def main() -> None:
script_dir = Path(__file__).resolve().parent
binary_path = script_dir / "traditional_re"
print(solve_flag(binary_path))
if __name__ == "__main__":
main()

浙公网安备 33010602011771号