KalmarCTF

名前的にKalmarunionenのCTFだと思う。

BunkyoWesternsで参加し、チームとしては9位でした。

st98さんのWriteupはこちら

モンハンとライブの合間にちょっと見ました。そのせいで確定申告には手が付けられず、今日やっと終わったので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?}

まとめ

面白かったです。解けてないけど見た問題はヤバそうだったのも面白そうなのもある。