Cybersecurity ·

Building a bug bounty hackbot

How I built a scope-gated recon and vuln-testing system for bug bounty programs with Claude Code agents: orchestrator, workers and a validator.


Since late August my hackbot has worked through 96 bug bounty programs. It has mapped 25,517 hosts and 41,088 endpoints, tracked 7,196 JavaScript files, and flagged 2,441 things that looked like vulnerabilities. Of those, 2,244 were thrown out before they ever reached a report. 54 are confirmed, 24 have been reported, and 36 turned out to be real bugs that someone else had already reported.

That ratio is the most important thing I have learned from building it. Automation is very good at finding things that look like bugs, and almost none of them are. Most of the work in the hackbot ended up going into proving its own findings wrong.

The hackbot is a recon and light vulnerability testing system built entirely inside Claude Code: a methodology document, a set of wrapped command line tools, and three kinds of agents that coordinate them. Everything it does runs against programs that explicitly invite testing, within their published scope and rate limits, and a lot of the design is about keeping it that way.

A pipeline of boring tools

The first commit was not code. It was a written SOP describing how I approach a program, phase by phase: passive OSINT first, then asset discovery, unauthenticated endpoint testing, APIs, the mobile app, and finally authenticated testing with real accounts. Writing that down first gave the tooling a shape to fill, instead of letting it grow into a pile of scripts.

The design that came out of it is deliberately unoriginal. It is a pipeline of existing, well maintained tools glued together, mostly from ProjectDiscovery, with small standard library Python stand-ins wherever I did not want another binary on the box.

[program scope config: allowed domains, rate limit, allowed tools]
        |
Stage 1  OSINT (passive)       subfinder, crt.sh, GitHub secret scan
Stage 2  Resolve + liveness    dns resolve, httpx (status/title/tech)
Stage 3  Crawl + JS mining     katana, JS endpoint extraction
Stage 4  Light vuln pass       nuclei, filtered to the detected tech stack
Stage 5  Triage + diff         only what is NEW since the last run
        |
structured report -> manual follow-up

Scope is code, not memory

Every active tool goes through a single gate before it sends a packet. Each program gets a YAML file, pulled from the platform's API where possible, and scope_check.py answers ALLOW or DENY for a given target and tool.

in_scope:
  domains: ["*.example.com", "example.com"]
out_of_scope:
  domains: ["internal.example.com"]
rate_limit:
  requests_per_second: 2
  concurrency: 5
attribution:
  header: null      # e.g. a research header the program requires
  required: false   # if true, deny when no header is configured
allowed_tools:
  active_scanning: false
  crawl: false
  nuclei: false
  port_scan: false
  httpx: false

Every tool starts switched off. A freshly pulled scope file cannot scan anything until I have read the program's actual policy page and turned on what it allows. The gate also fails closed: if the config cannot be loaded or parsed, the answer is DENY, never a guess. The rate limits are not advisory either. They are translated into each tool's own flags and passed by default, and I can only tighten them by hand, never loosen them. Programs that require a research header get it injected on all active traffic, and if one is required but missing, the tools refuse to run. A separate conflict-of-interest denylist lives in its own file, so re-pulling a program's scope can never quietly clear it.

On top of that, every request the tooling sends is appended to a per-program request log. That is useful when writing reports, and it is the proof if anyone ever asks whether a request was in scope.

Most of what it finds is not a bug

The agent layer has three roles, all Claude Code subagents. An orchestrator owns each run: it resumes earlier state, decides which targets look worth the effort, sends out workers and decides when to dig deeper and when to move on. It never runs a tool itself. Recon workers get one target each, run the actual tools, chase anything suspicious and report candidates back. They are not allowed to call anything confirmed. That is the validator's job, and the validator's job is really to say no. It gets one candidate at a time, has no attachment to it, and tries to disprove it before it returns CONFIRMED or REJECTED. Report writing only accepts findings the validator has confirmed.

Here is what that looks like in practice.

2,016 subdomain takeovers that were not

A subdomain takeover is when a DNS record still points at something that no longer exists, like a deleted cloud resource, and an attacker can claim that resource and serve content on the company's subdomain. The detection signal is a CNAME pointing at a name that does not resolve. My takeover check found 2,016 of them, and it was wrong about essentially all of them.

The bulk came from one program, where around 1,850 records pointed at names that did not resolve but sat inside DNS zones the company itself owns and has locked down. A dangling name inside your own zone is untidy, but nobody outside can register it, so there is nothing to take over. Others were Cloudflare CNAME flattening pointing back at itself, or CDN setups where the target name is assigned by the provider and cannot be picked by an attacker. The orchestrator learned to reject these by structure, without even sending them to the validator, and those rules now live in the takeover check itself.

My own DNS was lying

The most embarrassing batch came from my own machine. On one run, every single takeover candidate turned out to be fake. My desktop runs Tailscale, and its MagicDNS resolver at 100.100.100.100 answers NXDOMAIN for public names it does not know, instead of passing the question on to the internet. So hosts that resolved perfectly well through a public resolver looked like they did not exist, and the takeover check read that as a dangling record.

The false positives were the visible half of the problem. The invisible half was worse: the same fake NXDOMAIN answers made the resolver step silently drop live hosts, which then never got scanned at all. The fix was one small module that every DNS lookup in the tooling has to go through, pinned to public resolvers instead of whatever /etc/resolv.conf says.

An IDOR that was not

One API had GraphQL introspection switched on, which handed us the full schema, including mutations to create, rename and delete per-user objects, some of which took a user identifier as an argument. That looks like a textbook setup for an insecure direct object reference, where you change an ID and act on someone else's data.

The validator tested it with two accounts I own. The user argument was ignored; everything was scoped by the logged-in user's token. Reading another account's object by ID returned nothing. And the clincher: renaming an object with a real ID belonging to the other account and renaming one with an ID that does not exist gave exactly the same response. The API never told us whether the object existed, and never touched it. Ownership was enforced properly, just not in the place where it looked like it should be.

Secrets that were not secret

Secret scanners are great at finding strings that match a pattern and terrible at knowing whether they matter. The scan of one Android app flagged a private_key. It was the header of a bundled public certificate, used for certificate pinning, plus a couple of field names containing the word "password". Nothing secret at all.

The other recurring one is Google API keys in JavaScript bundles. They match the pattern every time, and they are almost always browser keys that are meant to be public and are restricted on Google's side. Programs generally do not accept them as findings, so the validator now treats them as noise unless there is proof the key can do something it should not.

Diffing runs is the real feature

Those 36 duplicates are a reminder of how bug bounty works: the first person to report a bug gets the credit, and everyone after that gets a duplicate. A one-off deep scan of a target mostly finds what everyone else running the same tools has already found. What actually gives you an edge is noticing what changed since last time.

Each program has its own SQLite database, and over time it grew from tracking hosts to tracking endpoints, JavaScript files by hash, and response hashes. That lets the orchestrator ask "what endpoints, JS files and parameters are new since the last run" instead of "what hosts exist". New or changed JavaScript in particular is a great signal, because new code means new routes, and new routes are where new bugs are.

The browser problem

Authenticated testing is where most of the interesting bugs are, and it needs a browser. For a while that was the biggest bottleneck. The Claude in Chrome extension drives my real Chrome, which is great for logins that need a real browser fingerprint, but its tools are only available to the main session. Subagents cannot use it, so all authenticated work was stuck in one lane.

The way out was the Playwright MCP server, which turned out to be available to subagents. The main session now only uses real Chrome for the login itself, when a login genuinely needs it because of SSO or bot protection. A small script then exports the logged-in session, and workers and the validator load it into headless Playwright and test in parallel. Headless also fixed a more mundane problem: a visible browser kept stealing focus from whatever I was doing on the desktop.

Credentials never touch the agent

Test account passwords are set by me, by hand, into the OS keyring. The account metadata (username, login URL, role) lives in a per-program YAML file, but the password never does. When an agent needs to log in, it does not read the password. A helper copies it to the clipboard, the agent pastes it with ctrl+v, and the clipboard clears itself after 20 seconds.

python3 scripts/credentials.py set <handle> <label>     # hidden prompt, stored in keyring
python3 scripts/credentials.py clip <handle> <label>    # to clipboard, prints nothing, clears in 20s

Claude Code's permission classifier actually blocks the naive approach of reading the password and typing it, and I think that is the right call. The plaintext password would otherwise end up in the conversation.

What I would tell myself on day one

Write the rules down as code before you write a single scanner. The scope gate is the reason I am comfortable letting agents run at all. Then spend at least as much effort on disproving findings as on producing them, because the producing part is easy and the result is mostly noise. A separate agent whose only job is to say no has been worth more than any extra recon tool I could have added. Store everything, so you can ask what is new. And keep a human where it counts: I read every program's policy before turning tools on, I set every credential, and I read every report before it is filed.