Djangoの脆弱性CVE-2019-12781について解説

Ryuji Tsutsui
CreditEngine Tech
Published in
11 min readJul 4, 2019

こんにちは、筒井です。今日は、2019年7月1日のリリースで修正されたDjangoの脆弱性CVE-2019-12781について解説します。

今回の脆弱性を突くとアプリケーションがhttpへのリクエストをhttpsと錯覚します

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)

リクエストヘッダに不正な値が含まれていると以下の問題が発生する場合があります。

脆弱性を利用した攻撃の例

この記事のサンプルコードの動作環境は以下のとおりです。

  • 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プロジェクトを作成します。
コードの変更内容は以下のとおりです。

以下のコマンドでアプリケーションを起動します。
migratecollectstatic は今回は必要ないので実行しなくても構いません)

$ 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.1https://127.0.0.1 が利用できるようになりました。
まずは仕様上の挙動を確認しておきましょう。
以下のようにhttp://127.0.0.1https://127.0.0.1にリクエストを送ると、is_securebuild_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.pySECURE_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_securebuild_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.pySECURE_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-Forhttps が入っているとhttpsへのアクセスと判定します。
X-Forwarded-Forhttps以外の場合は 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-Forhttp なのでgunicornに判定を任せ、gunicorn側では X-FORWARDED-SSLon が入っているので 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-Forhttp(=httpsへのリクエストではない)
  • X-FORWARDED-SSLon (=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のコードは以下のとおりです。

--

--

Ryuji Tsutsui
CreditEngine Tech

私がmediumに記事を書くことはおそらく当分ないので、 https://ryu22e.org/ の方を読んでください。