C言語で無名関数を呼び出す

2人の学生が、学校の自動採点器のすべての練習問題を通過する単一の解法を書こうとした。任意のコードを実行させるには、健全性チェックの目を盗んでC関数を呼び出す必要があり、そのコツは名前を書かずにそれらを呼び出すことだった。

日本語
コピー

2026 年 10 月 5 日

うちの学校には、提出されたコードを自動採点するための、制御されたリモートコード実行環境がある。システムは実行結果を確認し、フォーマットが正しいかを検証し、もちろんユーザーには見せないチェック(たとえばコードの盗用検出ツール 1)もいくつか走らせる。画面には結果の不一致とコンパイルエラーが報告される。

友達と二人で、すべての課題を解ける汎用的な方法を探し始めた。その第一歩は、任意の shell コードを実行できるプリミティブを手に入れることだった。当時は、採点用の VM に sh バイナリがあるはずだから、execve を直接呼べばいいと考えていた。

で……うまくいったか? 察しのとおり、いかなかった。

採点システムには他にもチェックがあると言ったのを覚えているだろうか。そのひとつが、課題で許可された関数しか使っていないかを確認するものだ。execve は決して許可リストに入らないので、コードが動く前に提出は弾かれる。我々の経験から言うと、このシステムはソースを単に grep するだけのものより込み入っている。おそらく clang でコードを前処理・パースし、生成された AST を使ってチェックしているのだろう。

問題の核心はここだ。execve を呼び出す必要があるのに、そのシンボル名を書くことはできない。どうする?

少し引いて考えよう。C のコードは最終的にバイナリにコンパイルされ、関数を呼ぶとは要するにメモリ上のどこかへジャンプすることだ。ならば、execve のメモリ上のアドレスを手に入れて、それをプログラムにハードコードし、直接呼び出せないだろうか。

現代のノート PC 上のソフトウェアでは、メモリアドレスは実行環境に依存することが多い。これは現代のカーネルがアドレス空間配置のランダム化(ASLR) を実装しているためで、これがバイナリ本体、依存ライブラリ、ヒープ、スタックの配置アドレスをランダム化する。今どき C のコードはデフォルトで -pie フラグ付きでコンパイルされ、これが位置独立実行ファイル を生成する。つまり、あなたのバイナリはまさに ASLR の恩恵を受けるということだ。-no-pie という C コンパイラフラグでこの挙動を切り、バイナリ内の関数に固定アドレスを強制することはできる。だが意味がない。多くの場合、ビルド過程は我々の制御下にないからだ。第二に、execve 関数は我々の実行ファイルに直接属しておらず動的にロードされるので、やはりランダムな位置に置かれる。

静的アドレスは通用しない。だが ASLR が作用するのは各セグメントのマッピングだけで、セグメントの中身はそのまま変わらない。セグメント内部では、関数の並び順は依然として不変だ。二つの関数 fA と fB のメモリアドレスの静的なオフセットさえ分かれば、fA のアドレスから fB のアドレスを導けるし、その逆もできる。

このオフセットはマシンごとに変わる。C ライブラリのバージョンが違うかもしれないし、アーキテクチャが違うかもしれないし、何か別の理由かもしれない。対象マシン上で二つの関数間の固定オフセットを取得する方法はいくつかある。ひとつは、課題で fA と fB という二つのシンボルに名前を付けられるなら、オフセットをそのまま漏らしてしまうことだ。

我々の目的の関数は依然として execve で、これは libc に属する。常に printf にアクセスできると仮定しよう。あとは VM 上でこの二つの関数間のオフセットを漏らすだけだ。まずローカルで試す:

printf("%td", (ptrdiff_t)((size_t)execve - (size_t)printf));

プログラムを何度か走らせると、結果は毎回違う。オフセットは固定だと言ったばかりではないか? バイナリを開いて見てみる。

$ objdump --dynamic-syms ./offset | grep -E "\s(execve|printf)"
0000000000000000      DF *UND*	0000000000000000 (GLIBC_2.2.5) execve
000000000004dd64  w   DF .text	0000000000000005  Base        printf

objdump はどんな種類の object ファイル解析にも役立つ。--dynamic-syms オプションは実行時にロードされる各シンボルの情報を出す。六列目がそのシンボルの出所を教えてくれる。execve は確かに glibc、つまり俺のノート PC にある C 標準ライブラリ実装から来ている。だが printf は glibc から来ているようには見えない。実際、objdump の生の出力には __interceptor_* シンボルが山ほどあって、それだけで話は見えてくる。

最初から、俺はすべてのコードを ASan(つまり -fsanitize=address)付きでコンパイルしていた。メモリエラーを早めに捕まえるためだ。ASan のやり方は、メモリの読み書きを含む大量のコードを、各操作が正しいかを一つずつ確認する遅い関数呼び出しの山に置き換えるというものだ。さらに C 標準ライブラリの関数もいくつか置き換える。たとえば malloc や printf だ。こうなると、これらの関数はもう glibc という object にはなく、置き換えられていない関数(我々の例では execve)は依然としてそこに残っている。自動採点システムもたまたまメモリエラーをチェックし、ASan 付きでコンパイルする。

printf とは別のシンボルで、execve と同じセグメントに入るものを試すことはできる。ただし、それもほとんどの場合で使用が許可される類のシンボルでなければならない。そうしたシンボルの大半は ASan が置き換えた後のセグメントにあるので、そこから選べばいい。おそらく execve だけが唯一の手ではない。このセグメントで他に使えそうなシンボルを探し続けていると、mmap が目に留まった。

mmap の何が特別なのか。それは W^X(write-xor-execute) セキュリティポリシーのため、書き込み可能かつ実行可能なメモリ領域が存在しないからだ。だから裸の x86 命令をバッファに書き込んで関数のようにジャンプすることも、既存の関数を自分のコードで上書きすることもできない。だが mmap ならこの問題を解決できる。書き込みと実行の両方の権限を持つページを直接もらえるからだ。

だが待て。別の課題で、VM 上で printf と mmap の間のオフセットを漏らすことができる。その課題では両方のシンボルに名前を付けられる。このオフセットを使うにはキャストを山ほど並べればいいが、採点システムに提出するコードは -pedantic でコンパイルできなければならず、これは関数ポインタ型からの変換、関数ポインタ型への変換、関数ポインタ型同士の変換を許さない。関数ポインタも結局はポインタなので、コンパイラに気づかれない形で書き換えてみることはできる。俺は最初、最適化なしのスタックスロットを基にメモリをいじる方法 2 を書いたが、すぐに C では不安全さそのものが機能だと思い出した。だから実は union をそのまま使えばいい。

union notmmap_build {
  int (*basefn)(const char *);
  size_t addr;
  void *(*fn)(void *addr, size_t length, int prot, int flags, int fd,
              long offset);
};

union notmmap_build notmmap = {.basefn = printf};
notmmap.addr += OFFSET; // whatever offset you leaked earlier

Linux のシステムコールを C の関数を呼ぶのと同じ感覚で叩ける、ひどい x86 アセンブリのルーチンを書ける。このルーチンを使えば、最大 3 つの引数を持つ任意のシステムコールを呼び出せる。

000000000040106a <notsyscall>:
  40106a:	48 89 f8             	mov    %rdi,%rax
  40106d:	48 89 f7             	mov    %rsi,%rdi
  401070:	48 89 d6             	mov    %rdx,%rsi
  401073:	48 89 ca             	mov    %rcx,%rdx
  401076:	0f 05                	syscall
  401078:	c3                   	ret

このバイト列を、書き込み可能かつ実行可能な新しいマッピングにコピーする。

unsigned char instructions[] = {
  0x48, 0x89, 0xf8, 0x48, 0x89, 0xf7, 0x48, 0x89,
  0xd6, 0x48, 0x89, 0xca, 0x0f, 0x05, 0xc3,
};

char *code =
  notmmap.fn(NULL, 4096, PROT_READ | PROT_WRITE | PROT_EXEC,
             MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

あとは何事もなかったかのようにこのルーチンを呼ぶだけだ。Linux が期待する引数は chromium の syscall ドキュメントで確認できる。

char *argv[] = {
    "/bin/sh",
    "-c",
    "echo 'Hello, World!'",
    NULL,
};

union notsyscall_build {
  char *ptr;
  // execve is syscall 59 on x86-64 Linux
  int (*notexecve)(int sys, char *path, char **argv, char **envp);
};

union notsyscall_build notsyscall = {.ptr = code};

notsyscall.notexecve(59, argv[0], argv, NULL);

ターゲットの VM 上で実行すると、Hello, World! が無事に出力された。

完全な proof-of-concept は GitHub にある。

結論

コードが ASan でコンパイルされていると分かっているので、printf(常に許可される関数)と mmap(任意のアセンブリコードを実行できる関数)の間のオフセットをリークさせることにした。これで学校のチェックを回避し、任意のシステムコールを呼び出せる。今回のケースでは execve を呼んでシェルを取った。

それ以上は追わず、この潜在的な問題を学校に報告するにとどめた。

簡単な修正方法は思いつかない。見てきたとおり、ここでは ASLR は役に立たない。一案として、実行のたびに C 標準ライブラリを別の順序で再リンクし、関数の並び順を毎回ランダム化する手がある(OpenBSD は起動時にこれをやっている。詳しくはこちら)。ただし ASan は今も自前の関数で元の関数を置き換えることを忘れてはいけない(つまりここでは緩和策にならない)。

この exploit は複雑ではないが、自力で全体を解き明かすのはなかなか面白かった。こういう瞬間、以前に無駄にこだわっていた細かい知識がようやく一本につながって、先へ進ませてくれる。そもそもこの exploit が簡単なのは、かなり緩いリモートコード実行がもともと機能として用意されているからだ。とはいえ ASLR の回避も絡むし、何が起きているかをかなり理解している必要もある。


学校の研究ラボによる講演(フランス語)が見られる。

この snippet もどうぞ。

© 2024-2026 Milo Moisson

タイポを見つけた?このページを編集 ✎

出典: wiro.world← ホームへ戻る