DirtyWords v2: smarter targeting for parameter brute-forcing
Summary
The original dirtywords was a password-candidate generator. You fed it OSINT about a target (names, dates, company terms) and it produced a wordlist tuned for that target’s likely passwords, instead of throwing a generic rockyou.txt at the problem. That post is from 2021 and the tool did exactly one thing.
v2 adds a second mode that has nothing to do with passwords. It generates targeted HTTP parameter-name wordlists for brute-forcing hidden parameters and endpoints. The motivation is the same one that drove the original tool: a generic wordlist is the wrong default when you have target-specific signal sitting right in front of you. This post is a tooling note on how the parameter mode works and why I built it the way I did.
I am writing this as a design note, not a tutorial. If you maintain a fuzzing or recon tool with a parameter-discovery step, the convention-detection approach below is portable and the failure modes are probably the same shape as the ones I hit.
The problem with generic parameter wordlists
Parameter discovery is the step where you find the inputs an application accepts but does not advertise: the debug=true that flips on a verbose error mode, the admin flag that nothing in the UI exposes, the user_id that the endpoint reads even though the documented call only takes a token.
The standard approach is to take a large generic list (the SecLists parameter collections, or the list that ships with Arjun) and brute-force every name in it against the endpoint. The lists are good. They are also large, undifferentiated, and built for the average application rather than the one in front of you.
Two costs follow from that. The first is volume. A list of tens of thousands of parameter names, multiplied across every endpoint you want to test, is a lot of requests. On a rate-limited target, or a target where request volume is itself a detection signal, the generic-list approach is slow and noisy. The second cost is ordering. The generic list is alphabetical or frequency-ranked against a global corpus. The parameter the specific target actually accepts might be on line 14,000. You will find it eventually, but “eventually” is doing a lot of work when you are inside an assessment window.
The observation behind v2 is that the target usually tells you its own naming conventions if you look. An application written by a team that uses snake_case for everything is not going to suddenly accept camelCaseParams. An API that prefixes booleans with is_ and has_ is telling you the shape of the boolean parameter you have not found yet. A Rails backend has idioms a Django backend does not. If you can read those conventions off the target’s observable surface, you can generate a much smaller, much better-ordered candidate list.
What v2 does
The parameter mode runs in three stages: learn the conventions, generate candidates that match them, rank the candidates by how well they fit.
Stage 1: learn the conventions
v2 harvests naming signal from whatever of the target’s surface you can give it:
- Observed requests. The parameter names already present in requests you have captured (from a proxy log, a HAR export, or a Burp session). These are ground truth for the target’s conventions.
- JavaScript bundles. Front-end bundles are full of parameter names, field names, and API path fragments. v2 pulls candidate tokens from string literals and object keys in the JS you point it at. This is the single richest source on most modern applications.
- OpenAPI / Swagger documents, if the target exposes one. A
swagger.jsonis a complete, authoritative list of the parameters the documented surface accepts, and a strong prior for the conventions the undocumented surface uses. See the OpenAPI specification for the structure v2 parses. - GraphQL introspection, if introspection is enabled. The schema field names are the same convention vocabulary as the REST parameters on most stacks that run both. See the GraphQL introspection docs.
- Response bodies. JSON response field names frequently mirror the input parameter names. If the response carries
createdAt, the input filter is more likelycreatedBeforethancreated_before.
From these sources v2 derives a small convention profile:
@dataclass
class ConventionProfile:
case_style: str # "snake" | "camel" | "kebab" | "mixed"
bool_prefixes: list[str] # e.g. ["is_", "has_", "can_"]
id_suffix: str # e.g. "_id" or "Id"
common_affixes: list[str] # observed prefixes/suffixes by frequency
framework_hint: str | None # "rails" | "django" | "express" | None
vocab: collections.Counter # observed tokens by frequency
The case-style detection is the load-bearing part and it is mostly counting. If 90% of observed names match [a-z]+(_[a-z]+)+, the profile is snake. The framework hint is a weaker signal derived from telltale parameter shapes (_attributes suffixes and []-bracketed nested params point at Rails; __ lookup separators point at Django ORM filters; specific pagination param names point at common Express middleware). The hint is advisory. v2 never hard-codes a behaviour on it; it only uses it to raise the rank of framework-idiomatic candidates.
Stage 2: generate candidates
With a profile in hand, v2 builds candidates three ways:
- Vocabulary recombination. Take the observed token vocabulary and recombine it under the target’s conventions. If the vocab contains
user,account,id, andemail, and the case style issnake, generateuser_id,account_id,user_email,account_email, and so on. This is where most of the high-value candidates come from, because the tokens are the target’s own words. - Convention-templated mutation. Apply the observed affixes and idioms. If the profile says booleans are prefixed
is_, andactiveis in the vocab, generateis_active. If the framework hint is Rails anduseris a known resource, generateuser_attributesanduser[]forms. - Generic-list intersection. Take a generic parameter list (SecLists, Arjun’s list, whatever you point it at) and re-case every entry into the target’s convention, then keep only the entries whose tokens overlap the target vocabulary or whose shape matches the profile. This is how v2 still benefits from the generic lists without inheriting their volume. A 20,000-entry generic list typically collapses to a few hundred convention-matched candidates.
Stage 3: rank
The output is ranked, not alphabetical. The ranking score for a candidate is a weighted sum of:
- token frequency in the observed vocabulary (a candidate built from words the target uses a lot ranks higher),
- convention-match strength (a candidate that perfectly matches the case style and affix profile ranks higher than one that partially matches),
- framework-idiom bonus (small, only applied when the framework hint is set),
- a penalty for length and token count (short, simple parameters are more common than long compound ones).
The point of the ranking is that the parameter the target actually accepts should be near the top of the list, so the brute-force finds it in the first few hundred requests rather than the last few thousand. On the assessments where I have run v2 against a baseline generic list, the convention-matched list found the same hidden parameters in roughly an order of magnitude fewer requests. That is a measured claim on a small sample, not a benchmark, and it depends heavily on how much JS or how complete a Swagger document the target exposed.
Output formats
v2 writes the ranked list in the formats the downstream tools want, because nobody wants to reformat a wordlist by hand:
$ dirtywords params --js ./bundles/ --har ./session.har --out target
Learned profile: case=snake bool_prefixes=[is_,has_] id_suffix=_id framework=rails
Generated 412 ranked candidates (from 18,400 generic + 1,240 observed tokens)
Wrote:
target.txt plain ranked wordlist
target.ffuf ffuf -w input
target.arjun Arjun --wordlist input
target.json full candidates with scores
The plain list pipes straight into ffuf or Arjun. The JSON output carries the per-candidate scores so you can threshold it (only try candidates above a score, when you want to keep the request count very low) or feed it into Burp Intruder as a ranked payload set. The parameter-discovery methodology this supports is the one in the OWASP Web Security Testing Guide; v2 is a wordlist generator for that step, not a replacement for it.
What did not work on the first pass
Three things I tried and dropped.
Inferring types and generating values. The first design tried to infer each parameter’s type (boolean, integer, ID, enum) and emit candidate values alongside the names. This turned into scope creep. Value generation is a different problem from name discovery, the type inference was wrong often enough to be misleading, and the downstream fuzzers already handle value generation. v2 generates names only. If you need values, that is the fuzzer’s job.
Over-trusting the framework hint. An early version hard-branched on the framework hint: if it detected Rails, it generated only Rails-idiomatic candidates. That was wrong twice. Once because the detection was sometimes incorrect and the wrong branch produced a useless list, and once because real applications mix conventions (a Rails monolith with a bolted-on Express service has two conventions, not one). The hint is now advisory and only nudges ranking. The generator never excludes candidates on the strength of it.
Scraping JS at runtime. I wanted v2 to fetch and parse the target’s live JS itself. I removed it. Fetching target assets is a decision the operator should make explicitly inside the engagement scope, not something a wordlist generator does as a side effect. v2 reads JS from files you give it. Collecting those files is a separate, deliberate step.
Status
The parameter mode is in the dirtywords main branch alongside the original password mode. The two modes share nothing except the CLI. The original password-generation behaviour from the 2021 post is unchanged; if you used dirtywords for password candidates before, that path still works the same way. The repo is at github.com/un4gi/dirtywords.
This is the same kind of small, single-purpose tooling work as the recent FAVE rebuild: take a step in an assessment workflow that defaults to a generic approach, and make it use the target-specific signal that is usually already available.
What is next
Two follow-ups are in scope. The first is a scoring-calibration pass: the current ranking weights are hand-tuned against a handful of assessments, and they should be fit against a larger labelled set of “convention profile to actually-accepted parameters” pairs. The second is an endpoint-path mode that applies the same convention-learning idea to directory and route discovery, which is the same problem one layer up. Both are notes-for-later, not commitments.
If you run a similar parameter-discovery step and want to compare convention-detection heuristics, the issues page on the repo is the right place.