The demo below simulates a server that compares your guess against a hidden secret. The vulnerable version exits as soon as a byte mismatches — so a correct prefix takes longer than a wrong one. Use that difference to discover the secret one byte at a time.
<!-- Demo layout — controls, secret display, guess boxes, timing bar, log -->
<div class="controls">
<label class="mode-label">
<input type="checkbox" id="ctMode"> Constant-time mode
</label>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div class="secret-row">
<span class="secret-label">Secret:</span>
<span id="secretDisplay" class="secret-display">?????</span>
</div>
<div class="guess-row">
<span class="guess-label">Recovered:</span>
<div id="byteBoxes" class="byte-boxes"></div>
</div>
<div class="timing-row">
<span class="timing-label">Timing:</span>
<div id="timingBar" class="timing-bar-wrap">
<div id="timingFill" class="timing-fill"></div>
<span id="timingVal" class="timing-val">—</span>
</div>
</div>
<div id="status" class="status"></div>
<div class="action-row">
<button id="guessBtn" type="button">Guess next byte</button>
<button id="autoBtn" type="button">Auto-recover all</button>
</div>
<div class="log-wrap">
<div class="log-header">Attack log</div>
<div id="log" class="log"></div>
</div>
/* Styling for the timing side-channel demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: .92rem; }
.controls { display: flex; align-items: center; gap: 1rem; margin-bottom: .6rem; }
.mode-label { display: flex; align-items: center; gap: .4rem; font-weight: 600; cursor: pointer; }
.secret-row, .guess-row, .timing-row { display: flex; align-items: center; gap: .5rem; margin: .35rem 0; }
.secret-label, .guess-label, .timing-label { width: 5.5rem; font-weight: 600; color: #444; flex-shrink: 0; }
.secret-display { font: 700 1.1rem ui-monospace, monospace; letter-spacing: .18em; color: #1d3557; }
.byte-boxes { display: flex; gap: 4px; }
.byte-box { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center;
font: 700 .9rem ui-monospace, monospace; border-radius: 6px; border: 1px solid #b0b8c4;
background: #e8eef3; color: #1d3557; transition: background .15s; }
.byte-box.match { background: #b7e8c4; border-color: #5db87a; }
.byte-box.guess-cur { background: #ffefc0; border-color: #e6a817; }
.timing-bar-wrap { flex: 1; display: flex; align-items: center; gap: .5rem; height: 18px; }
.timing-fill { height: 12px; border-radius: 6px; background: #e63946; transition: width .2s; min-width: 0; }
.timing-val { font: 600 .85rem ui-monospace, monospace; color: #555; white-space: nowrap; }
.status { min-height: 1.4em; font-weight: 700; margin: .4rem 0; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #1d3557; }
.action-row { display: flex; gap: .5rem; margin-bottom: .5rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: not-allowed; }
.log-wrap { border: 1px solid #dde2e8; border-radius: 8px; overflow: hidden; }
.log-header { background: #f0f4f8; padding: .3rem .6rem; font-weight: 600; font-size: .82rem; color: #444; }
.log { max-height: 130px; overflow-y: auto; font: .8rem ui-monospace, monospace; color: #333; }
.log-entry { padding: .18rem .6rem; border-bottom: 1px solid #edf0f3; }
.log-entry.match { background: #f0faf4; }
.log-entry.found { background: #e8f4ea; color: #0a7d33; font-weight: 700; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Timing side-channel demo: in vulnerable mode, the comparison exits */
/* early on mismatch, leaking the matched prefix length through timing. */
const SECRET = 'k1pu5'; // the hidden secret — the attacker does not know this
const LEN = SECRET.length;
let ctMode = false;
let recovered = []; // bytes recovered so far by the timing attack
let running = false;
/* simulateCompare: returns matched count and simulated response time */
function simulateCompare(guess, secretStr, constantTime) {
let matched = 0;
for (let i = 0; i < secretStr.length; i++) {
if (guess[i] === secretStr[i]) {
matched++;
} else if (!constantTime) {
break; // early exit — leaks position of first mismatch
}
}
// base response time in microseconds (microseconds simulated)
const base = 10;
// extra time per matched byte (0 in constant-time mode)
const nsPerMatch = constantTime ? 0 : 8;
// add small random noise to simulate real network/CPU jitter
const noise = (Math.random() - 0.5) * 4;
return { matched, time: base + matched * nsPerMatch + noise };
}
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; // candidate characters to try for each byte position
function buildBoxes() {
const wrap = document.getElementById('byteBoxes');
wrap.innerHTML = '';
for (let i = 0; i < LEN; i++) {
const d = document.createElement('div');
d.className = 'byte-box';
d.id = 'box-' + i;
d.textContent = recovered[i] || '?';
if (recovered[i]) d.classList.add('match');
wrap.appendChild(d);
}
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status ' + cls;
}
function setTimingBar(ns, maxNs) {
const fill = document.getElementById('timingFill');
const val = document.getElementById('timingVal');
const pct = Math.min(100, (ns / maxNs) * 100);
fill.style.width = pct + '%';
val.textContent = ns.toFixed(1) + ' µs';
}
function logEntry(msg, cls) {
const log = document.getElementById('log');
const d = document.createElement('div');
d.className = 'log-entry' + (cls ? ' ' + cls : '');
d.textContent = msg;
log.appendChild(d);
log.scrollTop = log.scrollHeight;
}
function guessOneByte(pos) {
// try every character and pick the one that yields the longest response time
let best = null, bestTime = -Infinity;
const samples = 5; // average over multiple samples to reduce noise
for (const ch of ALPHABET) {
let totalTime = 0;
const prefix = recovered.slice(0, pos).join('') + ch + '?'.repeat(LEN - pos - 1);
for (let s = 0; s < samples; s++) {
totalTime += simulateCompare(prefix, SECRET, ctMode).time;
}
const avg = totalTime / samples;
if (avg > bestTime) { bestTime = avg; best = ch; }
}
return { best, time: bestTime };
}
document.getElementById('ctMode').addEventListener('change', function () {
ctMode = this.checked;
// update status when mode changes
setStatus(ctMode ? 'Constant-time mode ON — timing signal is gone.' : 'Vulnerable mode — timing leaks secret bytes.', 'info');
});
document.getElementById('resetBtn').addEventListener('click', () => {
recovered = [];
running = false;
document.getElementById('autoBtn').disabled = false;
document.getElementById('guessBtn').disabled = false;
document.getElementById('log').innerHTML = '';
document.getElementById('secretDisplay').textContent = '?'.repeat(LEN);
setTimingBar(0, 50);
setStatus('', '');
buildBoxes();
});
document.getElementById('guessBtn').addEventListener('click', () => {
if (recovered.length >= LEN) return;
const pos = recovered.length;
const { best, time } = guessOneByte(pos);
recovered.push(best);
setTimingBar(time, 50 + LEN * 8);
buildBoxes();
const box = document.getElementById('box-' + pos);
if (box) box.classList.add('guess-cur');
logEntry(i18nFmt('Byte %pos%: ’%ch%’ (avg time %time% µs)', { pos: pos + 1, ch: best, time: time.toFixed(1) }), 'match');
if (recovered.length === LEN) {
const guess = recovered.join('');
if (guess === SECRET) {
document.getElementById('secretDisplay').textContent = SECRET;
setStatus('Secret fully recovered via timing attack!', 'ok');
logEntry('All bytes recovered! The timing oracle revealed the entire secret.', 'found');
} else {
setStatus('Recovery failed — try again (noise can mislead in constant-time mode).', 'bad');
}
} else {
setStatus(i18nFmt('Recovered %n% of %total% bytes…', { n: recovered.length, total: LEN }), 'info');
}
});
document.getElementById('autoBtn').addEventListener('click', async () => {
if (running) return;
running = true;
recovered = [];
document.getElementById('log').innerHTML = '';
document.getElementById('autoBtn').disabled = true;
document.getElementById('guessBtn').disabled = true;
document.getElementById('secretDisplay').textContent = '?'.repeat(LEN);
buildBoxes();
setStatus('Running timing attack…', 'info');
for (let pos = 0; pos < LEN; pos++) {
await new Promise(r => setTimeout(r, 120)); // small pause so the user can watch each byte being recovered
const { best, time } = guessOneByte(pos);
recovered.push(best);
setTimingBar(time, 50 + LEN * 8);
buildBoxes();
logEntry(i18nFmt('Byte %pos%: ’%ch%’ (avg time %time% µs)', { pos: pos + 1, ch: best, time: time.toFixed(1) }), 'match');
}
running = false;
document.getElementById('autoBtn').disabled = false;
document.getElementById('guessBtn').disabled = false;
const guess = recovered.join('');
if (guess === SECRET) {
document.getElementById('secretDisplay').textContent = SECRET;
setStatus('Secret fully recovered via timing attack!', 'ok');
logEntry('All bytes recovered! The timing oracle revealed the entire secret.', 'found');
} else {
setStatus('Recovery failed — try again (noise can mislead in constant-time mode).', 'bad');
logEntry(i18nFmt('Constant-time mode: no signal — recovered ’%guess%’ (may be wrong).', { guess: guess }), '');
}
});
buildBoxes();
setStatus('Click «Guess next byte» or «Auto-recover all» to start the attack.', 'info');
<!-- Demo layout — controls, secret display, guess boxes, timing bar, log -->
<div class="controls">
<label class="mode-label">
<input type="checkbox" id="ctMode"> Constant-time mode
</label>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div class="secret-row">
<span class="secret-label">Secret:</span>
<span id="secretDisplay" class="secret-display">?????</span>
</div>
<div class="guess-row">
<span class="guess-label">Recovered:</span>
<div id="byteBoxes" class="byte-boxes"></div>
</div>
<div class="timing-row">
<span class="timing-label">Timing:</span>
<div id="timingBar" class="timing-bar-wrap">
<div id="timingFill" class="timing-fill"></div>
<span id="timingVal" class="timing-val">—</span>
</div>
</div>
<div id="status" class="status"></div>
<div class="action-row">
<button id="guessBtn" type="button">Guess next byte</button>
<button id="autoBtn" type="button">Auto-recover all</button>
</div>
<div class="log-wrap">
<div class="log-header">Attack log</div>
<div id="log" class="log"></div>
</div>
/* Styling for the timing side-channel demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: .92rem; }
.controls { display: flex; align-items: center; gap: 1rem; margin-bottom: .6rem; }
.mode-label { display: flex; align-items: center; gap: .4rem; font-weight: 600; cursor: pointer; }
.secret-row, .guess-row, .timing-row { display: flex; align-items: center; gap: .5rem; margin: .35rem 0; }
.secret-label, .guess-label, .timing-label { width: 5.5rem; font-weight: 600; color: #444; flex-shrink: 0; }
.secret-display { font: 700 1.1rem ui-monospace, monospace; letter-spacing: .18em; color: #1d3557; }
.byte-boxes { display: flex; gap: 4px; }
.byte-box { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center;
font: 700 .9rem ui-monospace, monospace; border-radius: 6px; border: 1px solid #b0b8c4;
background: #e8eef3; color: #1d3557; transition: background .15s; }
.byte-box.match { background: #b7e8c4; border-color: #5db87a; }
.byte-box.guess-cur { background: #ffefc0; border-color: #e6a817; }
.timing-bar-wrap { flex: 1; display: flex; align-items: center; gap: .5rem; height: 18px; }
.timing-fill { height: 12px; border-radius: 6px; background: #e63946; transition: width .2s; min-width: 0; }
.timing-val { font: 600 .85rem ui-monospace, monospace; color: #555; white-space: nowrap; }
.status { min-height: 1.4em; font-weight: 700; margin: .4rem 0; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #1d3557; }
.action-row { display: flex; gap: .5rem; margin-bottom: .5rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: not-allowed; }
.log-wrap { border: 1px solid #dde2e8; border-radius: 8px; overflow: hidden; }
.log-header { background: #f0f4f8; padding: .3rem .6rem; font-weight: 600; font-size: .82rem; color: #444; }
.log { max-height: 130px; overflow-y: auto; font: .8rem ui-monospace, monospace; color: #333; }
.log-entry { padding: .18rem .6rem; border-bottom: 1px solid #edf0f3; }
.log-entry.match { background: #f0faf4; }
.log-entry.found { background: #e8f4ea; color: #0a7d33; font-weight: 700; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Timing side-channel demo: in vulnerable mode, the comparison exits */
/* early on mismatch, leaking the matched prefix length through timing. */
const SECRET = 'k1pu5'; // the hidden secret — the attacker does not know this
const LEN = SECRET.length;
let ctMode = false;
let recovered = []; // bytes recovered so far by the timing attack
let running = false;
/* simulateCompare: returns matched count and simulated response time */
function simulateCompare(guess, secretStr, constantTime) {
let matched = 0;
for (let i = 0; i < secretStr.length; i++) {
if (guess[i] === secretStr[i]) {
matched++;
} else if (!constantTime) {
break; // early exit — leaks position of first mismatch
}
}
// base response time in microseconds (microseconds simulated)
const base = 10;
// extra time per matched byte (0 in constant-time mode)
const nsPerMatch = constantTime ? 0 : 8;
// add small random noise to simulate real network/CPU jitter
const noise = (Math.random() - 0.5) * 4;
return { matched, time: base + matched * nsPerMatch + noise };
}
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; // candidate characters to try for each byte position
function buildBoxes() {
const wrap = document.getElementById('byteBoxes');
wrap.innerHTML = '';
for (let i = 0; i < LEN; i++) {
const d = document.createElement('div');
d.className = 'byte-box';
d.id = 'box-' + i;
d.textContent = recovered[i] || '?';
if (recovered[i]) d.classList.add('match');
wrap.appendChild(d);
}
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status ' + cls;
}
function setTimingBar(ns, maxNs) {
const fill = document.getElementById('timingFill');
const val = document.getElementById('timingVal');
const pct = Math.min(100, (ns / maxNs) * 100);
fill.style.width = pct + '%';
val.textContent = ns.toFixed(1) + ' µs';
}
function logEntry(msg, cls) {
const log = document.getElementById('log');
const d = document.createElement('div');
d.className = 'log-entry' + (cls ? ' ' + cls : '');
d.textContent = msg;
log.appendChild(d);
log.scrollTop = log.scrollHeight;
}
function guessOneByte(pos) {
// try every character and pick the one that yields the longest response time
let best = null, bestTime = -Infinity;
const samples = 5; // average over multiple samples to reduce noise
for (const ch of ALPHABET) {
let totalTime = 0;
const prefix = recovered.slice(0, pos).join('') + ch + '?'.repeat(LEN - pos - 1);
for (let s = 0; s < samples; s++) {
totalTime += simulateCompare(prefix, SECRET, ctMode).time;
}
const avg = totalTime / samples;
if (avg > bestTime) { bestTime = avg; best = ch; }
}
return { best, time: bestTime };
}
document.getElementById('ctMode').addEventListener('change', function () {
ctMode = this.checked;
// update status when mode changes
setStatus(ctMode ? 'Constant-time mode ON — timing signal is gone.' : 'Vulnerable mode — timing leaks secret bytes.', 'info');
});
document.getElementById('resetBtn').addEventListener('click', () => {
recovered = [];
running = false;
document.getElementById('autoBtn').disabled = false;
document.getElementById('guessBtn').disabled = false;
document.getElementById('log').innerHTML = '';
document.getElementById('secretDisplay').textContent = '?'.repeat(LEN);
setTimingBar(0, 50);
setStatus('', '');
buildBoxes();
});
document.getElementById('guessBtn').addEventListener('click', () => {
if (recovered.length >= LEN) return;
const pos = recovered.length;
const { best, time } = guessOneByte(pos);
recovered.push(best);
setTimingBar(time, 50 + LEN * 8);
buildBoxes();
const box = document.getElementById('box-' + pos);
if (box) box.classList.add('guess-cur');
logEntry(i18nFmt('Byte %pos%: ’%ch%’ (avg time %time% µs)', { pos: pos + 1, ch: best, time: time.toFixed(1) }), 'match');
if (recovered.length === LEN) {
const guess = recovered.join('');
if (guess === SECRET) {
document.getElementById('secretDisplay').textContent = SECRET;
setStatus('Secret fully recovered via timing attack!', 'ok');
logEntry('All bytes recovered! The timing oracle revealed the entire secret.', 'found');
} else {
setStatus('Recovery failed — try again (noise can mislead in constant-time mode).', 'bad');
}
} else {
setStatus(i18nFmt('Recovered %n% of %total% bytes…', { n: recovered.length, total: LEN }), 'info');
}
});
document.getElementById('autoBtn').addEventListener('click', async () => {
if (running) return;
running = true;
recovered = [];
document.getElementById('log').innerHTML = '';
document.getElementById('autoBtn').disabled = true;
document.getElementById('guessBtn').disabled = true;
document.getElementById('secretDisplay').textContent = '?'.repeat(LEN);
buildBoxes();
setStatus('Running timing attack…', 'info');
for (let pos = 0; pos < LEN; pos++) {
await new Promise(r => setTimeout(r, 120)); // small pause so the user can watch each byte being recovered
const { best, time } = guessOneByte(pos);
recovered.push(best);
setTimingBar(time, 50 + LEN * 8);
buildBoxes();
logEntry(i18nFmt('Byte %pos%: ’%ch%’ (avg time %time% µs)', { pos: pos + 1, ch: best, time: time.toFixed(1) }), 'match');
}
running = false;
document.getElementById('autoBtn').disabled = false;
document.getElementById('guessBtn').disabled = false;
const guess = recovered.join('');
if (guess === SECRET) {
document.getElementById('secretDisplay').textContent = SECRET;
setStatus('Secret fully recovered via timing attack!', 'ok');
logEntry('All bytes recovered! The timing oracle revealed the entire secret.', 'found');
} else {
setStatus('Recovery failed — try again (noise can mislead in constant-time mode).', 'bad');
logEntry(i18nFmt('Constant-time mode: no signal — recovered ’%guess%’ (may be wrong).', { guess: guess }), '');
}
});
buildBoxes();
setStatus('Click «Guess next byte» or «Auto-recover all» to start the attack.', 'info');
Comments
Loading comments...