Back to all posts
Published on · by Renaud Deraison

Nobody shipped a new version

Wordfence's August 9 advisory on the BdThemes compromise describes a supply-chain attack with no supply in it. No release, no install script, no typosquat, no modified file in the plugin repository. Attackers took write access to the vendor's storage bucket and changed a JSON feed that 350,000 sites fetched on every admin page load. For a developer, the question this raises is what the machine running your coding agent is allowed to write, and to whom.

Every supply-chain control you own inspects things that get published. This one published nothing, and reached 350,000 sites anyway.

On August 7, 2026, Wordfence started hearing about WordPress sites running BdThemes plugins that had grown administrator accounts nobody created. The list runs to seven plugins: Element Pack Addons for Elementor, Prime Slider, Pixel Gallery, Ultimate Post Kit, Ultimate Store Kit, Live Copy Paste, and Smart Admin Assistant. Element Pack alone has more than 100,000 active installations, and the seven together clear 350,000.

By August 8 the vendor had cleaned its API and WordPress.org had pulled all seven plugins pending investigation. Wordfence published its PSA on the compromise the following day, and its central finding is a negative: nobody had touched the plugin code.

Nothing was published

BdThemes plugins carry an internal component called Biggopti whose job is small and dull: fetch promotional banners from the vendor's remote API and draw them in the WordPress admin dashboard. The banner data is a static JSON file in a DigitalOcean Spaces bucket, served from behind Cloudflare.

Attackers got write access to that bucket. That is the whole intrusion.

Biggopti took the display_id field out of the JSON response and dropped it into an HTML id attribute without escaping it, a cross-site scripting flaw Wordfence rated CVSS 5.4, medium and traced back to March 1, 2026, when a developer wrote it once and copied it from one plugin to the next. A display_id carrying an onanimationstart handler is therefore JavaScript, and it runs in the browser of every logged-in administrator, on every wp-admin page load, on every site with the plugin installed.

The payload used that seat well. A script called w2.js asked a command-and-control server, the machine an attacker uses to tell a payload which sites are worth taking, for targeting instructions, then created hidden administrator accounts through the WordPress REST API on the back of the real administrator's own session and nonces. A variant derived predictable usernames from the site's hostname. A fake plugin, often named something like wp-smart-thumbnails, delivered a web shell at emer-run.php, meaning a script an attacker can drive by loading an ordinary URL. Must-use plugins, which WordPress loads on every request and hides from the plugins screen, carried a magic-login backdoor and a module that concealed the new accounts by rewriting the user totals the admin screen displays.

Timestamps in the poisoned records put the earliest possible start at June 23, 2026, seven weeks before anyone noticed.

Line that up against the controls a careful team runs. Version pinning: the versions never changed. Lockfiles and integrity hashes: they matched, because the files they covered were untouched. Install-script review: there was no install. Typosquat detection: right name, right vendor, right repository entry. Code review of the diff between releases: no diff, because no release. Certificate and CDN: both in perfect health. Cloudflare and TLS proved that the JSON reached the site unmodified from the bucket it was supposed to come from, which is precisely what made it useful.

The path everything watchesWordPress.orgseven plugins, 350,000+active installationswhat the controls inspectversions, hashes, installscripts, the release diffverdict: cleanand correct. No file in therepository was ever modified.The path nothing watchedthe vendor's bucketDigitalOcean Spaces,behind Cloudflaresomeone had write accessBiggopti fetches the bannerdisplay_id goes into an HTMLid attribute unescapedonanimationstart runs itin the admin's own sessionhidden admin accounts via REST,emer-run.php web shell,magic-login in mu-pluginsearliest poisoned record: June 23, 2026Wordfence notified: August 7, 2026Seven weeks in which every check of the published artifact passed, and passed correctly.
Two paths into a site running the plugin. The published path, the one every supply-chain control inspects, never changed. The runtime path fetched a JSON file from a bucket somebody else could write to, and that file arrived as JavaScript in the administrator's browser.

The incident starts with a credential that could write

Read the advisory from the vendor's side and the attack chain shortens to one step. Someone obtained write access to a storage bucket. Everything after that is consequence.

Upstream compromises keep arriving in that shape. The Red Hat npm scope worm, the repository that really was Microsoft's, the extension marketplaces, the container registries: in each of them the payload is the part that gets written up, and the part that mattered was somebody ending up with a credential that can overwrite an artifact other people fetch on a schedule.

Where does a credential like that live? In a developer's environment. A DIGITALOCEAN_ACCESS_TOKEN in a shell, a doctl config in ~/.config, a registry login in ~/.docker/config.json, an AWS profile, a GitHub token in ~/.git-credentials, an SSH key that can push. On a laptop, in a terminal, next to the project. In 2026, next to the project is also where you run your coding agent.

Most posts on this blog describe a way to turn that agent against you. A bug report that ran a command. A web page that rewrote the agent's config. A comment nobody could see. A skill that ran with your identity. Three harness bypasses in one Black Hat talk. Take the catalogue as read, and assume that on some Tuesday one of them lands on you.

How bad that Tuesday gets turns on a question you can answer today, without knowing which injection wins: what is this machine allowed to write, and to whom? On a laptop the answer is everything you are allowed to write, and if you ship anything, that includes the artifact several hundred thousand strangers fetch without looking.

The token is not in the machine

Bromure Agentic Coding runs the agent in a disposable Linux VM on Apple Silicon, and every byte of its traffic crosses a proxy on the host, outside the box the agent runs in. Bromure arranges the credentials around that boundary instead of putting them inside it.

Put your DigitalOcean personal access token in the Credentials panel and Bromure keeps the real value on the Mac. What lands in the VM is a fake: a DIGITALOCEAN_ACCESS_TOKEN environment variable and a ~/.config/doctl/config.yaml that make doctl work without doctl auth init, holding a placeholder. When the VM makes a request to api.digitalocean.com, the host proxy substitutes the real token on the wire, for that destination, and nothing else.

Anything running inside that VM can read the environment, grep the dotfiles and walk the whole disk: the agent, a dependency, a build step, a shell command that came out of a poisoned issue. What it collects is brm_…. The same arrangement covers the rest of the set: GitHub, GitLab and Bitbucket tokens, container registry logins written as a fake base64 blob in ~/.docker/config.json, a synthetic kubeconfig with throwaway client certificates, Linear, the HTTPS database endpoints, and anything else you add under Other API keys. AWS goes further: the host re-signs each request with SigV4 using the real secret, so bypassing the proxy earns an InvalidSignatureException rather than an unauthorized write.

Guardrails decide the verb, on the Mac

This incident needed one operation: overwrite an object in a bucket. Guardrails is a policy engine inside the host proxy, with a mode per resource: Off, Block destructive, or Read-only. Set DigitalOcean to Read-only and every mutation to api.digitalocean.com comes back as a hard 403 the agent reports as an ordinary API failure. Bromure makes that decision in macOS. There is no setting inside the VM to change, no environment variable to unset, and no file to edit.

Publishing asks first

Every credential entry has Require approval to use. Turn it on and each fake→real swap raises a consent dialog on the host before the real value reaches the wire, with time-bounded grants on the SSH path: five minutes, an hour, the rest of the session. Pushing a release is something you meant to do, and it costs you one click. A write you did not start shows up as a dialog you were not expecting, at a moment you were not expecting one.

Registries and git get the same treatment

Guardrails classifies container registry traffic by method against the registry's own hostname: GET and HEAD are a pull, PUT and POST are a push, DELETE is destructive. GitHub, GitLab and Bitbucket are covered for both the REST API and git over HTTPS, where git-receive-pack counts as a write and is blocked in Read-only mode while fetches always pass. A profile that reads your whole dependency graph and pushes nothing is two settings.

The environment is a list you wrote

A Bromure profile sees the Mac folders you shared with it, up to eight, each mounted under /home/ubuntu. Everything else on your Mac is not mounted, not reachable, and not enumerable. The credential set is the one you added to that profile, so a profile you use to build a frontend holds no cluster credentials for anything to find, hidden or otherwise.

On the developer's laptopDIGITALOCEAN_ACCESS_TOKEN=dop_v1_…~/.config/doctl, ~/.docker/config.json,~/.git-credentials, ~/.aws, ~/.sshthe agent is turned, oncean issue, a page, a README, a log rowit now reads what you readone PUT to a bucketand the artifact your users fetchautomatically is now theirsIn a Bromure profileDIGITALOCEAN_ACCESS_TOKEN=brm_…every dotfile is present and everysecret in them is a placeholderthe real value lives on the Macswapped on the wire by the host proxy,for api.digitalocean.com and nowhere elseGuardrails: Read-only → 403the write is ruled on in macOS, andRequire approval puts a dialog first
The same write credential, in two places. On a laptop it is a real token in a real file, one turned agent away from the artifact your users fetch. In a Bromure profile the real token never enters the VM, and the operation it would perform is ruled on by the host.

The runtime fetch crosses the proxy too

Now take the downstream side of the story, where you are one of the 350,000. What you can catch there depends on where you put the inspection.

Bromure's Supply Chain panel screens package fetches across npm, PyPI, Cargo, RubyGems, Maven, NuGet, Go modules and Packagist: an age gate that is on by default and refuses anything published in the last two days, OSV lookups, socket.dev or Delpi as a filtering provider, install-script stripping that rewrites the tarball and fixes up the registry metadata hash so npm still verifies, and a prompt before lockfile-pinned tarballs pass through unmodified. The .npmrc and pip.conf inside the VM can tighten those rules and cannot loosen them.

Those rules earn their keep because of where Bromure enforces them. The host proxy applies them to a request rather than to a package, so a JSON file pulled from a vendor bucket at runtime by a build step, a CLI, an MCP server or the application itself crosses the same boundary a tarball does. Everything the VM says to the network crosses it.

That changes what the seven weeks look like. With Session trace at Activity, Bromure records the host, the status, the latency, the swap report and any leak warnings for every request the VM makes. Set it to Everything and it keeps the bodies too, for every host, encrypted with the same keychain key as your profile secrets and readable in Trace Inspector. The day a feed your build depends on starts answering differently is a diff you can open, on the Mac, in a log the VM has no write access to.

Inspection inside the package managersees: tarballs, versions, lockfilesruns when you install somethingGET banner.json → the vendor's bucketnot an install, so not an eventseven weeks, no record keptInspection in the host proxysees: every request the VM makesage gate, OSV, filtering, script stripGET banner.json → the vendor's buckethost, status, latency, swap reportand the body, at the Everything level
Where the inspection happens decides what it can see. A package-manager control only inspects things that were published; a control in the host proxy inspects every request the VM makes, including the runtime fetch that carried this payload.

A poisoned feed is also, in the end, text arriving at a machine. When it reaches the agent as a tool_result, a file, a fetched page or an instruction file like CLAUDE.md or AGENTS.md, the Prompt Injection panel scores it on-device with a local model, and nothing leaves the Mac. Choose Ask me what to do and the request pauses with the flagged span shown; choose Block unilaterally and the agent gets a hard 451. When you want the machine gone, Erase home resets /home/ubuntu and Reset to base re-clones the workspace system disk from the base image.

BdThemes will patch the escaping, the bucket will get tighter keys, and the next version of this story will arrive with a different vendor and a different feed. What lasts is the arrangement underneath it. Your development environment holds a fistful of credentials, each of which can rewrite something thousands of people fetch on a schedule, and that same environment now runs an agent reading text from strangers all day.

Most posts here ask what your agent should be allowed to read. Answer the other question too, the one that is uncomfortable on a laptop and easy in a profile: what is it allowed to write, and to how many people?

Install Bromure Agentic Coding, and give the agent a machine that holds no key to anything you ship.