The Cleanup Script Was Right About the Name and Wrong About the File: Dynamic Includes and Leftover-File Hunts
Every few weeks we run a maintenance pass over the servers we look after. One step is a hunt for leftover files in web roots: the fix_something.php, test2.php, debug_login.php and install.php files that pile up on any se
Every few weeks we run a maintenance pass over the servers we look after. One step is a hunt for leftover files in web roots: the fix_something.php, test2.php, debug_login.php and install.php files that pile up on any server that has been alive long enough. They are worth hunting. On one pass we found a debug script that printed a payment provider's API credentials to anyone who opened it, an installer that still created an admin/admin123 account, and a "test" file that dumped server details.
So the hunt is useful. It is also how we broke a live product page on a customer site, and the way it happened is worth writing down, because the check we relied on looked solid and was not.
The rule we had
The rule was reasonable on paper:
- Find files whose names match a leftover pattern:
fix*,test*,debug*,check*,verify*,*_backup.php,*-old*.php,setup*.php,phpinfo*. - For each match, search the code base for references to the file name.
- If nothing references it, move it to a quarantine directory instead of deleting it.
- Never call a candidate over HTTP to "see what it does". Calling a
fix_*.phpscript runs it, and some of them change data.
Step 2 is the safety net. If fix_totals.php is included by cron.php, the search finds the name and the file stays.
The file that matched
On an older PHP catalogue site, the pattern fix* matched a file called something like product/<brand>/fixpro.php. Nobody had heard of it. The reference search over the whole document root returned nothing. The file went to quarantine, and some weeks later the quarantine was cleared.
fixpro was not a fix. It was a product name. The file was the product detail page for a hardware item called "FixPro", and the site loaded product pages like this:
// detail.php
$brand = $_GET['brand']; // validated against a list
$prog = $_GET['prog']; // validated against a list
require "product/$brand/$prog.php";
The string fixpro appeared nowhere in the code. It existed only in a database row, in a rewrite rule pattern and in incoming URLs. A grep for the file name could never find it, because the reference was built at runtime.
The page started returning 500. The off-site backups only kept a few days of revisions for that file, so by the time anyone noticed, the old copy was gone too. The text and images of one product page had to be recreated by hand.
Why the reference search lied
A static reference search answers "does any source file contain this string?". It does not answer "does anything load this file?". The gap between those two questions is wider than it looks in old PHP code:
-
Variable includes:
require "pages/$page.php",include $module . '/index.php'. -
Rewrite rules:
RewriteRule ^urun/([a-z]+)/([a-z0-9-]+)$ detail.php?brand=$1&prog=$2. The rule mentions the directory pattern, not the file. -
Database-driven routing: a
pagestable with atemplatecolumn whose values are file names. -
Autoloaders: class names mapped to paths.
FixProControllerbecomesControllers/FixProController.phpwithout the path ever being written down. - Cron and external callers: a hosting panel's scheduled task or a payment provider's callback URL can point straight at a file that nothing in the code base mentions.
- Direct links from outside: a PDF, a QR code, a newsletter, an old social media post.
Name patterns are a decent way to find candidates. They are a bad way to decide anything, because the name is the least reliable thing about the file.
What we check now
A file that matches a leftover pattern is only a candidate. Before it can be quarantined, three more checks have to come back empty. If any of them finds something, the file stays.
1. Does it have siblings that look like content?
ls "$(dirname "$f")" | head -50
If product/led/ contains strip5050.php, panel60.php and driver24.php, then product/brandx/fixpro.php is almost certainly one more content page, whatever its name looks like. A directory of similar files is a strong sign of runtime includes.
2. Does any rewrite rule or router mention its directory or name stem?
stem=$(basename "$f" .php)
dir=$(basename "$(dirname "$f")")
grep -rnE "$stem|$dir" .htaccess */.htaccess 2>/dev/null
grep -rnE "(require|include)(_once)?\s*[\"'(].*\\\$" --include=*.php . | head
The second grep lists every include that uses a variable. If there are any, the reference search from the old rule is no longer enough on its own, and the file needs one of the other checks to be cleared.
3. Has anyone requested it recently?
This is the check that would have saved the page:
# last 30 days of access logs, any status
zgrep -h "$(basename "$f")" /path/to/access.log* | awk '{print $9}' | sort | uniq -c
A product page gets requests. A forgotten fix script usually does not. A 200 in the log means something reaches the file, through a route you may not have found. A 404 or no line at all does not prove the file is dead, but it removes the strongest reason to keep it.
For URL-routed files the file name may not appear in the log at all, because the URL is /urun/brandx/fixpro and not /product/brandx/fixpro.php. So we search for the stem, not only the full file name.
And then: keep the quarantine longer than the backups
The second mistake was clearing the quarantine sooner than the backups could cover. We now keep quarantined files for at least 30 days and never clear quarantine before checking that the off-site backup for that site actually holds an older revision. A quarantine you delete in a week is just a slower rm.
A small script
This is roughly what the candidate filter does now. It prints a verdict per file and never moves anything itself.
#!/usr/bin/env bash
# usage: leftover-check.sh <docroot> <access-log-glob>
root=$1; logs=$2
find "$root" -type f -name '*.php' \
\( -iname 'fix*' -o -iname 'test*' -o -iname 'debug*' -o -iname 'check*' \
-o -iname '*_backup.php' -o -iname '*-old*.php' -o -iname 'setup*.php' \) \
-not -path '*/vendor/*' | while read -r f; do
stem=$(basename "$f" .php)
dir=$(dirname "$f")
reason=""
siblings=$(find "$dir" -maxdepth 1 -name '*.php' | wc -l)
[ "$siblings" -gt 3 ] && reason+="siblings=$siblings "
grep -rqF "$stem" --include=*.php --include=.htaccess "$root" \
--exclude="$(basename "$f")" && reason+="referenced "
grep -qE "$(basename "$dir")" "$root/.htaccess" 2>/dev/null && reason+="rewrite "
hits=$(zgrep -hc "$stem" $logs 2>/dev/null | paste -sd+ | bc)
[ "${hits:-0}" -gt 0 ] && reason+="log_hits=$hits "
if [ -n "$reason" ]; then echo "KEEP $f ($reason)"; else echo "CAND $f"; fi
done
CAND still means "a person looks at it", not "delete it".
What did not change
We still hunt leftovers, and we still find bad ones. The debug script that printed payment credentials was real, and a cautious rule that never removes anything would have left it online. Security findings like that one go to quarantine immediately, because the cost of leaving them is higher than the cost of a broken page.
The change is in how the decision is made. A file name is a reason to look at a file. Siblings, rewrite rules, access logs and the age of the newest backup are the reasons to keep it or let it go.
If you run maintenance on old PHP sites, the one-line version is: grep for the variable includes before you grep for the file name. If there are any, your reference search is incomplete, and the access log is the only witness you have left.
We run maintenance like this for the sites we build and host at Alesta WEB. The rule changes above came out of a real incident; the file and brand names here are changed.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.