Skip to main content

August 3, 2026

Side-Channel Attacks for CTF: Timing, Power, Compression, and Cache Oracles

Recover secrets from what a program leaks rather than what it outputs: timing leaks, correlation power analysis, compression oracles, and cache eviction channels.

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.

StepWhat you doWhat it produces
1. Find the observableIdentify something you can measure that changes when the secret changesA number per query
2. Build the leakage modelPredict what that number should be for each candidate value of one small piece of the secretA prediction table
3. CorrelateFind which candidate's predictions match the measurements bestOne 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.

Key insight: Whenever a challenge gives you per-query feedback that is finer-grained than "correct" or "incorrect", look for the decomposition. Feedback that scores a partial answer converts exponential work into linear work, and that conversion is the whole game.

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 False
for a, b in zip(guess, secret):
if a != b:
return False # <-- returns as soon as it finds a mismatch
return 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, time
from pwn import remote
 
ALPHABET = string.digits # narrow it to what the target accepts
known = ''
 
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 signal
io = 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 noise
known += max(timings, key=timings.get)
print(known)
Warning: Use the median, not the mean. Network and scheduler noise is one-sided: a request can be arbitrarily slow but never faster than the work it does. That skews the mean upward on whichever candidate happened to hit a hiccup, while the median ignores outliers entirely. More than one otherwise correct timing script fails only because it averaged.

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 output
hyp = 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 point
num = hyp @ centered
den = np.sqrt((hyp @ hyp) * col_ss)
peak = np.max(np.abs(num / den))
if peak > best_corr:
best, best_corr = guess, peak
return best, best_corr
 
key = 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.

Note: The number of traces you need grows with the square of the noise, because the Pearson correlation between a model and a measurement converges as 1 over the square root of the number of traces. Doubling the noise means quadrupling the traces. When an attack that worked on clean data fails on noisy data, the fix is almost never a better model; it is averaging repeated measurements of identical inputs before you correlate.

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 string
 
ALPHABET = string.ascii_letters + string.digits + '_}'
known = 'picoCTF{'
 
while not known.endswith('}'):
lengths = {}
for ch in ALPHABET:
# oracle() compresses (guess + secret) and returns the ciphertext length
lengths[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.

Tip: Compression oracles get noisy when the length difference is a single byte and block-level padding hides it. Two standard fixes: pad your guess with random filler until the boundary moves (the "two-tries" method), and try several equivalent guesses per candidate and take the best result. If every candidate returns the same length, you are being rounded to a block boundary rather than told nothing.

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.

PhaseWhat you do
PrimeFill the cache with markers you control, in a known order
TriggerCause the victim to process the secret, touching some markers and not others
EvictInsert enough new entries to push out everything not recently touched
ProbeAsk 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.

Decryption oracleMedium

When: A service decrypts anything except the target. Blind the target, get the blinded version decrypted, undo the blinding

Chosen-plaintext oracleMedium

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.

RuleWhy
Take many samples and use the medianNoise inflates measurements upward only. The median discards those outliers
Interleave candidates rather than testing each to completionMachine load drifts over minutes. Testing candidate A fully then B fully compares A against a different machine than B
Validate on a known secret firstIf the leak is not visible when you already know the answer, the harness is broken, not the theory
Check the margin, not just the winnerIf the best candidate barely beats the second, you are reading noise. Collect more samples until it separates
Reuse one connection when the protocol allowsTCP handshakes dominate the timing budget and swamp a microsecond-scale leak
Tip: Build the harness so it reports the full ranking each round, not just the chosen character. When recovery goes wrong at position seven, the ranking tells you instantly whether the correct character was second (add samples) or nowhere (the model is wrong).

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.

DefenseWhat it doesWhere you see it
hmac.compare_digestCompares every byte regardless of mismatches, so duration is constantPython token and signature checks
CRYPTO_memcmpThe same guarantee in C, accumulating differences with OR instead of branchingOpenSSL MAC verification
Masking and blindingRandomises intermediate values per execution so leakage decorrelates from the keySmartcards, hardened AES and RSA
Separate compression contextsNever compresses attacker input together with a secretPost-CRIME TLS
OAEP paddingDestroys RSA malleability so ciphertexts cannot be multiplied meaningfullyReal 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

ChallengeChannelDifficulty
SideChannelTiming. Early-exit PIN comparison, recovered one digit at a timeHard
PowerAnalysis WarmupPower. One scalar leakage value per query, no trace processingHard
PowerAnalysis Part 1Power. Real traces, CPA on the first-round S-box with scaredHard
PowerAnalysis Part 2Power. 100 noisy traces. Averaging and pre-processing carry the attackHard
Compress and AttackCompression. CRIME-style length oracle, one flag byte per roundHard
paper-2Cache. Redis LRU eviction order as an output channel under strict CSPHard
rsa_oracleProtocol. Multiplicative blinding past a single-value blacklistMedium
CloudsProtocol. Chosen-plaintext differential cryptanalysis of NimbusHard
Secure Dot ProductProtocol. Length-extension forgery feeding a linear oracleHard
jitfpTiming and runtime state. Per-character function pointer resolutionHard
Timestamped SecretsPredictability. An AES key derived from the clockMedium
ChronoHackPredictability. A PRNG seeded from the current timeMedium

Quick reference

# Timing: measure medians, interleave candidates, reuse connections
python3 -c "import time;t=time.perf_counter();...;print(time.perf_counter()-t)"
 
# Power: CPA target is always SBOX[plaintext ^ key], Hamming weight model
pip install scared numpy
 
# Compression: shorter output means your guess matched more of the secret
python3 -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 vulnerable
python3 -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.

Run it in the browser

Tools on this site that do the work described above. No install, nothing uploaded: they run entirely in your browser.

Try it on these picoCTF challenges

Walkthroughs that put this technique to work, grouped by event.

Keep reading

Guides that build on the same ideas, plus the roadmap this topic sits under.