---
title: 'Path Traversal'
source: 'https://academia.sh/en/courses/attack-techniques/directory-traversal'
course: 'Attack Techniques and the Threat Landscape'
language: en
updated: '2026-09-26T03:02:49+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "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'
---

# 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.

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

1. **The name comes from the request.** Precondition: the endpoint takes the name of the item
   to serve from a request field.
2. **The name is concatenated into the path.** Precondition: the name is not mapped to an
   identifier; it is used directly as a path component.
3. **The concatenated path falls outside the root.** Precondition: there is no check, after
   normalization, on whether the path stays under the root.
4. **The process identity can read that item.** Precondition: the file permissions of the
   identity the process runs as cover that item.
5. **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.

```js
// 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(""));
```

```js
// 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.
