Lesson 16 / 22
Cross-Site Scripting
Measuring the three forms as separate step sequences: reflected five, stored six, DOM-based four steps; the number of steps the three server-side layers break in the DOM-based form being zero, no link breaking in three of the twenty-four combinations across eight pages, the daily cost of one hundred ninety sessions for the seven combinations broken only at the last step, and a single browser-side layer closing off all three forms at once.
Published Updated
In this lesson
- Models the three forms of cross-site scripting as separate step sequences and compares their step counts
- Counts how many steps the server-side layers break in the DOM-based form
- Measures the first broken link per page and gives the cost of breaking late in sessions
Contents
In the previous lesson’s chain, the interpreter was inside the server, and three of the six steps passed through the server. This lesson measures the same step sequence when the interpreter is in the user’s browser. The only thing that changes is where parsing happens; the result is that the chain’s length and the breakable link land in three separate places across three separate forms.
The three forms are separated: the reflected form, where the value comes back in the same response; the stored form, where the value is first written to storage and then served to every viewer; and the DOM-based form, where the value never touches the server at all. Throughout the measurement, no script body, no value, and no page source is ever written out; the forms are referred to only by their step sequences.
Three Step Sequences
Reflected form, five steps. (1) The value reaches the endpoint in a request field — precondition: the endpoint writes that field back into its response. (2) The value lands in a position that will be read as code in the response — precondition: no encoding is applied for that position’s context. (3) The link reaches the victim and the victim opens it with their own session — precondition: a delivery channel exists. (4) The browser executes the placed value — precondition: the page’s content policy allows this source. (5) The running code accesses the session item and carries it out — precondition: the cookie is script-accessible.
Stored form, six steps. The reflected form’s first step splits in two: the value first enters storage through a write endpoint, then lands in the response along a view path. In exchange, the third step’s precondition disappears: nobody needs to be directed to a link, anyone who opens the page for their own work completes the chain themselves. The chain gets one step longer and the delivery link comes free.
DOM-based form, four steps. (1) The value is read from a client-side source — precondition: a source that never reaches the server is used, such as the URL fragment or local storage. (2) The client script writes the value to a target that is interpreted as code — precondition: the target selection is not fixed. (3) The browser executes it. (4) Access and exit. This sequence has no step that passes through the server; the server never sees the request, it never lands in its log, and it never alters its response.
What the three sequences share is the last two steps: execution and exit. That is why the defense can stand in two separate places — one blocks the value from landing in the response, the other blocks the browser from executing it. Where they diverge is the start: the reflected and stored forms pass through the server, the third does not.
TA10: each form is a separate step sequence. TA11: each layer breaks a specific step in each form; in a form it does not break, it has no value. TA12: for per-link delivery the cost is a fictional 12 people, for per-view delivery it is the page’s daily view count. TA13: the first broken link is the smallest step index broken by the layers deployed on that page.
// xss/form.mjs — cross-site scripting's three forms, their step sequences, and layers // (model). There is no real page, no real value, and no script body at all. export const FORM = { reflected: { steps: ["entry", "placement", "delivery", "execution", "exit"], side: ["server", "server", "victim", "browser", "browser"], delivery: "per link" }, stored: { steps: ["write", "storage", "placement", "view", "execution", "exit"], side: ["server", "server", "server", "victim", "browser", "browser"], delivery: "per view" }, domBased: { steps: ["client-source", "write-to-target", "execution", "exit"], side: ["browser", "browser", "browser", "browser"], delivery: "per link" }, }; // breaks[form] = 1-based step index, or 0 (does not break any step in that form). export const LAYER = [ { name: "input-contract", side: "server", breaks: { reflected: 1, stored: 1, domBased: 0 }, legit: 3 }, { name: "storage-sanitization", side: "server", breaks: { reflected: 0, stored: 2, domBased: 0 }, legit: 2 }, { name: "output-encoding", side: "server", breaks: { reflected: 2, stored: 3, domBased: 0 }, legit: 0 }, { name: "content-policy", side: "browser", breaks: { reflected: 4, stored: 5, domBased: 3 }, legit: 7 }, { name: "client-target-selection", side: "client-code", breaks: { reflected: 0, stored: 0, domBased: 2 }, legit: 1 }, { name: "cookie-flag", side: "browser", breaks: { reflected: 5, stored: 6, domBased: 4 }, legit: 4 }, ]; export const LINK = 12; // The fictional measurement network's eight pages: deployed layers and daily view count. export const PAGE = [ ["subscriber-portal.search", ["input-contract", "output-encoding", "content-policy", "cookie-flag"], 210], ["subscriber-portal.dispute", ["input-contract", "storage-sanitization", "output-encoding", "cookie-flag"], 90], ["subscriber-portal.billing", ["output-encoding", "content-policy", "cookie-flag"], 340], ["field-app.work-order", ["input-contract", "output-encoding", "cookie-flag"], 160], ["field-app.field-note", ["cookie-flag"], 75], ["branch-endpoint.dashboard", ["output-encoding", "content-policy"], 40], ["branch-endpoint.report-link", ["client-target-selection", "cookie-flag"], 55], ["admin.metric-search", [], 20], ]; export const write = (g, ...s) => console.log(s.map((v, i) => (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join(""));
// xss/measure.mjs — the three forms are run across eight pages: the first broken link, // how many steps the server side breaks, and the sessions affected if unbroken. No randomness. import { FORM, LAYER, PAGE, LINK, write } from "./form.mjs"; const forms = Object.keys(FORM); const LABEL = { reflected: "reflected", stored: "stored", domBased: "DOM-based" }; const firstBreak = (b, d) => Math.min(...LAYER .filter((k) => d.includes(k.name) && k.breaks[b] > 0).map((k) => k.breaks[b]), Infinity); const cost = (b, views) => (b === "stored" ? views : LINK); console.log(`${forms.length} forms, ${LAYER.length} layers, ${PAGE.length} fictional pages\n`); const A = [-16, 7, 11, 12, 20]; console.log("1. chain per form"); write(A, "form", "steps", "on server", "breakable", "delivery"); for (const b of forms) write(A, LABEL[b], FORM[b].steps.length, FORM[b].side.filter((t) => t === "server").length, LAYER.filter((k) => k.breaks[b] > 0).length, FORM[b].delivery); const B = [-25, -14, 10, 8, 15]; console.log("\n2. layer-to-step mapping"); write(B, "layer", "side", "reflected", "stored", "DOM-based"); for (const k of LAYER) write(B, k.name, k.side, k.breaks.reflected || "-", k.breaks.stored || "-", k.breaks.domBased || "-"); const server = LAYER.filter((k) => k.side === "server"); console.log(`the ${server.length} server-side layers break this many steps in the DOM-based form: ` + `${server.filter((k) => k.breaks.domBased > 0).length}`); const C = [-29, 7, 12, 12, 15]; console.log("\n3. first broken link per page"); write(C, "page", "layers", "reflected", "stored", "DOM-based"); let open = 0, sessions = 0; for (const [name, d, views] of PAGE) { const h = forms.map((b) => firstBreak(b, d)); h.forEach((v, i) => { if (v === Infinity) { open++; sessions += cost(forms[i], views); } }); write(C, name, d.length, ...h.map((v) => (v === Infinity ? "not broken" : `step ${v}`))); } console.log(`no link breaks in ${open} of the ${PAGE.length * forms.length} page-form combinations; ` + `those combinations' daily cost is ${sessions} fictional sessions`); const lateBreaks = PAGE.flatMap(([, d, views]) => forms.filter((b) => firstBreak(b, d) === FORM[b].steps.length).map((b) => cost(b, views))); console.log(`combinations broken only at the last step: ${lateBreaks.length}, daily cost ` + `${lateBreaks.reduce((a, x) => a + x, 0)} fictional sessions`); const D = [-25, 10, 8, 15, 9, 20]; console.log("\n4. if a single layer covered every page"); write(D, "single layer", "reflected", "stored", "DOM-based", "legit", "remaining sessions"); for (const k of LAYER) { const remaining = PAGE.reduce((t, [, , views]) => t + forms.reduce((s, b) => s + (k.breaks[b] > 0 ? 0 : cost(b, views)), 0), 0); write(D, k.name, k.breaks.reflected || "-", k.breaks.stored || "-", k.breaks.domBased || "-", k.legit, remaining); } const allOpen = PAGE.reduce((t, [, , views]) => t + forms.reduce((s, b) => s + cost(b, views), 0), 0); console.log(`daily cost with no layer at all: ${allOpen} fictional sessions`);
3 forms, 6 layers, 8 fictional pages 1. chain per form form steps on server breakable delivery reflected 5 2 4 per link stored 6 3 5 per view DOM-based 4 0 3 per link 2. layer-to-step mapping layer side reflected stored DOM-based input-contract server 1 1 - storage-sanitization server - 2 - output-encoding server 2 3 - content-policy browser 4 5 3 client-target-selection client-code - - 2 cookie-flag browser 5 6 4 the 3 server-side layers break this many steps in the DOM-based form: 0 3. first broken link per page page layers reflected stored DOM-based subscriber-portal.search 4 step 1 step 1 step 3 subscriber-portal.dispute 4 step 1 step 1 step 4 subscriber-portal.billing 3 step 2 step 3 step 3 field-app.work-order 3 step 1 step 1 step 4 field-app.field-note 1 step 5 step 6 step 4 branch-endpoint.dashboard 2 step 2 step 3 step 3 branch-endpoint.report-link 2 step 5 step 6 step 2 admin.metric-search 0 not broken not broken not broken no link breaks in 3 of the 24 page-form combinations; those combinations' daily cost is 44 fictional sessions combinations broken only at the last step: 7, daily cost 190 fictional sessions 4. if a single layer covered every page single layer reflected stored DOM-based legit remaining sessions input-contract 1 1 - 3 96 storage-sanitization - 2 - 2 192 output-encoding 2 3 - 0 96 content-policy 4 5 3 7 0 client-target-selection - - 2 1 1086 cookie-flag 5 6 4 4 0 daily cost with no layer at all: 1182 fictional sessions
The Chain the Server Never Sees
The last line of the second table is this lesson’s main finding: the number of steps the three server-side layers break in the DOM-based form is zero. The input contract never sees a request, storage sanitization never sees a write, output encoding never sees a value written into the response. The value never touches the server at all.
This has a second consequence, heavier than the first: the server records none of this chain’s steps. When a chain cannot be broken, the measure shifts to detection; here, detection cannot happen server-side either, because there is no log line to look at. The only places this form can be seen are a policy-violation report that describes browser behavior and the client code’s own logging.
The same line also highlights a layer class: client-target-selection only carries value in
this form, and it alone breaks the second step. No decision about hardening the server
affects this step.
This also determines where the measure moves. A defense report might count the three server-side layers and write “three-layer protection,” and that is true for two forms. For the third form, the count is three, but the number of steps it breaks is zero — and the difference stays invisible unless the report separates the two. This line is the reason for counting broken steps instead of counting layers.
Chain Length and Delivery
The first table puts the three forms side by side: 5, 6, and 4 steps. The longest chain is not the safest one. The stored form has six steps, and the extra step favors the attacker: the fourth step requires no delivery effort, anyone who opens the page for their own work completes that step.
The number of layers that can break it reads the same way: four layers can break the reflected form, five the stored form, three the DOM-based form. The stored form is the one seen by the most layers because most of its steps pass through the server; the one seen by the fewest layers is the one that never passes through the server at all. A chain’s visibility is measured not by its length, but by how many steps it spends on the side where the defense sits.
The delivery column says the unit of cost differs too. In the reflected and DOM-based forms, the cost is per link — a fictional 12 people. In the stored form, the cost is per view and ranges from 20 to 340 depending on the page. Same class, same page, different form: the cost of breaking late can run up to twenty-eight times as high.
Breaking Per Page
The third table measures deployment. In 3 of the 24 combinations formed by eight pages and three forms, no link breaks at all; all three are concentrated on a single page. That page has no layer deployed at all, and its daily cost is 44 fictional sessions.
The subtler result is in seven combinations: there, the chain does break, but only at the last step. On a page that carries the cookie flag but has no other layer, code runs, the page changes, requests go out — the only link that breaks is the access at the very end. These seven combinations have a daily cost of 190 sessions, and they show up as “protected” in the defense report.
The table’s left column gives the reason for this result. On the two pages with four layers,
the chain breaks at the first step; on pages with three layers, at the second or third step; on
the page with a single layer, at the fifth and sixth step. In this set, the layer count and the
index of the broken link move together, but not one to one: the three-layer
subscriber-portal.billing breaks at the second step, and the two-layer
branch-endpoint.dashboard also breaks at the second step. What matters is not the count, but
which steps the layers fall on.
The branch-endpoint.report-link row shows the directional difference between two layers. In
the DOM-based form, the chain breaks at the second step, because client target selection
sits there; in the reflected and stored forms, it only breaks at the fifth and sixth step. Same
page, same two layers, three different links depending on the form.
A Single Layer’s Coverage
The fourth table assumes each layer alone, deployed on every page, and counts the remaining daily cost. With no layer at all, the cost is 1,182 fictional sessions.
Two rows give zero: the content policy and the cookie flag break all three forms, because both are browser-side and all three forms converge there. Their legitimate-work cost is 7 and 4 — the most expensive of the six layers.
The storage-sanitization row gives the limit of a layer tied to a single form: it breaks the stored form at the second step, does nothing in the other two forms, and leaves 192 sessions remaining — not the highest of the six rows, but one of the two layers tied to only a single form.
Output encoding’s legitimate-work cost is zero, and it breaks the reflected and stored forms at the second and third step; but it does nothing in the DOM-based form and leaves 96 sessions remaining. The cheapest layer breaks early but never sees one of the three forms; the layer that covers all three forms at once is the most expensive one. Coverage and earliness do not converge in the same layer — that is the measured reason both layers are needed.
Summary
- The three forms are three separate chains: reflected 5, stored 6, DOM-based 4 steps. The stored form’s extra step favors the attacker, because it removes the delivery link’s precondition.
- The number of steps the 3 server-side layers break in the DOM-based form is 0, and none of that chain’s steps land in a server log; there, the measure shifts from breaking to detection, and detection can only happen browser-side.
- The unit of cost changes by form: 12 fictional people per link, 20 to 340 per view. On the same page, the cost of breaking late can run up to twenty-eight times as high depending on the form.
- Of the 24 combinations formed by eight pages and three forms, 3 have no link breaking at all (44 sessions/day), and 7 break only at the last step (190 sessions/day).
- Each of the two browser-side layers alone closes off all three forms and brings the remaining cost to 0; their legitimate-work cost is 7 and 4. Output encoding, with a cost of 0, breaks early but leaves 96 sessions remaining because it never sees one of the forms.
Next Step
In all three of this lesson’s forms, the code ran in the victim’s browser and accessed the session item at the last step. There is a quieter thing the same browser can do: send a request with the victim’s identity, without running any code at all. The next lesson measures that class — the direction in which a request travels through the victim’s browser and the direction in which it travels with the server’s own identity are written as separate chains, how many steps each direction has, which layer breaks which step, and why the two directions’ late-break costs cannot be measured in the same unit.
To keep your progress and take notes, Log in
My notes
Log in to take notes.