Skip to content
academia.sh

Lesson 11 / 22

Domain Spoofing

Detecting typosquatting and homograph domain names in a fictional registry list: the number of candidates edit distance and visual normalization catch separately and together, each metric's false positive rate, the one spoof that is missed, and the average 11.3-day window that registry monitoring gains.

Published Updated

In this lesson

  • Runs edit distance and visual normalization over a registry list to detect spoof candidates
  • Compares the two metrics' candidate, caught, and false-positive counts and shows what the combined metric changes
  • Measures the window between registration and first use and ties it to the early-break gain
Contents

Every chain in the previous lesson required passing through a door, and every door was a countable item. A spoof carried out remotely needs no door at all: a name one character away from a protected name faces no control the moment it is registered. Detecting these names is a measurable task, and this lesson measures only that — no name is generated, an observed registry list is scanned.

Two metrics are used. The first is edit distance: how many insertions, deletions, and substitutions it takes to turn a label into a protected name. The second is visual similarity: spellings that resemble each other are reduced to a single form, then distance is measured again. The two catch different spoofs, and each has its own false positive. The third measure is on the time side: the gap between the day a name is registered and the day it is first used determines at which link the defense can break the chain.

  • SE25. The protected names, the registry list, and the days are fictional, and extensions reserved for documentation were used. No real domain, organization, or brand is referenced; what is measured is the model built with node.
  • SE26. The records’ true class is known in the model. In reality the class is known only after investigation; it is assumed known here so that caught and false positive can be counted separately.
  • SE27. Visual normalization was kept to five confusable forms. In a real browser the set of confusable spellings is far wider and also covers characters from other writing systems; the narrow set lowers what is caught, it does not change the direction of the measure.
  • SE28. Label comparison is done independently of the extension. Registering the same label under a different extension gives distance zero, and there is such a record in the list.
  • SE29. The first-use day is known in the model. In reality use is known only once observed; in reality, the window is therefore measured shorter than it is in the model.
  • SE30. The registration feed was assumed to be observed in full. An unobserved registration cannot be found by any metric, and in that case the chain’s first link cannot be counted at all.

Output of the Two Metrics

// domain.mjs — protected names, observed registry list, and detection metrics; model (names are not generated, only measured)
export const PROTECTED = ['olcumag', 'aboneportali', 'sahaislem']; // extension .example
// [label, extension, registration day, first-use day, true class]
export const RECORD = [
  ['olcurnag', '.example', 3, 19, 'spoof'], ['o1cumag', '.test', 5, 12, 'spoof'],
  ['olcunag', '.example', 8, 26, 'spoof'], ['ab0neportali', '.example', 11, 21, 'spoof'],
  ['aboneportali-giris', '.test', 12, 15, 'spoof'], ['sahais1em', '.example', 14, 30, 'spoof'],
  ['sahaisiem', '.invalid', 16, 24, 'spoof'], ['sahaislem', '.test', 18, 22, 'spoof'],
  ['olcumak', '.example', 2, 4, 'unrelated'], ['aboneportal', '.test', 6, 9, 'unrelated'],
  ['sahaisleme', '.example', 7, 10, 'unrelated'], ['olcumagaza', '.test', 9, 13, 'unrelated'],
  ['abonelik', '.example', 10, 14, 'unrelated'], ['sahakayit', '.invalid', 13, 17, 'unrelated'],
  ['olcumerkezi', '.example', 15, 20, 'unrelated'], ['portalabone', '.test', 17, 23, 'unrelated'],
  ['veriolcum', '.example', 19, 25, 'unrelated'], ['sayacokuma', '.invalid', 20, 27, 'unrelated'],
  ['faturaservisi', '.example', 21, 28, 'unrelated'], ['isemriakisi', '.test', 22, 29, 'unrelated'],
  ['bolgeolcum', '.example', 23, 31, 'unrelated'], ['aboneislem', '.invalid', 24, 32, 'unrelated'],
  ['sahaportali', '.test', 25, 33, 'unrelated'], ['olcumveri', '.example', 26, 34, 'unrelated'],
  ['olcumaci', '.test', 4, 8, 'unrelated'],
];
export function distance(a, b) { // edit distance
  const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
  for (let j = 0; j <= b.length; j++) d[0][j] = j;
  for (let i = 1; i <= a.length; i++)
    for (let j = 1; j <= b.length; j++)
      d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
  return d[a.length][b.length];
}
// visual similarity: confusable forms are reduced to a single spelling, then distance is measured
export const normalize = (s) => s.replace(/rn/g, 'm').replace(/vv/g, 'w')
  .replace(/1/g, 'l').replace(/0/g, 'o').replace(/5/g, 's');
export const nearest = (label, f = (x) => x) =>
  PROTECTED.reduce((a, k) => Math.min(a, distance(f(label), f(k))), Infinity);
export const window = (k) => k[3] - k[2];
// [step, precondition, control that sees the step, item dropped once the step completes]
export const CHAIN = [
  ['domain registration', 'access to a registration service', 'monitoring the registration feed', 0],
  ['mail and certificate setup', 'a registered domain', 'monitoring the transparency log', 1],
  ['first message', 'a target address list', 'indicator threshold', 2],
  ['credential collection', 'a convincing collection point', 'second factor', 3],
  ['use of the collected information', 'a valid session', 'anomalous session detection', 6],
];
// run1.mjs — edit distance and visual similarity measured over the registry list
import { PROTECTED, RECORD, normalize, nearest, window } from './domain.mjs';

console.log(`${PROTECTED.length} protected names (${PROTECTED.map((k) => k + '.example').join(', ')}), ` +
  `${RECORD.length} observed records, ${RECORD.filter((k) => k[4] === 'spoof').length} spoofs\n`);
console.log(`${'label'.padEnd(19)} ${'extension'.padEnd(9)} ${'class'.padStart(9)} ${'distance'.padStart(8)} ` +
  `${'visual'.padStart(6)} ${'reg.'.padStart(5)} ${'used'.padStart(8)} ${'window'.padStart(7)}`);
const near = [...RECORD].sort((a, b) => nearest(a[0]) - nearest(b[0])).filter((k) => nearest(k[0]) <= 3 || k[4] === 'spoof');
for (const k of near)
  console.log(`${k[0].padEnd(19)} ${k[1].padEnd(9)} ${k[4].padStart(9)} ${String(nearest(k[0])).padStart(8)} ` +
    `${String(nearest(k[0], normalize)).padStart(6)} ${String(k[2]).padStart(5)} ${String(k[3]).padStart(8)} ` +
    `${String(window(k)).padStart(7)}`);
const far = RECORD.filter((k) => nearest(k[0]) > 3 && k[4] !== 'spoof');
console.log(`\nrest of the list: ${far.length} records, distance ${Math.min(...far.map((k) => nearest(k[0])))}-${Math.max(...far.map((k) => nearest(k[0])))}, ` +
  `none is a spoof and none has a visual match`);
const visualZero = RECORD.filter((k) => nearest(k[0], normalize) === 0);
console.log(`records with distance 0 after visual normalization ${visualZero.length}: ` +
  `${visualZero.map((k) => `${k[0]} (${k[4]}, raw distance ${nearest(k[0])})`).join('; ')}`);
console.log(`labels changed by normalization ${RECORD.filter((k) => normalize(k[0]) !== k[0]).length}`);
3 protected names (olcumag.example, aboneportali.example, sahaislem.example), 25 observed records, 8 spoofs

label               extension     class distance visual  reg.     used  window
sahaislem           .test         spoof        0      0    18       22       4
o1cumag             .test         spoof        1      0     5       12       7
olcunag             .example      spoof        1      1     8       26      18
ab0neportali        .example      spoof        1      0    11       21      10
sahais1em           .example      spoof        1      0    14       30      16
sahaisiem           .invalid      spoof        1      1    16       24       8
olcumak             .example  unrelated        1      1     2        4       2
aboneportal         .test     unrelated        1      1     6        9       3
sahaisleme          .example  unrelated        1      1     7       10       3
olcurnag            .example      spoof        2      0     3       19      16
olcumaci            .test     unrelated        2      2     4        8       4
olcumagaza          .test     unrelated        3      3     9       13       4
aboneportali-giris  .test         spoof        6      6    12       15       3

rest of the list: 12 records, distance 4-10, none is a spoof and none has a visual match
records with distance 0 after visual normalization 5: olcurnag (spoof, raw distance 2); o1cumag (spoof, raw distance 1); ab0neportali (spoof, raw distance 1); sahais1em (spoof, raw distance 1); sahaislem (spoof, raw distance 0)
labels changed by normalization 4

The table’s first row shows the cheapest spoof: the same protected label, only with a different extension. Its edit distance is zero and it contains no spelling change at all. A scan that does not compare labels independently of the extension never sees this one.

The rows at distance one show the measure’s tension: five of eight rows are spoofs, three are unrelated. olcumak and aboneportal, sitting in the same band, are not real spoofs — they are other words that happen to sit one character away from a protected name. Edit distance cannot separate them, because the information needed to separate them is not in the distance.

The visual column shows what the second metric does. In five records, distance drops to zero after normalization, and all five are spoofs. One of them is olcurnag, with a raw distance of two: two characters that together resemble a single character stretch the edit distance by two steps, even though there is no visual difference at all. Normalization changes four of the twenty-five labels; this metric works on a narrow set, and that is exactly why it produces no false positive.

The spoof in the last row falls outside both metrics. aboneportali-giris contains the protected name exactly as it is and adds a word to it; its edit distance is six. Both metrics look for changes in spelling, but here the spelling has not changed at all.

Comparing the Metrics and the Window

// run2.mjs — threshold scan, combined metric, monitoring window, and the chain's early-break gain
import { RECORD, CHAIN, normalize, nearest, window } from './domain.mjs';

const SPOOF = RECORD.filter((k) => k[4] === 'spoof').length;
const count = (f) => { const a = RECORD.filter(f); return { candidates: a.length, caught: a.filter((k) => k[4] === 'spoof').length,
  wrong: a.filter((k) => k[4] === 'unrelated').length, missed: RECORD.filter((k) => k[4] === 'spoof' && !f(k)) }; };
console.log(`${'metric'.padEnd(28)} ${'candidates'.padStart(10)} ${'caught'.padStart(9)} ${'false positive'.padStart(14)} ${'missed'.padStart(6)}`);
for (const e of [0, 1, 2, 3]) {
  const r = count((k) => nearest(k[0]) <= e);
  console.log(`${`edit distance <= ${e}`.padEnd(28)} ${String(r.candidates).padStart(10)} ${`${r.caught}/${SPOOF}`.padStart(9)} ` +
    `${String(r.wrong).padStart(14)} ${String(r.missed.length).padStart(6)}`);
}
const g = count((k) => nearest(k[0], normalize) === 0);
console.log(`${'visual similarity = 0'.padEnd(28)} ${String(g.candidates).padStart(10)} ${`${g.caught}/${SPOOF}`.padStart(9)} ` +
  `${String(g.wrong).padStart(14)} ${String(g.missed.length).padStart(6)}`);
const b = count((k) => nearest(k[0], normalize) === 0 || nearest(k[0]) <= 1);
console.log(`${'combined (visual 0 or <=1)'.padEnd(28)} ${String(b.candidates).padStart(10)} ${`${b.caught}/${SPOOF}`.padStart(9)} ` +
  `${String(b.wrong).padStart(14)} ${String(b.missed.length).padStart(6)}`);
console.log(`\nwhat the combined metric misses: ${b.missed.map((k) => `${k[0]} (distance ${nearest(k[0])}, window ${window(k)} days)`).join('; ')}`);
const found = RECORD.filter((k) => k[4] === 'spoof' && (nearest(k[0], normalize) === 0 || nearest(k[0]) <= 1));
const p = found.map(window);
console.log(`monitoring window for the ${found.length} caught spoofs: average ${(p.reduce((a, c) => a + c, 0) / p.length).toFixed(2)} days, ` +
  `shortest ${Math.min(...p)}, longest ${Math.max(...p)}`);
console.log(`manual-review load from false positives: ${b.wrong} records`);

let s = 0; const cumul = CHAIN.map((a) => (s += a[3]));
console.log(`\n${'#'.padStart(2)} ${'step'.padEnd(33)} ${'precondition'.padEnd(33)} ${'control'.padEnd(33)} dropped if broken`);
CHAIN.forEach((a, i) => console.log(`${String(i + 1).padStart(2)} ${a[0].padEnd(33)} ${a[1].padEnd(33)} ${a[2].padEnd(33)} ` +
  `${i ? cumul[i - 1] : 0}`));
console.log(`chain has ${CHAIN.length} steps, all of them controlled; if it never breaks, dropped items ${cumul[4]}`);
console.log(`with monitoring, earliest link 1 (dropped 0), latest link 5 (dropped ${cumul[3]}); early-break gain ${cumul[3]} items`);
console.log(`without registration-feed monitoring, earliest link 3 (dropped ${cumul[1]}) and the average ${(p.reduce((a, c) => a + c, 0) / p.length).toFixed(2)}-day window goes unused`);
metric                       candidates    caught false positive missed
edit distance <= 0                    1       1/8              0      7
edit distance <= 1                    9       6/8              3      2
edit distance <= 2                   11       7/8              4      1
edit distance <= 3                   12       7/8              5      1
visual similarity = 0                 5       5/8              0      3
combined (visual 0 or <=1)           10       7/8              3      1

what the combined metric misses: aboneportali-giris (distance 6, window 3 days)
monitoring window for the 7 caught spoofs: average 11.29 days, shortest 4, longest 18
manual-review load from false positives: 3 records

 # step                              precondition                      control                           dropped if broken
 1 domain registration               access to a registration service  monitoring the registration feed  0
 2 mail and certificate setup        a registered domain               monitoring the transparency log   0
 3 first message                     a target address list             indicator threshold               1
 4 credential collection             a convincing collection point     second factor                     3
 5 use of the collected information  a valid session                   anomalous session detection       6
chain has 5 steps, all of them controlled; if it never breaks, dropped items 12
with monitoring, earliest link 1 (dropped 0), latest link 5 (dropped 6); early-break gain 6 items
without registration-feed monitoring, earliest link 3 (dropped 1) and the average 11.29-day window goes unused

The comparison table shows the two metrics failing in different ways. As the edit-distance threshold rises from one to three, caught rises from six to seven and false positives from three to five. Visual similarity alone catches only five spoofs but produces no false positive at all. The combined metric catches seven spoofs and produces three false positives — the same catch as distance threshold two, with one fewer false positive. Combining two narrow metrics produces fewer errors than loosening a single metric — this is a design decision that stands in for threshold tuning.

The one missed spoof marks the boundary of both metrics. A label that contains the protected name exactly and adds a word to it is far from the protected name in terms of distance. A third metric would catch it: the protected name appearing as a substring. That metric would have its own false positive, and this lesson does not measure it; what it measures is that the two metrics together still miss one.

The window row gives the time side. Between the registration day and the first-use day of the seven caught spoofs, there is an average of 11.29 days; the shortest is four, the longest eighteen. This span is not a detector’s accuracy measure — it is the time the defense has: if the registration feed is monitored, the chain can break at the first link, and at that point no item has dropped yet.

The window’s distribution says more than its average. The shortest window is four days, and looking at the table shows which record it belongs to: the protected label registered with a different extension. The cheapest spoof to set up is also the fastest to go into use. The longest window is eighteen days, a ratio of four and a half between the two. A monitor that scans the registration feed once a week sees the records above the average in time but misses the four-day window; what is decisive in this measure is not the average but the shortest window.

The chain table writes this in terms of items. All five steps have a control; if the chain never breaks, dropped items reach twelve. Breaking at the first link ends with zero items dropped, breaking at the fifth with six — the early-break gain is six items. If the registration feed is not monitored, the earliest breakable link shifts to the third step: one item has already dropped there, and the average eleven-day window goes entirely unused. The cost of false positives, on the same table, is a three-record manual review — this is the price paid in exchange for the eleven-day window.

Summary

  • The fictional 25-record list has 8 spoofs. The 1 record with edit distance 0 is the protected label registered with a different extension; a scan that does not compare independently of the extension misses it.
  • At distance threshold 1, 6/8 are caught with 3 false positives; at threshold 3, caught stays at 7 while false positives rise to 5.
  • Visual normalization changes 4 of the 25 labels and, alone, catches 5 spoofs with 0 false positives. One of them is a record with a raw distance of 2.
  • The combined metric catches 7/8 with 3 false positives: the same catch as distance threshold 2, with 1 fewer false positive.
  • The one missed spoof is the record that contains the protected name exactly and adds a word (distance 6). Both metrics look for a change in spelling; here the spelling did not change.
  • The monitoring window for the 7 caught spoofs averages 11.29 days (shortest 4, longest 18). With registration monitoring, the chain breaks at link 1 (0 items); without it, at the earliest link 3 (1 item); early-break gain is 6 items, at a cost of manually reviewing 3 records.

Next Step

This lesson’s spoof called the target to itself: a name is registered and the target is expected to go to it. The reverse is also possible — corrupting a place the target already goes to. A portion of an organization’s users visit the same external resource every day: a shared document repository, an industry announcement page, a script or font resource. That resource is outside the organization’s control and is a single point. That is the next lesson’s question: how many users go to the same external resource, how many chains this shared dependency opens at once, and how many of them a resource-integrity check breaks.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close