[翻译] Magento2 – 在未授权服务器执行恶意PHP代码 (CVE-2016-4010)

译注:本文所关注的漏洞适用于Magento 2.0,并未发现与Magento 1.x相关的漏洞报告。漏洞报告于2016年5月17日。原文链接:Magento – Unauthenticated Remote Code Execution

引子

该漏洞(CVE-2016-4010)允许攻击者在未经验证的情况下在有漏洞的服务器上执行PHP代码。实际上,此漏洞是由许多小的漏洞组成,我们将在下文中一一描述。

Magento是一个非常流行的电商平台,占有30%的市场份额。Magento被主流的企业所采用,如Rosetta Stone,Nike,BevMo和Dyson等,它同样也适用于小规模线上商店。总之,有超过250,000家在线商店使用Magento,每年的成交额约达500亿美元

可以想象,如此的成交额以及庞大的用户信息库,使Magento成为众黑帽眼中的香饽饽。这也是我再一次审核它的原因。(本文作者Netanel Rubin于2015年4月20日发表Analyzing the Magento Vulnerability (Updated),分析Magento 1.x的安全隐患,其中涉及官方SUPEE-5344等重要补丁。译注。)

该漏洞攻击建立在“Magento系统中某一个RPC已启用,如 REST 或 SOAP”的假设之上。而这两种方法均被默认启用,且其中一个实际上是系统所必需的,因此这种假设是合情合理的。

本文,我将采用SOAP的方法,因为XML更易于理解。

此漏洞会影响社区版与企业版。我建议所有的Magento管理员更新安装2.0.6补丁。

受影响版本

Magento EE 2.0.6 与 Magento CE 2.0.6之前的版本。

技术说明

自从我上次审核Magento,其代码发生了许多变化:重写了大部分代码,
改变了目录结构,并改善了安全机制。所以,我由衷地乐意再重新审核这个庞大的代码库。

首先引起我注意的是,系统的面向对象的结构改进显著。几乎每一个类至少实现一个接口、继承一个父类、使一些魔术函数,如”__call()”或”__get()”,来公开私有属性。

当然,无论对我、研究者以及Magento的开发人员,如此极端的面向对象编程实现的例子,虽是巨大的进步,亦造就了一个非常复杂的系统。现在,当开发人员编写一个新的类时,类必须要继承类或实现接口,而这些类或接口是由另一位开发者实现的。类似情况在软件开发中经常发生,越来越多的安全因素被卷入到这种工作方法中,尤其是这个拥有动态代码流、动态程序接口的特定系统 – Magento。

Magento的核心是一个由不同的“模块”组成的系统。这些所谓的“模块”一般都在不同的目录下,包含着为系统提供的不同功能的代码。例如,支付模块、购物车模块以及顾客模块。

在每个模块中有一个名为“API”的特殊目录。该模块所有公开给其它模块的功能都被包含在该目录中。例如,支付模块需要与购物车模块、顾客模块与授权模块、邮寄模块与销售模块,这些模块之间两两都需要通信。

“API”目录由不同的PHP文件组成,每个文件包含一个PHP类,负责公平部分模块的功能至系统其余部分。大多数的模块API功能仅限于其余系统控制的模块,因为大部分都比较敏感。但是,有一些API调用也可以被其它的API所利用,其中大部分被用户所控制,也是就是恶名昭著的Web API。

Magento的Web API支持两种不同的远程过程调用(RPC) – REST 与 SOAP。它们提供相同的功能,唯一不同的是,前者使用JSON和HTTP但询字符串作为输入,而后者使用XML信封作为输入。因为这两种方法均默认开启,本文中,我将使用SOAP,便 于理解。

毫无疑问,保守地说,Magento是我所见过的最全面的PHP内容管理系统之一,或者说在任何一种语言的内容管理系统中,也是最全面的之一。

为了是公开每个模块的API的一部分,Magento为模块开发人员提供了”webapi.xml”文件来方便地定义他们想要在Web API中公开的方法。

“webapi.xml”简洁地、有序地以XML的形式描述了需要向Web API公开的类与方法。每一个公开的方法均指明特定的权限,从“匿名” – 允许任何用户(包括Guest)调用方法,至“self” – 只允许注册客户或仅限管理员,如“Magento_Backend::admin”是仅仅允许能够修改服务器配置的管理员访问的权限。

当使用”webapi.xml”给模块开发人员提供了一条从系统前端至后端捷径的同时,也开通了一条直达模块核心的后门。

当我们的身份仅是游客时,我们应当只可以调用仅需“匿名”权限的方法。这将大大缩小攻击的可能性,但会移除某一些预处理的必要条件。

另人意外的是,即使是“匿名”特权,我们仍然可以使用非常动态输入类型。这里不仅指常规XMLRPC的输入类型,如数组或Base64编码的字符串,而且指在系统中可用的不同对象类型。例如,“CustomerRepositoryInterface::save()”API函数允许我们在“$customer”变量是“CustomerInterface”对象的,正如以下原型中定义的:

如何通过RPC接口创建对象呢?有趣的是,这个问题的答案取决于Magento如何配置其SOAP服务器。

Magento使用PHP预装的SOAP服务器 – “SoapServer”。为了正确配置SOAP服务器,我们需要一个WSDL文件用于描述所有的方法、参数以及在某些RPC请求中使用的自定义类型。对于每一个支持XMLRPC功能的模块,Magento会生成一个不同的WSDL文件,其内容直接取自“webapi.xml”。

当服务器解析RPC请求时,服务器使用WSDL文件来判定该请求是否合法,检查请求的方法、参数、以及它们的数据类型。若请求合法,请求将交全Magento作进一步解析。必须指出,“SoapServer”并不与Magento直接交互,其对于模块的方法与参数的信息仅仅来自于WSDL文件。

至此,请求仍由嵌套数组组成 – 在SoapServer的解析阶段并没有创建对象实例。Magento将继续独立地处理输入来创建所需的实例。

如前文代码示例,Magento获取方法的原型以提取方法的参数和数据类型。通常来说,字符串,数组,布尔变量等,系统只是将输入转换为合适的类型。而对于对象,解决方案就有些复杂。

若参数的数据类型是一个类的实例,Magento的会尝试使用输入创建实例。请注意,到现在输入仅是一个字典 – 键是属性名称、值是属性值。

首先,Magento将创建一个所需类的新实例。然后,它会尝试使用以下算法来填充它:

  • 获取 属性名称(输入字典的“键”);
  • 寻找名为“Set[NAME]()”的公共方法,其中[NAME]是 属性名称;
  • 若该方法存在,将属性值作为参数执行该方法;
  • 若该方法不存在,则忽略并继续下一个。

Magento将对每个用户尝试设置的属性该算法进行设置。当所有属性均被检查后,Magento将认为实例已准备就绪,并继续处理下一个参数。当所有参数都处理完后,Magento将执行该API方法。

让我再捋一下,Magento允许你创建一个对象,设置其公共属性,并通过其RPC接口执行任何一个以“Set”为前缀的方法。出人意料的是,这种行为将会导致Magento的崩溃。

某些API调用允许我们设置购物车中的具体信息,如送货地址、产品,甚至付款方式。当Magento安全地在购物车实例中设置我们的信息时,它使用实例中的“save”方法,将新数据存储至数据库中。

让我们看一下“save”功能是如何实现:

Magento确保我们的对象是合法的,并序列化了所有应当被序列化的字段,将其存入数据库,最后再反序列化先前序列化过的字段。

是不是听起来很简单?

不。

让我们看一下Magento是如何决定哪些字段应该被序列化的:

可以发现,只有出现在事先已写死的应被序列化字段的字典“_serializableFields”中的字段才可以被序列化。接着,此方法在继续序列化之前,先会确保该字段的值是一个数组或对象。

现在,让我们再来看一下Magento是如何决定哪些字段应该被反序列化的:

好吧,这几乎是相同的。唯一不同的是,这一次Magento在操作之前确保该字段不是一个数组或对象。由于这两次检查,我们可以利用对象注入攻击 – 简单地给可序列化的字段设置一个普通字符串。

当我们给可序列化的字段设置一个普通字符串时,系统不会在存入数据库之前将其序列化,因为它不是对象或数组。但当系统在数据库查询执行之后尝试反序列化它时,该字符串将会被反序列化,因为它不是对象或数组。

有漏洞与安全的系统就在这一线之间,因为这个小到几乎不易察觉的条件判断。剩下的问题就是哪些字段是“可序列化”的,并且如何设置它们。

第一个问题很容易,我们只需搜索哪一个类定义了“_serializableFields”这个属性。很快,我发现若干个定义了这个字段的类,但没有一个可以被我们所钟爱的XMLRPC所创建。

诚然,其中一个名为“Payment”的类,其负责收集付款细节,出现在某一个API方法中,但并没有作为一个参数,所以我不能创建或控制它实例中的属性。紧接着,其可序列化的字段 – “additional_information”,只能使用常规的“Set[PROPERTY_NAME]”方法设置为一个数组。这作为一个安全措施,使得我们不仅无法通过XMLRPC创建它,更不能将其设为一个字符串。

但是,我们可以通过一个巧妙的方式来设置。

当Magento设置参数实例的属性时,其并没有真正设置参数实例的属性。相反,Magento将属性存放在一个字典结构中,名为“_data”。这个字典结构将会被应用于绝大多数情况。对于我们来说,我们的可序列化字段“additional_information”实际上是被存储在字典结构中,而不是通常意义的属性内。

因此,如果我们能够完全控制“_data”字典结构,我们就可以通过手动的设置来绕过对“additional_information”字段的数组限制,而不是使用“Set[PROPERTY_NAME]”方法。

但我们如何才能控制这个敏感的字典结构呢?

好吧,Magento将我们API的输入作为必须存入“Payment”实例的支付细节信息,在保存前会先修改实例的属性。这点可以参考以下方法:

可以看到,“Payment”数据通过调用“$method->getData()”方法获取,而实际上返回的是“$method”变量的“_data”属性。值得注意的是,“$method”是一个API调用时的参数,所以在我们的掌控之中。

当Magento调用“$method”参数的getData()方法时,返回的是该参数的“_data”属性,包含所有我们插入的支付信息。之后,Magento使用我们的“_data”属性作为调用“importdata()”的输入,正好用我们的“_data”属性取代 了“Payment”实例中原有的“_data”属性。

这是一个重大的进展。我们现在可以用我们掌控的“_data”属性取代 “Payment”实例中原有敏感的“_data”属性,这意味着我们可以设置“additional_information”字段了。

The problem is that for our unserialize() to work we need that field set to a string, but the “Set[PROPERT_NAME]” method only allows it to be an array.

现在的问题是,为了让我们的unserialize()得逞,我们需要将那个字段设置为一个字符串,但“Set[PROPERT_NAME]”方法限制其必须为一个数组。

但是,令人惊讶的是,因为“importData()“调用前的两行代码,我们的问题迎刃而解。

Magento一直以来致立于建立其最灵活的内容管理系统,允许开发者开发自己的支付方式,处理自身的数据和信息。因此,Magento使用用户自定义的“additional_data”来实现这一点。

“additional_data”字段是一个字典结构,包含了额外的完全由用户控制的支付方法相关的数据。为了使这些用户数据成为原始数据的一部分,Magento将“additional_data“字典结构与原始的“data”的字典结构合并在一起,高效地使用“additional_data”字典结构中的数据覆盖“data”字典结构中的相同字段,基本上意味着完全覆盖。

这意味着,两个字典结构合并后,用户控制的“additional_data”字典结构成为了参数中“_data”字典结构,并且由于“importData()”,更成为了“Payment”实例中敏感的“_data”属性。这意味着我们现在可以完全控制可序列化字段“additional_information”,并利用其进行对象注入攻击

反序列化

现在,我们可以根据需求反序列化任意字符串,是时候进行对象注入了。

首先,我们需要一个拥有“__wakeup()”或“__destruct()”方法的对象,这两个方法在对象被反序列化或销毁时会被自动调用。之所以需要这两个方法,是因为即使我们可以控制对象属性,我们仍然无法调用其方法。这就是为什么我们必须依靠PHP的魔术方法,其具体事件发生时,其会被自动调用。

第一个我们会使用的类是“Credis_Client”,包含了以下方法:

可以看出,该类确实有一个简单的“__destruct()”方法(该方法在对象被销毁时会被PHP自动调用),在“__destruct()”方法简单调用了“close()”方法。另一方面,更有趣的是,若存在有效的Redis连接,“close()”方法将尝试通过调用redis属性的“close()”方法关闭该连接。

因为“unserialize()”允许我们控制控制所有的对象属性,所以我们可以控制“redis”属性,同样,我们可以将任意的对象赋值给该属性 (不仅仅Redis类的实例),也可以随意地调用系统中任何类的“close()”方法。

事情一下豁然开朗。在Magento中存在一些其它的 “close()”方法。通常来说,“close()”方法用于终止流、关闭文件句柄、存储对象数据,因此我们一定能发现一些有趣的调用。让我们来看看“Transaction”类的“close()”方法:

这两个方法都非常简单。“close()”方法调用“save()”方法,后者调用了“_resource”属性的“save()”方法。

与之前逻辑相同,因为我们可以控制“_resource”的属性,继而控制具体的类,所以我们可以调用任何类中的任何“save()”方法。

这是一个重大的进展。我们可以大胆猜测,“save()”方法通常负责将一些数据存储在文件系统或数据库中。现在我们需要做的是寻找某一个文件系统工具类的“save()”方法。

这很容易:

这个方法做的就是将“components”写入一件文件中,文件名从“stat_file_name”中获取。因为我们可以直接修改这两个属性,于是这就是一个可以在服务器上任意写入文件的漏洞

于是,剩下我们只需要找到一个服务器可写、又可以通过网络访问的路径,就可以攻击成功了。其中之一就是“/pub”目录,在所有的Magento2.0的系统中必不可少。该目录通常用来存储图片、由管理员上传的公共文件,所以该目录必然对服务器可写。更重要的是,这个目录中的图片均需要被浏览器访问,所以其必然可以通过网络访问到。

最后,只需一段PHP代码,加上“.php”扩展名,我们就可以在一台未授权的服务器上执行任意的PHP代码了