Lesson 02 / 18
Domain Name and Registration Lookup
Measuring a record's evidentiary strength: four record criteria's hit rates on 20 candidates ranging from 100% to 25%, the three strong criteria combined splitting off 10 candidates and capturing 8 evidence with zero false positives, the weak fourth criterion bringing 0 new evidence and dropping the hit rate from 80% to 62%, 60% of the 10 silent candidates being false positives while one is a real asset, and the cache path leaving 0 records while missing 1 evidence item and adding 2 false positives.
Published Updated
In this lesson
- Separates the claims returned by record lookup by evidentiary strength and measures each criterion's hit rate
- Calculates the trade-off between coverage and hit rate as the criterion set grows
- Compares the cache and direct query paths in terms of trace and certainty
Contents
The previous lesson split twenty candidates into nine evidence, five leads, and six false positives — but what made that split was a referee that came from outside: the inventory the scope owner approved. In a real assessment, that inventory is the output, not the input. This lesson tries a split without a referee: the candidates’ names are queried, and the evidentiary strength of the records that come back is measured.
What record types are for and how they are managed was covered in the Endpoint and System Hardening course. What is measured here is different: a record carries a claim, and the claim’s value depends on who could write that record. Some records can only be created by the party controlling the name; others are the business of the third party hosting the name and say nothing about ownership.
A Record Is a Claim, Not Evidence
RE4: four pieces of information are kept for each candidate — the real class the referee assigned, whether the organization’s name appears in the registration record, whether a verification record exists that only the party controlling the name could write, and the address block the name resolves to. RE5: a criterion is a rule that counts a candidate as evidence; it is referred to by two numbers — hit rate (of the candidates it triggers, how many are actually evidence) and coverage (how much of all the evidence it captures). RE6: a response from the cache leaves no record on the target but can be stale; a response queried directly from the authoritative server is fresh, and every query leaves a record.
This lookup is part of an authorized assessment and is limited strictly to the names within the defined scope.
// recon/record.mjs — fictional record-lookup results for the twenty candidates from lesson 01 // (a model): no real domain name, no real address, no real registration record. No randomness. // RE4: each row carries four things for a candidate — the real class per the scope owner's // inventory (referee), whether the organization's name appears in the registration record, // whether a verification record exists that only the party controlling the name could write, // and the address block the name resolves to. Block A is org-assigned, B is shared hosting, // C is third party, "-" means it does not resolve. "zone" says who answers the name's query, // and "stale" gives the cache's outdated block value. export const CANDIDATE = ` subscriber-portal evidence 1 1 A org - field-app evidence 1 1 A org - branch-vpn-endpoint evidence 1 0 A org - collector-gateway evidence 0 0 A org C report-endpoint evidence 1 1 A org - test-portal evidence 0 0 C org - shared-interface evidence 1 0 B org - work-order-endpoint evidence 1 1 A org - meter-update-endpoint evidence 0 1 A org - old-subscriber-portal lead 1 0 C org - marketing-page lead 0 0 C org - cloud-storage-bucket lead 0 0 C outside - identity-provider-endpoint lead 0 1 C outside - training-environment lead 0 0 B org - payment-provider-endpoint falseP 0 0 C outside - old-field-app falseP 0 0 B outside - spare-domain falseP 0 0 - outside - dealer-portal falseP 0 0 B outside - support-portal falseP 0 0 C outside A old-report-endpoint falseP 0 0 - org A` .trim().split("\n").map((r) => { const [name, cls, registration, ownership, block, zone, stale] = r.trim().split(/\s+/); return { name, class: cls, registration: +registration, ownership: +ownership, block, zone, stale }; }); // RE5: a rule is a criterion that counts a candidate as evidence. Hit rate = of the triggered // candidates, how many are actually evidence; coverage = how much of all the evidence this // rule alone finds. export const RULE = [ { name: "ownership record", f: (a, b) => a.ownership === 1 }, { name: "registration record", f: (a, b) => a.registration === 1 }, { name: "org-assigned block", f: (a, b) => b === "A" }, { name: "shared hosting block", f: (a, b) => b === "B" }, ]; // RE6: a response from the cache leaves no record on the target but can be stale; a response // queried directly from the authoritative server is fresh and leaves two records for every // name in the org's zone. export const blockOf = (a, path) => (path === "cache" && a.stale !== "-" ? a.stale : a.block); export const EVIDENCE = CANDIDATE.filter((a) => a.class === "evidence").length;
// recon/ownership.mjs — which record proves ownership, which one only leaves a lead. import { CANDIDATE, RULE, EVIDENCE, blockOf } from "./record.mjs"; const write = (g, ...s) => console.log(s.map((v, i) => (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join("")); const count = (l, s) => l.filter((a) => a.class === s).length; const rate = (l) => l.length ? ((count(l, "evidence") / l.length) * 100).toFixed(0) : "-"; console.log(`${CANDIDATE.length} candidates, ${EVIDENCE} evidence, ${count(CANDIDATE, "lead")} leads, ` + `${count(CANDIDATE, "falseP")} false positives; ${RULE.length} record criteria, 3 queries per candidate`); const A = [-24, 7, 7, 7, 9, 9, 10]; console.log("\n1. hit rate and coverage per record criterion"); write(A, "criterion", "cand", "evid", "lead", "falseP", "hit %", "cover %"); for (const k of RULE) { const l = CANDIDATE.filter((a) => k.f(a, a.block)); write(A, k.name, l.length, count(l, "evidence"), count(l, "lead"), count(l, "falseP"), rate(l), ((count(l, "evidence") / EVIDENCE) * 100).toFixed(1)); } const B = [-32, 7, 7, 7, 9, 9, 10]; console.log("\n2. as criteria are combined (strongest to weakest)"); write(B, "set", "cand", "evid", "lead", "falseP", "hit %", "cover %"); const combined = []; for (const k of RULE) { combined.push(k); const l = CANDIDATE.filter((a) => combined.some((x) => x.f(a, a.block))); write(B, `+ ${k.name}`, l.length, count(l, "evidence"), count(l, "lead"), count(l, "falseP"), rate(l), ((count(l, "evidence") / EVIDENCE) * 100).toFixed(1)); } const strong = RULE.slice(0, 3); const triggered = CANDIDATE.filter((a) => strong.some((k) => k.f(a, a.block))); const silent = CANDIDATE.filter((a) => !strong.some((k) => k.f(a, a.block))); const C = [-32, 7, 7, 7, 9, 10]; console.log("\n3. the two lists the first three criteria split off"); write(C, "list", "cand", "evid", "lead", "falseP", "falseP %"); for (const [name, l] of [["triggered", triggered], ["no criterion triggered", silent]]) write(C, name, l.length, count(l, "evidence"), count(l, "lead"), count(l, "falseP"), ((count(l, "falseP") / l.length) * 100).toFixed(1)); console.log("evidence not triggered: " + silent.filter((a) => a.class === "evidence").map((a) => a.name).join(", ")); const D = [-26, 9, 7, 9, 8, 8, 10]; console.log("\n4. two query paths: trace vs. certainty"); write(D, "path", "records", "alerts", "stale", "evid", "falseP", "hit %"); for (const path of ["cache", "direct"]) { const l = CANDIDATE.filter((a) => strong.some((k) => k.f(a, blockOf(a, path)))); const record = path === "direct" ? CANDIDATE.filter((a) => a.zone === "org").length * 2 : 0; const stale = path === "cache" ? CANDIDATE.filter((a) => a.stale !== "-").length : 0; write(D, path, record, 0, stale, count(l, "evidence"), count(l, "falseP"), rate(l)); } const cacheTriggered = CANDIDATE.filter((a) => strong.some((k) => k.f(a, blockOf(a, "cache")))); console.log(`evidence missed by the cache path: ` + triggered.filter((a) => !cacheTriggered.includes(a)).map((a) => a.name).join(", ") + `; false positives added: ` + cacheTriggered.filter((a) => !triggered.includes(a)).map((a) => a.name).join(", "));
20 candidates, 9 evidence, 5 leads, 6 false positives; 4 record criteria, 3 queries per candidate 1. hit rate and coverage per record criterion criterion cand evid lead falseP hit % cover % ownership record 6 5 1 0 83 55.6 registration record 7 6 1 0 86 66.7 org-assigned block 7 7 0 0 100 77.8 shared hosting block 4 1 1 2 25 11.1 2. as criteria are combined (strongest to weakest) set cand evid lead falseP hit % cover % + ownership record 6 5 1 0 83 55.6 + registration record 9 7 2 0 78 77.8 + org-assigned block 10 8 2 0 80 88.9 + shared hosting block 13 8 3 2 62 88.9 3. the two lists the first three criteria split off list cand evid lead falseP falseP % triggered 10 8 2 0 0.0 no criterion triggered 10 1 3 6 60.0 evidence not triggered: test-portal 4. two query paths: trace vs. certainty path records alerts stale evid falseP hit % cache 0 0 3 7 2 64 direct 26 0 0 8 0 80 evidence missed by the cache path: collector-gateway; false positives added: support-portal, old-report-endpoint
The numbers belong to the measurement class; their inputs are RE4–RE6.
Four Criteria, Four Different Hit Rates
The first table runs all four criteria over the same twenty candidates, and their hit rates spread between 100% and 25%. That spread is the lesson’s main finding: the sentence “a record lookup was performed” says nothing at all, because which record carries which claim changes from criterion to criterion.
The org-assigned address block criterion triggers seven candidates and all seven are evidence — 100% hit rate. The reason is structural: if an address block is allocated to an organization, for a name resolving into that block to belong to another organization requires the block owner’s cooperation. Registration record follows at 86% and ownership record at 83%; both produce evidence, but both also cover only a little more than half.
The fourth criterion shows the opposite. Shared hosting block triggers four candidates; one is evidence, one is a lead, two are false positives — 25% hit rate. On a shared hosting block, hundreds of organizations’ names resolve to the same address; the observation “it resolves to the same address” says almost nothing about ownership there. Two observations that look identical — a name resolves to an address — carry a fourfold difference between evidence and noise depending on the block’s kind.
The single lead item triggered by the ownership record criterion is also instructive.
identity-provider-endpoint has a valid ownership record; the organization has proven it
controls that name. The item still is not evidence, because the record proves control of the
name, not who the asset belongs to. The name is the organization’s; the service running
on it is a third party’s.
Where the Return on Combining Stops
The second table combines criteria from strongest to weakest. The first three criteria combined trigger 10 candidates and contain 8 evidence, 2 leads, 0 false positives: 80% hit rate, 88.9% coverage. When the fourth criterion is added, candidates rise to 13, evidence stays at 8, false positives rise from 0 to 2, and the hit rate drops to 62%.
This is the same curve as the previous lesson’s, and here its shape is stricter: the weak criterion brings in no new evidence at all, it only adds elimination burden. The coverage column reads 88.9% on both rows. The question to ask when adding a new rule to a criterion set is not “does it find more candidates,” it should be “does it increase coverage”; in this table the fourth criterion’s contribution to coverage is zero.
The Ten Candidates That Stay Silent
The third table shows the other half of the list. None of the three strong criteria fire for 10 candidates, and 6 of these ten are false positives — 60%. So record lookup really does work as an elimination tool: all six of the six false positives are on this silent list, and none of them has leaked into the evidence list.
In return, one real asset is lost. test-portal belongs to the organization, sits in the
inventory, and no criterion triggers for it: it is not in a dedicated block because it is hosted by
a third party, it does not appear in the registration record, and it has no ownership record.
Record lookup tells this candidate “I cannot say anything about it,” and a binary elimination rule
reads that as “does not belong to the organization.”
The numeric summary is this: eight of nine pieces of evidence can be confirmed by a record (88.9%); one cannot be confirmed and falls into the silent list. The silent list is not a list to discard, it is a separate list; inside it, the 10% real asset stands next to the 60% noise.
The Trade-off Between Trace and Freshness
The fourth table opens this course’s trace column for the second time, and for the first time shows a trade-off. If queries are served from a resolver’s cache, 0 records remain on the target; if the target’s authoritative server is queried directly, two queries for each of the thirteen names in the org’s zone produce 26 records.
The path that looks free is not free. The cache holds three stale responses, and the result breaks
in three places: collector-gateway stops being evidence (the stale response places it in the
third-party block), while support-portal and old-report-endpoint show up in the org-assigned
block because of their stale records and enter the evidence list as false positives. The hit
rate drops from 80% to 64%.
The choice between the two paths carries a pattern this course will run into again and again: leaving no trace means giving up certainty. Read from the blue team’s side, it is symmetric too — the 26 records on the authoritative server produce no alert at all, because a name query is the network’s most ordinary event. This step is visible on the defense side but not noticed; noticing it depends on later correlating which names the queries targeted and in what order.
The Cost of Sixty Queries
Each of the twenty candidates required three queries: registration record, address, and ownership record. A total of 60 queries produced eight pieces of evidence — 7.5 queries per evidence item. The previous lesson’s validation cost was 5.00 steps per evidence item; this lesson runs above that cost and gives something back in return: the previous lesson’s nine pieces of evidence were read from a referee, this lesson’s eight verify themselves.
The cost is not evenly distributed either. The ten candidates that return no record still spend their three-query share; the silent list’s total cost is 30 queries and it produces zero evidence in return. Half of the validation budget goes to a list whose outcome is “unknown” — and this spending is not wasted, because it is what drops six false positives from the list.
There is also a boundary worth distinguishing in terms of scope: seven of the twenty names sit outside the organization’s zone. Querying these names is reading publicly available data and creates no scope problem; acting on those names, however, does step outside scope. An item that is free to query is not necessarily an item that is free to work on.
Summary
- A record carries a claim, and the claim’s strength depends on who can write the record: the four criteria’s hit rates are 100%, 86%, 83%, and 25%.
- The org-assigned address block criterion is correct on 7 of 7 candidates; the shared hosting block criterion only on 1 of 4 — the same kind of observation carries a fourfold difference in value depending on the block’s kind.
- Ownership record proves control of the name, not ownership of the asset: a candidate carrying a valid ownership record is a third party’s service and does not count as evidence.
- The three strong criteria combined trigger 10 candidates, capture 8 evidence, and produce 0 false positives; the fourth criterion adds 3 candidates and 2 false positives, brings 0 new evidence, and drops the hit rate from 80% to 62%.
- 6 of the 10 candidates no criterion triggers for are false positives (60%), but one is a real asset; the silent list is not a list to discard, it is a separate list.
- The cache path leaves 0 records on the target, misses 1 evidence item due to 3 stale responses, and adds 2 false positives; the direct path leaves 26 records, produces 0 alerts, and keeps the hit rate at 80%.
Next Step
Up to this point the candidate list arrived ready-made: twenty names came out of the previous lesson’s publicly available items, and this lesson only classified them. But the list is not a closed set. Under an organization’s domain name there can be names that appear in no publicly available document and have a link on no page; the way to find them is not reading documents, it is scanning the name space.
This scan produces two new numbers. The first is coverage: how many candidates do different discovery methods pull out of the same name space, how much do they overlap, and which one alone is enough. The second is this course’s most attention-grabbing item: some of the names found point to a target that no longer exists — items whose record has stopped, whose counterpart has fallen away, whose owner is unclear. The next lesson measures subdomain and asset discovery along these two axes and counts how many records each method leaves on the defense side.
To keep your progress and take notes, Log in
My notes
Log in to take notes.