そもそもシステムコールって?
現代のOS(Windows, Linux, Mac)は直接ハードウェアを操作することを許可していません。
好き勝手にソフトウェアにハードウェアをいじらせるとOSごとクラッシュしてしまいますからね。
また、プロセス同士のメモリ空間も独立していて、勝手にほかのプロセスのメモリにアクセスすることはできません。
x86シリーズのCPUにはリングプロテクションという機能が搭載されていて、リング0からリング3の計四つの権限が存在します。
Windowsでは、0がOS本体やデバイスドライバなど、3がユーザーのソフトウェアに割り当てられています。
ハードウェアに直接アクセスできるのは、リング0からのみに制限されています。
しかしながらそれでは、リング3のユーザーのソフトウェアは何もできないことになってしまいます。
そこで、システムコールというものがあります。
これは、OSに処理を依頼するものです。これで、ユーザーのソフトウェアからハードウェアをいじることができます。
Wikipedia: リングプロテクション
Wikipedia: システムコール
WindowsってWin32 Apiがあるじゃん?
今回するのは、Win32 Api (TerminateProcessとかCreateFile)より低層で、WindowsNTのカーネル(ntoskrnl.exe)を直接呼び出します。
つまりWin32 Api は ntoskrnl.exe のラッパーということになります。(正確にはntdll.dll)
たとえばWin32 Apiのkernel32.dllに入っているTerminateProcess関数 (MSDN)は内部的にはntdll.dllのNtTerminateProcessを呼び出します。
ntdll.dllのNtTerminateProcessもただのラッパーで、syscall命令を実行してretするだけです。
ntdll.dllのNtTerminateProcessの逆アセンブル
0x2B(システムコール番号)をEAXにに入れてsyscallしている。
R10については後述。
syscall命令を実行することによって内部的に(見えないけど)、ntoskrnl.exeが処理をします。
syscall 命令
x64の命令 syscall というものがあります。これは、簡単に言えばOSの機能を呼び出す命令です。(そのままですな)
ここにLinuxでのシステムコール呼び出し方法の詳細がありますが、Windowsでは少々異なります。
Windowsでは?
EAXレジスタにシステムコール番号
R10レジスタには第一引数をセットします。(理由は追記)
引数はおそらくすべてスタック渡しのはずです。
ただWindowsのシステムコールは直接呼び出されることを想定していないらしく(ラッパー前提)、syscall命令を実行する前にスタックの先頭に戻り先アドレスを積まなければならないようです。
2020/7/16追記:
第一引数にRCXレジスタを使えないのは、syscall命令がRCXレジスタを破壊するためでした。
また、引数はすべてスタック渡しかどうかは調査していません。この記事では、念の為、スタックとレジスタ両方をセットしています。
システムコール番号
これは、厄介なことにWindowsのバージョンごとに異なります。(Windows8と8.1とか、サービスパックでも違う。)
ここにOSごとの一覧があります。
たとえば私の環境、Win8.1のNtTerminateProcessというシステムコール(Unixでいうとkillに近いのかな?)は0x002bだそうです。
NTSTATUS ZwTerminateProcess(HANDLE ProcessHandle, NTSTATUS ExitStatus);
NTSTATUS NtTerminateProcess(HANDLE ProcessHandle, NTSTATUS ExitStatus);
NtTerminateProcess (MSDN) はプロセスを終了させます。
MSDNでは ZwTerminateProcess となっていますが、ユーザーモード(リング3)から見ればZwもNtも同じ物ですので同じものと捉えてもらって結構です。
ユーザーモードでは名前と序数は異なるものだけれどもエントリポイント(実体)が等しい。
カーネルモードから見ると違います。違いを知りたい方はここを参考。
Linux風のsyscall関数を作ってみる
syscall関数 を自作してみます。
;This will be compiled with "nasm -fwin64 syscall.asm"
bits 64
section .text
global syscall
;NTSTATUS syscall(DWORD number, ...)
syscall:
mov eax, ecx ;システムコール番号をEAXに代入
mov rcx, rdx ;分かりやすくするために第ニ引数(RDX)を第一引数として扱う
mov rdx, r8 ;分かりやすくするために第三引数(R8)を第ニ引数として扱う
mov r8, r9 ;分かりやすくするために第四引数(R9)を第三引数として扱う
mov r9, [rsp + 40] ;分かりやすくするために第五引数([rsp + 40])を第四引数として扱う
;これで引数はすべて一個ずつずれました
mov [rsp + 16], rcx ;[rsp + 16]にRCXを代入
mov [rsp + 24], rdx ;[rsp + 24]にRDXを代入
mov [rsp + 32], r8 ;[rsp + 32]にR8を代入
pop r11 ;リターンアドレスをR11にバックアップ
add rsp, 8 ;スタックのいらない領域を削除
push r11 ;リターンアドレスを置きなおす
mov r10, rcx ;第一引数をR10に代入
syscall ;システムコール
pop r11 ;リターンアドレスをR11にバックアップ
sub rsp, 8 ;いらない領域を復活させる
push r11 ;リターンアドレスを置きなおす
ret ;リターンする
解説
プロトタイプ
NTSTATUS syscall(DWORD number, ...);
WindowsのシステムコールはNTSTATUSという型を返します。
numberはシステムコール番号、それ以降にシステムコール引数を渡します。
呼び出し規約
まず、x64のWindowsで関数が呼ばれるときどのように引数が渡されるか(呼び出し規約)についてですが
レジスタ | 引数 |
---|---|
RCX | 第一引数 |
RDX | 第ニ引数 |
R8 | 第三引数 |
R9 | 第四引数 |
それ以上はスタックに入ります。
だだし、引数の数に依らず常に4つ分のスタック領域(8*4=32byte)は呼び出し側が確保しなければなりません。
これは退避用に使われます。
つまり呼び出された直後は次のような状態になります。
x64 アセンブリ言語プログラミングも参考にしてください
#1
mov eax, ecx ;システムコール番号をEAXに代入
そのままです。システムコール番号をEAXに代入します。
#2
mov rcx, rdx ;分かりやすくするために第ニ引数(RDX)を第一引数として扱う
mov rdx, r8 ;分かりやすくするために第三引数(R8)を第ニ引数として扱う
mov r8, r9 ;分かりやすくするために第四引数(R9)を第三引数として扱う
mov r9, [rsp + 40] ;分かりやすくするために第五引数([rsp + 40])を第四引数として扱う
;これで引数はすべて一個ずつずれました
この時点でRCXに入っていたシステムコール番号は不要です。
そこでRCXにRDXを代入してあたかも第二引数を第一引数のように扱います。
それ以降も同様にずらします。
#3
mov [rsp + 16], rcx ;[rsp + 16]にRCXを代入
mov [rsp + 24], rdx ;[rsp + 24]にRDXを代入
mov [rsp + 32], r8 ;[rsp + 32]にR8を代入
pop r11 ;リターンアドレスをR11にバックアップ
add rsp, 8 ;スタックのいらない領域を削除
push r11 ;リターンアドレスを置きなおす
これは、システムコール番号がなかったことにして、引数をスタックに置きます。
#4
mov r10, rcx ;第一引数をR10に代入
syscall ;システムコール
r10に第一引数(システムコールから見て)を代入します。
#5
pop r11 ;リターンアドレスをR11にバックアップ
sub rsp, 8 ;いらない領域を復活させる
push r11 ;リターンアドレスを置きなおす
ret ;リターンする
スタックを元に戻してretします。
C++でやってみる
#include <Windows.h>
#include <psapi.h>
#include <stdio.h>
#define SYS_NtTerminateProcessWin8_1 0x2B //Windows 8.1のNtTerminateProcessの番号
extern "C" NTSTATUS syscall(DWORD number, ...); //さっき書いたアセンブリ関数
HANDLE OpenProcessByExeName(LPCWSTR ExeName); //実行ファイル名からプロセスハンドルを開く関数
int main() {
HANDLE hProcess = OpenProcessByExeName(L"notepad.exe");
if (!hProcess) {
wprintf(L"Couldn't get process handle.\n");
return -1;
}
NTSTATUS result = syscall(SYS_NtTerminateProcessWin8_1, hProcess, 0x12345678);
if (result) {
CloseHandle(hProcess);
wprintf(L"syscall failed.\n");
return -1;
}
DWORD exitCode;
if (GetExitCodeProcess(hProcess, &exitCode))
wprintf(L"notepad.exe exited with 0x%08X\n", exitCode);
CloseHandle(hProcess);
return 0;
}
HANDLE OpenProcessByExeName(LPCWSTR ExeName) {
DWORD procs[1024];
DWORD procNeeded;
if (!EnumProcesses(procs, sizeof(procs), &procNeeded)) {
return NULL;
}
procNeeded /= sizeof(DWORD);
for (DWORD i = 0; i < procNeeded ; i++) {
HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, procs[i]);
if (!hProcess)
continue;
HMODULE hModule;
DWORD moduleNeeded;
if (EnumProcessModules(hProcess, &hModule, sizeof(hModule), &moduleNeeded)) {
WCHAR procName[MAX_PATH] = { L'\0' };
GetModuleBaseNameW(hProcess, hModule, procName, sizeof(procName) / sizeof(WCHAR));
if (!lstrcmpiW(procName, ExeName)) {
CloseHandle(hProcess);
return OpenProcess(PROCESS_ALL_ACCESS, FALSE, procs[i]);
}
}
CloseHandle(hProcess);
}
return NULL;
}
Unicode統一してあります。
これはNtTerminateProcessを使ってメモ帳を殺します。
さて、メモ帳を開いて実行してみましょう。
notepad.exe exited with 0x12345678
と表示されれば成功です。
ちなみにsyscallに渡している第二、第三引数は
NTSTATUS NtTerminateProcess(HANDLE ProcessHandle, NTSTATUS ExitStatus);
のProcessHandleとExitStatusです。
notepad.exeのハンドルと0x12345678で終了させることを指示して、本当に0x12345678で終了したことを確認しています。
おわり
最後まで閲覧ありがとうございます。