045 —Drupal
Drupal 7 pharma hack: tracing an old uploads injection
The customer's Google snippet read 'Buy Cialis 20mg' but the page itself looked clean. Tracing the Drupal 7 pharma hack took us through htaccess cloaking and a forgotten CV upload.
The Loom arrived at 23:41 on a Tuesday. A Dutch agency lead we work with had been forwarded a screenshot from his customer's marketing manager: the Google result for the customer's flagship landing page now read "Buy Cialis 20mg without prescription". The page itself loaded fine in a browser. The cached snippet was poison. Classic Drupal 7 pharma hack, the kind that has been quietly running on legacy site deployments since around 2014.
The site was Drupal 7, last touched in 2019, hosted on a shared cPanel box, and the agency had inherited it from the previous developer in 2022. Tracing one of these is mostly patience. The injection is almost never in the code you would look at first. Below is the path we walked from the SERP screenshot back to the original entry point, the cleanup we ran that night, and the one Apache rule that closes this category of attack for good.
SERP poisoned, browser clean
The first useful tell with any Drupal 7 pharma hack is that the page renders cleanly to you and dirtily to Googlebot. The attacker does not want the site owner to see the injection, because then the site owner fixes it. They want Google to see it long enough to ship link juice and clicks to a pharmacy affiliate, and they want a wide tail of cached snippets to do the SEO work.
You confirm the cloak with curl. Two requests, identical URL, different user agent strings:
curl -s https://example.com/over-ons | grep -ic cialis
# 0
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
-s https://example.com/over-ons | grep -ic cialis
# 3
Three matches with the Googlebot user-agent, zero without. The site was cloaking. The next question is always where the cloak lives. In our experience it is one of three places: Apache (.htaccess), PHP (a require_once line dropped into index.php or settings.php), or the database (a hook_boot implementation registered by a fake module). For Drupal 7 specifically, the cheapest place for an attacker to hide is the .htaccess at the root, because Drupal already ships one and the diff is small enough to miss.
The htaccess append
Drupal 7's root .htaccess has not meaningfully changed since the 7.x branch was cut. Diffing the live file against a clean 7.99 tarball is a 30-second job:
curl -sL https://ftp.drupal.org/files/projects/drupal-7.99.tar.gz \
| tar -xzOf - drupal-7.99/.htaccess > /tmp/clean-htaccess
diff /tmp/clean-htaccess /home/site/public_html/.htaccess
The live file had nine extra lines at the bottom. Three of them mattered:
RewriteCond %{HTTP_USER_AGENT} (googlebot|bingbot|yandex|baidu) [NC]
RewriteCond %{REQUEST_URI} !^/sites/default/files/\.cache/
RewriteRule ^(.*)$ /sites/default/files/.cache/index.php?u=$1 [L]
Translation: any search engine bot that hits any URL gets silently rewritten to a PHP file inside the uploads directory. Human visitors do not match the user-agent condition, so they keep seeing the real page. The condition on REQUEST_URI stops the rule from looping when the rewritten request itself comes back through. The cloak works, and it works only for crawlers, which is exactly what the SERP screenshot showed.
The Apache rewrite docs cover the same primitives if you want a reference: httpd.apache.org/docs/2.4/mod/mod_rewrite.html.
A directory that should not exist
The path /sites/default/files/.cache/ does not exist in a stock Drupal install. Drupal writes to /sites/default/files/, sure, and to a few subdirectories named after field machine names, but never to a dotfile directory. Listing it:
ls -la /home/site/public_html/sites/default/files/.cache/
# -rw-r--r-- 1 site site 47821 Mar 14 03:12 index.php
# -rw-r--r-- 1 site site 124003 Mar 14 03:12 favicon.jpg
# -rw-r--r-- 1 site site 18204 Mar 14 03:12 robots.jpg
The index.php read a list of spintax templates from favicon.jpg and robots.jpg (both files had real JPG headers, then roughly 100KB of base64-encoded pharma copy). It rendered a fake landing page seeded by the inbound URL slug, so /over-ons got a page about cheap generic Cialis with the customer's brand name interpolated into the H1. The whole rig was three files, 190KB total, and had been sitting there since March 14. Two months.
The first instinct is to delete the three files. Do not. Not yet. Copy them somewhere outside the docroot first. You will need favicon.jpg later to grep the access log for fetches against it, and you will need index.php to understand how it got written. Forensics first, cleanup second.
mkdir -p ~/forensics/drupal-pharma-2026-05/
cp -p /home/site/public_html/sites/default/files/.cache/* \
~/forensics/drupal-pharma-2026-05/
chmod -R 000 ~/forensics/drupal-pharma-2026-05/
The chmod 000 stops you from accidentally double-clicking the PHP file later on a machine that has PHP installed. Small habit, saved us once.
Walking the access log back
stat showed all three files written within the same second on March 14 at 03:12:47 UTC. cPanel keeps three months of access logs by default, which on a March 14 incident discovered May 26 is just inside the window. zgrep:
zgrep " 14/Mar/2026:03:12:4" /home/site/logs/example.com-Mar-2026.gz \
| grep -v "GET / "
One POST request stood out: a 200 to /?q=file/ajax/field_attachment/und/form-XYZ/field-attachment-upload-button, from a residential IP in Brazil, body 4.1MB. That endpoint is Drupal 7's AJAX file upload handler, exposed by any node form that has an attached file field. The site had a careers page with a CV upload widget that nobody on the customer side remembered enabling. It accepted .pdf and .doc. It also accepted .phtml, because the agency that built the site in 2017 had set the allowed extensions to pdf doc docx phtml by mistake (probably autocomplete on the extension field). .phtml is executable under the default Apache config on cPanel.
The attacker uploaded a phtml file, called it once, and got code execution as the web user. From there, dropping the three files into /sites/default/files/.cache/ and appending nine lines to the root .htaccess took maybe a minute of scripting. The original phtml file was already gone by the time we looked. The attacker cleaned up the entry point but left the moneymaker, which is normal.
The cleanup in order
A working sequence for a Drupal 7 pharma hack, in the order we ran it that night:
- Pull the site offline at the load balancer if you have one. If you do not, drop a maintenance
.htaccessat the docroot that 503s every request that is not from your IP. - Snapshot the docroot and the database.
tar -czffor files,mysqldump --single-transactionfor the database. Keep both outside the docroot. This is your evidence and your rollback. - Restore
.htaccessfrom a clean Drupal 7.99 tarball. Re-apply any genuinely custom rules from version control, and verify each one before adding it back. - Delete
/sites/default/files/.cache/and anything else that does not belong. Runfind sites/default/files \( -name "*.php" -o -name "*.phtml" -o -name "*.phar" \). On a clean site that returns zero. If it returns anything, read it. - Audit the database. The pharma payload was filesystem-only this time, but check
block_custombody fields,node_revisionbody fields, thevariabletable forsite_mailandsite_name, andmenu_linksfor anylink_pathstarting withjavascript:or pointing at a domain you do not own. Checksystemfor modules with afilenamethat does not match the module name. - Rotate every credential the web user could have read. Drupal user 1 password, the database password in
settings.php, any API keys in thevariabletable, the SFTP login, the cPanel password. Treat the box as having been read top to bottom, because it was. - Update Drupal core and every contributed module to its current 7.x release. Drupal 7 reached end-of-life in January 2025, but the Drupal Security Team still publishes advisories through the Drupal 7 Vendor Extended Support program. Check drupal.org/project/drupal/releases for the most recent 7.x point release before you patch.
The database audit is the part agencies skip and regret. If you want a faster pass through the suspect tables, a real MySQL editor sitting next to the file tree beats jumping between phpMyAdmin and an SFTP client. We ran the menu_links and variable queries side by side with the find output and the whole audit took 20 minutes.
One Apache block that ends this category
Cleanup is the obvious half. The half that prevents the next one is the file-upload field on the careers form, plus a single Apache block under the uploads directory. We changed the allowed extensions to pdf doc docx, added a hook_file_validate implementation that re-checks the MIME type against the actual file bytes (do not trust the extension, do not trust the browser-supplied MIME), and dropped a deny rule under /sites/default/files that refuses to execute anything ending in a server-side extension:
# /sites/default/files/.htaccess
<FilesMatch "\.(php|phtml|phar|pl|py|jsp|cgi|asp|aspx)$">
Require all denied
</FilesMatch>
# Belt and braces: disable the PHP handler in this tree
php_flag engine off
RemoveHandler .php .phtml .phar
RemoveType .php .phtml .phar
That block belongs in the .htaccess Drupal ships inside /sites/default/files. Check yours. On three out of the last five legacy sites we looked at, that file had been overwritten or deleted during a botched migration in 2020, usually by an rsync command that excluded dotfiles. OWASP's file upload guidance covers the same defense in more detail: owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload.
Notes the next morning
The Drupal 7 pharma hack story ended at 04:30. The customer's SERP took eleven days to clear, because Google had to re-crawl every poisoned URL and the cache TTL on some of them was a week. The agency invoiced six hours. The fix in version control was 14 lines, almost all of it the deny block above plus the corrected file_validate hook.
We do a lot of these on legacy site work, and the part that always cost the most was not the cleanup. It was figuring out which file had changed and when, and being sure the rollback did not drop a known-good edit from last week. When we built Pier we ran into this exact thing on a Drupal 6 incident, and the way we ended up handling it was to bake automatic version history into every SFTP edit, so the diff between what the site shipped with and what is on the server right now is always one click.
The smallest thing to do today
Open a terminal, SSH into the legacy site you forgot about last quarter, and run find sites/default/files -type f \( -name "*.php" -o -name "*.phtml" -o -name "*.phar" \) 2>/dev/null. If anything comes back, you have an evening's work ahead of you, and you would rather have it now than at 23:41 on a Tuesday.
— Questions —
Is Drupal 7 still safe to run in 2026?
Only with paid Vendor Extended Support and a hardened uploads directory. Core EOL was January 2025, but VES partners still ship security patches through 2027.
How do I check if my site is cloaking content to Googlebot?
Fetch the same URL twice with curl, once with a normal user agent and once with the Googlebot string. Diff the outputs. If they differ on a public page, your site is cloaking.
Should I delete the malicious files immediately?
Copy them out of the docroot first, with chmod 000 applied. The access log grep for fetches against those filenames is what tells you when the attacker got in and which endpoint they used.
What permissions should /sites/default/files have?
755 on directories, 644 on files, owned by the deploy user and writable by the web user. No file in that tree should ever have a server-side extension like .php or .phtml.