Dart/Flutterの公式パッケージレジストリである pub.dev で公開されていたFlutterパッケージ universal_file_viewer に、macOS開発者を狙うワーム XCSSET の一種が混入した。セキュリティベンダーのAikidoが2026-09-08に報告した事案だが、レジストリとGitHubリポジトリを自分で実測したところ、報告されている0.1.5だけでなく0.1.6も同一の感染Xcodeプロジェクトを同梱したまま公開されていたことが確認できた。本記事は一次データで時系列を再構成し、読者が自分の環境を確認するためのコマンドを陽性・陰性の対照群つきで検証した記録である。

universal_file_viewer 0.1.4から0.1.7までの実測タイムライン。0.1.5でGradleとXcodeの両方に注入、0.1.6でGradleのみ修正、0.1.7で完全に除去。
pub.dev APIとGitHubコミットから再構成した4バージョンの実測タイムライン(2026-09-09 09時JST時点で確認)。

30秒でわかる

・対象は universal_file_viewer(Flutter向けファイルプレビュー)の 0.1.5 と 0.1.6。0.1.4以前と0.1.7は該当しない
0.1.6も感染している——Aikidoの記事は0.1.5のみを挙げるが、0.1.5と0.1.6の project.pbxproj はSHA-256がバイト単位で一致した
・汚染ファイルは example/ の中だけ。依存として使うだけでは実行されない。危険なのはexampleアプリを自分でビルドした場合
retractは削除ではない。0.1.5/0.1.6は撤回済みだが今もHTTP 200で取得でき、lockに固定されていれば導入は成功し続ける
・OSVにもpub.devにも助言は出ていない。自動スキャナは沈黙するので手元で確認するしかない

同種のレジストリ汚染がどういう構造で起き、どこで止められるのかを体系的に押さえたい場合は、サプライチェーン攻撃とは|手口・防御ツール比較・npm 12の新機構まで実践解説を先に読むと本記事の位置づけがつかみやすい。

pub.devで何が起きたか——4バージョンの実測タイムライン

pub.devのパッケージAPI(https://pub.dev/api/packages/universal_file_viewer)は、全バージョンの公開時刻をマイクロ秒精度のUTCで返す。GitHubリポジトリ Shonu72/universal_file_viewer のコミット履歴と突き合わせると、当日の動きは1時間単位で再構成できる。

時刻(UTC / JST) 出来事 実測した根拠
2026-05-18 06:49 / 15:49 0.1.4 公開(クリーン) APIの published
2026-09-08 10:42 / 19:42 GitHubコミット 6b88507b「chore: release version 0.1.5」 コミットAPI
2026-09-08 10:44 / 19:44 0.1.5 公開(Gradle・Xcode両方に注入) APIの published
2026-09-08 11:42 / 20:42 コミット 01e4dc93「chore: release version 0.1.6」 コミットAPI
2026-09-08 11:45 / 20:45 0.1.6 公開(Gradleは修正・Xcodeは未修正) APIの published
2026-09-08 17:28 / 翌02:28 コミット d7a3388d「security: clean up Xcode project build rules in example app (v0.1.7)」 コミットAPI
2026-09-08 17:30 / 翌02:30 0.1.7 公開(クリーン) APIの published
(時刻不明) 0.1.5・0.1.6 がretractされる APIの retracted: true

Aikidoのブログ記事自体は2026-09-08付である。retractが行われた正確な時刻はAPIから取得できないため、「いつ撤回されたか」は未検証とする。

汚染版が公開されていた時間帯そのものは短い。0.1.5は約1時間で0.1.6に置き換えられ、0.1.7が出るまでの総経過時間は最初の汚染版公開から約6時間45分である。メンテナは同日中に修正版を出し、そのコミットに自ら「security」と付けている。メンテナは加害者ではなく被害者であり、対応は速かったと評価してよい。

flowchart TD A["メンテナのmacOS
XCSSETに感染済み"] --> B["ローカルのGradle/Xcodeプロジェクトに
ビルドフックを自動注入"] B --> C["git commit
注入済みファイルごとコミット"] C --> D["git tag を push"] D --> E["GitHub Actions
publish.yml が発火"] E --> F["OIDCでpub.devに認証
(Trusted Publishing)"] F --> G["pub.devに公開
0.1.5 / 0.1.6"] G --> H["利用者のpub-cacheへ
example/ ごと配布"] H --> I["exampleをビルドした場合のみ
ペイロードが起動"]

注目すべきは経路にどこも「盗まれた認証情報」が出てこない点である。公開はGitHub Actionsから OIDC(Trusted Publishing) で行われており、長期有効なトークンは存在しない。詳しくは後述する。

Aikidoが報告していない事実——0.1.6も同じ感染Xcodeプロジェクトを積んでいた

Aikidoの記事が名指ししているのは 0.1.5 のみである。しかしretractされているのは 0.1.5 と 0.1.6 の2つだった。この食い違いを埋めるため、4バージョンのアーカイブをpub.devから取得し、展開も実行もせずに中身を読み比べた。

まずアーカイブの真正性を確認する。ダウンロードした4本のSHA-256は、いずれもレジストリのメタデータが申告する archive_sha256 と完全に一致した。つまり以下の観測は改変されていない配布物そのものに対するものである。

バージョン example/android/app/build.gradle.kts iOS project.pbxproj macOS project.pbxproj isa = PBXBuildRule 判定
0.1.4 1,322 B 30,671 B 32,859 B 0 クリーン
0.1.5 1,748 B 32,074 B 34,403 B 各1 汚染
0.1.6 1,322 B 32,074 B 34,403 B 各1 汚染(Xcodeのみ)
0.1.7 1,321 B 31,032 B 32,862 B 0 クリーン

決め手はハッシュである。0.1.5 と 0.1.6 の example/ios/Runner.xcodeproj/project.pbxproj は SHA-256 が 7b14fc321d69992b…完全に一致した。macOS側も d070d148d6e5125a… で一致する。サイズが同じというだけでなくバイト単位で同一のファイルが、2バージョン続けて配布されていたということだ。

一方でGradle側は 0.1.6 で 1,322 B に戻り、これは 0.1.4 のクリーンなファイルとバイト単位で一致した。つまり0.1.6は「Androidビルドファイルの注入だけを取り消し、Xcodeプロジェクトの注入は残したまま」公開された版である。

この解釈は推測ではない。メンテナ自身のコミットが裏づけている。0.1.7 のコミット d7a3388d のメッセージは “security: clean up Xcode project build rules in example app” であり、変更内容は iOS project.pbxproj から24行、macOS側から23行の削除だった。Xcode側の後始末が0.1.7まで持ち越されたことをメンテナ自身が記録している。

.bak ファイルが示す「ランダム化された難読化」

0.1.5 には 0.1.4 に無かった example/android/app/build.gradle.kts.bak が現れる(GitHubのコミットでも +52行の新規追加として記録されている)。名前からは「注入前のバックアップ」に見えるが、実測では違った。.bak は 1,748 B で、これは注入後の 0.1.5 の Gradle ファイルと同じサイズであり、クリーンな 0.1.4 の 1,322 B とは一致しない。

.bak と 0.1.5 の Gradle ファイルの差分はわずか1行で、その1行は同じ処理を別の文字列変換で組み立て直したものだった。最終的に解決されるコマンドは両者で同じである。これはMicrosoftが2025年3月の解析で報告している、XCSSETのペイロード生成のランダム化——同じ処理を毎回異なる難読化で書き出す挙動——と整合する観測である。ペイロードそのものは本記事には掲載しない。

なお、Aikidoが挙げているC2ドメイン3件(いずれも .ru)については、4バージョンすべてのアーカイブを平文で検索しても0件だった。ペイロードはBase64相当で符号化されており、最長で132文字連続のトークンが確認できる。ドメインの帰属はAikidoの報告に依拠しており、本記事では独立に確認できていない。

自分は影響を受けているか——pubspec.lockの確認手順

ここが本記事の主目的である。掲載するコマンドはすべて、陽性・陰性の対照群を作って実際に動かし、正しく鳴り正しく黙ることを確認してある。

重要なのは2種類の確認を混同しないことだ。

区分 何を確かめるか ヒットしたら 誤解しやすい点
曝露(exposure) 汚染版アーカイブが自分の環境に届いたか 「要確認」であって感染ではない pub-cacheに残っていても、exampleをビルドしていなければ実行されていない
侵害(compromise) 自分のプロジェクトにXCSSETが注入されたか 実際に侵害された可能性が高い .git/hooks/ は配布物にもリポジトリにも含まれない=感染したマシンにしか存在しない

曝露の確認:pubspec.lock をSHA-256で検索する

pub.devのlockファイルは、hosted依存ごとに sha256: を記録する。バージョン文字列よりハッシュで探すほうが確実なので、こちらを主軸にする。

# 汚染版2つのアーカイブSHA-256(レジストリのメタデータと実ダウンロードの両方で一致を確認済み)
grep -rn -E '5cea38548f03cf44ad03bba44a3c6012782f280bd543a3c555535081353feb04|394220c2c0305231fd0f6fd09355634d51acdd87415404b57e7e422af6af3e8d' \
  --include=pubspec.lock .

検証結果は次のとおり。0.1.4を記録したlock(陰性対照)では何も出力せず、0.1.5・0.1.6を記録したlock(陽性対照)ではそれぞれ1件を検出した。沈黙が「安全」を意味するのか「コマンドが壊れている」のかを区別できる状態にしてある。

バージョン文字列で探したい場合は、窓幅に注意が必要だ。実際のlockファイルでは version: はパッケージ名の7行下にあるため、直感的に書きがちな -A6 では陽性対照すら検出できず、黙って素通りする。

# -A7 が必要。-A6 では陽性対照でも 0 件になり、安全と誤認する
grep -A7 -E '^  universal_file_viewer:' pubspec.lock | grep -E 'version: "0\.1\.[56]"'

この2つは実際に3種類のlockファイル(0.1.4 / 0.1.5 / 0.1.6)に対して走らせ、-A6 が陽性対照2件を取りこぼし、-A7 が正しく2件を検出することを確認している。

曝露の確認:pub-cache に残っていないか

ls -d ~/.pub-cache/hosted/pub.dev/universal_file_viewer-0.1.5 \
      ~/.pub-cache/hosted/pub.dev/universal_file_viewer-0.1.6 2>/dev/null

ここでヒットしても慌てないでほしい。キャッシュには example/ ごと展開されているため、感染したXcodeプロジェクトのファイルがディスク上に存在する。だがそれは「置いてある」だけで、ビルドされない限り実行されない。次項の侵害チェックをホームディレクトリ全体にかけると、このキャッシュが必ず引っかかって偽陽性になるので、除外するか結果を読み分ける必要がある。

侵害の確認:自分のXcodeプロジェクトを見る

# 自分で追加した覚えのないビルドルールが入っていないか(pub-cacheは除外)
grep -rl 'isa = PBXBuildRule' --include=project.pbxproj ~/ 2>/dev/null \
  | grep -v '\.pub-cache/'

このコマンドは、クリーンな 0.1.4 の project.pbxproj(陰性対照)で0件、汚染版 0.1.5 の同ファイル(陽性対照)で1件を返すことを確認済みである。Flutterが生成する標準のXcodeプロジェクトにカスタムビルドルールは含まれないため、心当たりのないヒットは疑わしいと判断してよい。

# git hooks への注入(これは感染したマシンにしか存在しない)
find ~ -type f -path '*/.git/hooks/pre-commit' -exec grep -l 'base64' {} \; 2>/dev/null

.git/hooks/ は配布アーカイブにもGitHubリポジトリにも含まれない。したがってここでヒットした場合、パッケージ経由の曝露ではなく自分のマシンが感染していることを意味する。曝露チェックと侵害チェックを分けるべき理由がここにある。

影響範囲マトリクス

上の2種類のチェック結果を、自分の使い方と突き合わせるための表を置く。

自分の状況 汚染版の取得 ペイロード実行 やること
0.1.4以前しか使っていない なし なし 対応不要
0.1.5 / 0.1.6 を依存に入れた(macOS)・exampleは触っていない あり なし 0.1.7へ更新し、pub-cacheの当該ディレクトリを削除
0.1.5 / 0.1.6 を依存に入れた(Linux / Windows CI) あり なし(実行条件を満たさない) 0.1.7へ更新。ただし注入ファイルが自リポジトリにコミットされていないかは確認する
リポジトリをcloneし、macOSでexampleアプリをビルドした あり あり得る 侵害チェックを実施し、ブラウザ保存の認証情報とセッションを失効させる
リポジトリをcloneしたがビルドしていない あり(作業コピー内) なし 作業コピーを最新に更新する

影響を受ける条件は「macOSで、example/ 配下のビルドを実際に走らせたか」の一点に集約される。依存として取り込んだだけではペイロードの実行条件を満たさない。

なぜ「インストールが失敗しない」のか——pub.devのretractは削除ではない

npmのサプライチェーン事案を追っていると、悪性版が unpublish で消され、その巻き添えで正規版まで消えてインストールが失敗する、という展開に慣れてしまう。pub.devはこれと正反対に振る舞うため、同じ感覚で臨むと危険だ。

Dartの公式ドキュメントは retraction について「Retraction isn’t deletion.(撤回は削除ではない)」と明記している。撤回された版はpub.devのバージョン一覧の「Retracted versions」セクションに残り続け、そして決定的なのは次の点である——依存側の pubspec.lock にその版が記録されていれば、撤回後もそのまま使い続けられる

実際に確認した。2026-09-09 09時JST時点で、4バージョンすべてのアーカイブURLがHTTP 200を返す。

バージョン 状態 アーカイブ取得 取得サイズ
0.1.4 通常 HTTP 200 292,881 B
0.1.5 retracted HTTP 200 295,225 B
0.1.6 retracted HTTP 200 294,947 B
0.1.7 最新 HTTP 200 294,214 B

つまり 3重に沈黙する。第一に、retractは削除ではないのでlockに固定されたCIは今日も汚染版を取得し続け、ビルドは成功する。エラーという形で気づく機会が無い。第二に、正規版が消えていないので「入らなくなった」という副次的なサインも出ない。第三に、OSV APIにPubエコシステムの該当エントリは無く(クエリの結果は空)、pub.devのパッケージページにもセキュリティ助言のバナーは表示されていない。OSVを参照する自動スキャナも dart pub outdated も何も言わない

  npmのunpublish型 pub.devのretract
悪性版の入手 不可(消える) 可能(今も200)
正規の旧版 巻き添えで消えることがある 残る
気づくきっかけ インストール失敗(ただし誤解を招く) 無い
lock固定時の挙動 解決できず失敗 そのまま取得し成功
事後検証 困難(現物が消える) 容易(現物が残る)

負の面ばかりではない。現物が残っているからこそ、本記事のようにアーカイブ同士をハッシュで突き合わせ、Aikidoが触れていない0.1.6の汚染を独立に確認できた。検証可能性という点ではretractのほうが優れている

ひとつ注意がある。Dartのドキュメントによれば retract は公開から7日以内に実行でき、撤回から7日以内であれば取り消して復活させることもできる。ここで観測した「0.1.5・0.1.6 = retracted」という状態は2026-09-15頃まで変わりうるので、時点つきの事実として読んでほしい。

CIで固定している場合に何が起きるか

チームでの実害が最も出やすいのは、pubspec.lock をリポジトリにコミットして CI で dart pub get している構成である。retractは既存のlockを壊さないので、CIは今日も0.1.5または0.1.6のアーカイブを取得し、緑のまま通る。誰も異常に気づかないまま、ビルドコンテナの中に感染したXcodeプロジェクトのファイルが展開され続ける。

幸いLinuxコンテナ上のCIではペイロードの実行条件を満たさないが、問題は「気づく機会が無いまま時間が経つ」ことにある。retractは公開から7日以内という制約があるため、pub.dev側の表示(Retracted versionsセクション)も永続的な警告としては当てにできない。lockをコミットしている構成では、レジストリ側の状態変化は自分のビルドに何も伝えてこないと考えて、手元での確認を能動的に行う必要がある。

なお、アプリを配布している場合でも、今回のケースで利用者側の成果物に混入する経路は無い。汚染されているのは example/ 配下だけで、lib/ のDartコードは実測でも汚染差分が無く、依存として取り込んだときにビルドされるのは lib/ のみだからである。

XCSSETとは何か——2020年から続くmacOS開発者狙いのワーム

XCSSETは新種ではない。Trend Microが2020年8月に、あるXcodeプロジェクトの中から発見して報告したのが最初で、当時は2件のゼロデイ(Data VaultsのCookie窃取と開発版Safariの悪用)を抱えていた。特徴は感染経路にあり、端末上の他のXcodeプロジェクトを探して自分を仕込み、そのプロジェクトがビルドされた時に実行される。開発者どうしがプロジェクトファイルを共有する習慣そのものを増殖経路にしている。

その後の主な公開解析は次のとおり。

時期 報告者 内容
2020-08 Trend Micro 初回発見。Xcodeプロジェクト感染とゼロデイ2件
2025-03-11 Microsoft Threat Intelligence 2022年以来となる新亜種。難読化の強化、永続化手法の更新、ペイロード生成のランダム化
2025-09-25 Microsoft Threat Intelligence さらなる更新とモジュール追加を報告
(継続) Unit 42(Palo Alto Networks) v4.0の詳細解析

Aikidoは今回の検体をXCSSETと同定している。本記事で独立に確認できたのは注入の構造がこの既知の手口と一致することまで(isa = PBXBuildRule を1件追加し、iOS/macOS双方に24行を挿入、Base64相当で符号化されたペイロード、そして前述の1行だけ異なるランダム化)である。具体的にどの亜種かの同定は本記事では未検証であり、Aikidoの帰属に依拠している。

Aikidoが報告している挙動としては、Gradle・Xcode・gitリポジトリへのビルドフック注入、ブラウザのパスワード・Cookie・セッションの窃取、Telegram/メモ/リマインダー/カレンダーの標的化、クリップボードの取得、Dockの偽Launchpadタイルによる永続化が挙げられている。これらは本記事では独立検証していない。

Trusted Publishingがあっても止まらなかった理由

このリポジトリは2026-05-18のコミット「ci: migrate to OIDC authentication for pub.dev」でTrusted Publishingに移行済みだった。実際にワークフロー publish.yml を確認すると、permissions:id-token: write(コメントに “Required for OIDC authentication with pub.dev”)が指定され、タグのpushで発火する構成になっている。長期有効なAPIトークンはリポジトリのSecretsに存在しない。

にもかかわらず汚染版が公開された。理由は単純で、Trusted Publishingが守るのは「誰が公開できるか」であって「何を公開するか」ではないからだ。今回の経路では認証情報は一切盗まれていない。感染したのはメンテナのマシンで、注入されたファイルはメンテナ自身の手で正当にコミットされ、正当なタグがpushされ、正当なCIが正当なOIDCで公開した。すべての工程が「正しく」動いた結果として汚染版がレジストリに載った

同じ構図は他のレジストリでも起きている。npmでのTrusted Publishing運用下の事案は@7nohe/openapi-react-query-codegenに悪性版10種|Trusted Publishingでも防げなかった理由で扱った。レジストリのindexそのものを一次資料として使って時系列を復元する手法は、crates.io サプライチェーン攻撃の全記録と本記事で同じ発想を採っている。

対策の方向は「公開経路の認証を固める」だけでは足りず、公開される中身を見るところに移る。具体的には、リリース対象のdiffをビルドファイルまで含めてレビューすること、CIをクリーンな環境で走らせて成果物を再現すること、そして本記事のように配布物そのものをハッシュで比較することである。

では何なら止められたか

今回の経路を止めうる地点は、認証より手前と後ろに分かれる。

対策 今回止められたか 理由
Trusted Publishing(OIDC) 止められない 認証は正当に成立していた
2要素認証・トークンの短命化 止められない 認証情報は盗まれていない
タグpushでの自動公開 止められない 正当なタグだった
リリースdiffのレビュー(ビルドファイル含む) 止められた可能性が高い project.pbxproj に+24行、.bak の新規追加という不自然な差分が出ていた
生成物の再現ビルド(クリーン環境) 止められた可能性が高い 感染マシン由来の差分は再現しない
レジストリ側の自動スキャン 部分的 Aikidoは実際にこの経路で検出している

注目すべきは、「差分を見る」という最も古典的な対策が今回の唯一の実効的な防御線だった点である。0.1.5のコミット差分は、パッケージ本体のリファクタリングに混じって example/android/app/build.gradle.kts.bak という見慣れないファイルが増えており、レビューで拾える形をしていた。

対処と、認証情報をローテーションすべきか

優先度順に整理する。

1. まず版を上げる。 pubspec.yaml の制約が 0.1.5 / 0.1.6 を許すなら 0.1.7 以降に上げ、lockを更新する。0.1.4 に留まっていた場合は影響を受けていない。

2. exampleをビルドしたか思い出す。 依存として使っていただけなら、example/ はビルド対象に入らない。cloneして example/ を開いてビルドした記憶がある場合だけ、次に進む。

3. macOS以外なら落ち着いてよい。 XCSSETのペイロードはmacOSを対象にしている。LinuxやWindowsのCIで当該版を取得していたとしても、ペイロードの実行条件は満たさない。ただし注入されたファイルがリポジトリにコミットされて残っている可能性はあるので、前述の PBXBuildRule チェックは実行しておく。

4. ビルドした可能性がある場合の認証情報。 XCSSETはブラウザに保存されたパスワード・Cookie・セッションを狙うとAikidoは報告している。したがって優先すべきは開発者向けのAPIトークンよりもブラウザに保存した認証情報とログインセッションである。ブラウザのパスワードを変更し、主要サービスで「他のセッションからログアウト」を実行する。そのうえでpub.dev・GitHub・クラウドの各トークンを見直す。

この記事のポイント(今すぐやること)

pubspec.lock を汚染版2つのSHA-256で検索する(本文の1つ目のコマンド)
・ヒットしたら 0.1.7 以降へ更新し、~/.pub-cache/hosted/pub.dev/universal_file_viewer-0.1.5-0.1.6 を削除する
・macOSで example/ をビルドした覚えがある場合だけ、ブラウザ保存の認証情報とログインセッションを失効させる

5. 検知の当てにしないもの。 前述のとおりOSVにもpub.devにも助言が出ていないため、dart pub outdated やOSV連携のスキャナで気づくことは期待できない。手元のlockを自分で見るのが唯一の確実な方法である。

なお、パッケージ本体(lib/ 配下のDartコード)については、0.1.5でディレクトリ構成のリファクタリングが行われている(lib/src/core/lib/src/viewers/ などが新設された)が、これは通常の開発上の変更で、汚染とは別系統の差分である。汚染ファイルは example/ 配下に限られる。

まとめ——時点つきで読むべき事実

2026-09-09 09時JST時点で確認できた事実を整理する。

universal_file_viewer0.1.5 と 0.1.6 が汚染版。0.1.4以前と0.1.7以降は該当しない
0.1.6の汚染は本記事の独立確認である。Aikidoの報告は0.1.5のみを挙げており、0.1.5と0.1.6の project.pbxproj はSHA-256が一致した
・汚染は example/ に限定され、依存として使うだけでは実行されない
・retractは削除ではないため、汚染版は今も取得でき、lockに固定されていればビルドは成功し続ける
・OSV・pub.devのいずれにもセキュリティ助言は無く、自動スキャナは沈黙する
・Trusted Publishing(OIDC)は運用されていたが、認証ではなく内容の問題なので機能しなかった
・retractは撤回から7日以内に取り消せるため、この状態は2026-09-15頃まで変わりうる

未検証として残るのは、retractが実行された正確な時刻、C2ドメインの帰属(Aikidoの報告に依拠)、XCSSETのどの亜種かの同定、そしてAikidoが報告する各種の窃取挙動である。pub.dev・Flutter・Googleいずれからも公式アナウンスは確認できなかった。

参照ソース