返回资讯中心
外部精选
软件工程
#开发者实时流

The Last Proof of Human Craft: Forging a Microsoft Edge Full Chain with Logic

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, t…

Hacker News 最新dverlaeckt802026/10/28 · 2 天前30 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

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

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. 🎉

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.

While 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:

1

location.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:

1 2 3 4 5

NavigateParams params(browser, switch_profile_url, ui::PAGE_TRANSITION_LINK); params.disposition = WindowOpenDisposition::CURRENT_TAB; params.window_action = NavigateParams::SHOW_WINDOW;

Navigate(&params);

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… 🤦‍♂️

The 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:

1 2 3

setTimeout(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

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46

base::WeakPtrcontent::NavigationHandle Navigate(NavigateParams* params) {

// [...]

// If no source WebContents was specified, we use the selected one from the // target browser. This must happen before GetBrowserAndTabForDisposition() // has a chance to replace |params->browser| with another one, but after the // above check that relies on the original source_contents value. if (!params->source_contents && params->browser) { params->source_contents = params->browser ->GetBrowserForMigrationOnly() ->tab_strip_model() ->GetActiveWebContents(); // <--- [1] Use the active tab as the navigation source }

// [...]

// If no target WebContents was specified (and we didn't seek and find a // singleton), we need to construct one if we are supposed to target a new // tab. if (!contents_to_navigate_or_insert) { DCHECK(!params->url.is_empty()); if (params->disposition != WindowOpenDisposition::CURRENT_TAB) { // [...] } else { // ... otherwise if we're loading in the current tab, the target is the // same as the source. DCHECK(params->source_contents); contents_to_navigate_or_insert = params->source_contents; // <--- [2] Set it as the target WebContents }

// Try to handle non-navigational URLs that popup dialogs and such, these // should not actually navigate. if (!HandleNonNavigationAboutURL( params->url, contents_to_navigate_or_insert->GetBrowserContext())) { // Perform the actual navigation, tracking whether it came from the // renderer. NavigationController::LoadURLParams load_url_params = LoadURLParamsFromNavigateParams(contents_to_navigate_or_insert, params); navigation_handle = contents_to_navigate_or_insert->GetController().LoadURLWithParams( load_url_params); // <--- [3] Start navigation; dispatch Mojo downstream } // [...] }

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 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 😉.

The 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.

1 2 3 4 5

content::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

1 2

popup = window.open('https://login.live.com/&#x27;, '_blank') popup.location = https://guidance-ca-error/?email={email}&switch_profile={url}

  1. The Popup Blocker

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:

1

popup = window.open('https://malicious.tld&#x27;, '_blank') // blocked

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!

1 2

popup = window.open('about:blank#quickAuthPopup', '_blank') // granted popup.location = 'https://malicious.tld&#x27;

Thank you, Microsoft!

Now that we can fire EdgeCAGuidanceNavigationThrottle from wherever we want, the next step is to get past the Conditional Access checks. The code first requires Microsoft Edge to already have a signed-in User Profile, and then checks if ?email={email} matches the profile’s email. Both conditions have to be met before we reach the sourceless, browser-initiated navigation to finally trigger our UXSS primitive.

1 2 3 4 5 6 7 8 9 10

Profile* current_profile = Profile::FromBrowserContext( web_contents->GetBrowserContext());

std::string email; net::GetValueForKeyInQuery(target_url, kEmailQueryKey, &email);

const base::FilePath target_profile_path = GetPathOfProfileWithEmail(email); if (current_profile->GetPath() != target_profile_path) { profiles::SwitchToProfile(target_profile_path, false, BindOnce(…)); }

However, since we can’t assume the target environment has a signed-in profile in advance, we still need to find a way to make the attack work even if it doesn’t. While exploring Microsoft Edge, we came across a great feature called “OneAuth”. The feature lets any Edge WebView application automatically sign in with your current MSA credentials when visiting Microsoft-owned sites. And it turns out that starting with 25H2, Windows OOBE requires you to sign in with an MSA (unless you use the local-account workaround). So even if you’ve never created a User Profile in the browser, just visiting www.microsoft.com is enough for Edge to create one and sign you in automatically.

With the sign-in issue cracked, the last challenge is to get the victim’s email in advance. Of course, we could try some social engineering, or keep reversing Microsoft Edge to find another bug that leaks that for us. But since the automatic sign-in already sets several Microsoft cookies, we thought Web Hacking would be an

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →