This is a lookup-trust lab, not a Log4Shell exploit write-up. Target is a 30-line Java main that interpolates a user string into a logger, plus a disabled JNDI path I keep behind a flag I never turn on in this notebook. Goal: show the message that would have been a lookup, show the system properties that turn lookups off, and show a local NamingException when I resolve a loopback name. I do not run an LDAP server, I do not serve a Java class, I do not set trustURLCodebase.

1Figure 1. A log sink that evaluates lookups is a name-resolution trust boundary.
2log.info("ua={}", header)
3  lookups ON  ->  InitialContext.lookup(...)
4  lookups OFF ->  literal string in the log

Lab layout

1labs/jndi_lab/
2  LogToy.java          # log4j2 2.14-shaped demo, lookups off
3  log4j2.xml
4  run.sh

I pin an old log4j only inside a throwaway VM so the property names match 2021 incident notes. I do not copy that VM image off the box.

 1// LogToy.java — lab, no network bind
 2import org.apache.logging.log4j.LogManager;
 3import org.apache.logging.log4j.Logger;
 4
 5public class LogToy {
 6    private static final Logger log = LogManager.getLogger(LogToy.class);
 7
 8    public static void main(String[] args) {
 9        String ua = args.length > 0 ? args[0] : "-";
10        // defender view: this string came from an HTTP header
11        log.info("request ua={}", ua);
12    }
13}
 1<!-- log4j2.xml : pattern that historically expanded lookups -->
 2<Configuration status="WARN">
 3  <Appenders>
 4    <Console name="c" target="SYSTEM_OUT">
 5      <PatternLayout pattern="%d{ISO8601} %p %m%n"/>
 6    </Console>
 7  </Appenders>
 8  <Loggers>
 9    <Root level="info"><AppenderRef ref="c"/></Root>
10  </Loggers>
11</Configuration>

Artifact: the lookup syntax in a header, lookups disabled

I pass a redacted user-agent that matches the 2021 shape without being a working exploit string. Host is loopback, class name is fake, no protocol gadget.

1$ java -Dlog4j2.formatMsgNoLookups=true \
2    -cp log4j-core.jar:log4j-api.jar:. LogToy \
3    '${jndi:ldap://127.0.0.1:1/x}'
42022-01-28T10:12:01,441 INFO request ua=${jndi:ldap://127.0.0.1:1/x}

The message is literal. That is the post-mitigation baseline. The same string without the property, on a vulnerable 2.14, would have asked JNDI to resolve ldap://127.0.0.1:1/x. I am not running that resolution against a real directory. Port 1 on loopback refuses the connection; I use it only in a unit test of the disable flag, not as an exploit.

What I refuse to put in this notebook: a working jndi:ldap://attacker/... URL, a marshalled Reference, a codebase URL, a javax.naming.spi.ObjectFactory gadget.

How a lookup is just a Java call

The dangerous API, independent of log4j, is:

1// conceptual — do not call this on attacker input
2javax.naming.Context ctx = new javax.naming.InitialContext();
3Object obj = ctx.lookup(userString);   // name → object, possibly remote

I keep a local-only exercise that looks up a nonexistent in-memory name so the exception is the artifact:

 1// LookupFail.java
 2import javax.naming.*;
 3public class LookupFail {
 4    public static void main(String[] args) throws Exception {
 5        Hashtable<String,String> env = new Hashtable<>();
 6        env.put(Context.INITIAL_CONTEXT_FACTORY,
 7                "com.sun.jndi.fscontext.RefFSContextFactory");
 8        env.put(Context.PROVIDER_URL, "file:///tmp/jndi_lab");
 9        Context ctx = new InitialContext(env);
10        try {
11            ctx.lookup("does-not-exist");
12        } catch (NamingException e) {
13            System.out.println("LOOKUP_FAIL " + e.getClass().getSimpleName());
14            System.out.println("msg " + e.getMessage());
15        }
16    }
17}
1$ java LookupFail
2LOOKUP_FAIL NameNotFoundException
3msg does-not-exist

That is JNDI as a dictionary. Log4Shell was “the logger called something like lookup() on a substring of the message”. Once you see it as a function call, “we only log” stops being a safety claim.

Sanitized reproduction (exception / timeout only)

With lookups forced off, I still want a crash-like artifact for the IR playbook: a connection refused when someone tests a mis-patched box against loopback.

1# THIS IS A NEGATIVE TEST. Destination is 127.0.0.1:1 (kernel discards).
2# No LDAP daemon is listening. No class is loaded.
3$ timeout 2 java -Dcom.sun.jndi.ldap.object.trustURLCodebase=false \
4    LookupTcp  # tiny class that calls new InitialContext().lookup("ldap://127.0.0.1:1/x")
5LOOKUP_FAIL CommunicationException
6msg 127.0.0.1:1  [REDACTED]
1# thread dump excerpt if I forget timeout(2)
2java.naming.ldap.LdapClient.open  ...
3java.net.Socket.connect  ...  127.0.0.1:1
4# kill -9 the JVM; do not wait for a remote

ASAN does not apply. The “crash” is CommunicationException or the timeout kill. Either proves outbound name resolution was attempted. On a patched box with formatMsgNoLookups=true and a current log4j, that stack does not appear from log.info.

App / HTTP log from the lab gateway in front of a toy service (token redacted):

12022-01-28T10:18:44+08:00  edge  GET /health
2  ua: ${jndi:ldap://127.0.0.1:1/x}
3  src: 127.0.0.1  req_id: [REDACTED]
4  action: 400  reason: ua_rejected_lookup_syntax
5# WAF/rule fired; origin logger never ran

Failed-auth style: rejecting the header is correct. Logging the full header into a vulnerable logger is how the class of bug was reached. Detect in the WAF, then log a hash.

How I disable JNDI lookups (the actual checklist)

Order I used in 2021 and still use on old images:

 1# 1. property — hot, then bake into the JVM flags
 2-Dlog4j2.formatMsgNoLookups=true
 3
 4# 2. remove the JndiLookup class from the jar if we cannot upgrade yet
 5zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class
 6
 7# 3. upgrade to 2.17.1+ (2.16 still had follow-on CVEs; read the matrix)
 8
 9# 4. JVM-wide, not log4j-specific
10-Dcom.sun.jndi.ldap.object.trustURLCodebase=false
11-Dcom.sun.jndi.rmi.object.trustURLCodebase=false
12-Dcom.sun.jndi.cosnaming.object.trustURLCodebase=false
1<!-- log4j2.xml : stop interpolating lookups in messages -->
2<PatternLayout pattern="%d %p %m%n" alwaysWriteExceptions="true"/>
3<!-- do not use %x / lookup plugins on untrusted data -->

Network: egress from app JVMs to LDAP/RMI/IIOP off, except the directories we own. 127.0.0.1:1 in the negative test is not a substitute for an egress ACL; it is a unit test.

Inventory (what I grep):

1$ grep -R 'InitialContext\|ctx.lookup\|${jndi' --include='*.java' --include='*.xml'
2# app code plus log4j2.xml plus leftover leftover.xml in configmaps

Any ctx.lookup(request.get*) is the same class without log4j in the name.

What the 2021 follow-on CVEs changed in this lab

I keep a matrix so “we set the flag” is not the whole ticket:

BuildformatMsgNoLookupsNested ${lower:${jndi:Notes
2.14.1neededstill looks up if flag offdo not run
2.15lookups off by default, bypasses existedfollow-ondo not run
2.16tighterstill had a recursorupgrade
2.17.1+current floor for this labmessage lookups gonethis is what we ship

Thread context / ThreadContext.put("ua", header) plus a pattern %X{ua} is data. A pattern %X{ua} that the layout then interpolates as a lookup is the old bug in a hat. I dump log4j2.xml and look for ${ in the pattern, not only in logged messages.

1$ grep -n '\${' log4j2.xml
2# (no hits in the lab file)

Any hit is a lookup in the layout. Layout lookups of date / pid are operator features; layout lookups of ctx:ua are the header again.

Mitigation beyond the flag

  • Treat log messages as data. If you need structured fields, use a parameterized API (log.info("ua={}", ua)) and a patched core so the parameter is not re-interpolated.
  • WAF: detect ${jndi: / ${lower: / nested variants at the edge; still patch, because encodings will evade.
  • Outbound allow-list from the JVM namespace. No LDAP to the internet.
  • trustURLCodebase=false everywhere, even after patch — remote codebase loading is a footgun of its own.
  • Do not run a “canary LDAP listener” on a shared network as a joke; that is how people accidentally create the missing piece of an exploit path.

What I file after this lab

  • App: LogToy logs ua={} with formatMsgNoLookups=true; string stays literal
  • Negative: InitialContext.lookup("ldap://127.0.0.1:1/x")CommunicationException, no daemon
  • File lookup: NameNotFoundException on does-not-exist
  • Fix: upgrade log4j, JVM flags above, zip-delete JndiLookup only as a stopgap, egress deny LDAP/RMI
  • Out of scope: LDAP exploit server, marshalled gadgets, trustURLCodebase=true

Commands appendix

1java -Dlog4j2.formatMsgNoLookups=true -cp "$CP" LogToy '${jndi:ldap://127.0.0.1:1/x}'
2jar tf log4j-core-*.jar | grep JndiLookup
3zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.class
4grep -R 'InitialContext' --include='*.java'