Skip to content
academia.sh

Lesson 10 / 16

Host-Based Firewall and Prevention

In a 432-endpoint model, 729,252 connection paths are counted and the 290,772 intra-zone paths the zone-boundary rule never sees at all are separated out: the paths a twelve-item host-based allowlist closes, the peer-to-peer traffic it has to leave open, the two legitimate flows it cuts because they were never written into the list, the way a denied path lands in the record, and the number of endpoints reachable in a single hop from a compromised endpoint.

Published Updated

In this lesson

  • Counts intra-zone and inter-zone connection paths separately and derives the set the perimeter rule never sees
  • Measures together the paths the host-based allowlist closes, the legitimate flow it cuts, and the denial that lands in the record
  • Compares the number of endpoints reachable in a single hop, filtered and unfiltered, by the class of the compromised endpoint
Contents

The previous lesson measured the telemetry on the endpoint and showed that the one source that saw the connection between endpoints could not be defended on coverage grounds. This lesson counts that connection itself. The box standing at the network’s edge was measured in another course, and there the traffic between zones was counted; that measurement had a structural blind spot: the connection between two endpoints in the same zone never crosses the boundary at all, so no rule at the boundary either sees or evaluates it.

Three numbers are measured. The first is how many connection paths exist inside a zone — this is the set the perimeter rule never sees at all. The second is how many of them a filter sitting on the host side closes. The third is the cost of closing: how many legitimate flows have to be written into the list, how many unwritten flows get cut, and which paths cannot be closed. No attack step is written in this lesson; what is counted is the path itself.

  • EP19. 432 endpoints, four endpoint classes, eighteen listening-service definitions, and twelve legitimate flows belong to a fictional regional measurement network. What is measured is the model built with node.
  • EP20. The path definition is fixed: a path is the combination of a directed endpoint pair and a service listening at the destination. Reaching the same service by two different routes is a single path in the model.
  • EP21. All endpoints in a class listen on the same services. In reality endpoints drift, and drift grows the path count; the fixed class makes the number look smaller than it is.
  • EP22. The allowlist was written at the endpoint class level. A list written to endpoint addresses closes more paths and multiplies the maintenance item by the endpoint count.
  • EP23. The host-side rule set was assumed to be unchangeable on the endpoint; if a privilege exists that can remove the rule, this measurement is invalid.
  • EP24. The rule at the zone boundary evaluates only inter-zone paths. This is the boundary’s definition; the paths that rule closes are taken here as a given input.

The Set the Boundary Never Sees

// network.mjs — endpoint classes, listening services, and legitimate internal flows; model, not a real network
// class -> [zone, endpoint count, listening services]
export const CLASS = {
  'field laptop': ['field', 120, ['remote support', 'file sharing', 'printer discovery']],
  'branch endpoint': ['branch', 240, ['remote support', 'file sharing', 'printer discovery', 'local cache']],
  'aggregator': ['aggregator', 60, ['remote support', 'metric ingestion', 'status query', 'file sharing', 'management interface']],
  'central server': ['central', 12, ['remote support', 'metric ingestion', 'status query', 'database', 'management interface', 'reporting interface']],
};
export const NAME = Object.keys(CLASS);
export const zone = (s) => CLASS[s][0];
export const count = (s) => CLASS[s][1];
export const services = (s) => CLASS[s][2];
// [source class, destination class, service] — legitimate flows written into the list
export const ALLOWED = [
  ['aggregator', 'central server', 'metric ingestion'],
  ['central server', 'aggregator', 'status query'],
  ['field laptop', 'central server', 'reporting interface'],
  ['branch endpoint', 'central server', 'reporting interface'],
  ['central server', 'field laptop', 'remote support'],
  ['central server', 'branch endpoint', 'remote support'],
  ['central server', 'aggregator', 'remote support'],
  ['branch endpoint', 'branch endpoint', 'local cache'],
  ['branch endpoint', 'branch endpoint', 'printer discovery'],
  ['field laptop', 'field laptop', 'file sharing'],
  ['aggregator', 'aggregator', 'file sharing'],
  ['central server', 'central server', 'database'],
];
// legitimate flows not written into the list, discovered later
export const FORGOTTEN = [
  ['branch endpoint', 'field laptop', 'file sharing'],
  ['field laptop', 'aggregator', 'status query'],
];
export const pair = (a, b) => (a === b ? count(a) * (count(a) - 1) : count(a) * count(b));
export function paths() {
  const l = [];
  for (const a of NAME) for (const b of NAME) for (const h of services(b)) l.push([a, b, h, pair(a, b)]);
  return l;
}
export const isInternal = (y) => zone(y[0]) === zone(y[1]);
export const isAllowed = (y, list) => list.some((i) => i[0] === y[0] && i[1] === y[1] && i[2] === y[2]);
export const sum = (l) => l.reduce((a, y) => a + y[3], 0);
// run1.mjs — intra-zone and inter-zone path count: what the perimeter rule sees and never sees
import { CLASS, NAME, count, services, paths, isInternal, sum } from './network.mjs';

const Y = paths(), N = NAME.reduce((a, s) => a + count(s), 0);
console.log(`${N} endpoints, ${NAME.length} endpoint classes, ${NAME.reduce((a, s) => a + services(s).length, 0)} listening-service definitions\n`);
console.log(`${'endpoint class'.padEnd(16)} ${'zone'.padStart(11)} ${'eps'.padStart(4)} ${'svcs'.padStart(6)} ` +
  `${'intra-zone'.padStart(13)} ${'inter-zone'.padStart(14)}`);
for (const s of NAME) {
  const target = Y.filter((y) => y[1] === s);
  console.log(`${s.padEnd(16)} ${CLASS[s][0].padStart(11)} ${String(count(s)).padStart(4)} ${String(services(s).length).padStart(6)} ` +
    `${String(sum(target.filter(isInternal))).padStart(13)} ${String(sum(target.filter((y) => !isInternal(y)))).padStart(14)}`);
}
const [inside, outside] = [sum(Y.filter(isInternal)), sum(Y.filter((y) => !isInternal(y)))];
console.log(`${'total'.padEnd(16)} ${''.padStart(11)} ${String(N).padStart(4)} ${''.padStart(6)} ` +
  `${String(inside).padStart(13)} ${String(outside).padStart(14)}`);
console.log(`\ntotal paths                    : ${inside + outside}`);
console.log(`paths the perimeter sees       : ${outside} (${(outside * 100 / (inside + outside)).toFixed(1)}%)`);
console.log(`paths the perimeter never sees : ${inside} (${(inside * 100 / (inside + outside)).toFixed(1)}%)`);
432 endpoints, 4 endpoint classes, 18 listening-service definitions

endpoint class          zone  eps   svcs    intra-zone     inter-zone
field laptop           field  120      3         42840         112320
branch endpoint       branch  240      4        229440         184320
aggregator        aggregator   60      5         17700         111600
central server       central   12      6           792          30240
total                         432               290772         438480

total paths                    : 729252
paths the perimeter sees       : 438480 (60.1%)
paths the perimeter never sees : 290772 (39.9%)

Of the seven hundred twenty-nine thousand two hundred fifty-two total paths, two hundred ninety thousand seven hundred seventy-two stay inside a zone: thirty-nine point nine percent. No rule at the boundary evaluates this set — it is not denied, not allowed, not logged. The boundary rule cannot even be said to “allow” these paths; it never sees them at all.

The distribution is not one-directional either. Two hundred twenty-nine thousand four hundred forty of the intra-zone paths come from the branch zone; the reason lies in the path definition itself, because the path count grows with the square of the endpoint count. The central zone listens on the most services and has only seven hundred ninety-two intra-zone paths.

What the Allowlist Closes and Cuts

// run2.mjs — host-based default-deny: closed paths, remaining paths, and cut legitimate work
import { NAME, ALLOWED, FORGOTTEN, count, paths, isInternal, isAllowed, sum } from './network.mjs';

const Y = paths(), IN = Y.filter(isInternal), OUT = Y.filter((y) => !isInternal(y));
const setup = (list) => ({
  intra: sum(IN.filter((y) => isAllowed(y, list))), inter: sum(OUT.filter((y) => isAllowed(y, list))),
  cut: FORGOTTEN.filter((u) => !list.some((i) => i.join() === u.join())),
});
console.log(`${'setup'.padEnd(30)} ${'intra-zone left'.padStart(15)} ${'inter-zone left'.padStart(16)} ` +
  `${'total left'.padStart(12)} ${'cut legit flows'.padStart(18)}`);
for (const [name, list] of [['no host filter', Y.map((y) => [y[0], y[1], y[2]])],
  ['allowlist (12 items)', ALLOWED], ['+ two forgotten flows added', [...ALLOWED, ...FORGOTTEN]]]) {
  const r = setup(list);
  console.log(`${name.padEnd(30)} ${String(r.intra).padStart(15)} ${String(r.inter).padStart(16)} ` +
    `${String(r.intra + r.inter).padStart(12)} ${String(r.cut.length).padStart(18)}`);
}
const r = setup(ALLOWED), t = sum(IN), d = sum(OUT);
console.log(`\nintra-zone paths closed  : ${t - r.intra}/${t} (${((t - r.intra) * 100 / t).toFixed(1)}%)`);
console.log(`inter-zone paths closed  : ${d - r.inter}/${d} (${((d - r.inter) * 100 / d).toFixed(1)}%)`);
console.log(`paths closed in the set the perimeter never sees: ${t - r.intra}`);
console.log(`denied path types that land in the record: ${IN.filter((y) => !isAllowed(y, ALLOWED)).length} intra + ` +
  `${OUT.filter((y) => !isAllowed(y, ALLOWED)).length} inter = ${Y.filter((y) => !isAllowed(y, ALLOWED)).length}`);
console.log(`\nintra-zone flows left open by the allowlist:`);
for (const y of IN.filter((y) => isAllowed(y, ALLOWED)))
  console.log(`  ${`${y[0]} -> ${y[1]}`.padEnd(36)} ${y[2].padEnd(20)} ${String(y[3]).padStart(6)} paths`);
console.log(`\nlegitimate flows cut because they were not written into the list:`);
for (const u of setup(ALLOWED).cut) {
  const y = Y.find((y) => y[0] === u[0] && y[1] === u[1] && y[2] === u[2]);
  console.log(`  ${`${u[0]} -> ${u[1]}`.padEnd(36)} ${u[2].padEnd(20)} ${String(y[3]).padStart(6)} paths`);
}

console.log(`\n${'breached class'.padEnd(16)} ${'no filter'.padStart(9)} ${'with allowlist'.padStart(15)} ${'closed'.padStart(7)}`);
for (const a of NAME) {
  const targets = NAME.filter((b) => ALLOWED.some((i) => i[0] === a && i[1] === b));
  const reached = targets.reduce((t, b) => t + (a === b ? count(b) - 1 : count(b)), 0);
  const N = NAME.reduce((t, s) => t + count(s), 0) - 1;
  console.log(`${a.padEnd(16)} ${String(N).padStart(9)} ${String(reached).padStart(15)} ${String(N - reached).padStart(7)}`);
}
setup                          intra-zone left  inter-zone left   total left    cut legit flows
no host filter                          290772           438480       729252                  0
allowlist (12 items)                    132672            10800       143472                  2
+ two forgotten flows added             132672            46800       179472                  0

intra-zone paths closed  : 158100/290772 (54.4%)
inter-zone paths closed  : 427680/438480 (97.5%)
paths closed in the set the perimeter never sees: 158100
denied path types that land in the record: 13 intra + 47 inter = 60

intra-zone flows left open by the allowlist:
  field laptop -> field laptop         file sharing          14280 paths
  branch endpoint -> branch endpoint   printer discovery     57360 paths
  branch endpoint -> branch endpoint   local cache           57360 paths
  aggregator -> aggregator             file sharing           3540 paths
  central server -> central server     database                132 paths

legitimate flows cut because they were not written into the list:
  branch endpoint -> field laptop      file sharing          28800 paths
  field laptop -> aggregator           status query           7200 paths

breached class   no filter  with allowlist  closed
field laptop           431             131     300
branch endpoint        431             251     180
aggregator             431              71     360
central server         431             431       0

A twelve-item list closes fifty-four point four percent of the intra-zone paths. Where the remaining one hundred thirty-two thousand six hundred seventy-two come from is written in the list below and sums to a single line: the printer discovery and local cache flows that branch endpoints open to each other, one hundred fourteen thousand seven hundred twenty paths together. These are legitimate flows, and when they are removed from the list, two hundred forty endpoints stop seeing each other. In a zone with legitimate peer-to-peer traffic, there is a ceiling on the paths host-based filtering can close, and what sets that ceiling is not the attack — it is the work itself.

The inter-zone column shows the opposite: ninety-seven point five percent closes. The difference comes from how few legitimate flows exist between zones — seven items. If the same list behaves this differently across the two sets, the value of host-based filtering is not in replacing the boundary rule, but in doing something in the set the boundary never looks at at all: one hundred fifty-eight thousand one hundred paths close without the boundary rule changing at all.

The third row gives the shape of the cost. When the allowlist was written, two legitimate flows were not in the list and were cut: file sharing from a branch endpoint to a field laptop, and a status query from a field laptop to an aggregator. When the two are added, the intra-zone remainder does not change at all — both are inter-zone — but the inter-zone remainder rises from ten thousand eight hundred to forty-six thousand eight hundred. Adding two items quadruples the open inter-zone path count. Every item written into the list behaves this way, and this is the real cost of maintenance: the twelve-item list is not a one-time job, it is an inventory that keeps growing.

The sixty denied path types, meanwhile, are written into the second column, visibility. The filter on the host side is the one layer in this lesson that both closes and records in the same move: the connection attempt it denies produces a record, and that record is added to the previous lesson’s telemetry sources. The closed path and the path that lands in the record are the same set here — one of the rare places in the course where these two columns overlap.

The last table gives the lesson’s harshest number. The number of endpoints reachable in a single hop from a compromised aggregator drops from four hundred thirty-one to seventy-one, and from a field laptop to one hundred thirty-one. From a compromised central server, four hundred thirty-one endpoints remain reachable and the closed count is zero. The reason is plain: the remote support flow is written from the center to all three of the other endpoint classes, and if that flow is removed there is no such thing as remote support left. The management path itself cannot be closed; a path that cannot be closed must at least be made visible.

How Much Each Item Opens

List items are not equal. Starting from an empty list and adding items one at a time shows, separately, how many paths each item opens and how many legitimate flows it saves in return.

// run3.mjs — paths opened as the list grows: how many paths each item opens, how many legitimate flows it saves
import { ALLOWED, FORGOTTEN, paths, isInternal, isAllowed, sum } from './network.mjs';

const Y = paths(), FULL = [...ALLOWED, ...FORGOTTEN];
console.log(`${'item'.padEnd(4)} ${'added flow'.padEnd(58)} ${'new paths'.padStart(10)} ` +
  `${'remaining'.padStart(10)} ${'still cut'.padStart(12)}`);
for (let n = 0; n <= FULL.length; n++) {
  const list = FULL.slice(0, n);
  const remaining = sum(Y.filter((y) => isAllowed(y, list)));
  const added = n === 0 ? 0 : remaining - sum(Y.filter((y) => isAllowed(y, FULL.slice(0, n - 1))));
  const label = n === 0 ? '(empty list: everything is denied)' : `${FULL[n - 1][0]} -> ${FULL[n - 1][1]} / ${FULL[n - 1][2]}`;
  console.log(`${String(n).padEnd(4)} ${label.padEnd(58)} ${String(added).padStart(10)} ${String(remaining).padStart(10)} ` +
    `${String(FULL.length - n).padStart(12)}`);
}
const firstThree = sum(Y.filter((y) => isAllowed(y, FULL.slice(0, 3))));
console.log(`\nfirst 3 items open ${(firstThree * 100 / sum(Y)).toFixed(2)}% of the total paths`);
const in_ = FULL.filter((i) => isInternal([i[0], i[1]]));
console.log(`intra-zone item count: ${in_.length}/${FULL.length}; paths they open: ` +
  `${sum(Y.filter((y) => isAllowed(y, in_)))} (${(sum(Y.filter((y) => isAllowed(y, in_))) * 100 / sum(Y.filter((y) => isAllowed(y, FULL)))).toFixed(1)}% of the remainder)`);
item added flow                                                  new paths  remaining    still cut
0    (empty list: everything is denied)                                  0          0           14
1    aggregator -> central server / metric ingestion                   720        720           13
2    central server -> aggregator / status query                       720       1440           12
3    field laptop -> central server / reporting interface             1440       2880           11
4    branch endpoint -> central server / reporting interface          2880       5760           10
5    central server -> field laptop / remote support                  1440       7200            9
6    central server -> branch endpoint / remote support               2880      10080            8
7    central server -> aggregator / remote support                     720      10800            7
8    branch endpoint -> branch endpoint / local cache                57360      68160            6
9    branch endpoint -> branch endpoint / printer discovery          57360     125520            5
10   field laptop -> field laptop / file sharing                     14280     139800            4
11   aggregator -> aggregator / file sharing                          3540     143340            3
12   central server -> central server / database                       132     143472            2
13   branch endpoint -> field laptop / file sharing                  28800     172272            1
14   field laptop -> aggregator / status query                        7200     179472            0

first 3 items open 0.39% of the total paths
intra-zone item count: 5/14; paths they open: 132672 (73.9% of the remainder)

The curve splits into two regions. The first seven items — all of the inter-zone flows — open ten thousand eight hundred paths in total; half of the fourteen items produce six percent of the remaining paths. The eighth and ninth items alone open one hundred fourteen thousand seven hundred twenty paths. Two items open roughly twice as much as the other twelve combined, and both can be defended with the same sentence: branch endpoints need to see each other.

A list measured by item count is therefore misleading. Five of the fourteen items are intra-zone, and they open seventy-three point nine percent of the remaining paths. Reviewing an allowlist is not counting items — it is writing down, next to each item, the path it opens.

Summary

  • The 432-endpoint model has 729,252 connection paths; 290,772 of them (39.9%) are intra-zone, and the boundary rule neither denies nor logs this set — it never sees it at all.
  • 79% of the intra-zone paths come from a single zone (branch): the path count grows with the square of the endpoint count and linearly with the service count.
  • The 12-item host-based allowlist closes 54.4% of the intra-zone paths (158,100 paths) and 97.5% of the inter-zone paths. What makes the difference is the legitimate peer-to-peer traffic inside a zone: the printer discovery and local cache flows between branch endpoints alone leave 114,720 paths open.
  • The cost is two items: 2 legitimate flows not written into the list were cut; adding them raised the open inter-zone paths from 10,800 to 46,800. The list is not a one-time job — it is an inventory that grows the surface with every addition.
  • The 60 denied path types land in the record in the same move: the host-based filter is the rare layer where the closing column and the visibility column overlap.
  • The number of endpoints reachable in a single hop from a compromised endpoint drops from 431 to 71 for an aggregator and to 131 for a field laptop; it stays at 431 for a central server, and the closed count is zero — the remote support flow cannot be removed.

Next Step

This lesson’s allowlist rested on an assumption: that whatever wants to use a closed path has a tool of its own. The legitimate flows written into the list were already open, and the tools that carried out those flows already sat on the endpoint. If the tool that carries the remote support flow is itself run with malicious intent, no rule is violated; no path closes, no denial record is produced. The next lesson’s question is this: how many of the built-in and signed tools on the endpoint can do the same work, why can a signature-based rule not tell them apart, and how many can behavioral context — parent process, command pattern, timing — tell apart.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close