0x00 前言
2020年4月25日,Sophos发布了安全公告,其中提到了影响XG Firewall产品线的一个未授权SQL注入(SQLi)漏洞。根据官方描述,攻击者至少从4月22日以来就开始积极利用该漏洞。在安全公告发表不久后,Sophos发表了对Asnarök攻击活动的详细分析。之前的安全公告主要关注SQLi漏洞,后续的分析报告表明,攻击者通过某种方式扩展了攻击技术,最终实现了远程代码执行(RCE)。
基于这个漏洞的严重性,我们也开始研究其中技术细节。由于该漏洞影响的设备特殊,且具备RCE条件,因此可能帮助红队打破安全边界。然而根据下文分析,漏洞利用过程可能并不像我们最初设想的那么顺利。
在分析过程中,我们不仅复现了已公开漏洞(CVE-2020-12271)的RCE代码,也找到了另一个SQLi漏洞,该漏洞也能实现代码执行(CVE-2020-15504)。新漏洞的严重程度与Asnarök攻击活动中用过的漏洞相同:可以通过用户或管理员页面实现未授权利用。Sophos迅速响应了我们的报告,为目前支持的固件版本发布了补丁,也为v17.5及v18.0发布了新的固件版本(参考Sophos社区公告)。
0x01 环境配置
这里我们不详细介绍如何部署实验环境,XG防火墙虚拟环境搭建起来非常简单,只需要从官方下载页面获取匹配的固件ISO即可。需要注意的是,该固件允许管理员直接通过串口获取root shell,因此不需要考虑受限shell逃逸问题,可以直接开始分析。
图1. Device Management -> Advanced Shell -> root权限的/bin/sh
大概了解文件系统结构、开放的端口及运行的进程后,我们注意到XG控制中心中有一条消息,提示我们正在研究的历史漏洞已经有补丁程序。
图2. 自动安装补丁后的控制中心(来源)
根据这种方式,我们分别在打补丁之前和之后创建了文件系统快照,diff这两个快照的web根目录后,我们发现官方只修改了一个文件,其中并没有关于修复SQL操作的直接改动。
0x02 架构分析
为了理解补丁逻辑,我们需要深入分析底层软件架构。根据官方公告,该漏洞可以通过web接口触发,因此我们比较关系服务器如何处理收到的HTTP请求。
用户和管理员web接口都基于同样的Java代码,由Apacher服务器后面的Jetty服务器提供服务。
图3. Jetty服务器在8009
端口监听,服务接口为/usr/share/webconsole
与大多数接口的交互行为(如登录动作)都会向/webconsole/Controller
端点发送HTTP POST请求,这类请求中至少包含2个参数:mode
及json
。第一个参数指定了一个数值,在内部映射到被调用的某个函数。第二个参数指定了调用该函数时的参数。
图4. 通过XHR向/webconsole/Controller
发送登录请求
对应的Servlet会检查所请求的参数是否需要身份认证,执行一些基本的参数校验操作(具体代码取决于被调用的函数),然后向另一个组件(CSC)发送消息。
if (151 == eventBean.getMode()) {
try {
final PrintWriter writer3 = httpServletResponse.getWriter();
if (httpServletRequest.getParameter("json") != null) {
final JSONObject jsonObject5 = new JSONObject();
final CSCClient cscClient4 = new CSCClient();
final JSONObject jsonObject6 = new JSONObject(httpServletRequest.getParameter("json"));
final int int1 = jsonObject6.getInt("languageid");
final int generateAndSendAjaxEvent = cscClient4.generateAndSendAjaxEvent(httpServletRequest, httpServletResponse, eventBean, sqlReader);
这个消息采用自定义格式,通过UDP或者TCP协议发送到本地主机的299
端口(防火墙)。消息中包含一个JSON对象,与原始HTTP请求中的json
参数类似,但不相同。
图5. 发送给299
端口上CSC的JSON对象
CSC组件(/usr/bin/csc
)似乎采用C编写,由多个子模块组成(类似于busybox二进制程序)。根据我们的理解,这个程序为防火墙的服务管理器,其中包含其他一些作业,也可以启动和控制这些作业。在针对Fortinet的研究过程中,我们也遇到过类似的架构。
图6. CSC程序生成的一些进程
CSC会解析收到的JSON对象,使用提供的参数来调用所请求的函数。然而这些函数采用Perl实现,通过Perl C语言接口来调用。为了完成该任务,该程序会加载并解密经过XOR加密的文件(cscconf.bin
),该文件中包含各种配置文件及Perl包。
这个架构中还有一点比较重要:web接口、CSC及Perl代码逻辑同时使用了不同的PostgreSQL数据库实例。
图7. 服务端使用了3个PostgreSQL数据库
图8. 整体架构视图
0x03 定位Perl逻辑
前面提到过,Java组件会将修改版的JSON参数(原始版本位于HTTP请求中)发送给CSC程序,因此我们开始观察这个文件。反汇编工具能帮助我们检测分布在多个内部函数中的不同子模块,但并没有提供关于登录请求的任何逻辑。然而我们还是找到了与Perl C语言接口有关的大量import
,因此我们猜测相关逻辑存放在外部Perl文件中。我们搜索文件系统后,并没有返回有用的信息。最终我们发现Perl代码以及各种配置文件存放在加密的tar.gz
文件中(/_conf/cscconf.bin
),该文件会在CSC初始化时解密并提取出来。之前我们之所以无法找到经过加密的文件,是因为这些文件位于独立的Linux命名空间中。
如下图所示,该程序会创建一个挂载点,使用0x20000
标志来调用unshare
syscall。这个值对应的是CLONE_NEWNS
标志,用来解除进程与初始挂载命名空间的关联。
这里介绍一下Linux命名空间的背景信息:通常情况下,每个进程都关联一个命名空间,只能看到并使用与该命名空间关联的资源。将自身从初始命名空间分离后,程序可以确保在unshare
syscall后创建的所有文件不会被其他进程获取。命名空间是Linux内核的一个功能,是docker之类的容器非常依赖的基础特性。
图9. 在提取配置文件前,调用unshare
将自身与初始命名空间分离
因此,即使在root shell中,我们也无法访问提取出的文件。然而我们有多种办法可以绕过该限制,最常用的方法是简单patch目标程序。通过这种方式,我们可以将提取出的配置文件拷贝到全局可写的一个路径中。事后经我们分析,使用nsenter
可能是更好的一种方法。
图10. 转入CSC程序命名空间后,可访问解密后释放出来的文件
0x04 CVE-2020-12271
官方补丁修改了已有的一个函数(_send
),并在/usr/share/webconsole/WEB-INF/classes/cyberoam/corporate/CSCClient.class
中引入了2个新函数(getPreAuthOperationList
以及addEventAndEntityInPayload
)。
getPreAuthOperationList
函数定义未授权下可以调用的所有mode
。addEventAndEntityInPayload
会检查请求中指定的mode
是否包含在preAuthOperationsList
中,如果满足该条件,则删除JSON对象中的Entity
及Event
属性。
private static ArrayList<Integer> getPreAuthOperationList() {
ArrayList<Integer> modeList = new ArrayList<Integer>();
modeList.add(151);
modeList.add(1503);
[...]
return modeList;
}
private void addEventAndEntityInPayload(HttpServletRequest req, JSONObject reqJson, int mode) {
try {
if (PRE_AUTH_OPERATIONS.contains(mode)) {
[...]
reqJson.remove("Event");
reqJson.remove("Entity");
CyberoamLogger.debug((String)"CSC", (String)("Request payload after sanitization: " + reqJson.toString()));
}
[...]
}
}
漏洞分析
根据补丁,我们猜测漏洞应该位于getPreAuthOperationList
中指定的某个函数。我们浏览了相关的Perl代码,想寻找使用Entity
或者Event
属性的逻辑,却发现情况并非如此。
但在这个过程中我们注意到一点:不论我们指定哪种mode
,每个请求都会由apiInterface
函数处理。Sophos在内部通过操作码(opcode)方式将函数映射成mode
参数。
opcode apiInterface{
CALL validateRequestType
CALL variableInitialization
[...]
CALL isAPIVersionSupported
CALL opcodePreProcess
FOR ("$ind=0";"$ind<scalar(@reqEntitiesArr)";"$ind++") {
$currRequest=$reqEntitiesArr[$ind];
if($requestType eq $REQUEST_TYPE{MULTIREQUEST}){
print "\n\n\t ============ Handling request -- Entity = $currRequest->{Entity}";
$request=$currRequest->{reqJSON};
$request->{Event}=uc($currRequest->{Event});
$request->{Entity}=lc($currRequest->{Entity});
}
CALL checkUserPermission
CALL preMigration
CALL createModeJSON
CALL migrateToCurrVersion
CALL createJson
CALL validateJson
CALL handleDeleteRequest
CALL replyIfErrorAtValidation
CALL getOldObject
IF("$entityJson->{Event} eq 'DELETE' && defined $modeJson->{ORM} && $modeJson->{ORM} eq 'true'"){
CALL executeDeleteQuery
}
}
[...]
apiInterface
函数也是我们最终发现SQLi漏洞的位置所在。如下部分源码所示,这个opcode会调用executeDeleteQuery
函数(第27行),该函数接收来自query
参数的SQL语句,在目标数据库中执行。
FUNCTION executeDeleteQuery{
@queryToExecute=@{$request->{query}};
FOR("$q=0";"$q<scalar(@queryToExecute)";"$q++") {
QUERY "$queryToExecute[$q]"
}
ON_FAIL{
QUERY "rollback"
%responsej=("status"=>"500","statusmessage"=>"Records Deletion Failed.","deleteObjects"=>\@deletingObjects,"references"=>$references);
REPLY %responsej 500
}
}
不幸的是,为了执行到漏洞代码,我们的payload需要通过前面的每个CALL语句,这些语句会在我们的JSON对象上设置各种条件及属性。
第一个调用(validateRequestType
)要求Entity
的值不为securitypolicy
,并且在调用后请求类型为ORM
。
FUNCTION validateRequestType{
IF("defined $request->{Entity} && defined $request->{Event}"){
IF("$request->{Entity} eq '' || $request->{Event} eq ''"){
Log applog "\n\n Error ----> Entity and Event is defined but NULL value is passed...! !\n"
FAIL
}
[...]
IF("$request->{Entity} eq 'securitypolicy' && $request->{Event} eq 'DELETE' && scalar(@{$request->{name}}) > 1"){
[...]
}
}ELSE IF("defined $request->{reqEntities}"){
IF("scalar(@{$request->{reqEntities}}) == 0"){
FAIL
}
[...]
variableInitialization
用来初始化Perl环境,始终能够执行成功。为了让我们的请求保持简洁性,不引入其他前提条件,我们payload中的Entity
值不能等于这些值:
securityprofile
mtadataprotectionpolicy
dataprotectionpolicy
firewallgroup
securitypolicy
formtemplate
authprofile
这样我们就能跳过opcodePreProcess
函数中的检查逻辑。
checkUserPermission
函数的功能与函数名一致。函数体如下所示,只有当传递给Perl的JSON对象中包含__username
参数时,函数体才会被执行。如果HTTP请求关联有效的用户会话,那么在请求被转发给CSC程序之前,Java组件会在其中添加这个参数。由于我们在payload中使用的是未经身份认证的mode
,因此请求中不会设置__username
参数,我们可以忽略相应的代码。
FUNCTION checkUserPermission{
IF("defined $request->{___username} && '' ne $request->{___username} && 'LOCAL' ne $request->{___username}"){
IF("(! defined $request->{currentlyloggedinuserid}) || '0' eq $request->{currentlyloggedinuserid} "){
curLogOut = QUERY "select userid from tbluser where username= '$currentUser'"
IF(" defined $curLogOut->{output}->{userid} && $curLogOut->{output}->{userid}[0] ne ''"){
[...]
为了跳过preMigration
调用,我们只需要选择不等于35
(cancel_firmware_upload
)、36
(multicast_sroutes_disable
)或者1101
(unknown
)的mode
即可。这3种mode
都需要身份认证,因此对我们的攻击场景来说意义不大。
根据请求的类型,createModeJSON
函数会使用不同的逻辑来加载连接到特定实体的Perl模块。尽管每个POST请求最初都为ORM请求,我们还是需要确保请求类型没有被改成其他类型。apiInterface
函数内部调用存在漏洞的函数之前有个if
语句,只有类型不变才能通过该语句。因此下面代码中第15行的条件肯定不满足,代码会检查已加载的Perl模块中指定的请求类型是否等于ORM。我们将识别这类Entity的工作留给大家来完成。
FUNCTION createModeJSON {
IF("$requestType == $REQUEST_TYPE{NORMALREQUEST}"){
$modeJson = getHashFromMode($request->{mode});
$packName=$modeJson->{entityFilename};
require $apiPath.$modeJson->{entityFilename};
}ELSE IF("$requestType == $REQUEST_TYPE{ORMREQUEST} || $requestType == $REQUEST_TYPE{MULTIREQUEST}"){
if(defined $request->{Entity}){
$packName=$ENTITYMAP->{$request->{Entity}};
print "\n\n package Name=$packName";
eval "use $packName";
$propertyObj="\$$packName"."::EventProperties";
$objecto=eval $propertyObj;
$modeJson = $objecto->{$request->{Event}};
$modeJson->{entityFilename}=$packName;
if((defined $modeJson->{ORM} && $modeJson->{ORM} ne 'true') || (!defined $modeJson->{ORM})){
$requestType = $REQUEST_TYPE{NORMALREQUEST};
$modeJson = getHashFromMode($request->{mode});
require $apiPath.$modeJson->{entityFilename};
}
}
}
}
我们跳过对migrateToCurrVersion
函数的分析,该函数对漏洞利用链来说不重要。代码随后会调用createJson
,验证前面加载的Perl包能否被初始化。只要引用的是已有的Entity,肯定能通过这个检查。
FUNCTION createJson{
IF("$modeJson->{entityFilename} eq \"\""){
<code>
$entityJson=$request;
print "\n\n MODE:$request->{mode} FILE NOT FOUND\n";
</code>
}ELSE{
<code>
$Package=$modeJson->{entityFilename};
print "\n PAckage ::::$Package";
eval "require $Package";
$entityJson=new $Package($request);
if(!$entityJson->can('new')){
print "\n\n PACKAGE:$Package NOT Found OBJECT NOT CREA TED\n";
}
</code>
}
}
handleDeleteRequest
函数会再次校验请求类型是否为ORM
。从我们的JSON中删除重复的属性后,该函数会确保我们的JSON payload中包含一个name
属性。随后代码会循环遍历我们在name
属性中指定的所有值,在其他数据库表中搜索外部引用以便删除对应表项。由于我们不想删除已有的任何数据,我们只需要将name
设置为不存在的值即可。
我们可以直接跳过最后两个函数(replyIfErrorAtValidation
以及getOldObject
),这两个函数与我们的利用链无关,现在我们已经完成大多数Perl代码的分析工作。
目前我们了解到如下知识:
1、我们需要设置可以未授权调用的mode
;
2、我们不应当使用某些Entity;
3、我们的请求必须为$REQUEST_TYPE{ORMREQUEST}
类型;
4、请求中必须包含name
属性,其中保存某些垃圾值;
5、已加载的Entity的EventProperties
(尤其是DELETE
属性)必须将ORM
值设置为true
;
6、我们的JSON对象必须包含一个query
属性,其中保存我们想执行的SQL语句。
当我们满足以上所有条件时,我们就可以执行任意SQL语句。这里有一个限制条件:我们不能在SQL语句中使用任何引号,因为csc程序会正确转义这些符号。因此我们使用concat
以及chr
SQL函数来定义字符串。
从SQLi到RCE
一旦我们能够根据需求来修改数据库,就可以通过某些方式将SQLi拓展为RCE,这是因为我们在多个地方发现数据库中包含的参数会传递给exec
调用,没有经过正确的过滤。为了实现RCE,我们根据自己的理解,借鉴Sophos对Asnarök攻击活动的分析报告,最终形成攻击路径。
根据官方提供的信息,攻击者将payload注入Sophos Firewall Manager(SFM)的hostname
字段以实现代码执行。SFM是用来管理多个单元的独立单元。这样自然引出一个问题:如果我们启用中心管理后,后端服务会出现什么变化呢?
为了寻找与SFM功能有关的数据库值,我们dump出数据库,在前端启用SFM,然后再次执行dump操作。对比这两次dump的差别后,我们可以发现有改动的地方,从而找到了多处被修改的数据库行。我们发现攻击者使用表tblclientservices
中的CCCAdminIP
属性来注入payload。grep查找CCCAdminIP
后,我们在Perl代码中找到了get_SOA
函数。
opcode get_SOA;
opcode get_SOA with attributes no_wait {
is_eula = NOFAIL EXECSH "/bin/nvram qget is_eula"
IF ("$is_eula->{status} eq '0'") {
TIMER get_SOA:add oncejob nosync "minutes 30" : opcode get_SOA
RETURN 200
}
[...]
#check if hotfixes are automatically installed
accepthotfixes = QUERY "select value from tblconfiguration where key='accepthotfixes'"
IF("$accepthotfixes->{output}->{value}[0] eq 'on'"){
[...]
# Our payload was inserted into CCCAdminIP and is here received from the database
out = QUERY "select servicevalue from tblclientservices where servicekey in ('CCCEnabled','CCCAdminIP','CCC_signature_distribution','CCCFWVersion') order by servicekey='CCCEnabled' desc,servicekey='CCCAdminIP' desc,servicekey='CCC_signature_distribution' desc,servicekey='CCCFWVersion' desc"
$CCCEnabled = $out->{output}->{servicevalue}[0];
$CCCAdminIP = $out->{output}->{servicevalue}[1];
$CCC_signature_distribution = $out->{output}->{servicevalue}[2];
$CCCFWVersion = $out->{output}->{servicevalue}[3];
[...]
out = EXECSH "echo $CCC_signature_port,$CCCAdminIP > /tmp/up2date_servers_srv.conf"
如上述代码15行所示,代码会从数据库中检索CCCAdminIP
的值,然后未经过滤,将其传递给第22行的EXECSH
调用。在某些cron
作业任务的帮助下,get_SOA
opcode会定期执行,因此会自动执行我们的payload。
这个利用链有个缺点,观察第11行的if
语句,只有自动安装补丁设置处于激活状态时(这是默认设置),并且设备使用SFM用来实现中心管理时,我们才能访问到EXECSH
调用。在这种情况下,攻击者很有可能只能在激活了自动更新的设备上才能实现代码执行,这将导致竞争条件:补丁安装时机与漏洞利用时机需要抢占先机。
未设置自动更新的设备或者未升级到最新版本的设备仍可能存在漏洞。
图11. CVE-2020-12271:通过SQLi实现代码执行