Below is a tiny "server". It holds a secret string and checks your guess with a naive comparison: it walks the characters and returns the instant one is wrong — taking a tiny bit of time per matching character first. You never see the secret, only the response time.
<p class="hint">The "server" holds a secret string. Its check walks character by character and <b>returns the instant</b> one is wrong — spending a little time per matching character first. You never see the secret, only the <b>response time</b>. Run the attack to recover it one position at a time.</p>
<div class="secretline">Secret length: <b id="len">6</b> · Alphabet: <code>a–z 0–9</code></div>
<div class="recovered">Recovered: <span id="rec" class="rec"></span><span class="cursor">_</span></div>
<div class="bars" id="bars"></div>
<div class="status" id="status">Press "Run timing attack".</div>
<div class="tally" id="tally"></div>
<div class="btns">
<button id="run" type="button">Run timing attack</button>
<button id="new" type="button" class="ghost">New secret</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.secretline { font-size: .9rem; color: #555; margin: .2rem 0 .5rem; }
code { background: #eef2f6; padding: .05rem .3rem; border-radius: 4px; }
.recovered { font-size: 1.15rem; margin: .3rem 0 .6rem; }
.rec { font: 700 1.2rem ui-monospace, monospace; color: #0a7d33; letter-spacing: 2px; }
.cursor { font: 700 1.2rem ui-monospace, monospace; color: #1d3557; animation: blink 1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.bars { display: grid; grid-template-columns: repeat(18, 1fr); gap: 2px; align-items: end;
height: 96px; margin: .5rem 0; padding: 4px; background: #f3f5f8; border-radius: 8px; }
.bar { background: #c9ccd1; border-radius: 2px 2px 0 0; min-height: 3px; position: relative; transition: height .03s, background .1s; }
.bar.best { background: #1d3557; }
.bar span { position: absolute; bottom: -15px; left: 50%; transform: translateX(-50%);
font: 9px ui-monospace, monospace; color: #777; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0 .2rem; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.tally { font-size: .85rem; color: #555; margin-bottom: .5rem; min-height: 1.2em; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Side-channel / timing attack on a NAIVE string comparison.
// The server's check returns as soon as a character is wrong, after spending a
// fixed cost per already-matching character. So the *time* to reject a guess
// grows with how long a correct PREFIX it shares with the secret. By trying every
// character at one position and keeping the slowest-to-reject, we recover the secret one position at a time: ~k*n probes instead of a blind k^n search.
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'.split(''); // k = 36
const LEN = 6; // n = 6
const COST = 1000; // simulated work units per matching character
let secret = '';
function newSecret(){
secret = '';
for (let i = 0; i < LEN; i++) secret += ALPHABET[Math.floor(Math.random()*ALPHABET.length)];
document.getElementById('len').textContent = LEN;
document.getElementById('rec').textContent = '';
setStatus('Press "Run timing attack".','');
document.getElementById('tally').textContent = '';
drawBars(new Array(ALPHABET.length).fill(0), -1);
}
// The vulnerable "server": time to reject == COST * (length of matching prefix) + tiny noise.
// (We model time instead of literally sleeping, so the demo is instant and deterministic-ish.)
function responseTime(guess){
let matched = 0;
for (let i = 0; i < secret.length; i++){
if (guess[i] === secret[i]) matched++; // one more step of work...
else break; // ...then bail on first mismatch
}
const noise = Math.random()*0.4 - 0.2; // measurement jitter
return matched * COST + noise;
}
function setStatus(t, cls){ const s = document.getElementById('status'); s.textContent = t; s.className = 'status '+cls; }
function drawBars(times, bestIdx){
const wrap = document.getElementById('bars'); wrap.innerHTML = '';
const max = Math.max(1, ...times);
times.forEach((t, i) => {
const b = document.createElement('div');
b.className = 'bar' + (i === bestIdx ? ' best' : '');
b.style.height = (8 + (t/max)*78) + 'px';
const lbl = document.createElement('span'); lbl.textContent = ALPHABET[i]; b.appendChild(lbl);
wrap.appendChild(b);
});
}
async function attack(){
document.getElementById('run').disabled = true;
document.getElementById('new').disabled = true;
let known = '';
let probes = 0;
for (let pos = 0; pos < LEN; pos++){
const times = new Array(ALPHABET.length).fill(0);
let bestIdx = 0, bestT = -Infinity;
for (let i = 0; i < ALPHABET.length; i++){
// average a few measurements to beat the jitter
let sum = 0;
for (let r = 0; r < 5; r++){ sum += responseTime(known + ALPHABET[i] + 'x'); probes++; }
times[i] = sum / 5;
if (times[i] > bestT){ bestT = times[i]; bestIdx = i; }
if (i % 4 === 0){ drawBars(times, bestIdx); await sleep(4); }
}
drawBars(times, bestIdx);
known += ALPHABET[bestIdx];
document.getElementById('rec').textContent = known;
setStatus(fmt('Locked character %pos%%v% of %LEN%: "%v2%"', { pos: pos, v: 1, LEN: LEN, v2: ALPHABET[bestIdx] }), '');
document.getElementById('tally').textContent = fmt('Probes so far: %probes%', { probes: probes });
await sleep(220);
}
const ok = known === secret;
setStatus(ok ? fmt('✓ Secret recovered: "%known%" — in %probes% probes (blind search ≈ %pow%).', { known: known, probes: probes, pow: Math.pow(ALPHABET.length, LEN).toLocaleString() })
: fmt('✗ Recovered "%known%" — actual "%secret%".', { known: known, secret: secret }), ok ? 'ok' : '');
console.log('secret =', secret, '|', 'recovered =', known, '|', 'probes =', probes);
document.getElementById('run').disabled = false;
document.getElementById('new').disabled = false;
}
function sleep(ms){ return new Promise(r => setTimeout(r, ms)); }
document.getElementById('run').addEventListener('click', attack);
document.getElementById('new').addEventListener('click', newSecret);
newSecret();
<p class="hint">The "server" holds a secret string. Its check walks character by character and <b>returns the instant</b> one is wrong — spending a little time per matching character first. You never see the secret, only the <b>response time</b>. Run the attack to recover it one position at a time.</p>
<div class="secretline">Secret length: <b id="len">6</b> · Alphabet: <code>a–z 0–9</code></div>
<div class="recovered">Recovered: <span id="rec" class="rec"></span><span class="cursor">_</span></div>
<div class="bars" id="bars"></div>
<div class="status" id="status">Press "Run timing attack".</div>
<div class="tally" id="tally"></div>
<div class="btns">
<button id="run" type="button">Run timing attack</button>
<button id="new" type="button" class="ghost">New secret</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.secretline { font-size: .9rem; color: #555; margin: .2rem 0 .5rem; }
code { background: #eef2f6; padding: .05rem .3rem; border-radius: 4px; }
.recovered { font-size: 1.15rem; margin: .3rem 0 .6rem; }
.rec { font: 700 1.2rem ui-monospace, monospace; color: #0a7d33; letter-spacing: 2px; }
.cursor { font: 700 1.2rem ui-monospace, monospace; color: #1d3557; animation: blink 1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.bars { display: grid; grid-template-columns: repeat(18, 1fr); gap: 2px; align-items: end;
height: 96px; margin: .5rem 0; padding: 4px; background: #f3f5f8; border-radius: 8px; }
.bar { background: #c9ccd1; border-radius: 2px 2px 0 0; min-height: 3px; position: relative; transition: height .03s, background .1s; }
.bar.best { background: #1d3557; }
.bar span { position: absolute; bottom: -15px; left: 50%; transform: translateX(-50%);
font: 9px ui-monospace, monospace; color: #777; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0 .2rem; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.tally { font-size: .85rem; color: #555; margin-bottom: .5rem; min-height: 1.2em; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Side-channel / timing attack on a NAIVE string comparison.
// The server's check returns as soon as a character is wrong, after spending a
// fixed cost per already-matching character. So the *time* to reject a guess
// grows with how long a correct PREFIX it shares with the secret. By trying every
// character at one position and keeping the slowest-to-reject, we recover the secret one position at a time: ~k*n probes instead of a blind k^n search.
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'.split(''); // k = 36
const LEN = 6; // n = 6
const COST = 1000; // simulated work units per matching character
let secret = '';
function newSecret(){
secret = '';
for (let i = 0; i < LEN; i++) secret += ALPHABET[Math.floor(Math.random()*ALPHABET.length)];
document.getElementById('len').textContent = LEN;
document.getElementById('rec').textContent = '';
setStatus('Press "Run timing attack".','');
document.getElementById('tally').textContent = '';
drawBars(new Array(ALPHABET.length).fill(0), -1);
}
// The vulnerable "server": time to reject == COST * (length of matching prefix) + tiny noise.
// (We model time instead of literally sleeping, so the demo is instant and deterministic-ish.)
function responseTime(guess){
let matched = 0;
for (let i = 0; i < secret.length; i++){
if (guess[i] === secret[i]) matched++; // one more step of work...
else break; // ...then bail on first mismatch
}
const noise = Math.random()*0.4 - 0.2; // measurement jitter
return matched * COST + noise;
}
function setStatus(t, cls){ const s = document.getElementById('status'); s.textContent = t; s.className = 'status '+cls; }
function drawBars(times, bestIdx){
const wrap = document.getElementById('bars'); wrap.innerHTML = '';
const max = Math.max(1, ...times);
times.forEach((t, i) => {
const b = document.createElement('div');
b.className = 'bar' + (i === bestIdx ? ' best' : '');
b.style.height = (8 + (t/max)*78) + 'px';
const lbl = document.createElement('span'); lbl.textContent = ALPHABET[i]; b.appendChild(lbl);
wrap.appendChild(b);
});
}
async function attack(){
document.getElementById('run').disabled = true;
document.getElementById('new').disabled = true;
let known = '';
let probes = 0;
for (let pos = 0; pos < LEN; pos++){
const times = new Array(ALPHABET.length).fill(0);
let bestIdx = 0, bestT = -Infinity;
for (let i = 0; i < ALPHABET.length; i++){
// average a few measurements to beat the jitter
let sum = 0;
for (let r = 0; r < 5; r++){ sum += responseTime(known + ALPHABET[i] + 'x'); probes++; }
times[i] = sum / 5;
if (times[i] > bestT){ bestT = times[i]; bestIdx = i; }
if (i % 4 === 0){ drawBars(times, bestIdx); await sleep(4); }
}
drawBars(times, bestIdx);
known += ALPHABET[bestIdx];
document.getElementById('rec').textContent = known;
setStatus(fmt('Locked character %pos%%v% of %LEN%: "%v2%"', { pos: pos, v: 1, LEN: LEN, v2: ALPHABET[bestIdx] }), '');
document.getElementById('tally').textContent = fmt('Probes so far: %probes%', { probes: probes });
await sleep(220);
}
const ok = known === secret;
setStatus(ok ? fmt('✓ Secret recovered: "%known%" — in %probes% probes (blind search ≈ %pow%).', { known: known, probes: probes, pow: Math.pow(ALPHABET.length, LEN).toLocaleString() })
: fmt('✗ Recovered "%known%" — actual "%secret%".', { known: known, secret: secret }), ok ? 'ok' : '');
console.log('secret =', secret, '|', 'recovered =', known, '|', 'probes =', probes);
document.getElementById('run').disabled = false;
document.getElementById('new').disabled = false;
}
function sleep(ms){ return new Promise(r => setTimeout(r, ms)); }
document.getElementById('run').addEventListener('click', attack);
document.getElementById('new').addEventListener('click', newSecret);
newSecret();
Press Run timing attack and watch it work. For each position it tries every possible character and keeps the one that takes longest to reject — that's the one that matched and pushed the comparison one step deeper. Position by position, the secret falls out. A blind search over a 6-character secret from this alphabet is millions of guesses; the timing attack needs only a few hundred. Same secret, but the stopwatch changed the difficulty class entirely.
Comments
Loading comments...