Category: Cryptography
Flag: apoorvctf{con5t4nt_tim3_or_di3}
Challenge Description
Our engineers are obsessed with performance.Their main goal? Speed.
To keep things fast, the password verification service avoids doing more work than necessary. Every millisecond counts.
Correct password gives the flag
The password consists only of digits: 0-9 Can you recover it?
Analysis
The service hint basically screams timing side channel, but I still verified behavior first. A quick wrong guess showed the server loops and immediately asks again, which is exactly what you want for repeated measurements over the network.
printf '0\n' | nc chals3.apoorvctf.xyz 9001Welcome to the password checker!Please enter the password: Incorrect password.Please enter the password:At that point I measured response time for one-digit guesses 0..9 over fresh TCP connections, timing from send to first verdict. One value was massively slower than the rest, so the checker was clearly doing early-exit comparison with a per-correct-digit delay.

import socket, time, statistics
HOST = "chals3.apoorvctf.xyz"PORT = 9001
def measure(guess: str) -> float: s = socket.create_connection((HOST, PORT), timeout=5) s.settimeout(8) s.recv(4096) t0 = time.perf_counter() s.sendall((guess + "\n").encode()) data = b"" while True: try: chunk = s.recv(4096) except Exception: break if not chunk: break data += chunk if b"Incorrect password." in data or b"{" in data: break dt = time.perf_counter() - t0 s.close() return dt
for d in "0123456789": vals = [measure(d) for _ in range(3)] print(f"{d}: mean={statistics.mean(vals)*1000:.1f}ms min={min(vals)*1000:.1f}ms max={max(vals)*1000:.1f}ms")0: mean=73.1ms min=71.5ms max=74.6ms1: mean=72.3ms min=70.3ms max=75.5ms2: mean=74.6ms min=70.5ms max=82.6ms3: mean=76.2ms min=74.0ms max=79.8ms4: mean=77.3ms min=74.7ms max=81.8ms5: mean=85.3ms min=70.7ms max=113.2ms6: mean=152.8ms min=73.5ms max=310.9ms7: mean=72.9ms min=72.1ms max=73.4ms8: mean=71.4ms min=70.2ms max=73.5ms9: mean=901.7ms min=870.6ms max=958.9msFrom there the solve was just greedy prefix extension: test prefix + digit for all digits, pick the slowest candidate, and repeat. Each correct next digit added roughly ~0.8s to the response time, which made the signal very clean even with network jitter. I ran an adaptive extractor, then continued from the recovered prefix to avoid timeout issues from long waits. The continuation step returned the flag directly.

import socket, time, re, statistics
HOST = "chals3.apoorvctf.xyz"PORT = 9001FLAG_RE = re.compile(rb"[A-Za-z0-9_]+\{[^}]+\}")PROMPT = b"Please enter the password:"START_PREFIX = "934780189"
def attempt(guess: str, timeout: int = 35): s = socket.create_connection((HOST, PORT), timeout=8) s.settimeout(timeout) data = b"" end = time.perf_counter() + 8 while PROMPT not in data and time.perf_counter() < end: chunk = s.recv(4096) if not chunk: break data += chunk
t0 = time.perf_counter() s.sendall((guess + "\n").encode()) out = b"" end = time.perf_counter() + timeout while time.perf_counter() < end: try: chunk = s.recv(4096) except socket.timeout: break if not chunk: break out += chunk if FLAG_RE.search(out): break if b"Correct password" in out: try: s.settimeout(2) out += s.recv(4096) except Exception: pass break if b"Incorrect password." in out: break
dt = time.perf_counter() - t0 s.close() m = FLAG_RE.search(out) return dt, out.decode(errors="ignore"), (m.group().decode() if m else None)
prefix = START_PREFIXprint("Starting prefix:", prefix)
for pos in range(len(prefix), 22): scores = [] for d in "0123456789": g = prefix + d dt, txt, flag = attempt(g) if flag: print("FLAG", flag) raise SystemExit scores.append((dt, d)) print(f" test {g} -> {dt*1000:.1f}ms")
scores.sort(reverse=True) top = scores[:3] conf = [] for _, d in top[:2]: vals = [attempt(prefix + d)[0] for _ in range(2)] conf.append((statistics.median(vals), d)) conf.sort(reverse=True)
best = conf[0][1] prefix += best print(f"pos={pos} choose={best} prefix={prefix}")
_, txt, flag = attempt(prefix) if flag: print("FLAG", flag) raise SystemExitStarting prefix: 934780189 test 9347801890 -> 8118.8ms test 9347801891 -> 7292.8ms test 9347801892 -> 7277.2ms test 9347801893 -> 7284.1ms test 9347801894 -> 7322.4ms test 9347801895 -> 7325.7ms test 9347801896 -> 7281.2ms test 9347801897 -> 7275.2ms test 9347801898 -> 7277.5ms test 9347801899 -> 7326.1mspos=9 choose=0 prefix=9347801890 test 93478018900 -> 8082.6ms test 93478018901 -> 8076.7ms test 93478018902 -> 8113.7ms test 93478018903 -> 8078.3ms test 93478018904 -> 8076.5ms test 93478018905 -> 8081.1ms test 93478018906 -> 8117.7ms test 93478018907 -> 8080.6ms test 93478018908 -> 8081.1ms test 93478018909 -> 8877.3mspos=10 choose=9 prefix=93478018909FLAG apoorvctf{con5t4nt_tim3_or_di3}Solution
import socket, time, re, statistics
HOST = "chals3.apoorvctf.xyz"PORT = 9001FLAG_RE = re.compile(rb"[A-Za-z0-9_]+\{[^}]+\}")PROMPT = b"Please enter the password:"
def attempt(guess: str, timeout: int = 35): s = socket.create_connection((HOST, PORT), timeout=8) s.settimeout(timeout) data = b"" end = time.perf_counter() + 8 while PROMPT not in data and time.perf_counter() < end: chunk = s.recv(4096) if not chunk: break data += chunk
t0 = time.perf_counter() s.sendall((guess + "\n").encode()) out = b"" end = time.perf_counter() + timeout while time.perf_counter() < end: try: chunk = s.recv(4096) except socket.timeout: break if not chunk: break out += chunk m = FLAG_RE.search(out) if m: return time.perf_counter() - t0, m.group().decode() if b"Incorrect password." in out: break
return time.perf_counter() - t0, None
prefix = ""for _ in range(20): scores = [] for d in "0123456789": dt, flag = attempt(prefix + d) if flag: print(flag) raise SystemExit scores.append((dt, d))
scores.sort(reverse=True) top = scores[:2] confirm = [] for _, d in top: vals = [] for _ in range(2): dt, flag = attempt(prefix + d) if flag: print(flag) raise SystemExit vals.append(dt) confirm.append((statistics.median(vals), d)) confirm.sort(reverse=True) prefix += confirm[0][1]python solve_tick_tock.pyapoorvctf{con5t4nt_tim3_or_di3}