人の手仕事が最後の証明になる、ロジックバグだけで Microsoft Edge のフルチェーンを組む
Orange Tsai は 5 つのバグと 2 つの Windows 機能だけで、メモリ破壊を一切使わない Microsoft Edge のフルチェーンを組み上げた。ソースなしのナビゲーションが UXSS になり、ポップアップブロッカーを迂回し、Immersive Reader でファイルを書き、最後は telnet:// からコード実行に至る。
日本語
コピー

たまには自分の成果をまとめておいたほうがいいだろう。最近は記事をじっくり読む人がめっきり減った気がするし(いや、今はみんなエージェントに任せているのかも XD)。ともあれ、これが Pwn2Own Berlin 2026 に向けて作ったものだ。完全に手作り、ロジックだけで組んだ Microsoft Edge のフルチェーン。メモリ破壊は一切なし、AI も一切なし! 🔥
確定!DEVCORE Research Team(@d3vc0r3)の Orange Tsai(@orange_8361)が4つのロジックバグをつないで Microsoft Edge のサンドボックスエスケープを達成し、$175,000 と 17.5 Master of Pwn ポイントを獲得。フルウィン! #Pwn2Own #P2OBerlin
チェーン全体は5つのバグと2つの Windows 機能で構成されている。バグの探し方からチェーンの組み方までの流れは、今年の HITCON と Hexacon でも話したし、道中のおもしろい話もいくつかある。スライドはこちらからどうぞ!
AI 時代だから LLM がもう何でも pwn できると思われているせいか、完全に手で組んだものにはどこか希少価値があるように感じる。今年の Pwnie Awards にノミネートされて Epic Achievement 賞を受賞できたのも、そういうことかもしれない。🎉

ナビゲーションを覗いてみよう
ロジックだけでフルチェーンを組むなら、どこから始めればいいか。ここは「ブラウザ」なのだから、「ブラウジング」(Chromium の用語では「ナビゲーション」)の仕組みから見ていくのがよさそうだ。この記事では細部には立ち入らない。今の時代、細かい部分はエージェントに埋めてもらえばいいと思うので :)
とはいえ全体像をつかみたいなら、Chrome University の「Life of a Navigation」を読んでおくといい。
忘れられがちな Navigation Throttle
Chromium のナビゲーションフローを追っているとき、「Navigation Throttle」という仕組みが目に留まった。Browser Process の中にいて、Browser からも Renderer からも発行されるすべてのナビゲーションリクエストを横取りする。後から言うのは簡単だが、Google Chrome がこの5年間に修正した 3,000 件以上の CVE のうち、これに関係するものは 20 件に満たない。あまり注目されていない角落なのだろう。
それはともかく、Navigation Throttle を調べ始め、Google Chrome と Microsoft Edge がそれぞれどう実装しているかを見た。コードを追う中で気づいたのが、Edge にはエンタープライズ向けの Entra ID 機能用に EdgeCAGuidanceNavigationThrottle という throttle があることだ(ここでの CA は Conditional Access の略)。Microsoft のサイト上でユーザーが仕事用アカウントでサインインしようとしているのを Edge が検知したとき、適切なユーザープロファイルへ誘導するのがこの throttle の役割だ。発火させるには、Microsoft が所有する特定のオリジンからナビゲーションを開始する必要がある。たとえば login.live.com だ:
location.href = `https://guidance-ca-error/?email={email}&switch_profile={url}`
throttle が発火すると、Edge は現在のユーザープロファイルのメールアドレスと ?email={email} を比較する。一致しなければ、アカウントの切り替えを促すダイアログを表示する。

しかし両者が一致した場合、Edge はもともと向かおうとしていた URL を switch_profile={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(¶ms);
つまりこの throttle は、遷移先 URL が特定の内部フォーマットに一致すると元のナビゲーションをキャンセルし、条件を満たせば攻撃者が制御する Query String の URL で新しいナビゲーションを開始する。この新しいナビゲーションは Browser Process が起動するため、理論上はこれまでできなかったことがいくつか可能になる。たとえば edge://settings や devtools://... のような通常は禁止されているページ、あるいは javascript:alert(origin) のような疑似スキームへ飛ばせてしまう。
JavaScript 疑似スキームは強力なプリミティブに思えるが、そもそも throttle を発火させるには login.live.com 上で Execution Context を取得する必要がある。だから javascript: へ飛べたところで、追加の能力が得られるわけではない。せいぜい Self-XSS のようなもの、あるいはそこにある Open Redirect を XSS に変える手段にすぎない。いずれにせよ、実行できる JavaScript は同じ login.live.com コンテキストの中だけだ。
正直なところ、このバグを最初に見たときは大して深刻には捉えていなかった。自分には、いくつか余分な Browser-Level Scheme へ飛べるだけの、より強力な Open Redirect に見えていたし、他のバグと組み合わせる必要もある。そんなわけで、このバグは何週間もメモの中に放置されていた… 🤦♂️
Sourceless Navigation:新しい 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 を新しいタブへ切り替え、何が起きるかを見てみると…
Boom! 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 の攻撃面が生まれたと言っていいだろう。
0クリックへの道
手に入れた UXSS プリミティブは悪くなさそうに見えるが、実際に発火させるには課題が多い。ここでは UXSS を武器化する過程で直面した4つの課題と、それをさらなるバグで突破した方法を紹介する 😉。
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(...) をブロックするのだ。

そこで私たちはMicrosoft Edgeのリバースエンジニアリングを続けた。そして見つけた。Microsoftはおそらく社内利用向けに、ポップアップのハードコードされたホワイトリストを独自に用意していた。つまり、次のようなポップアップは通常どおりブロックされる:
popup = window.open('https://malicious.tld', '_blank') // blocked
しかしURLをホワイトリスト登録済みのものに差し替えると……ウィンドウがそのまま現れる。あとは元のサイトへ戻るようにナビゲートすれば、ポップアップブロッカーを回避できる!
popup = window.open('about:blank#quickAuthPopup', '_blank') // granted
popup.location = 'https://malicious.tld'
ありがとう、Microsoft!

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アプリケーションならどれでも、Microsoft所有のサイトを訪れた際に現在のMSA資格情報で自動的にサインインできる機能だ。しかも25H2以降、Windows OOBEはMSAでのサインインを要求する(ローカルアカウントの回避策を使わない限り)。つまり、ブラウザーでユーザープロファイルを一度も作成していなくても、www.microsoft.comを訪れるだけでEdgeがプロファイルを作成し、自動的にサインインしてくれる。
重ね重ねありがとう、Microsoft!
4. ログイン用メールアドレスの事前入手が必要な理由
サインインの問題を解決できたので、残る課題は被害者のメールアドレスを事前に入手することだ。もちろんソーシャルエンジニアリングを試すこともできるし、Microsoft Edge のリバースエンジニアリングを続けてメールアドレスを漏洩させる別のバグを見つけることもできるだろう。しかし、自動サインインがすでに複数の Microsoft クッキーを設定しているため、メールアドレスを窃取するには Web ハッキングのほうが簡単だと考えた。そこで Burp Suite を立ち上げ、Web の脆弱性を探し始めた。
数時間かけて調べたところ、Microsoft のサイトで複数の XSS バグが見つかった。調査を進める中で、assets.msn.com が edge://new-tab-page-online に埋め込まれており、現在サインインしているユーザーのメールアドレスを返すことに気づいた。さらに興味深いことに、レスポンスヘッダーを確認すると、このサイトには CORS の設定不備があるようだ……

そこで、認証情報を含むシンプルな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 )
まとめ
ここまで、このプリミティブを動かすために次のものを連鎖させてきた:
- クロスウィンドウの Opener チェック漏れによる Trusted Origin バイパス
- ハードコードされたホワイトリストによるポップアップブロッカーのバイパス
- 自動でサインインさせる OneAuth の機能
- サインイン済みユーザーのメールアドレスを盗める CORS の設定不備
これでようやく、クリック一つなしに UXSS を発火できる! しかもこの UXSS はブラウザプロセスから起動される(興味があれば、Mojo で言うHandleRendererDebugURLだ)ので、CSP を完全に無視できる。つまりgoogle.com、facebook.com、icloud.com、その他どんなサイトでも JavaScript を実行できる!
ブラウザサンドボックスからの脱出
これで Universal XSS を手に入れた。とはいえ、何でもやり放題というわけではない。Web セキュリティの話であれば、その通りだ。しかしここでの目標は ACE(任意コード実行)であり、UXSS だけではまったく足りない。この UXSS をコード実行に変えるには、ブラウザサンドボックスを抜け出す方法をまだ見つける必要がある。
まず思いついた最も素直なアイデアはこうだ。任意のサイト、より正確には任意のオリジンで JavaScript を実行できるなら、edge://settingsやedge://downloadsのような特権 WebUI ページを利用して ACE を取ればいいのではないか?
もちろん、こんな安直な手は Chromium の開発者によってすでに塞がれている。Renderer の Mojo ハンドラには追加のチェックがあり、オリジンが以下の特権 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);
}
要するに Chromium は、思いつく限りの特権 WebUI をnot_allowing_javascript_urls_schemesのブラックリストに入れている。ここでしばらく完全に詰まってしまった。😖
Immersive Reader を経由した任意ファイル書き込み
しかし Microsoft Edge を調べ続けるうちに、Edge には Immersive Reader という独自のリーディングモードがあることがわかった。Chrome 内蔵のリーディングモードと違い、Immersive Reader は早い段階で Chromium からフォークされ、Microsoft がその上に機能を積み上げたようだ。そのため Edge は、古いchrome-distiller://やブラックリスト入りのchrome-untrusted://ではなく、独自のread://スキームを使っている。そして Microsoft はこのスキームをブラックリストに追加するのを忘れていたらしく、この特権 WebUI 上で UXSS が動く :)
この特権的なWebUIからは、JavaScriptを通じて多数のネイティブC++オブジェクトにアクセスできる。そのうちchrome.edgeFeedbackPrivate.zipDiagnosticLogFiles()というオブジェクトが特に興味を引いた。試してみると、この関数はファイル名とJSON文字列の2つの引数を取ることがわかった。
↓ これは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にまとめ、Microsoftのサーバーへアップロードし、最後にすべてを片付ける。もちろん../を試したくなるだろう……そう、一時フォルダの外へ抜け出せる。ただし.json拡張子がまだ少々うるさい。NULLバイトでそれを消せないかと思うかもしれない……もちろん消せる! こうして、ファイル名と内容の両方を制御できる任意ファイル書き込みが手に入り、好きな場所の好きなファイルを上書きできるようになった。
Microsoftよ、何度でも感謝する!

protocols.jsonを上書きしてプロトコルハンドラーを自動起動させる
これで材料はほぼ揃った。最後の仕上げは、ブラウザレベルでの任意ファイル書き込みをACEにつなげ、攻撃チェーンを完成させる方法だ。調べると、Microsoft Edgeは初期化時にいくつか興味深いサイドロードを行っていた。設定ファイルをまとめて読み込んだり、書き込み可能なフォルダーからDLLをロードしたりしている。
DLLサイドロードは魅力的に映る。だが、こちらのファイル書き込みはJSONで包まれているため、中身は有効なUTF-8文字列でなければならない。しかもMicrosoft Edgeは64ビットプロセスなので、落とすDLLも64ビットである必要がある。しばらく取り組んでみたが、UTF-8の制約を満たす有効な64ビットPEヘッダーはどうやっても組み立てられず、DLLサイドロードは諦めた。
代わりに見つけたのが、あまり自明でない方法だ。EdgeはOneAuth以外にも初期化時にさまざまなコンポーネントを読み込んでおり、その一つが「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が取れる。エクスプロイト全体で100行に満たないコードだ。実際の攻撃では再起動は不要で、必要なのはPwn2Ownのときだけ。デモを見てみよう! 🔥
最後に
モダンブラウザのエクスプロイト開発はまったくの未経験だったが、チェーン全体を完成させるまでに丸2か月かかった。LLMもAIも一切使っていない。なぜかって? かっこいいからかもしれないし、単にタイミングが良かっただけかもしれない。CodexやClaude Codeのようなコーディングエージェントを使い始めたのは今年の3月ごろで、その時点ですでにバグは全部見つけ終え、難しい部分は片付いていた ¯_(ツ)_/¯
難しい部分とは何か。メモリ破壊なら、それはたいていエクスプロイトだ。だが今回相手にしているのはロジックバグで、難しいのは見つけるほう。全部見つけてしまえば、チェーンをつなぐのは一番簡単な工程になる。だからこのエクスプロイトは最後までAIなしで通した。
それとは別に、このUXSSプリミティブは新しい攻撃面だと見ているし、AI時代において「Attack Surface is the new 0day」という意見にも賛成だ。この金鉱をAIの巨人たちに共有する前に、もう少し自分で掘ってみたかった。これもこのチェーンでAIを使わなかった理由のひとつ。
もちろん、新しいものを試さない古臭い人間でもない。このチェーンの外では、私もトークンを溶かすのに夢中で、AIにどっぷり浸かっている。LLMの便利さを一度味わったら、手作業でバグを探す生活には戻りたくならないはずだ。そこが未来でもあると思っている。
AIに置き換えられることも、それほど怖くはない。むしろAIのおかげで、TODOリストにずっと積まれていた面白いことを片付ける時間がようやくできたと言ったほうが正確だ……先日報告したWindows RCE、チェーンを反転させてcalc.exeからMicrosoft Edgeをポップさせる話、あるいはこのロジックのみのフルチェーンをGoogle Chromeに移植する話(もうすぐ ><)。来月のCODE BLUE in Japanでも進捗を披露する。お楽しみに! 😉
脆弱性研究のハードルがどこまで下がるかを心配するより、AIがどこまで引き上げてくれるかにワクワクしたほうがいい。どんな遊びができるのか、もっとかっこいいハックを組めるのか、不可能なことを可能にできるのか……?
Never Stop Exploiting!