The Last Proof of Human Craft: Forging a Microsoft Edge Full Chain with Logic Bugs Only!
Pangram verdict · v3.3
We believe that this entire text is human-written.
AI likelihood · overall
HumanArticle text · 1,319 words · 12 segments analyzed
I think I should still dump some of my work once in a while, even though it feels like fewer and fewer people actually sit down and read articles these days (or maybe now they all leave it to their agents XD).
Anyway, this is what I built for Pwn2Own Berlin 2026 — a completely handmade, logic-only Microsoft Edge Full Chain, with no memory corruption, and no AI at all! 🔥 Confirmed!
Orange Tsai (@orange_8361) of DEVCORE Research Team (@d3vc0r3) chained 4 logic bugs to achieve a sandbox escape on Microsoft Edge, earning $175,000 and 17.5 Master of Pwn points.
Full win!
#Pwn2Own #P2OBerlin https://t.co/vJaIpGG4cD— TrendAI Zero Day Initiative (@thezdi) May 14, 2026 The whole chain is made up of five bugs and two Windows features. I have also shared the whole bug-hunting and chaining process at this year’s HITCON and Hexacon, plus some fun stories along the way.
You can check the slides here!
Also, maybe everyone thinks LLMs can already pwn everything in the AI era, so something made entirely by hand somehow feels a bit more precious. That might also be why this chain was nominated for this year’s Pwnie Awards and won the Epic Achievement award. 🎉 Let’s Navigate!So where do we start with a logic-only full chain? Well, since it’s a “browser,” let’s start by looking at how “browsing” (or what Chromium calls “navigation”) works. I won’t go into too much detail in this post, because I think in this era, you can just ask your agents to fill in the details :) But I’d still recommend reading “Life of a Navigation“ from Chrome University if you want to get the full picture. The Forgotten Navigation ThrottlesWhile I was walking through the Chromium Navigation flow, something called “Navigation Throttles” caught my attention. They live in the Browser Process, and intercept all navigation requests initiated by either the Browser or the Renderer. Maybe it’s easy to say this in hindsight, but out of the 3,000+ CVEs Google Chrome has fixed over the past five years, there are only fewer than 20 related to them. So I guess this is also a corner not too many people pay attention to. Anyway, I started digging into the Navigation Throttles, and checked how Google Chrome and Microsoft Edge implemented them. And while going through the code, I found that Edge has a throttle called EdgeCAGuidanceNavigationThrottle for its enterprise Entra ID feature (the CA here stands for Conditional Access). The purpose of this throttle is to guide users to the right User Profile when Edge detects them signing in with a work account on a Microsoft website. And to fire this throttle, you have to start a navigation from a specific Microsoft-owned origin, such as login.live.com: 1location.href = `https://guidance-ca-error/?email={email}&switch_profile={url}` And once the throttle is fired, Edge would compare the email of the current User Profile with ?email={email}. If they don’t match, Edge pops up a dialog guiding you to switch accounts. But if they do match, Edge would then grab the URL you were originally heading to from switch_profile={url}, and launch a new navigation with Chromium’s internal Navigation API: 12345NavigateParams params(browser, switch_profile_url, ui::PAGE_TRANSITION_LINK);params.disposition = WindowOpenDisposition::CURRENT_TAB;params.window_action = NavigateParams::SHOW_WINDOW;Navigate(¶ms); So basically, the throttle cancels the original navigation when the target URL matches a specific internal format, and then launches a new one with the URL from the attacker-controlled Query String, if the conditions are met. Since this new navigation is initiated by the Browser Process, in theory we can do a few things we couldn’t do before, such as jumping to normally prohibited pages like edge://settings or devtools://..., or even pseudo-schemes like javascript:alert(origin). Though the JavaScript pseudo-scheme sounds like a powerful primitive, you still need to obtain the Execution Context on login.live.com to fire the throttle in the first place. So being able to jump to javascript: doesn’t really give you any extra capability. At most, it’s just like a Self-XSS, or a way to turn an Open Redirect there into XSS. Either way, you’re still only executing JavaScript in the same login.live.com context. Honestly, I didn’t take this bug too seriously at first glance. To me, it was just a stronger Open Redirect that could jump to a few more Browser-Level Schemes, and still needed to be chained with other bugs. So the bug just sat in my notes for weeks… 🤦♂ Sourceless Navigation: A New UXSS PrimitiveThe turning point was when I was going over the Chromium architecture one day and started thinking: The throttle is handled in the Browser Process, so the new navigation is initiated from the browser as well. However, the page we fired the throttle from, login.live.com, lives in the Renderer, and while investigating the logic, I couldn’t find any code tying the Browser and Renderer together. So… CURRENT_TAB refers to which tab, exactly? To find this out, I ran a quick test and added a delay to the original trigger: 123setTimeout(function(){ location.href = `https://guidance-ca-error/?email={email}&switch_profile={url}`}, 8000) And during the delay, we manually switched the Edge to a new tab to see what would happen, and… Boom! We got a UXSS on google.com, and that’s also the first bug of the whole chain. To fully understand the UXSS root cause, we dug into Chromium’s source code. We found that if Navigate() is called without specifying the source_contents (the Renderer source to bind to), Chromium would use the current active tab as the Renderer source automatically. ↓ Path: chromium/chrome/browser/ui/browser_navigator.cc 12345678910111213141516171819202122232425262728293031323334353637383940414243444546base::WeakPtr<content::NavigationHandle> Navigate(NavigateParams* params) { if (!params->source_contents && params->browser) { params->source_contents = params->browser ->GetBrowserForMigrationOnly() ->tab_strip_model() ->GetActiveWebContents(); } if (!contents_to_navigate_or_insert) { DCHECK(!params->url.is_empty()); if (params->disposition != WindowOpenDisposition::CURRENT_TAB) { } else { DCHECK(params->source_contents); contents_to_navigate_or_insert = params->source_contents; } if (!HandleNonNavigationAboutURL( params->url, contents_to_navigate_or_insert->GetBrowserContext())) { NavigationController::LoadURLParams load_url_params = LoadURLParamsFromNavigateParams(contents_to_navigate_or_insert, params); navigation_handle = contents_to_navigate_or_insert->GetController().LoadURLWithParams( load_url_params); } } Though the code path requires params.disposition to be CURRENT_TAB and params.source_contents to be NULL, luckily, those are the defaults, so even Chromium’s own example code gets this wrong. I guess this should count as a new UXSS attack surface! The 0-Click ChallengesThe UXSS primitive we just got sounds great, but firing that still comes with quite a few challenges in practice. Here we list four we faced while weaponizing the UXSS, and how we cracked them with more and more bugs 😉.
1.
The Throttle Must Be Fired from a Trusted OriginThe first challenge, as we mentioned earlier: this UXSS has to be fired from certain Microsoft sites. Though we could still find an XSS there to make that happen, that sounds like a painful job, so we’d keep it as a last resort. Instead, we decided to see if there was any low-hanging fruit in the origin check first. 12345content::WebContents* web_contents = navigation_handle()->GetWebContents();const GURL last_committed_url = web_contents->GetLastCommittedURL();if ( !IsSupportedLoginUrl(last_committed_url) ) return content::NavigationThrottle::CANCEL_AND_IGNORE; We found that EdgeCAGuidanceNavigationThrottle uses GetLastCommittedURL() to retrieve the origin that fired the navigation, and then check it. At first glance, this looks like the correct way to do the validation. But take a second look, and you’ll notice Microsoft seems to have forgotten that a navigation can come not just from the current WebContents, but also from another window… So a simple window.open(...) is enough to bypass it! ↓ You can run this on any website to fire the throttle 12popup = window.open('https://login.live.com/', '_blank')popup.location = `https://guidance-ca-error/?email={email}&switch_profile={url}` So far, we can bypass the origin check with a cross-window navigation. However, modern browsers don’t let webpages open new windows that easily nowadays, so here comes another new problem: the Popup Blocker would block window.open(...)
by default.
To crack this, we kept reversing Microsoft Edge. And we found Microsoft, maybe for internal use, made its own hardcoded whitelist for popups… So a popup like the following would normally be blocked as expected: 1popup = window.open('https://malicious.tld', '_blank') But if you swap the URL for the whitelisted one… the window just appears. And then remember to navigate it back to the original site, and you’ve bypassed the Popup Blocker! 12popup = window.open('about:blank#quickAuthPopup', '_blank') popup.location = 'https://malicious.tld' Thank you, Microsoft!
3.