Know your attacker · Part 5

Vulnerability scanners and secret probes: who is asking for your .env

Every public server is asked for /.env, /.git/config and wp-login.php within hours of going online. What these scanners want, what they find, and how to make sure the answer is always a 404.

6 min read scanners.envsecretsfail2ban

Put a new server on the internet with a web server listening, and within hours your access log fills with requests for files you never had: /.env, /.git/config, /wp-login.php, /phpinfo.php, /actuator/env. Nobody is targeting you in particular. Automated scanners sweep the whole IPv4 address space continuously, and every certificate you obtain is published in Certificate Transparency logs that they watch for new hostnames.

Most of these requests are harmless 404s. The danger is the one that is not.

The names

Vulnerability scanning, secret probing, path enumeration, forced browsing, dirbusting (after the DirBuster tool). The tools range from research scanners that announce themselves (zgrab, masscan followed by nuclei) to anonymous bots with a fake browser user agent, python-requests or Go-http-client.

What the attacker wants

Two things, in order of value:

  1. Secrets left in the document root. A .env file holds database passwords, API keys, SMTP credentials, cloud access keys. A .git/ directory lets an attacker download your whole repository, history included — and every secret ever committed. A backup.sql, config.php.bak or wp-config.php~ left by an editor or a hurried deployment is the same gift.
  2. Software with a known weakness. An exposed phpinfo() page, an unauthenticated phpMyAdmin, a Spring Boot /actuator endpoint, an old vendor/phpunit/.../eval-stdin.php that executes whatever PHP is posted to it, a WordPress installation to brute-force.

The scanner’s job is just to find them. What is found is then sold, used to send spam, mine cryptocurrency on your cloud account, or reach your database.

How it works

A scanner holds a list of a few hundred to a few thousand paths and requests them across as many hosts and virtual hosts as it can reach. It does not need to understand your site: it simply looks at the status code and the size of each answer. A 200 with content that looks like DB_PASSWORD= is a hit.

Typical requests in an access log:

203.0.113.9 - - [14/Oct/2026:03:12:40 +0000] "GET /.env HTTP/1.1" 404 196 "-" "Mozilla/5.0 ..."
203.0.113.9 - - [14/Oct/2026:03:12:40 +0000] "GET /.git/config HTTP/1.1" 404 196 "-" "Mozilla/5.0 ..."
203.0.113.9 - - [14/Oct/2026:03:12:41 +0000] "GET /wp-login.php HTTP/1.1" 404 196 "-" "Mozilla/5.0 ..."
203.0.113.9 - - [14/Oct/2026:03:12:41 +0000] "GET /vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1" 404 196 "-" "Mozilla/5.0 ..."

The signs: many 404s in a burst from one address, the same address touching many of your virtual hosts within minutes, and paths for software you do not run. On a shared server with many sites, one scanner visiting all of them in turn can generate enough uncached PHP 404 pages to push the load up on its own.

How to protect yourself

Never put secrets in the document root

The real fix is structural. Your document root should be a public/ directory that contains only what is meant to be served — the layout modern frameworks (Laravel, Symfony, CakePHP) already use. .env, vendor/, .git/ and your configuration then sit one level up, unreachable whatever the web server’s rules say. Deploy from a build artifact or with git archive, not by cloning a repository into the web root.

Deny dotfiles and backups anyway

As a second layer, refuse hidden files and common backup extensions, keeping /.well-known/ reachable for ACME challenges:

# nginx
location ~ /\.(?!well-known/) { return 404; }
location ~* (\.(sql|bak|old|orig|swp)|~)$ { return 404; }
# Apache, in the server config or a vhost
RedirectMatch 404 "/\.(?!well-known/)"
RedirectMatch 404 "(?i)(\.(sql|bak|old|orig|swp)|~)$"

Answering 404 rather than 403 tells a scanner nothing about whether the file exists.

Check what you actually serve

Test your own sites the way a scanner would:

for p in /.env /.git/config /backup.sql /phpinfo.php; do
  printf '%s ' "$p"; curl -s -o /dev/null -w '%{http_code} %{size_download}\n' "https://www.example.com$p"
done

A 200 with a size larger than your error page is a problem, even if you think the file is harmless. If a secret was ever served, rotate it: you cannot know who fetched it.

Lock down admin tools

phpMyAdmin, Adminer and similar tools should not be reachable from the internet at all; if they must be, put HTTP basic authentication in front of them so scanners never reach their login page. Remove phpinfo() files after debugging, and never deploy dev dependencies such as PHPUnit to production.

Ban the scanners if you like

fail2ban ships filters for this, such as nginx-botsearch and apache-noscript. Banning scanners reduces log noise and load. It does not fix a leaked secret, and the next scanner arrives from a different address — so treat it as hygiene, not as protection. If you do ban, close the banned address’s open connections too (why).

How Demerzel helps

This is the attack Demerzel’s Active Defense was designed around, and where it is most useful.

  • It notices when the answer is not a 404. The agent spots any secret file — .env, .git/config, a SQL dump — that a site answered with real content, reporting its path, status and size, never the content. Catch-all pages that answer 200 to everything are recognised and dropped on the host. A secret served to the internet is a critical Action and alert, and an item on the server’s security checklist.

  • It finds open phpMyAdmin installations. The agent reads the enabled Apache and nginx configuration to find each phpMyAdmin the web server serves, and checks whether basic authentication covers it. An open one raises an Action on the dashboard, with the fix.

  • It names the scanners. With web logs granted, each server’s Security tab lists the rules that fired and the busiest and most error-prone addresses. Each address has a dossier across all your servers: the paths it asked for (query strings removed), its user agents, the sites it hit, and what was done about it.

  • It blocks them, when you let it. The warden enforces rules on its own nftables table:

    • S1-secret-probe bans an address on a single request for a secret no site serves — /.env, /.git/, /.aws/, a config backup, phpunit’s eval-stdin.php. It is the one rule allowed to enforce from day one, because no visitor ever asks for those.
    • S2-cms-probe bans after three failed probes in ten minutes for software the server does not run — WordPress login on a non-WordPress site, phpinfo, xmlrpc.php, Spring actuator, phpMyAdmin. What the server actually serves never counts.
    • spread-vhosts bans an address hitting more than eight of the server’s sites in ten minutes; error-ratio throttles one whose requests end in a 4xx more than 60% of the time, over at least 50 requests in ten minutes — the shape of a fuzzer.

    Every rule but S1 enforces only after a replay over your own logs shows it would not have caught a real visitor. The warden starts in shadow mode and blocks nothing until you enable it; bans are timed (an hour for a first offence, longer for repeat offenders), allowlisted addresses and operators logged in over SSH are never banned, and the address of a banned scanner has its open web connections closed so the ban itself does no harm. On our own servers, a /.env probe is blocked about a second after it is asked.

A scanner will find your server whatever you do. The goal is that it finds nothing — and that you know the day it does.

Block a /.env probe about a second after it is asked.

Start in shadow mode, read what the warden would have blocked, and enforce the secret-probe rule from day one — no visitor ever asks for /.env.

14-day free trial · No credit card required