
システムコールの仕組み - ユーザー空間とカーネル空間の境界、モード遷移とそのコスト
システムコールの挙動を実験で確かめられます。
libcとシステムコールの関係が腑に落ちます。
カーネル側の入口処理まで踏み込む定番書。
当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。
printfでもファイル読み込みでもHTTPリクエストでも、プログラムが自分のメモリの外側に触れようとした瞬間、必ずカーネルの手を借りています。その受け渡し窓口がシステムコール(system call)です。普段はライブラリ関数の裏に隠れて見えませんが、そこではCPUの動作モードが切り替わり、アドレス空間が張り替えられ、ハードウェアレベルの権限チェックが走っています。この記事では、なぜその境界が必要なのか、x86-64のLinuxで実際にどう呼ばれているのか、どれくらいのコストがかかり、どう減らし、どう観測し、どう安全側の壁として使うのかを、一次ソースをたどりながら整理します。
以下の説明はすべてx86-64のLinuxを前提とします。macOSやWindowsは呼び出し規約もカーネル側の実装もまったく異なるため、この記事のコードをmacOS上でそのまま動かすことはできません。
なぜカーネル空間とユーザー空間が分かれているのか
もし全プログラムがディスクコントローラを直接叩けて、他プロセスのメモリも自由に読み書きできたら、たった一つのバグでシステム全体が壊れます。これを防ぐため、CPUは特権レベル(privilege level)という仕組みをハードウェアで持っています。
x86-64にはring 0からring 3までの4段階の特権レベルがありますが、実際にLinuxが使うのは2つだけです。
| 段階 | 呼び方 | 実行されるもの | できること |
|---|---|---|---|
| ring 0 | カーネルモード | Linuxカーネル、デバイスドライバ | 特権命令の実行、全物理メモリへのアクセス、I/Oポート操作 |
| ring 3 | ユーザーモード | 通常のアプリケーション | 自分のアドレス空間の許可された領域へのアクセスのみ |
ring 3で動いているコードが特権命令(たとえばページテーブルの基底を差し替えるmov cr3)を実行しようとすると、CPUが一般保護例外を発生させ、カーネルがそのプロセスを止めます。「アプリのバグでカーネルが落ちない」のは行儀の良さではなく、ハードウェアが物理的に禁じているからです。
もう一段の防壁がメモリ保護です。ページテーブルの各エントリには「ユーザーモードからアクセスしてよいか」を示すビットがあり、カーネル用のページにはそれが立っていません。ユーザープロセスがカーネルアドレスを読もうとすると、その場でページフォルトになります。この仕組みそのものは仮想メモリとページングの記事で扱った内容の応用です。
つまりユーザー空間とカーネル空間の境界とは、CPUの特権レベルとページテーブルの権限ビットという2枚のハードウェア機構によって引かれた線です。そしてシステムコールは、その線を安全に越えるための、カーネルが用意した唯一の正面玄関です。
関数呼び出しとシステムコールは何が違うのか
Cのコードから見るとwrite(fd, buf, n)もstrlen(s)も同じ「関数呼び出し」に見えます。しかし中身は別物です。
| 観点 | 通常の関数呼び出し(call) | システムコール(syscall) |
|---|---|---|
| 特権レベル | ring 3のまま | ring 3からring 0へ遷移し、戻りでring 3へ |
| 飛び先 | 命令に埋め込まれた、またはレジスタが指すアドレス | 呼び出し元が指定できない。MSRに事前設定された固定の入口 |
| スタック | 同じユーザースタックを継続使用 | カーネルスタックに切り替わる |
| アドレス空間 | 同じ | KPTI有効時はCR3を切り替えてカーネル用ページテーブルへ |
| 引数の扱い | 呼び出し先がそのまま信用してよい | すべて攻撃者由来とみなして検証が必要 |
| 失敗の伝え方 | 戻り値やポインタなど自由 | 戻り値が負のerrno値 |
| コスト感 | 1ナノ秒に満たないオーダー | 数百ナノ秒からマイクロ秒のオーダー(環境依存) |
決定的な違いは飛び先を呼び出し側が選べない点です。通常のcallは任意のアドレスへ飛べますが、syscall命令の飛び先はカーネルが起動時にMSR(モデル固有レジスタ)へ書き込んだ1点に固定されています。ユーザーはどこへ入るかを選べず、「何をしてほしいか」を番号と引数で伝えることしかできません。この非対称性が、境界をセキュリティ境界たらしめています。
x86-64 Linuxの呼び出し規約
syscall(2)のマニュアルページに、アーキテクチャごとの規約が一覧で載っています。x86-64の行は次のとおりです。
| 用途 | レジスタ |
|---|---|
| 命令 | syscall |
| システムコール番号 | rax |
| 第1引数 | rdi |
| 第2引数 | rsi |
| 第3引数 | rdx |
| 第4引数 | r10 |
| 第5引数 | r8 |
| 第6引数 | r9 |
| 戻り値 | rax |
| 第2戻り値 | rdx |
| 破壊されるレジスタ | rcx、r11 |
引数は最大6個です。それ以上が必要なシステムコールは、構造体へのポインタを1つ渡す形に設計されています。
なぜ第4引数だけ rcx ではなく r10 なのか
System V AMD64 ABIの通常の関数呼び出し規約では、整数引数はrdi、rsi、rdx、rcx、r8、r9の順です。システムコールだけ4番目がrcxではなくr10になっているのは、syscall命令自身がrcxを使ってしまうからです。カーネルのarch/x86/entry/entry_64.Sのコメントにそのまま書かれています。
* 64-bit SYSCALL saves rip to rcx, clears rflags.RF, then saves rflags to r11,
* then loads new ss, cs, and rip from previously programmed MSRs.
...
* rax system call number
* rcx return address
* r11 saved rflags (note: r11 is callee-clobbered register in C ABI)
* rdi arg0
* rsi arg1
* rdx arg2
* r10 arg3 (needs to be moved to rcx to conform to C ABI)syscallは戻り先のRIPをrcxへ、実行前のRFLAGSをr11へ退避します。だからrcxは引数置き場として使えず、4番目はr10へずらされました。そしてカーネル側は入口でr10をrcxへ移し替え、通常のC関数の規約に合わせてから本体を呼びます。
戻り値とエラーの表現
システムコールの生の戻り値はraxに入ります。ここが普段のCプログラミングと大きく違う点です。
- 成功時: 0以上の値(書き込んだバイト数、ファイルディスクリプタ番号など)
- 失敗時: 負のerrno値。たとえば
EPERM(1)なら-1、ENOENT(2)なら-2、EBADF(9)なら-9が返る
errnoという変数はカーネルには存在しません。あれは完全にlibc側の発明です。生のシステムコールは「-4095から-1の範囲の値が返ってきたらエラー」という規約だけを持っています。
実際に syscall 命令を叩いてみる
libcを一切使わず、syscall命令だけで標準出力に文字列を書いて終了するプログラムです。x86-64のLinuxでそのままビルドして動きます。
/*
* libc を使わず syscall 命令を直接発行する最小の例
* ビルド: gcc -O2 -nostdlib -static -o hello_raw hello_raw.c
* 実行環境: x86-64 Linux のみ
*/
#define NR_WRITE 1 /* arch/x86/entry/syscalls/syscall_64.tbl より */
#define NR_EXIT 60
static long raw_syscall3(long nr, long a1, long a2, long a3)
{
long ret;
__asm__ volatile (
"syscall"
: "=a" (ret) /* 戻り値は rax */
: "a" (nr), "D" (a1), "S" (a2), "d" (a3) /* rax, rdi, rsi, rdx */
: "rcx", "r11", "memory" /* syscall が壊すレジスタ */
);
return ret;
}
void _start(void)
{
static const char msg[] = "hello, syscall\n";
raw_syscall3(NR_WRITE, 1, (long)msg, sizeof(msg) - 1);
raw_syscall3(NR_EXIT, 0, 0, 0);
__builtin_unreachable();
}インラインアセンブリの制約文字の"a"がrax、"D"がrdi、"S"がrsi、"d"がrdxに対応します。clobberリストにrcxとr11を書き忘れると、最適化後に壊れたコードが生成されるので必須です。
システムコール番号はarch/x86/entry/syscalls/syscall_64.tblが正本で、readが0、writeが1、closeが3、exitが60、exit_groupが231、openatが257といった具合に決まっています。この番号は互換性のために永久に変わりません。新しいシステムコールは末尾に追加されるだけです。
古い int 0x80 との違い
64ビット化以前のi386では、システムコールはソフトウェア割り込みint $0x80で発行し、引数はebx、ecx、edx、esi、edi、ebpに置いていました。int命令は割り込みディスクリプタテーブル(IDT)を引き、特権レベルのチェックとスタック切り替えを行うため、専用命令であるsyscallに比べて手続きが重くなります。
x86-64ではsyscallとsysretという専用の命令ペアが用意され、飛び先や新しいセグメントセレクタをMSRから直接ロードする形になりました。IDTを経由しないぶん軽量です。なお64ビットのLinuxでも互換レイヤとしてint $0x80は生きていますが、これを使うと32ビットのシステムコール番号表が参照され、引数も32ビットに切り詰められます。64ビットのプログラムから使うべきものではありません。
libcラッパが裏でやっていること
普段書くwrite(fd, buf, n)は、glibcやmuslが提供する薄いラッパ関数です。ラッパの仕事は主に3つです。
- 引数をシステムコール規約のレジスタへ並べ替え、
syscall命令を発行する - 戻り値が-4095から-1の範囲なら、符号を反転した値をスレッドローカルな
errnoへ格納し、戻り値として-1を返す - 必要に応じて番号やシグネチャの差を吸収する
3番目が地味に重要です。たとえばglibcのopen()は実際にはopenatシステムコールを呼びますし、gettimeofday()は後述のvDSO経由で解決されてシステムコールを一切発行しません。libc関数名とシステムコール名は1対1ではないと覚えておくと、straceの出力が期待と違うときに悩まなくて済みます。
番号だけ分かっていてラッパが用意されていないシステムコールを呼びたいときは、glibcの汎用関数syscall(2)を使います。
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>
int main(void)
{
/* 存在しない fd に書き込んでわざと失敗させる */
long r = syscall(SYS_write, -1, "x", 1);
/* syscall(2) も libc ラッパなので errno 変換をしてくれる */
printf("ret=%ld errno=%d (%s)\n", r, errno, strerror(errno));
/* 出力例: ret=-1 errno=9 (Bad file descriptor) */
/* getpid は glibc がキャッシュせず毎回カーネルに聞く */
printf("getpid()=%d raw=%ld\n", getpid(), syscall(SYS_getpid));
return 0;
}ここで注意したいのは、syscall(2)関数もlibcのラッパであるという点です。戻り値は-1に正規化され、errnoがセットされます。カーネルが返す生の負値がそのまま見たい場合は、前節のようにインラインアセンブリを書く必要があります。
もう一つの落とし穴がerrnoのスコープです。errnoはマルチスレッドで安全なようにスレッドローカル変数として実装されたマクロで、グローバル変数ではありません。そして成功時にクリアされません。「失敗を確認してからerrnoを読む」という順序を守らないと、前回の残骸を読むことになります。ライブラリの実体がどう解決されるかについてはリンカとローダ、ELFと動的リンクの記事もあわせてどうぞ。
カーネル側の入口で何が起きるか
syscall命令が実行された瞬間、CPUはMSR_LSTARに書かれたアドレスへ飛びます。誰がそこに何を書いたのかは、カーネルソースを見ると一目です。arch/x86/kernel/cpu/common.cのidt_syscall_init()に次の1行があります。
wrmsrq(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);同じ関数で、MSR_STARにカーネルとユーザーのセグメントセレクタが、MSR_SYSCALL_MASKに「システムコール時にクリアするRFLAGSのビット」が設定されます。割り込み許可フラグIFもここでクリアされるため、カーネルに入った直後は割り込み禁止状態です。
entry_SYSCALL_64 の最初の数命令
arch/x86/entry/entry_64.Sのentry_SYSCALL_64は、実際にはこう始まります。
SYM_CODE_START(entry_SYSCALL_64)
UNWIND_HINT_ENTRY
ENDBR
swapgs
/* tss.sp2 is scratch space. */
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
movq PER_CPU_VAR(cpu_current_top_of_stack), %rspわずか数命令の中に、境界を越えるために必要なことが詰まっています。
swapgs: GSベースをユーザー用からカーネル用へ交換します。これでCPUごとのper-CPU変数にアクセスできるようになりますSWITCH_TO_KERNEL_CR3: KPTI(ページテーブル分離)が有効な場合にCR3を書き換え、カーネル用ページテーブルへ切り替えます。ここがMeltdown緩和の実体ですmovq ... %rsp: ユーザースタックからカーネルスタックへ乗り換えます。ユーザーが渡してきたrspは一切信用しません
その後、pt_regsという構造体をカーネルスタック上に組み立て(ユーザーのレジスタ一式を保存し)、IBRS_ENTERやUNTRAIN_RET、CLEAR_BRANCH_HISTORYといったSpectre系の緩和マクロを通してから、C言語で書かれたdo_syscall_64()を呼びます。
システムコールテーブルと番号の検証
arch/x86/entry/syscall_64.cのディスパッチ部分は次のようになっています。
static __always_inline bool do_syscall_x64(struct pt_regs *regs, unsigned long nr)
{
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
regs->ax = x64_sys_call(regs, (unsigned int)nr);
return true;
}
return false;
}範囲チェックだけでなくarray_index_nospec()が入っているのが今どきです。これは投機実行が範囲チェックを追い越して配列外を読むこと(Spectre variant 1)を防ぐための処理で、境界チェックを投機的にもすり抜けられないマスクに変換します。
もう一点、同じファイルのコメントに現代的な事情が書かれています。
/*
* The sys_call_table[] is no longer used for system calls, but
* kernel/trace/trace_syscalls.c still wants to know the system
* call address.
*/かつては関数ポインタ配列sys_call_table[]を番号で引く実装でしたが、間接分岐がSpectre系攻撃の標的になるため、現在は巨大なswitch文にコンパイルされるx64_sys_call()でディスパッチされます。配列は今やトレース機能のために残っているだけです。
copy_from_user と copy_to_user がなぜ必要か
カーネルがユーザーから受け取ったポインタを、生のmemcpyで読み書きすることは絶対にできません。理由は少なくとも3つあります。
- アドレスの正当性が保証されない: 悪意あるプロセスはカーネルアドレスを引数に渡してきます。カーネル権限でそこを読めば、任意のカーネルメモリを覗ける穴になります
- マップされているとは限らない: 有効なユーザーアドレスでも、まだ物理ページが割り当てられていなければページフォルトします。カーネルモードでの無防備なフォルトはpanicを招きます
- SMAP等のハードウェア保護がある: 近年のCPUにはSMAP(Supervisor Mode Access Prevention)があり、カーネルモードからユーザーページへのアクセスは既定で禁止されています。明示的な許可が必要です
そこでカーネルはcopy_from_user()とcopy_to_user()という専用関数を必ず経由します。この関数は、アドレスがユーザー空間の範囲にあるかを確認し、SMAPを一時的に解除し、フォルトが起きても復帰できる例外テーブル付きのコードでコピーし、コピーできなかったバイト数を返します。カーネル側のコードで戻り値を確認し忘れる、あるいはチェックと使用の間に別スレッドがメモリを書き換える(TOCTOU)、といったミスはそのまま脆弱性になります。実例としてLinuxカーネルの権限昇格脆弱性の記事も参考になります。
戻り道: sysret と iret
処理が終わったらsysretでユーザーへ戻ります。ただしこの命令は使える条件が厳しく、do_syscall_64()の末尾で、RCXがRIPと一致しているか、R11がRFLAGSと一致しているか、CSとSSが期待どおりか、RIPがカノニカルアドレスかを確認しています。一つでも満たさなければ、遅いが万能なiret経由の退出パスに切り替わります。ここのコメントには「非カノニカルなRCXでのSYSRETはカーネル空間で例外を起こし、ユーザーにカーネルを乗っ取らせうる」というCPUバグへの言及があり、境界の実装がいかに神経質な作業かがうかがえます。
vDSO: システムコールを発行しないシステムコール
clock_gettime()のような「時刻を聞くだけ」の処理まで毎回モード遷移していては、時刻取得が多いアプリケーションで無視できない負荷になります。vdso(7)のマニュアルにも、頻繁に使われるシステムコールが全体の性能を支配しうる、と書かれています。
そこでLinuxはvDSO(virtual dynamic shared object)という仕掛けを持っています。カーネルが小さな共有ライブラリをすべてのプロセスのアドレス空間へ自動的にマップし、その中に「カーネルが更新している共有データページを読むだけで答えが出る関数」を置いておくのです。
vdso(7)によれば、x86-64でエクスポートされるシンボルは次の4つです。
| シンボル | 対応する機能 |
|---|---|
__vdso_clock_gettime | 高分解能の時刻取得 |
__vdso_gettimeofday | 時刻取得 |
__vdso_time | 秒単位の時刻取得 |
__vdso_getcpu | 実行中のCPU番号とNUMAノード |
カーネルはタイマ割り込みのたびにクロックソースの値と補正係数を共有ページへ書き込んでおり、vDSOの関数はそれを読んでTSC(タイムスタンプカウンタ)から現在時刻を算出します。ユーザーモードのまま完結するので、モード遷移が丸ごと消えます。glibcのclock_gettime()は自動的にvDSOを使うため、アプリケーション側で特別なことをする必要はありません。
vDSOの位置は、プログラム起動時に補助ベクタ(auxiliary vector)のAT_SYSINFO_EHDRタグで渡されます。アドレスはASLRで毎回変わります。存在は簡単に確認できます。
# プロセスのメモリマップに [vdso] が現れる
$ grep -E 'vdso|vvar' /proc/self/maps
7ffc9f1b4000-7ffc9f1b6000 r-xp 00000000 00:00 0 [vdso]
# 動的リンクの依存にも linux-vdso.so.1 として顔を出す
$ ldd /bin/ls | head -2
linux-vdso.so.1 (0x00007ffd0c3f9000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (...)linux-vdso.so.1にはディスク上の実体がありません。ファイルシステムを探しても見つからないのは、カーネルがメモリ上に合成しているからです。
NOTE
vDSOの先祖にあたるvsyscallという固定アドレスの仕組みもありましたが、アドレスが固定であることがROP攻撃の踏み台になるため非推奨となり、現在はエミュレーションモードで細々と残っているだけです。新規に使うものではありません。
システムコールのコスト
「システムコールは重い」とよく言われますが、何が重いのかを分解してみます。
- モード遷移そのもの:
swapgs、スタック切り替え、pt_regsの構築と復元。命令数としては数十命令規模です - キャッシュとTLBの汚染: カーネルのコードとデータがL1/L2キャッシュを追い出します。戻ってきたときにアプリのワーキングセットが冷えている、という間接コストが実は大きく効きます(CPUキャッシュとメモリ階層の記事の話がそのまま効いてきます)
- 投機実行脆弱性の緩和: KPTIによるCR3切り替え、IBRSやretpolineといった分岐予測器の緩和処理
- システムコール本体の仕事: I/O待ちやロック競合。これは境界のコストではなく、頼んだ仕事そのもののコストです
オーダー感としては、通常の関数呼び出しが1ナノ秒に満たないのに対し、システムコールは数百ナノ秒からマイクロ秒の規模と考えておくとよいでしょう。ただしこの数字はCPU世代、カーネルバージョン、有効な緩和策、そしてシステムコールの種類によって数倍から一桁変わります。自分の環境で測らずに具体的な数値を前提に設計するのは危険です。
KPTIがどれだけ効くか
Meltdown対策のKPTI(ページ テーブル分離)は、カーネル出入りのたびにCR3を切り替えるため、システムコールのコストを直接押し上げます。Brendan Gregg氏の実測記事では、影響の幅が次のように報告されています。
- 「KPTIのパッチは1%から800%超まで、極めて大きなオーバーヘッドを招きうる」
- システムコール発行率との相関が強く、CPUあたり毎秒5万システムコール程度で約2%、発行率が上がるほど悪化する
- 毎秒7.5万システムコールのMySQL OLTPで、推定約4%に対し実測約5%の低下
- PCID(プロセスコンテキスト識別子)対応のカーネルではTLBフラッシュが減り、あるワークロードで2.1%だった低下が約0.5%まで下がった
重要なのは絶対値ではなく「システムコール発行率が高いほど損をする」という関係です。自分のアプリがCPUあたり毎秒どれだけシステムコールを出しているかは、後述のperf trace -sやbpftraceですぐ測れます。まずそこを知るのが出発点になります。
コストを減らす設計
境界を越える回数そのものを減らす、が基本方針です。実務で効く順に並べます。
1. バッファリングする
もっとも基本的で、もっとも効きます。1バイトずつwrite(2)を呼べば100万回のモード遷移ですが、64KiBずつまとめれば16回で済みます。C標準ライブラリのstdio(fwriteなど)がユーザー空間でバッファリングしているのはこのためで、write(2)を直接使うコードはこの恩恵を自前で用意する必要があります。
2. 1回の呼び出しで複数の仕事をさせる
| やりたいこと | 素朴な方法 | まとめる方法 |
|---|---|---|
| 複数バッファを連結して書く | バッファごとにwrite | writev(ベクタI/O) |
| ファイルをソケットへ転送 | readとwriteの往復 | sendfile(カーネル内で完結) |
| 大量のファイル読み込み | readを繰り返す | mmapしてメモリアクセスとして扱う |
| 多数のfdの状態確認 | fdごとにノンブロッキングread | epoll_waitでまとめて待つ |
sendfile(2)はデータをユーザー空間へ持ち上げずにカーネル内でページキャッシュからソケットへ渡すため、モード遷移の削減とコピーの削減を同時に達成します。mmap(2)はファイルアクセスをページフォルト経由の暗黙の処理に置き換えるアプローチで、ページキャッシュの挙動を理解していると効果が読みやすくなります(inodeとページキャッシュの記事を参照)。
3. io_uringで非同期化する
究極的には、システムコールを1回も発行せずにI/Oを投げて回収する、という方向もあります。io_uringはユーザー空間とカーネルで共有するリングバッファ(投入キューと完了キュー)を使い、リクエストをキューに積むだけで済ませます。カーネル側のポーリングスレッドを有効にすれば、io_uring_enterすら呼ばずに処理が進みます。epollとio_uringの使い分けはI/O多重化の記事で詳しく扱っています。
4. 呼ばなくていいものは呼ばない
意外に多いのが、ループの中で毎回時刻を取っている、毎回getpid()を呼んでいる、といったケースです。時刻はvDSOのおかげで比較的安価ですが、それでもマイクロ秒単位のループの中では無視できません。プロファイルを取って「そもそも必要か」を疑うのが最短の最適化になることは珍しくありません。
システムコールを観測する
strace: 何を呼んでいるかを見る
もっとも手軽です。呼び出しと戻り値を人間が読める形で表示します。
# 特定のシステムコールだけを追う(-f は子プロセスも追跡)
$ strace -f -e trace=openat,read,write,close ./myapp
openat(AT_FDCWD, "/etc/hostname", O_RDONLY|O_CLOEXEC) = 3
read(3, "myhost\n", 4096) = 7
read(3, "", 4096) = 0
close(3) = 0
write(1, "myhost\n", 7) = 7
+++ exited with 0 +++
# 実行中のプロセスへアタッチする
$ sudo strace -p 12345 -e trace=network
# 集計モード(-w は実時間、既定はシステム時間で集計)
$ strace -c -w ./myapp
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
52.11 0.004213 42 100 write
31.44 0.002542 25 100 read
...openat(AT_FDCWD, ...)と表示されているのに注目してください。ソースではopen()を呼んでいても、実際に発行されるのはopenatです。
注意点として、straceはptraceベースなので非常に遅くなります。1呼び出しごとにトレーサへの切り替えが発生するため、実行時間が桁で変わることもあります。本番環境の性能問題の切り分けに常用するものではなく、「何を呼んでいるか」を知るための道具と考えてください。
ltrace: ライブラリ関数の側から見る
ltraceはlibc関数など動的リンクされた関数呼び出しを追います。-Sを付けるとシステムコールも混ぜて表示するので、「fopenが内部でopenatを呼んでいる」といった対応関係が見えます。
$ ltrace -S ./myapp 2>&1 | head -20perf trace: 低オーバーヘッドで集計する
perf traceはtracepointベースで動くため、straceよりはるかに軽量です。本番寄りの調査で使えます。
# システム全体のシステムコールをリアルタイム表示
$ sudo perf trace
# 特定PIDだけ、要約モード(呼び出し回数と待ち時間の統計)
$ sudo perf trace -s -p 12345
# コマンドを起動して測る
$ sudo perf trace -s -- ./myappbpftrace: 好きな粒度で数える
eBPFを使うため、オーバーヘッドが小さく、集計をカーネル内で完結できます。「秒間いくつシステムコールを出しているか」を知るのに最適です。
# プロセスごとのシステムコール発行数を集計(Ctrl-Cで表示)
$ sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'
# 1秒ごとの総発行数を出す
$ sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @n = count(); }
interval:s:1 { print(@n); clear(@n); }'
# openat で開かれたファイル名を表示
$ sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat {
printf("%-16s %s\n", comm, str(args->filename)); }'引数へのアクセス記法はbpftraceのバージョンで異なり、新しめのものはargs.filenameのようにドットで書きます。手元のbpftrace --versionを確認してください。
セキュリティ境界としてのシステムコール
システムコールは「カーネルへの唯一の入口」なので、そこを絞れば、プロセスにできることを根本から制限できます。これが現代のサンドボックスの基本戦略です。
seccomp-bpf
seccomp(2)には2つのモードがあります。SECCOMP_SET_MODE_STRICTはread、write、_exit、sigreturnの4つしか許さない極端なモードで、SECCOMP_SET_MODE_FILTERがBPFプログラムで細かく制御する実用的なモードです。
フィルタはstruct seccomp_dataに対して評価され、参照できるのはシステムコール番号(nr)、アーキテクチャ(arch)、syscall命令のアドレス(instruction_pointer)、6つの引数(args)です。ポインタの指す先は追えません。フィルタ適用後に別スレッドがメモリを書き換えるTOCTOU攻撃が成立してしまうためで、「ファイル名で許可を決める」といった芸当はseccompでは原理的にできません。
フィルタの判定結果は、SECCOMP_RET_KILL_PROCESS(プロセス即殺)、SECCOMP_RET_KILL_THREAD、SECCOMP_RET_TRAP(SIGSYS送出)、SECCOMP_RET_ERRNO(実行せずエラーを返す)、SECCOMP_RET_USER_NOTIF(ユーザー空間の監視役へ委譲)、SECCOMP_RET_TRACE、SECCOMP_RET_LOG、SECCOMP_RET_ALLOWから選びます。
また特権のないプロセスがフィルタを設定するには、事前にprctl(PR_SET_NO_NEW_PRIVS, 1)が必要です。これはexecveによるsetuidバイナリでの特権昇格と組み合わせた悪用を防ぐための条件です。
/*
* unlink(2) だけを EPERM で拒否する最小の seccomp フィルタ
* ビルド: gcc -O2 -o seccomp_demo seccomp_demo.c
* 実行環境: x86-64 Linux
*/
#define _GNU_SOURCE
#include <errno.h>
#include <linux/audit.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdio.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>
int main(void)
{
struct sock_filter filter[] = {
/* 1. アーキテクチャ確認。想定外ならプロセスごと停止 */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
/* 2. システムコール番号をロード */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
/* 3. unlink なら実行させず EPERM を返す */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_unlink, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)),
/* 4. それ以外は許可 */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = {
.len = (unsigned short)(sizeof(filter) / sizeof(filter[0])),
.filter = filter,
};
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) {
perror("PR_SET_NO_NEW_PRIVS");
return 1;
}
if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog) != 0) {
perror("seccomp");
return 1;
}
/* フィルタ適用後はカーネルに届く前に弾かれる */
if (unlink("/tmp/anything") != 0) {
perror("unlink"); /* Operation not permitted */
}
return 0;
}このフィルタは一度適用すると解除できません。子プロセスにも継承され、フィルタを追加することはできても緩めることはできない、という一方通行の設計になっています。
コンテナのseccompプロファイル
Dockerは既定で、300を超えるシステムコールのうち約40個以上をブロックするseccompプロファイルをコンテナに適用します。ブロック対象にはmount、reboot、init_module、kexec_loadといった、そもそもコンテナ内から呼ぶ理由がないものが並びます。コンテナが「軽量な仮想マシン」ではなく「システムコール境界を共有したまま名前空間で仕切られたプロセス」である以上、この一枚が非常に重要です。詳しくはコンテナのnamespaceとcgroupsの記事でも触れています。
--security-opt seccomp=unconfinedでこれを外す指示をネット上でよく見かけますが、これはカーネル攻撃面を丸ごと開ける行為です。問題のシステムコールだけを許可するカスタムプロファイルを書くほうが健全です。
Landlock
seccompが「どのシステムコール番号を通すか」を見るのに対し、Landlockは「どのファイルやネットワークリソースに触れてよいか」を、特権なしで自プロセスに課せる仕組みです。Linux 5.13でマージされ、その後ファイルシステムに加えてネットワークのTCP接続やバインドも制御できるよう拡張されてきました。
ポイントは非特権プロセスが自分自身を縛れることです。ルールセットを作り、許可するアクセス権を付けたディレクトリのファイルディスクリプタを登録し、自プロセスに適用する、という流れになります。seccompと同様、一度適用したら緩められません。ファイル名で許可を決められるため、seccompでは表現できない種類の制限を補完できます。
まとめ: アプリ開発者が押さえておく指針
システムコールの仕組みを知っていると、日々の実装判断が変わります。
- ライブラリ関数名とシステムコール名は一致しない。実際に何が発行されているかは
straceで確かめる習慣を持つ - 境界を越える回数がコストを決める。バッファリング、
writev、sendfile、epoll、io_uringはすべて「回数を減らす」ための道具として同じ系譜にある - 時刻取得はvDSOのおかげで安価だが無料ではない。ループの中の
clock_gettimeは疑ってよい - KPTI等の緩和策により、システムコール発行率の高いアプリほど損をする。まず
perf trace -sやbpftraceで自分の発行率を測る - エラーは必ず負のerrnoとして返る。libcが-1と
errnoに変換している。errnoは失敗を確認してから読む - カーネルはユーザーの入力を一切信用しない。
copy_from_userやarray_index_nospecはその表れであり、同じ姿勢は自分が書くAPI境界にも応用できる - システムコールは最良のセキュリティ境界でもある。seccompやLandlockで、アプリが呼べる範囲を先に狭めておく
「関数呼び出しに見えて、実はハードウェアの特権境界を越えている」という一点を意識するだけで、性能の見立ても、セキュリティの設計も、ずいぶん解像度が上がるはずです。プロセスとスレッドの切り替えコストとあわせて考えたい場合は並行と並列、プロセスとスレッドの記事もどうぞ。
参考リンク
- syscall(2) - Linux manual page: https://man7.org/linux/man-pages/man2/syscall.2.html
- syscalls(2) - Linux manual page: https://man7.org/linux/man-pages/man2/syscalls.2.html
- vdso(7) - Linux manual page: https://man7.org/linux/man-pages/man7/vdso.7.html
- seccomp(2) - Linux manual page: https://man7.org/linux/man-pages/man2/seccomp.2.html
- landlock(7) - Linux manual page: https://man7.org/linux/man-pages/man7/landlock.7.html
- Linux kernel: arch/x86/entry/entry_64.S: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/entry/entry_64.S
- Linux kernel: arch/x86/entry/syscall_64.c: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/entry/syscall_64.c
- Linux kernel: arch/x86/entry/syscalls/syscall_64.tbl: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/entry/syscalls/syscall_64.tbl
- Page Table Isolation (PTI) - Linux kernel documentation: https://docs.kernel.org/arch/x86/pti.html
- Seccomp BPF - Linux kernel documentation: https://docs.kernel.org/userspace-api/seccomp_filter.html
- Landlock - Linux kernel documentation: https://docs.kernel.org/userspace-api/landlock.html
- KPTI/KAISER Meltdown Initial Performance Regressions (Brendan Gregg): https://www.brendangregg.com/blog/2018-02-09/kpti-kaiser-meltdown-performance.html
- Seccomp security profiles for Docker: https://docs.docker.com/engine/security/seccomp/
- System V Application Binary Interface x86-64 psABI: https://gitlab.com/x86-psABIs/x86-64-ABI
- perf-trace(1) - Linux manual page: https://man7.org/linux/man-pages/man1/perf-trace.1.html
- bpftrace: https://bpftrace.org/


