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,而是找出“程序期待的输入如何被映射成目标结果”。

image.png

继续跟 data.rel.ro 的引用,可以看到这里不是单一常量,而是一批指针数组。sub_CF710 会连续 5 次调用 sub_14CFF0,把这些指针数组展开到内存中的目标缓冲区。前四次调用分别是:

  • off_183BC0 -> unk_184C80,大小 0x30,seed 0x61F29C0D87A453B1
  • off_183BF0 -> unk_184CC0,大小 0xA000,seed 0xD3B5407C19EA8F26
  • off_183C50 -> unk_18ECC0,大小 0x24000,seed 0x7A8CE153D604B92F
  • off_183CC0 -> unk_1B2CC0,大小 0x1000,seed 0xC94E2F81A73D650B

image.png

image.png

image.png

image.png

已知 sub_14CFF0 内部会先调用 sub_14D120 算初始状态,再调用 sub_14D1D0 生成互素步长,然后用 sub_14D250 迭代伪随机值,最后通过 sub_14D2D0 从原始行指针中按规则取字节,所以可以直接按相同逻辑把运行时表复现出来。

这里有一个关键点:这些指针都是虚拟地址,脚本不能直接拿来切文件,必须先把虚拟地址映射回 ELF 文件偏移,再按运行时逻辑去读数据。把这一步补齐之后,就可以把真正参与校验的表拆成四类:

  • target:最终要命中的目标密文
  • t1:每一轮、每个位置的字节替换表,大小 0xA000 = 10 * 16 * 256
  • t2:中间混合层使用的大表,大小 0x24000 = 9 * 16 * 0x400
  • t3:最后一轮输出替换表,大小 0x1000 = 16 * 256

这几个尺寸本身已经很像白盒 AES:10 轮字节替换、9 轮混合层、最后一轮单独输出替换,再加上 48 字节目标密文,基本可以确定思路应当是“逆查表恢复明文”,而不是继续纠缠外层控制流混淆。

已知最后一轮只做位置相关的替换,所以最容易下手的是先把 t3 反过来。接着,每一轮的 t1 也是逐字节替换,因此同样可以逐位置构造逆表。真正麻烦的是中间的混合层:从 t2 的布局可以看出,它不是单字节映射,而是按列把四个字节混在一起,不能逐字节独立逆回去。

如果直接爆破一列四字节,需要 2^32 级别的尝试,成本过高。已知混合层是四个字节共同参与的查表异或,所以可以把一列拆成前两个字节和后两个字节两半来做 meet-in-the-middle:

  • 先枚举前两个字节,记录它们对四个输出字节的贡献
  • 再枚举后两个字节,去匹配与目标状态的差值

这样每一列只需要两次 2^16 规模的枚举,复杂度可以落到可接受范围。按轮次从后往前把状态逆回去,最后得到的是带 PKCS#7 padding 的明文;去掉 padding 之后就是最终输入,也就是 flag。

把恢复出的字符串重新喂给原程序,可以进入 ok 分支,说明逆向结果正确。

image.png

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()
posted @ 2026-05-19 16:29  MillionMind  阅读(26)  评论(0)    收藏  举报