/app //系统程序所在目录
common //公共模块
model_name //home模块目录
common.php //模块函数文件
contraller //控制器目录
model //模型目录
view //视图目录
/extend //拓展程序所在目录
/plugins //系统插件所在目录
/public //公开文件所在目录,包括html、js、图片等
/runtime //运行目录
/think //thinkphp框架目录
/vender //thinkphp 依赖环境目录
/index.php //系统入口文件
前台路由文件: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',
];
系统配置文件: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',
],
原始全局变量获取:$_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方法获取的参数也同样不会经过全局过滤。所以存在一定的安全风险。
首先我们通过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_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;
}
这里使用下面这个paylaod:
//构造XML实体,读取系统文件
DOCTYPE xxe [<!ELEMENT name ANY >]>
<root>
<MsgType>textMsgType> //构造msgType为text,使其调用MsgTypeText()函数
<ToUserName>&xxe; ToUserName> //通过ToUserName代用XML实体&XXE,也可以使用FromUserName
root>
如果要进行复现,就只需要访问我们的的/wchat/Wchat/getMessage,然后使用POST方式提交我们的payload即可。
任意文件下载:
首先我们进入后台,找到数据库管理模块,先备份一个数据库文件,然后进入还原数据库选项:
在这里就可以进行文件的下载与删除,我们先进行文件下载,抓包后观察数据包,发现是通过传入文件名的方式下载的文件:
我们将文件名修改为系统文件,并使用目录穿越符穿越到根目录下然后发动数据包,发现成功下载了系统的ini文件,说明存在任意文件下载漏洞。
任意文件删除:
这里我们还是先抓包,发现依然使用的传输文件名的方式来进行删除:
我们还是修改文件名为系统中的任意文件,比如win.ini:
可以看到,根据提示,我们已经成功删除了。然后我们在使用任意文件下载来验证一下,是否确实已经删除了:
可见,提示文件不存在,说明文件确实已经被删除了。
任意文件下载:
根据黑盒测试的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;
}
}
可见,漏洞原因也是未经过任何过滤就将文件名拼接到文件路径中,导致了任意文件删除漏洞。
我们进入会员中心,然后上传一个图片之后抓包:
修改文件名和文件内容如下图:
提示上传成功,我们访问一下该文件:public/upload/20220819/ae022e7c463afa05ea2dde9a4825270d.php
可见成功的上传并执行了phpinfo()。说明漏洞存在。
我们进入漏洞点: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目录下。
所以可以看到,这里并没有对文件上传操作进行任何的安全检测,导致了任意文件长传。