On 6 October 2026, Google's Chrome Secure Web and Networking Team reported that attackers had compromised the registries of three country-code TLDs: .gh (Ghana), .sl (Sierra Leone) and .as (American Samoa). The attackers changed authoritative DNS records and got publicly trusted HTTPS certificates for several Google domains under those TLDs. Certificate Transparency data showed that domains of other organisations were affected too. Google's own systems were not breached, and Google says it has no reason to think the issuing CAs did anything wrong.
That last point is the uncomfortable part. A DNS hijack doesn't need a bad CA. If an attacker controls what your domain resolves to, they can pass domain validation honestly and get a valid certificate for your name. This article covers how that works, what you can and can't prevent, and how to catch it on a Debian 13 host that already uses certbot.
How a DNS hijack defeats ACME validation
An ACME CA such as Let's Encrypt issues a certificate to anyone who proves control of the name. The usual challenges are:
- DNS-01: publish a token in a TXT record at
_acme-challenge.example.org. - HTTP-01: serve a token under
/.well-known/acme-challenge/on port 80 of whatever IP the name resolves to. - TLS-ALPN-01: answer a special TLS handshake on port 443 of that IP.
All three start with a DNS lookup. Once an attacker controls the authoritative answers, whether through the registry, your registrar account or your DNS provider, they can publish the TXT record or point the A record at a server they own. Google's post doesn't say which validation method was used in the ccTLD incident. With the zone under the attacker's control, any of the three would have worked.
Why the usual safeguards don't fire
- CAA is fetched from the same hijacked authoritative servers. The attacker can delete your CAA records or rewrite them. Google's post states plainly that CAA cannot prevent issuance during an active DNS hijack.
- Multi-perspective validation, which Let's Encrypt launched on 19 February 2020, checks the challenge from several network locations. It protects against BGP and on-path attacks that only affect some routes. A registry-level hijack changes the actual authoritative answer, so every vantage point sees the same forged data.
- DNSSEC: since 15 March 2026 the CA/Browser Forum's ballot SC-085 requires CAs to validate DNSSEC, where present, for CAA and validation lookups. If your DS record stays in the parent zone and the attacker only changes the delegation, their answers won't validate. Someone who controls the registry itself can also replace or remove the DS record, though. DNSSEC raises the bar but doesn't close the gap.
Where CAA still matters: after the hijack
Google's argument for restrictive CAA is about what happens after you regain DNS control. CAs can cache and reuse a completed validation. At Let's Encrypt the authorization reuse period is currently 30 days. It drops to 10 days on 10 February 2027 and to 7 hours on 16 February 2028. An attacker who passed validation during the hijack could keep issuing from that cached authorization after you've fixed DNS. A CAA record that names only your ACME account and your validation method stops that, because the CA checks CAA again at issuance.
| Control | Active registry hijack | After DNS is restored | On-path / BGP attack |
|---|---|---|---|
CAA with accounturi and validationmethods | No, the attacker rewrites it | Blocks reuse of the attacker's cached validations | Can stop some routing-based and HTTP attacks (per Google) |
| DNSSEC | Only if the attacker can't change your DS | Yes | Yes, CAs validate since 2026-03-15 |
| Multi-perspective validation | No | n/a | Makes it much harder |
| Registry lock | No if the registry itself is breached; yes against registrar-account takeover | n/a | No |
| CT monitoring | Detects, doesn't prevent | Detects | Detects |
Detecting rogue certificates with Certificate Transparency
Publicly trusted certificates are logged in CT logs, and crt.sh exposes those logs as JSON. Google's main recommendation is to monitor CT for your entire domain portfolio, including parked domains and regional ccTLDs that nobody looks at.
A list of every certificate for your domain isn't much use on its own. What you want is the certificates you didn't issue. Certbot keeps every certificate it ever obtained under /etc/letsencrypt/archive, so you can diff serial numbers. Install the tools first:
apt install curl jq openssl bind9-dnsutils
The script below reads the serials from certbot's archive plus an optional list of serials issued elsewhere (another host, a load balancer, a commercial CA). It then queries crt.sh for the apex and all subdomains and prints every recently logged certificate whose serial it doesn't recognise. crt.sh returns one JSON row per log entry and puts only the names that match the query into name_value. The same certificate can therefore come back several times: as precertificate and as final certificate, and once from each of the two queries. The script groups the rows by serial and joins their names, so each certificate prints on one line.
#!/bin/bash
# List CT-logged certificates for a domain that we did not issue ourselves.
set -euo pipefail
domain="${1:?usage: ct-diff.sh example.org}"
archive="${ARCHIVE:-/etc/letsencrypt/archive}"
known="${KNOWN:-/etc/ct-diff/known-serials.txt}"
days="${DAYS:-14}"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
norm() {
tr 'A-F' 'a-f' | sed -e 's/^0*//' -e '/^$/d'
}
# 1. Serials of every certificate certbot stored on this host
find "$archive" -name 'cert*.pem' -print0 |
while IFS= read -r -d '' pem; do
openssl x509 -in "$pem" -noout -serial | cut -d= -f2
done > "$tmp/local.raw"
# Serials issued elsewhere: one hex serial per line, # for comments
if [ -f "$known" ]; then
grep -v '^#' "$known" >> "$tmp/local.raw" || true
fi
norm < "$tmp/local.raw" | sort -u > "$tmp/local.txt"
# 2. What CT logs have seen for the apex and all subdomains
i=0
for q in "$domain" "%25.$domain"; do
i=$((i + 1))
if ! curl -fsS --retry 3 --retry-delay 20 --max-time 180 \
"https://crt.sh/?q=${q}&exclude=expired&output=json" > "$tmp/ct-$i.json"; then
echo "crt.sh query failed for $q" >&2
exit 2
fi
done
# One row per serial: precert, final cert and both queries merged
since="$(date -u -d "-${days} days" +%Y-%m-%dT%H:%M:%S)"
jq -rs --arg since "$since" '
(add // [])
| map(select(.entry_timestamp >= $since))
| group_by(.serial_number) | .[]
| [.[0].serial_number, .[0].not_before, .[0].issuer_name,
([.[].name_value | split("\n")[]] | unique | join(" "))]
| @tsv' "$tmp"/ct-*.json > "$tmp/ct.tsv"
# 3. Anything in CT that is not in our list
awk -F'\t' 'NR == FNR { ok[$1] = 1; next }
{ s = tolower($1); sub(/^0+/, "", s); if (!(s in ok)) print }' \
"$tmp/local.txt" "$tmp/ct.tsv" | sort -u > "$tmp/unknown.tsv"
if [ -s "$tmp/unknown.tsv" ]; then
echo "Unknown certificates for $domain logged in the last $days days:"
cat "$tmp/unknown.tsv"
exit 1
fi
chmod 755 /usr/local/bin/ct-diff.sh
mkdir -p /etc/ct-diff
touch /etc/ct-diff/known-serials.txt
/usr/local/bin/ct-diff.sh example.org
The script prints nothing when everything matches. A hit looks like this (example values; the names column lists the merged names from both queries):
Unknown certificates for example.org logged in the last 14 days:
4a1f0c9e2b7d33aa81f0e5c27d9b6e1f05c2 2026-09-23T08:14:02 C=US, O=Let's Encrypt, CN=R12 example.org www.example.org
Run it as root from cron, because certbot's archive directory is readable only by root. Cron mails any output, so you only hear from it when there's a rogue certificate or crt.sh is down. crt.sh has no SLA and returns 502 errors under load, so expect the occasional failure mail.
MAILTO=root
17 */6 * * * root /usr/local/bin/ct-diff.sh example.org
47 */6 * * * root /usr/local/bin/ct-diff.sh example.net
Add one line for each domain you own, including the parked and regional ones. If certbot renews on several hosts, either run the check on each host or collect all serials into known-serials.txt.
Defending: CAA with accounturi and validationmethods
RFC 8657 adds two parameters to CAA issue records. accounturi limits issuance to one ACME account, and validationmethods limits which challenge types the CA may accept. Let's Encrypt supports both and recognises http-01, dns-01 and tls-alpn-01. Get your account URI from certbot:
certbot show_account
Account details for server https://acme-v02.api.letsencrypt.org/directory:
Account URL: https://acme-v02.api.letsencrypt.org/acme/acct/1234567890
If you have several accounts, for example one per host, list them all from the account files:
grep -rhoE 'https://[^"]+/acme/acct/[0-9]+' /etc/letsencrypt/accounts/ | sort -u
Then publish one record for each account and method. In this example, the web server renews with HTTP-01 under one account, and a separate host issues the wildcard certificate with DNS-01 under another. The zone in this example accepts dynamic updates from the certbot TSIG key (see the BIND config further down), so don't edit the zone file while named is running. Freeze the zone first, add the records, bump the SOA serial, then thaw it:
rndc freeze example.org
nano /var/lib/bind/db.example.org
rndc thaw example.org
example.org. 3600 IN CAA 0 issue "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567890; validationmethods=http-01"
example.org. 3600 IN CAA 0 issue "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/2345678901; validationmethods=dns-01"
If you skip the freeze, your manual edits clash with the zone's .jnl journal and can be lost, or the zone fails to load. Adding the records with nsupdate works too, but that needs a key that is allowed to write CAA records, which the narrow certbot key below is not.
Records with the same tag are additive, so each one allows its own account and method combination. An issue record also covers wildcards unless you add a separate issuewild. Check what the world sees, and whether it validates:
dig +short CAA example.org
delv example.org CAA
Warning: a typo in accounturi makes the record unsatisfiable, and every renewal fails. Publish the records while your certificates still have weeks left, then force a single real renewal to confirm issuance still works. The renewal runs your deploy hooks, which typically reload nginx or other services:
certbot certificates
certbot renew --cert-name example.org --force-renewal
RFC 8657 notes two limits. The parameters only bind CAs that document support for them, so list only CAs you've checked. CAA records are public, so the account URI reveals which of your domains share an account.
If you followed the certbot renewal setup for 45-day certificates, nothing else changes. The timer and deploy hook keep working as long as the account and method match the CAA record.
Narrow DNS-01 credentials, DNSSEC and registry lock
Scope the DNS-01 key to one record
A DNS-01 setup holds a credential that can change your zone. If that key leaks, the attacker doesn't need to touch the registry at all. With your own BIND server and python3-certbot-dns-rfc2136 (4.0.0-1 in trixie), limit the TSIG key to TXT records at the challenge name only:
zone "example.org." IN {
type master;
file "/var/lib/bind/db.example.org";
update-policy {
grant certbot-key. name _acme-challenge.example.org. txt;
};
};
Keep the credentials file at mode 600. If you run your own BIND, also keep it patched; see the BIND9 and Unbound security updates on Debian 13. With a hosted DNS provider, create an API token that can edit only the one zone, not the whole account.
Sign the zone and lock the delegation
Turn on DNSSEC so that CAs, which have validated it since March 2026, reject forged answers. Then ask your registrar for a registry lock. The exact EPP statuses vary from registry to registry. A lock typically sets serverUpdateProhibited, serverDeleteProhibited and serverTransferProhibited (Nominet's Domain Lock for .uk does this, for example), so nameserver and DS changes need a manual step at the registry. Neither control helps if the registry itself is breached, as in the ccTLD case, but together they block the far more common registrar-account takeover. Compare what the TLD servers publish against what you expect:
tld_ns="$(dig +short NS org. | head -n1)"
dig +norec NS example.org @"$tld_ns"
dig +norec DS example.org @"$tld_ns"
Response plan for a rogue certificate
- Confirm. Open the crt.sh entry, check the issuer, the names and the
not_beforedate, and download the PEM. - Check DNS from the parent down. Run the
dig +norecqueries above anddig +trace. Changed NS or DS records mean an active hijack. Contact the registrar, or the registry, immediately. - Regain control. Rotate the registrar password and 2FA, DNS provider API tokens and TSIG keys. Turn on registry lock once you've recovered.
- Tighten CAA. Publish
accounturirecords now, so cached validations in the attacker's account become useless. - Revoke. Let's Encrypt lets you revoke a certificate issued to another account once you prove control of every name in it. Validate the names (their docs suggest adding a nonexistent subdomain so no certificate is actually issued), then revoke. Let's Encrypt may record the reason as
cessationOfOperation. For other CAs, use the problem-reporting contact in their CPS. - Rotate what matters. The attacker generated their own key, so your private keys aren't exposed by the rogue certificate itself. Re-key only if your server or DNS credentials were compromised. Users who reached the attacker's server during the hijack may have sent passwords or session cookies, so invalidate sessions and force password resets. If the hijack also covered MX records, check what mail was received for your domain during that window.
certbot certonly --manual --preferred-challenges=dns -d example.org -d nonexistent.example.org
certbot revoke --cert-path /root/rogue-cert.pem --reason unspecified --no-delete-after-revoke
Afterwards, review the baseline in the web server security baseline for Debian 13. HSTS doesn't help against a certificate that is genuinely valid, but short session lifetimes limit what a few hours of hijack can steal.
Takeaways
- Anyone who controls your authoritative DNS can pass DNS-01, HTTP-01 or TLS-ALPN-01. The CA isn't at fault, and neither CAA nor multi-perspective validation stops it while the hijack is active.
- Publish CAA
issuerecords withaccounturiandvalidationmethods, one for each account. This blocks reuse of cached validations after you recover DNS. - Run
ct-diff.shfrom cron for every domain you own, parked and ccTLD names included. - Sign your zones with DNSSEC and request registry lock from your registrar.
- Limit DNS-01 credentials to TXT records at
_acme-challenge. - Keep the revocation steps written down before you need them, and include session invalidation.
Sources
- Chrome's Response to Recent ccTLD Registry Hijacks (Google, 2026-10-06)
- The Register: Attackers hijacked top-level domains, minted fake security certs for Google and other orgs
- RFC 8657: CAA Record Extensions for Account URI and ACME Method Binding
- Let's Encrypt: Certificate Authority Authorization (CAA)
- Let's Encrypt: Multi-Perspective Validation Improves Domain Validation Security
- Let's Encrypt: Decreasing Certificate Lifetimes to 45 Days
- Let's Encrypt: Revoking Certificates
- CA/Browser Forum Ballot SC-085v2: Require Validation of DNSSEC for CAA and DCV Lookups
- CA/Browser Forum Ballot SC-067v3: Multi-Perspective Issuance Corroboration
- Certbot User Guide
- certbot-dns-rfc2136 documentation
- Debian trixie: certbot package
- Debian trixie: python3-certbot-dns-rfc2136 package
- Nominet: .UK EPP Status Fields
- crt.sh Query Tool (crt.sh JSON field reference)
- crt.sh Google Group: Differences between json and crt.sh site
Comments