Lesson 18 / 22
Path Traversal
Measuring path traversal as a five-step chain and a count of accessible items: in a thirty-item fictional inventory, the root check brings the item count down to twelve and the sensitivity down to seven; the narrow process identity removes no items within the jail; the identifier mapping breaks the chain at the second step and leaves ten items; and the jail, which breaks no step at all, brings the sensitivity down from forty-one to twenty.
Published Updated
In this lesson
- Models path traversal as a five-step chain and records each step's precondition
- Compares layer sets by accessible item count and total sensitivity
- Distinguishes a step-breaking layer from a layer that only reduces cost, in the same run
Contents
In the previous lesson’s server direction, the request went to an address that came from outside. The same structure turns into a different class when, instead of an address, it is a file path: the endpoint takes an item name, appends it to the root directory it means to serve from, and reads the result. Path traversal is that chain.
This lesson’s measure departs from the previous ones on one point. Here, the cost of breaking late is not a transaction or session count, but the number of readable items — and that number does not depend on where the chain breaks, but on the size of the inventory the process sees when it does not. That is why a fictional file inventory sits at the center of this measurement. No path string is ever produced; items are defined only by their location and permission properties.
The Chain’s Five Steps
- The name comes from the request. Precondition: the endpoint takes the name of the item to serve from a request field.
- The name is concatenated into the path. Precondition: the name is not mapped to an identifier; it is used directly as a path component.
- The concatenated path falls outside the root. Precondition: there is no check, after normalization, on whether the path stays under the root.
- The process identity can read that item. Precondition: the file permissions of the identity the process runs as cover that item.
- The content is written to the response. Precondition: the response body carries the item’s content as-is.
The third step is the one that carries this class’s name, but it does not produce anything on its own. A path that falls outside the root does not produce a read unless the fourth step also holds. What determines this class’s cost is not the third step, but the inventory the fourth step covers.
The second step is also a decision in its own right, and it is skipped in most discussions. An endpoint can determine the item it serves not by the name coming from the request, but by selecting from a predetermined set of identifiers. In that case, the value coming from the request is not a path component; it is a key, and if the key is outside the set, the request goes unanswered. This decision is the earliest breakable point in the chain, and it defines the inventory not from the root, but from the list.
TA18: the step sequence is a model. TA19: each layer breaks a single step; the jail breaks no step, it only narrows the inventory. TA20: the inventory is 30 fictional items, and each item is defined by location, permission, and a sensitivity weight. TA21: the accessible set is the result of the deployed layers narrowing the inventory.
// pt/inventory.mjs — a fictional file inventory and the path traversal chain (model). // No real file system, no real path, and no traversal string at all; items are defined // only by their location and permission properties. export const STEP = ["name-from-request", "path-concatenation", "escape-root", "process-read", "response-write"]; export const LAYER = [ { name: "identifier-mapping", breaks: 2, legit: 6 }, { name: "root-check", breaks: 3, legit: 1 }, { name: "narrow-process-identity", breaks: 4, legit: 4 }, { name: "output-boundary", breaks: 5, legit: 3 }, { name: "jail", breaks: 0, legit: 9 }, ]; // root = is it under the root the app means to serve from, jail = is it inside the process's jail, // owner = the item's owner, shared = is it world-readable, sens = fictional sensitivity weight. const I = (name, root, jail, owner, shared, sens) => ({ name, root, jail, owner, shared, sens }); export const ITEM = [ I("invoice/2026-01.doc", 1, 1, "app", 0, 1), I("invoice/2026-02.doc", 1, 1, "app", 0, 1), I("invoice/2026-03.doc", 1, 1, "app", 0, 1), I("report/period-summary.doc", 1, 1, "app", 0, 1), I("report/meter-dump.doc", 1, 1, "app", 0, 1), I("template/notification.text", 1, 1, "app", 0, 0), I("template/invoice.text", 1, 1, "app", 0, 0), I("field/photo-1.image", 1, 1, "app", 0, 0), I("field/photo-2.image", 1, 1, "app", 0, 0), I("field/photo-3.image", 1, 1, "app", 0, 0), I("download/dump.archive", 1, 1, "app", 0, 1), I("download/history.archive", 1, 1, "app", 0, 1), I("app/setting.config", 0, 1, "app", 0, 3), I("app/database.connection", 0, 1, "app", 0, 3), I("app/signing.key", 0, 1, "app", 0, 3), I("app/log/access.record", 0, 1, "app", 0, 2), I("app/log/error.record", 0, 1, "app", 0, 2), I("app/version.text", 0, 1, "app", 1, 0), I("shared/timezone.table", 0, 1, "system", 1, 0), I("shared/codepage.table", 0, 1, "system", 1, 0), I("system/user-list.record", 0, 0, "system", 1, 2), I("system/weight-table.record", 0, 0, "system", 1, 1), I("system/network-setting.config", 0, 0, "system", 1, 2), I("system/service-definition.unit", 0, 0, "system", 1, 1), I("system/secret/identity.store", 0, 0, "system", 0, 3), I("system/secret/key.store", 0, 0, "system", 0, 3), I("neighbor/collector-setting.config", 0, 0, "neighbor", 0, 3), I("neighbor/validator-setting.config", 0, 0, "neighbor", 0, 3), I("neighbor/invoice-template.text", 0, 0, "neighbor", 0, 1), I("neighbor/log-archive.record", 0, 0, "neighbor", 0, 2), ]; // Identifier mapping: the endpoint selects from a fixed set of identifiers instead of the name. export const MAPPING = ITEM.filter((k) => k.root && !k.name.startsWith("download/")); export const ENDPOINT = [ ["subscriber-portal.invoice-download", ["identifier-mapping", "root-check", "narrow-process-identity"]], ["subscriber-portal.report-view", ["root-check", "narrow-process-identity", "output-boundary"]], ["field-app.photo-fetch", ["root-check", "jail"]], ["billing-generation.template-read", ["identifier-mapping"]], ["report.dump-download", ["narrow-process-identity"]], ["admin.record-view", []], ]; 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(""));
// pt/measure.mjs — accessible item count: for each layer set, how much of the inventory // stays readable. No randomness; no path string is ever produced. import { STEP, LAYER, ITEM, MAPPING, ENDPOINT, write } from "./inventory.mjs"; function accessible(d) { if (d.includes("identifier-mapping")) return MAPPING; // step 2 broken let k = d.includes("jail") ? ITEM.filter((x) => x.jail) : ITEM; if (d.includes("root-check")) k = k.filter((x) => x.root); // step 3 broken if (d.includes("narrow-process-identity")) // step 4 broken k = k.filter((x) => x.owner === "app" || x.shared); return k; } const sens = (k) => k.reduce((t, x) => t + x.sens, 0); const firstBreak = (d) => Math.min(...LAYER .filter((x) => d.includes(x.name) && x.breaks > 0).map((x) => x.breaks), Infinity); console.log(`${STEP.length}-step chain, ${LAYER.length} layers, ` + `${ITEM.length}-item fictional inventory (under root ${ITEM.filter((k) => k.root).length}, ` + `inside jail ${ITEM.filter((k) => k.jail).length}, total sensitivity ${sens(ITEM)})\n`); const A = [-26, 9, 10, 12]; console.log("1. layer-to-step mapping"); write(A, "layer", "breaks", "legit", "deployment"); for (const k of LAYER) write(A, k.name, k.breaks ? `step ${k.breaks}` : "none", k.legit, ENDPOINT.filter(([, d]) => d.includes(k.name)).length); const B = [-46, 8, 14, 13]; console.log("\n2. eight setups (excluding identifier mapping)"); write(B, "deployment", "items", "sensitivity", "first break"); const TRIO = ["root-check", "narrow-process-identity", "jail"]; for (let m = 0; m < 8; m++) { const d = TRIO.filter((_, i) => m & (1 << i)); const k = accessible(d); const h = firstBreak(d); write(B, d.length ? d.join(" + ") : "none", k.length, sens(k), h === Infinity ? "not broken" : `step ${h}`); } const em = accessible(["identifier-mapping"]); console.log(`identifier mapping alone: ${em.length} items, sensitivity ${sens(em)}, ` + `breaks at step 2; its legitimate-work cost is every new item having to be added to the mapping`); const C = [-38, 8, 13, 9, 14, 15]; console.log("\n3. chain per endpoint"); write(C, "endpoint", "layers", "first break", "items", "sensitivity", "outside root"); for (const [name, d] of ENDPOINT) { const k = accessible(d), h = firstBreak(d); write(C, name, d.length, h === Infinity ? "not broken" : `step ${h}`, k.length, sens(k), k.filter((x) => !x.root).length); } const open = ENDPOINT.filter(([, d]) => firstBreak(d) === Infinity); console.log(`endpoints with no link broken at all: ${open.length}; at these endpoints, readable ` + `items ${accessible([]).length}, sensitivity ${sens(accessible([]))}`); const D = [-20, 12, 14, 14]; console.log("\n4. the jail's separate place"); write(D, "deployment", "step broken", "items", "sensitivity"); for (const [name, d] of [["none", []], ["jail only", ["jail"]], ["root-check only", ["root-check"]], ["jail + root-check", ["jail", "root-check"]]]) { const k = accessible(d), h = firstBreak(d); write(D, name, h === Infinity ? "none" : `step ${h}`, k.length, sens(k)); } console.log(`the jail breaks no step at all, but it brings the inventory down from ${ITEM.length} ` + `items to ${ITEM.filter((k) => k.jail).length}, and the sensitivity down from ${sens(ITEM)} to ` + `${sens(ITEM.filter((k) => k.jail))}`);
5-step chain, 5 layers, 30-item fictional inventory (under root 12, inside jail 20, total sensitivity 41) 1. layer-to-step mapping layer breaks legit deployment identifier-mapping step 2 6 2 root-check step 3 1 3 narrow-process-identity step 4 4 3 output-boundary step 5 3 1 jail none 9 1 2. eight setups (excluding identifier mapping) deployment items sensitivity first break none 30 41 not broken root-check 12 7 step 3 narrow-process-identity 24 26 step 4 root-check + narrow-process-identity 12 7 step 3 jail 20 20 not broken root-check + jail 12 7 step 3 narrow-process-identity + jail 20 20 step 4 root-check + narrow-process-identity + jail 12 7 step 3 identifier mapping alone: 10 items, sensitivity 5, breaks at step 2; its legitimate-work cost is every new item having to be added to the mapping 3. chain per endpoint endpoint layers first break items sensitivity outside root subscriber-portal.invoice-download 3 step 2 10 5 0 subscriber-portal.report-view 3 step 3 12 7 0 field-app.photo-fetch 2 step 3 12 7 0 billing-generation.template-read 1 step 2 10 5 0 report.dump-download 1 step 4 24 26 12 admin.record-view 0 not broken 30 41 18 endpoints with no link broken at all: 1; at these endpoints, readable items 30, sensitivity 41 4. the jail's separate place deployment step broken items sensitivity none none 30 41 jail only none 20 20 root-check only step 3 12 7 jail + root-check step 3 12 7 the jail breaks no step at all, but it brings the inventory down from 30 items to 20, and the sensitivity down from 41 to 20
The Inventory’s Narrowing
The second output tries every combination of the three layers. With no layer at all, all 30 items are readable and the total sensitivity is 41; the root check alone brings this down to 12 items and 7 sensitivity. The item count drops by 60% while the sensitivity drops by 83%: the 18 items outside the root carry a disproportionately large share of the inventory’s sensitivity.
The narrow process identity alone leaves 24 items and 26 sensitivity. It is noticeably weaker than the root check, because it only looks at ownership: the app’s own items and world-readable system items are not eliminated. The app’s own configuration, connection, and key items sit outside the root and stay readable even when the process identity is narrowed.
The sensitivity column dropping faster than the item column is the most useful thing about this measurement. Counting items gives the inventory’s size, while the sensitivity total gives what is lost. If a defense decision is judged by item count alone, a layer that closes twenty of thirty items looks good; the sensitivity column tells how much of the total weight those twenty items carried, and the two columns do not always move in the same direction.
The combinations say a second thing: the root check pins the result to 12/7 in every row it appears in. No layer added to it changes the number. When the earliest breakable link determines the result, the layers that follow it contribute nothing to the inventory — this does not mean the fourth and fifth layers are unnecessary; it shows they would take effect if the root check were ever missing from an endpoint.
The identifier mapping breaks the chain earlier: at the second step, before the name is even turned into a path. What is left is 10 items and 5 sensitivity — a narrower set than the root check, because the two archive items under the root are not part of the mapping. Its cost is of a different kind: every new item has to be added to the mapping by hand; its legitimate-work cost of 6 is six times the root check’s.
The Jail’s Separate Place
The fourth output makes this lesson’s distinction. The jail breaks no step at all: the name still comes from the request, is still concatenated into the path, still falls outside the root, is still read, and is still written to the response. All five steps of the chain still complete.
What it does sits on the other side of the measure: it brings the inventory down from 30 items to 20, and the sensitivity down from 41 to 20. In a defense table, the “link broken” row stays empty, while the “cost of breaking late” row is cut in half. A layer that does not break the chain is not outside the measurement; it is the layer that reduces the cost.
This distinction shows why this course keeps three columns separate. The breakable-link column and the cost-of-breaking-late column can move independently: one layer can cut the second in half without improving the first at all, another can improve the first and never touch the second. An assessment made with a single column always misses one of these two layers.
The row where the jail and the narrow process identity are used together gives a second finding: the two together produce the same result as the jail alone. All 20 items inside the jail are already either the app’s own items or world-readable items; the narrow identity removes no item there. When two layers narrow the same set, the second one’s contribution does not show up in the number.
Breaking Per Endpoint
The third output measures six endpoints. At four endpoints, the chain breaks at the second or third step, and the count of outside-root items in the accessible set is zero. At one endpoint, the chain breaks at the fourth step and 24 items remain; 12 of those are outside the root. At one endpoint, no link breaks at all: 30 items, 41 sensitivity, 18 items outside the root.
The difference between two endpoints is a single layer. report.dump-download carries only the
narrow process identity; if the root check had been added, readable items would have dropped
from 24 to 12, sensitivity from 26 to 7, and its legitimate-work cost would have been 1. In the same
measurement, the jail, with a legitimate-work cost of 9, would have brought the sensitivity down from 26 to
only 20.
This comparison is this lesson’s practical conclusion: the cheapest layer is the root check, and it sits right behind the one that narrows the most — it leaves 12 items at a cost of 1, while the identifier mapping, which breaks at the second step, costs 6 for 10. The jail and the narrow identity do not replace it; they bound the remaining cost on an endpoint where it is forgotten.
Where the output boundary sits is also readable in the same table. This layer, which breaks the fifth step, is deployed on only one of six endpoints, and at that endpoint the chain already breaks at the third step; that is, it changes no number in this run. Its value shows up at an endpoint where all three layers before it are missing at once — and there is such an endpoint in the table, but the output boundary is not there either. The layers’ deployment was built to repeat one another, not to complement one another; the measurement does not show this at a glance, but it becomes visible once the layer sets are compared endpoint by endpoint.
Summary
- Path traversal is a five-step chain, and the third step, which carries the class’s name, does not produce a read on its own; what determines the cost is the inventory the fourth step covers.
- In the 30-item fictional inventory, the root check alone brings readable items down to 12 and sensitivity down to 7; the item count drops by 60%, the sensitivity by 83%.
- The narrow process identity leaves 24 items and 26 sensitivity; the app’s own configuration and key items outside the root are not eliminated by the ownership criterion.
- The identifier mapping breaks the chain at the earliest point — the second step — and leaves 10 items, but its legitimate-work cost of 6 is six times the root check’s.
- The jail breaks no step at all; it brings the inventory down from 30 to 20 items and the sensitivity down from 41 to 20. Used together with the narrow process identity, the second layer removes no item.
- At 1 of six endpoints, no link breaks at all (30 items, 41 sensitivity); adding a single layer at one endpoint would have brought sensitivity down from 26 to 7, at a legitimate-work cost of 1.
Next Step
What the four classes so far have in common is that the value was handed to an interpreter: a query parser, a page resolver, an address resolver, a path concatenator. The fifth has no interpreter. A value overflows into the neighboring memory space because it is larger than the space allocated for it, and the item in that neighboring space becomes something else. The next lesson measures that class at the logic level: which neighboring space the overflow corrupts in a bounded-array model, how many cases the boundary check catches, which language class removes this step entirely, and which compiler and operating-system check breaks which step.
To keep your progress and take notes, Log in
My notes
Log in to take notes.