//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 runschall.pylocally 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
abit is equal tok's bit. The OR across n independent samples will keep that bit 0 only if everykbit was 0. That probability is (1/2)^n. -
For a bit in the second half where flag bit f = 0, a single observed
obit equalsk's bit. The AND across n independent samples will keep that bit 1 only if everykbit 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
-
Repeatedly query the oracle to obtain
(a, o)pairs whereais the left-half AND-leak andois the right-half OR-leak. -
For the first half, compute the combined byte as
left[i] |= a[i]across all samples. The finalleft[i]will be equal to the flag's left-half bytes with very high probability. -
For the second half, compute the combined byte as
right[i] &= o[i]across all samples. The finalright[i]will be equal to the flag's right-half bytes with very high probability. -
Concatenate
left || rightand 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)
#!/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:
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)
#!/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:
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
nsamples is (1/2)^n. Use a value that balances speed and safety;n = 128is 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.pyis 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
# 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