812 words
4 minutes
ApoorvCTF 2026 - Tick Tock - Cryptography Writeup

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 9001
Welcome 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.

wink

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.6ms
1: mean=72.3ms min=70.3ms max=75.5ms
2: mean=74.6ms min=70.5ms max=82.6ms
3: mean=76.2ms min=74.0ms max=79.8ms
4: mean=77.3ms min=74.7ms max=81.8ms
5: mean=85.3ms min=70.7ms max=113.2ms
6: mean=152.8ms min=73.5ms max=310.9ms
7: mean=72.9ms min=72.1ms max=73.4ms
8: mean=71.4ms min=70.2ms max=73.5ms
9: mean=901.7ms min=870.6ms max=958.9ms

From 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.

smile

import socket, time, re, statistics

HOST = "chals3.apoorvctf.xyz"
PORT = 9001
FLAG_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_PREFIX
print("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 SystemExit
Starting 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.1ms
pos=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.3ms
pos=10 choose=9 prefix=93478018909
FLAG apoorvctf{con5t4nt_tim3_or_di3}

Solution#

# solve_tick_tock.py
import socket, time, re, statistics

HOST = "chals3.apoorvctf.xyz"
PORT = 9001
FLAG_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.py
apoorvctf{con5t4nt_tim3_or_di3}
ApoorvCTF 2026 - Tick Tock - Cryptography Writeup
https://blog.rei.my.id/posts/96/apoorvctf-2026-tick-tock-cryptography-writeup/
Author
Reidho Satria
Published at
2026-03-10
License
CC BY-NC-SA 4.0