diff --git a/CHANGELOG.md b/CHANGELOG.md index 06bad5f..ae8f91a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.9] - 2026-10-31 + +### Added + +- `web.php2shell-attempt`: bans clients whose requests run PHP out of memory + before any script runs, the step every known PHP2Shell variant relies on. + Read from Apache error logs (`[client …] … Allowed memory size … in Unknown + on line 0`), nginx error logs in front of php-fpm (`upstream sent + unexpected FastCGI record … request: "POST …"`), and the public scanner's + `php2shell-…` User-Agent. Each line is one offense, so five in the ban + window ban the client in the same pass. Only the client and request are + kept from nginx lines: `server:` and `host:` can be this server's own + address. +- The `vuln.php2shell` alert now names the interim workaround, + `session.upload_progress.enabled = 0`. + ## [0.2.8] - 2026-10-31 ### Added diff --git a/Cargo.toml b/Cargo.toml index c525f98..217082b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stackdog" -version = "0.2.8" +version = "0.2.9" authors = ["Vasili Pascal "] edition = "2021" description = "Security platform for Docker containers and Linux servers" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b32936b..28d7b40 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,7 +1,7 @@ # Stackdog Security - Development Plan **Last Updated:** 2026-04-07 -**Current Version:** 0.2.8 +**Current Version:** 0.2.9 **Status:** Phase 2 In Progress ## Project Vision diff --git a/README.md b/README.md index ec05e19..ee5804e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Stackdog Security -![Version](https://img.shields.io/badge/version-0.2.8-blue.svg) +![Version](https://img.shields.io/badge/version-0.2.9-blue.svg) ![License](https://img.shields.io/badge/license-MIT-green.svg) ![Rust](https://img.shields.io/badge/rust-1.75+-orange.svg) ![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey.svg) @@ -179,7 +179,7 @@ curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh Pin a specific version: ```bash -curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.8 +curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.9 ``` If your repository has no published stable release yet, use `--version` explicitly. @@ -623,18 +623,39 @@ fix is upgrading PHP; Stackdog tells you where you still have to. |---|---|---| | `vuln.php2shell` | a PHP build older than 8.2.35, 8.3.36, 8.4.27 or 8.5.12, or any 8.1 and older | Critical alert for upstream builds (official `php` images, read from `PHP_VERSION`); Medium "check your vendor" alert for distribution packages, which backport fixes without changing the version number | | `php.worker-crash` | 3 or more php-fpm or Apache workers dying on SIGSEGV, SIGBUS, SIGABRT or SIGILL in one log source within one pass; 1–2 per pass never alert, so one flaky extension stays quiet | alert; repeated crashes are what attempts against PHP memory bugs leave behind | +| `web.php2shell-attempt` | the exploit's out-of-memory step, with the client address: Apache `[client …] PHP Fatal error: Allowed memory size … in Unknown on line 0`; nginx `upstream sent unexpected FastCGI record … client: …, request: "POST …"` (php-fpm); or the public scanner's `php2shell-…` User-Agent | alert + an offense per line, so a client causing 5 within the ban window is banned in the pass that reads them | The version check reads `php8.x-common` (Debian, Ubuntu) and `php8x-common` or `php8x` (Alpine) from the package database on the host and inside each running container, and `PHP_VERSION` from container environments. RPM-based hosts are not covered. -Neither finding names a client, so neither bans. Turn the version check off -with `STACKDOG_PHP_VERSION_CHECK_ENABLED=false`. - -Crash lines come from php-fpm and Apache error logs. Container logs are read -on their own (official images send php-fpm errors to stderr); add host logs -such as `/var/log/php8.3-fpm.log` to `STACKDOG_LOG_SOURCES`. - -Stackdog does not detect the exploit request itself and cannot patch PHP. +The version and crash findings name no client, so they never ban. Turn the +version check off with `STACKDOG_PHP_VERSION_CHECK_ENABLED=false`. + +`web.php2shell-attempt` bans. Every known PHP2Shell variant pushes PHP past +`memory_limit` with a multipart POST; when that happens while PHP is still +parsing the request, the error says `in Unknown on line 0`. An application +running out of memory names its script and line instead, and is never an +attempt. The two log formats were reproduced against `php:8.3-apache` and +`php:8.3-fpm` behind nginx with a benign oversized form POST, not taken from +exploit traffic. Limits: + +- the exploit can succeed in a few requests, so a ban mostly stops repeats and + scanners, not a fast first attempt; +- the web server error log must be read: Docker container logs are, host logs + such as `/var/log/apache2/error.log` and `/var/log/nginx/error.log` go in + `STACKDOG_LOG_SOURCES`; +- behind a CDN or proxy, the error log must record the real client address; +- the same lines have rare innocent causes. A user posting a giant form to a + PHP with a very low `memory_limit` hits the Apache rule (at the default + 128M that takes a body over 128 MB, which `client_max_body_size` usually + stops first). A buggy extension crashing php-fpm on an upload hits the + nginx rule. Either way it takes 5 in 5 minutes from one address; +- the scanner User-Agent comes from one report (Fastly, via Brandefense). If + the scanner changes it, that rule goes quiet; the two out-of-memory rules + do not depend on it. + +Crash lines come from php-fpm and Apache error logs too; add host logs such as +`/var/log/php8.3-fpm.log`. Stackdog cannot patch PHP: upgrading is the fix. --- diff --git a/VERSION.md b/VERSION.md index a45be46..1866a36 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -0.2.8 +0.2.9 diff --git a/docs/INDEX.md b/docs/INDEX.md index 9c761ec..66ee160 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,6 +1,6 @@ # Stackdog Security - Documentation Index -**Version:** 0.2.8 +**Version:** 0.2.9 **Last Updated:** 2026-04-07 --- diff --git a/install.sh b/install.sh index 1fab145..679469a 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,7 @@ # # Usage: # curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -# curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.8 +# curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.9 # # Installs the stackdog binary to /usr/local/bin. # Requires: curl, tar, sha256sum (or shasum), Linux x86_64 or aarch64. @@ -73,7 +73,7 @@ resolve_version() { fi if [ -z "$TAG" ]; then - error "Could not determine latest release. Create a GitHub release, or specify one with --version (e.g. --version v0.2.8)." + error "Could not determine latest release. Create a GitHub release, or specify one with --version (e.g. --version v0.2.9)." fi VERSION="$(echo "$TAG" | sed 's/^v//')" @@ -136,7 +136,7 @@ main() { echo "Install stackdog binary to ${INSTALL_DIR}." echo "" echo "Options:" - echo " --version VERSION Install a specific version (e.g. v0.2.8)" + echo " --version VERSION Install a specific version (e.g. v0.2.9)" echo " --help Show this help" exit 0 ;; diff --git a/src/detectors/mod.rs b/src/detectors/mod.rs index e352358..5617274 100644 --- a/src/detectors/mod.rs +++ b/src/detectors/mod.rs @@ -22,7 +22,7 @@ use self::audits::{ConfigAssessmentMonitor, DockerPostureMonitor, PackageInvento use self::http_request::{extract_request, HttpRequest}; use self::integrity::FileIntegrityMonitor; use self::ml::MlBehavioralDetector; -use self::php2shell::{PhpVersionMonitor, PhpWorkerCrashDetector}; +use self::php2shell::{Php2ShellAttemptDetector, PhpVersionMonitor, PhpWorkerCrashDetector}; use self::php_shell::{ ContainerFilesystem, PhpShellAccessDetector, PhpShellOptions, PhpShellScanner, ScanRoot, }; @@ -146,6 +146,7 @@ impl DetectorRegistry { self.php_shell_scanner.flagged_files(), )); self.register(PhpWorkerCrashDetector); + self.register(Php2ShellAttemptDetector); } pub fn detect_log_anomalies(&self, entries: &[LogEntry]) -> Vec { diff --git a/src/detectors/php2shell.rs b/src/detectors/php2shell.rs index 6828ec1..504e907 100644 --- a/src/detectors/php2shell.rs +++ b/src/detectors/php2shell.rs @@ -10,13 +10,29 @@ //! - whether PHP workers keep crashing, which is what memory-corruption //! attempts tend to leave behind (`php.worker-crash`). //! -//! Neither names a client address, so neither ever bans. +//! Neither names a client address, so neither ever bans. A third, +//! `web.php2shell-attempt`, reads the error-log lines the exploit's +//! out-of-memory step leaves behind with a client address, and bans: +//! +//! - Apache mod_php: `[client 203.0.113.9:51234] PHP Fatal error: Allowed +//! memory size of ... exhausted ... in Unknown on line 0`. "Unknown on line +//! 0" means PHP ran out of memory while parsing the request, before any +//! script ran; application out-of-memory errors name a file and line; +//! - nginx in front of php-fpm: the worker exits after that fatal error and +//! nginx logs `upstream sent unexpected FastCGI record ... client: ..., +//! request: "POST ..."`; +//! - the User-Agent of the public scanner, `php2shell-...`. +//! +//! Formats were reproduced against php:8.3-apache and php:8.3-fpm behind +//! nginx with a benign oversized form POST, not taken from exploit traffic. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Mutex, OnceLock}; use std::time::SystemTime; +use regex::Regex; + use crate::detectors::audits::{parse_apk_installed, parse_dpkg_status}; use crate::detectors::php_shell::{under_host_root, ContainerFilesystem}; use crate::detectors::{DetectorFamily, DetectorFinding, LogDetector}; @@ -25,6 +41,11 @@ use crate::sniff::reader::LogEntry; pub const VERSION_DETECTOR_ID: &str = "vuln.php2shell"; pub const CRASH_DETECTOR_ID: &str = "php.worker-crash"; +pub const ATTEMPT_DETECTOR_ID: &str = "web.php2shell-attempt"; + +/// Findings per pass. Each one is an offense, so a client that causes five +/// startup out-of-memory errors is banned in the pass that reads them. +const MAX_ATTEMPT_FINDINGS: usize = 50; const CVES: &str = "CVE-2026-109618, CVE-2026-109619"; @@ -283,6 +304,10 @@ pub fn findings(installs: &[PhpInstall]) -> Vec { 60, ), }; + let description = format!( + "{}. Until then, session.upload_progress.enabled = 0 closes the confirmed php-fpm path", + description + ); findings.push(DetectorFinding { detector_id: VERSION_DETECTOR_ID.into(), @@ -338,6 +363,111 @@ impl LogDetector for PhpWorkerCrashDetector { } } +/// Requests that run PHP out of memory before any script runs, and the known +/// scanner, with the client address the error or access log records. +pub struct Php2ShellAttemptDetector; + +impl LogDetector for Php2ShellAttemptDetector { + fn id(&self) -> &'static str { + ATTEMPT_DETECTOR_ID + } + + fn family(&self) -> DetectorFamily { + DetectorFamily::Web + } + + fn detect(&self, entries: &[LogEntry]) -> Vec { + // Identical lines are separate requests: an exploit burst over one + // keep-alive connection logs the same nginx line within one second. + // The registry drops findings with identical samples, so repeats are + // numbered to count as the offenses they are. + let mut seen: HashMap = HashMap::new(); + + entries + .iter() + .filter_map(|entry| { + let reason = attempt_reason(&entry.line)?; + let sample = client_only(&entry.line); + let count = seen.entry(sample.clone()).or_insert(0); + *count += 1; + let sample_line = if *count > 1 { + format!("{} (repeat {})", sample, count) + } else { + sample + }; + Some(DetectorFinding { + detector_id: ATTEMPT_DETECTOR_ID.into(), + family: DetectorFamily::Web, + description: format!("PHP2Shell attempt ({}): {}", CVES, reason), + severity: AnomalySeverity::Critical, + confidence: 90, + sample_line, + }) + }) + .take(MAX_ATTEMPT_FINDINGS) + .collect() + } +} + +fn attempt_reason(line: &str) -> Option<&'static str> { + let lower = line.to_ascii_lowercase(); + + if lower.contains("allowed memory size of") + && lower.contains("in unknown on line 0") + && lower.contains("[client ") + { + return Some("PHP ran out of memory parsing the request, before any script ran"); + } + if lower.contains("upstream sent unexpected fastcgi record") + && lower.contains("request: \"post ") + { + return Some("php-fpm worker died on a POST before answering"); + } + if access_log_user_agent(&lower).is_some_and(|agent| agent.starts_with("php2shell")) { + return Some("public PHP2Shell scanner user agent"); + } + None +} + +/// The sample a ban reads its target from. Every address in it gets an +/// offense, and an nginx error line also carries `server:` and `host:`, which +/// are this server's own address when a scanner hits the bare IP. Keep the +/// timestamp, the client and the request. +fn client_only(line: &str) -> String { + let field = |name: &str, end: char| { + line.find(name).map(|start| { + let value = &line[start + name.len()..]; + value[..value.find(end).unwrap_or(value.len())].to_string() + }) + }; + match (field("client: ", ','), field("request: \"", '"')) { + (Some(client), Some(request)) => { + let timestamp = line.split(" [").next().unwrap_or_default(); + format!( + "{} upstream sent unexpected FastCGI record, client: {}, request: \"{}\"", + timestamp, client, request + ) + } + _ => line.to_string(), + } +} + +/// The User-Agent of a combined-format access log line: +/// `"POST / HTTP/1.1" 500 5 "-" "agent"`. Only that shape: the last quoted +/// text of any other line can be a file name or a message, and this rule bans. +fn access_log_user_agent(line: &str) -> Option<&str> { + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| { + Regex::new( + r#""(?:get|post|put|patch|delete|head|options) [^"]* http/[0-9.]+" \d{3} (?:\d+|-) "[^"]*" "([^"]*)""#, + ) + .expect("valid regex") + }); + re.captures(line) + .and_then(|captures| captures.get(1)) + .map(|agent| agent.as_str()) +} + /// php-fpm: `[pool www] child 123 exited on signal 11 (SIGSEGV) after ...` /// (php-src sapi/fpm/fpm/fpm_children.c). Apache: `child pid 123 exit signal /// Segmentation fault (11)` (httpd os/unix/unixd.c, AH00051/AH00052). @@ -510,6 +640,78 @@ mod tests { assert!(found[0].description.contains("8.3.36")); } + // Lines captured from the lab: php:8.3-apache, and nginx:alpine in front + // of php:8.3-fpm, memory_limit = 4M, one benign 24 MB form POST. + const APACHE_STARTUP_OOM: &str = "[Sat Oct 31 16:40:23.085328 2026] [php:error] [pid 18:tid 18] [client 203.0.113.9:55324] PHP Fatal error: Allowed memory size of 4194304 bytes exhausted (tried to allocate 2097152 bytes) in Unknown on line 0"; + const APACHE_APP_OOM: &str = "[Sat Oct 31 16:40:23.305094 2026] [php:error] [pid 20:tid 20] [client 198.51.100.7:55342] PHP Fatal error: Allowed memory size of 4194304 bytes exhausted (tried to allocate 67112960 bytes) in /var/www/html/oom.php on line 1"; + const NGINX_FPM_STARTUP_OOM: &str = r#"2026/10/31 16:40:22 [error] 22#22: *3 upstream sent unexpected FastCGI record: 3 while reading response header from upstream, client: 203.0.113.9, server: , request: "POST /index.php HTTP/1.1", upstream: "fastcgi://172.19.0.3:9000", host: "127.0.0.1:18080""#; + const FPM_LOG_STARTUP_OOM: &str = "NOTICE: PHP message: PHP Fatal error: Allowed memory size of 4194304 bytes exhausted (tried to allocate 2097152 bytes) in Unknown on line 0"; + + #[test] + fn test_attempts_are_read_from_lab_log_lines() { + let found = Php2ShellAttemptDetector.detect(&crash_entries(&[ + APACHE_STARTUP_OOM, + NGINX_FPM_STARTUP_OOM, + r#"203.0.113.10 - - [31/Oct/2026:16:40:22 +0000] "POST / HTTP/1.1" 500 5 "-" "php2shell-109618""#, + ])); + assert_eq!(found.len(), 3); + assert!(found + .iter() + .all(|finding| finding.detector_id == ATTEMPT_DETECTOR_ID)); + // The nginx line names this server too (`host: "127.0.0.1:18080"`); + // only the client may reach the ban. + for finding in &found[..2] { + assert_eq!( + crate::ip_ban::engine::IpBanEngine::extract_ip_candidates(&finding.sample_line), + vec!["203.0.113.9".to_string()], + "{}", + finding.sample_line + ); + } + } + + #[test] + fn test_ordinary_failures_are_not_attempts() { + let found = Php2ShellAttemptDetector.detect(&crash_entries(&[ + // A script running out of memory hits real users too. + APACHE_APP_OOM, + // php-fpm's own log has no client address. + FPM_LOG_STARTUP_OOM, + r#"2026/10/31 16:40:22 [error] 22#22: *3 upstream sent unexpected FastCGI record: 3 while reading response header from upstream, client: 198.51.100.7, server: , request: "GET /index.php HTTP/1.1""#, + r#"198.51.100.7 - - [31/Oct/2026:16:40:22 +0000] "GET /blog/php2shell-explained HTTP/1.1" 200 5 "-" "Mozilla/5.0""#, + ])); + assert!(found.is_empty(), "{found:#?}"); + } + + #[test] + fn test_scanner_agent_only_from_an_access_log_line() { + let found = Php2ShellAttemptDetector.detect(&crash_entries(&[ + r#"2026-10-31 10:00:00 upload rejected: filename "php2shell-report.pdf" too large (client 198.51.100.7)"#, + r#"198.51.100.7 - - [31/Oct/2026:16:40:22 +0000] "GET /docs/php2shell HTTP/1.1" 200 5 "-" "Mozilla/5.0""#, + ])); + assert!(found.is_empty(), "{found:#?}"); + } + + #[test] + fn test_identical_lines_each_count_through_the_registry() { + // Same second, same connection, same request: what a burst over one + // keep-alive connection logs. The registry must keep all five. + let lines = vec![NGINX_FPM_STARTUP_OOM; 5]; + let anomalies = crate::detectors::DetectorRegistry::default() + .detect_log_anomalies(&crash_entries(&lines)); + let attempts: Vec<_> = anomalies + .iter() + .filter(|anomaly| anomaly.detector_id.as_deref() == Some(ATTEMPT_DETECTOR_ID)) + .collect(); + assert_eq!(attempts.len(), 5); + for attempt in attempts { + assert_eq!( + crate::ip_ban::engine::IpBanEngine::extract_ip_candidates(&attempt.sample_line), + vec!["203.0.113.9".to_string()] + ); + } + } + fn crash_entries(lines: &[&str]) -> Vec { lines .iter() diff --git a/src/sniff/mod.rs b/src/sniff/mod.rs index 65cd265..3d53743 100644 --- a/src/sniff/mod.rs +++ b/src/sniff/mod.rs @@ -637,6 +637,7 @@ fn should_auto_ban(anomaly: &analyzer::LogAnomaly) -> bool { | "web.sqli-probe" | "web.webshell-probe" | "web.php-shell-access" + | "web.php2shell-attempt" | "file.sensitive-access" | "cloud.metadata-ssrf" ) { @@ -1632,6 +1633,58 @@ mod tests { .all(|anomaly| anomaly.detector_id.as_deref() != Some("web.php-shell-access"))); } + #[actix_rt::test] + async fn test_php2shell_attempts_count_one_offense_per_line() { + let orchestrator = SniffOrchestrator::new(memory_sniff_config()).unwrap(); + let engine = IpBanEngine::new( + orchestrator.pool.clone(), + IpBanConfig { + enabled: true, + // High enough that the test records offenses without blocking. + max_retries: 100, + find_time_secs: 300, + ban_time_secs: 60, + unban_check_interval_secs: 60, + trusted_proxy_ranges: vec![], + allowlist_ranges: vec![], + max_bans_per_minute: 0, + protect_local_ips: true, + }, + ); + let line = "[Sat Oct 31 16:40:23.085328 2026] [php:error] [pid 18:tid 18] [client 95.163.183.216:55324] PHP Fatal error: Allowed memory size of 4194304 bytes exhausted (tried to allocate 2097152 bytes) in Unknown on line 0"; + let entries: Vec = (0..5) + .map(|_| reader::LogEntry { + source_id: "apache".into(), + timestamp: Utc::now(), + line: line.into(), + metadata: std::collections::HashMap::new(), + }) + .collect(); + let mut summary = make_summary("unused", AnomalySeverity::Critical); + // Through the registry, as production runs it: identical lines, as a + // burst within one timestamp tick logs them, must each count. + summary.anomalies = orchestrator + .detectors + .detect_log_anomalies(&entries) + .into_iter() + .filter(|anomaly| anomaly.detector_id.as_deref() == Some("web.php2shell-attempt")) + .collect(); + assert_eq!(summary.anomalies.len(), 5); + assert!(summary.anomalies.iter().all(should_auto_ban)); + + orchestrator + .apply_ip_ban(&[], &test_log_source(), &summary, &engine) + .await + .unwrap(); + + // One row per address; five lines count five times. + let since = Utc::now() - chrono::Duration::minutes(5); + let offenses = + find_recent_offenses(&orchestrator.pool, "95.163.183.216", "sniff", since).unwrap(); + assert_eq!(offenses.len(), 1); + assert_eq!(offenses[0].offense_count, 5); + } + #[actix_rt::test] async fn test_php_shell_access_records_offense_except_for_allowlisted() { let orchestrator = SniffOrchestrator::new(memory_sniff_config()).unwrap(); diff --git a/web/package.json b/web/package.json index f4571c8..535d65a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "stackdog-web", "description": "Stackdog Security Web Dashboard", - "version": "0.2.8", + "version": "0.2.9", "scripts": { "start": "cross-env REACT_APP_VERSION=$npm_package_version webpack serve --mode development", "build": "cross-env REACT_APP_VERSION=$npm_package_version webpack --mode production", diff --git a/website/app/docs/page.tsx b/website/app/docs/page.tsx index b2bb6c5..925f2ee 100644 --- a/website/app/docs/page.tsx +++ b/website/app/docs/page.tsx @@ -8,7 +8,7 @@ import { buildDocsStructuredData, toJsonLd } from '@/lib/structured-data'; const installCurl = 'curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash'; const installPinned = - 'curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.8'; + 'curl -fsSL https://raw.githubusercontent.com/trydirect/stackdog/main/install.sh | sudo bash -s -- --version v0.2.9'; const installDocker = `docker run --rm -it \\ --name stackdog \\ --network host \\ diff --git a/website/app/page.tsx b/website/app/page.tsx index 4cb2976..2e998df 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -212,7 +212,7 @@ export default function HomePage() {
- Stackdog Security v0.2.8 + Stackdog Security v0.2.9

AI-Powered Security for Docker Containers & Linux Servers

diff --git a/website/app/php-shell-detection/page.tsx b/website/app/php-shell-detection/page.tsx index 67036fc..b671b39 100644 --- a/website/app/php-shell-detection/page.tsx +++ b/website/app/php-shell-detection/page.tsx @@ -70,6 +70,11 @@ const php2shellRows = [ 'php.worker-crash', 'Three or more php-fpm or Apache workers dying on SIGSEGV, SIGBUS, SIGABRT or SIGILL in one log source within one pass. One or two per pass never alert, so a single flaky extension stays quiet.', 'Alert. Repeated crashes are what attempts against PHP memory bugs leave behind.' + ], + [ + 'web.php2shell-attempt', + 'A request that runs PHP out of memory before any script runs (Apache "in Unknown on line 0", or nginx "upstream sent unexpected FastCGI record" on a POST to php-fpm), or the public scanner user agent', + 'Alert and an offense per line. A client causing five within the ban window is banned in the same pass.' ] ] as const; @@ -373,16 +378,22 @@ export default function PhpShellDetectionPage() { file scan or log rule can fix it: the fix is upgrading PHP. Stackdog reads the PHP version on the host and in every running container, from the package database and from the{' '} PHP_VERSION variable of official images, and tells you which ones are behind. - It also watches for PHP workers that keep crashing. Neither finding names an attacker, so - neither bans, and Stackdog does not detect the exploit request itself. + It watches for PHP workers that keep crashing, and it bans clients whose requests run PHP + out of memory before any script runs, the step every known PHP2Shell variant relies on. + The exploit can succeed in a few requests, so the ban mostly stops repeat attempts and + scanners; upgrading stays the fix. Rare innocent causes exist (a giant form on a PHP with a + tiny memory_limit, a buggy extension crashing php-fpm on an upload), which is why a ban + takes five within five minutes from one address.

Covered: Debian and Ubuntu (php8.x-common), Alpine (php8x-common) and - official php images. RPM-based hosts are not read yet. A shell an attacker - drops after getting in is still caught by the file scan above. + official php images. RPM-based hosts are not read yet. Attempts are read from + web server error logs: container logs on their own, host logs once listed in{' '} + STACKDOG_LOG_SOURCES. A shell an attacker drops after getting in is still + caught by the file scan above.

diff --git a/website/app/php-shell-quickstart/page.tsx b/website/app/php-shell-quickstart/page.tsx index 8d781d0..53df6d4 100644 --- a/website/app/php-shell-quickstart/page.tsx +++ b/website/app/php-shell-quickstart/page.tsx @@ -24,7 +24,7 @@ STACKDOG_PHPSHELL_PATHS=/var/www STACKDOG_PHP_VERSION_CHECK_ENABLED=true # Access logs the bans are read from, and PHP error logs for worker crashes. # 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,/var/log/apache2/error.log,/var/log/php8.3-fpm.log +STACKDOG_LOG_SOURCES=/var/log/nginx/access.log,/var/log/apache2/access.log,/var/log/httpd/access_log,/var/log/apache2/error.log,/var/log/nginx/error.log,/var/log/php8.3-fpm.log # Bans: 5 offenses in 5 minutes, banned for 30 minutes STACKDOG_IP_BAN_ENABLED=true @@ -109,7 +109,7 @@ const faqEntries = [ { question: 'Which release do I need?', answer: - 'v0.2.7 or later for PHP shell detection, v0.2.8 or later for the PHP2Shell version check. The installer picks the latest release by default.' + 'v0.2.7 or later for PHP shell detection, v0.2.8 or later for the PHP2Shell version check, v0.2.9 or later for PHP2Shell attempt bans. The installer picks the latest release by default.' } ]; diff --git a/website/components/Footer.tsx b/website/components/Footer.tsx index 8b7d5ae..d74c65d 100644 --- a/website/components/Footer.tsx +++ b/website/components/Footer.tsx @@ -92,7 +92,7 @@ export default function Footer(_: FooterProps) {

Release

    -
  • Version 0.2.8
  • +
  • Version 0.2.9
  • MIT Licensed