611 words
3 minutes
Log4Shell

Companion code: top50vulns_2023 (recommended to read together).

Root Cause#

This vulnerability stems from an official Log4j feature called Message Lookup Substitution, which dynamically resolves certain placeholders.

For example, Running ${java:runtime} may be resolved into something like Running Java version 1.8xxx.

The docs describe it like this:

The JavaLookup allows Java environment information to be retrieved in convenient preformatted strings using the java: prefix.

You can retrieve Java-related information with keys such as:

KeyDescription
versionThe short Java version, like:Java version 1.7.0_67
runtimeThe Java runtime version, like:Java(TM) SE Runtime Environment (build 1.7.0_67-b01) from Oracle Corporation
vmThe Java VM version, like:Java HotSpot(TM) 64-Bit Server VM (build 24.65-b04, mixed mode)
osThe OS version, like:Windows 7 6.1 Service Pack 1, architecture: amd64-64
localeSystem locale and file encoding information, like:default locale: en_US, platform encoding: Cp1252
hwHardware information, like:processors: 4, architecture: amd64-64, instruction sets: amd64

Log4j also provides JNDI lookups, which is where the vulnerability comes from.

JNDI Lookup#

As of Log4j 2.17.0 JNDI operations require that log4j2.enableJndiLookup=true be set as a system property or the corresponding environment variable for this lookup to function. See the enableJndiLookup system property.

The JndiLookup allows variables to be retrieved via JNDI. By default the key will be prefixed with java/env/, however if the key contains a ”:” no prefix will be added.

The JNDI Lookup only supports the java protocol or no protocol (as shown in the example below).

Example configuration:

<File name="Application" fileName="application.log">
<PatternLayout>
<pattern>%d %p %c{1.} [%t] $${jndi:logging/context-name} %m%n</pattern>
</PatternLayout>
</File>

Because JNDI lookup exists, the vulnerability becomes possible. When JNDI is used together with LDAP, Log4j may fetch a remote Java class and deserialize/instantiate it. During that process, attacker-controlled code can run, leading to deserialization/RCE.

Payload example: ${jndi:ldap://ip:port}

Analysis & Reproduction#

Let’s build a demo to reproduce Log4Shell.

In Log4j2 there is an interface named StrLookup:

package org.apache.logging.log4j.core.lookup;
import org.apache.logging.log4j.core.LogEvent;
public interface StrLookup {
String CATEGORY = "Lookup";
String lookup(String key);
String lookup(LogEvent event, String key);
}

When Log4j2 sees a placeholder like ${prefix:key}, it will call the corresponding StrLookup implementation.

These implementations are stored in Interpolator as a Map<String, StrLookup>. You can set a breakpoint in project code and inspect what gets registered into Interpolator.

image-20230928013525232

When prefix is present, Interpolator.lookup will call the lookup method of the StrLookup mapped to that prefix. When prefix is jndi, this becomes JNDI injection:

image-20230928020051845

image-20230928020841071

The key part of this vulnerability is how the converter for msg (the MessagePatternConverter plugin instance) handles message content. In most scenarios, the “message” is attacker-controlled. MessagePatternConverter parses ${prefix:key} patterns and resolves them. When the prefix is jndi, it becomes exploitable.

The high-level flow:

When Log4j starts processing, AbstractOutputStreamAppender.directEncodeEvent obtains the layout and calls its encode method:

image-20230928125229635

In the default layout, PatternLayout.encode calls toText:

image-20230928125540683

toText obtains the serializer and calls toSerializable:

image-20230928125705420

Inside toSerializable, it iterates and uses the appropriate converter to process the input:

image-20230928135519938

Going further, in MessagePatternConverter it processes the ${prefix:key} syntax:

image-20230928141108185

You might notice differences like offset=68 and count=99 (a 31-character gap):

image-20230928141812113

image-20230928141752845

That’s because formatTo extracts the ${prefix:key} substring, reducing the length (31 in my case):

image-20230928142544264

image-20230928142701509

image-20230928142939212

image-20230928143110502

After that, Log4j calls into the lookup machinery (select the proper StrLookup and resolve the key). Eventually it reaches JndiManager.lookup and triggers an LDAP lookup:

image-20230928150520456

image-20230928145907568

image-20230928150108133

At this point, start a malicious JNDI server and point the payload to it:

image-20230928182725540

image-20230928183008647

About Bypasses#

The RC1 fix can be bypassed, but only when developers manually enable log4j2.formatMsgLookups=true or configure a layout like %msg{lookups}%n. Even so, it’s still a meaningful learning case.

For log4j2-rc1 with those settings enabled, there are still allowlists and stricter checks. But if a URISyntaxException is thrown, those restrictions can be bypassed: after the exception is caught, the logic re-enters the JNDI injection flow.

The fix commit is here:
https://github.com/apache/logging-log4j2/commit/bac0d8a35c7e354a0d3f706569116dff6c6bd658

Since the overall flow is similar, I won’t reproduce it again here.

For WAF bypasses, the basic idea is to abuse Log4j’s recursive/iterative parsing to build payloads, though most modern defenses can block them.

For high-version JDK bypass ideas, you can look into classes like org.apache.naming.factory.BeanFactory, etc.

Log4Shell
https://springkill.github.io/en/posts/log4shell/
Author
SpringKill
Published at
2023-09-28
License
CC BY-NC-SA 4.0