人类手艺的最后证明:只用逻辑漏洞拼出 Microsoft Edge 全链

Orange Tsai 用五个 bug 和两个 Windows 特性,在 Microsoft Edge 上拼出一条完全没有内存破坏的全链:无源码导航变成 UXSS,绕过弹窗拦截,借 Immersive Reader 写文件,最后用 telnet:// 拿到代码执行。

中文
复制
Pwnie Awards 2026 的 Epic Achievement 奖状与蓝色独角兽奖杯,奖状上印着本文标题

我偶尔还是会把一些工作成果发出来,虽然现在真正坐下来读文章的人似乎越来越少(也可能大家都交给 agent 去读了 XD)。总之,这是我为 Pwn2Own Berlin 2026 做的东西,一条完全手工打造、纯逻辑的 Microsoft Edge Full Chain,没有内存破坏,也完全没用 AI!🔥

确认了!DEVCORE 研究团队(@d3vc0r3)的 Orange Tsai(@orange_8361)串起 4 个逻辑漏洞,在 Microsoft Edge 上完成沙箱逃逸,拿下 17.5 万美元和 17.5 个 Master of Pwn 积分。全胜!#Pwn2Own #P2OBerlin

整条链由五个 bug 和两个 Windows 特性组成。我也在今年 HITCON 和 Hexacon 上分享了完整的挖洞与串联过程,还有一路上的一些趣事。可以看这里的 slides!

另外,也许大家都觉得 AI 时代 LLM 已经能 pwn 一切了,所以纯手工做出来的东西反而显得有点珍贵。这可能也是这条链被提名今年 Pwnie Awards 并拿下 Epic Achievement 奖的原因。🎉

2026年Pwnie Awards史诗成就奖证书,旁边是一座蓝色独角兽奖杯,证书上印有本文标题

从导航说起

如果只想靠逻辑把整条链路跑通,该从哪儿下手?既然做的是“浏览器”,那就先看“浏览”这件事,也就是 Chromium 说的 “navigation”,究竟是怎么运作的。这篇不打算讲太细,毕竟在这个年代,细节直接让 agent 去补就行了 :)

不过想了解全貌的话,还是推荐读一下 Chrome University 的 Life of a Navigation。

被忽视的 Navigation Throttle

翻 Chromium 的导航流程时,有个叫 Navigation Throttle 的东西引起了我的注意。它跑在 Browser Process 里,拦截 Browser 和 Renderer 发起的每一个导航请求。事后诸葛亮地说,Google Chrome 过去五年修掉的 3000 多个 CVE 里,跟它相关的不到 20 个。看来这确实是个没多少人盯的角落。

于是我顺着 Navigation Throttle 往下挖,看了 Google Chrome 和 Microsoft Edge 各自的实现。翻代码时发现,Edge 为自家的企业级 Entra ID 功能写了一个叫 EdgeCAGuidanceNavigationThrottle 的 throttle(这里的 CA 指 Conditional Access)。它的作用是,当 Edge 发现用户用工作账号登录微软网站时,把用户引导到正确的 User Profile。要触发这个 throttle,导航必须从某个微软自家的 origin 发起,比如 login.live.com:

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

throttle 一旦触发,Edge 就会拿当前 User Profile 的邮箱和 ?email={email} 做比对。对不上,就弹窗引导你切换账号。

Edge 的“使用工作或学校帐户继续”对话框,其中列出了一个工作帐户 research@devco.re

但如果两者确实匹配,Edge 就会从 switch_profile={url} 取出你原本要前往的 URL,并用 Chromium 内部的 Navigation API 发起一次新的导航:

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

Navigate(&params);

也就是说,当目标 URL 匹配某个特定的内部格式时,这个 throttle 会取消原本的导航,然后在条件满足的情况下,用攻击者可控的 Query String 里的 URL 发起一次新导航。由于这次新导航由 Browser Process 发起,理论上我们可以做一些以前做不到的事,比如跳转到通常被禁止的页面,例如 edge://settings 或 devtools://...,甚至像 javascript:alert(origin) 这样的伪协议。

虽然 JavaScript 伪协议听起来是个很强的原语,但你仍然需要先拿到 login.live.com 上的 Execution Context,才能触发这个 throttle。所以能跳到 javascript: 其实并不会给你带来额外能力。充其量它就像一个 Self-XSS,或者一种把那里的 Open Redirect 变成 XSS 的手段。无论哪种情况,你终究还是只在同一个 login.live.com 上下文里执行 JavaScript。

说实话,我第一眼并没把这个 bug 太当回事。在我看来,它只是一个更强的 Open Redirect,能多跳几个 Browser-Level Scheme,而且仍然需要和其他 bug 串联。于是这个 bug 就在我的笔记里躺了好几周…… 🤦‍♂️

无源导航:一个新的 UXSS 原语

转折点是有天我梳理 Chromium 架构时开始琢磨。throttle 是在 Browser Process 里处理的,所以新导航也是由浏览器发起的。然而,我们触发 throttle 的那个页面 login.live.com 活在 Renderer 里,而我在翻逻辑时找不到任何把 Browser 和 Renderer 关联起来的代码。那么…… CURRENT_TAB 指的到底是哪个标签页?

为了搞清楚,我做了个快速测试,给原本的触发加了一段延迟:

setTimeout(function(){
    location.href = `https://guidance-ca-error/?email={email}&switch_profile={url}`
}, 8000)

在延迟期间,我们手动把 Edge 切到一个新标签页,看看会发生什么,结果……

演示:受害者将 Edge 切换到另一个标签页后,导航仍会落在新标签页中作者网站上的原始视频,约 10 秒并循环播放。

砰!我们在 google.com 上拿到了一个 UXSS,这也是整条利用链的第一个漏洞。为了彻底弄清这个 UXSS 的根因,我们翻了 Chromium 的源码,发现调用 Navigate() 时如果不指定 source_contents(要绑定的 Renderer 来源),Chromium 会自动把当前活动标签页当作 Renderer 来源。

↓ 路径: chromium/chrome/browser/ui/browser_navigator.cc

base::WeakPtr<content::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
    }
  // [...]
}

虽然这条代码路径要求 params.disposition 为 CURRENT_TAB、params.source_contents 为 NULL,但好在这些都是默认值,所以连 Chromium 自己的示例代码都写错了。我猜这应该算是一个新的 UXSS 攻击面!

零点击的难题

刚拿到的这个 UXSS 原语听起来不错,但实际用起来还有不少坎。下面列出我们在武器化这个 UXSS 时遇到的四个问题,以及我们如何靠越来越多的漏洞把它们逐个解决 😉。

1. 触发器必须从可信来源发起

第一个难题前面提过,这个 UXSS 必须从某些 Microsoft 站点发起。虽然我们也可以在这些站点上再找一个 XSS 来满足条件,但那听起来是件苦差事,只能留作最后手段。于是我们先去看看来源校验里有没有什么好摘的果子。

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;

我们发现 EdgeCAGuidanceNavigationThrottle 用 GetLastCommittedURL() 取出发起导航的来源,然后做校验。乍一看,这像是正确的校验方式。但再看一眼就会发现,Microsoft 似乎忘了导航不仅可以来自当前的 WebContents,也可以来自另一个窗口……所以一个简单的 window.open(...) 就足以绕过它!

↓ 你可以在任意网站上运行这段代码来触发该触发器

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

2. 弹窗拦截

到这里,我们已经可以用跨窗口导航绕过来源校验了。但现代浏览器不会让网页那么轻易地打开新窗口,于是又来了一个新问题:弹窗拦截默认会拦下 window.open(...)。

Edge 的弹出窗口被阻止气泡提示:一次对 google.com 的 window.open 被阻止,并提供始终允许来自 orange.tw 的弹出窗口的选项

为了破解这一点,我们一直在逆向分析 Microsoft Edge。结果发现,微软大概是出于内部使用的目的,自己写死了一份弹窗白名单……因此,像下面这样的弹窗,正常情况下会如预期被拦截:

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

但只要把 URL 换成白名单里的那个……窗口就直接弹出来了。之后再记得把它导航回原来的网站,你就绕过了弹窗拦截器!

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

谢谢你,微软!

一张库存照片,照片中一名身穿浅蓝色T恤的男子微笑着竖起大拇指,旁边是Microsoft标志和巨大的THANKS!字样

3. 需要已登录的配置文件

既然现在可以在任意位置触发 EdgeCAGuidanceNavigationThrottle,下一步就是绕过 Conditional Access 检查。代码首先要求 Microsoft Edge 已经存在已登录的用户配置文件,然后检查 ?email={email} 是否与该配置文件的电子邮件地址匹配。只有这两个条件都满足,才能进入无源、由浏览器发起的导航,最终触发我们的 UXSS 原语。

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(…));
}

不过,我们不能假定目标环境事先就有已登录的配置文件,所以还得想办法让攻击在没有配置文件的情况下也能奏效。在探索 Microsoft Edge 的过程中,我们发现了一个叫“OneAuth”的功能。它让任何 Edge WebView 应用在访问微软旗下的站点时,自动用你当前的 MSA 凭据登录。而从 25H2 开始,Windows OOBE 要求你使用 MSA 登录(除非你用本地账户的变通办法)。所以,即便你从未在浏览器里创建过用户配置文件,只要访问 www.microsoft.com,Edge 就足以创建一个配置文件并自动为你登录。

再次感谢微软!

演示:在全新安装的 Edge 中访问 microsoft.com,OneAuth 会登录并创建配置文件作者网站上的原始视频,循环播放。

4. 需要事先知道登录邮箱

登录问题解决后,最后一个难题是提前拿到受害者的邮箱。当然,可以试试社会工程学,或者继续逆向 Microsoft Edge,找另一个能泄露邮箱的漏洞。但自动登录已经写入了好几个 Microsoft cookie,我们觉得用 Web Hacking 来偷邮箱更省事。于是打开 Burp Suite,开始找 Web 漏洞。

花了几个小时,在 Microsoft 的站点上找到了几个 XSS 漏洞。翻看的过程中,我注意到嵌在 edge://new-tab-page-online 里的 assets.msn.com 会返回当前登录用户的邮箱。更有意思的是,查看响应头时,这个站点似乎存在 CORS 配置错误……

Edge 中 assets.msn.com API 的响应,JSON 中的 email 字段用红圈标出

只需一个带凭据的简单 fetch(...),就能从任意网站轻松获取任何 Microsoft Edge 用户的邮箱:

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 )

串联起来

到目前为止,为了让这个原语生效,我们串联起了以下环节:

  • 一个 Trusted Origin 绕过,源于缺失的跨窗口 Opener 检查
  • 一个弹窗拦截器绕过,源于硬编码的白名单
  • 一个会自动帮你登录的 OneAuth 功能
  • 一个 CORS 配置错误,让我们能窃取已登录用户的邮箱

现在终于可以零点击触发 UXSS 了!更妙的是,这个 UXSS 是从 Browser Process 发起的(如果你好奇的话,是 Mojo 里的 HandleRendererDebugURL),因此可以完全无视任何 CSP。也就是说,我们能在 google.com、facebook.com、icloud.com 或任何我们想要的网站上执行 JavaScript!

突破浏览器沙箱

现在我们有了 Universal XSS。这是否意味着可以为所欲为?如果只谈 Web 安全,那确实如此。但我们的目标是 ACE(任意代码执行),光有 UXSS 远远不够。还得找到突破浏览器沙箱的办法,才能把 UXSS 变成代码执行。

我想到的第一个、也是最直接的办法是,既然我们能在任意网站、更准确地说任意 origin 上执行 JavaScript,为什么不直接利用 edge://settings 或 edge://downloads 这类特权 WebUI 页面来拿到 ACE?

这种天真做法当然早已被 Chromium 开发者堵上了。Renderer 的 Mojo handler 里有一道额外检查,如果 origin 属于下面这些特权 WebUI 之一,Renderer 会直接忽略 JavaScript URL。

edge://
chrome-extension://
chrome://
chrome-untrusted://
chrome-distiller://
chrome-error://
chrome-native://
chrome-search://
devtools://

// more...

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

void LocalFrame::LoadJavaScriptURL(const KURL& url) {
  // Protect privileged pages against bookmarklets and other JavaScript
  // manipulations.
  if (SchemeRegistry::ShouldTreatURLSchemeAsNotAllowingJavascriptURLs(
          GetSecurityContext()
              ->GetSecurityOrigin()
              ->GetOriginOrPrecursorOriginIfOpaque()
              ->Protocol())) {
    return;
  }
  // [...]
}

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

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);
}

基本上,你能想到的每一个特权 WebUI 都被 Chromium 放进了 not_allowing_javascript_urls_schemes 黑名单。于是我又卡了很长一段时间。😖

通过 Immersive Reader 实现任意文件写入

不过,在继续探索 Microsoft Edge 的过程中,我们发现 Edge 有一套自己的阅读模式,叫 Immersive Reader。与 Chrome 内置的阅读模式不同,Immersive Reader 似乎很早就从 Chromium 分叉出来,由 Microsoft 在其上继续开发。因此 Edge 用的不是旧的 chrome-distiller://,也不是被拉黑的 chrome-untrusted://,而是一个自定义的 read:// scheme。而 Microsoft 似乎也忘了把这个 scheme 加进黑名单,于是我们的 UXSS 在这个特权 WebUI 上同样有效 :)

从这个特权 WebUI 出发,我们可以通过 JavaScript 访问大量原生 C++ 对象。其中一个叫 chrome.edgeFeedbackPrivate.zipDiagnosticLogFiles() 的对象让我格外感兴趣。我们试了一下,发现这个函数接收两个参数,一个文件名和一个 JSON 字符串:

↓ 我们在 read://https_orange.tw/?url=https://orange.tw 上运行了这段代码

chrome.edgeFeedbackPrivate.zipDiagnosticLogFiles(
    'LOL.ZIP', 
    '{
        "logFiles": {
            "ORANGE": "AAAAAAAA"
        }, 
        "deviceInfo": {1337: 1337}
    }'
)

摆弄的过程中我们注意到,Microsoft Edge 会先创建一个临时文件夹,把 AAAAAAAA 写入文件 ORANGE.json,然后把整个文件夹打包成 LOL.ZIP,上传到微软的服务器,最后把一切清理干净。当然,你会想试试 ../……没错,它确实能让你跳出临时文件夹。但那个 .json 扩展名还是有点碍事。也许你也在想,NULL 字节能不能把它去掉……当然可以!于是我们就有了一个任意文件写入漏洞,文件名和内容都能控制,想覆盖哪里就覆盖哪里!

再次感谢微软,感谢一遍又一遍!

一个文件资源管理器窗口,上面贴着一张橘猫照片,一个红色箭头指向新建的 PWned.TXT 文件

覆写 protocols.json 以自动拉起协议处理器

到这里,大部分拼图已经凑齐了。最后一步是研究如何把浏览器层面的任意文件写入变成 ACE,从而闭合整条利用链。我们观察到,Microsoft Edge 在初始化时会做一些有意思的侧加载,比如读取一堆配置文件,或者从可写目录加载 DLL。

DLL 侧加载听起来很诱人。但我们的文件写入被包在 JSON 里,内容必须是合法的 UTF-8 字符串;而 Microsoft Edge 是 64 位进程,所以还得投递一个 64 位 DLL。折腾了一阵之后我们发现,无论如何都构造不出既满足 UTF-8 要求、又是合法 64 位 PE 头的文件,只好放弃 DLL 侧加载这条路。

最终我们用一个不那么直观的办法解决了问题。我们发现,除了 OneAuth,Edge 在初始化时还会加载一批组件,其中之一是“AutoLaunchProtocolsComponent”。这个组件会从我们可以写入的目录里读取一个名为 protocols.json 的配置文件,该文件定义了哪些源可以拉起哪些 URL 协议处理器。于是只要覆写这个文件,我们就能从任意源拉起任意协议处理器。

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

{
  "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": [
    // [...]
  ]
}

至于怎么让 Microsoft Edge 重新初始化?用我们前面那个由浏览器发起的导航跳到 edge://restart 就行了!:)

从 telnet:// 到代码执行!

这里我们选了 telnet:// 来获取代码执行。在 Windows 上,telnet 处理器会尝试调用 telnet.exe,但这个二进制默认并不随系统提供,于是 Windows 会到 PATH 环境变量列出的目录里去找。而这些目录中恰好有一个我们可以写入……

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    # <--- it's writable for `lowpriv`...

所以,只要投递一个改过的 protocols.json 和一个 32 位的小 PE,我们就拿到了 ACE。整个 exploit 不到 100 行代码。真实攻击中其实不需要重启,只有 Pwn2Own 才需要。来看 demo 吧!🔥

最后的一些想法

从对现代浏览器漏洞利用毫无经验,到完成整条利用链,整个研究花了我整整两个月,全程没有借助任何 LLM/AI。你可能会问为什么?也许是因为听起来很酷,也可能只是时机刚好。我今年三月左右才开始用 Codex 和 Claude Code 这类编码 agent,而那时我已经找完了所有 bug,把最难的部分做完了 ¯_(ツ)_/¯

那么难点在哪?对内存破坏类漏洞来说,难的通常是利用。但我们玩的是逻辑漏洞,难的是找到它们;一旦全部找齐,串链就是最轻松的一步。这也是我整条利用链都没用 AI 的原因。

除此之外,我也把 UXSS 原语看作一个新的攻击面,并且认同在 AI 时代“攻击面就是新的 0day”。我想在把这座金矿分享给 AI 巨头之前,自己再多探索探索。这是我决定在这条链上不用 AI 的另一个原因。

当然,我也不是那种拒绝尝试新事物的老古董。在这条链之外,我也在 tokenmaxxing,对 AI 上瘾。我相信一旦尝过 LLM 有多方便,就没人愿意再回去手工挖洞了。我也认为这就是未来。

我也不太担心被 AI 取代。倒不如说,正是因为 AI,我终于有足够的时间去完成 TODO 列表上搁置已久的那些酷东西……比如我刚刚报告的 Windows RCE、把利用链改成从 calc.exe pop Microsoft Edge,或者把这条纯逻辑的完整利用链移植到 Google Chrome(快成了 ><)。下个月在日本的 CODE BLUE 上我也会展示一些进展。敬请期待!😉

所以,与其担心漏洞研究的门槛能低到什么程度,不如为 AI 能把我们带到多高而兴奋?我们能玩出什么花样,能做出多酷的 hack,能让哪些不可能的事情成真……?

Never Stop Exploiting!

来源: blog.orange.tw← 返回首页