- Critical Thinking - Bug Bounty Podcast
- Posts
- [HackerNotes Ep. 193] Browser Quirks Galore with J0R1AN
[HackerNotes Ep. 193] Browser Quirks Galore with J0R1AN
Digging new research with J0r1an
Hacker TL;DR
Status-code XS-Leak via
:visited: 404s are skipped in browser history, so a top-level navigation plus a leakable:visitedselector turns a200vs404response into a no-interaction, cross-site oracle.Service workers revive ORB detection: a no-op fetch-proxying service worker rewrites
Sec-Fetch-Destto empty, hitting a Chrome ORB edge case that returns a loadable response instead of a network error, restoringonload/onerrorstatus detection on SameSite=None.DNS rebinding still works past LNA: fetch to localhost is now blocked, but navigations are not. Rebind in a popup, then read the body through the same-origin
windowreference to bypass both CORS and LNA.Declarative partial updates are sanitizer nightmare fuel: Chrome's new
<template for>feature smuggles payloads past server-side and client-side sanitizers, plus a<selectedcontent>DOMPurify race.
We do subs at $25, $10, and $5, premium subscribers get access to:
– Hackalongs: live bug bounty hacking on real programs, VODs available
– Live data streams, exploits, tools, scripts & un-redacted bug reports
Need a Pentest? We just launched CTBB Pentests!
Hack full time? Check out the Full-Time Hunter’s Guild!
Who's J0R1AN
Jorian Woltjer is a security researcher at Aikido (AI-driven pentesting and CVE research), who learned to hack through CTFs after a stint doing traditional pentesting in the Netherlands. He doesn't really do bug bounty. What he does is watch the CTBB Discord for questions nobody can answer, get nerd-sniped into solving them, and turn each one into a Critical Research Lab writeup. Most of the techniques below came out of exactly that loop. Follow him at @J0R1AN and on jorianwoltjer.com.
The Top-Level Status Code XS-Leak in CTFd
XS-Leaks are about squeezing a single boolean out of a cross-origin site, then chaining those yes/no answers 20-questions style to leak secrets one character at a time. The target here was CTFd, chosen because its features were already familiar.
The gadget. In the admin submissions view, CTFd exposes a paginated search. Request ?page=2: more than 50 matching items means the page exists (200), fewer than 50 means nothing overflows onto page two (404). That's a clean 200 / 404 oracle over a partial search of flag submissions.
Pro Tip: the "50 submissions" precondition isn't real. Regular users can submit flags too, so you pad the search prefix with 49 junk submissions and the 50th real match overflows the pagination.
Why the classic XS-Leak failed. The usual trick points a <script src> at the target: a 200 fires onload, a 404 fires onerror. But CTFd's cookies are SameSite=Lax, so a background request never carries the admin's session.
The :visited breakthrough. Two facts combine:
404 responses are silently skipped in browser history. A
200navigation lands in history, a404never shows up.The CSS
:visitedselector is leakable. Chrome restricts what you can style on:visitedto stop width-measurement leaks, but public performance-based leaks still exist: render a pile of tiny-width CJK glyphs ("Matrix rain" style) and a visited link forces measurably more layout work than an unvisited one.
Chained:
Open the target search URL in a new tab. Being a top-level navigation, SameSite=Lax cookies are sent.
A
200enters history, a404does not.Back on the attacker page, add a
:visited-styled link to that URL and run the heavy render.Measure. A heavy (visited) link means
200, means the guessed character was correct. Repeat per character.
The result is a 1-click, no-further-interaction status-code XS-Leak that pulls flags out of CTFd character by character.
Fun fact: this was almost killed by Chrome's history partitioning, which scopes :visited leakage to a URL's original opener. But the attacker is both the one opening the target and the one leaking it, so partitioning doesn't apply and the technique survives.
Solving an ORB Mystery
Same status-code leak, but fully in the background on SameSite=None cookies, fast and quiet.
The weird behavior. Someone in the Discord found that the <script src> status trick worked on xsleaks.dev but failed on example.com, probing the same target. The usual suspects (stored state, extensions, cookies) were ruled out, yet it flipped between working and not working seemingly at random.
The clue. A network-tab screenshot showed the request was served by a service worker. xsleaks.dev registers one, and it's a complete no-op: intercept fetch, re-fetch, return the response. That should change nothing, but it does. When a service worker re-issues a request with fetch(), it doesn't perfectly proxy it: the fetch API normalizes Sec-Fetch-Dest to empty. That one header was the only difference between the two outgoing requests:
# example.com (direct <script src>)
Sec-Fetch-Dest: script
# xsleaks.dev (through the no-op service worker)
Sec-Fetch-Dest: # empty
The nail in the coffin. Chrome's ORB (Opaque Response Blocking) logic is, in effect:
if (request.destination != "" /* e.g. "script" */) {
return network_error; // always errors, even on a 200
} else {
return empty_response; // 200-equivalent, triggers script onload
}
On a plain <script src> the destination is script, so ORB returns a network error regardless of the real status code, killing the onload / onerror distinction. Route the request through a fetch-proxying service worker and the destination becomes empty, so ORB returns an empty, loadable response that fires onload. The status-code oracle is back.
Pro Tip: you don't need a target that already has a service worker. Register your own no-op fetch-proxying service worker on the attacker origin, and status-code detection through <script> works again on SameSite=None targets, in the background, with no interaction.
Stopping Redirects
Cancelling or stalling a navigation sounds niche, but it comes up constantly, most notably in OAuth dirty dancing, where you need to freeze a redirect chain before the browser consumes the code. The Stopping Redirects writeup collects several primitives.
1. Weaponize dangling-markup protection (@kire_devs_hacks). Chrome blocks navigations to URLs that look like dangling-markup exfiltration: an angle bracket < anywhere plus a tab or newline anywhere. If you have partial control over a redirect destination, injecting < and a tab makes Chrome refuse to navigate.
2. Overflow the URL length limit. Browsers cap URLs at roughly 2 MB. A two-million-character destination simply won't navigate. Bonus recon: each backend layer (Cloudflare, Nginx, the app) caps request size at a different length with a different error, so binary-searching the cutoff fingerprints the stack. The limit also includes the hash fragment, which is preserved across same-origin navigations.
3. Leak built-in error pages via navigation.entries (the OAuth-code killer). A status code with no body (from cookie-bombing, or a too-long URI producing 431 / 414) makes Chrome render its "This site can't be reached" page in a null origin (chrome-error://). You can't read location.href on it, which breaks reading the OAuth code off the callback. But navigation.entries reads the URL strings, not the page's origin:
// XSS on victim.com opens the OAuth flow; a cookie-bomb or over-long state gives a bodyless error page
w = window.open("/oauth/callback?...", "popup");
// the error page is null-origin, so w.location.href is blocked
w.location = "about:blank"; // redirect back to any same-origin URL to regain document access
console.log(w.navigation.entries().map(e => e.url)); // exposes the "unreadable" URL
navigation.entries() (recently in Firefox too) returns the full URL of every history entry, including the null-origin error page. The same idea leaks a signed S3 URL out of a same-origin XSS context by reading the redirect destination back out of history.
4. Navigation throttling (@RafaX / corrupted_bytes). Chrome and Firefox throttle after 200 navigations in 10 seconds. On Firefox the counter persists across same-site, different-origin navigations. So burn 199 navigations on site A, move to same-site site B with one navigation left, let your javascript: redirect consume it, and the follow-up https: redirect that would clobber the XSS gets throttled. Fully client-side, and repurposable to intercept an OAuth code.
5. Sandbox someone else's document (found on a pentest). Auto-submitting POST forms are everywhere in login flows. The allow-forms sandbox flag is off by default (blocking submission), and a window opened from inside a sandboxed iframe retains that sandbox even cross-origin. Open the target from a sandboxed iframe without allow-forms and its auto-submit is dead, so an XSS that needs a click has time to fire. Dropping allow-scripts similarly neuters JavaScript while keeping CSS/HTML injection alive.
DNS Rebinding in the Browser
DNS rebinding: register a domain, serve it from your attacker IP first, then flip its DNS to 127.0.0.1. In the browser:
The victim visits
attacker.com(your IP) and you serve JS that loopsfetch("/").You repoint the DNS to localhost.
After the ~60s DNS cache expires, the same-origin
fetch("/")hits localhost, and since it's "same origin" you read the response freely. CORS bypassed. Works for192.168.x.xtoo, giving full local-network access from a webpage.
The LNA fix, and the navigation bypass. Chrome and Firefox now ship Local Network Access (LNA): even a same-origin fetch is blocked if the resolved IP is more local than the requesting origin, so fetch is dead. But navigations are not guarded. Drop fetch, rebind through a navigation, and read the body through the same-origin window reference:
<p id="msg">Click once</p>
<script>
if (name === "popup") msg.innerText = "Wait ~60s...";
onclick = async () => {
onclick = null;
w = window.open("/", "popup", "width=1,height=1,top=9999,left=9999");
msg.innerText = "Wait ~60s...";
while (true) {
try {
const text = await fetch("/", { cache: "reload" }).then(r => r.text());
} catch (e) {
break; // LNA error means we rebinded successfully
}
await new Promise(r => setTimeout(r, 2000));
}
msg.innerText = "Rebind successful! Check console";
w.location = "/"; // top-level navigation still works
setTimeout(() => {
console.log(w.document.documentElement.outerHTML); // same-origin read
}, 2000);
}
</script>
Navigation to localhost is allowed and the popup stays same-origin, so you can read w.document or even call w.fetch() from that now-local context. Known issue, no Chrome or Firefox activity since January.
Pro Tip: the common defense is a Host header check, since rebinding forces Host: blah.attacker.com and never localhost. Worth remembering how many local services (VS Code, MCP servers, every "run my AI app on localhost" tool) sit bound to loopback. Binding to localhost instead of 0.0.0.0 doesn't save you, because the victim's browser is visiting localhost. Audit your loopback ports.
My Own Popunder: The Weak-Password Prompt
A popunder opens a window and pushes it behind the current one in a single click. It makes double-clickjacking (Paulos Yibelo's technique) position-independent: instead of forcing the victim to click exactly where the target button renders, a movable, resizable popup slides under the cursor wherever they click. You can only moveTo / resizeTo a popup while it's same-origin, and you need it hidden behind the main window.
Chrome's PopunderPreventer kills the known ones. Renwa's classic (built on the Google sign-in prompt, which force-refocuses the main window) got patched, so the hunt through the Chromium source turned up the compromised-password notification. Submitting a login form with breached creds triggers it, and it yanks focus back to the main window. Fire it and open the popup in the same click:
<form id="form" action="/loading.html">
<input name="username" type="text" value="admin"><br>
<input name="password" type="password" value="admin"><br>
<button type="submit">Trigger popunder</button>
</form>
<script>
form.onsubmit = () => {
window.open("about:blank", "popup", "popup");
}
</script>
The popup opens in front, the form submits admin:admin in the background, and the notification snaps focus back to the main window, dropping the popup behind it. To clear the leftover password warning cleanly, watch the popup's blur event (it fires the moment it's pushed behind) and navigate away then (a blob: URL counts as cross-origin):
<h1>Waiting for blur...</h1>
<script>
w = window.open('', 'popup');
w.onblur = () => {
w.location = "https://example.com";
location = URL.createObjectURL(new Blob(["<h1>Popunder complete!</h1>"], { type: "text/html" }));
};
</script>
From there you move, resize, and redirect the popunder to the target. Live PoC at jtw.sh/popunder. Reported to Chrome and closed, since they don't seem to care much about popunders.
Declarative Partial Updates: A New Class of Sanitizer Bypasses
Chrome quietly shipped declarative partial updates, meant for out-of-order streaming: send the skeleton, fill in the data later. During parsing the browser recognizes processing instructions like <?marker name="x">, which act as named blank slots in HTML, SVG, or MathML. A later <template for="x"> dumps its content into the matching slot:
<?marker name="x">
...
<template for="x"><script>alert(1)</script></template>
<!-- the script ends up where the marker was, not where the template is -->
Two attack primitives:
1. Server-side sanitizer parser differential. Sanitizers don't understand processing instructions yet. If a <\w+-style regex ignores <?..., drop a marker inside a filtered context (an SVG <style> block) and fill it later via a template with unfiltered text. Processing instructions only work in XML contexts, so SVG or MathML <style> tags let you concatenate two text nodes into one malicious CSS string the sanitizer already cleared as harmless placeholders.
2. Client-side sanitizer impossible nesting. Client-side sanitizers work on the DOM, not strings. This feature places any element into any other without mxss gadgets, for example an HTML <style> inside an SVG that looks like textContent but reparses into live HTML.
<template src> (not shipped yet). Documented alongside template for, it pulls HTML from a same-origin URL inline: a native "load HTML from URL" gadget. Sanitized by default, but a sanitize=false attribute disables it, and template tags are usually treated as benign, so sanitizers are likely to miss it.
<selectedcontent>, the four-hour DOMPurify bypass. This element renders the selected <option> as full HTML in a closed dropdown, and selecting an option clones its node into <selectedcontent>. Cloning is normally JS-only, and exposing it to raw HTML breeds mutation XSS:
<select>
<selectedcontent></selectedcontent> <!-- empty placeholder -->
<option selected="javascript:"><img src=x onerror=alert(1)></option>
</select>
On parse, the only option is selected, so its node (payload included) is cloned into
<selectedcontent>.DOMPurify reaches
<selectedcontent>and strips the payload.It continues into
<option>and removes theselected="javascript:"attribute (it hatesjavascript:).Removing
selectedre-triggers the browser's "which option is selected now" logic, which re-selects option one and re-clones the still-unsanitized node into<selectedcontent>.DOMPurify's cursor is already past
<selectedcontent>, so the payload lands in an already-sanitized node and survives.
DOMPurify was bypassable in default config for about four hours after adding support, until an advisory dropped and it got patched. The generalizable principle: a sanitizer walking top-to-bottom can be tricked into reinserting a payload into a node it already cleared. Watch for it wherever node cloning and ordered sanitization meet.
Closing Thoughts: Finding Chrome Vulnerabilities
The throughline is simple: research is mostly refusing to accept "that's weird" as an answer. If you don't know why something happens, investigate, because the explanation usually hands you an unintuitive fact you'll reuse for years. Bug bounty problems are CTF challenges with different subjects.
On breaking into Chrome specifically: a single accepted Chrome vulnerability felt impossible for a long time, and the count is now up to six. It's a mental boundary that dissolves after the first valid bug. Read the docs, experiment with edge cases, and learn the boundaries that existing techniques can't break and that sites quietly rely on. Memory-corruption bugs are increasingly automated by AI, but it still takes an expert human to know which logic bugs are actually interesting.
Resources
That's it for the week, keep hacking!
