Home/Journal/Teardown
TeardownCriticalpath-traversalmulti-tenantcredentials

One Traversal, Every Tenant

A path-traversal bug read the app config, the config held cleartext database credentials, and every clinic ran the same build. Here is the full chain.

Rahul Dharan··9 min read·a veterinary clinic-management SaaSPath traversal to full database compromise (CWE-22)

The setup

The target was a multi-tenant veterinary clinic-management SaaS: the software a vet practice runs its whole day on, from patient records and appointment books to owner billing and staff logins. In this product, each clinic gets its own host on the same shared product build. That is a common and sensible design - it isolates customers from each other and keeps deployment simple. It also means every clinic is running the same code, a detail that turned out to matter more than anything else here.

We looked at this class of product for a specific reason. Clinic-management software holds a dense concentration of sensitive data: pet-owner identities, contact details, government IDs collected for billing, medical histories, and the credentials of every staff member. It is exactly the kind of quietly high-stakes system that gets built for reliability first and hardened later.

Recon

Working black-box, from the open internet with no account, we started with a single clinic’s host. We enumerated what the application served, fingerprinted the stack behind it, and noted the framework and runtime versions where they were observable.

The stack was older than the product’s marketing suggested. One component in the request-handling path was a legacy piece with a long public history of a particular weakness. That was the thread we pulled.

The hypothesis

Legacy stacks carry legacy bug classes. When we see an older component sitting in front of file-serving logic, the first thing we reach for is path traversal - the decades-old class where user input is folded into a file path without being validated, and a crafted string walks the request out of its intended directory and into the rest of the filesystem.

The hypothesis was concrete: this component would accept a traversal sequence, and if it did, the real prize was not any single file but the application’s own config. Config files are where credentials live. If traversal worked and the config was readable, the bug would not stop at file disclosure - it would reach the database.

What we tried

Modern frameworks strip the obvious ../ sequences, so the naive payload usually fails, and it did here. What the older component did not fully normalize was an alternate encoding of the same idea - a ....\-style sequence that collapses back into a traversal after the framework’s first pass at cleaning it. That got us out of the intended directory.

We proved arbitrary file read with benign files first: well-known, boring paths that exist on essentially every host of this type and prove the read primitive without touching anything sensitive. Once the read primitive was confirmed, we requested the application config file.

The config was readable, and it held the database credentials in cleartext. We used them, read-only, to confirm they were valid and to pull schema and row counts - enough to establish exactly what an attacker would reach. On this development host, one more thing compounded the problem: the database port was itself exposed to the internet, so the credentials could be used directly from anywhere, no traversal required as a second step.

What we deliberately did not do matters just as much, and it defined how we handled the live tenant. When we moved to confirm the same build on a production clinic, we reproduced the traversal with benign files only. We did not read the production config. We did not touch the production database. We confirmed the vulnerable code was present and identical, retained only the counts needed to describe impact, and shredded our working notes afterward. Proving the door opens does not require walking through it on a live hospital’s system.

What we found

The chain was short and complete:

  1. A ....\-style traversal sequence defeated the legacy component’s path handling and gave arbitrary file read.
  2. Reading the application config file returned the database credentials in cleartext.
  3. Those credentials opened the full clinic database.
-> GET /<file-serving-endpoint>?path=....\....\....\<app-config>

<- ... database host, database name, username, and
       password, all in cleartext ...

The database behind those credentials held tens of thousands of records: pet-owner PII including the government IDs collected for billing, patient medical records, and the login credentials for clinic staff. On the development host, the internet-exposed database port meant an attacker did not even need to keep using the web application - they could connect straight to the database.

Then the finding multiplied. Because every clinic runs the same product build, the same traversal works on every clinic’s host. This was not one clinic’s misconfiguration. It was a single product-level flaw, present for the entire customer base at once. One bug, every tenant.

Why it happens

No one made a reckless decision here. This is what a fast-shipped product looks like a few years in, when the stack that got it launched has quietly aged underneath it.

Three ordinary choices lined up. A legacy component with a known traversal class stayed in the request path because it worked and replacing it was never urgent. Database credentials sat in a readable config file because that is the path of least resistance in almost every framework’s getting-started guide. And a database port was reachable from the internet on a development host because dev environments get less scrutiny than production. Each is common on its own. Chained, they turn a single crafted request into full database access - and because the product is one shared build, that chain is every customer’s chain.

For developers

If you run a multi-tenant product on a shared build, four controls close this:

  1. Patch or replace the traversal-prone component. Legacy pieces in the file-serving path are the usual source of this bug class. Upgrade them, and canonicalize every file path, then validate the resolved path against an allowlist of intended directories before you open anything.
  2. Never store database credentials in a readable config file. Move secrets into a secrets manager or a scoped runtime environment, so that a file-read bug discloses nothing usable. The goal is that reading your config teaches an attacker nothing.
  3. Never expose the database port to the internet. Bind it to the private network, and treat a publicly reachable database port - especially on a dev or staging host - as an incident, not a convenience.
  4. Remember that a product bug is every tenant’s bug. When all customers run one build, threat-model at the build level. A single flaw is not one support ticket; it is your entire customer base exposed simultaneously, so it deserves the priority of a fleet-wide event.

And one detection step: audit your file-serving endpoints for requests carrying traversal sequences, including the encoded and doubled-up variants, not just literal ../.

The takeaway

Severity here did not come from one clever bug. It came from three ordinary weaknesses in a line: a traversal that read files, a config that held secrets, and a database that trusted the network. Chained, they became full compromise, and a shared build made that compromise every tenant’s at once. The lesson is that real severity is almost always a chain, and the strongest defense is making sure no single link - the read, the secret, the reachable database - can hand over the next one.

This teardown is one instance of a pattern we see repeatedly. We wrote up the general case, and how to defend against it, in Chaining: Where Real Severity Comes From.

Frequently asked

What is a path-traversal vulnerability?
Path traversal is when user-supplied input is used to build a file path without proper validation, letting an attacker step outside the intended directory with sequences like '../' and read arbitrary files on the server. In its worst form it exposes application config files, which often hold secrets.
Why is reading a config file so dangerous?
Application config files frequently store database credentials, API keys, and other secrets in cleartext. An arbitrary-file-read bug that reaches the config file turns a limited disclosure into full database access, because the attacker now holds the same credentials the application uses.
How does one bug affect every tenant in a multi-tenant SaaS?
When every customer runs the same product build, a flaw in that build is present for all of them. One tenant-specific proof of concept generalizes to the whole customer base, because the vulnerable code is identical everywhere. The bug is a product bug, not a customer bug.
How do you fix a path-traversal to credential-disclosure chain?
Patch or replace the traversal-prone component, canonicalize and validate every file path against an allowlist, move secrets out of readable config into a secrets manager or scoped environment, and make sure the database is never reachable from the public internet.

This is one finding from a harness that runs continuously. See how Greywatch finds, proves, and fixes them.

How it works →