]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Ensure NSEC authority does not cross zonecut boundary
authorEvan Hunt <each@isc.org>
Fri, 22 May 2026 02:34:00 +0000 (19:34 -0700)
committerEvan Hunt <each@isc.org>
Mon, 13 Jul 2026 17:43:38 +0000 (10:43 -0700)
When using a cached NSEC record to prove that a delegation is insecure,
we now check that the signer name in the corresponding RRSIG is not
above a known secure delegation point. This prevents a signed namespace
from being downgraded to insecure using an NSEC record from the
grandparent zone.

Fixes: isc-projects/bind9#5967
Co-Authored-By: Ondřej Surý <ondrej@isc.org>
lib/dns/validator.c

index d7136309edc4e749e7aab4c79ad20f59e3e345d5..e7c4c28b0ababd0e6bb1d74acbdbd59e78c86126 100644 (file)
@@ -171,6 +171,9 @@ static isc_result_t
 create_ds_fetch(dns_validator_t *val, dns_name_t *name, isc_job_cb callback,
                const char *caller);
 
+static isc_result_t
+view_find(dns_validator_t *val, dns_name_t *name, dns_rdatatype_t type);
+
 /*%
  * Ensure the validator's rdatasets are marked as expired.
  */
@@ -248,6 +251,32 @@ validator_done(dns_validator_t *val, isc_result_t result) {
        isc_async_run(val->loop, val->cb, val);
 }
 
+static bool
+closer_secure_ds_exists(dns_validator_t *val, const dns_name_t *signer,
+                       const dns_name_t *name) {
+       dns_fixedname_t fl;
+       dns_name_t *l = dns_fixedname_initname(&fl);
+       unsigned int n = dns_name_countlabels(name);
+       unsigned int s = dns_name_countlabels(signer);
+
+       for (unsigned int i = s + 1; i < n; i++) {
+               isc_result_t result;
+               bool secure;
+
+               dns_name_getlabelsequence(name, n - i, i, l);
+               result = view_find(val, l, dns_rdatatype_ds);
+               secure = (result == ISC_R_SUCCESS &&
+                         val->frdataset.trust >= dns_trust_secure);
+               disassociate_rdatasets(val);
+
+               if (secure) {
+                       return true;
+               }
+       }
+
+       return false;
+}
+
 /*%
  * The is_insecure_referral() function is called as part of seeking the DS
  * record. Look in the NSEC or NSEC3 record returned from a DS query to see if
@@ -258,6 +287,11 @@ validator_done(dns_validator_t *val, isc_result_t result) {
  * are going to treat the message as insecure and just assume the DS was at
  * the delegation.
  *
+ * If 'crossed' is not NULL, it is set to true when a referral is rejected
+ * because the NSEC/NSEC3 signer sits above a known secure delegation point.
+ * Such a proof is forged: the caller can stop the insecurity walk rather than
+ * descend into more attacker-supplied labels.
+ *
  * Returns:
  *\li  #true  the NS bitmap was set in the NSEC or NSEC3 record, or
  *            the NSEC3 covers the name (in case of opt-out), or
@@ -268,27 +302,31 @@ validator_done(dns_validator_t *val, isc_result_t result) {
 static bool
 is_insecure_referral(dns_validator_t *val, dns_name_t *name,
                     dns_rdataset_t *rdataset, isc_result_t dbresult,
-                    const char *caller) {
+                    const char *caller, bool *crossed) {
        dns_fixedname_t fixed;
        dns_label_t hashlabel;
-       dns_name_t nsec3name;
+       dns_name_t nsec3name = DNS_NAME_INITEMPTY;
        dns_rdata_nsec3_t nsec3;
-       dns_rdataset_t set;
+       dns_rdataset_t set = DNS_RDATASET_INIT;
        int order;
        int scope;
-       bool found;
+       bool found = false;
        isc_buffer_t buffer;
        isc_result_t result;
        unsigned char hash[NSEC3_MAX_HASH_LENGTH];
        unsigned char owner[NSEC3_MAX_HASH_LENGTH];
        unsigned int length;
+       dns_fixedname_t fsigner;
+       dns_name_t *signer = NULL;
+       dns_rdataset_t sigset = DNS_RDATASET_INIT;
+       dns_rdata_t srdata = DNS_RDATA_INIT;
+       dns_rdata_rrsig_t sig;
 
-       REQUIRE(dbresult == DNS_R_NXRRSET || dbresult == DNS_R_NCACHENXRRSET);
-
-       dns_rdataset_init(&set);
-       if (dbresult == DNS_R_NXRRSET) {
+       switch (dbresult) {
+       case DNS_R_NXRRSET:
                dns_rdataset_clone(rdataset, &set);
-       } else {
+               break;
+       case DNS_R_NCACHENXRRSET:
                result = dns_ncache_getrdataset(rdataset, name,
                                                dns_rdatatype_nsec, &set);
                if (result == ISC_R_NOTFOUND) {
@@ -301,11 +339,16 @@ is_insecure_referral(dns_validator_t *val, dns_name_t *name,
                        dns_rdataset_cleanup(&set);
                        goto trynsec3;
                }
+               break;
+       default:
+               UNREACHABLE();
        }
 
        INSIST(set.type == dns_rdatatype_nsec);
 
-       found = false;
+       /*
+        * Is there an NS in the NSEC?
+        */
        result = dns_rdataset_first(&set);
        if (result == ISC_R_SUCCESS) {
                dns_rdata_t rdata = DNS_RDATA_INIT;
@@ -313,33 +356,94 @@ is_insecure_referral(dns_validator_t *val, dns_name_t *name,
                found = dns_nsec_typepresent(&rdata, dns_rdatatype_ns);
        }
        dns_rdataset_disassociate(&set);
+
+       /*
+        * Recover the NSEC's RRSIG signer so its authority can be bounded. A
+        * cached proof keeps the signature in the ncache blob; a live NSEC has
+        * it in the parallel signature rdataset.
+        */
+       if (found) {
+               dns_rdataset_t *sigp = NULL;
+
+               if (dbresult == DNS_R_NCACHENXRRSET) {
+                       if (dns_ncache_getsigrdataset(rdataset, name,
+                                                     dns_rdatatype_nsec,
+                                                     &sigset) == ISC_R_SUCCESS)
+                       {
+                               sigp = &sigset;
+                       }
+               } else if (dns_rdataset_isassociated(&val->fsigrdataset) &&
+                          val->fsigrdataset.covers == dns_rdatatype_nsec)
+               {
+                       sigp = &val->fsigrdataset;
+               }
+
+               if (sigp != NULL && dns_rdataset_first(sigp) == ISC_R_SUCCESS) {
+                       dns_rdataset_current(sigp, &srdata);
+                       if (dns_rdata_tostruct(&srdata, &sig, NULL) ==
+                           ISC_R_SUCCESS)
+                       {
+                               signer = dns_fixedname_initname(&fsigner);
+                               dns_name_copy(&sig.signer, signer);
+                       }
+               }
+               if (sigp == &sigset) {
+                       dns_rdataset_cleanup(&sigset);
+               }
+       }
+
+       if (signer != NULL && closer_secure_ds_exists(val, signer, name)) {
+               validator_log(val, ISC_LOG_DEBUG(3),
+                             "is_insecure_referral: NSEC signer above known "
+                             "secure DS; refusing insecure-delegation proof");
+               found = false;
+               SET_IF_NOT_NULL(crossed, true);
+       }
+
        return found;
 
 trynsec3:
        /*
         * Iterate over the ncache entry.
         */
-       found = false;
-       dns_name_init(&nsec3name);
        dns_fixedname_init(&fixed);
        dns_name_downcase(name, dns_fixedname_name(&fixed));
        name = dns_fixedname_name(&fixed);
+
        DNS_RDATASET_FOREACH(rdataset) {
+               dns_rdataset_cleanup(&set);
                dns_ncache_current(rdataset, &nsec3name, &set);
                if (set.type != dns_rdatatype_nsec3) {
-                       dns_rdataset_disassociate(&set);
                        continue;
                }
                if (set.trust < dns_trust_secure) {
                        dns_rdataset_cleanup(&set);
                        continue;
                }
+
+               /*
+                * Remember this NSEC3's zone (the owner name's parent) as the
+                * signer to bound. It is refreshed for every record so that,
+                * when one below triggers the terminal condition, 'signer'
+                * reflects that record -- not some earlier non-matching NSEC3.
+                * The bound check walks the cache and would disassociate
+                * 'rdataset' (== val->frdataset), so it runs only after the
+                * loop, at checksigner.
+                */
+               unsigned int labels = dns_name_countlabels(&nsec3name);
+               if (labels > 1) {
+                       dns_name_t parent = DNS_NAME_INITEMPTY;
+                       dns_name_getlabelsequence(&nsec3name, 1, labels - 1,
+                                                 &parent);
+                       signer = dns_fixedname_initname(&fsigner);
+                       dns_name_copy(&parent, signer);
+               }
+
                dns_name_getlabel(&nsec3name, 0, &hashlabel);
                isc_region_consume(&hashlabel, 1);
                isc_buffer_init(&buffer, owner, sizeof(owner));
                result = isc_base32hexnp_decoderegion(&hashlabel, &buffer);
                if (result != ISC_R_SUCCESS) {
-                       dns_rdataset_disassociate(&set);
                        continue;
                }
                DNS_RDATASET_FOREACH(&set) {
@@ -361,8 +465,9 @@ trynsec3:
                                validator_log(val, ISC_LOG_DEBUG(3),
                                              "%s: too many iterations",
                                              caller);
+                               found = true;
                                dns_rdataset_disassociate(&set);
-                               return true;
+                               goto checksigner;
                        }
                        length = isc_iterated_hash(
                                hash, nsec3.hash, nsec3.iterations,
@@ -376,7 +481,7 @@ trynsec3:
                                found = dns_nsec3_typepresent(&rdata,
                                                              dns_rdatatype_ns);
                                dns_rdataset_disassociate(&set);
-                               return found;
+                               goto checksigner;
                        }
                        if ((nsec3.flags & DNS_NSEC3FLAG_OPTOUT) == 0) {
                                continue;
@@ -392,12 +497,32 @@ trynsec3:
                             (order > 0 ||
                              memcmp(hash, nsec3.next.base, length) < 0)))
                        {
+                               found = true;
                                dns_rdataset_disassociate(&set);
-                               return true;
+                               goto checksigner;
                        }
                }
-               dns_rdataset_disassociate(&set);
        }
+
+       dns_rdataset_cleanup(&set);
+       return found;
+
+checksigner:
+       /*
+        * The proof claims an insecure delegation. Reject it if the NSEC3
+        * signer sits above a known secure delegation point: such a proof is
+        * forged by a zone above the real zone cut.
+        */
+       if (found && signer != NULL &&
+           closer_secure_ds_exists(val, signer, name))
+       {
+               validator_log(val, ISC_LOG_DEBUG(3),
+                             "is_insecure_referral: NSEC3 signer above known "
+                             "secure DS; refusing insecure-delegation proof");
+               SET_IF_NOT_NULL(crossed, true);
+               found = false;
+       }
+
        return found;
 }
 
@@ -633,10 +758,11 @@ fetch_callback_ds(void *arg) {
                        result = proveunsecure(val, true, false, true);
                        break;
                case DNS_R_NXRRSET:
-               case DNS_R_NCACHENXRRSET:
+               case DNS_R_NCACHENXRRSET: {
+                       bool crossed = false;
                        if (is_insecure_referral(val, resp->foundname,
                                                 &val->frdataset, eresult,
-                                                "fetch_callback_ds"))
+                                                "fetch_callback_ds", &crossed))
                        {
                                /*
                                 * Failed to find a DS while trying to prove
@@ -646,7 +772,18 @@ fetch_callback_ds(void *arg) {
                                result = markanswer(val, "fetch_callback_ds");
                                break;
                        }
-                       FALLTHROUGH;
+                       if (crossed) {
+                               /*
+                                * The NSEC/NSEC3 signer sits above a known
+                                * secure delegation, so this proof is forged.
+                                * Stop instead of descending further.
+                                */
+                               result = DNS_R_NOTINSECURE;
+                               break;
+                       }
+                       result = proveunsecure(val, false, false, true);
+                       break;
+               }
                case DNS_R_CNAME:
                        /*
                         * Not a zone cut, so we have to keep looking for
@@ -751,16 +888,33 @@ validator_callback_ds(void *arg) {
                have_dsset = (val->frdataset.type == dns_rdatatype_ds);
                name = dns_fixedname_name(&val->fname);
 
-               if ((val->attributes & VALATTR_INSECURITY) != 0 &&
-                   val->frdataset.covers == dns_rdatatype_ds &&
-                   NEGATIVE(&val->frdataset) &&
-                   is_insecure_referral(val, name, &val->frdataset,
-                                        DNS_R_NCACHENXRRSET,
-                                        "validator_callback_ds"))
-               {
-                       result = markanswer(val, "validator_callback_ds");
-               } else if ((val->attributes & VALATTR_INSECURITY) != 0) {
-                       result = proveunsecure(val, have_dsset, false, true);
+               if ((val->attributes & VALATTR_INSECURITY) != 0) {
+                       bool crossed = false;
+                       bool insecure = false;
+
+                       if (val->frdataset.covers == dns_rdatatype_ds &&
+                           NEGATIVE(&val->frdataset))
+                       {
+                               insecure = is_insecure_referral(
+                                       val, name, &val->frdataset,
+                                       DNS_R_NCACHENXRRSET,
+                                       "validator_callback_ds", &crossed);
+                       }
+
+                       if (insecure) {
+                               result = markanswer(val,
+                                                   "validator_callback_ds");
+                       } else if (crossed) {
+                               /*
+                                * The NSEC/NSEC3 signer sits above a known
+                                * secure delegation, so this proof is forged.
+                                * Stop instead of descending further.
+                                */
+                               result = DNS_R_NOTINSECURE;
+                       } else {
+                               result = proveunsecure(val, have_dsset, false,
+                                                      true);
+                       }
                } else {
                        result = validate_async_run(val, validate_dnskey);
                }
@@ -3424,11 +3578,24 @@ seek_ds(dns_validator_t *val, isc_result_t *resp) {
                        return ISC_R_COMPLETE;
                }
 
-               if (is_insecure_referral(val, tname, &val->frdataset, result,
-                                        "seek_ds"))
                {
-                       *resp = markanswer(val, "seek_ds (3)");
-                       return ISC_R_COMPLETE;
+                       bool crossed = false;
+                       if (is_insecure_referral(val, tname, &val->frdataset,
+                                                result, "seek_ds", &crossed))
+                       {
+                               *resp = markanswer(val, "seek_ds (3)");
+                               return ISC_R_COMPLETE;
+                       }
+                       if (crossed) {
+                               /*
+                                * The NSEC/NSEC3 signer sits above a known
+                                * secure delegation, so this insecurity proof
+                                * is forged. Stop walking instead of descending
+                                * into more attacker-supplied labels.
+                                */
+                               *resp = DNS_R_NOTINSECURE;
+                               return ISC_R_COMPLETE;
+                       }
                }
 
                break;