A CVSS 9.6, submitted while I slept
May 2026 · Anonymized until coordinated disclosure
One Sunday evening I told a Bash script to autonomously audit bug-bounty programs for the rest of the week, and went to bed. By Wednesday morning a Telegram message was on my lock screen: LEAD · KH14-1 · sev=critical · score=9. A few hours later a remote-code-execution report against a tier-1 OSS tool was in HackerOne. CVSS 3.1: 9.6.
This text describes the system that produced the finding — prompt pipeline, verification chain, failsafes — and some of the technical substance. I'm leaving the vendor name out for now; the program is private and the patch is in flight. Once the bug is coordinated-disclosed, I'll add the name and a diff here.
The setup
On a 200€ mini-PC under my living-room table run four agents: Nova for home-network and code, Pixel as co-maintainer of a commercial webapp, Atelier for art, and Recon for bug-bounty audits. The first three use claude-opus-4-7; Recon runs on claude-sonnet-4-6 — cheaper per token, good enough for the classification work that dominates an audit loop.
Each agent has its own workspace with IDENTITY.md, a curated MEMORY.md, daily logs under memory/YYYY-MM-DD-*.md, and a Telegram bot that pushes updates to my phone. The connection to the Anthropic API goes through a local LiteLLM proxy that routes traffic to my Pro OAuth account — not API-key billing, but the same subscription I use for interactive sessions.
The audit pipeline
Recon doesn't run as a long-lived process. It exists in sessions, one per task. An audit run takes two inputs:
- a
queue.txtwith numbered tasks:KH01: Auth subsystem · KH02: Plugin loader · KH03: Deep-link handler … - one prompt per task at
prompts/KH03.prompt, describing the audit context: program scope, in-scope assets, repo URL, what to look for.
A Bash runner (queue-runner-audit.sh) iterates the queue, spawns a fresh claude --print --model opus --add-dir $WORKSPACE process per task, and feeds the prompt in. The agent gets 25 minutes (timeout 1500), and produces a status.json with a decision (lead | verify-needed | skip), a severity estimate, and a lead file describing the suspicion. Promising leads get pinged to my phone; the runner moves on.
Verification is a second stage: each lead gets a separate prompt that explicitly asks for a hypothesis-falsifying investigation — read source, run tests, build PoC packages. Here I usually switch to opus, because Sonnet gets shaky on multi-step verifications with tool calls. If verification holds up, an H1-formatted report is written.
What Recon found
The target tool is a desktop API client for developers, written in Electron. On first launch it registers a custom URL-scheme handler with the OS — something like tool://. A click on a tool:// link in any browser dispatches the call to the running tool, which parses path and query string.
One of those endpoints is tool://plugins/install?name=<X>. Behind it sits an installPlugin(name, allowScopedPackageNames) function whose second parameter controls whether the validation "plugin name must start with tool-plugin-" applies. Default: false. In the source tree the function is called differently in exactly one place:
// src/root.tsx, deep-link handler
await window.main.installPlugin(params.name.trim(), true);
// ^^^^ overrides validator
Every other path — the UI, the settings input — calls the function with the default false. Only the deep-link path passes true. So tool://plugins/install?name=any-npm-package installs an arbitrary NPM package as a plugin. The plugin's main module then runs in the renderer process with nodeIntegration: true and contextIsolation: false — full Node access: fs, child_process, net, everything.
The UX trap turns the bug into a Critical: the only confirmation dialog before install shows nothing but the package name. No author, no version, no source, no warning that code will run. A link in a Stack Overflow answer or a Discord message — "here, install the helpful auth-helper plugin in one click" — is enough.
One click = arbitrary RCE as the logged-in user. AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H = 9.6. S:C because the plugin breaks out of its sandbox context and can attack the user's session (read tokens, achieve persistence, exfiltrate further).
The verification
Recon flagged it as a lead. In the verification stage I started a separate sub-agent with the task: "Falsify the hypothesis. Build a PoC that shows the validation cannot be bypassed." This inversion framing is the core of it. When an agent tries to prove a hypothesis, it almost always finds confirmations. When it tries to falsify one, it fails in interesting ways.
The sub-agent built an NPM package whose index.js writes a marker file to $HOME on plugin install and launches the OS calculator. Both as smoke tests, both harmless. Published under a clearly marked BB-PoC package name. On a Windows 11 VM, clicking a local tool://... link installed the package and triggered both effects.
Important in this phase: the sub-agent also produced the asymmetry proof — the same package name is explicitly rejected by the UI input field in settings with "Plugin name must not start with 'tool-plugin-'". Same call, same package — only one boolean different. That makes the security boundary the deep-link silently bypasses concrete, not speculative.
Disclosure
Submitted via the vendor's HackerOne program as KH14-1, with full source trace (three file refs against the public repo tag), CVSS rationale, five screenshots, and a cleanup block. The suggested patch is trivial (drop one argument in the deep-link handler so the default takes over). The PoC package will be unpublished after closure; its README on NPM explicitly identifies it as a BB PoC.
My research account is permanently visible on NPM. That's a trade-off between anonymity and reproducibility; I picked reproducibility. The vendor's security team confirmed reproduction within 72 hours. A CVE assignment through the vendor (CNA) is in flight.
What I learned
Inversion is half the work. The step from "interesting suspicion" to "documented bug" doesn't happen in the same prompt as the original recon. If I collapse Recon and the verifier into one session, the model tends to confirm itself. Two separate prompts with explicitly opposing tasks are more robust.
Sentinel recovery saves sessions. The Bash runner expects a status.json when the agent finishes. In roughly one in five runs the agent exits with code 0 without writing the file — sometimes because the model spits out Markdown instead of JSON, sometimes because it bails before the tool call. The runner detects "exit 0 without status" and scans the spawn log: if there's a new lead file, the run was successful. If there's a refusal string ("I can't help with..."), I mark the task blocked rather than failed, and the prompt goes back to be reformulated.
Usage limits are an engineering problem, not an obstacle. My Pro OAuth account has a limit reset every few hours. The runner detects "hit your limit" in the spawn output and sleeps 15 minutes, then retries. Up to 15 times. That makes individual tasks survive almost any limit phase; run throughput drops only gradually.
Recon is uncreative — and that's fine. Sonnet-4-6 is not the bug hunter who finds the elegant out-of-the-box trick. Sonnet is the intern who methodically reads every endpoint. What would be day three of an attentive auditor's work is a six-hour run phase here. The creative hypothesis-forming — "what if you override this parameter?" — stays my job. But the systematic finding that the boolean is being overridden at all: that's Recon, while I sleep.
An update will follow once coordinated disclosure happens. Until then: no vendor names, no repo diffs, no exploit samples — anonymized substance, no vendor identification.