- What is a Virtual Host β Plain English
- Why Companies Accidentally Expose Vhosts
- Tools for Vhost Fuzzing
- gobuster vhost Mode
- ffuf vhost Fuzzing
- Finding the Filter Value
- HTTPS Targets
- What To Do When You Find a Vhost
- Full Vhost Discovery Workflow
- CTF vs Professional Use
Virtual host fuzzing is one of the most important techniques in web enumeration that beginners consistently skip. You can run a full subdomain enumeration, find nothing interesting, and completely miss three hidden web applications running on the exact same IP address β because they're configured as virtual hosts, not DNS subdomains. This file explains what virtual hosts are, why they're different, and how to find them.
A virtual host (vhost) is a way to run multiple websites on a single server using a single IP address. The web server uses the Host header in the HTTP request to decide which website to serve.
Think of it like an apartment building. The building has one street address (one IP address). But inside there are 50 different apartments (50 different websites). When a package arrives (an HTTP request), the mailroom looks at the apartment number on the label (the Host header) and delivers it to the right unit.
The key difference from subdomains:
A subdomain (dev.example.com) has its own DNS record that resolves to an IP address. Anyone can discover it through DNS enumeration.
A virtual host may have NO DNS record at all. The web server just knows "if someone asks for internal.example.com, show them this application." Without knowing to ask for that hostname, you'll never find it through DNS enumeration. It's invisible to the outside world β unless you fuzz for it.
# DNS subdomain β discoverable through DNS queries
dev.example.com β A record β 10.10.10.1
# Virtual host β no DNS record, only web server config
Host: internal.example.com β web server routes to internal app
# There is no DNS record for internal.example.com
# Standard subdomain enumeration finds nothing
# Vhost fuzzing finds it
Virtual hosts are frequently used for:
- Development environments β
dev.,test.,staging.running on the same server as production - Internal tools β admin panels, monitoring dashboards, internal APIs
- Legacy applications β old versions of the site still running but no longer linked anywhere
- Client portals β separate applications for different clients on shared infrastructure
The misconfiguration pattern is always the same: a developer spins up an application on a vhost thinking "it has no DNS record so no one can find it." But the web server will happily respond to anyone who sends the right Host header β you just have to know to ask.
Both gobuster and ffuf handle vhost fuzzing. The approach is slightly different for each.
# Basic vhost scan
gobuster vhost \
-u http://<target-ip> \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt
# With domain appended β gobuster adds .example.com to each wordlist entry
gobuster vhost \
-u http://<target-ip> \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
--append-domain \
-t 50
# Filter false positives by response length
gobuster vhost \
-u http://<target-ip> \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
--append-domain \
--exclude-length 280 \
-t 50 \
-o gobuster-vhosts.txtπ‘
--append-domainis important β without it gobuster sendsHost: devinstead ofHost: dev.example.com. Many web servers only respond to fully qualified hostnames. Always use this flag when you know the base domain.
ffuf fuzzes vhosts by placing FUZZ inside the Host header directly β giving you full control over the format.
# Basic vhost fuzzing
ffuf \
-u http://<target-ip> \
-H "Host: FUZZ.example.com" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt
# Filter false positives β run without filter first to get default size
ffuf \
-u http://<target-ip> \
-H "Host: FUZZ.example.com" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-fs <default_response_size>
# Filter by word count instead of size
ffuf \
-u http://<target-ip> \
-H "Host: FUZZ.example.com" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-fw <default_word_count>
# Save output
ffuf \
-u http://<target-ip> \
-H "Host: FUZZ.example.com" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-fs <default_response_size> \
-o ffuf-vhosts.txt \
-of mdBefore you can filter false positives you need to know what the default response looks like β what the server returns when you ask for a vhost that doesn't exist.
# Step 1 β run a quick scan without any filter
ffuf \
-u http://<target-ip> \
-H "Host: FUZZ.example.com" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-t 10
# Step 2 β look at the output
# Almost every result will show the same size β that's your false positive size
# Example output:
# dev [Status: 200, Size: 612, Words: 45, Lines: 12]
# staging [Status: 200, Size: 612, Words: 45, Lines: 12]
# admin [Status: 200, Size: 612, Words: 45, Lines: 12]
# internal [Status: 200, Size: 4521, Words: 342, Lines: 89] β DIFFERENT
# test [Status: 200, Size: 612, Words: 45, Lines: 12]
# Step 3 β the outlier is your finding
# Filter everything matching size 612
ffuf \
-u http://<target-ip> \
-H "Host: FUZZ.example.com" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-fs 612If the target is running HTTPS with a self-signed certificate add -k to skip certificate verification:
# gobuster
gobuster vhost \
-u https://<target-ip> \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
--append-domain \
-k
# ffuf
ffuf \
-u https://<target-ip> \
-H "Host: FUZZ.example.com" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-fs <default_size> \
-k# Step 1 β add it to /etc/hosts
echo "<target-ip> internal.example.com" >> /etc/hosts
# Step 2 β visit it in your browser
# Open: http://internal.example.com
# Step 3 β enumerate it like any other web target
gobuster dir \
-u http://internal.example.com \
-w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt \
-x php,html,txt \
-t 50
# Step 4 β check for more vhosts on the same application
ffuf \
-u http://<target-ip> \
-H "Host: FUZZ.internal.example.com" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-fs <default_size># Step 1 β confirm the base domain
# Check nmap output, web response headers, SSL certificate
nmap -sV -p 80,443 <target-ip>
curl -I http://<target-ip>
# Step 2 β get the default response size
curl -s http://<target-ip> | wc -c
# Step 3 β run vhost fuzzing
ffuf \
-u http://<target-ip> \
-H "Host: FUZZ.example.com" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-fs <default_size> \
-t 50 \
-o vhost-results.txt
# Step 4 β add all found vhosts to /etc/hosts
echo "<target-ip> internal.example.com admin.example.com dev.example.com" >> /etc/hosts
# Step 5 β enumerate each discovered vhost
for vhost in internal.example.com admin.example.com dev.example.com; do
gobuster dir -u http://$vhost \
-w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt \
-t 50 -o gobuster-$vhost.txt
done| Situation | CTF | Professional Engagement |
|---|---|---|
| Always run vhost fuzzing | Yes β after initial web enum | Yes β standard methodology |
| Filter false positives | Essential | Essential |
| HTTPS with self-signed cert | Use -k |
Use -k β document cert details |
| Found internal tool | Enumerate it fully | Document access β high finding |
| Found dev/staging environment | Exploit it | High finding β often less secure than prod |
by SudoChef Β· Part of the SudoCode Pentesting Methodology Guide