由于传播、利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,文章作者不为此承担任何责任。
version < 2.15.0-rc1
commit 分析
首先根据这两个commits 基本可以确定最终是通过lookup执行了JNDI攻击
LOG4J2-3198中可以发现一丝端倪
通过这两段diff,基本确认就是在MessagePatternConverter通过触发lookup形成了漏洞
另外可见官方Lookups
文档,这里大概知道怎么构造触发
分析
首先经过org.apache.logging.log4j.spi.AbstractLogger#logIfEnabled
,在这里isEnabled的作用是比较配置文件中Root设定的最低记录信息等级,确认是否需要记录,等级对应在org.apache.logging.log4j.spi.StandardLevel
,数字越大记录越全
@Override
public void logIfEnabled(final String fqcn, final Level level, final Marker marker, final String message,
final Throwable t) {
if (isEnabled(level, marker, message, t)) {
logMessage(fqcn, level, marker, message, t);
}
}
之后通过默认工厂类构造msg,(这里可能会有隐患,详见:关于自定义 log4j2.messageFactory 一节
private static MessageFactory2 createDefaultMessageFactory() {
try {
final MessageFactory result = DEFAULT_MESSAGE_FACTORY_CLASS.newInstance();
return narrow(result);
} catch (final InstantiationException | IllegalAccessException e) { ... }
}
执行到org.apache.logging.log4j.core.layout.PatternLayout.PatternSerializer#toSerializable
,这里会根据配置解析出的转换器进行数据的格式化并将内容填充到buffer
@Override
public StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) {
final int len = formatters.length;
for (int i = 0; i < len; i++) {
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;
}
当formatter为org.apache.logging.log4j.core.pattern.MessagePatternConverter#format
时,如果noLookups
未开启,则会尝试通过replace
根据表达式替换信息
@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) { ... } else { ... }
// 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);
workingBuilder.append(config.getStrSubstitutor().replace(event, value));
}
}
}
if (doRender) {
textRenderer.render(workingBuilder, toAppendTo);
}
return;
}
if (msg != null) { ... }
}
最后实际由org.apache.logging.log4j.core.lookup.StrSubstitutor#substitute
进行替换操作
由org.apache.logging.log4j.core.lookup.StrSubstitutor#resolveVariable
执行lookup表达式
并通过前缀判断进入到对应协议解析
后面就是常规JNDI利用,不再赘述(这里还未限制协议头
自定义 log4j2.messageFactory
首先msg通过messageFactory.newMessage(message)
产生,如果全以默认且仅传入message则默认进入org.apache.logging.log4j.message.ReusableMessageFactory#newMessage(java.lang.CharSequence)
@Override
public Message newMessage(final String message) {
final ReusableSimpleMessage result = getSimple();
result.set(message);
return result;
}
如果设定messageFactory为log4j2自有的factory
- FormattedMessageFactory
- LocalizedMessageFactory
- MessageFormatMessageFactory
- ParameterizedMessageFactory
- ParameterizedNoReferenceMessageFactory
- SimpleMessageFactory
- StringFormatterMessageFactory
则会进入org.apache.logging.log4j.message.AbstractMessageFactory#newMessage(java.lang.String)
@Override
public Message newMessage(final String message) {
return new SimpleMessage(message);
}
在记录event时,会通过org.apache.logging.log4j.core.impl.MutableLogEvent#setMessage
尝试提取msg信息,这里比较关键
public void setMessage(final Message msg) {
if (msg instanceof ReusableMessage) {
final ReusableMessage reusable = (ReusableMessage) msg;
reusable.formatTo(getMessageTextForWriting());
this.messageFormat = msg.getFormat();
if (parameters != null) {
parameters = reusable.swapParameters(parameters);
parameterCount = reusable.getParameterCount();
}
} else {
this.message = InternalAsyncUtil.makeMessageImmutable(msg);
}
}
public static Message makeMessageImmutable(final Message msg) {
// if the Message instance is reused, there is no point in freezing its message here
if (msg != null && !canFormatMessageInBackground(msg)) {
msg.getFormattedMessage(); // LOG4J2-763: ask message to makeMessageImmutable parameters
}
return msg;
}
以及后面开始执行格式化org.apache.logging.log4j.core.pattern.MessagePatternConverter#format
时注意第二部分,会直接无视lookup是否开启,尝试执行替换
所以关键点在控制以下两点的返回
- messageFactory.newMessage(message) - 返回非继承 ReusableMessage、StringBuilderFormattable、MultiformatMessage
- msg.getFormattedMessage() - 返回恶意 payload 字符串
所以如何控制呢,其实很简单
- getLogger时指定工厂类,不是ReusableMessage、MultiformatMessage对应Factory(而开发喜欢自定义的工厂类更具危险
- 看情况使用单参、多参构造,控制messageFactory.newMessage(…) 返回
很容易构造相关场景,这里暂不提供
version == 2.15.0-rc1
这个版本出现了bypass,也是提供了现实案例:官方的补丁不一定安全
commit 分析
很明显,通过异常报错绕过了一些限制,导致直接不安全的lookup(为什么需要这么绕过呢,因为JndiManager初始化时通过设置allowedProtocols与allowedHosts还有allowedClasses基本限制了ldap(s)的利用
另外注意到LOG4J2-3198在MessagePatternConverter创建实例时新增了判断,默认是SimpleMessagePatternConverter,如果格式化字符带有{lookup}选项时,则通过FormattedMessagePatternConverter创建
分析
通过一样的PoC,一直进入到MessagePatternConverter,这里发生了变化,原来的MessagePatternConverter.format全部通过新分配的XXXConverter.format实现,由于配置了lookup选项,所以交由org.apache.logging.log4j.core.pattern.MessagePatternConverter.LookupMessagePatternConverter#format
进行格式化(原来的MessagePatternConverter.format的修改也导致无法通过自定义factory进行绕过的风险
@Override
public void format(final LogEvent event, final StringBuilder toAppendTo) {
int start = toAppendTo.length();
delegate.format(event, toAppendTo);
int indexOfSubstitution = toAppendTo.indexOf("${", start);
if (indexOfSubstitution >= 0) {
config.getStrSubstitutor()
.replaceIn(event, toAppendTo, indexOfSubstitution, toAppendTo.length() - indexOfSubstitution);
}
}
最终一路进入到,可以看到如果想要触发异常,需要URI(name)
异常,这里就很好操作了(坏字符、空格…
public synchronized <T> T lookup(final String name) throws NamingException {
try {
URI uri = new URI(name);
if (uri.getScheme() != null) { ... }
}
} catch (URISyntaxException ex) {
// This is OK.
}
return (T) this.context.lookup(name);
}
关于触发条件
< 2.15.0-rc1
在org.apache.logging.log4j.core.pattern.PatternParser#finalizeConverter
很明显,需要进入到org.apache.logging.log4j.core.pattern.MessagePatternConverter#MessagePatternConverter
这个类需要有特征转换标记%m %msg %message
之一,转换标记在各个Converter中通过修饰符定义
而默认在 org.apache.logging.log4j.core.config.DefaultConfiguration#DEFAULT_PATTERN
定义有
public static final String DEFAULT_PATTERN = "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n";
当然如果开发自己写有配置文档,但是不带特征标记,也无法触发(但是谁会不记录msg呢?
>= 2.15.0-rc1
根据 LOG4J2-3198 - Log4j2 no longer formats lookups in messages by default
中的修改内容可以看到,启用lookup需要在占位符后添加{lookups}
声明(选项)才可执行lookup解析
所以需要配置类似有
<PatternLayout pattern="%d{HH:mm:ss} %msg{lookups}%n"/>
Waf Bypass
分析
表达式解析主要使用以下三个函数,通过深度遍历,解析所有存在的表达式
- org.apache.logging.log4j.core.pattern.MessagePatternConverter#format
- org.apache.logging.log4j.core.lookup.StrSubstitutor#substitute(org.apache.logging.log4j.core.LogEvent, java.lang.StringBuilder, int, int, java.util.List<java.lang.String>)
- org.apache.logging.log4j.core.lookup.Interpolator#lookup
代码比较长,做了个解析流程图
举几个例子方便理解流程和结果
表达式 | 解析流程 | 是否替换 | 结果 |
---|---|---|---|
${:-} |
1-2-4-6-7-8 | √ | 空字符串 |
${::} |
1-3-4-6-7 | × | |
${lower:d} |
1-3-4-5-8 | √ | d |
${clower:d} |
1-3-4-6-7 | × | |
${clower:hostName} |
1-3-4-6-8 | √ | 本地 hostname |
${c:d:lower:d} |
1-3-4-6-7 | × | |
${c:d:lower:-d} |
1-2-4-6-7-8 | √ | d |
${lower:${:-D}} |
1-1-2-4-6-7-8-3-4-5-8 | √ | d |
检测
这里只说纯表达式字符串在流量中的指纹匹配,初步就是检测${j
、${$
是否存在,解码获取完整内容匹配 jndi头、domain 信息等内容
修补方案
标准方案
- 设置
log4j2.formatMsgNoLookups=True
- 在 <2.15.0-rc1 之前,使用自定义factory实际有很大隐患,可能导致绕过
- 升级至
2.15.0-rc2
即2.15.0
- 可能需要升级JDK
非标准方案
PLUGIN_CACHE_FILE
首先直接上结论:删除META-INF/org/apache/logging/log4j/core/config/plugins/Log4j2Plugins.dat
里对应JNDI字符串即可
通过上面知道strLookupMap中存有允许的前缀及对应处理类,而其在org.apache.logging.log4j.core.lookup.Interpolator#Interpolator
中初始化有(看起来是这里
try {
// [LOG4J2-703] We might be on Android
strLookupMap.put(LOOKUP_KEY_JNDI,
Loader.newCheckedInstanceOf("org.apache.logging.log4j.core.lookup.JndiLookup", StrLookup.class));
} catch (final LinkageError | Exception e) {
handleError(LOOKUP_KEY_JNDI, e);
}
而在org.apache.logging.log4j.core.config.AbstractConfiguration
初始化有
private final StrLookup tempLookup = new Interpolator(propertyMap);
private final StrSubstitutor subst = new StrSubstitutor(tempLookup);
而实际最后是在这里填充可处理对象
public void setVariableResolver(final StrLookup variableResolver) {
if (variableResolver instanceof ConfigurationAware && this.configuration != null) {
((ConfigurationAware) variableResolver).setConfiguration(this.configuration);
}
this.variableResolver = variableResolver;
}
而configuration最终通过org.apache.logging.log4j.core.config.plugins.util.PluginRegistry#decodeCacheFiles
缓存文件获取加载类
所以删除META-INF/org/apache/logging/log4j/core/config/plugins/Log4j2Plugins.dat
里对应JNDI字符串即可
其他魔法
各种临时性的hook或者卸载对甲方来说并不实用,在此不表
官方GIT时间线
时间 | 行为 | 链接 |
---|---|---|
2021年12月5日 GMT+8 下午12:00 | 限制JNDI | LOG4J2-3201 - Limit the protocols JNDI can use by default. Limit the servers and classes that can be accessed via LDAP. |
2021年12月5日 GMT+8 下午3:20 | 默认nolookup | LOG4J2-3198 - Log4j2 no longer formats lookups in messages by default |
2021年12月10日 GMT+8 上午2:18 | 修复rc1异常绕过 | Handle URI exception |
2021年12月12日 GMT+8 上午7:05 | 默认禁用JNDI | LOG4J2-3208 - Disable JNDI by default |
2021年12月13日 GMT+8 下午1:32 | 删除Message Lookups | LOG4J2-3211 - Remove Messge Lookups (开发急的…英文都打错了) |
附言说说1.x版本
看有说要注意 log4j 一代可能也有安全问题,翻了下源码感觉实在没什么可说的,这么显式的lookup下还能出问题,纯属开发背锅