Genesis
After finding and cleaning an XMRig miner on my VPS (see the dedicated article), a question naturally arose: how do you make sure this doesn't silently happen again for 5 months?
I could have set up generic monitoring — Wazuh, Falco, AIDE. But these tools are heavy, complex to configure, and don't know the specific IoCs of the Diicot/color1337 campaign I had traced.
I preferred to write a targeted, lightweight, modular, open-source script.
What it does
bq-watchdog is a Linux security monitor written in pure bash. It runs via cron every 30 minutes, performs a series of checks, and only sends a Discord alert if something suspicious is detected. Silent when everything is fine.
Installation
curl -fsSL https://github.com/BugQuest/bq-watchdog/releases/latest/download/install.sh | sudo bash
Modular architecture
Each check is an independent bash file in `checks/`. The main script loads them all in numerical order and automatically calls the corresponding function. Adding a check = creating a file.
check_mon_check() {
# finding <warning|critical> "titre" "détail"
if [[ -f /fichier/suspect ]]; then
finding critical "Fichier suspect" "Chemin: /fichier/suspect"
fi
}
Included checks
**01 — IoC color1337/Diicot**: `ElPatrono1337` SSH backdoor key, `.ladyg0g0` `.pr1nc35` `.b4nd1d0` files, connections to known C2s (195.24.237.240, digital.digitaldatainsights.org), obfuscated hex binaries, `myservices.service`, `node` account.
**02 — SSH config**: `PasswordAuthentication yes` in the effective config (including cloud-init overrides), `PermitRootLogin yes`, `PermitEmptyPasswords yes`, the classic trap `/etc/ssh/sshd_config.d/50-cloud-init.conf`.
**03 — Crontabs**: `curl | bash` patterns, execution from `/tmp`, base64 encoding, hardcoded C2 IPs, perl/python one-liners.
**04 — Temp files**: ELF binaries in `/tmp`, `/var/tmp`, `/dev/shm`, hidden directories in these locations.
**05 — Users/SSH keys**: system accounts with interactive shell, SSH keys with suspicious names (`1337`, `h4x`, `backdoor`...), recently created sudo accounts.
**06 — Network**: connections to known malicious IPs/ranges, mining stratum ports (3333, 4444, 5555...), processes from `/tmp` with active connections.
**07 — Processes**: 8-character hex names (Diicot pattern), execution from temporary directories, deleted binaries still in memory, known miners (`xmrig`, `kswapd0`...).
Discord alerts
Each finding produces a colored Discord embed — red for critical, yellow for warning. The summary embed gives the hostname, UTC timestamp, and total number of findings. Details follow with full context.
# Extrait : construction du payload Discord
discord_send() {
local embeds="$1"
# Couleur selon la sévérité (rouge critique / jaune warning)
if [[ $SEVERITY -ge 2 ]]; then
status_color=15158332; status_emoji="🚨"
else
status_color=16776960; status_emoji="⚠️"
fi
# Embed de résumé + liste des findings
curl -fsSL -X POST "$DISCORD_WEBHOOK" \
-H "Content-Type: application/json" \
-d "$(jq -n --argjson e "$all_embeds" \
'{username:"bq-watchdog",embeds:$e}')"
}
GitHub Actions releases
A GitHub Actions workflow automatically generates a release on each git tag. The release contains the compressed tarball and the install script. The install URL stays stable via `/releases/latest/`.
# Déclenché par: git tag v1.1.0 && git push --tags
on:
push:
tags: ["v*"]
steps:
- name: Create tarball
run: |
tar -czf dist/bq-watchdog-${{ steps.version.outputs.VERSION }}.tar.gz \
--exclude='.git' --exclude='dist' --exclude='.github' .
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
dist/bq-watchdog-*.tar.gz
install.sh
Updates
The script manages its own updates. An automatic check runs every Monday at 3am via cron — if a new release is available on GitHub, it's downloaded, installed, and the `config` file is preserved. A Discord notification confirms the update.
# Mise à jour manuelle
sudo /opt/bq-watchdog/watchdog.sh --update
# Version installée
sudo /opt/bq-watchdog/watchdog.sh --version
False positives encountered
Deploying on a real server is the best test. Three false positives surfaced on the first audit and led to three targeted fixes.
**certbot + perl** — The Let's Encrypt renewal cron contains `perl -e 'sleep int(rand(43200))'` to randomize the renewal time. The `perl.*-e` pattern was too broad. Fixed to only alert if the perl one-liner calls truly dangerous functions: `socket`, `exec`, `system`, `eval`, `base64`, `chr()`.
**vmail** — The virtual mail account (Dovecot) runs with `/bin/sh`, which is expected on a mail server. Fixed with a whitelist of known service accounts: `vmail`, `dovecot`, `postfix`, `www-data`, `git`, `postgres`...
**kswapd0** — The kernel thread `[kswapd0]` (swap management) has the same name as a known XMRig miner. The distinction is simple: a kernel thread has no `/proc/<pid>/exe` symlink, unlike a userspace process. The bug came from `readlink -f` returning the literal path when the target doesn't exist — replaced by `readlink` without `-f`, which returns empty in that case.
Roadmap
v1 covers the known IoCs from the Diicot campaign and generic attack vectors. Planned next:
- Support for other documented Linux malware families
- `--report` option for machine-readable JSON output
- Clean uninstaller
- Support for Slack and ntfy webhooks in addition to Discord