11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

RustAdvent Calendar 2021

Day 10

Rustを使ってWindowsで動くシェルコードを作る

Last updated at Posted at 2021-12-10

前置き

みなさんはシェルコードというものについて知っていますか?次のように定義されているようです。

シェルコード(英: Shellcode)とは、コンピュータセキュリティにおいて、ソフトウェアのセキュリティホールを利用するペイロードとして使われるコード断片である。
侵入したマシンを攻撃者が制御できるようにするため、シェルを起動することが多いことから「シェルコード」と呼ぶ。

昔はシェルを起動するコードが多かったためにこのような定義になっていますが、現代においてはほとんどのコード断片をシェルコードと呼んで大丈夫なようです。

なのでシェルだけではなく、現代では悪意を持った処理や他の悪意のあるプログラムを呼び出すときによく使用されてしまうようになりました。

というのも、シェルコードのいい点として、実行権限があるメモリ領域を確保さえできれば、そこにシェルコードを書き込むだけで実行できるスタンドアローンのようなプログラムとして成り立つ点です。

今回は、Windowsで動く簡単なシェルコードをRustで作ってみようと思います!

誰もやってなさそうだし!...と思ったら先駆者がいました -> b1tg/rust-windows-shellcode
実はこちらについては既に手元で試しましたが、後半で説明する部分でうまくできてない部分があったので、この記事はこちらの改良版を改めて説明する、といった感じのものととらえてよきです!

間違ってるとことかあったら教えてください >_<!

実装

前提として、Windowsの実行ファイル周りについてそこそこ知ってる事とします。

※ 当初はコードをまるまる載せる予定でしたが、使い方によっては悪用出来てしまうので、一部を疑似コードまたはコメントに置き換えるようにしました。

用意するもの:

  • Rust nightly版
  • Windows10 x64 (今回は64bitのみをターゲットにします)

では早速実装していきます。
今回は実際に悪意のあるアクションを実行するわけではなく、メッセージボックスを出すだけにしておきます。

実装する前に、注意しておくことがあります。
最終的に出来たものはシェルコードとして動くようになりますが、それはメモリに直接バイナリとして書き込まれるので、Windowsのローダーに依存しないように実装しなければならない、ということです。

どういうことかというと、Windows上で例えば新しくメモリを確保しようと思った時、それは内部的にWindowsのAPI関数を呼び出しますが、Windowsのローダーを介さないで実行されるためにAPI関数の依存のような物が解決されていません。

私たちが普段 .exe をダブルクリック等で実行した時、Windowsはこういった複雑なロード処理を事前に行っているので、普通は気にすることはないのですが、今回はシェルコードとして実行したいため、ローダーに依存しないように注意して実装していきます。

とは言ってもそこまで難しくは無くて、基本的に no_std のような感じでカキカキすればよいです。

まずはおまじないから!

#![no_std]
#![no_main]
#![feature(asm)]

#[no_mangle]
pub unsafe extern "C" fn main() {
    ...
}
  • #![no_std] ... シェルコードはそれ単体で動かせるために no_std な環境に近いため、定義
  • #![no_main] ... main 関数は必要ないため、定義
  • #![feature(asm)] ... 後ほどWindowsマジック(笑)を使用するため、asmfeatureをONにします
  • #[no_mangle] ... 関数のマングリングをOFF!

unsafe が必要な理由は何となくわかるかと思いますが、extern "C" が必須な理由はちょっと調査不足でわかりませんでした...精進精進
no_main をつけていますが、メインとなる関数の名前は main にしておきます。わかりやすいので!
ちなみにもう少し後で説明するコンパイルフラグを調整するとmainじゃなくても大丈夫です。

おまじないはひとまずこれで良いと思います。

次に、使用するWindowsの様々な型についてバインドが欲しいところです。
winapi-rs のようなものを使用せず、自分で定義していきましょう。
適当に binding.rs とでもしてバインドを適当に書いて生きます。
型が多すぎるので全てバインドする必要はなく、自分が使用したいものだけでいいですね。

binding.rs
// こんな感じでてきとーにいっぱい書いていく
pub type uint64 = u64;
...

バインドではあまり説明することが無いので、全部書き終わった前提で進みます。

mainを埋める前に、先に実装しておきたい関数を作ります。

先に、文字列を比較するだけの簡単な関数を作っておきましょう。
Windowsは本当にめんどくさくて、*const u8*const i8*const u16という感じで複数の文字列ポインタが混同しているので、これをいい感じに比較できる関数を作っておきたいのです。
これを一つの関数で実装するために、num_traitsの力を借りて実現します!

use num_traits::Num;

pub fn compare_raw_str<T>(s: *const T, u: *const T) -> bool
where
    T: Num,
{
    unsafe {
        // 文字列の最後がNULL文字になっているので、is_zeroでチェックすることで長さを取得できる
        let u_len = (0..).take_while(|&i| !(*u.offset(i)).is_zero()).count();

        // メモリから指定された長さでスライスを取得できるやつ。めっちゃ便利~!
        let u_slice = core::slice::from_raw_parts(u, u_len);

        // 上に同じ
        let s_len = (0..).take_while(|&i| !(*s.offset(i)).is_zero()).count();
        let s_slice = core::slice::from_raw_parts(s, s_len);

        // 長さが違えばそもそも違う文字列なので、チェックせずにfalseを返す!
        if s_len != u_len {
            return false;
        }

        // 一文字ずつチェック!
        for i in 0..s_len {
            if s_slice[i] != u_slice[i] {
                return false;
            }
        }

        return true;
    }
}

これでu8だろうがu16だろうが文字列ポインタを投げてやるだけで比較できるようになりました!
この関数は他のWindows APIプログラミングで割と便利すぎて感動、涙がぽろぽろ

文字列比較関数を作ったところで次に進みます。

次に、mainの処理の中でWindowsのAPIを使用したいのですけど、ローダーを介さないのでそれらを普通に使用出来ません。
そこで、Windowsのローダーが行っていることを自分で実装することでそれを可能にします。
必要なのは2つ!

  • メモリからシステムDLLへのポインタを取得
  • システムDLLから使いたいWindows API関数を取り出す

これが出来れば自由にWindowsのAPI関数を呼び出して使用することが出来ます。

まずは メモリからシステムDLLへのポインタを取得 から!
(ここからWindowsの実行ファイル周りの用語とか出てきますが、知ってる前提で進みます!雰囲気で伝われ!)

システムDLLはWindows起動時に特定の場所に配置されているのですが、実はそこへの仮想アドレスは全てのプロセスで全く一緒です。
じゃあそれをどのように取り出すかというと、PEBと呼ばれるプロセスに関する様々な情報が入った構造体の特定のフィールドから仮想アドレスを割り出すことが出来ます。
PEBの特定のフィールドにロードされたモジュール、つまりDLLのリストの情報が入っているので、そこから割り出していく流れになります。
じゃあそのPEBをどうやって取得するの?となりますが、安心してください、ここでようやく**速攻魔法 Windowsマジック(笑)**を使います。
PEBの特性として、とあるセグメントレジスタの特定の場所に配置されているというものがあります。
32bitではfs0x30、64bitではgs0x60PEBが隠れています。

これをアセンブリコードで取り出し、ロードされているDLLリストを取り出して、欲しいシステムDLLを特定してそのアドレスを返す関数を作ります。
その他細かい部分はコード中のコメントに残しました。

unsafe fn get_module_by_name(module_name: *const u16) -> PVOID {
    // PEB構造体のポインタ変数
    let mut ppeb = NULL as *mut PEB;

    // 記事では64bit前提にしてるので、今回はgs
    asm!(
        "mov {}, gs:[0x60]",
        out(reg) ppeb,
    );

    // LdrにLDR_DATA_TABLE_ENTRY構造体が入っているので、そこをたどる
    // ref: https://docs.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb_ldr_data
    let p_peb_ldr_data = (*ppeb).Ldr;

    // InLoadOrder, InMemoryOrder, InInitializationOrderの3種類あるのですが、
    // うちの環境ではInLoadOrderじゃないとNULLになってしまったのでこっちを使う
    let mut module_list = (*p_peb_ldr_data).InLoadOrderModuleList.Flink as *mut LDR_DATA_TABLE_ENTRY;

    // DllBaseがNULLになったら終わりの合図なので、それまでループ
    while (*module_list).DllBase != NULL {
        // このバッファフィールドにDLLの名前が入ってる
        // もちろんNULL終端なので、自作した比較関数で比較できます!
        let dll_name = (*module_list).BaseDllName.Buffer;

        if compare_raw_str(module_name, dll_name) {
            return (*module_list).DllBase;
        }

        module_list = (*module_list).InLoadOrderLinks.Flink as *mut LDR_DATA_TABLE_ENTRY;
    }

    NULL
}

これで、この関数にUTF-16な文字列(最悪)を突っ込むことでシステムDLLへのポインタが取得できるようになりました。
UTF-16を手で扱うのはだるいので、utf16_literal crateを使わせてもらいましょう!

utf16_example.rs
use utf16_literal::utf16;
// ちなみにこの名前のシステムDLLは存在しません
let unko32 = get_module_by_name(utf16!("unko32.dll\x00").as_ptr());

次に、取ってきたシステムDLLから、DLLがエクスポートしている関数を取得する関数を作ろうと思うのですが...ここがなかなか厄介な部分です。
取ってきたシステムDLLのバッファを自分でパースし、複雑なポインタ演算をしなければならないのです。
流れとしては、

1: DLLのポインタを逆参照し、PE構造体としてパース
2: エクスポート情報が入った構造体フィールドの仮想アドレスをオプショナルヘッダーから取得
3: 2で取ってきた仮想アドレスへアクセス、エクスポート関数情報の構造体としてキャスト
4: 3の構造体で、関数群の数、関数群の最初の関数のポインタ、関数名へのポインタ、序数を取得
5: 4で取ってきた4つの値を使っていい感じにポインタ演算し、関数と関数名へのアドレスを取得
6: 目的の関数名とマッチしたら関数へのアドレスをreturn、関数名が違ったらポインタをずらし、4へ戻る

という感じの流れです。
特に4番目の序数が入ってくる演算で細かなミスをしがちなんですよね...
念のために悪用出来ないように計算部分は伏せますが、まぁググったら載せてくれてる人いるので、どう計算してるのかは自己責任でそちらへ!

unsafe fn get_func_by_name(module: PVOID, func_name: *const u8) -> PVOID {
    // DLLへのポインタをキャストし、NT_HEADERS構造体とする
    let nt_header = (module as u64 + (*(module as *mut IMAGE_DOS_HEADER)).e_lfanew as u64)
        as *mut IMAGE_NT_HEADERS64;
    // エクスポート関数の情報が入った構造体への仮想アドレス
    let export_dir_rva = (*nt_header).OptionalHeader.DataDirectory[0].VirtualAddress as u64;

    if export_dir_rva == 0x0 {
        return NULL;
    };

    // キャスト!
    let export_dir = (module as u64 + export_dir_rva) as *mut IMAGE_EXPORT_DIRECTORY;

    let number_of_names = (*export_dir).NumberOfNames;
    let addr_of_funcs = (*export_dir).AddressOfFunctions;
    let addr_of_names = (*export_dir).AddressOfNames;
    let addr_of_ords = (*export_dir).AddressOfNameOrdinals;
    for i in 0..number_of_names {
        // ここでポインタ演算と関数名マッチの処理
    }

    return NULL;
}

ここまで実装出来たら、ようやくシェルコードのmain関数を埋めることが出来ます!やったねたえちゃん!

早速メッセージボックスの関数を呼びたいですが、なんとこいつが格納されているuser32.dllは標準で読み込まれていないようです。
なので、LoadLibrary関数を使って手動で読み込んでやる必要が出てきます。
LoadLibrary関数はkernel32.dllに存在するので、これを自作した関数で取ってきた後、更にGetProcAddress関数も取ってきて、
LoadLibray関数の戻り値からMessageBoxを取得します。

pub type PLoadLibraryA = unsafe extern "system" fn(LPCSTR) -> HMODULE;
pub type PGetProcAddress = unsafe extern "system" fn(HMODULE, LPCSTR) -> LPVOID;
pub type PMessageBoxW = unsafe extern "system" fn(h: PVOID, text: LPCWSTR, cation: LPCWSTR, t: u32) -> u32;

let kernel32 = get_module_by_name(utf16!("KERNEL32.DLL\x00").as_ptr());

// transmuteでポインタから関数に出来る!すごーい
let LoadLibraryA: PLoadLibraryA = transmute(get_func_by_name(kernel32, "LoadLibraryA\x00".as_ptr() as _));
let GetProcAddress: PGetProcAddress = transmute(get_func_by_name(
    kernel32,
    "GetProcAddress\x00".as_ptr() as _,
));

let u32_dll = LoadLibraryA("user32.dll\x00".as_ptr() as _);
let MessageBoxW: PMessageBoxW = transmute(GetProcAddress(u32_dll, "MessageBoxW\x00".as_ptr() as _));

これでMessageBoxW変数はMessageBoxW関数となりましたので、さっそく呼び出しコードを書いてあげます。
MessageBoxW関数のドキュメントを参考にしつつ、引数を埋めてあげます。
MessageBoxAというのもありますが、こっちは日本語とか突っ込まない時とかに!今回は日本語突っ込むのでWの方を使います。

MessageBoxW(
    NULL,
    utf16!("うんちぶりぶり\0").as_ptr(),
    utf16!("タイトルテキスト\0").as_ptr(),
    0x00,
);

ちなみにここまでのコードを一応そのままコンパイルして実行できるはずです。
うんちぶりぶりが表示されたら成功!

...ではなく、本題のシェルコード作成に入りましょう!

上でコンパイルして出てきた実行可能ファイルから必要な部分を切り出してパッチを当ててバイナリファイルを作成、という流れなのですが、
まず上のコンパイルで特殊なコンパイルフラグが必要なので、先にそれを設定しちゃいます。
.cargoディレクトリをsrcと同じ階層に作り、以下をconfig.tomlとして保存します。

config.toml
[build]
target = "x86_64-pc-windows-msvc"
rustflags = [
    "-Z", "pre-link-arg=/NOLOGO",
    "-Z", "pre-link-arg=/NODEFAULTLIB",
    "-C", "link-arg=/ENTRY:main",
    "-C", "link-arg=/MERGE:.edata=.rdata",
    "-C", "link-arg=/MERGE:.rustc=.data",
    "-C", "link-arg=/MERGE:.rdata=.text",
    "-C", "link-arg=/MERGE:.pdata=.text",
    "-C", "link-arg=/DEBUG:NONE",
    "-C", "link-arg=/EMITPOGOPHASEINFO",
    "-C", "target-feature=-mmx,-sse,+soft-float",
    "--emit", "asm",
]

コンパイルフラグについてはあまり詳しいわけではありませんが、不必要なデバッグ情報を抜いたり、セクションと呼ばれるものをマージしてなるべくまとまるように?してるようです。
これが無いと動かないので、必要なんです!!!(強行突破)

では、実行可能ファイルにパッチを当てるコードを別で作成していきましょう。
どうしてパッチを当てるのかというと、スレッドの起点として、等の状況でシェルコードを走らせたとき、そのままいくと正常にスレッドが終了しないという問題があります。
実行可能ファイルから自分が実装した処理部分だけを切り出してるので、その処理の前後にはWindowsのABIルールに乗っ取ったアセンブリが必要になります。(合ってるよね...?)
てことは、WindowsのABIドキュメントを見れば全て解決し...出来ませんでしたので、先駆者の知恵を借りて、アセンブリを少し弄って流用したいと思います。
先駆者のアセンブリとこちらを読みながら見ると、わかりやすいかもしれんです: x64 での呼び出し規則

; 次の命令を呼び出す、ここではpop rcx
call    0x5

; 現在のメモリ位置をキャプチャします
pop     rcx

; 現在のrsiを保存
push    rsi

; 後で使うので、rspを押し込む
mov     rsi,rsp

; スタックを16bytes境界に揃える
and     rsp,0xfffffffffffffff0

; 引数が無いので、呼び出し規約にのっとって4つの引数、
; つまり0x20分だけ確保
sub     rsp,0x20

; 呼び出しを転送
; 下のretという部分から下のアセンブリに飛ぶ
call    0x5

; スタックポインターを元に戻す
mov     rsp,rsi

; スタックを元に戻す
pop     rsi

; 呼び出し元に戻る
ret

こちらがブートストラップとなり、最後のret部分の後に実行可能ファイルから切り出した処理部分なりをくっつけていきます。
必要なのは上のbootstrapコード、.textのバイナリ、jmpパッチ、の3つです。
jmpパッチをしないと、うまく処理が走りませんでした。

let src_path = "shellcode.exe";
let mut buffer = get_binary_from_file...(src_path)?;
let pe = PE::parse(&mut buffer)?;
let standard_fileds = pe.header.optional_header.unwrap().standard_fields;
let entry_offset = standard_fileds.address_of_entry_point - standard_fileds.base_of_code;

for section in pe.sections {
    let name = String::from_utf8(section.name.to_vec())?;
    if !name.starts_with(".text") {
        continue;
    }
    let start = section.pointer_to_raw_data as usize;
    let size = section.size_of_raw_data as usize;
    let dst_path = ".\\shellcode.bin";
    let shellcode = File::create(dst_path)?;
    let mut bootstrap: Vec<u8> = Vec::new();
    bootstrap.extend_from_slice(b"\xe8\x00\x00\x00\x00");
    bootstrap.push(b'\x59');
    bootstrap.push(b'\x56');
    bootstrap.extend_from_slice(b"\x48\x89\xe6");
    bootstrap.extend_from_slice(b"\x48\x83\xe4\xf0");
    bootstrap.extend_from_slice(b"\x48\x83\xec\x20");
    bootstrap.push(b'\xe8');
    bootstrap.push(5 as u8);
    bootstrap.extend_from_slice(b"\x00\x00\x00");
    bootstrap.extend_from_slice(b"\x48\x89\xf4");
    bootstrap.push(b'\x5e');
    bootstrap.push(b'\xc3');

    let mut buf_writer = BufWriter::new(shellcode);

    // write bootstrap first
    for b in bootstrap {
        buf_writer.write(&[b])?;
    }

    // write jmp to entry code
    buf_writer.write(&[0xe9])?;

    for byte in &(entry_offset as u32).to_le_bytes() {
        buf_writer.write(&[*byte])?;
    }

    // write .text section
    for i in start..start + size {
        buf_writer.write(&[buffer[i]])?;
    }

    buf_writer.flush()?;
}

これにメッセージボックスを出す実行可能ファイルへのパスを食わせると、カレントディレクトリにパッチを当て終わったバイナリファイルが出てきます。
そのバイナリファイルをスレッドの起点なりに指定してからスレッド再開、うんちぶりぶりメッセージが出てスレッドが正常終了すればどっきり大成功です!

まとめ

今回はRustを用いてWindows上で動くシェルコードを作ってみました。
後半はかなり駆け足説明になってしまって、おそらくセキュリティに興味のない大半の人は何言ってるか伝わってないかもしれませんが、「何言ってるか分からん」と、はなくそほじっておけば大丈夫です!
もしかしたら私文章書くの向いてないかもしれへん...へこむ...

私は独自にマルウェア技術的な物を調査するのが大好きで、その過程で得た物をRustを使用してアウトプット出来て良かったと思います。

また、今回は全てのコードを完全に載せたわけではありませんが、こういった技術は簡単に悪用することが出来てしまうので、そういう方向で使用せず、あくまで技術的な興味、身を守るために攻撃方法を知るといった目的で読んでもらえると嬉しいです...

最後に

なんかあんまRust関係なくね?

さんこーぶんけん

b1tg/rust-windows-shellcode
mattifestation/PIC_Bindshell
monoxgas/sRDI
x64 での呼び出し規則

11
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?