~/tutorials/058-ffuf-and-the-shape-of-a-guess.md
058: ffuf, and the shape of a guess
Lesson 052 proved the spider's blind side: files exist that no link reaches, and the crawler cannot see them. Lesson 057 found the same wall from the scanner side, a planted credential file invisible to every checklist. This lesson is the weapon built for exactly that wall, the fuzzer, which asks the question neither of them asks: what else answers when you guess names. And because a guess has a shape, name, extension, place, the lesson is as much about the shape as the tool.
Before anything else, get the wordlist. This lesson's small list was built for its receipts, but real fuzzing runs on a real wordlist, and the field's standard one is SecLists, maintained by Daniel Mieller's community on GitHub. From your own terminal:
$ git clone https://github.com/danielmiessler/SecLists
And yes, from this site's own terminal too. The shell at the bottom of the page speaks git clone against github.com, gitlab.com, codeberg.org, and git.sr.ht, and answers with the archive as a link rather than unpacking it into the tree, because this site does not host other people's software. Try it:
git clone https://github.com/danielmiessler/SecLists
Either lane lands you the same collection, and the one directory this lesson's Try-it wants is SecLists/Discovery/Web-Content/, the common-name lists. The receipts below stay on the seventeen-word list so you can read every hit; your runs will use the big ones, and the lesson's laws hold at any size.
What this lesson is for
Discovery beyond the link graph. The fuzzer takes a wordlist and a template, asks the target for every word in every slot, and reports what answers differently. It is the 052 lesson's complement, the what-else-exists instrument, and used honestly it is the single highest-yield passive-to-active step in web testing: no exploit fired, no state changed, and findings arrive that no crawl and no checklist will ever produce.
What the reader meets
ffuf, a fast web fuzzer, run from your own machine against the lab box on the web lab page. The whole instrument is one template idea: the word FUZZ in a URL marks where the guesses go, and the wordlist fills the slot. First run, seventeen common names, no extensions:
$ ffuf -u http://172.31.18.2/FUZZ -w common.txt
config [Status: 301, Size: 311]
hidden [Status: 301, Size: 311]
2 hits, 17 guesses
Two hits from seventeen guesses, and both are directories, 301 redirects, found because a directory answers differently than a missing file. Notice what is absent: every real page on the site. index, login, security, instructions, setup, all real, all serving, none found. Seventeen correct names, zero file hits, and the reason is the lesson's title.
The instrument
The shape of a guess. A URL guess is not a name. It is a name, an extension, and a place, and the fuzzer only finds what the shape includes. The first run guessed bare names, so it could only find what answers at a bare name, directories. Every file on the site is name.extension, and the wordlist carried no extensions, so the files were invisible, not because the fuzzer is weak but because nobody asked. The fix is the second shape, two lists, two slots, names crossed with extensions:
$ ffuf -u http://172.31.18.2/FUZZ.FUZ2Z -w names.txt:FUZZ -w ext.txt:FUZ2Z
login.php [Status: 200, Size: 1523]
instructions.php [Status: 200, Size: 14014]
setup.php [Status: 200, Size: 4077]
about.php [Status: 200, Size: 4840]
robots.txt [Status: 200, Size: 26]
db_backup.sql [Status: 200, Size: 15]
TODO.md [Status: 200, Size: 4]
9 hits, 85 guesses
Same wordlist, one five-extension list added, and the yield went from two to nine. Every real page found. And the row that matters most is one the site never linked and no checklist carried: db_backup.sql, planted for this lesson, 200, fifteen bytes, and one curl later its contents read back, backup db creds. That is the 057 lesson's planted file, the one the baseline diff could not see, found by guessing in the right shape. The fuzzer does not out-smart the target. It out-guesses your assumptions about the target, and the shape of the guess is the whole game.
Three more shapes worth their words. Recursion: a hit that is a directory is a new place to guess, and ffuf's -recursion re-aims into every directory it finds, so hidden/ becomes hidden/FUZZ, which found hidden/admin/, one more level than the wordlist knew about. Place: the slot can sit in a path, /api/FUZZ, in a parameter, ?page=FUZZ, in a header, and each placement asks a different question of the same wordlist. And calibration: the first run's two directory hits both came back size 311, because this server's redirect page is always 311 bytes, and ffuf's -fs 311 filters that size away, 2 hits became 0 in the corrected run, meaning both were noise, the same redirect page answering the same way. Filter by size when a known page answers every guess, -fs with the calibration page's size, and what survives is what answered differently.
What all four shapes share, and the habit to build: before running, say the guess out loud. I am asking for these names, at this extension, in this place, and anything that answers this size I will ignore. That sentence is the run's contract with yourself, and the receipt files are the proof it was kept. A fuzzer run without the sentence is a slot machine. With it, the same commands are an experiment.
Try it
- Run both shapes against your own lab, the bare-name run and the two-list run, and count the difference. Then add two extensions to the list,
.bakand.old, and run again. Backup artifacts are the classic fuzz find,config.php.bakstyle, and the extension list is where they live. One new extension is one new class of findable thing. - Plant a file for yourself, a name not in any wordlist, and add its name to the list. Watch it appear. Then remove it from the list, run again, and watch the site silently not tell you it exists. That pair of runs is 052's lesson and this one in a single exercise: the wordlist is the question, and the site only answers what is asked.
- Measure the noise. Pick the size of the site's 404 or redirect page, run with
-fsthat size, and compare hit counts before and after. Write down your lab's noise sizes, like 057's noise floor, and you carry a calibrated fuzzer into every future session instead of a loud one.
Reveal the answer
The guess-shape card, every slot ffuf knows how to fill, with this lesson's receipts wired to each:
# NAME ONLY finds directories, and bare files
ffuf -u http://TARGET/FUZZ -w names.txt
# NAME x EXTENSION finds files. The big yield. 2 hits -> 9 in this lesson
ffuf -u http://TARGET/FUZZ.FUZ2Z -w names.txt:FUZZ -w ext.txt:FUZ2Z
# PLACE a hit directory is a new place
ffuf -u http://TARGET/hidden/FUZZ -w names.txt # found admin/
ffuf -u http://TARGET/FUZZ -w names.txt -recursion # does it for you
# PARAMETER the slot in a query string
ffuf -u "http://TARGET/page.php?user=FUZZ" -w users.txt
# CALIBRATION ignore what answers like the known page
ffuf -u http://TARGET/FUZZ -w names.txt -fs 311 # the 301-noise size
# and the output contract: always
-o run-name.json -of json # the receipt, parseable, diffable, keepable
The card's law, one sentence, and it is the tier's quiet thesis again: the wordlist is the question. Ninety thousand words of common names is a big question, and it still only asks names someone wrote down, in the shapes you listed, in the places you pointed. The files with odd names, the endpoints nobody named yet, the shapes you did not think to cross, remain the spider's blind side and the fuzzer's too, one layer down. What the fuzzer adds over the checklist is that the question is yours to shape, and a tester who shapes questions well will out-perform every tool that ships with its questions pre-loaded, which is all of them.
What this lesson does not claim
A fuzzer is loud, and the honesty has two edges. Loud in volume: eighty-five guesses in this lesson's small run, and a real wordlist run is tens of thousands of requests in seconds, which will show up in every log the target keeps, may trip rate limits and alarms, and is squarely the attack traffic lesson 048's fence governs. You do not fuzz what you are not authorized to fuzz, at a rate you did not write down while calm. And loud in confidence: a 200 answer means the name exists, not that it matters, and the yield of a fuzz run is a queue for your eyes, not a findings list. The db_backup.sql row was a finding because of what was in it. Most rows are TODO.md, four bytes of nothing, and telling them apart is the 054 discipline applied to a longer list. The fuzzer finds names. The tester still does the meaning.