From 4450c89f48c5097735939be42d74adb126d2c0fd Mon Sep 17 00:00:00 2001
From: Vasili Pascal
Date: Sat, 31 Oct 2026 19:01:53 +0200
Subject: [PATCH 1/2] feat: ban clients attempting PHP2Shell
Every known PHP2Shell variant pushes PHP past memory_limit with a
multipart POST. web.php2shell-attempt reads the lines that step leaves
with a client address, and bans:
- Apache mod_php: "[client ...] PHP Fatal error: Allowed memory size ...
in Unknown on line 0". "Unknown on line 0" means PHP ran out of memory
while parsing the request, before any script ran. Application OOMs
name a script and line, and are not attempts.
- nginx in front of php-fpm: the worker exits after that fatal error,
and nginx logs "upstream sent unexpected FastCGI record ... client:
..., request: "POST ..."". php-fpm's own log has no client address.
- the public scanner's php2shell-* User-Agent, read only from a
combined-format access line.
Both log formats were reproduced locally against php:8.3-apache and
php:8.3-fpm behind nginx with memory_limit = 4M and a benign 24 MB form
POST, not taken from exploit traffic.
Each line is one offense. Identical lines in a pass are numbered so the
registry does not collapse them, so five in the ban window ban in the
pass that reads them. From nginx lines only the timestamp, client and
request are kept: server: and host: can be this server's own address,
and every address in a sample gets an offense.
The vuln.php2shell alert also names the interim workaround,
session.upload_progress.enabled = 0.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 14 ++
README.md | 37 +++-
src/detectors/mod.rs | 3 +-
src/detectors/php2shell.rs | 206 +++++++++++++++++++++-
src/sniff/mod.rs | 53 ++++++
website/app/php-shell-detection/page.tsx | 19 +-
website/app/php-shell-quickstart/page.tsx | 2 +-
7 files changed, 318 insertions(+), 16 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 06bad5f..cd631b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### 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/README.md b/README.md
index ec05e19..b0bc384 100644
--- a/README.md
+++ b/README.md
@@ -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/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/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..c84b71b 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
From bd08830f627048364a6f71789964efe9c7a35dab Mon Sep 17 00:00:00 2001
From: Vasili Pascal
Date: Sat, 31 Oct 2026 19:02:33 +0200
Subject: [PATCH 2/2] chore: bump version to v0.2.9
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 2 ++
Cargo.toml | 2 +-
DEVELOPMENT.md | 2 +-
README.md | 4 ++--
VERSION.md | 2 +-
docs/INDEX.md | 2 +-
install.sh | 6 +++---
web/package.json | 2 +-
website/app/docs/page.tsx | 2 +-
website/app/page.tsx | 2 +-
website/app/php-shell-quickstart/page.tsx | 2 +-
website/components/Footer.tsx | 2 +-
website/lib/structured-data.ts | 2 +-
13 files changed, 17 insertions(+), 15 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd631b3..ae8f91a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ 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
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 b0bc384..ee5804e 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Stackdog Security
-
+



@@ -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.
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/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-quickstart/page.tsx b/website/app/php-shell-quickstart/page.tsx
index c84b71b..53df6d4 100644
--- a/website/app/php-shell-quickstart/page.tsx
+++ b/website/app/php-shell-quickstart/page.tsx
@@ -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
-