システムコールテーブルとvDSOの内部
システムコール番号がなぜ「永久欠番」になるのか、なぜvDSOだと時刻取得が速いのかが腑に落ちます。ディスパッチテーブルの仕組みとvsyscall廃止のセキュリティ経緯まで原理から押さえられます。
- 1.syscall番号はrax上限と照合後、sys_call_table関数ポインタ配列を引いて実体へ分岐する。番号はABIの一部なので一度割り当てたら永久に再利用できない。
- 2.vDSOはカーネルが書く読み取り専用ページをユーザーモードのまま読み、clock_gettime等をring遷移なしで返す。フォールバック時のみ本物のsyscallを発行する。
- 3.vsyscallは固定アドレスにコードを置いたためASLRを無力化するROPガジェット源になり、現在はemulate/xonlyへ縮小・実質廃止された。
番号からカーネル関数までの距離
ユーザープロセスが syscall 命令を実行した瞬間、CPU は ring0 へ遷移してカーネルの固定入口へ飛びます。ここまでは呼び出し機構の話で、システムコールのABIと呼び出し機構の内部で扱いました。本稿はその先——カーネルが受け取った 番号をどう実体の関数へ振り分けるか、そして読み取り系を遷移ごと省く vDSO の内部、最後に廃止された vsyscall がなぜ危険だったかを、セキュリティの観点まで含めて解剖します。前提となるモード分離はカーネルモードとユーザーモードを参照してください。
カーネル入口に到達した時点で、syscall 番号は rax に入っています。カーネルがまず行うのは番号の 境界チェック です。番号が NR_syscalls(登録総数)以上なら即座に -ENOSYS を返します。チェックを通った番号だけが、次のディスパッチテーブル引きへ進みます。境界外をそのまま配列添字に使えば、配列外の関数ポインタを呼ぶ任意コード実行になるため、この上限照合は保護の要です。
sys_call_table:関数ポインタ配列の正体
ディスパッチの中心は sys_call_table という 関数ポインタの配列 です。添字が syscall 番号、要素がその番号に対応するカーネル実装関数のアドレスです。x86-64 の入口は概念的に次を行います。
/* arch/x86/entry/common.c の do_syscall_64 を単純化 */
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls); /* Spectre v1 緩和 */
regs->ax = sys_call_table[nr](regs); /* 配列を引いて実体を呼ぶ */
}
array_index_nospec は投機実行(Spectre v1)対策で、境界チェックを CPU が投機的に飛び越えて配列外を読むのを、データ依存のマスクで封じます。実体の関数は SYSCALL_DEFINE マクロ群で定義され、たとえば read は SYSCALL_DEFINE3(read, ...) から __x64_sys_read という入口ラッパーを生成します。このラッパーが pt_regs からレジスタ渡しの引数を取り出し、本体 ksys_read を呼ぶ二段構成です。
sys_call_table はソースに直接書かれた配列ではなく、syscall_64.tbl のような 番号・名前・実装 を列挙したテキスト表からビルド時にスクリプトが生成します。番号の対応をアーキテクチャごとに1つの正本(テーブル)に集約することで、人手の添字ずれを防ぎ、unistd_64.h(ユーザー向け番号定義)とカーネル側配列の整合を機械的に保証します。
番号がアーキごとに違い、永久欠番になる理由
syscall 番号は ABI(バイナリ互換の契約)の一部 です。一度ある番号を割り当てて公開バイナリが依存し始めたら、その番号は二度と別の意味に再利用できません。機能が廃止されても番号は欠番として残り、sys_ni_syscall(-ENOSYS を返すだけの関数)に紐づけられます。古いバイナリが消えた番号を呼んでも、別機能が誤って動く事故を防ぐためです。
番号体系はアーキテクチャごとに独立しています。read は x86-64 で 0、i386 で 3、arm64 で 63 と、まったく異なります。下の対比はこの独立性の帰結を示します。
| 観点 | x86-64 | i386(32bit) | arm64 |
|---|---|---|---|
| readの番号 | 0 | 3 | 63 |
| 番号の出所 | syscall_64.tbl | syscall_32.tbl | 汎用asm-generic表 |
| 新規ABIの方針 | 番号は安定・追記のみ | 歴史的経緯で穴あり | 新規archは連番で整備 |
| 欠番の扱い | sys_ni_syscall | sys_ni_syscall | 予約として確保 |
この「番号は契約」という性質が、後述の vsyscall 問題の伏線になります。固定された番号やアドレスは互換性に有利だが、攻撃者にとっても予測可能 だからです。
vDSO:モード遷移を丸ごと省く
clock_gettime や gettimeofday のように 値を読むだけで副作用がない 操作にまで ring 遷移コストを払うのは無駄です。そこでカーネルは vDSO(virtual Dynamic Shared Object) という小さな共有ライブラリを、全プロセスのアドレス空間へ自動でマップします。これは実体のファイルを持たず、カーネルイメージ内の ELF を実行時に貼り付けたものです。
仕組みの核は データとコードの分離 です。カーネルは時刻・クロックソース係数・CPU 番号などを書いた 読み取り専用ページ(vvar)を公開し、vDSO 内のコードページがそのページをユーザーモードのまま読みます。アプリは vDSO のシンボル(__vdso_clock_gettime 等)を普通の関数として呼ぶだけで、内部で計算が完結します。syscall 命令を一度も実行しないので、ring 遷移もスタック切替も swapgs も発生しません。
clock_gettime(CLOCK_MONOTONIC) の vDSO 経路
1. vvar から最終更新時刻 base と TSC 係数 mult/shift を読む
2. rdtsc で現在のサイクルを取得(ユーザーモードのまま)
3. ns = base + ((tsc_now - tsc_base) * mult) >> shift を計算
4. seqlock の世代番号が読み取り前後で不変なら結果を返す
─ クロックソースが TSC 以外等で対応不可なら本物の syscall へフォールバック
ステップ4の seqlock(シーケンスロック)が要です。カーネルが時刻データを更新する最中にユーザーが読むと不整合になるため、更新ごとに増える世代番号を読み取りの前後で照合し、変化していれば読み直します。ロックを取らずに読み手をブロックしないので、極めて軽量です。
vDSO はマップ先アドレスを ASLR でプロセスごとにランダム配置します。ローダはその位置を AT_SYSINFO_EHDR という補助ベクタ(auxv)経由で受け取り、ELF として再配置・シンボル解決します。配置の流れはELFバイナリのロードとリンカ・ローダの内部と地続きで、vDSO は「カーネルが供給する共有ライブラリ」として通常の動的リンク機構に乗ります。
vsyscall の廃止:固定アドレスという原罪
vDSO の前身が vsyscall です。同じく高速化が目的でしたが、設計が決定的に違いました。vsyscall は 0xffffffffff600000 という 全プロセス共通の固定アドレス に、ごく少数の関数(gettimeofday など)のコードを直接置いたのです。固定なので auxv も再配置も要らず単純でしたが、この単純さがセキュリティ上の致命傷でした。
アドレスが全プロセスで不変ということは、攻撃者が 常に存在を予測できる実行可能コード片 を握れることを意味します。ASLR はスタックやヒープ、ライブラリの位置を乱数化して攻撃を難しくしますが、vsyscall ページだけは例外的に固定で実行可能でした。これは ROP(Return-Oriented Programming)のガジェット供給源になり、ASLR の保護を部分的に無力化しました。番号やアドレスを固定すると互換性は上がるが攻撃者にも予測されるという、前述のトレードオフが最悪の形で現れた例です。
そこでカーネルは段階的に防御を強化しました。まず vsyscall ページを読み取り専用化し、次に emulate モード(既定)へ移行します。emulate モードでは、ユーザーがあの固定アドレスへ飛んでも実コードは実行されません。ページフォルトをカーネルが捕捉し、ハンドラ内で本物の処理をエミュレートして返します。コード片そのものが実行されないため、ROP ガジェットとして使えなくなります。
さらに新しいカーネルでは xonly(実行専用・読み取り不可)が既定となり、最終的に vsyscall を完全無効化(CONFIG_LEGACY_VSYSCALL_NONE)してコンパイルすることも一般化しました。互換のために細々と残るだけで、実用上の役割は vDSO が完全に引き継いでいます。
| 項目 | vsyscall(旧) | vDSO(現行) |
|---|---|---|
| 配置アドレス | 全プロセス固定 | ASLRでランダム |
| ローダへの伝達 | 不要(固定で既知) | auxv AT_SYSINFO_EHDR |
| 対象 | 数個の関数のみ | 拡張可能・arch毎に複数 |
| セキュリティ | ROPガジェット源 | ASLR保護下 |
| 現状 | emulate/xonlyへ縮小 | 標準機構 |
なぜこの分業に落ち着いたか
全体像を一段上から見ると、設計は3層の役割分担になっています。番号とテーブル は「どの要求を、どの実装が処理するか」を ABI として安定に固定する層。vDSO は「カーネルへ入らずに済む読み取り系」をユーザー空間へ前倒しする高速化層。vsyscall の廃止 は「高速化のための固定配置が ASLR を壊した」反省から、機能を保ったまま攻撃面を畳んだ修正です。固定アドレスのカーネル領域がなぜ危険かはvmallocと高位メモリ・カーネルアドレス空間レイアウトの KASLR の議論とも通じます。
syscall 番号は rax 上限と照合し、array_index_nospec で投機実行対策を施したうえで sys_call_table 関数ポインタ配列を引き、SYSCALL_DEFINE 生成の実体へ分岐します。番号は ABI なので欠番は sys_ni_syscall で永久に塞がれ、アーキごとに独立します。vDSO は vvar 読み取り専用ページと seqlock により clock_gettime 等を ring 遷移なしで返し、対応不可時だけ本物の syscall へフォールバックします。vsyscall は固定アドレスゆえ ROP ガジェット源となり ASLR を弱めたため、emulate/xonly へ縮小され実質廃止されました。土台の概念はシステムコールとカーネルで確認できます。
OS Article
システムコールテーブルとvDSOの内部を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
システムコール
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
vDSOはカーネルが書く読み取り専用ページをユーザーモードのまま読み、clock_gettime等をring遷移なしで返す。フォールバック時のみ本物のsyscallを発行する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「システムコール / vDSO」に近いか確認する。
- 強みである「syscall番号はrax上限と照合後、sys_call_table関数ポインタ配列を引いて実体へ分岐する。番号はABIの一部なので一度割り当てたら永久に再利用できない。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。