Djangoの脆弱性CVE-2019-12781について解説
こんにちは、筒井です。今日は、2019年7月1日のリリースで修正されたDjangoの脆弱性CVE-2019-12781について解説します。
Django公式サイトのリリース記事については以下を参照してください。
影響を受けるバージョン
以下のバージョンが影響を受けます。
- Django master development branch
- Django 2.2 before version 2.2.3
- Django 2.1 before version 2.1.10
- Django 1.11 before version 1.11.22
脆弱性の内容(Incorrect HTTP detection with reverse-proxy connecting via HTTPS)
リクエストヘッダに不正な値が含まれていると以下の問題が発生する場合があります。
is_secure
・build_absolute_uri
が誤った結果を返すSECURE_SSL_REDIRECT
が有効になっていてもhttpsにリダイレクトされずhttpにアクセスできる
脆弱性を利用した攻撃の例
この記事のサンプルコードの動作環境は以下のとおりです。
- OS: Ubuntu 18.04.2 LTS
- Python: 3.6.8
- nginx: 1.14.0
- Django: 2.2.2
- gunicorn: 19.7.1
gunicornは意図的に古いバージョンを使っています。(理由は後述)
まず、django_example
というDjangoプロジェクトを作成します。
コードの変更内容は以下のとおりです。
以下のコマンドでアプリケーションを起動します。
( migrate
や collectstatic
は今回は必要ないので実行しなくても構いません)
$ gunicorn -b 127.0.0.1:8000 django_example.wsgi
nginxをリバースプロキシにして、httpsも利用できるようにします。
設定ファイルの内容は以下のとおりです。
SSL証明書は以下のようにopensslコマンドで自己証明書を作っておきます。
$ sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/ssl/private/nginx-selfsigned.key -out /etc/ssl/certs/nginx-selfsigned.crt
Generating a RSA private key
....+++++
.....+++++
writing new private key to '/etc/ssl/private/nginx-selfsigned.key'
-----
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:JP
State or Province Name (full name) [Some-State]:Tokyo
Locality Name (eg, city) []:Minato-ku
Organization Name (eg, company) [Internet Widgits Pty Ltd]:Credit Engine, Inc.
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:127.0.0.1
Email Address []:ryu22e@example.com
上記の構成で http://127.0.0.1
・https://127.0.0.1
が利用できるようになりました。
まずは仕様上の挙動を確認しておきましょう。
以下のようにhttp://127.0.0.1
・https://127.0.0.1
にリクエストを送ると、is_secure
・ build_absolute_uri
はhttp・httpsを正しく判定した値を返します。
$ curl http://127.0.0.1
request.is_secure(): False
request.build_absolute_uri(): http://127.0.0.1/dummy
$ curl https://127.0.0.1 --insecure # 自己証明書なので証明書の検証を行わないようにする
request.is_secure(): True
request.build_absolute_uri(): https://127.0.0.1/dummy
settings.py
に SECURE_SSL_REDIRECT = True
が書かれている場合は、httpへのリクエストはhttpsにリダイレクトされます。
(注意: 301リダイレクトなので、ブラウザでアクセスした場合はキャッシュを消しておいたほうがいいです)
$ curl -I http://127.0.0.1
HTTP/1.1 301 Moved Permanently
Server: nginx/1.14.0 (Ubuntu)
Date: Wed, 03 Jul 2019 02:49:30 GMT
Content-Type: text/html; charset=utf-8
Connection: keep-alive
Location: https://127.0.0.1/
ところが、以下のリクエストヘッダを加えると、is_secure
・ build_absolute_uri
はhttpへのリクエストでもhttpsの時の値を返します。
$ curl -H 'X-FORWARDED-SSL: on' http://127.0.0.1
request.is_secure(): True
request.build_absolute_uri(): https://127.0.0.1/dummy
settings.py
に SECURE_SSL_REDIRECT = True
が書かれている場合は、httpへのリクエストでもhttpsにリダイレクトされません。
$ curl -I -H 'X-FORWARDED-SSL: on' http://127.0.0.1
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Wed, 03 Jul 2019 02:49:41 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 80
Connection: keep-alive
X-Frame-Options: SAMEORIGIN
どのコードに問題があったか・どのように修正されたか
http・httpsを判定するロジックに使われているメソッドdjango.http.HttpRequest.scheme
に問題がありました。
このメソッドでは、 SECURE_PROXY_SSL_HEADER
に定義された以下の値を判定基準として使います。
- リクエストヘッダ名
- https時に上記リクエストヘッダ名に入っているはずの値
例えば、 SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO, 'https')
の場合はリクエストヘッダ X-Forwarded-For
に https
が入っているとhttpsへのアクセスと判定します。X-Forwarded-For
がhttps
以外の場合は django.http.HttpRequest._get_scheme
に判定させます。django.http.HttpRequest._get_scheme
が何を返すかはuWSGIサーバーの実装に依存します。gunicornの場合はリクエストヘッダが以下のいずれか値の場合に https
を返します。
(この条件は secure_scheme_headers
でカスタマイズ可能です)
- X-FORWARDED-PROTOCOL: ssl
- X-FORWARDED-PROTO: https
- X-FORWARDED-SSL: on
先述の攻撃例 curl -H 'X-FORWARDED-SSL: on' http://127.0.0.1
では、Django側ではX-Forwarded-For
が http
なのでgunicornに判定を任せ、gunicorn側では X-FORWARDED-SSL
に on
が入っているので https
と判定してしまいました。
この脆弱性に対応するため、django.http.HttpRequest.scheme
ではSECURE_PROXY_SSL_HEADER
の条件に一致しない場合にdjango.http.HttpRequest._get_scheme
を呼ばずに固定値 http
を返すようになりました。
具体的な変更内容は以下のGitHubコードを参照してください。
この記事のサンプルでgunicorn 19.7.1を使っている理由
gunicorn 19.8.0(19.7.1の次のバージョン)以降には、「リクエストヘッダに矛盾した値が入っていると400エラーを返す」という機能があり、Djangoの脆弱性を検証する前の段階でgunicornにリクエストを弾かれるため、19.7.1を使うようにしました。
この機能について、もう少し詳しく解説します。
先述の攻撃例 curl -H 'X-FORWARDED-SSL: on' http://127.0.0.1
を実行すると、gunicornは以下の情報を受け取ります。
X-Forwarded-For
がhttp
(=httpsへのリクエストではない)X-FORWARDED-SSL
がon
(=httpsへのリクエストである)
これではhttp・httpsどちらへのリクエストか分からないため、gunicornは以下のエラーを返します。
$ curl -H 'X-FORWARDED-SSL: on' http://127.0.0.1
<html>
<head>
<title>Bad Request</title>
</head>
<body>
<h1><p>Bad Request</p></h1>
Contradictory scheme headers
</body>
</html>
実装上は、判定条件となるリクエストヘッダを1件ずつ検証し、前の判定結果と矛盾するものがあれば例外を投げるようになっています。
該当するgunicornのコードは以下のとおりです。