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:
| Build | formatMsgNoLookups | Nested ${lower:${jndi: | Notes |
|---|---|---|---|
| 2.14.1 | needed | still looks up if flag off | do not run |
| 2.15 | lookups off by default, bypasses existed | follow-on | do not run |
| 2.16 | tighter | still had a recursor | upgrade |
| 2.17.1+ | current floor for this lab | message lookups gone | this 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=falseeverywhere, 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:
LogToylogsua={}withformatMsgNoLookups=true; string stays literal - Negative:
InitialContext.lookup("ldap://127.0.0.1:1/x")→CommunicationException, no daemon - File lookup:
NameNotFoundExceptionondoes-not-exist - Fix: upgrade log4j, JVM flags above, zip-delete
JndiLookuponly 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'