Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
ReverseMedium

Nononono

CTF writeup for Nononono from Cykor

//Nononono

>tl;dr

  • 64-bit PIE that checks a 10×10×10×10 grid of digits (0–3) encoded as 1000 text lines of 10 chars (plus newlines) → 11,000-byte file.

  • Each line is validated by a run-length rule checker at 0x1c00 using per-axis clues pulled from .rodata.

  • Logged all 4,000 line constraints via GDB script, then solved the nonogram-like puzzle with a constraint propagating backtracker.

  • Produced the valid 1000-line grid, fed it to the binary, got the flag.

Flag: CyKor{31a0488743b8887ab460573b2d9f48ccee3185b48f727666708a95c71970205d}


>Recon and Input Format

  • file → 64-bit PIE, stripped. Size check in main compares read length to 0x2af8 (10 * 1000 + 1000 newlines = 11,000 bytes).

  • Parsing loop (0x14d8–0x1594) walks lines with counter r15 from 0 to 0x3e7 and expects 0x0a terminators at position 10.

  • The index math (0x150e–0x1567) maps the current line number r15 to a base pointer offset rdx = r13 + f(r15), where f(r15) = r15 * 10. So line n corresponds to 10 cells starting at grid[10*n] in a flattened 10⁴ array.

  • Characters are validated as digits 0–3, then stored as bytes.

>The Validator (0x1c00)

  • Arguments: edi = axis (0..3), rdx = pointer to 4 ints (axis coordinates), rsi = pointer to clue bytes (20 bytes: len/color pairs, zero-terminated).

  • Algorithm: Treats the 10 cells along the chosen axis as a run-length encoded line. It enforces alternating blocks of the same color, with optional gaps when color repeats.

  • If runs match exactly and there are no leftover non-zero cells, it returns 1; otherwise 0.

  • Main iterates 1000 times (each axis has 250 lines of 10 cells → 4 axes × 1000 checks). Any failure aborts; all passes drop to success message.

>Extracting Constraints (GDB Script)

I disabled ASLR and logged every call to the validator while forcing it to return success so execution would continue and emit all clues.

Saved as log.gdb:

gdb

set pagination off

set logging file gdb_calls.log

set logging overwrite on

set logging enabled on

set disable-randomization on

file ./chall

set args dummy.txt

break *0x555555555c00

commands

  silent

  set $dptr = (unsigned int*)$rdx

  printf "%u %u %u %u %u ", $edi, $dptr[0], $dptr[1], $dptr[2], $dptr[3]

  set $cptr = (unsigned char*)$rsi

  printf "%02x %02x %02x %02x %02x %02x %02x %02x %02x %02x ", $cptr[0],$cptr[1],$cptr[2],$cptr[3],$cptr[4],$cptr[5],$cptr[6],$cptr[7],$cptr[8],$cptr[9]

  printf "%02x %02x %02x %02x %02x %02x %02x %02x %02x %02x\n", $cptr[10],$cptr[11],$cptr[12],$cptr[13],$cptr[14],$cptr[15],$cptr[16],$cptr[17],$cptr[18],$cptr[19]

  set $eax = 1          # force success

  set $pc = 0x555555555c8d  # skip epilogue and continue

  continue

end

run

quit

Run it with a dummy 1000-line file of zeros to trigger all calls:

bash

gdb -q -x log.gdb

Output: gdb_calls.log containing 4000 lines of axis d0 d1 d2 d3 runs....

>Modeling the Puzzle

  • Grid indices: idx = (((d0*10 + d1)*10 + d2)*10 + d3).

  • Each log line defines one of the 1000 lines: fix the 3 coords, vary the axis coordinate 0..9.

  • Runs are (length, color) pairs until a zero length; colors are 0..3.

  • This is a 4D nonogram: find assignments in {0,1,2,3} satisfying all 1000 line constraints.

>Solver

I wrote a constraint propagating backtracker in solve.py. Key pieces:

  • generate_patterns(runs): enumerates all 10-cell patterns matching the run-length rule (with the required gap when the same color repeats back-to-back).

  • propagate(): prunes cell domains using remaining-valid patterns per line until fixed point.

  • solve(): choose a cell with minimal branching, recurse; returns first consistent solution.

  • write_solution(): outputs the 1000 lines in the same order the binary expects (line n corresponds to (n//100, (n//10)%10, n%10) with varying d3).

Full solver code:

python

import sys

from collections import defaultdict

  

LOG_PATH = "gdb_calls.log"

GRID_SIZE = 10

VALUES = {0, 1, 2, 3}

  

def parse_log(path: str):

    lines = []

    with open(path, "r") as f:

        for raw in f:

            parts = raw.strip().split()

            if len(parts) != 25:

                continue

            axis = int(parts[0])

            coords = tuple(int(x) for x in parts[1:5])

            bytes_list = [int(x, 16) for x in parts[5:]]

            runs = []

            for i in range(0, 20, 2):

                length = bytes_list[i]

                color = bytes_list[i + 1]

                if length == 0:

                    break

                runs.append((length, color))

            lines.append((axis, coords, runs))

    return lines

  

def encode_index(c0: int, c1: int, c2: int, c3: int) -> int:

    return ((c0 * GRID_SIZE + c1) * GRID_SIZE + c2) * GRID_SIZE + c3

  

def min_remaining(runs, start_idx, prev_color):

    total = 0

    last_color = prev_color

    for length, color in runs[start_idx:]:

        gap = 1 if color == last_color else 0

        total += gap + length

        last_color = color

    return total

  

def generate_patterns(runs):

    if not runs:

        return [[0] * GRID_SIZE]

    patterns = []

    def dfs(idx, acc, prev_color):

        if idx == len(runs):

            if len(acc) <= GRID_SIZE:

                patterns.append(acc + [0] * (GRID_SIZE - len(acc)))

            return

        length, color = runs[idx]

        min_gap = 1 if (prev_color == color) else 0

        remaining_min = min_remaining(runs, idx + 1, color)

        max_gap = GRID_SIZE - len(acc) - length - remaining_min

        if max_gap < min_gap:

            return

        for gap in range(min_gap, max_gap + 1):

            new_acc = acc + [0] * gap + [color] * length

            dfs(idx + 1, new_acc, color)

    dfs(0, [], -1)

    return patterns

  

def build_line_data(log_lines):

    line_cells = []

    line_patterns = []

    for axis, coords, runs in log_lines:

        base = list(coords)

        cells = []

        for i in range(GRID_SIZE):

            c = base.copy()

            c[axis] = i

            cells.append(encode_index(*c))

        line_cells.append(tuple(cells))

        line_patterns.append(generate_patterns(runs))

    return line_cells, line_patterns

  

def propagate(domains, line_cells, line_patterns, cell_to_lines):

    num_lines = len(line_cells)

    to_check = set(range(num_lines))

    while to_check:

        line_idx = to_check.pop()

        cells = line_cells[line_idx]

        patterns = line_patterns[line_idx]

        valid_patterns = []

        for pat in patterns:

            if all(pat[i] in domains[cells[i]] for i in range(GRID_SIZE)):

                valid_patterns.append(pat)

        if not valid_patterns:

            return None

        possible = [set() for _ in range(GRID_SIZE)]

        for pat in valid_patterns:

            for i, val in enumerate(pat):

                possible[i].add(val)

        for i, cell in enumerate(cells):

            new_dom = domains[cell] & possible[i]

            if not new_dom:

                return None

            if new_dom != domains[cell]:

                domains[cell] = new_dom

                to_check.update(cell_to_lines[cell])

    return domains

  

def solved(domains):

    return all(len(d) == 1 for d in domains)

  

def choose_cell(domains):

    best_idx = None

    best_size = None

    for idx, dom in enumerate(domains):

        size = len(dom)

        if size > 1 and (best_size is None or size < best_size):

            best_size = size

            best_idx = idx

    return best_idx

  

def solve(domains, line_cells, line_patterns, cell_to_lines):

    domains = propagate(domains, line_cells, line_patterns, cell_to_lines)

    if domains is None:

        return None

    if solved(domains):

        return domains

    cell = choose_cell(domains)

    if cell is None:

        return domains

    for val in list(domains[cell]):

        next_domains = [set(d) for d in domains]

        next_domains[cell] = {val}

        result = solve(next_domains, line_cells, line_patterns, cell_to_lines)

        if result is not None:

            return result

    return None

  

def write_solution(domains, path):

    with open(path, "w") as f:

        for n in range(1000):

            d0 = n // 100

            d1 = (n // 10) % 10

            d2 = n % 10

            line_vals = []

            for d3 in range(10):

                idx = encode_index(d0, d1, d2, d3)

                val_set = domains[idx]

                if len(val_set) != 1:

                    raise RuntimeError("Unresolved cell in output")

                val = next(iter(val_set))

                line_vals.append(str(val))

            f.write("".join(line_vals) + "\n")

  

def main():

    log_lines = parse_log(LOG_PATH)

    line_cells, line_patterns = build_line_data(log_lines)

    cell_to_lines = defaultdict(set)

    for i, cells in enumerate(line_cells):

        for cell in cells:

            cell_to_lines[cell].add(i)

    domains = [set(VALUES) for _ in range(GRID_SIZE ** 4)]

    solution = solve(domains, line_cells, line_patterns, cell_to_lines)

    if solution is None:

        print("No solution found", file=sys.stderr)

        sys.exit(1)

    print("Solved")

    write_solution(solution, "solution.txt")

  

if __name__ == "__main__":

    main()

>Solving

  1. Generate log: gdb -q -x log.gdbgdb_calls.log.

  2. Run solver: python3 solve.py → prints Solved, writes solution.txt (1000 lines).

  3. Validate: ./chall solution.txt → prints Correct! and the flag.

>Notes & Pitfalls

  • Breakpoint address must be absolute with ASLR off; otherwise misses the validator.

  • The index math is just line_index * 10; discovering that avoided off-by-one crashes when writing output.

  • Patterns generation requires a gap of 1 when the same color repeats consecutively; missing this causes contradictions.

>Result

./chall solution.txtCorrect! and the flag above. Easy to reproduce: just rerun the steps with the included scripts.