😸

任意コード実行をして Stack Exploit を理解してみる

2023/09/25に公開

pwn とはメモリの書き換えや読み取りなどの低レイヤーの脆弱性を用いて意図しない動作を引き起こさせる分野で CTF の中で最もハッキングっぽい競技です。

今回はなぜ任意コード実行というものが出来るのかから始まり、 ASLR, PIE, RELRO, Stack Canary などのセキュリティ機構をバイパスしてスタック領域で攻撃する方法論を話したいと思います。

pwn とは何をする競技なのか

pwn で出る問題のゴールはシェル起動や権限昇格、サンドボックスからの脱出など多岐に渡りますが、どの問題でもやるべきことは 1 つだけです。

それはプログラムカウンタを奪取することです

プログラムカウンタとは次に実行する命令へのアドレスを格納しているレジスタのことで x86_64 では pc とも言われます。

通常、実行領域には命令列が書き込まれていてコンピュータは 1 つずつプログラムカウンタを進めながら実行していきます。その中には call / ret / jump 命令などのプログラムカウンタに直接アドレスを書き込む命令があります。

もし、そのアドレスを書き換えることが出来たらどうなるでしょうか?

ご想像の通り、システムの制御を奪えます。これを任意コード実行 (ACE; Arbitrary Code Execution) と言って、攻撃者はファイルの中身を覗いたり、書き換えたりと好き放題出来てしまいます。

そしてよくある pwn の問題の目標とは任意コード実行が出来るような脆弱性を見つけ出し、それを使ってシェルを起動することです。例えば次のような関数を呼び出すことでシェルを起動できます。

  • system("/bin/sh")
  • execve("/bin/sh", NULL, NULL)

このように脆弱性を駆使して任意コード実行を実現させて、フラグを獲得を目指します。ただし実際の問題はさまざなセキュリティ機構や限定された脆弱性などによって阻まれるので直線的には exploit 出来ません。なのでうまくバイパスするということも求められます。

スタック領域における書き換え手段

まずは普通は書き込むことができないメモリを書き換えられるようにする手段が必要です。メモリをどこでも自由に書き換えられることを AAW (Arbitrary Address Write) といって、ここでは代表的な 3 つの AAW ができる脆弱性を紹介します。

Out of bounds

C 言語において配列の外まで参照することができ、別の変数にまでアクセス出来てしまいます。これを配列の範囲外参照 (Out of bounds) といいます。

例えばローカル変数の後にはリターンアドレスが書き込まれており次のようにアクセス出来てしまいます。

#include <stdio.h>

int main() {
    void* a[4] = {};
    printf("ret addr: %p\n", a[7]);
    return 0;
}
$ gcc out_of_bounds.c
$ ./a.out
ret addr: 0x7f8b28229d90

Rust や JavaScript などは境界チェックという配列に書き込む前にインデックスが正当な位置にあるかどうかを検査する機構がデフォルトであって範囲外参照を未然に防げるようになりました。しかしながら境界チェックの境界についてオーバーフローなどに対して脆弱な仕組みを持っていた場合、境界を壊して AAW を実現できます。こういった脆弱性は過去に JavaScript, eBPF などにあり、VM 問ではこういう問題がよく出ます。

Buffer Overflow

Buffer Overflow とはプログラムが入力で受け取ったデータが確保したバッファのサイズを超えて範囲外のメモリを上書きしてしまう脆弱性で、通称 BOF と呼ばれます。よく間違えられますが Stack Overflow はスタック領域の上限までプッシュすることなので違う概念です。

例えば配列 char buf[8] に対して gets(buf) を使って改行を入れずに 8 文字以上入力すると buf 以外のデータも書き換えられてしまいます。

#include <stdio.h>

int main() {
    char buf[8];
    char target[] = "target value";
    gets(buf);
    printf("%s\n", target);
    return 0;
}
$ gcc bof.c
$ ./a.out
BUF
target value
$ ./a.out
AAAAAAAABOF
BOF

またちょうどバッファサイズまで書き込むものであっても最後に NULL 文字が書き込まれていないと、それを printf などで出力した際にバッファ以降の情報まで出力してしまいます。

この為 C 言語では char buf[40] と char buf2[60] を宣言したときに次の関数が脆弱性と成り得ます。

関数 挙動 脆弱性
scanf("%s", buf) 境界チェックせずに入力する。 BOF
scanf("%39s", buf) 39 バイト入力した後に NULL バイトを置く。 safe
scanf("%40s", buf) 40 バイト入力した後に NULL バイトを置く。 one-byte BOF
gets(buf) 境界チェックせずに入力する。 BOF
fgets(buf, 40, stdin) 39 バイト入力した後に NULL バイトを置く。 safe
read(stdin, buf, 40) 40 バイト入力した後に NULL バイトを置かない。 leakable
fread(buf, 1, 40, stdout) 40 バイト入力した後に NULL バイトを置かない。 leakable
strcpy(buf, buf2) 境界チェックせずに文字列をコピーする。 BOF
strncpy(buf, buf2, 40) 40 バイトコピーした後に NULL バイトを置かない。 leakable
memcpy(buf, buf2, 40) 40 バイトコピーした後に NULL バイトを置かない。 leakable
memmove(buf, buf2, 40) 40 バイトコピーした後に NULL バイトを置かない。 leakable
strcat(buf, buf2) 境界チェックせずに文字列を buf に連結する。 BOF
strncat(buf, buf2, 10) 10 バイト連結した後に NULL バイトを置かない。 BOF
sprintf(buf, format, ...) 書式を適用した文字列を境界チェックせずに入力した後に NULL バイトを置く。 BOF
snprintf(buf, 40, format, ...) 書式を適用した文字列を 39 バイト入力した後に NULL バイトを置く。 safe

ちなみに NULL バイトだけ BOF できることは一見 exploit に繋がらなさそうに見えますが、これはアドレスの下位 1 バイトを 0x00 に書き換えられるという能力を持ち、アドレスを若干ずらして書き換え可能な領域へ指すようになればデータを書き換えられることになります。

Format String Bug

printf 関数の第一引数は書式 (Format String) といい、% から始まるプレースホルダーによって引数に対して文字列処理を行って埋め込みます。例えば %s %d などよく見たことがあると思います。

これは %[parameter][flags][width][.precision][length]type という文法となっており、それぞれ次のようなことを入れます。

フィールド 具体例 説明
parameter 6$ 第二引数以降の引数の番号を表す。通常、書式が呼ばれる度インクリメントされるがこれによって一気に飛ばすことが出来る。x86-64 では 6 以降はスタックを指す。
width 40 * 出力するバイト長を表す。
length hh h l 入力する長さを表す。具体例はそれぞれ 1, 2, 8 バイトを表している。
type d x p s n ... データをどのように解釈するかや書き込みなどを表す。

規格の詳細は Wikipedia に書いてあり、例えば次のような例があります。

  • %42x は unsigned int を 16 進数として 42 文字出力する。余った文字は空白となる。
  • %6$p は第 7 引数、つまりスタック上の値をポインタとして出力する。
  • %s は引数のアドレスから文字列として NULL バイトまで出力する。
  • %hhn は引数のアドレスにこれまで出力した文字数を 1 バイト書き込む。Overflow するので一周すれば任意の値を書き込める。

実はこの %hhn が引数のアドレスにあるデータを書き換えることが出来る、つまり AAW の脆弱性となっています!!

#include <stdio.h>

char target[40] = "target value";

int main() {
    char buf[40];
    fgets(buf, 40, stdin);
    printf(buf, target, target + 1, target + 2);
    printf("%s\n", target);
    return 0;
}
$ gcc fsb.c
$ ./a.out
%p
0x55f90f928020
target value
$ ./a.out
%70x%1$hhn%13x%2$hhn%65519x%3$hn
...
FSB

これは大変汎用性が高く、スタックにアドレスを書き込んで指定すれば本当にどんな所でも書き換えることが出来ます。

スタック領域における攻撃方法

次は実際に攻撃する方法を取り上げます!上で紹介した 3 つのどれかを用いて次のメモリを書き換えます。

  • return address
  • GOT
  • vtable
  • フック関数

とその前にアセンブリや ABI などの基礎知識を知っておかないと攻撃を理解できません。先にその説明をしておきます。

アセンブリ

コンピュータは命令列である機械語を読み込んで処理します。アセンブリとは機械語を人間に読みやすくしたものです。

こういうのは実際に見た方がよくて、例えば次のようなプログラムをコンパイルしてアセンブリを作ってみます。 (Compiler Explorer というツールが有用です)

int sum(int a, int b) {
    return a + b;
}

int main() {
    return sum(1, 2);
}
sum:
        push    rbp
        mov     rbp, rsp
        add     edi, esi
        mov     eax, edi
        pop     rbp
        ret
main:
        push    rbp
        mov     rbp, rsp
        mov     esi, 2
        mov     edi, 1
        call    sum
        pop     rbp
        ret

こんな感じの命令がズラッと並んでいるのがアセンブリです。このようなアセンブリや機械語について知りたいときは次のような CPU アーキテクチャの規格書を参照しますが、ただめっっっっっっっっっちゃくちゃ長くてすぐに心折れます。

なので最初は次のようなアセンブリの入門記事を読むのがいいでしょう。

https://qiita.com/kaito_tateyama/items/89272098f4b286b64115

話を戻して、このアセンブリの中で攻撃で大事な命令は次の 4 つの命令です。

命令 説明
push スタックの先頭に operand を格納し、Stack pointer をデクリメント
pop スタックの先頭を operand に格納し、Stack pointer をインクリメント
call call 命令の次の命令アドレスをスタックに push し、アドレスをプログラムカウンタに書き込む
ret スタックから pop してプログラムカウンタに書き込む

まず関数を呼ぶ上で引数を設定しなければいけません。これは呼び出し規約 (Calling Convention) で規定されていて cdecl, stdcall, fastcall, thiscall などありますがほとんどの場合次のように設定する fastcall が使われます。

対象 x86-64 ARM RISC-V
関数の戻り値 rax r0 ra
第 1 引数 rdi r0 a0
第 2 引数 rsi r1 a1
第 3 引数 rdx r2 a2
第 4 引数 r10 r3 a3
第 5 引数 r8 スタック a4
第 6 引数 r9 スタック a5
第 7 引数以降 スタック スタック スタック
syscall 番号 rax r7 a7

アセンブリを見てみるとちゃんと main() で第一、第二引数である edi esi に引数を入れて、sum()edi esi を元に計算していることが分かります。

引数を設定したら次に call 命令によって関数を呼び出します。このとき次のアドレスである main()pop rbp のアドレスをスタックにプッシュします。これがリターンアドレスです。そして関数の最後に ret 命令によってポップしてリターンアドレスを貰い、そこに実行を渡します。

関数が呼ばれる順序はだいたいこんな感じです。このシステムを悪用することで任意コード実行が出来てしまいます。

リターンアドレスの書き換え

関数のリターンアドレスはローカル変数の直後にあります。これを書き換えれば任意コード実行できます。

例えば Out of bounds でリターンアドレスにアクセスして win() アドレスに書き換えれば処理が終わった後に win() が呼ばれます。

#include <stdio.h>

void win() {
    printf("win!\n");
}

int main() {
    void* a[4] = {};
    printf("ret addr: %p\n", a[7]);
    printf("win func: %p\n", win);
    a[7] = win;
    printf("ret addr: %p (overwrited)\n", a[7]);
    return 0;
}
$ gcc retaddr.c
$ ./a.out
ret addr: 0x7f8b28229d90
win func: 0x55d6655bd189
ret addr: 0x55d6655bd189 (overwrited)
win!
Segmentation fault

最後にスタックがズレてしまった為にリターンアドレスがあるべき場所に他のデータがあります。その為無理やりリターンしようとするとそのアドレスへ飛び、存在しないアドレスにアクセス (page fault) や存在するアドレスであるが実行領域ではないところにアクセス (illegal access) してしまい、Segmentation fault と出ます。

シェルコード

機械語 (shellcode) をスタック上に埋め込み、rip をそこに飛ばします。スタックアドレスが分かっている必要があります。

  • system("/bin/sh")
  • execve("/bin/sh", NULL, NULL)
  • fd = open("./flag", O_RDONLY) read(fd, buf, sizeof(buf))
    • chroot 環境で /bin/sh がなかったり、seccomp によってシステムコールを制限されているときに使います。

図式された x86 の命令集

GOT overwrite

実行時にリンクするライブラリ (動的ライブラリ) はヒープ領域上にランダムに置かれます。そのアドレスを解決して呼び出すキャッシュ機構が GOT / PLT です。

  • GOT (Global Offset Table)
    • 最初は PLT へのアドレスの表となっていて、アドレス解決後は直接ライブラリへのアドレスが書き込まれる
  • PLT (Procedure Linkage Table)
    • アドレスを解決し、ライブラリを呼び出す関数表

より詳細な処理は長くなるので略します。要するにこの GOT を書き換えることでライブラリを呼び出したときに pc を奪取できます。また PLT のアドレスをリターンアドレスへ書き込むことでライブラリに飛ばすこともでき、その攻撃手段を ret2plt といいます。

Return-Oriented Programming

リターンアドレスの書き換えを更に押し進めることで Return-Oriented Programming (ROP) となります。

ret 命令で終わる少ない命令列 (Gadget) が機械語の中にあるのでそれを return address の書き換えで呼び出す攻撃手法です。

呼び出す関数の引数がスタックを使用し、その後 2 つ以上の関数を呼ぶ場合は引数が Gadget と被らないように引数を削除する pop ret ガジェットを挟む。

また Gadget を見つける際に重宝するツールがあります。

https://github.com/david942j/one_gadget
https://github.com/JonathanSalwan/ROPgadget

$ ROPgadget --binary ./chall
Gadgets information
============================================================
0x000000000000121b : add byte ptr [rax], 0 ; add byte ptr [rax], al ; endbr64 ; jmp 0x11a0
0x0000000000001193 : add byte ptr [rax], 0 ; add byte ptr [rax], al ; ret
...
0x00000000000014bc : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000000014be : pop r13 ; pop r14 ; pop r15 ; ret
0x00000000000014c0 : pop r14 ; pop r15 ; ret
0x00000000000014c2 : pop r15 ; ret
0x00000000000014bb : pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x00000000000014bf : pop rbp ; pop r14 ; pop r15 ; ret
0x0000000000001213 : pop rbp ; ret
0x00000000000014c3 : pop rdi ; ret
0x00000000000014c1 : pop rsi ; pop r15 ; ret
0x00000000000014bd : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
...

Unique gadgets found: 89

$ one_gadget ./libc.so.6
0x4f2c5 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rsp & 0xf == 0
  rcx == NULL

0x4f322 execve("/bin/sh", rsp+0x40, environ)
constraints:
  [rsp+0x40] == NULL

0x10a38c execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

セキュリティ機構

スタック領域に関与する緩和策 (Mitigations) として NX bit, ASLR, RELRO, PIE, Stack Canary などがあります。これらによって様々な攻撃を未然に防ぎ、セキュリティが向上しました。ここではこの紹介を行っていきます。

Stack Canary

リターンアドレスの前に Canary という値を置いて関数がリターンするときに書き換えられていたら例外を送出する機構です。これによって連続的に書き込む BOF を検知することができます。これには Canary を読み出して BOF 時に書き込けば検査に引っかからずバイパスできます。

また Canary は Master Canary から得られるのでそれ自体を書き換えてしまえばいいという攻撃を Master Canary Forging といいます。詳しくは potetisensei の解説を観てください。

https://www.youtube.com/watch?v=UTC2iWxQ4qc

NX bit

NX bit (No eXecute bit) は読み書きのフラグの他に CPU が特定のセグメントを実行できないようにするフラグを追加します。ソフトウェア上で W^X (Write xor Execute) が成り立つようにフラグを立てることで shellcode が難しくなりました。

これを回避して shellcode を実行するには RWX のメモリ領域を作成する必要があります。これは ret2plt や ret2libc によって mmap() mprotect() を呼び出すことで作れるので回避できます。

RELRO

glibc などの動的ライブラリをリンクするとき、lazy binding といってシンボル名の検索とアドレスの解決については呼び出し時まで遅延させることでプログラムの起動を早めていました。ただ GOT overwrite などの攻撃手法が見つかり、その対策として今まで遅延していたのを起動即時に解決させて、その後書き込み禁止としました。この緩和策を RELRO (RELocation Read-Only) といいます。

これによって GOT overwrite は完全に防がれます。ただ一部の関数を除いて解決するような Partial RELRO の場合もあり、その場合 GOT overwrite ができる可能性があります。

lazy binding RELRO
No RELRO Yes No
Partial RELRO Yes Yes
Full RELRO No Yes

ASLR & PIE

Address Space Layout Randomization (ASLR) はスタックやヒープ、動的ライブラリが置かれる領域をランダム化する機構、 Position-Independent Executables (PIE) は .text 領域のアドレスもランダム化する機構です。

プロセス起動時にページ単位で物理アドレスと仮想アドレスの対応がされるのですが、ここでランダム化を行います。ちなみにこの対応は /proc/<pid>/maps に書かれてあります。そして技術的な困難から全てのページをランダム化することはできず、絶対的なアドレスは変わりますが、相対的には変わりません。

なのでバイパスするにはベースアドレスを取得すればよく、例えば次のようにしてベースアドレスを取得できます。

  • スタックに積まれたリターンアドレスの値から、.text 領域のベースアドレスが計算できる。これを .text 領域が分かると .data .bss .plt .got.plt などのベースアドレスも色々わかる。
  • 一度呼び出されたライブラリ関数の GOT アドレスの値から、そのライブラリのベースアドレスが計算できる。
  • スタックに積まれたsaved ebpの値から、スタック領域に置かれる他のデータのアドレスが計算できる。
  • ヒープ領域に確保されたデータを指すポインタの値から、ヒープ領域のベースアドレスが計算できる。

特に libc のベースアドレスを求めることは大変重要で、 libc leak といいます。libc leak できると例えばライブラリを呼び出したり (ret2libc)、文字列 /bin/sh も libc 内にあるので簡単にシェル起動ができます。

ASCII-armor

共有ライブラリのベースアドレスを 0x00XXXXXX のように \x00 を含めることで BOF によって書き込むことを難しくする機構です。

Control Flow Integrity

ROP, JOP 対策として導入された主に CPU のセキュリティ機構です。Intel, ARM, RISC-V における CFI (Control Flow Integrity) 拡張をまとめます。

  • Intel CET Shadow Stack
    • call 命令で return address を shadow stack に push し、ret 命令で pop して一致しなければ例外を送出する。既存のコードを変更せずに適用できるのが強み。
  • Intel CET Indirect Branch Tracking (IBT)
    • jump 先に endbranch 命令を埋め込み、関数ポインタの先が endbranch 命令を指していない場合には例外が送出される機構。JOP の検知ができる。
  • ARM Branch Target Identification (BTI)
    • br/blr 命令によって jump した先が bti 命令以外であれば例外を送出する機構。PTE の GP ビットが立っているときにそのアドレス範囲での BTI が有効になる。JOP の検知ができる。
  • ARM Pointer Authentication (PAC)
    • PAC 命令で 64 bit ポインタの上位 8 bit にタグ, 3-23 bit にアドレスの署名を埋め込み、AUTH 命令で認証し、無効な署名を持つ場合に例外が送出される機構。署名は QARMA アルゴリズムが推奨されている。ROP の検知ができる。
  • ARM Memory Tagging Extension (MTE)
    • メモリにタグを割り当て、ポインタの上位 4 bit にタグを埋め込み、アクセス時にそれらが不一致の場合には例外が送出される機構。Use After Free の検知ができる。
  • RISC-V CFI Shadow Stack
    • 特殊なメモリ領域に Shadow Stack を確保し、return address のみの読み書きをする。sspush, sspop, sschkra 命令によって return address のプッシュ、ポップ、比較をし、不一致ならば例外を送出する。
  • RISC-V CFI Landing Pads
    • 新しい命令 lpsll, lpcll 命令を追加して jalr 命令の分岐前と分岐後で label を照合することで想定された組み合わせかどうかを判定し、不一致なら例外を送出する。デメリットとしてツールチェイン側が実装する際にバグが発生しそうなことが挙げられる。

これらによって ROP は不可能になったといってもいいでしょう。

CFI をソフトウェアで実装することもあるのですがこれに関しては様々な脆弱性が見つかっているのでそこを攻撃することは可能です。

checksec

これらの緩和策を ELF ファイルから調べてくれる checksec というスクリプトがあります。pwndbg の checksec コマンドもエイリアスとしてあります。

https://github.com/slimm609/checksec.sh

$ checksec --file=a.out
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable   FILE
Full RELRO      Canary found      NX enabled    PIE enabled     No RPATH   No RUNPATH   38) Symbols       No    0               0    a.out

まとめ

スタック領域に関する exploit は pwn の begginer / easy レベルに当たるので解けるようになると初心者脱却できると思います。

次はヒープ領域に関する exploit です。少し難しいですが基本的にはここでやった考え方と同じなのでよく理解して挑んでください。

Discussion