Back to all posts
Published on · by Renaud Deraison

The wordlist already knew your home directory

F5 Labs logged 807 attacks and 32,000 events in one month against exposed Vite dev servers, using a query-string trick that walks straight past Vite's own deny-list. The scanners are not looking for your app. They ask for /home/ubuntu/.aws/credentials by absolute path, and for /proc/self/environ, and for terraform.tfstate. In a Bromure Agentic Coding workspace that port faces a private switch, and the file the wordlist wants does not hold a key.

This attacker ships you no package and no archive. Your own dev server is listening, and a stranger asks it for files by name. Which machine answers decides what they get.

You tell the agent to get the frontend running so you can look at it. It types npm run dev, binds the port, loads the page, and you go back to reviewing the diff. Somewhere in that loop, in a docker-compose.yml or in a --host flag you added weeks ago to make the preview reachable from your phone, the server bound every interface rather than loopback. Twelve minutes later something calling itself Googlebot asks your dev server for /@fs/../.env?raw?? and gets an HTTP 200.

F5 Labs published the numbers on September 11. Across a single month of August 2026 sensor data, their honeypots recorded 807 session-grouped attacks and roughly 32,000 raw events aimed at exposed Vite development servers, against a baseline of 1,732 events over the prior three months. That is eighteen times the traffic, month over month, for one bug. BleepingComputer covered it on September 14.

A query string that walks past the deny-list

Vite serves files from the host filesystem through an internal route called /@fs/, which is how the dev server hands your editor a module that lives outside the project root. Because that route can reach anywhere, Vite ships a deny-list, server.fs.deny, to block the obvious targets: .env files, certificates, private source.

CVE-2026-39364, published April 7, lets an attacker skip that deny-list by decorating the request with a query string. F5 describes the mechanism:

The server processes the request, normalizes the path, and strips or misinterprets the query string during access validation, failing to trigger the server.fs.deny check.

The server fails to enforce deny-list filtering and serves the target file with an HTTP 200 response.

The bug affects Vite 7.1.0 through 7.3.2, and 8.x before 8.0.5. F5 caught these request shapes in the wild, each short enough to read in one line: GET /@fs/.env?raw??, GET /@fs/../.env?raw??, GET /@fs/..%252f..%252f..%252f..%252froot/.env?raw??, and GET /@fs/..%252f..%252f..%252f..%252fproc/self/environ?raw??. Other variants in the same tooling use ?import&raw, ?import&url&inline, ?inline&import and ?raw?import.

Those parameter tricks carry their own CVE numbers: CVE-2025-30208, CVE-2025-31125 and CVE-2024-45811, three earlier @fs bypasses still loaded in the same scanners. Whoever runs the scanning fleet did not retool for a new bug. They appended one more line to a file they already had.

one request, one filescannerGET /@fs/../.env?raw??User-Agent: Googlebot/2.1vite dev serverpath normalized for serving/@fs/ → filesystem readserver.fs.denyquery string mishandled, check skippedfile read and returnedno auth, no session, no log line you readHTTP 200the contents of the file,in the response bodyalready in the same scannersCVE-2025-30208 · ?raw??CVE-2025-31125 · ?inline&importCVE-2024-45811 · ?import&raw
The bypass in one exchange. Vite's /@fs/ route is meant to serve project files, and server.fs.deny is meant to keep it away from secrets. Appending a trailing query string, of which ?raw?? is the common form, gets the path normalized for serving but mishandled during the access check, so the deny-list stays quiet and the file comes back with a 200. Path traversal in the same request reaches outside the project, which is how a frontend dev server ends up reading /proc/self/environ.

The wordlist is a map of a developer's machine

Read what the scanners ask for, and notice how little of it concerns your application.

They ask for .env, .env.local, .env.production, .env.development and .env.staging. They ask for terraform.tfstate, terraform.tfvars, .terraform/terraform.tfstate, serverless.yml and .serverless/serverless-state.json. They ask for .azure/credentials and .azure/accessTokens.json. They ask for /etc/passwd, /proc/1/environ, /proc/self/cwd/.env and /proc/self/environ. That last path holds the environment block of the dev server process itself, where an API_KEY your shell exported ends up.

And then they ask for AWS credentials by walking the list of home directories a developer's process might be running as:

/root/.aws/credentials, /home/ec2-user/.aws/credentials, /home/ubuntu/.aws/credentials, /home/node/.aws/credentials, /home/www-data/.aws/credentials, /home/admin/.aws/credentials, /home/debian/.aws/credentials, /var/www/.aws/credentials, /usr/src/app/.aws/credentials, /app/.aws/credentials. Then .aws/config, .aws/credentials.backup, .aws/credentials.bak, .aws/sso/cache/, rootkey.csv, aws-exports.js and amplifyconfiguration.json.

Those paths inventory a developer machine, enumerated by username, and Vite is only the door. The bug is incidental; three older bugs in the same route ride along in the same requests. The operators are betting that a process listening on a routable address runs as a user whose home directory holds real keys.

The traffic is dressed to survive a casual look at the logs. F5 recorded forged User-Agent headers rotating through Googlebot/2.1, ClaudeBot/1.0, GPTBot/1.4, PerplexityBot/1.0, OAI-SearchBot/1.3 and Amazonbot/0.1, plus forged X-Forwarded-For and X-Real-IP values to get past IP allow-lists. The sources sit in Google Cloud's 34.x and 35.x ranges across several regions, led by the United States at 17,297 events, then Belgium at 4,407 and the Netherlands at 4,011. This month, a line in your access log claiming to be an AI crawler is weak evidence that a crawler sent it.

Two of F5's recommendations are architecture questions

F5 closes with five pieces of advice. Three are ordinary and correct: patch to 7.3.2 or 8.0.5, filter /@fs/ at the edge, verify crawlers by reverse DNS instead of trusting the header. The other two describe a posture you have to hold rather than a task you finish.

Ensure development servers do not bind to external interfaces. Audit Docker compose configurations, Kubernetes ingress rules, and cloud security groups.

Rotate exposed secrets: If an unpatched Vite development server was reachable from external networks during August 2026, treat local .env variables, AWS credentials, Azure access tokens, and Terraform state files as potentially compromised.

The first asks you to keep a promise about each port, in each compose file, across each branch, for as long as the project lives, at a moment when whatever types npm run dev is often an agent rather than you. The second asks what you do afterward, and answers: rotate what the machine could see.

Both of those get easier if the port opens somewhere else, and if the files the wordlist names hold nothing worth rotating.

Where a Bromure workspace puts the port

Bromure Agentic Coding runs coding agents inside a hardware-virtualized Linux VM on your Mac, with the security controls on the host side of that boundary. Two of them answer this campaign.

The dev server binds inside a private switch. In the default NAT mode, every workspace VM attaches to one process-wide software L2 switch multiplexed onto a single vmnet interface, on a private subnet. That subnet is 192.168.64.0/24 unless your own LAN already uses it, Bromure runs its own DHCP server on it, and each workspace keeps a deterministic MAC and a stable lease. The manual states the consequence: your Mac can reach the VMs, your physical LAN cannot see them, and inbound connections from elsewhere are impossible unless you publish a service yourself. An agent that binds 0.0.0.0 inside that VM has bound each interface it owns, and each of them faces a switch that starts and ends on your laptop. You have no security group to audit, because you have no route in to secure.

You can still see what is listening, and it is a list, not an audit. The workspace dashboard carries a Listening Ports card showing every externally reachable socket in the guest as a <VM-IP>:<port> endpoint with one-click copy. That turns F5's recommendation #2 into a card you glance at, refreshed from the guest every second and a half, instead of an audit you schedule. When you do want the world to see a preview, a likely-HTTP service gets a globe button that publishes that one service through a Cloudflare quick tunnel, behind a one-time consent dialog. You expose a service by clicking it, per service, on the host, rather than by leaving a flag alive in a compose file.

You keep the reason you reached for --host in the first place. The disposable Chromium sidecar shares the same L2 segment as the workspace VM, so the embedded browser loads the agent's dev server at the VM's address. Not at localhost, since the browser runs on a separate machine.

If other machines do need to reach the VM, Bridged mode joins it to your physical LAN. You set that per workspace in the workspace editor, on the host, and it falls back to NAT if the interface is unavailable at launch. You make that choice in a panel, not in a config file the agent can edit.

an exposed dev boxa routable address, a real user, real filesGET /@fs/../.env?raw??arrives from 34.x, 200 OK/home/ubuntu/.aws/credentialsan access key id and a live secret/proc/self/environevery token the shell exportedafterwardsrotate the keys, the tokens, the state file,and guess at the windowin a workspacea private switch, a home full of placeholdersno inbound route to arrive onvmnet NAT · 192.168.64.0/24Listening Ports cardevery open socket, and a globe to publish onethe wordlist lands on fakessk-ant-api03-brm-… · ghp_… · credential_processafterwardsreset the disk if you want to,and rotate nothing
The same scan, against two machines. On a dev box with a routable address, the request reaches a process running as a real user, and each path in the wordlist resolves to a real file: .env with live keys, ~/.aws/credentials with a secret, /proc/self/environ with the shell's exported tokens. In a Bromure Agentic Coding workspace the port faces a private vmnet switch with no inbound route. Publish the service on purpose and the wordlist still lands on placeholders, and on an ~/.aws/config that names a helper rather than holding a key.

And if you publish the service on purpose

Sometimes you do want the preview on the internet, for a client or a teammate. So click the globe, open the tunnel, and let the scanners find it. Follow the wordlist down, entry by entry, and look at what comes back.

.env and /proc/self/environ return the workspace's environment, and each credential in it is a fake. Bromure derives each placeholder from the real value plus a per-install 32-byte salt through HKDF-SHA256, keeping the shape a client-side validator expects: an Anthropic key reads sk-ant-api03-brm-…, a GitHub token is ghp_ plus 36 characters, GitLab is glpat- plus 20. Bromure writes those fakes into the environment variables and into ~/.git-credentials, ~/.docker/config.json, ~/.kube/config and the MCP configs. The real values never enter the VM; they stay encrypted on the Mac, and a host-side proxy swaps each one onto the wire at the last moment, scoped to the destination host it belongs to.

/home/ubuntu/.aws/credentials is the sharpest entry, because /home/ubuntu is the home directory of a Bromure workspace VM. The scanner guesses right about which user runs the server. The file is still not there. Bromure's AWS setup writes no credentials file; it writes ~/.aws/config with a credential_process line pointing at a helper that vends, over a host socket, your real access key ID paired with a forty-character fake secret key, and omits the session token. The AWS SDKs, the aws CLI, terraform and boto3 all pick that up on their own. A scanner that reads the config file gets the path of a helper it cannot call.

Take the fake secret anyway and you have taken something inert. The guest signs its requests with that fake, and the host strips the signature and re-signs with the real key on the way out. A request that reaches AWS by any other route fails with InvalidSignatureException. F5's last recommendation is to treat each local secret as compromised and rotate it. The matching line in Bromure's own documentation reads the other way: because only the fake leaked, the real credential never needs rotation.

One entry in the wordlist names a file worth real money: terraform.tfstate. State files live wherever the repository lives, so your shared-folder list decides their exposure. Host folders attach to a workspace as virtiofs mounts at /home/ubuntu/<basename>, capped at eight per workspace, and they are the only part of your Mac's filesystem the VM can reach. Share the repository the agent is working on and nothing else, and the traversal finds one directory instead of a disk.

Most developer-security stories this year describe something arriving: a package, an archive, a documentation file. Nothing arrives here. Instead, 32,000 requests a month ask a process you started to read files aloud to a stranger, and the outcome turns on which machine runs that process and what sits in its home directory.

Put the agent's dev server on a switch that only your Mac is on, and fill its home directory with placeholders. Install Bromure Agentic Coding, then let the scanners ask.