Category: Hardware
Flag: apoorvctf{uncertain_about_that}
Challenge Description
While chasing The Spot through an abandoned Oscorp research facility, Miles Morales interrupted him while he was activating a strange prototype chip connected to the collider control systems.
Miles managed to shut the system down before it finished initializing, but The Spot escaped through a portal, leaving the device behind.
Spider-Byte recovered the hardware and began analyzing it.
The chip appears to be an experimental Oscorp System-on-Chip (SoC) composed of three custom modules:
OSCORP QRYZEN™ Hybrid Core OSCORP QOREX™ OSCORP QELIX™ Memory Array Components OSCORP QRYZEN™ Hybrid Core A programmable processor responsible for coordinating system operations and interacting with the memory array.
**OSCORP QOREX™ **
… Some IC it is not outputing any values.
OSCORP QELIX™ Memory Array A 16-cell experimental storage array used by the processor.
Unfortunately, the QOREX ASIC is completely destroyed.
However, Spider-Byte discovered that the QRYZEN Hybrid Core still exposes a low-level debug interface.
Recovered Clues
From the lab we recovered:
A diagnostic image dump from the device
A diagram of the SoC architecture Miles also noticed a note written on a nearby lab whiteboard:
Operator nibble mapping
0001 → BIT 0010 → PHASE 0011 → BITNPHASE
The processor expects correction instructions encoded as:
[4-bit operator][4-bit address]
Each instruction targets one of the 16 cells inside the QELIX memory array.
Each bits are addressed as 0,1,2,3 4,5,6,7 … …,15
Mission The processor is outputing decoding error find what is wrong and get an output.
nc chals4.apoorvctf.xyz 1338
Analysis
The first useful move was to validate what the remote interface actually accepts. Listing registers showed the expected R0..R6 plus ECR, and READOUT started at ERROR ON DECODING, so we definitely needed to feed correction instructions, not just read an existing flag.
python - <<'PY'from pwn import remote
host, port = 'chals4.apoorvctf.xyz', 1338cmds = ['LSREG', 'READ R0', 'READ R1', 'READ R2', 'READ R3', 'READ R4', 'READ R5', 'READ R6', 'READ ECR', 'READOUT']
p = remote(host, port, timeout=5)print(p.recvuntil(b'> ').decode(errors='ignore'), end='')for c in cmds: p.sendline(c.encode()) out = p.recvuntil(b'> ', timeout=5).decode(errors='ignore') print(f'### {c}') print(out, end='')p.close()PYMicroprocessor Ready> ### LSREGRegisters:R0 R1 R2 R3 R4 R5 R6 ECR> ### READ R0R0 = 00000000> ...### READ ECRECR = 00000000> ### READOUTERROR ON DECODING>Then I brute-checked operator nibble validity instead of assuming parser behavior from the note. This confirmed exactly three valid opcodes and rejected all others with OPERATOR OVERFLOW, which matched the whiteboard mapping and eliminated a huge amount of search noise.
python - <<'PY'import socket
host, port = 'chals4.apoorvctf.xyz', 1338
def recv_prompt(s): data = b'' while b'> ' not in data: data += s.recv(4096) return data.decode(errors='ignore')
for op in range(16): s = socket.create_connection((host, port), timeout=5) recv_prompt(s) ins = f'{op:04b}0000' s.sendall(f'WRITE ECR {ins}\n'.encode()) recv_prompt(s) s.sendall(b'FLUSHECR\n') out = recv_prompt(s) line = [ln.strip() for ln in out.splitlines() if ln.strip()][0] print(f'{ins} -> {line}') s.close()PY00000000 -> OPERATOR OVERFLOW00010000 -> ECR FLUSHED00100000 -> ECR FLUSHED00110000 -> ECR FLUSHED01000000 -> OPERATOR OVERFLOW...11110000 -> OPERATOR OVERFLOWAt that point the challenge became a mapping problem from code.png (diagnostic dump) into a valid correction sequence. The output didn’t budge with naïve writes, and the service had annoying state behavior where every 7th flush overflowed, so blind brute-force felt like a trap.

python - <<'PY'import socket
host, port = 'chals4.apoorvctf.xyz', 1338
def recv_prompt(s): data = b'' while b'> ' not in data: data += s.recv(4096) return data.decode(errors='ignore')
s = socket.create_connection((host, port), timeout=5)print(recv_prompt(s), end='')for i in range(1, 9): s.sendall(b'WRITE ECR 00010000\n'); recv_prompt(s) s.sendall(b'FLUSHECR\n') out = recv_prompt(s) line = [ln.strip() for ln in out.splitlines() if ln.strip()][0] print(f'{i}: {line}')s.close()PYMicroprocessor Ready> 1: ECR FLUSHED2: ECR FLUSHED3: ECR FLUSHED4: ECR FLUSHED5: ECR FLUSHED6: ECR FLUSHED7: MEMORY OVERFLOW8: ECR FLUSHEDSo the solve path was to model the image and derive compact candidates mathematically. I wrote a solver that segments the 5x5 syndrome tile grid from code.png, builds GF(2) equations over the 4x4 memory-cell lattice, applies lattice symmetries, and converts each solution into instruction tuples using 0001/0010/0011 as BIT/PHASE/BITNPHASE. The script generated 128 compact candidates, then tested each candidate directly against the remote by issuing WRITE ECR <bits> + FLUSHECR and checking READOUT.
That gave a clean hit at candidate #29 with six instructions:
('00010010', '00010100', '00110111', '00111000', '00111011', '00111100')
and READOUT finally returned the flag.

python qbitflipper_solve.pyExtracted 5x5 syndrome grid:. L R B .B R B B BB L R L RL R L B B. B R L .Generated candidate sets: 128Solved with candidate #29: ('00010010', '00010100', '00110111', '00111000', '00111011', '00111100')apoorvctf{uncertain_about_that}FLAG: apoorvctf{uncertain_about_that}Solution
import reimport socket
import numpy as npfrom PIL import Imagefrom scipy import ndimage as ndi
HOST = "chals4.apoorvctf.xyz"PORT = 1338FLAG_RE = re.compile(r"[A-Za-z0-9_]+\{[^}]+\}")
def extract_grid_from_code_png(path="code.png"): img = np.array(Image.open(path).convert("RGB")) h, w, _ = img.shape
protos = np.array( [ [28, 53, 127], [87, 149, 228], [231, 203, 95], [231, 70, 72], ], dtype=np.int16, ) labels = ["B", "L", "Y", "R"]
pix = img.reshape(-1, 3).astype(np.int16) d = ((pix[:, None, :] - protos[None, :, :]) ** 2).sum(axis=2) cls = d.argmin(axis=1).reshape(h, w) min_d = d.min(axis=1).reshape(h, w)
mask = (min_d < 1800) & (img.mean(axis=2) > 45) comp, _ = ndi.label(mask) objs = ndi.find_objects(comp)
tiles = [] for i, s in enumerate(objs, start=1): if s is None: continue yy, xx = np.where(comp == i) area = len(yy) if area < 1200: continue x0, x1 = xx.min(), xx.max() y0, y1 = yy.min(), yy.max() cx, cy = (x0 + x1) / 2, (y0 + y1) / 2 cidx = np.bincount(cls[yy, xx].ravel(), minlength=4).argmax() tiles.append((cx, cy, labels[cidx]))
ys = sorted([t[1] for t in tiles]) xs = sorted([t[0] for t in tiles])
def cluster(vals, thr=55): groups, cur = [], [vals[0]] for v in vals[1:]: if abs(v - cur[-1]) <= thr: cur.append(v) else: groups.append(cur) cur = [v] groups.append(cur) return [sum(g) / len(g) for g in groups]
rows = cluster(ys) cols = cluster(xs) grid = [["." for _ in range(len(cols))] for __ in range(len(rows))]
for cx, cy, ch in tiles: r = min(range(len(rows)), key=lambda i: abs(rows[i] - cy)) c = min(range(len(cols)), key=lambda i: abs(cols[i] - cx)) grid[r][c] = ch
return grid
def solve_all(A, b): A = A.copy() b = b.copy() m, n = A.shape row = 0 where = [-1] * n
for col in range(n): sel = -1 for r in range(row, m): if A[r, col]: sel = r break if sel == -1: continue if sel != row: A[[row, sel]] = A[[sel, row]] b[[row, sel]] = b[[sel, row]] where[col] = row for r in range(m): if r != row and A[r, col]: A[r] ^= A[row] b[r] ^= b[row] row += 1 if row == m: break
for r in range(m): if not A[r].any() and b[r]: return []
free = [c for c in range(n) if where[c] == -1] sols = [] for mask in range(1 << len(free)): x = np.zeros(n, dtype=np.uint8) for i, c in enumerate(free): x[c] = (mask >> i) & 1 for c in range(n - 1, -1, -1): rr = where[c] if rr == -1: continue s = 0 for k in np.where(A[rr] == 1)[0]: if k != c: s ^= x[k] x[c] = b[rr] ^ s sols.append(x) return sols
def sym_maps(): out = [] for k in range(8): p = [0] * 16 for r in range(4): for c in range(4): a = r * 4 + c if k == 0: rr, cc = r, c elif k == 1: rr, cc = c, 3 - r elif k == 2: rr, cc = 3 - r, 3 - c elif k == 3: rr, cc = 3 - c, r elif k == 4: rr, cc = r, 3 - c elif k == 5: rr, cc = 3 - r, c elif k == 6: rr, cc = c, r else: rr, cc = 3 - c, 3 - r p[a] = rr * 4 + cc out.append(p) return out
def apply_perm(v, p): o = np.zeros_like(v) for a, b in enumerate(p): o[b] = v[a] return o
def generate_candidates(grid): groups = {0: [], 1: []} for r in range(5): for c in range(5): if grid[r][c] == ".": continue groups[(r + c) & 1].append((r, c, grid[r][c]))
nodes = [(r, c) for r in range(4) for c in range(4)] idx = {rc: i for i, rc in enumerate(nodes)}
H = {} for gp in [0, 1]: mat = np.zeros((len(groups[gp]), 16), dtype=np.uint8) row_of = {(r, c): i for i, (r, c, _) in enumerate(groups[gp])} for qr, qc in nodes: j = idx[(qr, qc)] for rc in [(qr, qc), (qr, qc + 1), (qr + 1, qc), (qr + 1, qc + 1)]: if rc in row_of: mat[row_of[rc], j] ^= 1 H[gp] = mat
candidates = set() perms = sym_maps()
for gx in [0, 1]: gz = 1 - gx cols_x = sorted(set(ch for _, _, ch in groups[gx])) cols_z = sorted(set(ch for _, _, ch in groups[gz])) for one_x in cols_x: sx = np.array([1 if ch == one_x else 0 for _, _, ch in groups[gx]], dtype=np.uint8) X = sorted(solve_all(H[gx], sx), key=lambda v: int(v.sum()))[:64] for one_z in cols_z: sz = np.array([1 if ch == one_z else 0 for _, _, ch in groups[gz]], dtype=np.uint8) Z = sorted(solve_all(H[gz], sz), key=lambda v: int(v.sum()))[:64] for x in X: for z in Z: for p in perms: xp = apply_perm(x, p) zp = apply_perm(z, p) for swap in [0, 1]: xx, zz = (zp, xp) if swap else (xp, zp) ops = [] for a in range(16): xb, zb = int(xx[a]), int(zz[a]) if xb == 0 and zb == 0: continue if xb == 1 and zb == 0: op = "0001" elif xb == 0 and zb == 1: op = "0010" else: op = "0011" ops.append(op + f"{a:04b}") if 1 <= len(ops) <= 6: candidates.add(tuple(sorted(ops)))
return sorted(candidates, key=lambda t: (len(t), t))
def recv_prompt(sock): data = b"" while b"> " not in data: chunk = sock.recv(4096) if not chunk: break data += chunk return data.decode(errors="ignore")
def test_ops(ops): s = socket.create_connection((HOST, PORT), timeout=6) recv_prompt(s) for ins in ops: s.sendall(f"WRITE ECR {ins}\n".encode()) recv_prompt(s) s.sendall(b"FLUSHECR\n") recv_prompt(s) s.sendall(b"READOUT\n") out = recv_prompt(s) s.close() return out
def main(): grid = extract_grid_from_code_png("code.png") print("Extracted 5x5 syndrome grid:") for row in grid: print(" ".join(row))
candidates = generate_candidates(grid) print(f"Generated candidate sets: {len(candidates)}")
for i, ops in enumerate(candidates, start=1): out = test_ops(ops) if "ERROR ON DECODING" not in out: print(f"Solved with candidate #{i}: {ops}") print(out.strip()) m = FLAG_RE.search(out) if m: print(f"FLAG: {m.group(0)}") return
print("No valid candidate found")
if __name__ == "__main__": main()python qbitflipper_solve.pyExtracted 5x5 syndrome grid:. L R B .B R B B BB L R L RL R L B B. B R L .Generated candidate sets: 128Solved with candidate #29: ('00010010', '00010100', '00110111', '00111000', '00111011', '00111100')apoorvctf{uncertain_about_that}FLAG: apoorvctf{uncertain_about_that}