Build vs buy · anatomy of a good tool · a real async prober
Every operator hits the same wall eventually. You are mid-engagement, you know exactly what you want to check, and no tool does it. The target speaks a protocol nobody wrote a client for. Two tools you love output formats that do not line up. A scanner that would work is far too loud for this network. At that point you stop looking for a tool and you write one.
This is the first part of the Offensive Tooling & Automation series. It is about building offensive tools that hold up on a real job — not toy scripts. We will cover when it is actually worth building, what to never rebuild, how to pick a language, and the handful of properties that separate a tool you trust from a script you babysit. Then we build a real, runnable async prober in about twenty-five lines and make it compose into any recon pipeline.
When it is worth building
"Build versus buy" is the wrong framing for offensive work, because most of what you build is free either way. The real question is build versus use the existing tool — and the honest default answer is use the existing tool. Rebuilding nmap to feel clever is time stolen from the target. Build only when one of these signals is true:
| Signal | What it looks like | Why build |
|---|---|---|
| Capability gap | No tool does this exact check/logic | The cleanest reason — you fill a real gap |
| OPSEC | Known scanners are too loud here | A small, signatureless tool slips past |
| Scale | Tens of thousands of targets | Tune concurrency + rate for your case |
| Niche target | Odd protocol / format / API | Nobody wrote a parser; you will |
| Glue | Reshape tool A’s output for tool B | Fifty lines saves hours every job |
| Repeatable | Same steps every engagement | Automate once, win on every future job |
Know the map — do not rebuild these
Before you write a line, know the landscape cold — so you instantly recognise what is already excellent and refuse to rebuild it. These are mature, battle-tested, and almost always the right call. Your code lives in the seams between them and in the gaps none of them cover.
| Category | Use these | What they already solve |
|---|---|---|
| Recon | subfinder, amass, httpx, dnsx | Subdomain + DNS + HTTP probing at scale |
| Content / fuzzing | ffuf, feroxbuster | Directory + parameter fuzzing |
| Port + vuln scan | nmap, naabu, nuclei | Host scans, fast port sweeps, templated checks |
| Web proxy | Burp Suite, mitmproxy | Intercept, replay — extend with plugins/addons |
| AD + lateral | BloodHound, netexec (nxc), Impacket | Attack paths, spraying, protocol clients |
| Exploitation / C2 | sqlmap, Metasploit | Deep, tested engines — wrap, don’t replace |
The pattern across that whole table: extend and compose, do not replace. Add a nuclei template, a custom Cypher query for BloodHound, a netexec module, a Burp extension. You get the maturity of the host tool plus the one thing only you needed.
Pick the language for the job
There is no single right language — there is a right language per job. Three cover almost everything an operator builds, and fluency in two of them is plenty.
| Language | Why | Best for |
|---|---|---|
| Python | asyncio, huge ecosystem, fast to write | Glue, recon, PoCs, anything you iterate on |
| Go | One static binary, goroutines, cross-compile | Drop-on-target scanners and agents |
| Rust / C | Low-level, memory control, no runtime | Shellcode, loaders, evasion, hot loops |
go build produces a single dependency-free binary, and cross-compiling for another OS or architecture is one environment variable. Prototype in Python, ship the heavy scanner in Go, and reach for Rust or C only when you genuinely need to go low.# Go cross-compiles to anything from one machine — no toolchain juggling: GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o probe.exe . GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o probe-arm . # -s -w strips the symbol table + debug info: smaller binary, less for a defender to read.
Anatomy of a tool you can trust
Here is the part that actually separates a tool from a script. A script does the happy path once on your machine. A tool survives a real engagement — a flaky network, a hostile WAF, fifty thousand targets, and a client who will read the logs. Eight properties get you there.
| Property | What it means | Where |
|---|---|---|
| Bounded concurrency | A fixed worker pool, never thread-per-target | §5 |
| Rate-limit + jitter | Cap req/s and randomise timing | §6 |
| Timeouts + retries | Every call has a deadline + bounded retry | never hangs |
| Structured output | Emit JSONL, not pretty text | §9 |
| stdin in, stdout out | Read targets in, write results out | §8 |
| Resumable | Checkpoint so a crash does not restart from zero | --resume |
| Configurable | Targets/rate/output via flags + env | no hardcoding |
| Quiet by default | Data on stdout, logs on stderr | pipe-safe |
Concurrency — a bounded worker pool
Concurrency is where naive tools die. The instinct is either too slow or too violent, and both are wrong. There is exactly one pattern you want.
The three approaches
| Approach | What it does | Verdict |
|---|---|---|
| One at a time | A loop: probe, wait, next | Correct but useless — hours for 10k hosts |
| Thread per target | Spawn one per host | 10k sockets → you DoS yourself and the target |
| Bounded worker pool | N workers pull from a shared queue | The answer — predictable load, capped at N |
A bounded pool means you, not luck, decide the parallelism. Set it to 50 and exactly 50 requests are ever in flight, no matter whether you feed it ten hosts or a million. Results stream out the instant each one finishes, so a single slow host never holds up the fast ones. In Python that pool is one asyncio.Semaphore; in Go it is a buffered channel feeding a fixed set of goroutines.
Rate-limit + jitter — stay under the radar
Concurrency controls how many requests run at once; the rate limit controls how fast they go out. They are different knobs, and confusing them gets you banned. You can have a pool of 50 workers and still cap the whole tool at 20 requests per second.
Fire with no throttle and you send a burst the moment the run starts. It is fast for about three seconds — then the WAF rate-limit trips, the 429s begin, your source IP is banned, and a SOC analyst has a fresh alert with your traffic all over it. Speed bought you a detection and a dead IP. A token bucket fixes the rate: refill N tokens per second, each request spends one, and you hold a steady pace that stays under the limit instead of slamming into it.
Build it — an async prober
Theory is cheap. Here is a real, runnable async HTTP prober — it reads URLs on stdin, fetches each with bounded concurrency and a hard timeout, grabs the page title, and prints one JSON object per result. About twenty-five lines, and it already obeys half the anatomy rules.
import asyncio, aiohttp, json, sys from aiohttp import ClientTimeout SEM = asyncio.Semaphore(50) # cap: 50 requests in flight TIMEOUT = ClientTimeout(total=8) # never hang on a dead host async def probe(session, url): async with SEM: # acquire a slot from the pool try: async with session.get(url, ssl=False) as r: body = await r.text() title = "" if "<title" in body.lower(): title = body.split("<title")[1] \ .split(">", 1)[1].split("</")[0].strip() return {"url": url, "status": r.status, "title": title[:80]} except Exception as e: return {"url": url, "error": type(e).__name__} async def main(): urls = [l.strip() for l in sys.stdin if l.strip()] # targets from stdin async with aiohttp.ClientSession(timeout=TIMEOUT) as s: tasks = [probe(s, u) for u in urls] for fut in asyncio.as_completed(tasks): print(json.dumps(await fut), flush=True) # JSONL to stdout asyncio.run(main())
Run it
$ pip install aiohttp $ cat urls.txt | python3 probe.py {"url": "https://a.example", "status": 200, "title": "Admin Login"} {"url": "https://b.example", "status": 403, "title": ""} {"url": "https://c.example", "error": "TimeoutError"}
Notice the failure handling: a dead host becomes a result with an error field, not an exception that kills the run. One unreachable target must never stop the other 9,999. That single try/except is the difference between a tool you start and walk away from and a script you sit and restart all afternoon.
The contract — stdin in, stdout out
The prober has a quiet superpower: it reads stdin and writes stdout. That one decision is what lets fifty lines of yours slot into a pipeline of world-class tools. This is the unix philosophy, and the entire ProjectDiscovery ecosystem is built on it.
# Your tool is just one stage — it composes with everything:
subfinder -d target.com -silent \
| dnsx -silent -a -resp-only \
| httpx -silent \
| python3 probe.py \
| nuclei -silent -tags cveEach tool reads lines, does its one job, and writes lines. subfinder finds subdomains, dnsx resolves them, httpx keeps the live ones, your prober enriches each, and nuclei scans the result. Your custom logic — the niche check no off-the-shelf tool does — drops straight into the middle and the whole chain just works. The rule is simple: data on stdout, logs and progress on stderr, so a noisy progress bar never corrupts the data flowing down the pipe.
Why JSONL — structured output
The prober prints JSONL — one self-contained JSON object per line — and that choice pays off constantly. No wrapping array, no commas between records, so the output is streamable: you can tail it live, or kill the run halfway and still have a perfectly valid file of everything done so far.
# Each line stands alone, so jq + standard unix tools become your analytics: # only the live 200s, just the URLs: $ jq -r 'select(.status==200) | .url' out.jsonl # a status-code histogram from the same file: $ jq -r '.status' out.jsonl | sort | uniq -c # feed the live ones straight into the next tool: $ jq -r 'select(.status==200) | .url' out.jsonl | nuclei -silent
You never write a parser again. jq treats each line as a document, filters and reshapes it, and pipes the result onward. Pretty-printed tables look nice in a terminal and are useless the moment you need to filter, count, diff, or feed them to another tool — which on a real engagement is always. Emit machine-readable data first; pretty-print later if a human needs to read it.
Ship it — from script to tool
The gap between a clever script and something you trust on a paid engagement is mostly packaging and discipline. Run this checklist before the tool touches a client network.
| Step | How | Why it matters |
|---|---|---|
| Pin dependencies | requirements.txt / go.mod with exact versions | The tool you tested is the tool that runs |
| One artefact | go build -ldflags "-s -w" | Single static binary; nothing to install |
| Configurable | Targets/rate/output as flags or env | No scope baked into the binary |
| Believable User-Agent | Not the default library string | A default UA screams “tool” |
| Proxy / SOCKS | A --proxy option | Route via a redirector; choose your source IP |
| Resumable | Checkpoint progress | Survive a dropped run on a big sweep |
| Scope enforced in code | Load scope; refuse anything outside it | Make out-of-scope hits impossible |
| Kill-switch | Clean SIGINT + a hard time-box | When the window closes, it stops now |
Closing
Building offensive tooling is not about writing the next nmap. It is about the fifty lines that fill the gap nmap leaves — the niche check, the glue between two scanners, the quiet prober tuned for the network in front of you. Know the landscape so you never rebuild what is already excellent, reach for the right language for the job, and bake in the anatomy that separates a throwaway script from something you can trust: bounded concurrency, rate-limit and jitter, sane timeouts, structured JSONL, and a clean stdin/stdout contract.
Get those right and the async prober we built holds up under a real engagement — it does one job, stays exactly inside the scope you handed it, and passes its results to the next tool in a form that tool can read. Start there: find one gap your current toolkit cannot cover, write the smallest thing that fills it, and make it behave well enough to keep.