本章では、2 章で解説したクライアントアプリケーションへの AMSI 統合が実際のプロダクトでどのように使用されているかを紹介します。
本書では、実際のプロダクトの例として、OSS でソースコードが公開されている PowerShell を使用します。
なお、下記のイメージ図の通り PowerShell は 2 章で解説したサンプルプログラムとは異なり、 COM API レイヤーの IAmsiStream::Scan メソッドではなく、Win32 API レイヤーでラップされた AmsiScanBuffer 関数などを使用して、 システムに登録されている AMSI プロバイダーにスキャンを要求します。
しかし、Win32 API レイヤーの関数を使用する場合も、AMSI スキャン時の流れは基本的には 2 章で解説したサンプルプログラムと同等です。
本章では、公開されている PowerShell のソースコードから AMSI スキャン要求を行い、 その結果を元にコードの実行可否を決定する部分の動作を解説します。
なお、PowerShell の詳しい動作や実装については本書のスコープ外であるため、詳しい解説は行いません。
もくじ
PowerShell のソースコードを取得する
OSS として提供されている PowerShell のソースコードは、GitHub リポジトリ https://github.com/PowerShell/PowerShell から取得できます。
本章では、https://github.com/PowerShell/PowerShell/tree/v7.5.0 のソースコードを利用します。
Git を使用可能な場合は、以下のコマンドを順に実行することでも同様のソースコードを参照できるようになります。
# GitHub リポジトリの Clone
git clone https://github.com/PowerShell/PowerShell.git
# Clone したフォルダに移動
cd PowerShell
# v7.5.0 tag の Checkout
git checkout v7.5.0PowerShell による AMSI スキャン要求
まずは、PowerShell がどのように AMSI スキャン要求を行っているのかを特定します。
冒頭でも紹介した以下のイメージ図の通り、PowerShell は Win32 API レイヤーの AmsiScanBuffer 関数を使用して AMSI スキャンを要求します。
本章では、PowerShell が AmsiScanBuffer 関数を使用してスキャンを行う際の動作を確認していきます。
AmsiScanBuffer 関数について
AmsiScanBuffer 関数とは、AMSI を使用してバッファ内のコンテンツスキャンを AMSI プロバイダーに要求するために使用できる Win32 API レイヤーの関数です。1
AmsiScanBuffer 関数は以下通り HAMSICONTEXT 型のハンドルオブジェクトやスキャン対象のデータが含まれるバッファなどを引数として呼び出されます。
HRESULT AmsiScanBuffer(
[in] HAMSICONTEXT amsiContext,
[in] PVOID buffer,
[in] ULONG length,
[in] LPCWSTR contentName,
[in, optional] HAMSISESSION amsiSession,
[out] AMSI_RESULT *result
);この AmsiScanBuffer 関数は amsi.dll にて実装されています。
また、amsi.dll は Microsoft からパブリックシンボルが配布されているモジュールであるため、 比較的容易にデバッグを行うことができます。
AmsiScanBuffer 関数呼び出し時のコールスタックを調査する
まずは WinDbg を使用して AmsiScanBuffer 関数呼び出し時のコールスタックを取得します。
WinDbg を使用して amsi!AmsiScanBuffer にブレークポイントをセットした後に、
デバッグ中の PowerShell プロンプトで適当なコマンドを実行しようとすると、
以下のように AmsiScanBuffer 関数呼び出し時のコールスタックを取得できるようになります。
このコールスタックを見ると、System.Management.Automation.dll で実装されている AmsiUtils.ScanContent メソッドがさらに AmsiUtils.WinScanContent メソッドを呼び出し、 最終的に Win32 API レイヤーの AmsiScanBuffer 関数の実行に繋がっていることがわかります。
また、この呼び出しは同じく System.Management.Automation.dll で実装されている CompiledScriptBlockData.PerformSecurityChecks メソッドから行われていることを確認できます。
本書では PowerShell の詳しい実装については扱いませんが、 この PerformSecurityChecks メソッドは PowerShell で実行されるスクリプトが実行前に最終的にコンパイルされる際に呼び出される System.Management.Automation の ReallyCompile メソッドから実行されていることがわかります。2
上記の PerformSecurityChecks メソッドが呼び出される際の操作は src/System.Management.Automation/engine/runtime/CompiledScriptBlock.cs にて以下の通り定義されています。
private void ReallyCompile(bool optimize)
{
/* 省略 */
PerformSecurityChecks();
Compiler compiler = new Compiler();
compiler.Compile(this, optimize);
/* 省略 */
}また、AmsiUtils.WinScanContent メソッドを呼び出して直接的に AMSI スキャン要求を開始する PerformSecurityChecks メソッドも、 同じく CompiledScriptBlock.cs にて定義されています。
PerformSecurityChecks メソッド
以下は、PowerShell によるコード実行時に直接的に AMSI スキャン要求を行う PerformSecurityChecks メソッドのうち、
AMSI スキャンを要求し、結果が AMSI_RESULT_DETECTED であった場合に実行エラーを返す部分の抜粋です。
private void PerformSecurityChecks()
{
/* 省略 */
var scriptExtent = scriptBlockAst.Extent;
var scriptFile = scriptExtent.File;
/* 省略 */
// Call the AMSI API to determine
// if the script block has malicious content
var amsiResult = AmsiUtils.ScanContent(
scriptExtent.Text,
scriptFile
);
if (amsiResult == \
AmsiUtils.AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_DETECTED
)
{
var parseError = new ParseError(
scriptExtent,
"ScriptContainedMaliciousContent",
ParserStrings.ScriptContainedMaliciousContent
);
throw new ParseException(new[] { parseError });
}
/* 省略 */
}このメソッドの中では、AmsiUtils.ScanContent(scriptExtent.Text, scriptFile); を実行し、その結果を amsiResult として受け取っています。
その後、受け取ったスキャン結果(amsiResult) を評価し、結果が AMSI_RESULT_DETECTED の場合には、
エラー ID「ScriptContainedMaliciousContent」とともに ParseError を返し、コード実行をブロックします。
こうして、AMSI によって不正な PowerShell スクリプトの実行がブロックされ、 以下のようなエラー画面が表示されることになります。
ここからは、PowerShell 内で AmsiUtils.ScanContent メソッドが呼び出され、 AMSI を通して行ったスキャン結果を受け取るまでの流れを解説していきます。
AmsiUtils.ScanContent メソッド
ScanContent メソッドは、PowerShell が AMSI を使用して文字列バッファのスキャン要求を行うために呼び出されるメソッドであり、
src/System.Management.Automation/security/SecuritySupport.cs で定義されています。
このメソッドは、PerformSecurityChecks メソッドから呼び出される際に、 スキャン対象の文字列バッファである content と、実行されたスクリプトファイル名などを含む sourceMetadata の 2 つの引数を受け取り、 それをそのまま使用して WinScanContent メソッドを呼び出します。
internal static AmsiNativeMethods.AMSI_RESULT
ScanContent(
string content,
string sourceMetadata
)
{
#if UNIX
return AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED;
#else
return WinScanContent(
content,
sourceMetadata,
warmUp: false
);
#endif
}ソースコードが公開されている PowerShell の場合には、 デバッグ用のバイナリを自分でビルドすることでプライベートシンボルを使用したデバッグが可能になるため、 以下のように関数呼び出し時の引数情報などを簡単に確認することができます。
なお、もちろんプライベートシンボル無しでも関数呼び出し時の引数に含まれる文字列情報を確認することはできますが、 プライベートシンボルを使用した場合の方がスムーズかつ確実にデバッグ情報を確認できるのは明白です。
AmsiUtils.WinScanContent メソッド
WinScanContent メソッドは、ScanContent メソッドと同じく src/System.Management.Automation/security/SecuritySupport.cs で定義されている内部メソッドです。
前項で確認した通り、WinScanContent メソッドは ScanContent メソッドから以下の引数と共に呼び出されます。
internal static AmsiNativeMethods.AMSI_RESULT WinScanContent(
string content,
string sourceMetadata,
bool warmUp)引数 warmUp は AMSI コンポーネントのウォームアップ中であるかを指示する値であり、
True が与えられた場合には実際には何のスキャンも行われず AMSI_RESULT_NOT_DETECTED が返されます。
そのため、通常のスキャン時に ScanContent メソッドから WinScanContent メソッドが呼び出される場合には、 引数 warmUp の値はハードコードされている False が強制されます。
WinScanContent メソッドが呼び出し直後の箇所には、 以下のような Eicar テストマルウェアの文字列をチェックするコード部分が存在しています。
しかし、これは恐らくデバッグ用途のフラグである InternalTestHooks.UseDebugAmsiImplementation が True の場合にのみ実行されるコードなので今回は無視します。
const string EICAR_STRING = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
if (InternalTestHooks.UseDebugAmsiImplementation)
{
if (content.Contains(EICAR_STRING, StringComparison.Ordinal))
{
return AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_DETECTED;
}
}なお、余談ですが上記の検知動作については PowerShell プロンプト上で以下のコマンドを順に実行し、 UseDebugAmsiImplementation を True に変更した上で Eicar テストマルウェアの文字列を含むコマンドを実行することでテストが可能です。
# UseDebugAmsiImplementation フラグを True に変更する
[System.Management.Automation.Internal.InternalTestHooks]::SetTestHook('UseDebugAmsiImplementation', $true)
# Eicar テストマルウェアの文字列を含むコマンドを実行する
[ScriptBlock]::Create('X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*') | Out-Null以下は、実際に上記のコマンドを実行した場合の実行結果です。
続くコード部分では、AMSI スキャンのためにいくつかのチェックが行われます。
まず、以下では AMSI コンポーネントの初期化が成功しているかをチェックし、
失敗している場合にはスキャンを要求せずに AMSI_RESULT_NOT_DETECTED を返すよう実装されています。
// If we had a previous initialization failure,
// just return the neutral result.
if (s_amsiInitFailed)
{
PSEtwLog.LogAmsiUtilStateEvent(
"ScanContent-InitFail",
$"{s_amsiContext}-{s_amsiSession}"
);
return AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_NOT_DETECTED;
}また、AMSI コンポーネントの初期化の失敗以外にも、
前項で解説したように warmUp の値が True に設定されている場合などにも、
WinScanContent メソッドはスキャンを要求せずに AMSI_RESULT_NOT_DETECTED を返します。
これらのチェックにパスした場合、WinScanContent メソッドは冒頭で解説した Win32 API レイヤーの API 関数である AmsiScanBuffer を呼び出してコンテンツのスキャン結果を取得します。
AmsiNativeMethods.AMSI_RESULT result = \
AmsiNativeMethods.AMSI_RESULT.AMSI_RESULT_CLEAN;
// Run AMSI content scan
int hr;
unsafe
{
fixed (char* buffer = content)
{
var buffPtr = new IntPtr(buffer);
hr = AmsiNativeMethods.AmsiScanBuffer(
s_amsiContext,
buffPtr,
(uint)(content.Length * sizeof(char)),
sourceMetadata,
s_amsiSession,
ref result
);
}
}なお、この時呼び出される AmsiScanBuffer 関数は、 AmsiNativeMethods クラスのメソッドとして呼び出されていますが、 実際には amsi.dll で実装されている AmsiScanBuffer 関数を外部メソッドとして実行しています。
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
[DllImport("amsi.dll", EntryPoint = "AmsiScanBuffer", CallingConvention = CallingConvention.StdCall)]
internal static extern int AmsiScanBuffer(
System.IntPtr amsiContext,
System.IntPtr buffer,
uint length,
[In][MarshalAs(UnmanagedType.LPWStr)] string contentName,
System.IntPtr amsiSession,
ref AMSI_RESULT result
);AmsiScanBuffer 関数呼び出し後のスキャン動作
残念ながら、amsi.dll で実装されている AmsiScanBuffer 関数はソースコードが公開されていないため、本書では詳しい挙動は解説しません。
ただし、幸いなことに amsi.dll は Microsoft によりパブリックシンボルが公開されているため、比較的容易にデバッグを行うことができます。
実際に AmsiScanBuffer 関数呼び出し後のコールスタックを取得してみると、以下のように AmsiScanBuffer 関数から CAmsiAntimalware::Scan が呼び出されており、 その後システムに登録されている各 AMSI プロバイダーがオーバーライドしている IAntimalwareProvider インターフェースの Scan メソッドが順に実行されたことを確認できます。
# Microsoft Defender AntiVirus の呼び出し
MpOav!CComMpOfficeAV::Scan
amsi!CAmsiAntimalware::Scan+0xf0
amsi!AmsiScanBuffer+0xd3
/* 省略 */
# SampleAmsiProvider の呼び出し
AmsiProvider!SampleAmsiProvider::Scan
amsi!CAmsiAntimalware::Scan+0xf0
amsi!AmsiScanBuffer+0xd3
/* 省略 */上記の挙動から、AmsiScanBuffer 関数によりスキャン要求が行われた場合でも、 3 章および 4 章で解説した流れでシステムに登録されている AMSI プロバイダーによる スキャンが実施されていることがわかります。
また、上記の結果から、要求されたデータはシステムに登録されているすべての AMSI プロバイダーによりスキャンされていることがわかります。
実際に、その結果として、Microsoft Defender AntiVirus のプロバイダーでのみ検知される AMSI Test Sample: 7e72c3ce-861b-4339-8740-0ac1484c1386 という文字列と、
4 章で実装したサンプルプロバイダー用の検知テスト文字列 Sample Provider: Malicious-Script がどちらも AMSI により検知されることを確認できます。
5 章のまとめ
本章では、実際に PowerShell に統合されている AMSI によりコンテンツスキャンが実施される動作の流れを解説しました。
本章で解説した通り、もし高度な手法によりスクリプトが難読化されており、ファイルスキャンによる検知がバイパスされている場合でも、 最終的に実行コードがコンパイルされるまでのロジックの中に AMSI によるスキャンが組み込まれていることで、 アンチマルウェアエンジンは難読化解除後のペイロードをスキャンし、脅威を実行前にブロックすることができます。
あとがき
本書を最後までお読みいただき誠にありがとうございました。
今回は、前回頒布した「A part of Anti-Virus -サンプルコードで学ぶ Windows AntiVirus とミニフィルタドライバー-」1に続き、 公式の AmsiStream および AmsiProvider のサンプルコードをベースに、AMSI (Windows Antimalware Scan Interface) の概要としくみを解説しました。
前著で解説したアンチマルウェアのリアルタイムファイルスキャン機能については、 「マルウェア(ウイルスを含む)」という明確な実体のある脅威をスキャンし排除するという、 ある種シンプルな仕組みが一般に広く認知されています。
そのため、多くの場合セキュリティや IT に対する知識を持たない一般のユーザーであっても、 端末にファイルスキャンが可能なアンチマルウェア製品をインストールする必要性を認識しており、 場合によってはライセンス料を支払い、有料のアンチマルウェア製品を購入していることすら珍しくありません。
一方で、AMSI は非常に強力な保護機能を提供する仕組みであり、多くの悪意ある攻撃者にとっては非常に邪魔な存在であるにも関わらず、 その保護の仕組みやセキュリティ上の利点についてはあまり認知されていないためか、 一般のユーザーにはファイルスキャン機能と比較して軽視されており、安易に無効化されてしまう傾向があると感じています。
AMSI のようなセキュリティ機能はユーザーの可用性とのトレードオフにより提供されていることもあり、 一般のユーザーにとっては単なる邪魔な機能としか認識されていない場合がある点は否定できません。
しかし、AMSI のような強力なセキュリティ機能について、リスクに対する検討が不十分なまま機能が無効化され、 本来防げたはずの脅威による侵害を許してしまう事例を少しでも減らすためにも、 本書が AMSI の利点や必要性について少しでも多くのユーザーに理解してもらう一助となることを願っています。
なお、本書は AMSI をテーマに執筆しましたが、今後もさまざまな AntiVirus の仕組みについて紹介していきたいと考えています。
もし本書を通して AntiVirus ソフトウェアについて関心を持ってくれる方がいましたら、ぜひ次回作にもご期待ください。
改めて、本書をお読みいただき誠にありがとうございました。