C言語で呼び出された関数が戻る先を書き換えることでセキュリティを突破できる は話としてはよく知られているが、その対策としてのコンパイラオプションとして何が有効か調べた。C言語のソースファイルと生成されるアセンブラの対応関係を見るためには Hello,worldをちょっとだけよく見てみる にあるように objdump -S
を用いるのが良い。
戻り先を書き換えるCプログラム
#include <stdint.h> // uintptr_t の定義を読み込んでいる
#include <stdio.h>
#include <stdlib.h>
__attribute__ ((noinline)) int f(void); // プロトタイプ宣言
int main(void){ return f()+f(); /* 最適化したときにf()の呼び出しを削除されないための細工 */ }
void g(void) {
puts("この関数g()はどこからも呼び出されていない❣");
_Exit(0); // プログラムを直ちに終了させる標準関数
}
__attribute__ ((noinline)) int f(void)
{ // uintptr_t は関数を含むアドレスを格納するために十分な幅を持つ符号なし整数型
// volatile はコンパイル最適化により読み書きを省略することを禁止する
volatile uintptr_t * volatile p;
for (p=(uintptr_t *)&p-20 ; p<(uintptr_t *)&p+20; ++p) {
// 関数fが終わったときに戻るアドレスは関数mainのアドレスより少し大きい値なので次行で判定する
if ((uintptr_t)main < *p && *p < 64+(uintptr_t)main) {
printf("関数f()が戻るアドレスは *(&p %+td) == main + %tu\n", p-(uintptr_t *)&p, *p-(uintptr_t)main);
// 関数fが終わったときに、関数g()の先頭に戻るような細工を次行でしている
*p = (uintptr_t)g;
}
}
return rand();
}
これをgcc (Ubuntu 11.4.0-1ubuntu1~22.04)
でコンパイルしを実行すると以下のようになる:
$ gcc -O2 ror.c
$ ./a.out
関数f()が戻るアドレスは *(&p +9) == main + 10
この関数g()はどこからも呼び出されていない❣
AArch64 (ARM 64-bit)では関数戻り先の保存場所が変数よりも小さいアドレスに格納される
このような場合、入力を工夫してバッファーオーバーフローを引き起こすことで関数の戻り先を操作すること は相当に難しくなる。
# gcc -dumpmachine
aarch64-linux-gnu
# gcc --version
gcc (Debian 14.2.0-17) 14.2.0
Copyright (C) 2024 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
# gcc -O2 ror.c
# ./a.out
関数f()が戻るアドレスは *(&p -8) == main + 16
この関数g()はどこからも呼び出されていない❣
clang も同様である:
# clang --version
Debian clang version 19.1.7 (3)
Target: aarch64-unknown-linux-gnu
Thread model: posix
InstalledDir: /usr/lib/llvm-19/bin
# clang -O2 ror.c
# ./a.out
関数f()が戻るアドレスは *(&p -2) == main + 16
この関数g()はどこからも呼び出されていない❣
戻り先書き換えを防がないコンパイラオプション
CおよびC++のコンパイラ・オプション強化ガイド に従ってコンパイルしてみる。-fstrict-flex-arrays=3
はコンパイラが対応しておらず、-shared
は実行可能ファイルを作成するときには使えないため外している:
$ gcc -O2 -Wall -Wformat=2 -Wconversion -Wtrampolines -Wimplicit-fallthrough -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 -D_GLIBCXX_ASSERTIONS -fcf-protection=full -fstack-clash-protection -fstack-protector-all -Wl,-z,nodlopen -Wl,-z,noexecstack -Wl,-z,relro -Wl,-z,now -fPIE -pie -fPIC ror.c
$ ./a.out
関数f()が戻るアドレスは *(&p +5) == main + 30
この関数g()はどこからも呼び出されていない❣
コンパイラをclang Ubuntu clang version 14.0.0-1ubuntu1.1
に換えても戻り先の書き換えを防ぐことはできない。
clang -fsanitize=safe-stack
で防ぐことができる
$ clang -g -fsanitize=safe-stack ror.c
$ ./a.out
Segmentation fault (コアダンプ)
gdbを用いてどこでsegmentation faultが起きているか調べると:
$ gdb a.out
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1
(途中略)
(gdb) run
(途中略)
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555ad8 in f () at ror.c:20
20 if ((uintmax_t)main < *p && *p < 64+(uintmax_t)main) {
(gdb) print p
$1 = (volatile uintmax_t *) 0x7ffff7b90000
(gdb) print &p
$2 = (volatile uintmax_t **) 0x7ffff7b8fff8
ポインター p
の次のアドレスをアクセスした瞬間にsegmentation faultしていることがわかる。なお fsanitize=safe-stack
はclangでは利用できるがgccでは利用できない。
gcc -fsanitize=undefined
では防ぐことができない
$ gcc -O2 -fsanitize=undefined ror.c
$ ./a.out
関数f()が戻るアドレスは *(&p +9) == main + 16
この関数g()はどこからも呼び出されていない❣
gcc -fsanitize=address
でも戻り先書き換えを防ぐことができる
safe-stackよりも性能低下が大きいがgccでも利用できる防御法には-fsanitize=address
がある。
$ gcc -g -O2 -fsanitize=address ror.c
$ ./a.out
=================================================================
==1770281==ERROR: AddressSanitizer: stack-buffer-underflow on address 0x7e5df1409000 at pc 0x579b3f22c51b bp 0x7ffea2a06470 sp 0x7ffea2a06460
READ of size 8 at 0x7e5df1409000 thread T0
#0 0x579b3f22c51a in f /tmp/ror.c:20
#1 0x579b3f22c209 in main /tmp/ror.c:7
#2 0x7e5df4c29d8f in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
#3 0x7e5df4c29e3f in __libc_start_main_impl ../csu/libc-start.c:392
#4 0x579b3f22c274 in _start (/tmp/a.out+0x1274)
Address 0x7e5df1409000 is located in stack of thread T0 at offset 0 in frame
#0 0x579b3f22c37f in f /tmp/ror.c:14
This frame has 1 object(s):
[32, 40) 'p' (line 16)
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
(longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-underflow /tmp/ror.c:20 in f
Shadow bytes around the buggy address:
0x0fcc3e2791b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0fcc3e2791c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0fcc3e2791d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0fcc3e2791e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0fcc3e2791f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0fcc3e279200:[f1]f1 f1 f1 00 f3 f3 f3 00 00 00 00 00 00 00 00
0x0fcc3e279210: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0fcc3e279220: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0fcc3e279230: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0fcc3e279240: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x0fcc3e279250: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
Shadow gap: cc
==1770281==ABORTING