The browser is without doubt one of the most interesting and misunderstood pieces of software out there. For the last few years I've been on a personal journey to familiarise myself with browser internals, and was even fortunate enough to get to work professionally on browser security. When most people think of browser security though, they think of binary exploitation, and rightly so. Almost 70% of high and critical severity Chromium security issues are memory safety and corruption problems1, so if you want to get into browser security you basically have to learn binary exploitation first, and then pivot into compilers, sandboxes, rendering engines, and the rest until you can build a mental model of even a fraction of the attack surface of a modern web browser.
But there's another class of bugs that don't fall into the memory safety category. In this bucket you'll find logic bugs, race conditions, policy boundary failures, interface deception, and more. These kinds of bugs are unique, because traditional fuzzing can't find them, so they don't suffer from the same brutally competitive market that memory safety bugs fall under.
the state of browser bug hunting
Quick detour, because I think it's worth setting the scene of what browser bug hunting looks like right now.
The most common path researchers take is roughly this: set up a big rig or rent some beefy hardware, install a fuzzer like SaGe or Fuzzilli, spend a bunch of time tweaking your corpus of fuzz inputs and building harnesses, and let that puppy run for as long as you can afford. The biggest challenge is that most common inputs have already been deeply fuzzed by Google, Apple, and the other browser vendors themselves, so to get interesting new triggers you have to invest a lot of time into your harnesses. It's time consuming, laborious work.
Then once you have a crash, you need to triage it, identify if it can lead to code execution, figure out if it's chainable, and so on. I ran SaGe fuzzer on my fuzzing rig for two months. It produced over 300 crashes, and not a single one was a true memory safety issue, never mind an exploitable bug beyond a tab crash. The time it took to triage all of those was painful. And even when you do find a real bug and report it, it's entirely possible someone else is already sitting on the same bug or has reported it first.
After going down this rabbit hole I knew I wanted to take a different approach. I started looking for recent research in browser fuzzing that doesn't rely on finding memory safety issues, and that's where I stumbled upon FuzzOrigin2.
FuzzOrigin is an open source fuzzer developed by researchers from Samsung Research and Seoul National University, and it has one goal: fuzz browsers for origin boundary failures. This was very interesting to me, since this is exactly the kind of bug I'd found through manual testing before. For those of you who don't know, Universal Cross-Site Scripting (UXSS) sounds confusing but in reality it's just a combination of a Same-Origin Policy bypass and script execution: if you can bypass the SOP, you can often get UXSS.
UXSS sits at the top of the severity ladder. It's often classed at the same level as remote code execution, because a UXSS bug lets an attacker steal cookies, session tokens, and credentials from any origin the victim visits: not just a single misconfigured site, but every site. Browser vendors know this, and bounties reflect it: Apple's Safari bug bounty pays up to $100,000 for this class of vulnerability. Ryan Pickren famously demonstrated the impact with his Safari UXSS chains that could hijack the victim's webcam through origin boundary failures. That's the league we're playing in.
Better yet, FuzzOrigin works out of the box with any browser that supports WebDriver.
how FuzzOrigin works
I set it up immediately and started fuzzing while I studied the code and the paper. FuzzOrigin's approach is clever: it developed a way to determine whether origin A successfully ran code in origin B by using a "sanitizer" (which in this case is not a memory address sanitizer as you might expect, it's much simpler). It uses console.log calls with predetermined identifiers and an out-of-browser check to confirm that code ran in the expected origin. Simple, elegant, and effective.
console.log calls with origin identifiers into generated test pages, then checks whether code executed in an unexpected origin.
The core of the sanitizer relies on driver.get_log to extract console output from the browser session after each test case runs. Which brings us to the first problem.
the Safari problem
The first hurdle I hit while running FuzzOrigin against Safari is that Safari's WebDriver implementation doesn't support driver.get_log. That's basically the core call that the sanitizer relies on.
driver.get_log('browser') to extract console output.
get_log() doesn't work with WebKit-based browsers at all.
This was inconvenient, but it also gave me some hope: if the fuzzer couldn't easily be run against WebKit, it probably hadn't been tested extensively against it. Untapped potential.
porting FuzzOrigin to WebKit
I decided to ditch stock Safari and go down one level, looking at WebKit itself, since it's the underlying engine that Safari is built on. I knew there had to be a way for WebKit to get logs out of running browser sessions, because they must also run integration tests somehow.
WebKit has this concept of LayoutTests which allow you to load a specific "test page" in the browser, extract the logs, and specify the expected output from console.log: exactly what we need. The key insight was that WebKit's test runner supports a magic HTML comment that tells it to dump all JavaScript console output to stderr:
<!-- webkit-test-runner [ dumpJSConsoleLogInStdErr=true ] -->
By injecting this directive into every generated test page, I could get the same console output that FuzzOrigin normally extracts via driver.get_log. The test runner writes each test's stderr to a separate file under layout-test-results/, so I could parse the CONSOLE MESSAGE: lines and feed them into the same violation detection logic.
The port wasn't trivial though. FuzzOrigin expects a WebDriver session that loads URLs, clicks elements, and extracts logs. I had to replace all of that with WebKit's run-webkit-tests infrastructure, which works completely differently: you write HTML files into the LayoutTests/ directory, run the test suite, and read the results from disk. Each test needs a dummy expected-output file (I used a string that would never match) to force the runner to write stderr output even for "passing" tests. The generated pages also had to live under LayoutTests/http/tests/ specifically, because that's where WebKit's built-in Apache server serves cross-origin content from (ports 8000 and 8080).
I also had to deal with WebKit's flatpak build environment, set up multiple Xvfb instances for parallel headless execution, and symlink the test directories to tmpfs ramdisks because the I/O from thousands of generated HTML files was destroying my SSDs. After a lot (and I mean a lot) of fiddling, I had a working port.
dumpJSConsoleLogInStdErr directive, runs via run-webkit-tests, and parses CONSOLE MESSAGE: lines from stderr.
I also made some other changes along the way. I added SOP-specific grammar patterns for cross-origin iframe access, location manipulation, postMessage, blob URLs, and timing-based race conditions. More importantly, I enabled experimental web APIs including the Navigation API, and added more complexity to the Domato generator. Not long after that, I had my first origin violation.
the 3,286-line monster
All those grammar changes I made to the corpus generator may have shot me in the foot, because my first proof-of-concept was an absolute monster.
contentDocument accesses everywhere. The actual trigger is buried somewhere in there.
I spent a huge amount of time writing scripts and using Claude to help minimise it, but this was also in the days of Sonnet 4.5 so even AI wasn't much use on such a massive file.
Eventually though I started to get more violations, and a pattern emerged: every single origin violation had the same gadget injected into the page: a Navigation API call that hooked navigation.navigate. I got the whole PoC down to this:
navigate event, detect a cross-origin navigation, and intercept it. The handler now runs in the context of the target origin.
UXSS baby!!
With the right trigger, this code appeared to execute JavaScript cross-port: localhost:8080 could execute JS in the context of localhost:8081. That's the definition of UXSS.
However (and of course there's a however) it didn't trigger on arbitrary origins. It appeared to manifest as an origin confusion bug for hosts where the origin is only slightly different: same host but different port, or in the more interesting case, same primary domain but different subdomain. I was able to run code on victim.crossorigin.site from attacker.crossorigin.site, but something still wasn't adding up.
attacker.crossorigin.site, with location.origin reporting https://victim.crossorigin.site.
Although I could trigger the alert box with location.origin showing a different page's origin, I found no way to access the cookies for that page. The security boundary that the SOP creates was still holding, despite the fact that the browser believed my origin was that of another page. The cookie jar wasn't fooled.
address bar spoofing and CSRF
An interesting side-effect was that we also obtained address bar spoofing in the process. The URL bar showed the victim's URL while our attacker content was displayed. So there had to be some component of the browser that truly believed our origin was that of the victim site, even if it wasn't the cookie jar.
victim.crossorigin.site, but the page content is from the attacker. The debug output confirms the cross-origin navigation was intercepted.
So I set out on a different goal. What if my HTML page has a web form on it, and I dispatch a hidden form submission to an endpoint I know exists on victim.com? This is more commonly known as CSRF. My hope was that even if cookies aren't directly accessible to my JavaScript, the browser might still attach them to the request because it believed it was actually on victim.com.
Lo and behold, this triggered successfully. I was able to prove a blind CSRF bypass across subdomain boundaries. The browser dutifully attached the victim's cookies to our forged requests.
POST /api/legacy-endpoint request with a 200 response. The request came from our attacker page, but the server processed it as authenticated: the cookies were attached.
what's actually broken in WebKit
With the bug confirmed, I wanted to understand exactly what was going wrong at the source code level. The Navigation API specification is clear on this point: NavigateEvent.canIntercept must return false for cross-origin navigations, and calling intercept() should throw a SecurityError3. Chrome does this correctly. WebKit doesn't.
Looking at WebKit's source, the navigate event is created in Navigation.cpp via NavigateEvent::create(), and the event's canIntercept property is supposed to gate whether the intercept handler is allowed to run. But WebKit was setting canIntercept to true even for cross-origin navigations, so calling e.intercept() didn't throw the expected SecurityError. Instead, the intercept handler ran, and when it did, WebKit had already updated the frame's URL and document origin to match the navigation destination.
This is where it gets interesting. The URL update happens in Navigation::setupInterceptionState(), which calls document.updateURLForPushOrReplaceState(destination.url()) and frame->loader().updateURLAndHistory(). These functions update the document's URL (and by extension location.origin and the address bar) to the destination, because for same-document navigations this is the correct thing to do. The problem is that WebKit was treating a cross-origin intercept as a same-document navigation, when it should have blocked the intercept entirely.
The result is a split-brain state: the document's URL and location.origin now point to the victim's origin, the address bar shows the victim's URL, and the networking stack uses the victim's origin for Referer headers and cookie attachment. But the JavaScript execution context, DOM tree, and cookie API still belong to the attacker's origin. This is why cookies weren't directly accessible via document.cookie but were still attached to form submissions: the cookie jar keys on the document's actual security origin (unchanged), while the networking stack keys on the URL (spoofed).
Apple's fix was straightforward: make canIntercept return false for cross-origin navigations, matching the spec and Chrome's behaviour. With that check in place, the intercept handler never runs, so the URL update never happens and the split-brain state can't be reached.
disclosure
This bug was reported to Apple in December 2025.
It was patched in Safari 26.4 under CVE-2026-20643.
None of this would have been possible without the incredible work from the FuzzOrigin team at Samsung Research and Seoul National University. They built the foundation; I just pointed it at a new target.
Daybreak and Mythos may now be finding bugs like this at ferocious scale, but this one was discovered the old-fashioned way: by a carbon-based researcher spending far too long staring at 3,428 lines of generated JavaScript. I'm just glad I got it in before AGI.