← all logs
5 min read

Fuzzing for SQLi and XSS at scale

A vulnerability scanner is really just a very patient, very rude user. It finds every place your app accepts input and then sends each one exactly the payload you hoped nobody would. Building the scanner inside CivicShield — the attack-surface mapper and vulnerability scanner I worked on — taught me that the interesting engineering isn't the injection payloads everyone can look up. It's the plumbing: how you discoverthe inputs in the first place, how you decide something actually fired, and how you rank what you find so a human isn't drowned in noise. Here's the pipeline, end to end.

1. Crawl to find the surface

You can't test inputs you don't know exist. The first stage is a crawler that walks the target from a seed URL, following links and recording every page, form, and query string it reaches. The goal isn't content — it's surface: the set of places the application will take something from a user. Every form field, every ?id=, every path segment that looks dynamic is a candidate.

Two things keep a crawler from either missing half the app or running forever: scope and dedup. Stay on the target's origin so you're not fuzzing someone else's site, and normalize URLs before you queue them or you'll crawl the same page a thousand times under a thousand tracking parameters.

2. Mine JavaScript for the endpoints crawling misses

Here's the part that separates a real scanner from a link-follower. Modern apps don't put their API surface in <a href> tags — it's buried in JavaScript bundles as fetch() calls and string-built URLs that a link crawler never sees. So the scanner pulls down every script the pages load and mines them for routes and parameter names.

endpoint extraction from JS
# Static extraction: pull candidate API paths and params out of
# bundled JS. Regex is crude but catches the fetch/axios patterns
# that a link crawler structurally cannot reach.
ENDPOINT = re.compile(r'["\'](/api/[\w/-]+)["\']')
PARAM    = re.compile(r'[?&]([\w-]+)=')

for script in scripts:
    endpoints |= set(ENDPOINT.findall(script))
    params    |= set(PARAM.findall(script))

The payoff is disproportionate. An /api/internal/lookupthat appears in no HTML anywhere, only in a minified bundle, is exactly the kind of forgotten endpoint that skipped a code review — and it's now on the list to fuzz.

3. Fuzz each parameter

Now the rude part. For every parameter discovered, the scanner sends a battery of payloads and watches how the app reacts. For the two classes I focused on — SQL injection and cross-site scripting — the tells are different, and reading them correctly is the whole game.

SQLi is inferred from the server's behavior, not from seeing your payload echoed. A quote that produces a database error, a boolean pair (1=1 vs 1=2) that flips the response, or a timing payload that makes a fast endpoint suddenly hang for five seconds — each is evidence the input reached a SQL engine.

signal, not just payload
SQLI_PROBES = [
    "'",                      # provoke a raw DB error
    "' OR '1'='1",            # boolean: does the result set widen?
    "1' AND SLEEP(5)-- -",    # blind/time-based: does it stall?
]

XSS_PROBES = [
    "<script>__probe()</script>",
    "\"><svg onload=__probe()>",  # break out of an attribute context
]

XSS is the opposite: you send a marked payload and check whether it comes back unescapedin the response, landing in a context (raw HTML, an attribute, a script block) where a browser would execute it. Reflected verbatim means the app didn't encode it, which means a browser won't either.

The payload list is the easy 10%. Deciding a vulnerability actually fired — distinguishing a real DB error from a generic 500, a reflected script from a harmlessly-escaped one — is the 90% that makes a scanner trustworthy instead of a false-positive cannon.

4. Classify severity so a human knows where to look

A scanner that emits 400 undifferentiated "findings" is worse than useless — it trains people to ignore it. Every confirmed issue gets sorted into a severity tier, Low through Critical, from the class of flaw and where it sits. A blind SQL injection on an auth-adjacent endpoint is Critical; a reflected XSS behind three logins is not. That ranking is what lets someone triage the report top-down and fix what matters first.

In CivicShield this fed the same severity model the rest of the platform used to color its dashboard — the scanner's output was just another stream of classified events, sitting next to the live threat feed. A finding wasn't a log line; it was a ranked, actionable item.

The mindset that made it work

Building an offensive tool changes how you write defensive code, because you internalize where the inputs really are. They're not just the form fields — they're the query params, the JSON body keys, the header values, and the endpoints you forgot you shipped inside a JavaScript bundle. That's the whole thesis I build under now: assume every one of them is hostile, validate at the boundary, and encode on the way out.

The scanner and the secure app are the same knowledge pointed in opposite directions. Writing the attacker taught me the defender's checklist better than any checklist could.

Only ever run tooling like this against systems you own or are explicitly authorized to test. The techniques are for finding your own holes before someone else does — not for poking at someone else's site.

next log

How I built this portfolio (and the bug that hid every click)