Learning Man's Blog

Log4shell 小记

字数统计: 2.2k阅读时长: 10 min
2021/12/14

由于传播、利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,文章作者不为此承担任何责任。

version < 2.15.0-rc1

commit 分析

首先根据这两个commits 基本可以确定最终是通过lookup执行了JNDI攻击

272ead80ffed4144b536829a1312aac9

LOG4J2-3198中可以发现一丝端倪

9f77e65bf41448d9b8394bd5ba3c8775

0a0bb15def3c40a38a9ff873ade0ae24

通过这两段diff,基本确认就是在MessagePatternConverter通过触发lookup形成了漏洞

另外可见官方Lookups文档,这里大概知道怎么构造触发

fc3a53e4edf6417db5f0833067a6dba8

分析

首先经过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表达式

并通过前缀判断进入到对应协议解析
7c2e017a39584e83a011826291233312

后面就是常规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是否开启,尝试执行替换

03ee5f0b5af94c3e8e5e8a27471fcd56

所以关键点在控制以下两点的返回

  1. messageFactory.newMessage(message) - 返回非继承 ReusableMessage、StringBuilderFormattable、MultiformatMessage
  2. msg.getFormattedMessage() - 返回恶意 payload 字符串

所以如何控制呢,其实很简单

  1. getLogger时指定工厂类,不是ReusableMessage、MultiformatMessage对应Factory(而开发喜欢自定义的工厂类更具危险
  2. 看情况使用单参、多参构造,控制messageFactory.newMessage(…) 返回

很容易构造相关场景,这里暂不提供

version == 2.15.0-rc1

这个版本出现了bypass,也是提供了现实案例:官方的补丁不一定安全

commit 分析

ea42bbc5f05a4cf796789b7e2e8a83af

很明显,通过异常报错绕过了一些限制,导致直接不安全的lookup(为什么需要这么绕过呢,因为JndiManager初始化时通过设置allowedProtocols与allowedHosts还有allowedClasses基本限制了ldap(s)的利用

b6fa61e593f945e89278b35ed553e256

另外注意到LOG4J2-3198在MessagePatternConverter创建实例时新增了判断,默认是SimpleMessagePatternConverter,如果格式化字符带有{lookup}选项时,则通过FormattedMessagePatternConverter创建

e712a600e6704c79ace4033855eaef8a

分析

通过一样的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

2a3f499fe1f44d88a6c9ff825232bc6d

很明显,需要进入到org.apache.logging.log4j.core.pattern.MessagePatternConverter#MessagePatternConverter这个类需要有特征转换标记%m %msg %message之一,转换标记在各个Converter中通过修饰符定义

e99ceba45b5f4d52b932574d174ff07d

而默认在 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解析

cf1ca4fd61824f459280a9acfd0825e1

所以需要配置类似有

<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

代码比较长,做了个解析流程图

map

举几个例子方便理解流程和结果

表达式 解析流程 是否替换 结果
${:-} 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 信息等内容

修补方案

标准方案

  1. 设置log4j2.formatMsgNoLookups=True
    • 在 <2.15.0-rc1 之前,使用自定义factory实际有很大隐患,可能导致绕过
  2. 升级至2.15.0-rc22.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);

而实际最后是在这里填充可处理对象

08c804856ef5414497721122dd3b1cd3

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缓存文件获取加载类

d7273e6a7ae448deb7e99d27b7d8f425

所以删除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下还能出问题,纯属开发背锅

CATALOG
  1. 1. version < 2.15.0-rc1
    1. 1.1. commit 分析
    2. 1.2. 分析
    3. 1.3. 自定义 log4j2.messageFactory
  2. 2. version == 2.15.0-rc1
    1. 2.1. commit 分析
    2. 2.2. 分析
  3. 3. 关于触发条件
    1. 3.1. < 2.15.0-rc1
    2. 3.2. >= 2.15.0-rc1
  4. 4. Waf Bypass
    1. 4.1. 分析
    2. 4.2. 检测
  5. 5. 修补方案
    1. 5.1. 标准方案
    2. 5.2. 非标准方案
      1. 5.2.1. PLUGIN_CACHE_FILE
      2. 5.2.2. 其他魔法
  6. 6. 官方GIT时间线
    1. 6.1. 附言说说1.x版本