• 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     //系统入口文件
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    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',
    ];
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    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',
        ],
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    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<>'"
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    可见,对于原始的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)
    
    • 1
    • 2
    • 3
    • 4

    可见,通过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 '';
            }
        }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33

    可见,在这里通过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;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    可以看在这里,进行回复消息处理,然后判断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 = "
                            <![CDATA[%s]]>
                            
                            
                            
                        
                    ";
            $item_str = "";
      			//获取回复内容,输出到$item_str
            foreach ($arr_item as $item) {
                $item_str .= sprintf($itemTpl, $item['Title'], $item['Description'], $item['PicUrl'], $item['Url']);
            }
            $newsTpl = "
            
            
            %s
            
            
            %s
            $item_str
            %s
            ";
      			//通过sprintf函数返回6个节点,其中ToUserName、FromUserName来自于我们传入的XML文档,所以可控。所以也就能通过FromUserName或者ToUserName够构造paylaod来读取文件或者执行命令。
            $resultStr = sprintf($newsTpl, $postObj->FromUserName, $postObj->ToUserName, time(), count($arr_item), $funcFlag);
            return $resultStr;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    2、Payload分析

    这里使用下面这个paylaod:

      
    //构造XML实体,读取系统文件
    DOCTYPE xxe [<!ELEMENT name ANY >]>                                 
    <root>
      <MsgType>textMsgType>            //构造msgType为text,使其调用MsgTypeText()函数
      <ToUserName>&xxe; ToUserName>    //通过ToUserName代用XML实体&XXE,也可以使用FromUserName
    root>  
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    如果要进行复现,就只需要访问我们的的/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);  //读取文件进行输出
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

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

    任意文件删除:

    漏洞点:/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
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

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

    四、任意文件上传

    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;
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    可见,这里直接获取了文件明和文件函数,然后使用了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;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

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

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

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

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

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

    五、参考资料

  • 相关阅读:
    终端便捷ssh(免密)连接
    华为设备总部与分部配置
    kotlin实现LRUCache
    springboot毕设项目车位预定管理系统76ov7(java+VUE+Mybatis+Maven+Mysql)
    Spring Cloud和Dubbo有哪些区别?
    与AI对话的艺术:如何优化Prompt以获得更好的响应反馈
    STM32F1与STM32CubeIDE编程实例-金属触摸传感器驱动
    【JavaScript】一文了解定时器的使用
    微信小程序(小程序入门)
    软考高级系统架构 上午真题错题总结
  • 原文地址:https://blog.csdn.net/qq_45590334/article/details/126503369