- Published on
axios アカウント乗っ取り3時間 — provenance はオンだったが、誰も確認しなかった
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに — 2026年3月31日00:21 UTC
- レジストリが残した領収書
- 侵入経路 — トークンではなく人のPC
- trusted publishing はオンになっていた
- ベンダーの説明の一つがタイムラインとずれている点
- 実際にこれを止めたのは人間だった
- provenance は何を証明するのか
- npm audit signatures を実際に走らせてみると
- 実際に効いていたはずの統制 — 待つこと
- 爆発半径は誰にもわからない
- では何をすべきか
- おわりに
- 参考資料
はじめに — 2026年3月31日00:21 UTC
サプライチェーンセキュリティの記事は、たいてい二種類に分かれます。事故が起きたというニュースか、「SBOMを導入して署名を検証しましょう」という一般論か。どちらもあまり役に立ちません。前者はどの統制がなぜ失敗したのかを教えてくれず、後者はすでにその統制をオンにしていた人がなぜやられたのかを説明できません。
2026年3月31日の axios の事件は、まさに後者に当てはまります。axios はすでに npm の trusted publishing を使っていました。直前の正規リリースには SLSA provenance が付いていました。保守者のアカウントには2FAがオンになっていました。そして、突破されました。
本稿が他の記事と違うのは、ここに出てくるタイムスタンプの大半を私が npm レジストリ API から直接確認したという点です。そして読者のみなさんも、今すぐ同じ方法で確認できます。事件は終わりましたが、領収書はレジストリに残っています。
レジストリが残した領収書
npm レジストリのパッケージメタデータには time マップがあります。ここには面白い性質が一つあります。バージョンが unpublish されても time エントリは残る、という点です。つまり、削除された悪性バージョンがいつアップロードされたかは、今も公開されています。
自分でやってみてください。
curl -s https://registry.npmjs.org/axios | python3 -c "
import sys, json
d = json.load(sys.stdin)
t = d['time']
for v in ['1.14.0', '1.14.1', '0.30.3', '0.30.4']:
print(v, t.get(v), '| 現在存在:', v in d['versions'])
"
私が2026年7月16日に実行した結果はこうでした。
1.14.0 2026-03-27T19:01:40.915Z | 現在存在: True
1.14.1 2026-03-31T00:21:58.168Z | 現在存在: False
0.30.3 2026-02-18T17:19:20.081Z | 現在存在: True
0.30.4 2026-03-31T01:00:57.285Z | 現在存在: False
1.14.1 と 0.30.4 は、発行された時刻は記録されているのに今は存在しません。これが unpublish の痕跡です。そしてこの二つのタイムスタンプは、保守者 Jason Saayman が書いた公式ポストモーテムのタイムラインと秒単位で一致しています。
注入された依存パッケージの側も同様です。
curl -s https://registry.npmjs.org/plain-crypto-js | python3 -m json.tool | head -20
plain-crypto-js の time マップには 4.2.0 が 2026-03-30T05:57:32.867Z、4.2.1 が 2026-03-30T23:59:12.278Z として残っており、現在の dist-tags.latest は 0.0.1-security.0 です。repository は npm/security-holder — npm が悪性パッケージを削除した後に名前を確保しておくプレースホルダーです。そのプレースホルダーが立った時刻が 2026-03-31T03:25:11.636Z です。
まとめると、攻撃の順序はこうです。攻撃者はまず3月30日05:57に無害な plain-crypto-js@4.2.0 を公開して足場を作り、同じ夜の23:59に悪性の postinstall を仕込んだ 4.2.1 を公開し、それから1時間も経たない3月31日00:21に、それを依存関係として組み込んだ axios@1.14.1 を発行しました。おとりを先に仕込み、本体を後で動かしたわけです。
侵入経路 — トークンではなく人のPC
ポストモーテムによれば、始まりはレジストリではなく人でした。攻撃者は約2週間にわたる標的型ソーシャルエンジニアリングで、リード保守者のPCに RAT(遠隔操作型トロイの木馬)を仕込み、そこから npm アカウントの資格情報を手に入れました。
ここで重要なのは、2FAがオンになっていたという点です(Datadog Security Labs の分析)。当然です — 攻撃者が保守者のマシンの中に座っている以上、2FAは通過儀礼にすぎません。2FAは「よその場所からログインする他人」を止める統制であって、「あなたのコンピュータそのものになった他人」を止める統制ではありません。
ペイロードは macOS・Windows・Linux すべてを狙うクロスプラットフォームの RAT で、C2 は sfrclak[.]com(ポート8000)でした。ポストモーテムは、影響を受けた可能性のある人にロックファイルで axios@1.14.1、axios@0.30.4、plain-crypto-js を grep してみて、該当すればそのマシンを侵害済みとみなしすべての資格情報をローテーションするよう案内しています。特に CI ランナーで該当した場合は、そのビルドに注入されたすべてのシークレットをローテーションするように、とも書かれています。
trusted publishing はオンになっていた
ここからがこの事件でいちばん興味深い部分です。axios はすでに trusted publishing を使っていました。レジストリからバージョンごとの発行者と provenance の有無を取り出すと、それがすぐに見えます。
curl -s https://registry.npmjs.org/axios | python3 -c "
import sys, json
d = json.load(sys.stdin)
for v in ['1.13.0', '1.14.0', '1.18.1', '0.30.3']:
ver = d['versions'][v]
att = ver['dist'].get('attestations')
print(v, '| 公開者:', ver['_npmUser']['name'], '| provenance:', 'O' if att else 'X')
"
結果です。
1.13.0 | 公開者: jasonsaayman | provenance: O
1.14.0 | 公開者: GitHub Actions | provenance: O
1.18.1 | 公開者: GitHub Actions | provenance: O
0.30.3 | 公開者: jasonsaayman | provenance: X
ここから二つのことが読み取れます。
第一に、1.13.0と1.14.0のあいだで trusted publishing への切り替えが起きた痕跡がそのまま見えます。発行者名が個人アカウントから GitHub Actions に変わっています。つまり、攻撃の4日前である3月27日に出た正規リリース1.14.0は GitHub Actions が OIDC で発行したものであり、provenance も自動的に付いていました。
第二に、レガシーな0.xラインには provenance がまったくありませんでした。個人アカウントから手動で出続けていたわけです。攻撃者が1.14.1と0.30.4を同時に狙ったのは偶然ではないかもしれません — 0.xラインは、そもそも見逃す信号すら存在しない、脆い標的だったのです。
すると、問いはこう変わります。trusted publishing をオンにしていたのに、なぜ個人アカウントから発行が出たのか?
答えは npm の公式ドキュメントにそのまま書かれています。trusted publishing を設定すると、npm は承認されたワークフローからの発行を受け付けますが、ドキュメントの表現をそのまま移せば「npm トークンや手動発行のような従来の認証手段に加えて(in addition to)」受け付けます。つまり trusted publishing は、既定では追加の経路であって排他的な経路ではありません。トークンと手動発行の経路を閉じるには、パッケージ設定で「Require two-factor authentication and disallow tokens」を別途選ばなければなりません。
したがって正確に言えば、trusted publishing が突破されたわけではありません。暗号学的に迂回されたわけでもありません。鍵のかかっていない裏口がそのまま残っていて、攻撃者はそこから歩いて入ってきました。保守者がポストモーテムの「今後変わること」の表に「Proper adoption of OIDC flow for publishing」と書いたのは、この意味です。ここで力点は proper にあります — オンにすることではなく、唯一の経路にすることです。
Orca Security もこの構造的な指摘そのものは正確に押さえています。彼らの表現を移せば「Trusted Publishing only works if it's the sole publishing path」— 唯一の配布経路であるときにのみ機能する、ということです。これは正しく、npm のドキュメントも裏付けています。
ベンダーの説明の一つがタイムラインとずれている点
ただし、同じ Orca の記事にはこうも書かれています —「The likely entry point was a long-lived classic npm access token, which can sidestep 2FA requirements entirely.」古い classic トークンが残っていて、それが入口だったらしいという仮説です。もっともらしく聞こえます。レガシートークンが最新の統制を迂回する裏口だったという筋書きはきれいで、セキュリティ業界が好む型です。
問題は日付です。GitHub は2025年12月9日のチェンジログでこう明言しています —「We've permanently revoked all existing npm classic tokens. They can no longer authenticate, be recreated, or be recovered.」すべての classic トークンが恒久的に失効し、認証も再作成も復元もできない、ということです。同じ発表で npm login は、長期トークンの代わりに2時間のセッショントークンを発行するように変わりました。
axios への攻撃は、それから約3か月半後の2026年3月31日です。その時点で classic トークンは認証そのものが不可能でした。 したがって Orca の仮説は成り立ちにくいものです。そして保守者本人は、ポストモーテムでずっと単純な経路を語っています — ソーシャルエンジニアリングで PC に RAT を仕込み、そこから資格情報を持ち出した、というものです。
公正に言えば、Orca は「likely」とヘッジしており、構造的な結論(「唯一の経路であるべき」)は正しいものでした。彼らが嘘をついたと言いたいわけではありません。ただ、これは事故分析でよくある失敗のパターンです — 既知の失敗パターンに事件を当てはめてしまうと、結論がたまたま合っていても、対応はまったく的外れな方向に進みます。 この仮説を信じると「残存する classic トークンを監査せよ」というアクション項目が出てきますが、それは npm がすでに4か月前にグローバルに解決した問題であり、本当の問題だった「保守者のワークステーションが侵害されたこと」と「手動発行の経路が開いていたこと」には、まったく手が届きません。
教訓は単純です。ベンダーブログは IOC や技術分析には有用ですが、侵入経路についての説明は一次のポストモーテムとレジストリのタイムラインに照らし合わせてください。今回、その照合にかかった時間は5分でした。
実際にこれを止めたのは人間だった
では、何が止めたのでしょうか。provenance ではありません。trusted publishing でも2FAでも、どんなスキャナでもありません。
ポストモーテムの文章をそのまま移すのがいちばん正確です —「There was no automated way to detect an unauthorized publish. Detection depended entirely on the community noticing.」無断発行を検知する自動化された方法はなく、検知は完全にコミュニティが気づくことに依存していた、ということです。
実際に起きたことはこうです。GitHub API で確認した PR #10591 は、axios のコラボレーターである DigitalBrainJS が 2026-03-31T01:38:47Z に開き、01:42:15Z にマージされました。タイトルは素っ気なく chore(ci): add deprecate action; です。悪性発行(00:21)から77分後です。彼は侵害されたバージョンを deprecate し、コミュニティに知らせ、npm に直接連絡しました。そして03:15に、悪性バージョンがレジストリから削除されました。
ここで見逃しやすい細部が二つあります。
攻撃者は通報を消していました。 ポストモーテムによると、01:00頃にコミュニティのメンバーが侵害を報告するイシューを立てましたが、攻撃者は乗っ取ったアカウントでそれらのイシューを削除しました。アカウント乗っ取りは発行権限だけが渡るわけではありません — モデレーション権限も一緒に渡ります。 攻撃者は配布と同時に、通報を消すこともできたのです。
対応者の権限は攻撃者より低いものでした。 保守者は謝辞のなかで、DigitalBrainJS が「乗っ取られたアカウントが自分より高い権限を持つ状況の中で」素早く動き、npm に対処させたと書いています。インシデント対応計画を立てるときにほとんど誰も想定しないシナリオです — 最も高い権限を持つアカウントが敵対的なとき、残りの人たちに何ができるのか。
3時間という窓は短く見えますが、その短さは統制の成果ではなく、目を覚ましていた協力者一人の成果でした。彼がその夜眠っていたら、窓は何倍にも長引いていたはずです。人に依存する検知はスケールしません。
provenance は何を証明するのか
ここで provenance をきちんと押さえておく必要があります。これを「安全証明書」だと誤解する人が多いからです。
1.14.0 の attestation バンドルを直接開くと、実際に何が入っているかが見えます。
curl -s "https://registry.npmjs.org/-/npm/v1/attestations/axios@1.14.0" | python3 -c "
import sys, json, base64
d = json.load(sys.stdin)
for a in d['attestations']:
if 'slsa' not in a['predicateType']: continue
p = json.loads(base64.b64decode(a['bundle']['dsseEnvelope']['payload']))
print(json.dumps(p['predicate']['buildDefinition']['externalParameters'], indent=2))
"
出てくる内容はこうです。
{
"workflow": {
"ref": "refs/tags/v1.14.0",
"repository": "https://github.com/axios/axios",
"path": ".github/workflows/publish.yml"
}
}
つまり provenance が署名して語っているのはこういうことです — このtarballは axios/axios リポジトリの v1.14.0 タグから、publish.yml ワークフローで、GitHub ホスティッドランナーがビルドした。出自とビルド経路です。
それがすべてです。npm のドキュメント自身がこう明言しています —「When a package in the npm registry has established provenance, it does not guarantee the package has no malicious code.」provenance があるからといって、悪性コードが無い保証にはならない、ということです。
これがなぜ重要かというと、今回の攻撃者は保守者のPCを掌握していました。その状態なら GitHub の資格情報も一緒に持ち出していた可能性が高いです。もし攻撃者が npm に直接発行する代わりに、リポジトリへコミットを押してタグを打っていたら、CI がビルドし、完全に有効な provenance が付いた悪性パッケージができあがっていたはずです。署名は本物で、出自も本物で、コードだけが悪性という状態。provenance はそれを捕まえられません。捕まえるために作られたものではないからです。
したがって、trusted publishing を排他的にオンにしていれば今回の攻撃は防げたはずですが、それは攻撃を無くしたのではなく、より高くつく経路へ移しただけです。リポジトリへのプッシュ → レビュー → タグ → CI という経路には、ブランチ保護とレビューがかかっており、何より、コミットという痕跡が残ります。これが統制の正直な価値です。不可能にするのではなく、高くつく上に騒がしくすること。
npm audit signatures を実際に走らせてみると
ところが、この事件にはもっと痛いところがあります。信号はレジストリの中にありました。
1.14.0には provenance があり、発行者は GitHub Actions でした。1.14.1には provenance がなく、発行者は個人アカウントでした。この違いは、発行の瞬間から公開APIに露出していました。消費者側で「axios は必ず axios/axios リポジトリ由来の provenance を持たなければならない」を強制してさえいれば、1.14.1は自動的に弾かれていたはずです。
問題は、誰もそれを強制していないという点です。既定値がそうなっていないからです。
自分で確かめてみました。provenance のあるパッケージと無いパッケージを一緒にインストールし、npm audit signatures を走らせました。
npm init -y
npm i axios@1.14.0 left-pad@1.3.0
npm audit signatures
結果です。
audited 24 packages in 0s
24 packages have verified registry signatures
1 package has a verified attestation
終了コードは0でした。通過です。
24個のうち attestation が検証されたのはたった1個なのに、通過します。つまり npm audit signatures は、あるものだけを検証し、無いものは問題にしません。 provenance の不在はエラーではないのです。攻撃者が provenance なしで1.14.1をアップロードしていたとしても、このコマンドは同じように0を返していたはずです。
これがこの事件の核心的な皮肉です。エコシステムは信号を作ることには成功し、それを消費することには失敗しました。 provenance は発行者側の成果物であり、消費者が検証を強制しない限り価値はゼロです。バッジは綺麗に付いていますが、CIは何も問いません。
ここには鶏と卵の問題が横たわっています。provenance のカバレッジが低いので「provenance が無ければ失敗」を既定でオンにはできず、誰もオンにしないから provenance の価値が上がりません。だから現実的な強制は、グローバルなポリシーではなく自分が知っている高リスクなパッケージ数個へのピンポイント検証です。axios、ビルドツール、CIアクション — 数えられる範囲から始めます。
実際に効いていたはずの統制 — 待つこと
では、消費者としてこの攻撃を実際に止められた統制は何だったのでしょうか。署名ではありません。時間です。
pnpm には minimumReleaseAge という設定があります。公式ドキュメントの説明はこうです — 新しく発行されたバージョンのインストールを遅らせることで、侵害されたパッケージをインストールしてしまうリスクを減らす。値は分単位で、推移的依存を含むすべての依存関係に適用されます。そしてドキュメントが根拠として挙げている文章が、この事件にぴったり重なります —「In most cases, malicious releases are discovered and removed from the registry within an hour.」大半の悪性リリースは1時間以内に発見され、レジストリから削除される、ということです。
既定値が重要です。pnpm v11から minimumReleaseAge の既定値は1440分、つまり24時間です(v11より前は0でした)。
axios の悪性バージョンが生きていた窓は約3時間でした。24時間の待機は、その窓を丸ごと覆います。事件当時 pnpm v11の既定値でインストールしていた人は、1.14.1にまったく出会いませんでした。署名もスキャナも脅威インテリジェンスも要りません。ただ一日待っただけです。
これはやや拍子抜けする結論です。エコシステムがこの3年間 sigstore や SLSA に注いできた労力に比べると、「一日待ちましょう」はいささか貧相に見えます。しかし今回の事件に限って言えば、それが効いていて provenance は効いていませんでした。サプライチェーン攻撃の支配的な形が「短い窓にアップロードし、発覚したら下ろされる」であるかぎり、待機は非対称に強力です。攻撃者が持つ唯一の資産である、その窓そのものを直接狙うからです。
もちろんトレードオフがあり、それは正直に言うべきです。
- セキュリティパッチが遅く届きます。 本当に急ぐ CVE の修正も24時間待つことになります。そのため
minimumReleaseAgeExcludeで特定の依存関係を例外扱いする仕組みが用意されています。 - 長い潜伏型の攻撃は防げません。 攻撃者が悪性バージョンをアップロードしたまま一週間待てば、待機は無力です。待機が効くのは「早く発覚する攻撃」だけです。発覚が遅れるほど価値は下がります。
- 検知ではなく回避です。 待機は何も知らせてくれません。ただ、他の誰かに先に踏ませるだけです。これがエコシステム全体として公正な戦略かどうかは別問題です — みんなが24時間待てば、誰も先に発覚させてくれないからです。この統制は、他人が使っていないときにこそいちばんよく効く種類のものです。
爆発半径は誰にもわからない
最後に数字の話です。この事件を扱った記事は、たいてい「週間ダウンロード1億件」と「依存パッケージ17万4千個」を引用します。Orca と Datadog はどちらも "roughly 100 million weekly downloads and over 174,000 dependent packages" と書いています。
私は npm の公式ダウンロードAPIで直接測り直しました。
curl -s "https://api.npmjs.org/downloads/point/last-week/axios"
{"downloads":85473581,"start":"2026-07-09","end":"2026-07-15","package":"axios"}
2026年7月9日から15日までの実測は8,547万3,581件です。「約1億」は丸めがかなり甘い方で、いずれにせよ事件当時ではなく今の時点の数値です。大きな差ではありませんが、引用された数字をそのまま流用する前に30秒あれば原本を確認できる、という点は指摘しておく価値があります。
もっと重要なのはこちらです。悪性バージョンが実際に何回インストールされたかは、公開情報からはわかりません。 npm のバージョン別ダウンロードAPIを見ると、1.14.1と0.30.4はそもそも一覧に出てきません。
curl -s "https://api.npmjs.org/versions/axios/last-week" | python3 -c "
import sys, json
dl = json.load(sys.stdin)['downloads']
for v in ['1.14.0', '1.14.1', '0.30.4']:
print(v, dl.get(v, 'なし'))
"
1.14.0 1735295
1.14.1 なし
0.30.4 なし
unpublish されたバージョンのダウンロード数は露出されません。したがって「1億人が危険にさらされた」といった類の文章はすべて、潜在的な露出規模であって、実際の被害規模ではありません。 週間ダウンロード8,500万件のパッケージであっても、その3時間の窓の中で実際に何件が新規インストールを走らせ、そのうち何件が latest を追いかけていたかは、まったく別の問題です。ロックファイルにピン留めされていて、その3時間にフレッシュインストールを走らせなかったのなら、安全でした — ポストモーテムもまさにそう述べています。
Orca が「live for under three hours」という点を挙げて、実際の露出は限定的だったと留保を付けたのは、公正な記述です。しかし見出しに残るのはいつも1億です。セキュリティベンダーの集計はおおむね潜在的な露出を最大値で見積もる方向に傾き、それが常に嘘というわけではありませんが、常に上限であることは覚えておく必要があります。正直な答えはこうです —わかりません。レジストリはそのデータを公開していません。
では何をすべきか
この事件から実際に導かれる行動は、役割によって異なります。
パッケージを発行する側なら
- trusted publishing をオンにしただけで終わらせないでください。パッケージ設定でトークンと手動発行を閉じて初めて、排他的な経路になります。オンにしただけの trusted publishing は、axios が証明したとおり裏口を残します。
- レガシーなリリースラインを忘れないでください。axios の0.xは provenance なしで個人アカウントから出続けていました。攻撃者は最もよく守られたラインではなく、最も守られていないラインを選びます。
- 無断発行に対する通知経路を作ってください。今回の検知は完全に人の運でした。
パッケージを消費する側なら
minimumReleaseAgeをオンにしてください(pnpm v11からはすでに24時間が既定値です)。この事件に限って言えば、これが唯一実際に機能していたはずの統制です。- ロックファイルにピン留めし、フレッシュインストールをいつでも走らせないようにしてください。ピン留めしていた人たちは、ただ安全でした。
- 高リスクなパッケージ数個に限って、provenance の出自を強制的に検証してください。
npm audit signaturesは無い provenance を問題にしないので、それだけを信じてはいけません。 - インストールスクリプトを遮断することを検討してください。今回のペイロードは
postinstallで実行されました。
どちらの立場でも
RAT が仕込まれた保守者のワークステーションの前では、レジストリ側の統制の大半が無力になります。この事件の根本原因は npm ではなく、2週間のソーシャルエンジニアリングでした。そしてそれは署名で解ける問題ではありません。
おわりに
まとめるとこうです。axios は trusted publishing をオンにしており、provenance を発行しており、2FAを使っていました。それでも3時間、クロスプラットフォームの RAT を配布し続けました。trusted publishing が突破されたからではなく排他的でなかったから、provenance が失敗したからではなく誰もそれを強制しなかったから、です。そしてこれを止めたのはどんな統制でもなく、その夜たまたま目を覚ましていた協力者一人でした。
本稿から一つだけ持ち帰るとすれば、これです —統制はオンにするだけでなく、唯一のものにし、強制して初めて価値を持ちます。 オンになっているだけの統制は監査チェックリストを通過させはしますが、ダッシュボードの外にいる攻撃者は止められません。
もう一つ。本稿の事実関係の大半は、ベンダーブログではなく curl と公式ポストモーテムから得たものであり、その過程で、広く引用された侵入経路の説明の一つがレジストリのタイムラインとずれていることを見つけました。事故を読むときは、まず一次資料から見てください。レジストリは、たいてい嘘をつきません。
参考資料
- Post Mortem: axios npm supply chain compromise — Jason Saayman (axios/axios#10636) — 保守者本人による一次ポストモーテム
- PR #10591 — DigitalBrainJS が侵害されたバージョンを deprecate したコミット
- Compromised axios npm package delivers cross-platform RAT — Datadog Security Labs — 技術分析と IOC(セキュリティベンダー自身の分析)
- Supply Chain Attack on Axios Delivers Cross-Platform RAT via Compromised npm Account — Orca Security — 構造的な指摘は妥当だが、classic トークン仮説はタイムラインとずれている(セキュリティベンダー自身の集計)
- npm Trusted Publishers 公式ドキュメント — 「in addition to」という文言とトークン遮断設定
- Generating provenance statements — npm 公式ドキュメント — provenance が悪性コードの不在を保証しないことの明記
- npm classic tokens revoked, session-based auth — GitHub Changelog (2025-12-09)
- pnpm settings — minimumReleaseAge
- npmのサプライチェーン攻撃が消えない理由(関連記事)