Introduction
Most cryptography challenges ask you to break the math. Side-channel challenges ask something different and, once it clicks, easier: ignore the math entirely and watch the machine. A correct implementation of AES with a correct key is unbreakable in any sense that matters. The same implementation running on a computer that takes measurably longer to reject a wrong first byte than a wrong second byte is not unbreakable at all, and the attack that beats it involves no cryptanalysis whatsoever.
The reason this class of attack feels magical at first is that it violates an assumption nobody states out loud: that a program communicates only through its return values. It does not. It communicates through how long it took, how much power it drew, how large its output compressed to, which cache lines it touched, and which of its internal caches still hold entries afterwards. Every one of those is a channel, and every channel that varies with a secret is a channel that leaks the secret.
A side-channel attack does not break the algorithm. It reads the answer off the implementation while the algorithm is still busy being correct.
This guide covers the five channels that actually appear in CTF, in rough order of how often you will meet them: timing, power, compression, cache, and protocol oracles. For each, the goal is the same: recognise the shape from the challenge description, know what to measure, and know the standard statistical move that turns noisy measurements into a key.
The model: any observable is an output
Every side-channel attack is the same three-step recipe wearing a different costume.
| Step | What you do | What it produces |
|---|---|---|
| 1. Find the observable | Identify something you can measure that changes when the secret changes | A number per query |
| 2. Build the leakage model | Predict what that number should be for each candidate value of one small piece of the secret | A prediction table |
| 3. Correlate | Find which candidate's predictions match the measurements best | One piece of the secret. Repeat |
The critical structural feature, and the reason these attacks are feasible at all, is that step 3 recovers the secret piecewise. Brute forcing a 16-byte AES key means searching 2128 candidates. Recovering it one byte at a time with a side channel means 16 independent searches of 256 candidates each, which is 4,096 tests total. The side channel does not make the search faster; it decomposes one impossible search into many trivial ones.
Timing leaks
The most common channel, and the one with the clearest cause. Consider the obvious way to compare two strings:
def check(guess, secret):if len(guess) != len(secret):return Falsefor a, b in zip(guess, secret):if a != b:return False # <-- returns as soon as it finds a mismatchreturn True
This is correct. It is also a per-character oracle. A guess that matches the first two characters runs two loop iterations before returning; a guess that matches three runs three. The function returns False either way, but it takes longer to do so, and that difference is the secret leaking one character at a time.
The attack is a nested loop, and it is linear in the length of the secret:
import string, timefrom pwn import remoteALPHABET = string.digits # narrow it to what the target acceptsknown = ''while len(known) < PIN_LENGTH:timings = {}for ch in ALPHABET:candidate = (known + ch).ljust(PIN_LENGTH, '0')samples = []for _ in range(TRIALS): # repeat: one sample is noise, not signalio = remote(HOST, PORT)start = time.perf_counter()io.sendline(candidate.encode())io.recvline()samples.append(time.perf_counter() - start)io.close()samples.sort()timings[ch] = samples[len(samples) // 2] # median beats mean under noiseknown += max(timings, key=timings.get)print(known)
SideChannel is this attack in its purest form: a PIN checker with an early-exit comparison, where you confirm the leak by hand first and then automate the measurement. The lesson it drives home is the arithmetic. A brute force over an eight-digit PIN is 100 million attempts. The same PIN recovered one digit at a time is 80.
Timing does not only leak comparisons. It leaks anything whose duration depends on the data: a loop that runs a variable number of times, a lookup that hits or misses a cache, a branch that skips expensive work. jitfp extends the idea to a password checker whose function pointers are resolved differently depending on the character, so the measurable quantity is which code path executes rather than how long a comparison runs.
Power analysis and CPA
A CPU draws different amounts of current depending on the data it moves, because flipping a bit from 0 to 1 charges a capacitance and leaving it alone does not. Aggregate that over a register write and the current drawn correlates with the Hamming weight of the value written: the number of set bits. That is the whole physical basis of power analysis, and it is enough to extract an AES key from a device that is otherwise perfect.
The attack targets the first-round S-box output, and the reason is worth understanding because it is the same reason every CPA attack picks the target it picks. After the firstSubBytes, the intermediate value is:
v = SBOX[plaintext_byte ^ key_byte]
This expression has two properties that make it the ideal target. It depends on exactly one byte of the key, so you can guess that byte independently of the other fifteen. And it passes through the S-box, which is strongly non-linear, so a wrong key guess produces a prediction that is not merely wrong but uncorrelated. The correct guess stands out sharply; near-misses do not glow.
import numpy as np# traces: (N, samples) measured power. plaintexts: (N, 16) bytes you chose.def recover_byte(traces, plaintexts, byte_index):best, best_corr = None, -1# Centre the traces ONCE. Doing this inside the guess loop is the# single most common reason a CPA script takes minutes per byte.centered = traces - traces.mean(axis=0)col_ss = (centered * centered).sum(axis=0)for guess in range(256):# Predicted leakage: Hamming weight of the S-box outputhyp = np.array([bin(SBOX[p[byte_index] ^ guess]).count('1') for p in plaintexts], dtype=float)hyp -= hyp.mean()# Pearson correlation of the hypothesis against every sample pointnum = hyp @ centeredden = np.sqrt((hyp @ hyp) * col_ss)peak = np.max(np.abs(num / den))if peak > best_corr:best, best_corr = guess, peakreturn best, best_corrkey = bytes(recover_byte(traces, plaintexts, i)[0] for i in range(16))
The picoCTF power analysis series steps through the realistic version of this. PowerAnalysis Warmup removes the signal processing entirely: the server hands back one scalar leakage value per query instead of a trace, so you can concentrate on building the prediction matrix and correlating. Part 1 gives real traces and expects you to drive the scared library over them. Part 2 is the instructive one: same attack, noisier traces, and only 100 of them.
Compression oracles
A beautiful channel, because it leaks through a completely ordinary feature working exactly as designed. DEFLATE and every other LZ-family compressor replaces repeated byte sequences with short back-references. So if a server compresses your input concatenated with a secret, the compressed output gets shorter whenever your input repeats something in the secret.
That is a character oracle. Submit picoCTF{a, then picoCTF{b, and so on; whichever guess compresses smallest shares one more byte with the flag.
import stringALPHABET = string.ascii_letters + string.digits + '_}'known = 'picoCTF{'while not known.endswith('}'):lengths = {}for ch in ALPHABET:# oracle() compresses (guess + secret) and returns the ciphertext lengthlengths[ch] = oracle(known + ch)known += min(lengths, key=lengths.get)print(known)
Compress and Attack is exactly this: the server compresses your input together with the flag and then encrypts the result, and the encryption is irrelevant because a stream cipher preserves length. This is the CTF-sized version of CRIME and BREACH, which used the same property against real TLS to extract session cookies.
Cache and eviction channels
The most modern channel, and the one that shows up when a challenge has closed every direct exfiltration path. If a strict Content-Security-Policy stops a payload from sending data anywhere, the payload can still touch things, and which things it touched may be observable afterwards.
The mechanism is eviction order. A fixed-size cache with a least-recently-used policy discards whatever was used longest ago. So if a secret-dependent branch touches key A but not key B, then after filling the cache to force eviction, exactly one of A and B survives, and a single request telling you which one is a bit of the secret.
| Phase | What you do |
|---|---|
| Prime | Fill the cache with markers you control, in a known order |
| Trigger | Cause the victim to process the secret, touching some markers and not others |
| Evict | Insert enough new entries to push out everything not recently touched |
| Probe | Ask which markers survive. The survivors encode the branch taken |
paper-2 is a full worked example at web scale: XSLT injection gives code execution inside the document processor, a strict CSP blocks every outbound request, and the solution turns a Redis LRU buffer into the output channel. Marker pairs get prefilled, the payload touches one of each pair depending on the secret, eviction runs, and cheap HEAD requests read out which survived. It is worth reading even if you never meet the exact setup again, because it demonstrates the general move: when you cannot send data out, find a shared resource whose state you can both influence and observe.
Protocol oracles
Some leaks need no measurement at all, because the protocol hands them to you. An oracle in this sense is any service that will perform a secret-key operation on input you choose and tell you something about the result. The side channel is the willingness itself.
When: A service decrypts anything except the target. Blind the target, get the blinded version decrypted, undo the blinding
When: A service encrypts what you pick. Feed structured inputs and read structure back out of the ciphertexts
rsa_oracle is the textbook decryption oracle. Textbook RSA is multiplicatively homomorphic, meaning the ciphertext of a product is the product of the ciphertexts. The server refuses to decrypt one specific ciphertext, so you multiply it by the encryption of 2, hand over the result (which is not on the blacklist because it is a different number), and divide the returned plaintext by 2. The blacklist was never a security control, only a filter on one exact value.
Clouds is the chosen-plaintext version taken seriously: a chosen-plaintext oracle over the Nimbus block cipher, broken with differential cryptanalysis by choosing plaintext pairs with a fixed XOR difference and watching which difference survives the round function. Secure Dot Product combines a forgeable MAC with a linear oracle: length extension gets you valid queries, and the linear responses become a system of equations you solve for the key material. If the length-extension half is unfamiliar, read hash length extension first.
A near neighbour worth naming: when the key is not leaked but simply predictable, the "channel" is the clock. Timestamped Secrets derives an AES key from the current time, and ChronoHack seeds a PRNG the same way. Both collapse to a search over a few thousand plausible timestamps. That is the subject of insecure randomness, but it belongs in your head next to side channels, because the diagnostic question is identical: what does this secret actually depend on, and can I observe or guess that instead?
Measuring without fooling yourself
Most failed side-channel attempts are not wrong attacks, they are correct attacks buried in measurement error. Five rules cover nearly all of it.
| Rule | Why |
|---|---|
| Take many samples and use the median | Noise inflates measurements upward only. The median discards those outliers |
| Interleave candidates rather than testing each to completion | Machine load drifts over minutes. Testing candidate A fully then B fully compares A against a different machine than B |
| Validate on a known secret first | If the leak is not visible when you already know the answer, the harness is broken, not the theory |
| Check the margin, not just the winner | If the best candidate barely beats the second, you are reading noise. Collect more samples until it separates |
| Reuse one connection when the protocol allows | TCP handshakes dominate the timing budget and swamp a microsecond-scale leak |
Why the fixes look strange
Knowing the defenses is a solving aid, because a challenge that pointedly does not use one is telling you where the intended leak is.
| Defense | What it does | Where you see it |
|---|---|---|
| hmac.compare_digest | Compares every byte regardless of mismatches, so duration is constant | Python token and signature checks |
| CRYPTO_memcmp | The same guarantee in C, accumulating differences with OR instead of branching | OpenSSL MAC verification |
| Masking and blinding | Randomises intermediate values per execution so leakage decorrelates from the key | Smartcards, hardened AES and RSA |
| Separate compression contexts | Never compresses attacker input together with a secret | Post-CRIME TLS |
| OAEP padding | Destroys RSA malleability so ciphertexts cannot be multiplied meaningfully | Real RSA, per RFC 8017 |
The pattern across all five: the fix is to make the observable independent of the secret, not to make it smaller. Faster comparison does not help. Constant comparison does.
picoCTF challenges
| Challenge | Channel | Difficulty |
|---|---|---|
| SideChannel | Timing. Early-exit PIN comparison, recovered one digit at a time | Hard |
| PowerAnalysis Warmup | Power. One scalar leakage value per query, no trace processing | Hard |
| PowerAnalysis Part 1 | Power. Real traces, CPA on the first-round S-box with scared | Hard |
| PowerAnalysis Part 2 | Power. 100 noisy traces. Averaging and pre-processing carry the attack | Hard |
| Compress and Attack | Compression. CRIME-style length oracle, one flag byte per round | Hard |
| paper-2 | Cache. Redis LRU eviction order as an output channel under strict CSP | Hard |
| rsa_oracle | Protocol. Multiplicative blinding past a single-value blacklist | Medium |
| Clouds | Protocol. Chosen-plaintext differential cryptanalysis of Nimbus | Hard |
| Secure Dot Product | Protocol. Length-extension forgery feeding a linear oracle | Hard |
| jitfp | Timing and runtime state. Per-character function pointer resolution | Hard |
| Timestamped Secrets | Predictability. An AES key derived from the clock | Medium |
| ChronoHack | Predictability. A PRNG seeded from the current time | Medium |
Quick reference
# Timing: measure medians, interleave candidates, reuse connectionspython3 -c "import time;t=time.perf_counter();...;print(time.perf_counter()-t)"# Power: CPA target is always SBOX[plaintext ^ key], Hamming weight modelpip install scared numpy# Compression: shorter output means your guess matched more of the secretpython3 -c "import zlib;print(len(zlib.compress(b'guess'+b'secret')))"# Oracle sanity check: does the service transform input under a secret key# and tell you anything at all about the result? Then it is an oracle.# Constant-time comparison, for recognising what is NOT vulnerablepython3 -c "import hmac;print(hmac.compare_digest(b'a',b'b'))"
Related reading: AES for CTF for what the S-box target means, RSA attacks for the malleability that oracle attacks exploit, padding oracles for the best-known protocol oracle of them all, and insecure randomness for when the secret was never random to begin with.
Sources and further reading
This field has unusually readable founding papers. Both are worth an afternoon.
- Kocher, "Timing Attacks on Implementations of Diffie-Hellman, RSA, DSS, and Other Systems" (1996) introduced the entire idea that execution time is an output channel.
- Kocher, Jaffe and Jun, "Differential Power Analysis" (1999) for the statistical machinery that CPA refines, and scared for a working implementation to attack traces with.
- BREACH for the real-world compression oracle, which is the same attack as the CTF version at internet scale.
hmac.compare_digestandCRYPTO_memcmpfor what a constant-time comparison looks like, and RFC 8017 for the OAEP padding that removes the RSA malleability oracle attacks depend on.