スタックプロテクターは alloca 割り当てを使うプログラムも守ってくれるか?

先だって同僚からそんな疑問を受け、気になっていたので調べた。結論から言うと、守ってくれる。
そもそもスタックプロテクターは、関数呼び出し時にスタックフレーム内の戻りアドレスの直上にカナリア変数を置き、呼び出し元に返る前にカナリア変数がオーバーフローによって破壊されていないことを確かめるという実装だから(つまりカナリア変数が壊れていたら戻りアドレスも壊されているだろうから、そこへ ret してはいけないと判断する)、スタックが可変長だろうと関係なく守ってくれるっていうわけ。

というわけでサンプルコード。

/* stack-protector.c */
#include <stdlib.h> /* for alloca */
#include <memory.h> /* for memset */

void f()
{
  memset(alloca(1), 0x90, 56);
}

int main(void)
{
  return f(), 0;
}

コンパイルして実行すると確かに処理を中断してくれる:

$ gcc -O2 -fstack-protector -o stack-protector stack-protector.c
$ ./stack-protector
*** stack smashing detected ***: stack-protector terminated
^@Illegal instruction
$ 

アセンブルリストを眺めてみよう (gcc -O2 -fstack-protector -S stack-protector.c で生成):

        .file   "stack-protector.c"
        .text
        .p2align 4,,15
.globl f
        .type   f, @function
f:
        pushl   %ebp
        movl    $56, %ecx
        movl    %esp, %ebp
        subl    $56, %esp
        movl    __stack_chk_guard, %eax
        movl    %eax, -4(%ebp)
        xorl    %eax, %eax
        leal    27(%esp), %eax
        movl    $144, %edx
        andl    $-16, %eax
        movl    %ecx, 8(%esp)
        movl    %edx, 4(%esp)
        movl    %eax, (%esp)
        call    memset
        movl    -4(%ebp), %eax
        xorl    __stack_chk_guard, %eax
        jne     .L5
        leave
        ret
.L5:
        .p2align 4,,6
        call    __stack_chk_fail
        .size   f, .-f
        .p2align 4,,15
.globl main
        .type   main, @function
main:
        leal    4(%esp), %ecx
        andl    $-16, %esp
        pushl   -4(%ecx)
        pushl   %ebp
        movl    %esp, %ebp
        pushl   %ecx
        subl    $4, %esp
        call    f
        xorl    %eax, %eax
        popl    %edx
        popl    %ecx
        popl    %ebp
        leal    -4(%ecx), %esp
        ret
        .size   main, .-main
        .ident  "GCC: (GNU) 4.1.2 20061115 (prerelease) (Debian 4.1.1-21)"
        .section        .note.GNU-stack,"",@progbits

関数ラベル f 以降の 5, 6 行目で eax レジスターを経由して __stack_chk_guard 変数の値 (カナリア) をフレームポインター (ebp レジスターに格納されている値) から -4 バイトオフセットしたアドレスに格納している。
その後引数などを設定して 14 行目で memset を呼びだす。それから 15 行目でフレームポインターの隣のアドレスに退避したカナリア値を eax レジスターに取り出し、もとのカナリア値と xor する。
xor は同値確認の常套手段で、同じ値で xor をとると 0 になる性質を利用するもの。 17 行目はゼロフラグによる条件ジャンプ命令。直前の xor 命令でゼロになっていればゼロフラグがたつため leave ret で呼び出し元に戻り、そうでなければ __stack_chk_fail 関数を呼び出してアボート処理に移る。

gdb で実際に何が起きているか確認してみよう。
gdb でソースと対応をとるためには -g オプションをつけてコンパイルすること。

$ gdb stack-protector
...
(gdb) start
Breakpoint 1 at 0x8048491: file stack-protector.c, line 11.
Starting program: /misc/cf-users/i/ik/ikb/code/stack-protector
main () at stack-protector.c:11

プログラムカウンターが指しているのは関数ラベル f の呼び出し。

(gdb) x/2i $pc
0x8048491 <main+17>:    call   0x8048430 <f>
0x8048496 <main+22>:    xor    %eax,%eax

ステップ実行して f の逆アセンブルリストを出してみる。

(gdb) si
f () at stack-protector.c:5
(gdb) disas
Dump of assembler code for function f:
0x08048430 <f+0>:       push   %ebp
0x08048431 <f+1>:       mov    $0x38,%ecx
0x08048436 <f+6>:       mov    %esp,%ebp
0x08048438 <f+8>:       sub    $0x38,%esp
0x0804843b <f+11>:      mov    0x80496bc,%eax
0x08048440 <f+16>:      mov    %eax,0xfffffffc(%ebp)
0x08048443 <f+19>:      xor    %eax,%eax
0x08048445 <f+21>:      lea    0x1b(%esp),%eax
0x08048449 <f+25>:      mov    $0x90,%edx
0x0804844e <f+30>:      and    $0xfffffff0,%eax
0x08048451 <f+33>:      mov    %ecx,0x8(%esp)
0x08048455 <f+37>:      mov    %edx,0x4(%esp)
0x08048459 <f+41>:      mov    %eax,(%esp)
0x0804845c <f+44>:      call   0x8048350 <memset@plt>
0x08048461 <f+49>:      mov    0xfffffffc(%ebp),%eax
0x08048464 <f+52>:      xor    0x80496bc,%eax
0x0804846a <f+58>:      jne    0x804846e <f+62>
0x0804846c <f+60>:      leave
0x0804846d <f+61>:      ret
0x0804846e <f+62>:      mov    %esi,%esi
0x08048470 <f+64>:      call   0x8048360 <__stack_chk_fail@plt>
End of assembler dump.

カナリア値を設定しているのは で、カナリア値の格納されたアドレスが 0x80496bc だとわかる。中身を確認しよう。

(gdb) x/x 0x80496bc
0x80496bc <__stack_chk_guard@@LIBSSP_1.0>:      0xe9563252

6 行目までステップ実行して、 ebp レジスターの 4 バイトとなりのアドレスの値を確認する。たしかにカナリア値が退避されている。

(gdb) si 6
(gdb) x/i $pc
0x8048443 <f+19>:       xor    %eax,%eax
(gdb) x/x $ebp - 4
0xbfc4d8e4:     0xe9563252

ここで memset されるアドレス付近の値をダンプしてみよう。

(gdb) si 4
(gdb) x/20w $eax - 16
0xbfc4d8b0:     0x00000007      0x080496bc      0x00000004      0xb7e9bf1e
0xbfc4d8c0:     0xb7f3baf9      0xb7f65ff4      0x00000004      0x080483b0
0xbfc4d8d0:     0xb7f6c500      0x08049694      0xbfc4d8e8      0x0804832d
0xbfc4d8e0:     0xb7f8cff4      0xe9563252      0xbfc4d8f8      0x08048496
0xbfc4d8f0:     0xb7e43c8c      0xbfc4d910      0xbfc4d958      0xb7e4dea8

それから C ソースでの実行単位で 1 行分処理を進めて memset の呼び出しを完了し、もう一度付近の値をダンプする。
たしかに 0x90 で埋まっている。

(gdb) s
(gdb) x/20w $eax - 16
0xbfc4d8b0:     0xbfc4d8c0      0x00000090      0x00000038      0xb7e9bf1e
0xbfc4d8c0:     0x90909090      0x90909090      0x90909090      0x90909090
0xbfc4d8d0:     0x90909090      0x90909090      0x90909090      0x90909090
0xbfc4d8e0:     0x90909090      0x90909090      0x90909090      0x90909090
0xbfc4d8f0:     0x90909090      0x90909090      0xbfc4d958      0xb7e4dea8

そして ebp - 4 の値を確認すると、たしかに 0x90909090 で埋められていることがわかる (バッファオーバーラン)。

(gdb) x/x $ebp - 4
0xbfc4d8e4:     0x90909090

ここで実行を継続するとスタックスマッシングプロテクターに制御が移り、アボートする。

(gdb) cont
Continuing.
*** stack smashing detected ***: stack-protector terminated
^@
Program received signal SIGILL, Illegal instruction.
0xb7fd6c65 in ?? () from /usr/lib/libssp.so.0
(gdb) cont
Continuing.

Program terminated with signal SIGILL, Illegal instruction.
The program no longer exists.
(gdb)

蛇足ながら、オーバーランする前のダンプを解説:

0xbfc4d8e0:     0xb7f8cff4      0xe9563252      0xbfc4d8f8      0x08048496
                                ~~~~~~~~~~      ~~~~~~~~~~      ~~~~~~~~~~
                                canary val      frame ptr       ret addr

フレームポインターは呼び出しもとの関数のスタックのトップアドレスで、リターンアドレスが続く。 gdb 実行直後で二命令逆アセンブルしておいたけれど、 call の次の命令番地になっていることがわかる。