原文: CWE - CWE-121: Stack-based Buffer Overflow (4.4)
脆弱性の概要
スタック上のバッファが上書きされる脆弱性。
実行時のスタックには任意のコードを実行できる要因となり得るいくつかの重大なデータが存在している。最も有名なのはstored return addressで、return addressとは、現在実行中の関数が実行を終えた後にその次に実行されるべきメモリのアドレスである。攻撃者はその値を「実行したい任意のコードを置く用の、書き込むことのできるメモリ」のアドレスの値へと書き換える。もしくは、スタック上の引数を呼び出す関数用に調整するとともに、POSIXのsystem()のような重大な関数のアドレスへと書き換える。後者の攻撃は、呼び出し元に戻る際プログラムにlibcのルーチンにジャンプするよう強いることから、「a return into libc exploit」と呼ばれる。他に重要なスタック上のデータとしてstack pointerとframe pointerがあり、これら二つの値はスタックをコンピュータが操作するためのオフセットである。この二つの値を特定することは「write-what-where」1状態を導く一助となることがある。
関連する脆弱性
child of
TODO:上の脆弱性の内容もブログにまとめてその記事のリンクをここに貼る
脆弱性が生まれるタイミング
- 実装段階
プラットフォーム
- C
- C++
この脆弱性によって起こるセキュリティの侵害
大抵バッファオーバーフローはプログラムをクラッシュさせる。他にもプログラム中に無限ループを作るなど、可用性を侵害する攻撃を生み出し得る。また、バッファオーバーフローによって、プログラムの暗黙のセキュリティポリシーの範囲を超えたところで任意のコードを実行できるようになることがある。任意コード実行ができると他のセキュリティ機構も侵害し得る。
対策
- ビルドしてコンパイルする際にバッファオーバーフローを検知して改善したり除去したりするようなコンパイラやその拡張機能を使う。例えばthe Microsoft Visual Studioの/GSオプション、Fedora/Red HatのFORTIFY_SOURCEオプション、StackGuard、ProPoliceなど。
- 抽象ライブラリをつかってリスキーなライブラリを取り除く。(根本的な解決方法ではない)
- StackGuard、ProPolice、Microsoft Visual Studioの/GSオプション等のコンパイラベースのカナリー機構を取り入れる。(自動で境界をチェックするだけなので完璧な対策ではない)
- 入力に対する境界のチェックを実装して機能させる。
- getsのような脆弱な関数を使わず境界チェック機能のある関数を使う。
- ASLRのようなOS-levelでのセキュリティ機構を使う。(根本的な解決方法ではない)
脆弱性の再現
ubuntu20.04にて検証を行いました。
//sample1.c #include <stdio.h> #include <stdlib.h> #define BUFSIZE 256 void win() { system("cat flag.txt"); } int main(int argc, char **argv) { char buf[BUFSIZE]; gets(buf); return 0; }
gets()はNULL文字が読み込まれるまで際限なくバッファに入力を格納してしまうのでバッファのサイズを超えてスタックに書き込むことができてしまいます。他にもscanf("%s",)もほぼ同じ性質を持っています。まずはバッファオーバーフローの触りとしてこのプログラムの実行をwin()に移して同じディレクトリに作ったflag.txtを読み込むことにチャレンジします。問題の単純化のために様々なセキュリティ機構をオフにします。尚、このwin関数という表記はpwnable.xyzを参考にしましたが、プログラム自体は自作です。
$ gcc sample1.c -o sample1 -no-pie -fno-stack-canary
checksec.shをつかって念の為確認すると
RELRO STACK CANARY NX PIE Partial RELRO No canary found NX enabled No PIE
canaryとPIEがオフになっています。
さて、スタックはアドレスの高いところから低いところへと伸長していくメモリ領域です。引数がスタックに積まれた後、call命令で呼び出す関数のアドレスにジャンプするときに、命令を実行する時のレジスタripの値をスタックに格納してからジャンプします。当然rspの値は8bytes分小さくなります。次に、飛んだ先の関数でpush rbp、つまりrbpの値をスタックに保存します。このときもrspの値は8bytes分小さくなります。次にrbpにrspの値を代入します。その後、飛んだ先の関数のローカル変数用の領域をrspの値を更に減ずることで作り今後はそこに値を出し入れするわけです。今回のgets用のバッファはmain関数のローカル変数領域にあります。つまり、スタック上ではアドレスの高い方から、main関数を実行する直前のripの値8bytes、同じく直前のrbpの値8bytes、256bytesのバッファ、という順番に並んでいます。これを順にstored return address、stored rbp value、bufferと呼びます。
関数を終了する際は、逆の手順が行われます。rspの値を足してローカル変数領域をスタックから消して(入ってる値そのものは消えませんがスタック上のデータとしてアクセスすることはできない状態にする)、pop rbpでスタックからrbpの値を復元し、その次にスタックに入っているはずの8bytesの値をstored return addressとして認識し、ret命令によってそのアドレスに実行を移すわけです。これら一連の流れはまさにアセンブラのコードそのもので、gdbやradare2などを使って確認することができます。
ということはret命令を実行する前にそのstored return addressの値を任意の値に書き換えてしまえば、任意のアドレスに実行を移せる筈です。その書き換えを引き起こす一つの手段が本題のバッファーオーバーフローです。バッファオーバーフローはバッファとして用意した領域を乗り越えて値を書き込んでしまいます。x86_64では溢れ出した値はスタック上のバッファより高位アドレスを書き換えます。つまりバッファに257bytesをいれると、257bytes目の値はstored rbp valueのLSB(ここでは最下位バイトの意)を書き換えてしまうのです。また、特別なセキュリティ機構を設けない限りこれを検知することはできません。スタックは関数の呼び出しに応じて変化し、プログラム実行中に値の書き込みが頻繁に行われているので、書き込み不可領域は普通は存在しないからです。
さてそれでは実際にstored return addressを書き換える入力を送ってみましょう。256+8bytes入力することでstored rbp valueまで書き換えられ、その次に意味をなすアドレスの値を8bytes送ればメイン関数からretする時に、その値をreturn addressとして解釈するはずです。リトルエンディアンであることに注意し、また、win関数の中でsystem関数が呼ばれていてsystem関数ではmovaps命令が呼ばれているので、スタックを16bytesでalignする必要があります。まずreadelfコマンドでwin関数のアドレスを確認します。
$ readelf -s sample1
今回はwin関数のアドレスは0x401156でした。もしスタックが16bytesでalignされている状態であれば
$ python2 -c 'print "a"*264 + "\x56\x11\x40\x00\x00\x00\x00\x00" ' | ./sample1
としてflag.txtの中身が表示されるはずですが、segmentation faultで落ちました。ということで、途中にret命令しかないアドレスを挟むことでスタック上を16bytesにalignします。つまりスタック上にはstored return addressが2つ続けて積まれている状態を作り、最初のstored return addressはret命令の入っているどこかの領域のアドレスを入れます。そうするとret命令→ret命令→任意のアドレス(今回はwin関数のアドレス)という順番になり、まわりまわってsystem関数の中のmovaps対象の値のスタック上の位置も8bytesずれて16bytesでalignされているアドレスになるはずです。今回、メイン関数のret命令のアドレスはradare2で確認すると0x4011a3でした。最終的には
$ python2 -c 'print "a"*264 + "\xa3\x11\x40\x00\x00\x00\x00\x00" + \x56\x11\x40\x00\x00\x00\x00\x00" ' | ./sample1
とすればflag.txtの値を見ることができるはずです。尚、python2を使っているのは文字列とバイト列を足す際、python3だと型が違うと怒られてしまうからです。
対策に対する実験
それでは、紹介されている対策を順に実行して、それに弱点はないのか見ていこうと思います(ほんのちょっとしかやっていません。)。まず、FORTIFY_SOURCEオプションを使ってみます。どうやらFORTIFY_SOURCEオプションは最適化オプションと一緒に使うみたいです。(以下のURLを参照)
Man page of FEATURE_TEST_MACROS
$ gcc sample1.c -o sample1-1 -fno-stack-protector -no-pie -D_FORTIFY_SOURCE=2 -O1 -Wall
結果としてはオプションをつけていないときとつけたときでコンパイル時の警告の内容はあまりかわりませんでした(と、この時点では思っていました)。gets()がやばい関数だというのは有名で、なんのオプションを指定せずともgetsを使うなと言う警告が出ます。gets()の代わりにscanf()を使うとFORTIFY_SOURCEオプションをつけている時に新たに警告が出ましたが、それもscanfの返り値を使っていないというよくある警告でした。この場合はあまり関係ないですが(と、この時点では思っていました)、system関数の返り値も使っていないという警告も出ていました。バッファオーバーフローに関する警告は特になく、このオプションをつけた後もバッファオーバーフローの脆弱性そのものは実行することができました。しかし、上のURLのmanページによると
_FORTIFY_SOURCE が 1 に設定された場合、コンパイラの最適化レベルが 1 (gcc -O1) かそれ以上であれば、規格に準拠するプログラムの振る舞いを 変化させないようなチェックが実行される。 _FORTIFY_SOURCE が 2 に設定された場合、さらなるチェックが追加されるが、 規格に準拠するプログラムのいくつかが失敗する可能性がある。 いくつかのチェックはコンパイル時に実行でき、コンパイラの警告として 表示される。他のチェックは実行時に行われ、チェックに失敗した場合 には実行時エラーとなる。
とあるので何かしらのチェックが走っているはずですので、それがどのようなチェックなのか確認します。woboqのglibcを見てみました。
userspace/glibc/ Source Tree - Woboq Code Browser
_FORTIFY_SOURCEが2で設定されているとき、__USE_FORTIFY_LEVELが2で定義されます。この__USE_FORTIFY_LEVELが使われている箇所はglibcでは74箇所あり(2021/3月現在)それをすべて調べれば何を検出するのかわかるはずです。
<stdlib/bits.stdlib.h>を理解してみる
たとえば<stdlib.h>のなかでは、_USE_FORTIFY_LEVEL>0かつ__fortify_functionが定義されていたとき、<bits/stdlib.h>をincludeし、結果として<stdlib/bits/stdlib.h>がincludeされるようです。
__fortify_functionは、ファイル中で#define __fortify_functionと定義すると、
sample1.c:3: warning: "__fortify_function" redefined 3 | #define __fortify_function | In file included from /usr/include/features.h:461, from /usr/include/x86_64-linux-gnu/bits/libc-header-start.h:33, from /usr/include/stdio.h:27, from sample1.c:1: /usr/include/x86_64-linux-gnu/sys/cdefs.h:356: note: this is the location of the previous definition 356 | # define __fortify_function __extern_always_inline __attribute_artificial__
と表示されるのでstdio.h等ですでにdefineされているようです。
そして<stdlib/bits/stdlib.h>中でいくつかの関数がバッファオーバーフローを検知する機能を備えた関数へと変化しています。これらの関数の変化を理解するには__REDIRECT_NTH()と__NTH()という関数の仕様を知る必要があるようです。__REDIRECT_NTH()はglibc/misc/sys/cdefs.hの中で定義されていて
/* __asm__ ("xyz") is used throughout the headers to rename functions at the assembly language level. This is wrapped by the __REDIRECT macro, in order to support compilers that can do this some other way. When compilers don't support asm-names at all, we have to do preprocessor tricks instead (which don't have exactly the right semantics, but it's the best we can do). #if defined __GNUC__ && __GNUC__ >= 2 # define __REDIRECT(name, proto, alias) name proto __asm__ (__ASMNAME (#alias)) # ifdef __cplusplus # define __REDIRECT_NTH(name, proto, alias) \ name proto __THROW __asm__ (__ASMNAME (#alias)) # else # define __REDIRECT_NTH(name, proto, alias) \ name proto __asm__ (__ASMNAME (#alias)) __THROW # endif
となっています。 __THROWというのは
For C++ programs we add throw() to help it optimize the function calls. # if !defined __cplusplus && __GNUC_PREREQ (3, 3) # define __THROW __attribute__ ((__nothrow__ __LEAF)) # define __NTH(fct) __attribute__ ((__nothrow__ __LEAF)) fct # else # if defined __cplusplus && __GNUC_PREREQ (2,8) # define __THROW throw () # define __NTH(fct) __LEAF_ATTR fct throw () # else # define __THROW # define __NTH(fct) fct # endif # endif
とあるようにC++のコンパイラ用のマクロのようです。同じところに__NTH()も定義されていました。注意深くコードを読んでいくと、__REDIRECT_NTH()は、既に定義された関数を、バイナリレベルでは同じコードでありながらも別のシンボル名と別の引数で扱うようコンパイラに教える関数のようです。__NTH()はある関数が呼ばれたときに何か別の関数を代わりに使うようにしたいときの場合分けに使うみたいです。
最終的にはglibc/misc/sys/cdefs.hを次のように解釈できました。
realpath()が呼ばれたとき
「__bos(__resolvedというバッファ) == (size_t)-1」ならばrealpath()をそのまま呼ぶ
「_LIBC_LIMITS_H_とPATH_MAXが定義されていて、__bos(__resolved) < PATH_MAX」ならば、__realpath_chk_warn()を呼ぶ
__realpath_chk()を呼ぶ
(__realpath_chk_warn()の__realpath_chk()と違うところは__THROWでないこと)
どの関数を呼ぶにせよ__USE_FORTIFY_LEVEL>0のとき、__wurがその関数に__warn_unused_result__属性を付加している。
__bos(ptr)は__builtin_object_size (ptr, __USE_FORTIFY_LEVEL > 1)とあり
Object Size Checking (Using the GNU Compiler Collection (GCC))
に仕様が書いてある。
__realpath_chk()では第3引数であるresolved_lenが第2引数のresolved_pathのサイズであり、これが、PATH_MAXよりも小さいと__chk_fail()が呼ばれる。これは直感的には理解できなかったが、確かにPATH_MAXより小さいとPATH_MAXの長さのパスネームを入れられたときに困る。PATH_MAXが存在しないときは実際に__realpath()を呼んでみて、取られるバッファのサイズが十分に大きいか確認している。そして、最終的にはどれも、__realpath() の実行結果を返す。
場合分けや__wurが付加されているかどうかなど細部は異なるものの、realpath()と同様にptsname_r()、mbstowcs()、wcstombs()が呼ばれるときも*_chk()や*_chk_warn()が呼ばれたりする。
以上のことを理解すると__USE_FORTIFY_LEVELが使われている74箇所は、他で呼ばれているバッファを使う何らかの関数(例えば、__realpath())を実行する前にそのバッファのサイズを確かめる処理をしているのでは?という仮説が立ちます。また、特定の関数には__wurが付加されていて、返り値を使わないとwarningを吐くようになっていると思われます。
どの関数がバッファをチェックするようになり、返り値を使うよう警告するのか(TODOです。ごめんなさい)
それらを全て確かめてみましょう。 以下の条件で、それぞれのヘッダファイルを追加でincludeするようです。
- __USE_FORTIFY_LEVEL > 0
#include <bits/setjmp2.h>
- __USE_FORTIFY_LEVEL > 0 && defined __fortify_function && defined __va_arg_pack_len
#include <bits/fcntl2.h>
#include <bits/mqueue2.h>
- __USE_FORTIFY_LEVEL > 0 && defined __fortify_function
#include <bits/poll2.h>
#include <bits/stdio2.h>
#include <bits/syslog.h>
#include <bits/stdlib.h>
#include <bits/unistd.h>
#include <bits/wchar2.h>
#include <bits/socket2.h>
- __USE_FORTIFY_LEVEL > 0 && defined __GNUC__
#include <bits/select2.h>
- __GNUC_PREREQ (3,4) && __USE_FORTIFY_LEVEL > 0 && defined __fortify_function
#include <bits/strings_fortified.h>
TODO: 次に、各ヘッダファイルの中でどのような関数のバッファをチェックしているのか確認します。
さて、glibcの中ではどうなっているかみたところで実際にコードを書いて確かめようとおもったのですが、ここで次のようなブログを見つけました。
__attribute__(alloc_size) を使わないと_FORTIFY_SOURCE を活かせないよ。という話 : 革命の日々 その2
ここで、同じことをやるのも冗長なのでリンクを貼るだけにします。見にいってください。kosakiさん、勉強になりました。ありがとうございます。
-D-FORTIFY-SOURCE=2とすれば大半のことは解決するはずですし、何か新しい攻撃方法を知ることができたわけではないですが、自作の関数でバッファを利用するときによりセキュアなコードを書けるようになったと思います。
glibcのような巨大なプログラムを読むのは初めてで、尚且つ他作のプログラムを読むのにも慣れておらず疲れてしまったので、一旦ここでこの記事は休憩します。マクロやポインタが入り乱れていて非常に読みにくいプログラムでしたが、次に読むときにはもっとスムーズに理解できると信じています。気が向いたらStackGuardやProPliceも使ってみたり回避の方法を探ってみたりして、記事を更新します。後kosakiさんのブログにあった簡易的なプログラムの実験が非常に分かりやすかったので、見た方が良いですよ。URLを再喝しておきます。
__attribute__(alloc_size) を使わないと_FORTIFY_SOURCE を活かせないよ。という話 : 革命の日々 その2
TODO: StackGuard、 ProPolice、/GSオプション、 canary、ASLR