I Built a ClamAV Scanner Bridge. A “Hacker” Was Kind Enough to Test It.

Every piece of security infrastructure needs a real-world test. Unit tests are fine. Staging environments are fine. But nothing validates your upload scanning pipeline quite like an actual threat actor uploading a PHP webshell to your server while you’re mid-development.

Allow us to introduce our tester: ~XBumbbleB33~.


The Setup

For context: CFM is our homegrown traffic management and WAF platform built on OpenResty/Lua with a Go backend. It handles everything from bot challenges to WAF rule enforcement, and recently we’ve been extending it with an async ClamAV scanning bridge for file uploads.

The architecture works like this:

  1. cfm_clamav.lua — an OpenResty Lua module that hooks into the request pipeline. On any POST or PUT with a multipart/form-data content type, it checks whether the body actually contains a filename= part. Generic admin-ajax or FormData payloads without a real file attachment are intentionally skipped — only actual uploads enter the ClamAV lane.
  2. The Go bridge — once the Lua side confirms it’s a real file upload, it serializes the job metadata (IP, host, URI, filename, path to the body spool file or a temp copy) and fires it over a Unix socket to the Go side with a POST /nginx/upload.
  3. clam.go — a proper Manager/Client abstraction that talks the ClamAV wire protocol (SCAN, PING, CONTSCAN). It maintains a worker pool, a job queue, and handles the aftermath: clean files get deleted, infected files get moved to a quarantine directory with evidence preserved, and everything gets logged with full enrichment (ASN, country, PTR).

The Lua side is intentionally fire-and-forget with a short 300ms timeout — it blocks just long enough to confirm the bridge received the job (waits for a 200 response), then hands back control to nginx. The actual scanning happens asynchronously in Go. Users never wait for ClamAV.

One of the subtleties we’re proud of: the has_file_part() check before anything hits the scanner. It would’ve been easy to just dump every multipart POST into the queue. But WordPress sites generate a lot of multipart requests that aren’t file uploads at all — admin-ajax calls, form submissions, API payloads. Flooding ClamAV with those would be wasteful and would fill your logs with noise. We only care about requests that include an actual filename= disposition header in the body.


Enter ~XBumbbleB33~

While we were in the middle of validating all this, someone quietly dropped a PHP webshell onto one of the servers. The file introduced itself politely at the top:

php
/**
 * ~XBumbbleB33 Was Here~ - CYBER-OSC V2.8
 * "The Ultimate Weapon for the Ultimate Ops"
 */

V2.8. There are apparently at least 2.7 previous versions of this thing. A whole versioning history. A roadmap, probably. Quarterly release notes.

The shell was password protected (the password was @XBumbbleB33, stored in plaintext, in the file), featured a glowing yellow aesthetic on a black background — very 1998 geocities meets Mr. Robot — and upon successful login, would dutifully fire a Telegram notification to its author containing your server’s IP, OS details, and current user. Very thoughtful. Very enterprise.

 


The Flaw in His Opsec (There Were Several)

The Telegram bot token and chat ID were, of course, hardcoded in the file.

$telegram_bot_token = "865597xxxx:AAH42-...";
$telegram_chat_id   = "823545xxxx";

This means that anyone who finds the shell — which, reminder, you presumably uploaded to do secret hacker things — can:

  • Query getMe on the bot API and get the bot’s identity
  • Pull getUpdates and see every notification the bot has ever received, potentially including alerts from other compromised servers
  • Send a message directly to the operator’s personal Telegram account via their own bot

That last one is the fun part. Imagine getting a notification from your own hacking tool that just says:

👋 Hi. Found your file. You left your keys in the door.

I did not resist. Sorry 🙂

{“ok”:true,”result”:{“message_id”:15,”from”:{“id”:8655979350,”is_bot”:true,”first_name”:”XB33Sh3llBot”,”username”:”xb33sh3llbot”},”chat”:{“id”:8235457641,”first_name”:”Rey”,”username”:”reyinfinity9″,”type”:”private”},”date”:1774021973,”text”:”Nice try, XBumbbleB33 \ud83d\udc4b”}}

 


What Actually Happened (The Boring But Important Part)

The shell was found, removed, and the bot token was revoked via @BotFather — which silently kills their entire notification pipeline for every server they’ve hit. We also did the responsible follow-up:

  • Audited access logs for the upload window
  • Checked for cron persistence and additional dropped files
  • Verified no lateral movement had occurred

The server was fine. The hacker’s Telegram alerts were not.


Back to the Scanner

The incident was a good reminder of why we’re building this. The webshell in this case was a PHP file — ClamAV’s PHP.Webshell signature family covers many of these variants. Had the file been uploaded through a web form rather than via some other vector, our pipeline would have caught it:

  1. Lua detects multipart/form-data with filename= in the body
  2. Job gets queued to Go with the spool file path
  3. ClamAV scans asynchronously
  4. INFECTED result → file moved to /var/lib/cfm/scanner/infected/
  5. CLAM/INFECTED event enqueued to the notify pipeline with full enrichment
  6. Log line: result=INFECTED sig="PHP.Webshell.Agent-..." with IP, host, URI

The Go Manager handles the worker pool and ensures we don’t block nginx on scan latency. The TempCopy flag in the Job struct tells workers whether they own the file’s lifecycle or whether it’s nginx’s spool file (which nginx will clean up on its own). No double-frees, no orphaned temp files in the pending dir.


What’s Next

  • INSTREAM scanning — rather than passing a file path (which requires clamd to have filesystem access to the same path), switching to the INSTREAM protocol so we can stream file data directly over the socket. More portable, works across container boundaries.
  • Magic byte inspection — checking the first few bytes before even sending to ClamAV, to fast-reject obviously non-file content that slipped past the filename= check.
  • Per-extension risk scoring — feeding the filename back through the WAF scoring system so .php, .phtml, .exe uploads get flagged even before scan results come back.

Closing Thoughts

Building security tooling is one of those things where you don’t really know if it works until something tries to break through. We’d have preferred a controlled test. We got ~XBumbbleB33~ and CYBER-OSC V2.8 instead.

Thanks for the QA, buddy. The bot’s been revoked. The shell’s been removed. The logs have been reviewed.

The blog post, however, will live forever.