SideChannel picoCTF 2022 Solution

Published: July 20, 2023

Description

A provided PIN checker binary validates an 8-digit code one character at a time. Because it returns more slowly for each correct digit, you can abuse the timing difference to recover the full PIN and then use it to obtain the flag from the remote service.

Fetch pin_checker, mark it executable, and probe it with sample 8-digit inputs while timing execution.

Notice that each correct digit increases the runtime slightly-classic timing side channel behavior.

Write a script (pwntools + time.perf_counter) to brute-force each position, then submit the final PIN to the remote service.

bash
wget https://artifacts.picoctf.net/c/74/pin_checker
bash
chmod +x pin_checker
bash
echo "11111111" | time ./pin_checker
python
python3 solve_pin.py
bash
nc saturn.picoctf.net 53639

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Confirm the timing leak
    Observation
    I noticed the binary validates PIN digits one at a time and exits early on a mismatch, which suggested that measuring how long ./pin_checker takes for different inputs would reveal how many leading digits were correct.
    Running echo "11111111" | time ./pin_checker and then tweaking one digit shows the runtime grows whenever the prefix is correct. That leak lets us learn each digit sequentially.
    What didn't work first

    Tried: Running strings or disassembling the binary to find the PIN stored in plaintext.

    The binary does not store the PIN as a literal string - it validates digits procedurally. Strings and objdump show the code structure but not the secret value. The PIN must be recovered at runtime by observing timing behavior.

    Tried: Trying only one sample per digit and assuming the slowest digit is correct.

    A single measurement is too noisy - process scheduling and OS interrupts can make a wrong digit appear slower than the correct one. You need multiple repeated measurements and a median or averaging step to see the true timing signal reliably.

    Learn more

    A timing side-channel attack exploits the fact that a program takes measurably different amounts of time depending on secret data. In this case, the PIN checker validates digits left-to-right and exits early on the first mismatch - so a PIN with 3 correct leading digits takes longer than one with 0 correct digits.

    This is the same class of vulnerability that affects naive string comparison in authentication code. Languages like Python return from == as soon as a character mismatches, which leaks information about how far into the string the comparison succeeded. Secure implementations use constant-time comparison - they always check every byte regardless of where a mismatch occurs, so timing reveals nothing.

    In Python, hmac.compare_digest(a, b) provides constant-time string comparison. In C, memcmp is not constant-time; security libraries provide safe alternatives like CRYPTO_memcmp (OpenSSL) or timingsafe_bcmp (BSD libc).

  2. Step 2
    Automate the measurement
    Observation
    I noticed the timing signal grows one digit at a time, which suggested writing a pwntools script using time.perf_counter() to brute-force each position independently, reducing the search from 10^8 to just 80 measurements.
    Spawn the checker, send a candidate PIN, and time the response with time.perf_counter() (Python 3.3+; on older interpreters, fall back to the timeit module or time.process_time()). For each position 0-7 try digits 0-9, repeat each measurement N times to control for noise, take the per-digit median, and pick the slowest as the correct character before locking it in and moving to the next index.

    A minimal sketch with N samples and a median across runs:

    from statistics import median
    from time import perf_counter
    from pwn import process
    
    N = 20
    
    def measure(pin: str) -> float:
        samples = []
        for _ in range(N):
            p = process('./pin_checker')
            t0 = perf_counter()
            p.sendline(pin.encode())
            p.recvall()
            samples.append(perf_counter() - t0)
            p.close()
        return median(samples)
    
    found = ""
    for pos in range(8):
        timings = {d: measure(found + str(d) + "0" * (7 - pos)) for d in range(10)}
        best = max(timings, key=timings.get)
        found += str(best)
        print(f"pos {pos}: {found}  (timings: {timings})")
    print("PIN:", found)
    What didn't work first

    Tried: Using time.time() instead of time.perf_counter() to measure elapsed time.

    time.time() has low resolution (often only millisecond precision) and can jump when the system clock is adjusted. time.perf_counter() uses a high-resolution monotonic clock and is the correct choice for sub-millisecond timing measurements.

    Tried: Trying all 10^8 PIN combinations by brute force instead of recovering one digit at a time.

    Brute-forcing all 100 million combinations would take an impractical amount of time. The timing leak lets you treat each digit position independently - 10 candidates times 8 positions equals only 80 measurements total, which finishes in seconds.

    Learn more

    pwntools is a Python library designed for CTF challenges and exploit development. Its process() and remote() classes make it easy to spawn local processes or connect to remote services, send inputs, and read responses. Combined with time.perf_counter() for high-resolution timing, it's the standard toolkit for timing attacks.

    The brute-force strategy here works digit by digit: fix the known-correct prefix, try all 10 digits for the next position, and pick the one that takes longest. This reduces the search space from 10^8 (100 million) combinations to just 10 × 8 = 80 measurements - a massive speedup that makes the attack practical in seconds.

    Real-world timing attacks require careful statistics to handle noise. Network latency introduces variance, so attackers typically repeat each measurement hundreds of times and take the median or minimum. Tools like tlsfuzzer implement statistical timing analysis for remote attacks against TLS implementations, where differences as small as nanoseconds can be detected over a network.

  3. Step 3
    Redeem the PIN
    Observation
    I noticed the recovered PIN was obtained locally from the downloaded pin_checker binary, which suggested connecting to the remote nc service and submitting it to receive the actual flag.
    Connect to the remote service. It prints a prompt, expects the 8-digit PIN as a newline-terminated string, and responds with the flag.

    The exchange looks like this:

    $ nc saturn.picoctf.net 53639
    Please enter your secret pin to retrieve the secret information: 13371337
    Access granted. Here is the flag: picoCTF{t1m1ng_4tt4ck_914c...}

    The PIN must be terminated with a newline; nc sends one when you press Enter, but if you script it, remember echo "13371337" | nc ... rather than printf "13371337".

    What didn't work first

    Tried: Sending the PIN with printf without a trailing newline, e.g. printf "13371337" | nc ...

    The remote service reads a line terminated by a newline character. Without the newline, nc sends the bytes but the server keeps waiting for input and never responds. Using echo or appending a newline ensures the server processes the PIN immediately.

    Tried: Running the timing script directly against the remote service instead of the local binary.

    Network latency adds far more noise than the tiny timing difference the checker produces. The correct approach is to recover the PIN locally against the downloaded binary where latency is negligible, then send the recovered PIN to the remote service once.

    Learn more

    netcat (nc) is a versatile networking tool that opens raw TCP or UDP connections. It's described as the "TCP/IP Swiss Army knife" - you can use it to connect to any open port and send/receive arbitrary data. For CTF challenges, it's the standard way to interact with remote challenge servers that expose a text-based interface.

    Timing side channels have been exploited against major real-world systems: early TLS implementations leaked RSA key bits through timing differences during decryption (Bleichenbacher's attack), and CPU branch prediction has been abused in the Spectre and Meltdown vulnerabilities to leak memory across security boundaries using cache timing.

    The defense against this class of attack is comprehensive: constant-time algorithms, adding random delays (jitter), and rate-limiting repeated authentication attempts. Modern security standards like FIPS 140-3 require constant-time implementations for cryptographic operations precisely because timing leaks can undermine otherwise mathematically sound algorithms.

Flag

Reveal flag

picoCTF{t1m1ng_4tt4ck_914c...}

Timing side channels are powerful-even without reading the binary, runtime differences disclosed the entire secret.

Key takeaway

Side-channel attacks exploit observable physical or computational behavior rather than breaking the underlying algorithm. An early-exit comparison leaks how many leading characters matched, turning a brute-force search from exponential to linear by recovering one position at a time. Constant-time comparison functions (hmac.compare_digest in Python, CRYPTO_memcmp in OpenSSL) fix this by always examining every byte, ensuring runtime reveals nothing about the secret. The same timing principle has been applied against TLS decryption (Bleichenbacher), CPU caches (Spectre and Meltdown), and remote network services.

Related reading

Want more picoCTF 2022 writeups?

Useful tools for Forensics

What to try next