スマートフォン解析 top

TOP > タイガーチームセキュリティレポート > Buffer Overflow脆弱性の動的パッチの方法 - Part 3

タイガーチームセキュリティレポート

Buffer Overflow脆弱性の動的パッチの方法 - Part 3

今回は前回紹介したSystemTapを使ってbuffer overflow脆弱性にパッチを当てる方法を実装してみます。


-基本の考え方-

buffer overflowに対する攻撃は、対象となる関数の実行中にその関数の戻りアドレスを書き換えることでプログラムの実行フローを外部から変更するものなので関数の出入り口で戻りアドレスをチェックし、戻りアドレスが書き換えられていた場合にエラーとして処理すれば攻撃の成立をかなり難しくすることができます。

同様の考え方で実装されたSSP(Stack Smashing Protection)はすでに存在しており、あとで紹介するようにgcc等のコンパイラの機能として利用することができます。しかし今回は実行中のプログラムに動的にパッチをあてる方法を実装したいのでプログラムの実行ファイルそのものに対策を組み込む方法は利用できません。

そこで今回はSystemTapを利用して対象プロセスのメモリ空間を書き換えるという方法を選択します。

-SystemTapの実例-

実装に入る前に、buffer overflowが発生する際の状況をSystemTapを利用して確認しておきます。

前々回のコラムでみたように今回検証に使用しているnginx-1.3.9ではngx_http_read_discarded_request_body()という関数に脆弱性がありました。

ngx_http_read_discarded_request_body()のソースを下に再掲します。この関数では特殊なリクエストを処理すると下の609行目でbuffer overflowが発生して戻りアドレスが書き換えられ、630行目で呼び出し元の関数にreturnするタイミングで関数の制御が奪われていました。

 580 static ngx_int_t
 581 ngx_http_read_discarded_request_body(ngx_http_request_t *r)
 582 {
 583     size_t     size;
 584     ssize_t    n;
 585     ngx_int_t  rc;
 586     ngx_buf_t  b;
 587     u_char     buffer[NGX_HTTP_DISCARD_BUFFER_SIZE];
 588
 589     ngx_log_debug0(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
 590                    "http read discarded body");
 591
 592     ngx_memzero(&b, sizeof(ngx_buf_t));
 593
 594     b.temporary = 1;
 595
 596     for ( ;; ) {
 597         if (r->headers_in.content_length_n == 0) {
 598             r->read_event_handler = ngx_http_block_reading;
 599             return NGX_OK;
 600         }
 601
 602         if (!r->connection->read->ready) {
 603             return NGX_AGAIN;
 604         }
 605
 606         size = (size_t) ngx_min(r->headers_in.content_length_n,
 607                                 NGX_HTTP_DISCARD_BUFFER_SIZE);
 608
 609         n = r->connection->recv(r->connection, buffer, size);
 610
 611         if (n == NGX_ERROR) {
 612             r->connection->error = 1;
 613             return NGX_OK;
 614         }
 615
 616         if (n == NGX_AGAIN) {
 617             return NGX_AGAIN;
 618         }
 619
 620         if (n == 0) {
 621             return NGX_OK;
 622         }
 623
 624         b.pos = buffer;
 625         b.last = buffer + n;
 626
 627         rc = ngx_http_discard_request_body_filter(r, &b);
 628
 629         if (rc != NGX_OK) {
 630             return rc;
 631         }
 632     }
 633 }
http/ngx_http_request_body.c

まずは、関数の入口と出口で関数の実行コンテキストがどうなっているか確認してみます。下のSystemTapスクリプトを使用します。

#!/bin/stap

probe process("/usr/local/nginx-1.3.9/sbin/nginx").function("ngx_http_read_discarded_request_body") {
        printf("** function %s called **\n\n", probefunc());
        print_ubacktrace();
        print("\n");
}

probe process("/usr/local/nginx-1.3.9/sbin/nginx").statement("*@http/ngx_http_request_body.c:630") {
        printf("** returning from %s **\n\n", probefunc());
        print_ubacktrace();
        print("\n");
}
print_bktrace.stp

このスクリプトでは2つのprobe handlerを定義しています。1つ目は関数の入口(呼び出し直後)に実行されるprobe handlerで、2つ目は関数の出口(630行目、return直前)で実行されるprobe handlerです。以下に実行結果を示します。

まず端末A上でスクリプトを実行しておきます。

[root@fedora32 stap]# stap print_bktrace.stp

つぎに別の端末B上でnginxに下のHTTPリクエストを送信してbuffer overflowを発生させてみます。

[acruel@fedora32 ~]$ perl -e 'print "GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n" . "a"x961 . "b"x4096 . "c"x64 . "AAAA"' | nc -n -v 127.0.0.1 80
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Connected to 127.0.0.1:80.
Ncat: 5188 bytes sent, 0 bytes received in 0.04 seconds.
[acruel@fedora32 ~]$

端末Aに戻って結果を確認すると、以下のように出口では関数の実行コンテキストが書き換えられngx_http_read_discarded_request_body()は0x8079d84にreturnすべきなのに0x41414141("AAAA")にreturnしようとしていることがわかります。

[root@fedora32 stap]# stap print_bktrace.stp
** function ngx_http_read_discarded_request_body called **

 0x8078cee : ngx_http_read_discarded_request_body+0x0/0xdf [/usr/local/nginx-1.3.9/sbin/nginx]
 0x8079d84 : ngx_http_discard_request_body+0xd6/0x132 [/usr/local/nginx-1.3.9/sbin/nginx]
 0x8084b60 : ngx_http_static_handler+0x344/0x524 [/usr/local/nginx-1.3.9/sbin/nginx]
 0x806e8cd : ngx_http_core_content_phase+0x3e/0x13a [/usr/local/nginx-1.3.9/sbin/nginx]
 0x806a083 : ngx_http_core_run_phases+0x23/0x3f [/usr/local/nginx-1.3.9/sbin/nginx]
 0x806a197 : ngx_http_handler+0xf8/0xfc [/usr/local/nginx-1.3.9/sbin/nginx]
 0x806f56f : ngx_http_internal_redirect+0x13a/0x145 [/usr/local/nginx-1.3.9/sbin/nginx]
 0x80857c0 : ngx_http_index_handler+0x7c0/0x840 [/usr/local/nginx-1.3.9/sbin/nginx]
 0x806e8cd : ngx_http_core_content_phase+0x3e/0x13a [/usr/local/nginx-1.3.9/sbin/nginx]
 0x806a083 : ngx_http_core_run_phases+0x23/0x3f [/usr/local/nginx-1.3.9/sbin/nginx]
 0x806a197 : ngx_http_handler+0xf8/0xfc [/usr/local/nginx-1.3.9/sbin/nginx]
 0x8072f53 : ngx_http_process_request+0x91/0xa0 [/usr/local/nginx-1.3.9/sbin/nginx]
 0x8073559 : ngx_http_process_request_headers+0x5f7/0x5ff [/usr/local/nginx-1.3.9/sbin/nginx]
 0x8073927 : ngx_http_process_request_line+0x3c6/0x4ae [/usr/local/nginx-1.3.9/sbin/nginx]
 0x807166c : ngx_http_init_request+0x40a/0x426 [/usr/local/nginx-1.3.9/sbin/nginx]
 0x8066903 : ngx_epoll_process_events+0x204/0x28a [/usr/local/nginx-1.3.9/sbin/nginx]
 0x805f48b : ngx_process_events_and_timers+0x8e/0xff [/usr/local/nginx-1.3.9/sbin/nginx]
 0x80654c3 : ngx_worker_process_cycle+0xb3/0x1a1 [/usr/local/nginx-1.3.9/sbin/nginx]
 0x8063d78 : ngx_spawn_process+0x472/0x58a [/usr/local/nginx-1.3.9/sbin/nginx]
 0x8065dcc : ngx_master_process_cycle+0x436/0x8d9 [/usr/local/nginx-1.3.9/sbin/nginx]

** returning from ngx_http_read_discarded_request_body **

 0x8078dc3 : ngx_http_read_discarded_request_body+0xd5/0xdf [/usr/local/nginx-1.3.9/sbin/nginx]
 0x41414141

-プロトタイピング-

プロトタイプとしてbuffer overflowによる戻りアドレスの書き換えを検知する単純なSystemTapスクリプトを書いてみます。

下のスクリプトは関数の入口での戻りアドレスをret_iにコピーし、出口での戻りアドレスをret_oにコピーし、ngx_http_read_discarded_request_body()がreturnする前にret_iとret_oを比較します。スタック上にある関数の戻りアドレスをコピーするためにCで記述された関数get_saved_eip()を定義しています。

#!/bin/stap

global ret_i = 0;
global ret_o = 0;

function get_saved_eip:long(addr:long)
%{
        unsigned int ret;
        unsigned int addr = (unsigned int)STAP_ARG_addr;
        if (copy_from_user(&ret, (const void*)addr, 4)) {
                _stp_printf("error copying from user space\n");
                STAP_RETVALUE = -1;
        } else {
                STAP_RETVALUE = ret;
        }
%}

probe process("/usr/local/nginx-1.3.9/sbin/nginx").function("ngx_http_read_discarded_request_body") {
        printf("** function %s called **\n\n", probefunc());
        ret_i = get_saved_eip(register("esp"));
        printf("  saved return address: 0x%08x\n\n", ret_i);
}

probe process("/usr/local/nginx-1.3.9/sbin/nginx").statement("*@http/ngx_http_request_body.c:630") {
        printf("** returning from %s **\n\n", probefunc());
        ret_o = get_saved_eip(register("esp") + 0x105c);
        printf("  saved return address: 0x%08x\n\n", ret_o);

        if (ret_i != 0 && ret_i != ret_o) {
                print("	## return addresses mismatched! ##\n\n");
        }
}
check_retaddr.stp

早速実行してみましょう。まず端末A上でスクリプトをguruモードで実行しておきます。

[root@fedora32 stap]# stap -g check_retaddr.stp

つぎに別の端末B上で先ほどと同じHTTPリクエストを送信してbuffer overflowを発生させてみます。

[acruel@fedora32 ~]$ perl -e 'print "GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n" . "a"x961 . "b"x4096 . "c"x64 . "AAAA"' | nc -n -v 127.0.0.1 80
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Connected to 127.0.0.1:80.
Ncat: 5188 bytes sent, 0 bytes received in 0.04 seconds.
[acruel@fedora32 ~]$

端末Aに戻って結果を確認すると、"return addresses mismatched!"が表示されました。buffer overflowを検知しています。

[root@fedora32 stap]# stap -g check_retaddr.stp
** function ngx_http_read_discarded_request_body called **

  saved return address: 0x08079d84

** returning from ngx_http_read_discarded_request_body **

  saved return address: 0x41414141

## return addresses mismatched! ##

このスクリプトはうまく動作していますが、対策としては大きな抜け穴があります。

先ほどのprint_bktrace.stpの実行結果からも明らかですが、プロセスのスタック上には関数呼び出しのネストの深さの数だけ戻りアドレスが保存されています。攻撃者はbufferのもっとも近くにある戻りアドレス0x8079d84だけでなく0x8084b60や0x806e8cdを書き換えることでもプログラムの制御を奪うことができます。

以下はngx_http_read_discarded_request_body()の戻りアドレス0x08079d84を同じ値0x08079d84に上書きした上で呼び出し元のngx_http_discard_request_body()の戻りアドレス0x8084b60を攻撃者が指定した値0xdeadbeefに上書きするリクエストです。

[acruel@fedora32 ~]$ perl -e 'print "GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n" . "a"x961 . "b"x4096 . "c"x64 . "\x84\x9d\x07\x08" . "d"x28 . "\xef\xbe\xad\xde"' | nc -n -v 127.0.0.1 80
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Connected to 127.0.0.1:80.
Ncat: 5220 bytes sent, 0 bytes received in 0.04 seconds.
[acruel@fedora32 ~]$

デバッガで結果を確認すると、戻りアドレスが書き換えられてSegmentation faultが発生することがわかります。

Program received signal SIGSEGV, Segmentation fault.
0xdeadbeef in ?? ()
(gdb)

というわけで、check_retaddr.stpには致命的な欠点があることがわかりました、次にこのSystemTapスクリプトを改良してみます。

-改良版-

先ほどのcheck_retaddr.stpの欠点を修正したものが下のスクリプトです。

#!/bin/stap

global ret_i = 0;
global ret_o = 0;
global ret_r = 0;

function get_saved_eip:long(addr:long)
%{
        unsigned int ret;
        unsigned int addr = (unsigned int)STAP_ARG_addr;
        if (copy_from_user(&ret, (const void*)addr, 4)) {
                _stp_printf("error copying from user space\n");
                STAP_RETVALUE = -1;
        } else {
                STAP_RETVALUE = ret;
        }
%}

function set_saved_eip:long(addr:long, ret:long)
%{
        unsigned int ret = (unsigned int)STAP_ARG_ret;
        unsigned int addr = (unsigned int)STAP_ARG_addr;
        if (copy_to_user((void*)addr, &ret, 4)) {
                _stp_printf("error copying to user space\n");
                STAP_RETVALUE = -1;
        } else {
                STAP_RETVALUE = ret;
        }
%}

probe process("/usr/local/nginx-1.3.9/sbin/nginx").function("ngx_http_read_discarded_request_body") {
        printf("** function %s called **\n\n", probefunc());
        ret_i = get_saved_eip(register("esp"));
        printf("  saved return address: 0x%08x\n\n", ret_i);
        ret_r = randint(0x100);
        for (i = 0; i < 3; i++) {
                ret_r = 0x100 * ret_r + randint(0x100);
        }
        set_saved_eip(register("esp"), ret_r);
}

probe process("/usr/local/nginx-1.3.9/sbin/nginx").statement("*@http/ngx_http_request_body.c:630") {
        printf("** returning from %s **\n\n", probefunc());
        ret_o = get_saved_eip(register("esp") + 0x105c);
        printf("  saved return address: 0x%08x\n\n", ret_o);

        if (ret_o != 0 && ret_r != ret_o) {
                printf("## return addresses mismatched! expected: 0x%08x ##\n\n", ret_r);
        } else {
                set_saved_eip(register("esp") + 0x105c, ret_i);
        }
}
check_retaddr_rand.stp

このスクリプトでは関数の入口で関数の戻りアドレスをランダム値に書き換え、関数の出口でランダム値が書き換えられていないかどうかチェックします。チェックの結果、値が書き換えられていなければ関数の戻りアドレスを元の値(ここでは0x08079d84)に書き戻します。スタック上の戻りアドレスを書き換えるためにCで記述された関数set_saved_eip()の定義を追加しています。

このスクリプトを実行するとngx_http_discard_request_body()の戻りアドレス0x8084b60を書き換えようとするリクエストを検知することができます。

[root@fedora32 stap]# stap -g check_retaddr_rand.stp
** function ngx_http_read_discarded_request_body called **

  saved return address: 0x08079d84

** returning from ngx_http_read_discarded_request_body **

  saved return address: 0x08079d84

## return addresses mismatched! expected: 0xee8f1c25 ##

ランダム値0xee8f1c25が0x08079d84に書き換えられたのでエラーとして検知しました。とりあえずはうまくいったようです。:-)

戻りアドレスを上書きするランダム値はprobe handlerを実行する度に新しく生成されるので、推測は非常に困難と思われます。

このスクリプトはマルチスレッドな場合に対応していません。マルチスレッドに対応するにはTID(Thread ID)ごとに分けて戻りアドレスを管理するなどの工夫が必要と思われます。今回はただの検証なのでこれ以上頑張るのはやめました。

-gdbでパッチあて-

SystemTapとは直接関係はないですが、別のパッチあての方法も紹介します。

今回紹介したSystemTapを使ったbuffer overflow対策はKernelレベルで実装されたものでしたが、Userレベルで動的パッチすることも可能です。方法は単純です。デバッガ(gdb)を使ってプロセスのtext segmentの一部を書き換えます。

nginx-1.3.9ではngx_http_read_discarded_request_body()の以下の部分でcontent_length_nが負の値になった場合にbuffer overflowが発生することを確認しました。

 606         size = (size_t) ngx_min(r->headers_in.content_length_n,
 607                                 NGX_HTTP_DISCARD_BUFFER_SIZE);
問題の箇所(修正前)

これを機械語レベルで以下と同等なコードに書き換えることができればbuffer overflowは発生しないはずです。
 606         size = ngx_min((size_t) r->headers_in.content_length_n,
 607                                 NGX_HTTP_DISCARD_BUFFER_SIZE);
問題の箇所(修正後)

606行目から607行目あたりをdisassembleするとつぎのようになります。

0x8078d49 :test   %edx,%edx
0x8078d4b :jg     0x8078d59
0x8078d4d :test   %edx,%edx
0x8078d4f :js     0x8078d5e
0x8078d51 :cmp    $0x1000,%ecx
0x8078d57 :jbe    0x8078d5e
0x8078d59 :mov    $0x1000,%ecx
問題の箇所(修正前)

これを以下のアセンブリ命令列に書き換えれば良さそうです。ここで4つのnopは何もしない(プログラムカウンタeipをインクリメントするだけ)の命令列(nop sled)です。

0x8078d49 :test   %edx,%edx
0x8078d4b :jne    0x8078d59
0x8078d4d :nop
0x8078d4e :nop
0x8078d4f :nop
0x8078d50 :nop
0x8078d51 :cmp    $0x1000,%ecx
0x8078d57 :jbe    0x8078d5e
0x8078d59 :mov    $0x1000,%ecx
問題の箇所(修正後)

以下のコマンドを実行します。nginxのworker processに対し、上の書き換え(パッチ)を適用しています。

[root@fedora32 ~]# ps -ef | grep nginx
root      2178     1  0 Mar27 ?        00:00:00 nginx: master process /usr/local/nginx-1.3.9/sbin/nginx
nobody   10202  2178  0 01:58 ?        00:00:00 nginx: worker process
root     10318  8877  0 02:36 pts/0    00:00:00 grep --color=auto nginx
[root@fedora32 ~]# gdb -q --pid=10202 --batch --ex "set *0x8078d4b = 0x90900c75" --ex "set *(short *)0x8078d4f = 0x9090"
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/libthread_db.so.1".
__kernel_vsyscall () at arch/x86/vdso/vdso32/sysenter.S:49
49              pop %ebp
[root@fedora32 ~]#

パッチを適用後は、buffer overflowが発生しないはずです。以下はパッチを適用後のworker processにchunked encodingされた長いHTTPリクエストを送ったときの様子です。

[acruel@fedora32 ~]$ perl -e 'print "GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n" . "a"x961 . "b"x4096 . "c"x64 . "\xef\xbe\xad\xde" . "d"x8096' | nc -n -v 127.0.0.1 80
Ncat: Version 6.40 ( http://nmap.org/ncat )
Ncat: Connected to 127.0.0.1:80.
HTTP/1.1 400 Bad Request
Server: nginx/1.3.9
Date: Fri, 28 Mar 2014 06:43:35 GMT
Content-Type: text/html
Content-Length: 172
Connection: close

<html>
<head><title>400 Bad Request</title></head>
<body bgcolor="white">
<center><h1>400 Bad Request</h1></center>
<hr><center>nginx/1.3.9</center>
</body>
</html>
Ncat: 13284 bytes sent, 323 bytes received in 0.01 seconds.
[acruel@fedora32 ~]$

今度は何事もなく正常にHTTPレスポンスが返ってきました。:-) buffer overflowが発生しなかったことがわかります。

-まとめ-

今回はSystemTapを使用した動的パッチ適用の方法を紹介しました。SystemTapは今回のような少し特殊な使い方以外にもプログラム解析をする上で役に立つ非常に強力なツールです。

gdbを使った方法も紹介しました。SystemTapとgdbは一見まったく関係のないツールに見えますが、SystemTapはgdb等のデバッガがbreakpointを設定するのに使っているint 3命令を使ってprobe handlerをhookするなどの共通点があります。

今回はおもにSystemTapの使い方の面に焦点をあてましたが、SystemTapのベースであるKprobeというLinux Kernelの機能についてもいずれ機会があれば紹介できたらと思っています。

次回はgcc等のコンパイラで実装されているSSPについてみていきたいと思います。


参考情報:
[1] Smashing The Stack For Fun And Profit
http://insecure.org/stf/smashstack.html
[2] Intel 64 and IA-32 Architectures Software Developer Manuals
http://www.intel.com/content/www/us/en/processors/architectures-software-developer-manuals.html
[3] An introduction to KProbes [LWN.net]
https://lwn.net/Articles/132196/


タイガーチームメンバー 塚本 泰三