Skip to content

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

BACK TO INTEL
CryptoMedium

Andor

CTF writeup for Andor from heroCTF

//Andor

>TL;DR

  • The service leaks two halves of the flag with complementary bitwise operations each run:

  - a = flag[:len//2] & k[:len//2]  (AND with a fresh random key each request)

  - o = flag[len//2:] | k[len//2:]  (OR with a fresh random key each request)

  • Collecting many independent samples allows recovering each bit of the flag with negligible error probability:

  - First half: OR-accumulation of all observed a values yields flag bits for positions that are 1; zeros stay zero.

  - Second half: AND-accumulation of all observed o values yields flag bits for positions that are 1; zeros tend to remain zero.

  • Result: Using 128–512 samples I reconstructed the remote flag: Hero{y0u_4nd_5l33p_0r_y0u_4nd_c0ff33_3qu4l5_fl4g_4nd_p01n75}.

>Files & Tools

  • chall.py — the challenge (server) script that prints leaks

  • entry.sh, Dockerfile — server wrapper

  • flag.txt — local flag for testing

  • solve.py — local solver that runs chall.py locally and re-constructs the flag

  • remote_solve.py — remote solver that connects to the service and reconstructs the flag

Python 3 is required. The remote_solve.py uses Python's telnetlib to communicate with the network service; for quick manual checks, use nc.


>Vulnerability (Detailed)

The server is repeatedly generating a fresh random key k and printing two leaks on each connection/loop:

# Suppose flag is split in half: F = F_left || F_right a = F_left & k_left  # printed as hex o = F_right | k_right # printed as hex

For each bit position in a byte:

  • If the flag bit f = 0:

  - f & r = 0 always (no information about k)

  - f | r = r -> yields random bits from k

  • If the flag bit f = 1:

  - f & r = r -> yields random bits from k

  - f | r = 1 always

This means the two halves provide complementary information about the truth of each flag bit:

  • First half (AND): if a bit is 0 in every sample, it implies the flag bit is 0. If a bit ever becomes 1 in any iteration (because that bit was set by the random key), the bit is likely 1 in the flag.

  • Second half (OR): if a bit is 1 in every sample, it implies the flag bit is 1. If a bit is 0 in any sample, it implies the flag bit is 0 in the flag.

In both cases we use repeated random keys to turn the variable outputs (which equal k for some bits) into deterministic information about whether a flag bit is 0 or 1.

Using repeated samples — probability analysis

A single bit b of a byte in either half is probabilistic across samples due to the random key k bits.

  • For a bit in the first half where flag bit f = 1, a single observed a bit is equal to k's bit. The OR across n independent samples will keep that bit 0 only if every k bit was 0. That probability is (1/2)^n.

  • For a bit in the second half where flag bit f = 0, a single observed o bit equals k's bit. The AND across n independent samples will keep that bit 1 only if every k bit was 1. That probability is (1/2)^n.

Hence with n = 128 samples, error probability per bit is roughly 2^-128 (negligible). Even 32 or 64 samples are already very reliable (1 in 4 billion for 32, etc.).


>Attack Strategy

  1. Repeatedly query the oracle to obtain (a, o) pairs where a is the left-half AND-leak and o is the right-half OR-leak.

  2. For the first half, compute the combined byte as left[i] |= a[i] across all samples. The final left[i] will be equal to the flag's left-half bytes with very high probability.

  3. For the second half, compute the combined byte as right[i] &= o[i] across all samples. The final right[i] will be equal to the flag's right-half bytes with very high probability.

  4. Concatenate left || right and decode ASCII.

This works because the leaks are complementary and the random k mask is fresh per iteration.


>Local Exploitation (Testing)

The challenge has a flag.txt for local testing and local chall.py that prints a and o. I created solve.py which runs chall.py locally and accumulates enough samples.

solve.py (local solver)

python

#!/usr/bin/env python3

import subprocess

import sys

from typing import Optional

  

ITERATIONS = 512

  
  

def read_line(proc: subprocess.Popen, prefix: str) -> bytes:

    line = proc.stdout.readline()

    if not line:

        raise RuntimeError(f"EOF while waiting for {prefix} line")

    line = line.strip()

    if not line.startswith(prefix):

        raise RuntimeError(f"Unexpected line: {line!r}")

    hex_part = line[len(prefix):].strip()

    return bytes.fromhex(hex_part)

  
  

def main() -> int:

    proc = subprocess.Popen([

        "python3", "-u", "chall.py"

    ], cwd=".", stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1)

  

    first_half: Optional[bytearray] = None

    second_half: Optional[bytearray] = None

  

    for _ in range(ITERATIONS):

        a = read_line(proc, "a =")

        o = read_line(proc, "o =")

  

        if first_half is None:

            first_half = bytearray(len(a))

        if second_half is None:

            second_half = bytearray([0xFF] * len(o))

  

        for i, value in enumerate(a):

            first_half[i] |= value

        for i, value in enumerate(o):

            second_half[i] &= value

  

        # Consume the input prompt ("> ") before sending newline to request the next sample.

        prompt = proc.stdout.read(2)

        if prompt != "> ":

            raise RuntimeError(f"Unexpected prompt: {prompt!r}")

        proc.stdin.write("\n")

        proc.stdin.flush()

  

    proc.terminate()

  

    if first_half is None or second_half is None:

        raise RuntimeError("No data captured from challenge")

  

    flag_guess = bytes(first_half + second_half)

    print(flag_guess)

    try:

        print(flag_guess.decode())

    except UnicodeDecodeError:

        pass

  

    return 0

  
  

if __name__ == "__main__":

    raise SystemExit(main())

Running locally

From the andor/ folder:

bash

python3 -u chall.py &   # Optional: run server in background for a local manual check

python3 solve.py

Output (example, local testing uses flag.txt):

b'Hero{FAKE_FLAG}' Hero{FAKE_FLAG}

This indicates that the approach works locally and reconstructs the local test flag.


>Remote Exploitation

For remote exploitation, I made remote_solve.py which connects using telnetlib and performs the same accumulation across samples. We can also perform a quick manual check with nc.

remote_solve.py (remote solver)

python

#!/usr/bin/env python3

"""Recover the flag from the remote AND/OR oracle."""

from __future__ import annotations

  

import argparse

import telnetlib

from typing import Optional

  

ITERATIONS = 512

PREFIX_A = b"a ="

PREFIX_O = b"o ="

PROMPT = b"> "

  
  

def read_prefixed_line(tn: telnetlib.Telnet, prefix: bytes) -> bytes:

    while True:

        line = tn.read_until(b"\n")

        if not line:

            raise RuntimeError("Connection closed while waiting for data")

        line = line.strip()

        if not line:

            continue

        if not line.startswith(prefix):

            continue

        hex_part = line[len(prefix):].strip()

        return bytes.fromhex(hex_part.decode())

  
  

def await_prompt(tn: telnetlib.Telnet) -> None:

    chunk = tn.read_until(PROMPT)

    if not chunk.endswith(PROMPT):

        raise RuntimeError(f"Unexpected prompt sequence: {chunk!r}")

  
  

def recover_flag(host: str, port: int, iterations: int) -> bytes:

    with telnetlib.Telnet(host, port) as tn:

        left: Optional[bytearray] = None

        right: Optional[bytearray] = None

  

        for _ in range(iterations):

            part_and = read_prefixed_line(tn, PREFIX_A)

            part_or = read_prefixed_line(tn, PREFIX_O)

  

            if left is None:

                left = bytearray(len(part_and))

            if right is None:

                right = bytearray([0xFF] * len(part_or))

  

            for i, value in enumerate(part_and):

                left[i] |= value

            for i, value in enumerate(part_or):

                right[i] &= value

  

            await_prompt(tn)

            tn.write(b"\n")

  

    if left is None or right is None:

        raise RuntimeError("Did not receive any oracle data")

  

    return bytes(left + right)

  
  

def main() -> int:

    parser = argparse.ArgumentParser(description=__doc__)

    parser.add_argument("host")

    parser.add_argument("port", type=int)

    parser.add_argument("-n", "--iterations", type=int, default=ITERATIONS)

    args = parser.parse_args()

  

    flag = recover_flag(args.host, args.port, args.iterations)

    print(flag)

    try:

        print(flag.decode())

    except UnicodeDecodeError:

        pass

    return 0

  
  

if __name__ == "__main__":

    raise SystemExit(main())

Running remotely

Ask the organizer for the remote address/port (the challenge provided nc crypto.heroctf.fr 9000). Then run:

bash

python3 remote_solve.py crypto.heroctf.fr 9000 -n 128

I used -n 128 which is more than sufficient. Example output:

b'Hero{y0u_4nd_5l33p_0r_y0u_4nd_c0ff33_3qu4l5_fl4g_4nd_p01n75}' Hero{y0u_4nd_5l33p_0r_y0u_4nd_c0ff33_3qu4l5_fl4g_4nd_p01n75}

This is the real remote flag.


>Additional Notes & Improvements

  • The failure probability per-bit after n samples is (1/2)^n. Use a value that balances speed and safety; n = 128 is already astronomically safe and still fast to run.

  • You can optimize runs: run multiple connections in parallel or reduce number of iterations if you want faster but slightly riskier runs.

  • The script remote_solve.py is robust to different prompt behaviors since it waits for > after each shown sample; if the remote environment changed, you can adapt prompt detection logic.

>Final Thoughts

This is a classical information leakage problem using bitwise primitives. The trick is to recognize how AND and OR leaks can be combined with multiple random masks to extract the secret deterministically with high probability.

The final remote flag recovered: Hero{y0u_4nd_5l33p_0r_y0u_4nd_c0ff33_3qu4l5_fl4g_4nd_p01n75}


>Quick Reference Commands

bash

# Local test

cd andor

python3 -u chall.py   # optional: manual cluster loop

python3 solve.py

  

# Remote test

python3 remote_solve.py crypto.heroctf.fr 9000 -n 128

# Or faster but lower reliability

python3 remote_solve.py crypto.heroctf.fr 9000 -n 64