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', 1338
cmds = ['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
> ### LSREG
Registers:
R0 R1 R2 R3 R4 R5 R6 ECR
> ### READ R0
R0 = 00000000
> ...
### READ ECR
ECR = 00000000
> ### READOUT
ERROR 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 OVERFLOW
00010000 -> ECR FLUSHED
00100000 -> ECR FLUSHED
00110000 -> ECR FLUSHED
01000000 -> 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 FLUSHED
2: ECR FLUSHED
3: ECR FLUSHED
4: ECR FLUSHED
5: ECR FLUSHED
6: ECR FLUSHED
7: MEMORY OVERFLOW
8: 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 B
B L R L R
L R L B B
. B R L .
Generated candidate sets: 128
Solved with candidate #29: ('00010010', '00010100', '00110111', '00111000', '00111011', '00111100')
apoorvctf{uncertain_about_that}
FLAG: apoorvctf{uncertain_about_that}Solution
# qbitflipper_solve.py
import re
import socket
import numpy as np
from PIL import Image
from scipy import ndimage as ndi
HOST = "chals4.apoorvctf.xyz"
PORT = 1338
FLAG_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 B
B L R L R
L R L B B
. B R L .
Generated candidate sets: 128
Solved with candidate #29: ('00010010', '00010100', '00110111', '00111000', '00111011', '00111100')
apoorvctf{uncertain_about_that}
FLAG: apoorvctf{uncertain_about_that}