1165 字
6 分钟
New Year CTF 2026 逆向WP
给XDSEC的新年小比赛出了两道逆向题
nonogram
底层逻辑是把flag画成了数织,为了保证解唯一加了个crc32的check(感觉很意义不明,但是不知道怎么控制解唯一)
埋了个坑,让数织的线索在.init里加载。下面是AI写的解题脚本,把数织画出来之后OCR或者用人眼看。
#!/usr/bin/env python3import argparseimport subprocessfrom functools import lru_cache
WIDTH = 0x113 # 275HEIGHT = 5BIT_BUDGET = 0xFE5 # 4069 bits consumed by verifier
# Derived from reversing nonogram_gateCIPHER_FILE_OFFSET = 0x2020CIPHER_LEN = 0x1FD # 509XOR_STRIDE = 0x3D
def extract_cipher(path: str) -> bytes: with open(path, "rb") as f: blob = f.read() end = CIPHER_FILE_OFFSET + CIPHER_LEN if end > len(blob): raise ValueError("binary too small for expected encrypted clue region") return blob[CIPHER_FILE_OFFSET:end]
def decode_clue_blob(cipher: bytes) -> bytes: tmp = bytearray(CIPHER_LEN) out = bytearray(CIPHER_LEN) for i, c in enumerate(cipher): tmp[i] = (c ^ ((i * XOR_STRIDE - 0x0D) & 0xFF)) & 0xFF for i, v in enumerate(tmp): out[(i * 7) % CIPHER_LEN] = v return bytes(out)
class BitReader: def __init__(self, data: bytes): self.data = data self.bitpos = 0
def read(self, nbits: int) -> int: val = 0 for _ in range(nbits): if self.bitpos >= len(self.data) * 8: raise ValueError("bitstream underflow") byte = self.data[self.bitpos // 8] shift = 7 - (self.bitpos % 8) bit = (byte >> shift) & 1 val = (val << 1) | bit self.bitpos += 1 return val
def parse_clues(decoded: bytes): br = BitReader(decoded) row_clues = [] for _ in range(HEIGHT): cnt = br.read(7) row_clues.append([br.read(3) for _ in range(cnt)])
col_clues = [] for _ in range(WIDTH): cnt = br.read(7) col_clues.append([br.read(3) for _ in range(cnt)])
if br.bitpos != BIT_BUDGET: raise ValueError(f"unexpected clue bit count: {br.bitpos} != {BIT_BUDGET}") return row_clues, col_clues
def runs_from_bits(bits): runs = [] run = 0 for b in bits: if b: run += 1 elif run: runs.append(run) run = 0 if run: runs.append(run) return runs
def column_candidates(col_clues): cand = [] for clue in col_clues: valid = [] for mask in range(1 << HEIGHT): bits = [1 if (mask >> r) & 1 else 0 for r in range(HEIGHT)] if runs_from_bits(bits) == clue: valid.append(bits) if not valid: raise ValueError(f"no candidate column for clue {clue}") cand.append(valid) return cand
def row_transition_table(row_clues): tables = [] starts = [] accepts = [] for runs in row_clues: k = len(runs) state_id = {} states = [] trans = []
def intern(st): if st in state_id: return state_id[st] idx = len(states) state_id[st] = idx states.append(st) trans.append([-1, -1]) return idx
start = intern((0, 0, 0)) # (next_run_idx, rem_in_current_run, must_gap_zero) q = [start] qi = 0 while qi < len(q): sid = q[qi] qi += 1 run_idx, rem, must_gap = states[sid] for bit in (0, 1): nxt = None if rem > 0: if bit == 1: rem2 = rem - 1 if rem2 == 0: if run_idx == k - 1: nxt = (k, 0, 0) else: nxt = (run_idx + 1, 0, 1) else: nxt = (run_idx, rem2, 0) else: if run_idx == k: if bit == 0: nxt = (k, 0, 0) else: if must_gap: if bit == 0: nxt = (run_idx, 0, 0) else: if bit == 0: nxt = (run_idx, 0, 0) else: rem2 = runs[run_idx] - 1 if rem2 == 0: if run_idx == k - 1: nxt = (k, 0, 0) else: nxt = (run_idx + 1, 0, 1) else: nxt = (run_idx, rem2, 0) if nxt is not None: nid = intern(nxt) trans[sid][bit] = nid if nid >= len(q): q.append(nid)
accept_set = {sid for sid, (ri, rem, _) in enumerate(states) if ri == k and rem == 0} tables.append(trans) starts.append(start) accepts.append(accept_set) return tables, starts, accepts
def solve_board(row_clues, col_clues): col_cands = column_candidates(col_clues) trans, starts, accepts = row_transition_table(row_clues)
@lru_cache(maxsize=None) def dfs(col, s0, s1, s2, s3, s4): states = (s0, s1, s2, s3, s4) if col == WIDTH: ok = all(states[r] in accepts[r] for r in range(HEIGHT)) return tuple() if ok else None
for bits in col_cands[col]: nxt = [] valid = True for r in range(HEIGHT): ns = trans[r][states[r]][bits[r]] if ns < 0: valid = False break nxt.append(ns) if not valid: continue tail = dfs(col + 1, nxt[0], nxt[1], nxt[2], nxt[3], nxt[4]) if tail is not None: return (tuple(bits),) + tail return None
path = dfs(0, starts[0], starts[1], starts[2], starts[3], starts[4]) if path is None: raise ValueError("no satisfying board found")
board = [[0] * WIDTH for _ in range(HEIGHT)] for c, col_bits in enumerate(path): for r in range(HEIGHT): board[r][c] = col_bits[r] return board
def board_to_submission_hex(board): bits = [] for r in range(HEIGHT): bits.extend(board[r]) bits.append(0) # parser expects one extra trailing bit from 344th nibble; must be zero
if len(bits) != 0x560: # 1376 raise ValueError("unexpected bit length for hex encoding")
out = [] for i in range(0, len(bits), 4): nib = (bits[i] << 3) | (bits[i + 1] << 2) | (bits[i + 2] << 1) | bits[i + 3] out.append(format(nib, "x")) return "".join(out)
def pretty(board): return "\n".join("".join("#" if board[r][c] else "." for c in range(WIDTH)) for r in range(HEIGHT))
def main(): ap = argparse.ArgumentParser(description="Solve nonogram_gate by decoding and solving its nonogram clues.") ap.add_argument("binary", nargs="?", default="./nonogram_gate", help="path to challenge binary") ap.add_argument("--run", action="store_true", help="run the binary with computed hex") args = ap.parse_args()
cipher = extract_cipher(args.binary) decoded = decode_clue_blob(cipher) row_clues, col_clues = parse_clues(decoded) board = solve_board(row_clues, col_clues) answer_hex = board_to_submission_hex(board)
print(answer_hex) print() print(pretty(board))
if args.run: p = subprocess.run( [args.binary], input=(answer_hex + "\n").encode(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, ) print() print(p.stdout.decode(errors="replace"))
if __name__ == "__main__": main()starless_c
我说这是原题你耳朵🐲吗
底层逻辑就是WASD推箱子,F检测结果,但是这里推的是内存块。由于箱子很少,写一个状压BFS可以很快跑出解。我这里就偷懒直接贴出题人源码了
from copy import deepcopy
puzzle = """#########..######.....###oo.oo###..#o..##......#########""".strip().split("\n")puzzle = [list(x) for x in puzzle]
pos = (1, 1)
inp = ""history = []while True: print(inp) for (i, r) in enumerate(puzzle): for (j, v) in enumerate(r): x = v if (i, j) == pos: x = "x" print(x, end="") print() for c in input("> "): if c == "z": (puzzle, pos, inp) = history.pop() continue elif c in ("w", "a", "s", "d"): (dr, dc) = {"w": (-1, 0), "s": (1, 0), "a": (0, -1), "d": (0, 1)}[c] (nr, nc) = (pos[0] + dr, pos[1] + dc) hist = (deepcopy(puzzle), pos, inp) if puzzle[nr][nc] == "#": continue if puzzle[nr][nc] == "o": (nnr, nnc) = (nr + dr, nc + dc) if puzzle[nnr][nnc] != ".": continue puzzle[nnr][nnc] = "o" puzzle[nr][nc] = "." pos = (nr, nc) history.append(hist) inp += c else: print("unknown") New Year CTF 2026 逆向WP
https://sandt3a.github.io/posts/new-year-ctf-2026-re-wp/