
Magento の未認証リモートコード実行 (CVE-2016-4010)
0x00 はじめに
5月17日、海外のセキュリティ研究者Netanel Rubin氏は、Magentoの未認証リモートコード実行の脆弱性(CVE-2016-4010)を公開しました。この脆弱性は実際には複数の小さな脆弱性から構成されており、攻撃者が脆弱性のあるMagentoサーバー上で未認証のままPHPコードを実行することを可能にします。Magentoは非常に人気のあるEコマースプラットフォームであり、2011年にeBayに買収されました。サムスン、ニコン、レノボなどの有名企業や多くの小規模ECサイトが使用しています。Magentoは25万のオンラインショップで使用されており、毎年600億ドル以上の取引が行われています。
0x01 分析
この脆弱性の悪用条件:
MagentoのWeb APIは、REST RPCとSOAP APIという2種類のRPCを許可しています。この2つの方法は同じ機能を提供しますが、唯一の違いは、前者がJSONとHTTPリクエストを使用して入力を渡すのに対し、後者はXMLを使用する点です。
一部のモジュールのAPIだけを公開するために、Magentoは開発者に便利な方法を提供しています。それは「webapi.xml」ファイルに、アクセス可能にしたいモジュールのAPIだけを宣言するというものです。webapi.xmlファイルには、公開する必要があるWeb APIのクラスとメソッドがすべて含まれ、各メソッドは必要な具体的な権限も指定します。それらの権限は次のとおりです。
もちろん、開発者がwebapi.xmlファイルを使用してシステムのフロントエンドとバックエンド(Web API)の間で通信するこの方法は、実際にはモジュールの中核に直接アクセスできるバックドアを開くことになります。
さらに、仮に「anonymous」権限を手に入れても、値を動的に渡す方法がまだ必要です。ここで言及しているのは、システム内で使用できるさまざまなオブジェクトです。例えば、「CustomerRepositoryInterface::save()」API機能を使用すると、「$customer」変数に「CustomerInterface」のオブジェクトを渡せます。コードの原型は次のとおりです。
interface CustomerRepositoryInterface
{
/**
* Create customer.
*/
public function save(\Magento\Customer\Api\Data\CustomerInterface $customer);
}
では、RPCインターフェースを使用してオブジェクトを作成するにはどうすればよいでしょうか? 実は、この答えはMagentoがSOAPサーバーをどのように設定するかにあります。
Magentoは、PHP標準の「SoapServer」を同梱したSOAPサーバーを使用します。正しく設定するには、「SoapServer」はWSDLファイルが必要です。そのファイルで、すべてのメソッド、パラメータ、および実際のRPCリクエストで使用されるカスタム型を定義します。Magentoは、XML-RPC機能をサポートする各モジュールに対し異なるWSDLファイルを生成し、モジュールのwebapi.xmlファイルの値を直接設定します。
RPCリクエストがサーバーによって解析されるとき、サーバーはWSDLファイルで見つけたデータを使用してリクエストが有効かどうかを判断し、リクエストのメソッド、パラメータ、型をチェックします。リクエストが有効な場合、解析済みのリクエストオブジェクトをMagentoに渡してさらに解析します。非常に重要な点は、「SoapServer」が何らかの形でMagentoとやり取りすることはなく、モジュールのメソッドとパラメータに関するすべての情報はWSDLファイルから取得されるということです。この時点では、送信されたリクエストは依然としてネストされた配列で構成されており、SoapServerの解析段階ではオブジェクトは作成されません。必要なオブジェクトを作成するために、Magentoは独自に入力を処理し続けます。
パラメータ名とデータ型を抽出するために、Magentoはリクエストのメソッドからシグネチャを取得します(前述のコードを参照)。文字列、配列、ブールなどの基本的なデータ型については、システムは入力を対応する型にマッピングします。しかし、オブジェクト型の場合、解決方法はより厄介です。
パラメータのデータ型がクラスのインスタンスである場合、Magentoは提供された入力を使用してインスタンスを作成しようとします。ここで、入力は単なる辞書であり、そのキーはプロパティ名、値はプロパティ値であることに注意してください。
まず、Magentoは必要なクラスの新しいインスタンスを作成します。次に、以下の方法で設定を試みます。
Magentoはこの方法で、ユーザーが設定しようとしている各プロパティを処理します。すべてのプロパティがチェックされると、Magentoはインスタンスの設定が完了したとみなし、次のパラメータを処理します。すべてのパラメータがこのように処理されると、最終的にMagentoはこのAPIメソッドを実行します。
要するに、Magentoはオブジェクトを作成し、そのパブリックプロパティを設定し、最後にRPCを介して「Set」で始まる任意のメソッドを実行させます。そして、まさにこの動作がMagentoの脆弱性の原因となっています。
調査によると、一部のAPI呼び出しでは、ショッピングカートに特定の情報を設定できます。その情報には、配送先住所、商品、さらには支払い方法も含まれます。
Magentoがショッピングカートインスタンスに私たちの情報を設定するとき、インスタンスの「save」メソッドを使用して、新しく追加されたデータをデータベースに保存します。
では、「save」メソッドがどのように機能するか見てみましょう。
/**
* Save object data
*/
public function save(\Magento\Framework\Model\AbstractModel $object)
{
...
// If the object is valid and can be saved
if ($object->isSaveAllowed()) {
// Serialize whatever fields need serializing
$this->_serializeFields($object);
...
// If the object already exists in the DB, update it
if ($this->isObjectNotNew($object)) {
$this->updateObject($object);
// Otherwise, create a new record
} else {
$this->saveNewObject($object);
}
// Unserialize the fields we serialized
$this->unserializeFields($object);
}
...
return $this;
}
// AbstractDb::save()
Magentoはオブジェクトが有効であることを確認し、シリアライズが必要な部分をすべてシリアライズしてデータベースに保存し、最後に先ほどシリアライズした部分をアンシリアライズします。
とても簡単に見えますよね? 実はそうでもありません。Magentoがどの部分をシリアライズすべきかをどのように判断しているのか、見続けましょう。
/**
* Serialize serializable fields of the object
*/
protected function _serializeFields(\Magento\Framework\Model\AbstractModel $object)
{
// Loops through the '_serializableFields' property
// (containing hardcoded fields that should be serialized)
foreach ($this->_serializableFields as $field => $parameters) {
// Get the field's value
$value = $object->getData($field);
// If it's an array or an object, serialize it
if (is_array($value) || is_object($value)) {
$object->setData($field, serialize($value));
}
}
}
// AbstractDb::_serializeFields()
見てのとおり、シリアライズできるのは、ハードコードされた辞書「_serializableFields」に存在するフィールドだけです。最も重要なのは、このメソッドはフィールドの値が配列またはオブジェクトであることを確認した後にのみシリアライズを続行する点です。
次に、Magentoがどの部分をアンシリアライズすべきかをどのように判断しているかを見てみましょう。
/**
* Unserialize serializeable object fields
*/
public function unserializeFields(\Magento\Framework\Model\AbstractModel $object)
{
// Loops through the '_serializableFields' property
// (containing hardcoded fields that should be serialized)
foreach ($this->_serializableFields as $field => $parameters) {
// Get the field's value
$value = $object->getData($field);
// If it's not an array or an object, unserialize it
if (!is_array($value) && !is_object($value)) {
$object->setData($field, unserialize($value));
}
}
}
// AbstractDb::unserializeFields ()
まあ、非常によく似ています。唯一の違いは、今回はMagentoがフィールドの値が配列またはオブジェクトでないことを確認する必要がある点です。この2回のチェックにより、オブジェクトインジェクション攻撃を実行できるはずです。つまり、シリアライズ可能なフィールドに、特定のルールに従った文字列を設定するだけです。そのように設定すると、システムはオブジェクトをデータベースに保存する前にこのフィールドをシリアライズしません。なぜなら、配列またはオブジェクトではないからです。しかし、システムがそれをアンシリアライズしようとするとき、データベースクエリが実行された後、配列またはオブジェクトではないため、アンシリアライズされます。
しかし、まさにこのほとんど目に見えないほど小さな条件が脆弱性を引き起こしました。残る問題は、どのフィールドが「シリアライズ可能」と見なされるか、そしてそれをどう設定するかです。
もちろん、最初の問題は簡単です。どのクラスが「_serializableFields」プロパティを含むかを検索するだけです。すぐに「Payment」クラスでAPIメソッドが見つかりましたが、パラメータとしてではないため、そのインスタンスのプロパティを作成または制御できません。最も重要なのは、そのシリアライズ可能なフィールド「additional_information」は、「Set[PROPERTY_NAME]」テクニックを追加のセキュリティ対策として使用し、配列としてのみ設定できることです。そのため、作成できないだけでなく、仮にできたとしても文字列に設定することはできません。
しかし、非常に興味深いことに、別の「巧妙な」方法で設定することができます。Magentoがパラメータインスタンスのプロパティを設定するとき、実際にはプロパティを直接設定するのではなく、「_data」という名前の辞書に保存します。インスタンスのプロパティが使用されるとき、この辞書が使用されます。つまり、私たちにとっては、シリアライズ可能なフィールド「additional_information」が、実際には通常のプロパティではなく、内部の辞書に保存されていることを意味します。
したがって、「_data」辞書を完全に制御できれば、「Set[PROPERTY_NAME]」を呼び出す代わりに手動で設定できるため、「additional_information」フィールドの配列制限を簡単に回避できます。
しかし、どうやってこの機密の辞書を制御するのでしょうか?
「Payment」インスタンスを保存する前に、Magentoが行うことの1つは、そのプロパティを編集することです。Magentoは、API入力を「Payment」インスタンスに保存する必要がある支払い情報として扱います。次のとおりです。
/**
* Adds a specified payment method to a specified shopping cart.
*/
public function set($cartId, \Magento\Quote\Api\Data\PaymentInterface $method)
{
$quote = $this->quoteRepository->get($cartId); // Get the cart instance
$payment = $quote->getPayment(); // Get the payment instance
// Get the data from the user input
$data = $method->getData();
// Check for additional data
if (isset($data['additional_data'])) {
$data = array_merge($data, (array)$data['additional_data']);
unset($data['additional_data']);
}
// Import the user input to the Payment instance
$payment->importData($data);
...
}
// PaymentMethodManagement::set()
先ほど見たように、「Payment」データは、「$method->getData()」を呼び出すことで「$method」パラメータから「_data」プロパティを返して取得されます。覚えておいてください。「$method」はAPIメソッドのパラメータであるため、私たちはそれを制御できます。
Magentoが私たちの「$method」パラメータで「getData()」を呼び出すと、パラメータの「_data」プロパティが返され、私たちが挿入したすべての支払い情報が含まれます。その後、「_data」プロパティを入力として「importData()」を呼び出し、私たちの「_data」プロパティで「Payment」インスタンスの「_data」プロパティを置き換えます。これで、私たちが制御できる「_data」プロパティを使用して、「Payment」インスタンス内の機密の「_data」プロパティを置き換えることができるようになりました。つまり、シリアライズ可能なフィールド「addition_information」を設定できるようになったのです。
unserialize() を機能させるには、フィールドを文字列として設定できる必要があります。しかし、「Set[PROPERTY_NAME]」メソッドは配列のみを許可します。解決策は、「importData()」を呼び出す前に2行のコードを置くことです。Magentoは開発者が独自の支払い方法を追加し、独自のデータと情報を提供することを許可しています。これを実現するために、Magentoは「addition_data」フィールドを使用します。このフィールドは、より多くのデータを含む支払い方法であり、完全にユーザー制御可能な辞書です。カスタマイズされたコンテンツを元のデータの一部にするために、Magentoは「additional_data」辞書を元の「data」辞書とマージします。実際には、「additional_data」辞書が「data」辞書のすべての値を上書きできるようにし、基本的には完全に上書き可能です。つまり、2つの辞書がマージされた後、ユーザー制御可能な「additional_data」辞書はパラメータの「_data」辞書になり、「importData()」によって「Payment」インスタンスの機密の「_data」プロパティにもなります。言い換えれば、シリアライズ可能なフィールド「additional_information」を完全に制御し、オブジェクトインジェクション攻撃を実行できるようになりました。
好きな文字列をアンシリアライズできるようになったので、オブジェクトインジェクション攻撃を実行する時が来ました。
まず、オブジェクトがアンシリアライズされるか破棄されるときに自動的に呼び出されるように、「__wakeup()」または「__destruct()」メソッドを持つオブジェクトが必要です。なぜなら、オブジェクトのプロパティを制御できても、そのメソッドを呼び出すことはできないからです。そのため、特定のイベントが発生したときに自動的に呼び出されるPHPのマジックメソッドに依存しなければなりません。
最初に使用するオブジェクトは、「Credis_Client」クラスのインスタンスで、次のようなメソッドを含みます。
/*
* Called automaticlly when the object is destrotyed.
*/
public function __destruct()
{
if ($this->closeOnDestruct) {
$this->close();
}
}
/*
* Closes the redis stream.
*/
public function close()
{
if ($this->connected && ! $this->persistent) {
...
$result = $this->redis->close();
}
...
}
// Credis_Client::__destruct(), close()
このクラスには、単純な「__destruct」メソッド(オブジェクトが破棄されるときにPHPによって自動的に呼び出されます)があり、「close()」メソッドを呼び出すことがわかります。興味深いことに、「close()」メソッドは、Redisサーバーへのアクティブな接続がある場合、「redis」プロパティの「close()」を呼び出してそれを閉じます。
unserialize() を使用するとすべてのオブジェクトプロパティを制御できるため、「redis」プロパティも制御できます。プロパティには(Redisに限らず)任意のオブジェクトを設定でき、システム内の任意のクラスで任意の「close()」メソッドを呼び出せます。これにより、攻撃面が大幅に広がります。Magentoにはいくつかの「close()」メソッドがあり、通常これらのメソッドはストリームの終了、ファイルハンドルのクローズ、オブジェクトデータの保存に使用されるため、いくつかの興味深い呼び出しを見つけられるはずです。
予想どおり、「Transaction」クラスに次の「close()」メソッドが見つかりました。
/**
* Close this transaction
*/
public function close($shouldSave = true)
{
...
if ($shouldSave) {
$this->save();
}
...
}
/**
* Save object data
*/
public function save()
{
$this->_getResource()->save($this);
return $this;
}
// Magento\Sales\Model\Order\Payment\Transaction::__destruct(), close()
とても簡単に見えます。「close()」メソッドは「save()」メソッドを呼び出し、次に「_resource」プロパティの「save()」メソッドを呼び出します。同じ考え方で、「_resource」プロパティを制御しているのでそのクラスも制御できるため、任意のクラスの「save()」メソッドを呼び出すことができます。
また一歩前進しました。予想どおり、「save()」メソッドは通常、さまざまなストレージメディア(ファイルシステム、データベースなど)にさまざまなデータを保存するために使用されます。今必要なのは、ファイルシステムをストレージメディアとして使用する「save()」メソッドを見つけることです。
すぐに、次のものを見つけました。
/**
* Try to save configuration cache to file
*/
public function save()
{
...
// save stats
file_put_contents($this->getStatFileName(), $this->getComponents());
...
}
// Magento\Framework\Simplexml\Config\Cache\File::save()
このメソッドは、実際には「components」フィールドのデータをファイルに保存します。ファイルのパスは「stat_file_name」フィールドから取得されるため、この2つのパラメータを制御することで、実際にはファイルのパスと内容を制御でき、任意のファイル書き込みの脆弱性が発生します。
あとは、書き込み可能でWebサーバーからアクセス可能な有効なパスを見つけてファイルを書き込むだけです。すべてのMagentoインストールディレクトリには「/pub」ディレクトリがあり、画像や管理者がアップロードしたファイルを保存するために使用されます。これは有効に悪用可能なパスです。
最後に、PHPのウェブシェルファイルをサーバーに書き込むだけで、Magentoサーバー上で未認証のまま任意のPHPコードを実行できます。
0x02 悪用
テスト環境の構築
注意:ここではいくつかの問題に遭遇する可能性があります。以下を参照してください:
脆弱性の悪用
exploit-dbで公開されている脆弱性のエクスプロイト(exp)のダウンロード先:https://www.exploit-db.com/exploits/39838/
悪用方法は次のとおりです。
脆弱性のあるMagentoサイトを見つける Magentoバージョンのオンラインチェック:http://magentoversion.com/
商品を1つショッピングカートに追加する

ショッピングカートに入り「レジに進む」をクリックする

配送先住所を入力し、POSTリクエスト /rest/default/V1/guest-carts/[guestCartId]/shipping-information を確認して [guestCartID] を取得する

上記のexpをmagento_exp.phpとして保存し、実行する:php magento_exp.php [Magento_URL] [guestCartID] ([webshell書き込みパス])

一括検出
上記のエクスプロイトを調査した結果、この悪用には以下の条件を満たす必要があることがわかりました。

そこで、上記のexpと組み合わせて使用する簡単な一括検証スクリプトを作成しました。
#!/usr/bin/env python
import urllib
import sys
import socket
timeout = 5
socket.setdefaulttimeout(timeout)
input = sys.argv[1] #包含Magento站点的URL的文件
output = sys.argv[2] #结果的保存文件,可以为:output.txt
def logFile(str):
f = open(output,'a')
f.write(str+"\n")
f.close()
def checkVul(url):
try:
html = urllib.urlopen(url).read()
if "guest-carts" in html:
print url,"is vulnerable!"
logFile(url)
else:
print url,"is not vulnerable!"
except Exception:
pass
if __name__ == '__main__':
inp = open(input,'r')
for i in inp:
url=i.strip()
#print url
checkVul(url)
print "All Done!"
実行結果:

0x03 防御
Magentoを最新版(2.0.6)にアップグレードする。ダウンロード先: https://www.magentocommerce.com/download
参考