Repository navigation
appsec: mitigate PHP2Shell on unpatched PHP (CVE-2026-109618/109619) - #4383
cataphract wants to merge 29 commits into
Conversation
|
ef9d585 to
89bfe1e
Compare
Benchmarks [ appsec ]Benchmark execution time: 2026-11-03 13:33:02 Comparing candidate commit f9adaa5 in PR branch Found 0 performance improvements and 2 performance regressions! Performance is the same for 10 metrics, 0 unstable metrics.
|
|
Fwiw: this does nothing if has_prev, and the profiler does install its own forwarding handlers. |
Benchmarks [ tracer ]Benchmark execution time: 2026-11-03 14:20:53 Comparing candidate commit f9adaa5 in PR branch Found 1 performance improvements and 2 performance regressions! Performance is the same for 190 metrics, 1 unstable metrics.
|
89bfe1e to
cc09c06
Compare
|
Is there a specific reason you went with an attempt at emulating the full fix partially, rather than just going with the emulation of the minimal fix? With the way you emulated it, it's not possible to get an OOM during input parsing, but it does not prevent any delayed OOM, e.g. bringing heap size to 127.9M and then just nudging it over the limit at a controlled place (like an array operation in runtime code - e.g. if you simply have a wordpress installation, an attack can just whitebox that). |
bedd9cf to
aae407b
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Critical request-lifecycle defects can prevent later FrankenPHP traces from being force-kept and leave dangling request-heap pointers after failed startup.
2 open issues
What changed in this PR
Adds WAF-controlled PHP2Shell mitigation for vulnerable PHP runtimes, integrating memory-error deferral, request-state cleanup, telemetry, and trace reporting into AppSec.
Changes:
- Adds mitigation configuration, runtime vulnerability signaling, and WAF-driven activation.
- Integrates request lifecycle protection, monitoring, span tags, and early telemetry exports.
- Adds regression fixtures, worker and profiler integration coverage, and CI documentation updates.
This pull request requires changes. Two critical issues remain:
- Critical —
appsec/src/extension/request_lifecycle.c:641: Reset the ASM-emission latch before evaluating each worker HTTP request’s WAF data. Otherwise, a previous request’s WAF event prevents later PHP2Shell reporting from force-keeping the correct trace. - Critical —
ext/sidecar.c:995: Early application registration retains request-heap service and environment strings in tracer globals. Failed startup can bypass their cleanup, leaving dangling pointers released during the next request. Avoid populating these caches on the early path or clear them during failed-startup teardown.
| File | Description |
|---|---|
zend_abstract_interface/alloc.h |
Detects the EOL minimal-fix symbol. |
metadata/supported-configurations.json |
Registers the mitigation configuration. |
ext/telemetry.h |
Declares the public telemetry flush API. |
ext/telemetry.c |
Exports the telemetry flush implementation. |
ext/sidecar.h |
Declares early application registration. |
ext/sidecar.c |
Implements early application registration. |
ddtrace-extension.sym |
Exposes both new tracer APIs. |
appsec/tests/integration/src/test/www/frankenphp/run.sh |
Configures worker mitigation tests. |
appsec/tests/integration/src/test/www/frankenphp/public/index.php |
Registers worker test endpoints. |
appsec/tests/integration/src/test/www/base/public/php2shell_sink.php |
Reports startup parsing and memory usage. |
appsec/tests/integration/src/test/www/base/public/php2shell_runtime.php |
Drives runtime parsing scenarios. |
appsec/tests/integration/src/test/www/base/public/php2shell_runtime_oom.php |
Drives runtime allocation failures. |
appsec/tests/integration/src/test/www/_handlers/src/Php2ShellWorkerHandler.php |
Reports worker identity and memory. |
appsec/tests/integration/src/test/www/_handlers/src/Php2ShellOomHandler.php |
Triggers worker allocation failures. |
appsec/tests/integration/src/test/www/_handlers/src/Php2ShellHoldHandler.php |
Retains memory across worker requests. |
appsec/tests/integration/src/test/bin/enable_extensions.sh |
Enables profiler allocation hooks. |
appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/docker/PhpFpm.groovy |
Supports capturing profiler startup logs. |
appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/docker/AppSecContainer.groovy |
Mounts and enables profiler artifacts. |
appsec/tests/integration/scripts/prepare-rust-libc-compat.sh |
Prepares AArch64 profiler ABI compatibility. |
appsec/tests/extension/php2shell_mitigation_waf_sticky.phpt |
Tests decisions across successive requests. |
appsec/tests/extension/php2shell_mitigation_waf_startup_parse.phpt |
Tests startup parsing under each policy. |
appsec/tests/extension/php2shell_mitigation_waf_runtime.phpt |
Tests runtime policy and span tags. |
appsec/tests/extension/php2shell_mitigation_waf_request_parse_body.phpt |
Tests body-parsing policy and tags. |
appsec/tests/extension/php2shell_mitigation_waf_no_answer.phpt |
Tests missing answers and AppSec gating. |
appsec/tests/extension/php2shell_mitigation_waf_monitor_out_of_memory.phpt |
Tests monitoring of OS allocation failures. |
appsec/tests/extension/php2shell_mitigation_waf_monitor_module_rinit.phpt |
Tests monitoring during module initialization. |
appsec/tests/extension/php2shell_mitigation_waf_monitor_after_arming.phpt |
Tests monitor decisions after request arming. |
appsec/tests/extension/php2shell_mitigation_waf_decisions.phpt |
Tests decision replacement and preservation. |
appsec/tests/extension/php2shell_mitigation_waf_address.phpt |
Tests vulnerability address emission. |
appsec/tests/extension/php2shell_mitigation_use_zend_alloc_0.phpt |
Tests operation without Zend MM accounting. |
appsec/tests/extension/php2shell_mitigation_status.phpt |
Tests status and hook reporting. |
appsec/tests/extension/php2shell_mitigation_sapi_off.phpt |
Tests excluded SAPIs against CGI behavior. |
appsec/tests/extension/php2shell_mitigation_sapi_deactivate_clear_last_error.phpt |
Tests clearing late last-error state. |
appsec/tests/extension/php2shell_mitigation_runtime_php70.phpt |
Tests PHP 7.0 runtime limitations. |
appsec/tests/extension/php2shell_mitigation_runtime_pending_dropped.phpt |
Tests pending-error cleanup at request end. |
appsec/tests/extension/php2shell_mitigation_runtime_parse_str.phpt |
Tests runtime parse_str() deferral. |
appsec/tests/extension/php2shell_mitigation_runtime_hard_limit.phpt |
Tests runtime hard-limit enforcement. |
appsec/tests/extension/php2shell_mitigation_rinit_after_failed_startup.phpt |
Tests configuration after failed startup. |
appsec/tests/extension/php2shell_mitigation_rfc1867_off.phpt |
Tests upload progress with mitigation disabled. |
appsec/tests/extension/php2shell_mitigation_request_parse_body.phpt |
Tests deferred body-parsing failures. |
appsec/tests/extension/php2shell_mitigation_request_parse_body_os_refusal.phpt |
Tests finite limits after parsing bailouts. |
appsec/tests/extension/php2shell_mitigation_request_parse_body_no_user_code.phpt |
Tests suppression of parsing callbacks. |
appsec/tests/extension/php2shell_mitigation_post_deactivate_reset.phpt |
Tests request-owned engine-global resets. |
appsec/tests/extension/php2shell_mitigation_post_deactivate_hook.phpt |
Tests reset lifecycle and gating. |
appsec/tests/extension/php2shell_mitigation_patched_detection.phpt |
Checks detection against fixed releases. |
appsec/tests/extension/php2shell_mitigation_oom_cgi.phpt |
Tests calibrated startup parsing failures. |
appsec/tests/extension/php2shell_mitigation_on.phpt |
Tests forced activation. |
appsec/tests/extension/php2shell_mitigation_off.phpt |
Tests explicit deactivation. |
appsec/tests/extension/php2shell_mitigation_multipart_header_lines.phpt |
Tests stopping oversized multipart headers. |
appsec/tests/extension/php2shell_mitigation_multipart_header_lines_rpb.phpt |
Tests multipart header stopping at runtime. |
appsec/tests/extension/php2shell_mitigation_multipart_files_after_crossing.phpt |
Tests multipart parts after limit crossing. |
appsec/tests/extension/php2shell_mitigation_multipart_file_part_name.phpt |
Tests startup multipart name headroom. |
appsec/tests/extension/php2shell_mitigation_multipart_file_part_name_rpb.phpt |
Tests runtime multipart name headroom. |
appsec/tests/extension/php2shell_mitigation_minfo.phpt |
Tests mitigation information rows. |
appsec/tests/extension/php2shell_mitigation_memory_limit_below_chunk.phpt |
Tests limits below Zend chunk size. |
appsec/tests/extension/php2shell_mitigation_interrupt_chain.phpt |
Tests chaining of interrupt handlers. |
appsec/tests/extension/php2shell_mitigation_input_filter_parse_str.phpt |
Tests filter rewriting and null lengths. |
appsec/tests/extension/php2shell_mitigation_input_filter_off.phpt |
Tests filtering while mitigation is disabled. |
appsec/tests/extension/php2shell_mitigation_input_filter_displaced_drop.phpt |
Tests dropped-variable forwarding behavior. |
appsec/tests/extension/php2shell_mitigation_hook_sandbox.phpt |
Tests deferral outside tracer sandboxes. |
appsec/tests/extension/php2shell_mitigation_hook_error_cb_opcache.phpt |
Tests error-hook ordering with OPcache. |
appsec/tests/extension/php2shell_mitigation_hook_displaced_opcache_preload.phpt |
Tests temporary preload hook displacement. |
appsec/tests/extension/php2shell_mitigation_hook_displaced_after_startup.phpt |
Tests displaced-hook reporting and behavior. |
appsec/tests/extension/php2shell_mitigation_hard_limit_post_max_size.phpt |
Tests input-bound parsing headroom. |
appsec/tests/extension/php2shell_mitigation_hard_limit_post_max_size_user_ini.phpt |
Tests bounds after .user.ini changes. |
appsec/tests/extension/php2shell_mitigation_gc_mem_caches.phpt |
Tests garbage collection while armed. |
appsec/tests/extension/php2shell_mitigation_gated_disabled.phpt |
Tests startup AppSec-disable gating. |
appsec/tests/extension/php2shell_mitigation_gated_disabled_upload_progress.phpt |
Tests upload progress with AppSec disabled. |
appsec/tests/extension/php2shell_mitigation_gate_trace_disabled_startup.phpt |
Tests startup tracer-disable gating. |
appsec/tests/extension/php2shell_mitigation_gate_rinit_disabled.phpt |
Tests request-time disable gating. |
appsec/tests/extension/php2shell_mitigation_gate_remcfg_follow.phpt |
Tests remote-config activation gating. |
appsec/tests/extension/php2shell_mitigation_gate_fcgi_env_enabled.phpt |
Tests startup disable remaining final. |
appsec/tests/extension/php2shell_mitigation_filter_default_warning.phpt |
Tests expanding-filter warnings. |
appsec/tests/extension/php2shell_mitigation_deferred_warning_limit.phpt |
Tests finite limits during warning callbacks. |
appsec/tests/extension/php2shell_mitigation_deferral_not_carried_over.phpt |
Tests deferral isolation between requests. |
appsec/tests/extension/p2s_stub/CMakeLists.txt |
Builds the test-only extension. |
appsec/tests/extension/inc/php2shell_user_ini/child.php |
Reports effective per-directory limits. |
appsec/tests/extension/inc/php2shell_user_ini/.user.ini |
Lowers the fixture’s POST bound. |
appsec/tests/extension/inc/php2shell_child.php |
Reports request status and parsed variables. |
appsec/tests/extension/inc/php2shell_child_waf.php |
Reports memory-error span tags. |
appsec/tests/extension/inc/php2shell_child_waf_helper.php |
Drives mock WAF decision exchanges. |
appsec/tests/extension/inc/php2shell_child_status.php |
Reports soft and hard limits. |
appsec/tests/extension/inc/php2shell_child_runtime_parse_str.php |
Drives calibrated runtime string parsing. |
appsec/tests/extension/inc/php2shell_child_runtime_alloc.php |
Drives allocation and shutdown scenarios. |
appsec/tests/extension/inc/php2shell_child_rpb.php |
Drives body parsing with options. |
appsec/tests/extension/inc/php2shell_child_rpb_os_refusal.php |
Forces OS refusal during body parsing. |
appsec/tests/extension/inc/php2shell_child_rpb_ob_callback.php |
Allocates from deferred-warning output callbacks. |
appsec/tests/extension/inc/php2shell_child_rpb_no_user_code.php |
Tracks error and session callbacks. |
appsec/tests/extension/inc/php2shell_child_rpb_filter.php |
Reports filter storage after deferral. |
appsec/tests/extension/inc/php2shell_child_pending_dropped.php |
Leaves an error pending at script end. |
appsec/tests/extension/inc/php2shell_child_parse_str_filter.php |
Reports string and environment filtering. |
appsec/tests/extension/inc/php2shell_child_minimal.php |
Exercises sub-chunk memory limits. |
appsec/tests/extension/inc/php2shell_child_minfo.php |
Reports SAPI and hook installation. |
appsec/tests/extension/inc/php2shell_child_interrupt_chain.php |
Exercises signal-handler interrupt chaining. |
appsec/tests/extension/inc/php2shell_child_hook_sandbox.php |
Crosses limits inside a tracer hook. |
appsec/tests/extension/inc/php2shell_child_gc_mem_caches.php |
Exercises armed garbage collection. |
appsec/tests/extension/inc/php2shell_child_filter_input.php |
Reports raw and filtered request values. |
appsec/tests/extension/inc/php2shell_child_filter_count.php |
Checks filter storage consistency. |
appsec/tests/extension/inc/php2shell_child_carry_over.php |
Exercises consecutive-request deferral isolation. |
appsec/src/extension/request_lifecycle.c |
Applies remote-config and worker-request policy. |
appsec/src/extension/php2shell/p2s_startup_parse.h |
Defines startup parsing interfaces. |
appsec/src/extension/php2shell/p2s_stale_state.h |
Defines stale-state cleanup interfaces. |
appsec/src/extension/php2shell/p2s_runtime.h |
Defines request-level protection interfaces. |
appsec/src/extension/php2shell/p2s_request_parse_body.h |
Defines body-parsing hook interfaces. |
appsec/src/extension/php2shell/p2s_report.h |
Defines memory-error reporting interfaces. |
appsec/src/extension/php2shell/p2s_report.c |
Adds span tags and trace-retention requests. |
appsec/src/extension/php2shell/p2s_policy.h |
Defines activation and WAF decision policy. |
appsec/src/extension/php2shell/p2s_monitor.h |
Defines native-error monitoring interfaces. |
appsec/src/extension/php_compat.h |
Adds older-PHP handler-type compatibility. |
appsec/src/extension/ddtrace.h |
Declares resolved early telemetry APIs. |
appsec/src/extension/ddtrace.c |
Resolves the new tracer exports. |
appsec/src/extension/ddappsec.h |
Declares once-guarded configuration refresh. |
appsec/src/extension/ddappsec.c |
Integrates mitigation lifecycle and status. |
appsec/src/extension/configuration.h |
Declares mitigation settings and disable checks. |
appsec/src/extension/configuration.c |
Implements explicit-disable checks. |
appsec/src/extension/commands/request_init.c |
Sends the vulnerability address to WAF. |
appsec/src/extension/commands_helpers.c |
Processes returned mitigation decisions. |
appsec/recommended.json |
Adds the default mitigation rule and action. |
appsec/helper-rust/src/service/updateable_waf.rs |
Assigns unique WAF build generations. |
appsec/helper-rust/src/service.rs |
Pairs request contexts with generations. |
appsec/helper-rust/src/client/protocol.rs |
Tests decision response serialization. |
appsec/cmake/run_tests.cmake |
Adds the test extension dependency. |
.gitlab/generate-appsec.php |
Adds profiler integration CI coverage. |
.claude/ci/appsec-native-tests.md |
Updates documented native-test toolchain versions. |
Review effort: Balanced
7990a55 to
6453152
Compare
The appsec native test instructions in .claude/ci/appsec-native-tests.md told readers to install libc++-17-dev and libc++abi-17-dev in the bookworm-11 CI images. Those packages no longer exist there, so the documented apt-get step exits with status 100 and the build never starts. Use the -21 packages that .gitlab/generate-appsec.php installs in CI, and the matching clang-tidy-21 name in the notes about the lint job. The troubleshooting note now names the CI generator as the place to look up the version, so the instructions are easier to keep in step with the next bump. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tracer flushes buffered user-request telemetry metrics when a
request finishes, after registering the telemetry application
(service/env) on the sidecar connection in its own RINIT. An extension
that needs to report something at a point the tracer never reaches --
during request startup, before the tracer's RINIT, or right before the
process calls _exit() -- has no way to get its metric points delivered:
the request-finish flush does not run, and a plain buffer flush made
before the application is registered is dropped by the sidecar ("No
application found").
The appsec PHP2Shell mitigation (next commit) is such a case: it can
end a request during startup with a deferred out-of-memory error, or
terminate the worker with _exit(137) when the hard memory limit is
crossed, and its counters would otherwise be lost.
Export two functions so that another extension can resolve them with
dlsym() and flush synchronously to the sidecar, which outlives the
worker:
- datadog_telemetry_commit_user_request_metrics() already existed; it
is now DATADOG_PUBLIC. The extension builds with hidden visibility,
so without that a dlsym() from appsec cannot find it.
- datadog_sidecar_ensure_telemetry_application() is new. It registers
(or refreshes) the telemetry application on the sidecar connection
with the configured service, env and version, as
datadog_sidecar_rinit() does. set_application does nothing when the
application is unchanged, so calling it again later in the request
is harmless.
Both are listed in ddtrace-extension.sym. The flush is a bounded,
blocking IPC write; the only intended callers are rare failure paths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6453152 to
e0eeca1
Compare
CVE-2026-109618: on unpatched PHP, _zend_hash_str_add_or_update_i() bumps a hash table's entry counters before allocating the key. If that allocation crosses memory_limit, the engine longjmps out of the insertion, and request shutdown later walks the counted but uninitialised bucket as a zval. Request variables are parsed before any userland runs and land in superglobals, which shutdown always destroys, so the request alone reaches it; parse_str() and other char*-key builders reach it at runtime. CVE-2026-109619: request-owned engine globals are left pointing into the freed request heap after a fast shutdown, and early code in the next request can read them. Upstream fixed both in 8.2.35/8.3.36/8.4.27/8.5.12, with an engine-wide soft limit that defers the error to a safe point, a hard limit that exits, and resets of the stale state. Customers on older patch levels stay exposed, and an extension cannot patch the engine, so appsec mitigates the parts it can reach. While the heap is armed, our Zend MM handlers (chained over any existing ones, such as the profiler's) serve every allocation and the engine limit is lifted to a sentinel, so no insertion is abandoned. Crossing the soft limit (memory_limit) makes the error pending instead. Two parses get a window of their own: the startup treat_data parse of POST, GET and COOKIE data before RINIT, and request_parse_body() on 8.4+. In a window an input_filter wrapper drops later variables, a php_rfc1867_callback wrapper skips later file parts at FILE_START and a read_post wrapper ends a multipart body, so growth stops with the item in flight; when the parse returns, the arrays are complete and the error is raised as a normal E_ERROR under a finite limit. A window's hard limit bounds the in-flight item: min(max(L, S + k*B + 8 MiB), S + cap), with L upstream's own limit (2S, or S + 1 GiB from 1 GiB), B the input bound of the parse, k = 6 per input byte, or 11 for a multipart body, and a constant cap of max(1 GiB, 2S), or 4S for multipart. On PHP 7.1+ the whole request is armed too, from the end of appsec's RINIT to the start of its RSHUTDOWN. There a pending error is raised at the next VM interrupt, after the inner interrupt handlers ran, the way max_execution_time is delivered, and the hard limit is max(L, S + 8 MiB). Making the affected allocations fallible, as upstream does, would need engine changes that cannot reach unpatched runtimes. Above any hard limit, or before a fresh huge allocation would cross it, the process ends with _exit(137) rather than run cleanup over a runaway heap. The handlers follow one rule: install once at a fixed point, chaining the previous handlers; remove at the mirrored point, only if the slot still holds ours; in between only flags change. That keeps the order LIFO with the profiler, which installs in RINIT and removes in RSHUTDOWN, in either module order. Swapping handlers in and out mid-request, or removing them where they were not installed, broke that order and made later requests recurse. When a bailout skips our RSHUTDOWN, post_deactivate and, after a failed request startup, MSHUTDOWN reset the heap to plain zend_mm, because the profiler's own fallback has already run by then. "Installed" means "in the chain": with another extension on top we stay chained under it, we never chain over ourselves, and before arming without installing we check the slot. The handlers also put the sentinel back whenever something writes the engine limit directly (php_error_cb() before every fatal, appsec's own restore) and adopt a changed memory_limit as the new soft limit, so runtime changes are followed however they happen; an INI on_modify hook was dropped because it only saw changes while it sat on top of the chain. Below 8.4, gc_mem_caches() is wrapped as the profiler does, since zend_mm_gc() does nothing on a heap with custom handlers. For CVE-2026-109619, post_deactivate resets the request-owned EG/PG fields, the object store and the superglobal slots that upstream resets; a sapi_module.deactivate wrapper releases the last-error strings after every extension's post_deactivate; and session upload progress is turned off for the startup POST parse, so the session callback cannot decode session data before RINIT. Every hook is captured once, from the zend_extension startup hook after all MINITs. A read-only check per request logs once and shows in MINFO if another extension displaces one, though it cannot tell a chaining wrapper from a replacement. No hook is installed at all when the configuration seen at startup rules the mitigation out: appsec or the tracer explicitly disabled, the mode off, the mode auto on a patched runtime, or the cli, phpdbg and embed SAPIs, which carry no attacker-controlled request data. A php-fpm pool therefore cannot re-enable what the master disabled; we accept that rather than intercept the parses of every process that will never use it. Otherwise the mitigation is gated on an unpatched runtime (the fix's _femalloc symbol does not resolve; on forces it on a patched one), on DD_APPSEC_PHP2SHELL_MITIGATION (auto by default), and on a process-wide flag that follows appsec's own state, from RINIT and remote config. Two metrics, the deferred error (tagged with the phase: startup, request_parse_body or runtime) and the hard exit, are flushed synchronously to the sidecar, because the request dies in startup or the process exits before the normal flush. The code is split by function under appsec/src/extension/php2shell/: the out-of-memory guard (handlers, limits, deferral), the startup-parse, request_parse_body() and runtime protections, the stale-state resets and the activation policy. Each unit owns its hooks, state, MINFO rows and testing functions; php2shell_mitigation.c is a facade sequencing the lifecycle and holding the design overview and the coverage list. The guard's per-thread state and the allocation hot path stay in one file, so no allocation crosses a unit. Every unit includes ddappsec.h, so that on ZTS builds of PHP 8 the engine's TSRMLS_CACHE keeps appsec's local-dynamic TLS model and ddappsec.so is not marked STATIC_TLS. Coverage limits are documented in the source, grouped by consequence: what stays unprotected (FrankenPHP's per-request re-import, USE_ZEND_ALLOC=0, OS allocation refusals, code outside appsec's RINIT..RSHUTDOWN), where the gate does not follow the expected configuration, which cases end in a 137 rather than the deferred fatal (the caps, very large max_input_vars, an expanding filter.default, a memory_limit lowered below usage on old patch releases, a skipped RSHUTDOWN on 7.x), and the observable differences from stock PHP. Tests cover the windows and the request level in php-cgi and php -S children (the mitigation is off under the cli test process), red on the code before each fix; a p2s_stub test extension exercises the lifecycle fallbacks (bailouts in RINIT and RSHUTDOWN, a neighbour chaining over our handlers). The integration tests check the metrics, and a dedicated CI job runs the mitigation next to the profiler with allocation profiling on, across requests and in both module orders. Alternatives considered and rejected: arming only around insertions (no hook marks the end of a registration); arming individual runtime functions (no closed list, and the VM's own array writes are unreachable); hot-patching zend_hash (per-version reimplementations in live code, blocked by W^X policies); a separate opt-in mode for the request level; and honouring per-directory datadog.appsec.enabled per request, which appsec itself does not do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PHP2Shell mitigation (CVE-2026-109618/109619) was armed on every unpatched runtime as soon as the extension loaded. Whether it should be active, and whether it should only observe or actually intervene, is a decision we want to take from the backend, per runtime vulnerability, without a new tracer release. The ruleset is already the channel the backend uses to steer appsec, so the activation is expressed as a WAF rule. The extension now sends the address php.php2shell.vulnerable in every request_init: "full" on an unpatched runtime, "partial" on the EOL branches carrying only the minimal fix (detected by the exported zend_mm_safe_error_ex). A patched runtime sends nothing and stays off. The default ruleset gains a rule on that address, in the waf module with its own type (so its own collection), whose action is a custom, non-blocking php2shell_mitigation action with a mode parameter (monitor or block). The helper evaluates the address in a separate WAF run with no timeout before running the rest of the request data with the normal timeout, so the decision can never be lost to a timeout. The decision is cached per client, keyed on the ruleset generation and the address value, so the extra run is skipped while the ruleset is unchanged. The rule's event and action are stripped from the normal results (no trigger, no record, no keep, no WAF metrics), and the request_init response carries an explicit decision (none, monitor or block) in its settings map whenever the WAF ran; an absent field means "no information". The extension keeps a process-wide sticky state, armed initially: "none" disarms it, "monitor" disarms it but detects native memory fatals, "block" arms it as before; no decision (helper unreachable, WAF error, appsec inactive) leaves it unchanged. DD_APPSEC_PHP2SHELL_ MITIGATION=on still forces block behaviour without asking, and off is still a hard off. Monitor mode wraps zend_error_cb to recognise the "Allowed memory size" and "Out of memory" fatals as zend_mm raises them, tagging the root span with _dd.appsec.php2shell.memory_error and _dd.appsec.php2shell.phase. A wrapper rather than an error observer, which PHP 7.x lacks; on PHP 8.0 it is installed after opcache's own wrapper. Block mode tags deferred fatals with _dd.appsec.php2shell.deferred. Any reported memory error keeps the trace with user-keep priority, as an appsec event would. Alternatives rejected: a libddwaf non-expiring module would have needed libddwaf changes for one rule; putting the rule in authentication-acl would let its single shared collection shadow real ACL rules; a shadow mode computing limits in monitor mode added complexity for little signal; a new metric was unnecessary next to the existing soft_limit_deferred and hard_limit_exit; and sending the address only on the first call would miss ruleset changes delivered later by remote config. Tests: helper unit tests for the separate run, the cache and the stripping; extension .phpt tests for the address, each decision, the sticky state, missing answers, monitor detection in each phase and the opcache interaction; Groovy integration tests against the real helper and ruleset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of c81d7d1 (letting the WAF drive PHP2Shell mitigation activation) found that FrankenPHP worker mode latched the arming decision at the worker's RINIT, that ZTS threads could observe a decision changing mid-request, that errors raised during module RINIT were never reported, and that an unrecognised rule mode was re-evaluated on every request. This commit addresses those findings. W-A: in FrankenPHP worker mode one PHP request spans every HTTP request, while request_init runs per HTTP request in _start_user_req. We now call dd_php2shell_user_req_begin() after request_init there, which reconciles the guard with the current decision: it opens the request level if needed, otherwise only the flags and the engine limit change (handlers stay until the worker's RSHUTDOWN). Following the decision per HTTP request was chosen over keeping the worker-level latch because remote config changes must take effect without restarting workers. Monitor detection now skips our own deferred fatal (DD_P2S_DEFERRED_MARKER), which also fixes a double tag on ZTS. W-11.4: decisions.md 11.4 (worker superglobal re-import out of scope) is superseded: on PHP 7.1+ the re-import is covered with request-level semantics. The FrankenPHP mention is dropped from the "PHP 7.0, after RINIT" coverage entry. W-B: on a partial runtime, memory errors end the process through zend_mm_safe_error_ex() with _exit(1) before any hook runs, so monitor mode cannot report them. This is documented in the coverage list rather than treating monitor as block on partial runtimes or adding limit-checking allocator handlers. W-E: _decision and _allowed are now thread-local (ZEND_TLS, like _arm_state) instead of process-wide. Their sources are already per thread on ZTS (module globals, per-thread helper connection), so a decision only changes at that thread's own request_init. This avoids a per-request snapshot and atomics, and is free on NTS; each thread converges separately. W-F: memory errors are reported whenever a root span exists, whatever the phase (tagged phase:startup), instead of skipping the startup phase, which wrongly included every module RINIT. "No span, no report" still holds by construction. W-G: the helper treats an action mode other than block/monitor as `none` (the extension goes off, as for a ruleset without the rule) and caches it per ruleset generation like any other decision, logging the warning once per generation, instead of returning no decision and re-running the evaluation on every request_init. W-I: decision_ignores_the_waf_timeout is removed: it could never fail, since run_php2shell_rule hard-codes Duration::MAX. Tests: new .phpt cases for monitor detection after arming and during module RINIT, and a FrankenPHP worker integration test (Php2ShellFrankenphpWorkerTests, 8.4 ZTS) that changes the rule's action through remote config and checks the next HTTP request on the same worker follows it, both ways. Accepted without change: W-C (tags on the HTTP span, trace keep on the active root, as for appsec's own events); W-D (the semantics stay: a ruleset without the rule means off; release dependency: the backend ASM_DD rulesets must ship dd-php2shell-001); W-H (PHP2Shell evaluation errors are only logged); W-J (the full/partial detection test stays tolerant); W-K (request_init-before-arming order protected by review). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e0eeca1 to
5b37138
Compare
The second review round of the WAF-driven activation found that, in worker mode, a reconcile that switches the decision from block to monitor while a memory_limit error is pending disarmed the guard and tried to restore memory_limit. Usage was already above that limit, so the restore failed and the engine kept the sentinel limit with nothing checking it: the worker ran with effectively no memory limit for the rest of its run, not only for the documented RSHUTDOWN gap. The first idea was to raise the pending error at reconcile, since request_init has returned by then. That does not work: in FrankenPHP worker mode reconcile runs inside the integration's begin hook closure, and ddtrace runs hook closures in its ZAI sandbox, which swallows any bailout other than a timeout or a block. The deferred fatal was logged as an error in ddtrace's closure and the request ran on. Letting our bailout through the sandbox was tried and rejected: a bailout from a begin hook skips the end hook, so the hooked call's span stays open and the trace is dropped. So reconcile now leaves the guard armed while an error is pending, and the error is raised at a VM interrupt as any other runtime crossing. That exposed an older problem, present since the request level was added and not specific to FrankenPHP: an interrupt that lands while any ddtrace hook closure runs raised the deferred fatal inside the sandbox, after the guard had disarmed and set a finite limit; the sandbox swallowed it, and the request carried on unprotected, where the next native memory_limit error could bail out mid-insertion. _interrupt now does not raise under EH_THROW, which the sandbox sets. It records the refusal; it cannot ask for the interrupt again itself (the VM checks again before resuming, so it would loop), so after a refusal each allocation asks for it, whatever the usage, and the raise happens at the first interrupt outside the closure. Without a refusal nothing changes: code that inner interrupt handlers run, such as pcntl signal handlers, still completes before the raise. Accepted and documented in the coverage list: the raise can come late (code after the closure that does not allocate runs on); raised after the HTTP request's span is finished, as for a FrankenPHP request the WAF blocks, it is not tagged; internal functions that set EH_THROW around userland callbacks delay it the same way. dd_p2s_guard_raise is renamed dd_p2s_guard_raise_deferred_oom, and comments that named appsec's RINIT as the only place the request level is installed now also mention the start of a worker HTTP request. Tests: a FrankenPHP worker step holds usage near memory_limit, switches block to monitor and crosses the limit while appsec reads the next request's body, expecting the deferred fatal (without the fix the request runs on with no limit); a .phpt checks that an error pending inside a tracer hook closure is raised after it (red before); the monitor-module-rinit test and its helpers learn the runtime kind, since the minimal fix of the EOL branches exits on a native memory error. The reds of the worker test and of the helper test unknown_mode_gives_none_and_is_cached were run against the previous revision. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Running the PHP2Shell tests on the fixed runtimes master's integration images now pin (8.2.35, 8.3.36, 8.4.27, 8.5.12), and on a PHP 7.1 build carrying the minimal fix of the EOL branches (php-src 1d7dec178), showed failures that come from the tests, not from the mitigation. The integration tests forced mode on only in the php-fpm pool's env[]. The hooks are installed, or not, once from the master's configuration (dd_p2s_policy_startup_may_hook), where the mode was still auto, so on a patched runtime nothing was hooked and every case got the native fatal. That is the documented behaviour (a pool cannot re-enable what the master ruled out), and it is the configuration a user who wants on needs, so the tests now pass the mode to the master's environment too rather than skipping on patched runtimes, which would leave master's images without any mitigation coverage. On a partial runtime the engine's memory_limit error goes through zend_mm_safe_error_ex and exits, and rfc1867 keeps only two headers per part. The WAF behaviour-table tests (runtime, startup parse, sticky decision) expect the unpatched engine in their native rows, and the multipart header-lines vector no longer exists there, so these four tests skip on a partial runtime (p2s_skip_if_partial, detected with p2s_runtime_kind). php2shell_mitigation_rinit_after_failed_startup.phpt hung on 7.1 debug in the integration images: php -S never shuts down a request whose startup failed, the next request's shutdown reports its allocations as leaks, and the leak checker of PHP 7.0-7.3.16 debug builds can loop forever (fixed upstream in 45b4368d5c; the dd-trace-ci images patch it, the integration images do not). Its server now runs with report_memleaks=0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Benchmarking the mitigation on Laravel and WordPress under php-fpm (PHP 7.1.33 and 8.4.0, unpatched) showed block mode costs 2.4-4.8% more server CPU per request, almost all of it in the allocator path: every armed allocation goes through our Zend MM handlers, which then call zend_memory_usage(true) through the PLT to compare usage with the soft and hard limits. _usage() now reads real_size from the heap head, the same private layout _heap_limit() already relies on. _arm() checks once per arm that the layout holds (the armed heap is the current one, the head's limit is the sentinel just written, and real_size equals zend_memory_usage(true)), in release builds too; if any check fails, _usage() keeps calling zend_memory_usage(). _check_limit is forced inline, which only pays off once the call is gone: alone, neither change was distinguishable from noise, together they removed 18-21% of block mode's overhead on WordPress 8.4 and 25-36% on 7.1, with every benchmark round improving. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_heap_limit() is called on every armed allocation. It copied the heap's limit field out with memcpy, and the fortified headers of the musl-clang build turn that into an overlap check plus a stack canary: in the profiles of the benchmark, about a third of _dd_mm_malloc's self time. A plain load through the dd_mm_heap_head layout, which _usage() already reads real_size through, does the same without it, and removed about 5-9% of block mode's overhead on WordPress 8.4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a419743 to
8ec91d4
Compare
memprof replaces the request heap with its own in its RINIT, and forwards to the real one. When its RINIT runs before ours, which is the order its README suggests (extension=memprof.so ahead of ddappsec), the guard arms that heap: the sentinel limit is written there, its usage reads 0, and the real heap keeps its finite limit, so a crossing at runtime takes the engine's own memory-limit error, the vulnerable path. memprof turns on per request from MEMPROF_PROFILE in the query string or the body, so any client can switch the protection off. Snuffleupagus does not coexist with the mitigation either. Its upload hook keeps one variable for both "saved original" and "already hooked", so it re-wraps our rfc1867 callback and the two recurse on a multipart request; and its INI protection turns our runtime toggle of session.upload_progress.enabled into a bailout. memprof is a diagnostic profiler, not something to run in production, and Snuffleupagus is rare enough, so rather than arming under them we rule the mitigation out when either is loaded: a small table of incompatible extensions in dd_p2s_policy_startup_may_hook() means no hook is installed, a warning is logged once and MINFO names the extension. Every extension= and zend_extension= is registered before the zend_extension startup hook that makes this decision, so the load order does not matter. Arming per request only when memprof did not swap the heap, or arming the real heap under memprof's, would keep some protection but depend on memprof's internals. Likewise, a cycle guard in our upload callback and a different way to toggle upload progress would have kept the mitigation armed under Snuffleupagus, but both only work around its internals, and the guard would have to be generalized to every chained hook to be robust. The list has no test: it is a plain name lookup, and a fake module registered under each name would cost more than it checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The deferred fatal disarms the guard and sets a finite engine limit before it bails out, on the assumption that the bailout ends the request. The compatibility study found code that catches it and carries on: ext/soap turns a fatal raised inside a SoapClient call that runs user code into a SoapFault, parallel goes on to the next task of the same runtime, and agents' sandboxes do the same. The rest of such a request runs disarmed under max(memory_limit, usage at the raise + 8 MiB), so a later crossing takes the engine's own error, the vulnerable path. Under parallel, the later tasks do not get the error either; they just run unarmed. Re-arming when an allocation shows the request survived its fatal would keep the protection, but it needs telling a swallowed fatal from the shutdown functions that legitimately run after a real one, which PHP 7.1 and 7.2 cannot. The gap is documented in the coverage list instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sub-mitigation B (CVE-2026-109619) reset EG(objects_store) from ddappsec's post_deactivate, mirroring upstream, which does it in shutdown_executor(). A module whose post_deactivate runs after ours (one registered before ddappsec) then crashed if it released a request object there. A fast shutdown (every release build) takes no reference on objects with the default free_obj, so dropping the last reference reaches zend_objects_store_del(), which from PHP 7.3 writes to the bucket array without checking it for NULL. Elastic APM 1.11.0-1.17.0 releases the exception and the error backtrace it recorded in its post_deactivate, so with Elastic loaded first every request that recorded an error with objects in its backtrace ended in a SIGSEGV (a 502 under php-fpm), in mode on and in mode auto on unpatched PHP 7.3+. Nothing in this is specific to Elastic: a test module that keeps an exception until its post_deactivate crashes the same way. Nor is the pattern valid on fixed PHP: there the engine resets the store before every post_deactivate, so Elastic 1.17.0 crashes on PHP 8.3.36 with no Datadog extension loaded, which is why Elastic 1.17.1 moved the release to RSHUTDOWN. Detecting and excluding the affected Elastic versions would therefore leave every other such module crashing, so the reset is moved instead. The object store reset now runs from the sapi_module.deactivate wrapper, which sapi_deactivate_module() calls after every post_deactivate and before shutdown_memory_manager(). The bucket array becomes dangling only when the heap is reset, after that call, so deferring the reset opens no window in which a stale pointer can be read. The wrapper resets only when our post_deactivate left the reset pending and the executor is shut down: FrankenPHP worker mode calls sapi_deactivate() at every HTTP-request boundary while the worker script still runs, and an unconditional reset there would wipe its live store. When our wrapper is not in the slot, post_deactivate resets the store itself, as before. The other B resets stay in post_deactivate: they only clear slots that stock PHP leaves dangling, so a later post_deactivate is better off seeing them cleared. Debug builds always shut down slowly, which from PHP 7.4 keeps a reference on every object, so the crash cannot happen there. The new test therefore also has the stub report whether the store had been reset when its post_deactivate ran, which makes it fail without this change on debug builds too. Up to 7.3, a slow shutdown takes no such reference: shutdown_executor() frees the object storage and destroys the store (object_buckets = NULL) before any post_deactivate, so the stub's late release would assert in zend_objects_store_del() on stock PHP as well. On such a shutdown the stub leaks the object instead; the store_reset report, printed first, still catches a reset done in ddappsec's post_deactivate, and fast shutdowns, the real crash, still release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A zend_error_cb that returns without chaining for E_ERROR (Pinpoint's, when error_reporting or @ masks the type) makes zend_error() return. On PHP 7.0-7.3 zend_error_noreturn() is only an alias of zend_error(), so our ZEND_NORETURN raise of the deferred out-of-memory error fell off its end and the process crashed (SIGSEGV); from 7.4 zend_error_noreturn aborts instead (SIGABRT). Stock zend_mm_safe_error survives such callbacks because it calls zend_bailout() itself after raising. Raise the same way: zend_error(E_ERROR, ...) and then an explicit zend_bailout(). This covers any swallowing callback on every version, rather than detecting particular agents or masking error_reporting around the raise, and keeps the function NORETURN for its callers. p2s_stub gains p2s_stub.error_cb_swallow, a callback that prints and swallows E_ERROR; the new test raises a runtime deferral under it and expects the request to end without a crash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The request level raises a deferred out-of-memory error at the next VM interrupt, after chaining to the inner zend_interrupt_function. Inner handlers such as Excimer's timer callbacks and pcntl's async signal handlers run PHP code from there. If an interrupt fires while that code runs, our wrapper is entered again, chains to the inner handler again, and so on: each level runs over memory_limit, and the raise is starved until the hard limit ends the process with _exit(137). A per-thread flag now marks the inner call. Re-entered with an error pending (outside a window and not under the tracer's EH_THROW sandbox, where it is still refused), _interrupt raises at once. With no error pending it chains as before. The flag is restored after the call, and also in a zend_catch that bails out again, so a fatal from the inner handler or from our own nested raise does not leave it set; RINIT, each worker-mode request and post_deactivate reset it as a fallback. Raising before chaining whenever an error is pending at entry was the other option. It would skip the inner handlers for the interrupt that the crossing itself requested, and pcntl, ddtrace and the profiler are expected to see it (interrupt_chain.phpt); only the re-entrant case needs to change. A new p2s_stub switch provides an inner handler with a PHP callback that requests the interrupt again, for the test. Like the handler, its functions and their arginfo exist only from PHP 7.1, so that the stub still builds with -Werror on 7.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Swoole 6.2's swoole.leak_detection re-hooks the Zend MM heap on every allocation, which drops our per-request handlers while the engine stays at our sentinel. Swoole only runs on CLI-like SAPIs, all of which we already exclude except phpmicro's "micro", a self-contained CLI binary that, like cli, serves no attacker-controlled request. Add "micro" to the excluded SAPIs, so the mitigation is off there in every mode. Hardening the runtime to re-check the handler chain on each arm was rejected: the overlap is a single CLI-only SAPI where the mitigation has nothing to protect, and the check would cost every request on every other SAPI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hook slot found replaced after our startup hook was reported as a PHP
warning, through appsec's default log sink (PHP error reporting), so
with display_errors it landed in the response. It was often false: New
Relic's zend_error_cb, Swow/OpenSwoole interrupt hooks and the
profiler's gc_mem_caches() wrapper (loaded after ddappsec) all chain to
ours. Telling "wraps and chains" from "replaced" is not possible in
general, so the report is reworded instead ("replaced after startup by
another extension; ... unless that extension chains to ours"), goes to
the appsec log only and is skipped when that log is PHP error
reporting. MINFO says "Replaced" rather than "Displaced".
The profiler also replaces the handler of gc_mem_caches() (PHP
7.1-8.3) from its startup hook. When that ran before ours, our wrapper
is no longer installed: a handler outside the engine's binary (checked
with dladdr) is left alone. The profiler's turns use_custom_heap off
only with allocation profiling on, so with CPU/wall-time profiling only
gc_mem_caches() frees nothing while a request is armed; this was
accepted and is in the coverage list. Loaded after us, the profiler
wraps our wrapper and chains to it, which already works, so that order
needs nothing.
p2s_stub replaces gc_mem_caches() the same way for the new test; that
helper is used on every PHP version, so it is declared outside the
stub's PHP 7.1+ block.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After every deferred PHP2Shell runtime fatal, usage is above
memory_limit when appsec's request shutdown puts memory_limit back
after lifting it. The restore failed, so the engine kept ZEND_LONG_MAX
for the rest of shutdown (later RSHUTDOWNs, session write, output
teardown) and appsec logged "Failed to restore memory limit in request
shutdown" at error level, which display_errors shows in the response.
Now, when the configured limit no longer fits, the engine gets the
same finite limit the guard sets on a deferred raise (usage + 8 MiB),
computed by a helper shared with _set_finite_limit. In appsec's
RSHUTDOWN this is logged at debug and nothing more: no user code runs
after it, and before PHP 8.1 one zend_try covers every module's
RSHUTDOWN (php-src cf6c354e1f0), so a bailout would skip the later
ones. Skipping the restore after a deferred error would have left the
shutdown unbounded; keeping the error log would have kept the noise on
every deferred fatal. PG(memory_limit) keeps the configured value, as
the guard does: it outlives the request when the INI entry was not
changed at runtime, so the fallback would have become the next
request's limit.
When a user request of a worker script using ddtrace's user-request
API ends (_do_request_finish_user_req), the same fallback is set and
the E_ERROR "Failed to restore memory limit after AppSec request
shutdown" is still raised, ending the worker, as a fixed PHP's delayed
OOM does. php_error_cb() resets the engine limit to PG(memory_limit),
which before 7.4.21, and on 8.0, succeeds below usage, so that the
allocator's limit - real_size check wraps and nothing limits the
shutdown after the fatal; PG(memory_limit) therefore holds the
fallback while the error is raised and gets the configured value back
before the bailout goes on. Keeping the worker alive under the
fallback was rejected: no php_request_shutdown() runs between HTTP
requests, so the fallback, recomputed from usage at the end of each,
would ratchet up by up to 8 MiB per request, and capping it needs
per-thread state, a re-apply at every user request and two more
tests, for a case upstream handles by ending the worker.
On PHP 8.1.4+, zend_set_memory_limit() frees cached chunks when the
limit fits only without them, and returns SUCCESS without writing the
limit, so a restore would count as done while the engine kept
ZEND_LONG_MAX. _set_memory_limit() calls the setter a second time
after a success; real usage fits by then, so that call writes it. A
zend_mm_gc() before the call would also work, but frees more than
needed on every restore.
The new php2shell test checks that no restore line is shown and that
an allocation in a later RSHUTDOWN (a p2s_stub switch) still fails; on
a partial runtime the engine's own memory error ends the process before
the stub can report, so that error, in its release and its debug form
("at file:line" before "(tried to allocate"), counts too. A second one
reaches the cached-chunk case through p2s_stub: blocks it frees in its
RSHUTDOWN leave one chunk cached, and an allocation in its
post_deactivate must still fail, which it does not without the second
setter call. The user request test expects the fatal and then a memory
error from a shutdown function, checked last because on a partial
runtime (EOL minimal fix) a memory error ends the process with
_exit(1); fatal_error_backtraces=0 drops PHP 8.5's trace.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The deferred out-of-memory fatal and its log line printed the soft limit in force at the raise, not the one that was crossed. memory_limit can change in between: appsec's shutdown raise to ZEND_LONG_MAX (e.g. in a FrankenPHP worker's request finish) or an ini_set() run while the raise is refused inside the tracer's sandbox. _follow_limit then moves the soft limit, and users saw "Allowed memory size of 9223372036854775807 bytes exhausted". Record the soft limit when the error becomes pending and print that one. It is stored only on the crossing, so the per-allocation path does no extra work. It is not re-stored when the raise is refused: the error is already pending then, and the soft limit may have moved since. Printing PG(memory_limit) saved before appsec's shutdown raise was rejected: it would only cover that one path, not ini_set(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The php2shell tests assumed an unfixed engine, and CI only has those: "partial" was reached through the testing knob alone. On real fixed builds several tests failed although the mitigation behaved correctly. waf_monitor_out_of_memory expects zend_mm's "Out of memory (allocated" message, which a patched or partial engine never delivers. The minimal EOL fix disables session upload progress, which breaks the three upload-progress tests, and on 8.0/8.1 it clears the last error in php_request_shutdown, so the two clear_last_error tests do not test our wrapper there. post_deactivate_order crashes on a partial engine with the mode off too: the engine itself resets the object store before post_deactivate. These now skip on patched or partial runtimes as appropriate. patched_detection compared the detection with a release table both ways, but distros backport the full fix below upstream's first fixed release (Ubuntu noble 8.3.6). It now fails only when a release at or past the first fixed one is not detected as patched. A gate is only trustworthy if something exercises it, so a new pair of jobs runs the php2shell tests under Ubuntu jammy's php8.1, a real partial build that needs no third-party repository. The extension, p2s_stub, mock_helper and ddtrace.so are built in the bookworm 8.1 NTS image (same API number, non-debug), with libc++ linked statically into mock_helper since jammy lacks libc++ 21. ddtrace.so is built there rather than taken from the compile jobs: those are in the package child pipeline, and needs cannot reach a sibling pipeline's artifacts. The jobs are allow_failure until they have proven stable. Besides php2shell_*.phpt, the jammy job runs user_req_shutdown_memory_limit_restore_failure.phpt, so that the partial-engine form of the memory_limit fallback is exercised too. The bailout test of the same family is left out: it fails on partial engines regardless of this branch, since the engine writes the memory fatal to stderr whatever display_errors says. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The compatibility review of the PHP2Shell mitigation found several interactions with other extensions and SAPIs that need no code change but were missing from the coverage list, which is the user-facing compatibility documentation. They are added as short entries: mbstring.encoding_translation, a tracer hook closure that keeps allocating, colopl_bc and hp_uploadprogress_guard ending the process on the error (so apache2handler does not always keep it), Xdebug displacing the monitor's error callback, profilers using the old handler setter, the synthetic profiler recursion, plus one-line notes on FrankenPHP's force-kill, NGINX Unit, the OPcache JIT, parallel and coroutine runtimes. Session upload progress is now described as off inside request_parse_body() too, which the code already did. Comments and the coverage list said "FrankenPHP worker mode" for the per-HTTP-request arming, but it runs for every worker script that uses ddtrace's user-request API (FrankenPHP, RoadRunner and Ratchet); they now say so, noting that only FrankenPHP has integration tests. The "partial" runtime wording tied it to the 8.0/8.1 EOL branches, but distributions ship the minimal fix down to 7.0; the comments now say so, and that the patch state is read from the binary, not PHP_VERSION. mbstring's encoding translation allocates about 32 bytes per '&' in one call before max_input_vars, beyond the hard limit's allowance, so a body of '&' ends in _exit(137) instead of the deferred fatal. Raising k for that case or disabling the mitigation were rejected: the setting is off by default and the heap stays safe. Instead, as for an expanding filter.default, one warning is logged per process when a window arms with it on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit said per-HTTP-request arming applies to worker scripts using ddtrace's user-request API, naming FrankenPHP, RoadRunner and Ratchet. RoadRunner workers and Ratchet servers run under the CLI SAPI, where the mitigation is off and installs nothing, and PHP never parses their HTTP requests: the Go server or the PHP library does. The only worker mode where per-request arming applies is FrankenPHP's, so say that, and note in the coverage list why the other two are out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the minimal PHP2Shell fix of the EOL branches (e.g. Ubuntu jammy's php8.1), an allocation over memory_limit ends the process with _exit(1) instead of bailing out. The stub's post_deactivate allocation therefore cannot report a bailout there, and the test failed although the restored limit still bounded it. On a partial runtime the test now accepts the engine's exit as the same proof: status 1 and its "Allowed memory size" error for the 64 MiB allocation, naming a finite limit below it. Full runtimes still require the stub to see the bailout. Found by the new partial-runtime CI job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Disarming the PHP2Shell guard restored memory_limit with a bare zend_set_memory_limit() and ignored the result. With usage above the limit, as after a pending error dropped at disarm, that fails on PHP 7.4.21+ and 8.1+ and leaves the sentinel engine limit, or is accepted below usage on older versions and on 8.0, where the allocator's check then wraps. Either way nothing limited the rest of the request. appsec's request shutdown repaired this right after, with its own restore that falls back to usage + 8 MiB, but it does not run when appsec's RSHUTDOWN is skipped: a bailout in an earlier RSHUTDOWN before PHP 8.1 (the guard is then disarmed from post_deactivate) or the FPM block-in-RINIT path that sets skip_rshutdown. Move that restore out of request_lifecycle.c into the guard as dd_p2s_guard_restore_limit (dd_php2shell_restore_limit in the facade): the below-usage check before 8.1, the second setter call after 8.1.4+ frees cached chunks, and the usage + 8 MiB fallback. _disarm() with DD_DISARM_RESTORE_LIMIT and both request_lifecycle.c callers now use it, so every restore ends with the configured limit or a finite one, never the sentinel. The callers keep their behaviour: request shutdown ignores the result, the user-request worker path still raises on it. A narrower fix, setting the finite limit only when _disarm()'s restore fails, was not chosen: it would keep two copies of the version-specific setter workarounds that have to stay in sync. The new test bails out in a test extension's RSHUTDOWN after it pushed usage to 20M under memory_limit=16M, so ddappsec's RSHUTDOWN is skipped (PHP < 8.1) and post_deactivate drops the pending error. A 64M allocation from a new p2s_stub sapi_module.deactivate hook, after every post_deactivate, used to succeed on 7.4.33 and 8.0.30; it now gets a memory error at about usage + 8 MiB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
php_error_cb() resets the engine limit to PG(memory_limit) before it bails out of any fatal. After a PHP2Shell deferred fatal the usage is above memory_limit. Before 7.4.21, and on 8.0, the setter accepts a limit below the usage. On 7.2+ the allocator's limit - real_size check then wraps, so shutdown functions and output callbacks run with no limit at all. On 8.0 a shutdown function allocated 200 MiB under memory_limit=16M. On 7.0/7.1 the limit stays below the usage and everything after the fatal fails at once. The worker path in request_lifecycle.c already worked around this inline: it set PG(memory_limit) to the finite limit around the zend_error() call. The deferred raise did not. As decided in E2, there is now one helper for this, dd_p2s_guard_raise_fatal(), exposed from the facade as dd_php2shell_raise_fatal(). When the usage is above PG(memory_limit), it sets PG(memory_limit) to max(limit, usage + 8 MiB). It raises the E_ERROR inside zend_try, so php_error_cb() writes that finite limit. Then it restores PG(memory_limit), which can outlive the request, and calls zend_bailout() itself. The explicit bailout keeps C7's guarantee when a zend_error_cb swallows E_ERROR. The deferred raise passes its usual message, with breached_soft (C17), through a stack buffer, so the raise allocates nothing that a bailout would leak. Callers: the deferred raise, the worker restore failure, the RINIT dd_oom fatal, and the block E_ERROR in request_abort.c. The RINIT fatal runs unarmed today, with the usage within the limit, so the helper only adds the bailout there. It is routed anyway so that arming from the start of RINIT (C23) cannot reintroduce the wrap. The block can run after a deferred fatal, for example a RASP block in a shutdown function, or on a RINIT block after a disarm with the usage above the limit. It keeps its php_verror() docref formatting through a raise callback. Adding a format string to the helper was rejected: the facade would need a va_list variant, and the block's message is already formatted. dd_php2shell_finite_limit() had only the worker path as a user, so it is removed. The new test shows a deferred fatal, then a shutdown function that allocates 200 MiB under memory_limit=16M. It must now get a memory error at usage + 8 MiB. Before this change it succeeded on 8.0 and failed at 16M on 7.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_hard_abort runs inside the allocation that crossed the hard limit, possibly in the middle of a hash insertion, and must end in _exit(137). It recorded its metric through dd_telemetry_add_metric, which also logs the point. At debug log level with display_errors on, that log line is displayed, which runs a user output callback; a callback that fails bails out of the half-done allocation, so the process ran the shutdown functions on a corrupt heap and exited with status 255 instead. The hard-exit path now calls datadog_metric_register_buffer and datadog_metric_add_point directly, which is what dd_telemetry_add_metric does minus the log line, and then flushes and exits as before. _emit_and_flush takes a flag for this; the deferred raise keeps logging, as a bailout is fine there. A thread-local "logging off" switch set at the top of _hard_abort was considered, but it would affect every log call in the window rather than the one that matters. A zend_try around the emit and flush was not added either: it would only catch the bailout after the user callback had already run. The new test runs a chunked output callback that raises E_USER_ERROR at debug log level and expects status 137 with neither the callback nor the shutdown function having run (status 255 before the change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
appsec's own RINIT work ran unarmed: the guard was opened at the end of RINIT, after the helper exchange. Processing a request_init reply that keeps the trace makes the tracer insert fresh char*-keyed tags (_dd.p.ts, _dd.p.dm) into the root span, the exact insertion the CVE corrupts when a memory_limit bailout lands mid-insertion. An attacker picks that moment with a WAF-flagged request and a large body. On an EOL-partial engine any crossing there instead ends the process with _exit(1). The request level is now armed right after the fully-disabled return, with the previous request's WAF decision (pending arms on a new thread), and the end of RINIT reconciles it with this request's decision and the remote-config gate, as worker mode already does per HTTP request. A crossing anywhere in appsec's RINIT is thus deferred to the script's first VM interrupt. A narrower window around the code that packs user data was rejected: packing does not insert into PHP hashes; the inserts come later, in the reply processing and the block path, so a window covering them is this option with more machinery. Arming earlier needs two removal points, to keep LIFO nesting with a profiler loaded before ddappsec, whose RSHUTDOWN or deactivate hook otherwise finds our handlers on top and gives up on every blocked request: - A block in RINIT runs appsec's request shutdown and the RSHUTDOWNs of earlier modules from inside RINIT. Our handlers are now removed first, as our RSHUTDOWN does; this also keeps appsec's finite memory_limit fallback from being undone by still-armed handlers, and on php -S < 8.1 keeps the blocked request's state from leaking into the next one. - Any other bailout out of the armed part (e.g. the AppSec body parser refusing to allocate) is caught, our handlers removed, and the bailout re-raised. Skipping the reconcile after a block that returns into RINIT (php-fpm, FrankenPHP) was not adopted: the normal RSHUTDOWN removes the handlers in order anyway. To test the real RINIT exchange, a test can now hand a php-cgi child the mock helper's socket (MOCK_HELPER_FD, testing builds only), which makes the automatic request init and shutdown run in testing mode. p2s_stub gains rinit_pad (place usage before appsec's RINIT) and rinit_mm (profiler-like handlers installed in RINIT, removed if on top). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
php -S before PHP 8.1 and NGINX Unit run no shutdown at all after a failed request startup: no RSHUTDOWN, no post_deactivate. If a module whose RINIT runs after ours bails out while the request is armed, the guard's flags survive into the next request, a pending error included. That request's startup parse then runs nested in the stale arming and raises the old error at its end: a deferred fatal for a request that never crossed memory_limit, whatever its own WAF decision. The post_deactivate and MSHUTDOWN fallbacks cover every other SAPI, but neither runs here. ddappsec's zend_extension activate hook, which runs before anything of the request, now clears the request flags and, if the guard was armed, moves the engine off the sentinel to memory_limit, or to a finite limit when the usage no longer fits it (dd_p2s_guard_restore_limit). It only resets: the policy is not resolved yet at that point (no per-directory INI, no .user.ini), and EG(vm_interrupt) is cleared after the activators run, so arming there could lose an interrupt request. The handlers left in the slot stay and only forward until RINIT reuses them. The common case costs one thread-local load per request. The test (php -S; the stub, loaded after ddappsec, crosses memory_limit in its RINIT and then bails out, on the first request only) gets the deferred fatal on the second request before this change on PHP 7.4 and runs its script after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The facade listed PHP 7.0 after RINIT under "Not protected", saying parse_str() and add_assoc_*_ex() still reach the vulnerable zend_hash_str_* insertion unarmed. That overstates it. On 7.0-7.2, _zend_hash_str_add_or_update() allocates the key with zend_string_init() before _zend_hash_add_or_update_i() counts the bucket (php-7.0.33 and php-7.2.34 Zend/zend_hash.c), and nothing between the counter bump and the bucket's initialisation allocates. A bailout there leaves the table unchanged. The bump-before-allocate form of _zend_hash_str_add_or_update_i() first appears in 7.3. The entry moves to "Not addressed" and says so; the CVE paragraph names 7.3+ for the vulnerable function. The same pass checks the overview against this round's commits. Two statements had fallen behind: a fatal error in our RINIT now ends the request level as a block does (the zend_try around the armed part of RINIT), and the hard-exit metric is recorded without a log line. Text only; no behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSHUTDOWN no longer restores the engine memory limit when it finds our handlers still armed. It only gets there after php-cgi or php-fpm failed a request startup, and then the process is exiting: no user code and no further request run, so the limit is irrelevant. It still clears the request flags and resets the heap to plain zend_mm, which the final shutdown_memory_manager() and the profiler's own fallback need. _forget_request() becomes _clear_request_flags(), which says what it does: it clears the per-request bools and leaves the handlers alone. On php-fpm and FrankenPHP a block inside appsec's RINIT returns into it after the block path has already removed our handlers. The end-of-RINIT reconcile then installed and armed them again, lifting the limit appsec's request shutdown had just set, for a request whose script never runs. It is now skipped when skip_rshutdown shows the block path ran. Nothing is lost: the WAF decision was stored when the request_init reply was processed, and the next request arms with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Description
Mitigates the PHP2Shell vulnerabilities (CVE-2026-109618 and CVE-2026-109619) in the appsec extension, for customers running PHP releases older than the upstream fix (8.2.35 / 8.3.36 / 8.4.27 / 8.5.12). Whether it applies to a process is decided by a WAF rule, so it can be switched between block and monitor, or off, from the ruleset.
CVE-2026-109618. On unpatched PHP, a
memory_limiterror raised while a key is being inserted into a hash table (_zend_hash_str_add_or_update_i) bails out mid-insertion, and request shutdown then walks the half-initialised bucket. Request variables are parsed before any userland runs, so the request alone can reach it;parse_str()and otherchar *-key builders reach it at runtime.While the heap is armed, our Zend MM handlers serve every allocation and the engine limit is lifted to a sentinel, so no insertion is abandoned: crossing the soft limit (
memory_limit) makes the error pending instead.treat_dataparse (before RINIT) andrequest_parse_body()(8.4+) get a window of their own. Once the error is pending, aninput_filterwrapper drops later variables, an rfc1867FILE_STARTwrapper skips later file parts and aread_postwrapper ends a multipart body; the error is raised as a normal fatal once the arrays are complete. Hard limit:min(max(L, S + k·B + 8 MiB), S + cap), withLupstream's limit (2S, or S + 1 GiB from 1 GiB),Bthe input bound andk = 6(11 for multipart).max_execution_timeis delivered, but never inside a ddtrace hook closure: the tracer's sandbox would swallow the fatal and the request would run on unprotected. After such a refusal, every allocation asks for the interrupt again until it is raised outside the closure. Hard limit:max(L, S + 8 MiB)._exit(137).Handler lifecycle. Install once at a fixed point, chaining the previous handlers; remove at the mirrored point, only if the slot still holds ours; in between only flags change. That keeps the order LIFO with the profiler's allocation handlers in either module order. When a bailout skips our RSHUTDOWN (or request startup fails),
post_deactivateor MSHUTDOWN reset the heap to plain zend_mm. The handlers also put the sentinel back whenever something writes the engine limit directly (e.g.php_error_cb()before every fatal) and follow runtimememory_limitchanges. Below 8.4,gc_mem_caches()is wrapped as the profiler does.CVE-2026-109619. Request-owned engine globals that upstream resets are reset in
post_deactivate; the last-error strings are released from asapi_module.deactivatewrapper; session upload progress is off during the startup POST parse.Activation by the WAF. Under
DD_APPSEC_PHP2SHELL_MITIGATION=auto(the default), an unpatched runtime sendsphp.php2shell.vulnerablein everyrequest_init:full, orpartialwhen only the minimal fix of the EOL branches is present (zend_mm_safe_error_exexported, no_femalloc). A patched runtime sends nothing and stays off. PHP 8.6 counts as patched: its first release ships the fix, and only its pre-releases (the 8.6.0RC images CI now uses) lack it.dd-php2shell-001, waf module, its own type) matching that address, with a custom non-blocking action (php2shell_mitigation,mode: block|monitor).none|monitor|block. An unknown mode counts asnone.blockarms as above;monitordoes not arm and only detects native memory errors (zend_error_cb);nonedoes not arm. With no decision yet the process is armed; a request with no WAF answer keeps the last decision. In worker scripts using ddtrace's user-request API (FrankenPHP, RoadRunner, Ratchet) each HTTP request follows its own decision; a disarm with an error pending stays armed until the error is raised.onforces the block behaviour on any runtime without asking;offis a hard off._dd.appsec.php2shell.deferred=<phase>; a native memory error seen in monitor mode tags_dd.appsec.php2shell.memory_errorand.phase. Both keep the trace.Gating.
off,autoon a patched runtime) or under thecli,phpdbg,embedandmicroSAPIs, or when memprof or Snuffleupagus is loaded (both replace hooks the mitigation depends on). The mode is read from php-fpm's master: a pool'senv[]cannot re-enable what the master ruled out.on), and appsec's own state (RINIT, remote config).ext/, in a separate commit.Compatibility. A review of extensions that use the same engine hooks, and of distro builds, led to a few changes: the deferred fatal bails out explicitly (an error callback that swallows
E_ERRORcould otherwise return into it on PHP 7.0–7.3); a nested VM interrupt raises a pending error instead of chaining again; the object-store reset runs after every module'spost_deactivate(Elastic APM's crashed on release builds); hooks replaced by another extension are reported in the appsec log only; the fatal names the limit actually exceeded. Appsec's own memory-limit restore at request end now falls back to a finite limit instead of leaving the heap unlimited, and in worker scripts ends the worker, as a fixed PHP does. A safety review against EOL-partial engines led to three more: the hard-limit exit records its metric without a log line (a displayed log line could run a user output callback and bail out mid-insertion); every fatal the mitigation raises leaves a finite limit behind, even wherephp_error_cb's reset would leave none (7.2–7.4.20, 8.0); and every limit restore ends withmemory_limitor a finite limit. The remaining interactions are documented in the coverage list.Release dependency. The first ASM_DD remote-config ruleset replaces the default one, so the backend's rulesets must carry the
dd-php2shell-001rule; without it every process receivesnoneand the mitigation turns off.Code layout. The code is split by responsibility into compilation units under
appsec/src/extension/php2shell/: the out-of-memory guard (handlers, limits, deferral; the allocation hot path stays in this one file), the startup-parse,request_parse_body()and runtime protections, the stale-state resets, the activation policy, monitor detection and span reporting. Each owns its hooks, state, MINFO rows and testing functions.php2shell_mitigation.cis a facade: lifecycle sequencing, the design overview and the coverage list, where the remaining gaps are documented by consequence.Commits:
Testing
On amd64:
.phptsuite on the 22 cells of the CI job "test appsec extension" (debug7.0–8.5,debug-zts7.0–7.3,debug-zts-asan7.4–8.5): 0 failures before the last two commits; after them, the PHP2Shell tests on 8.3 debug, 7.1 debug-zts and 8.5 debug-zts-asan.cargo test, fmt): pass.Php2ShellMitigationTests,Php2ShellProfilerTestswith-PwithProfiler) on 8.3 and 8.5, both on the vulnerable images this branch pins and on the fixed images master now pins (8.3.36, 8.5.12): all pass, skips only where expected. FrankenPHP 8.4 ZTS (worker and classic classes,Php2ShellFrankenphpWorkerTests): pass, including a decision switched block → monitor → block on one worker and a disarm with an error pending..phptsuite through the integration images on PHP 7.1 with php-src 1d7dec178 backported, and on stock 7.1.-O2 -Werrorbuilds on 7.0 and 8.3..phptsuite on 7.0, 7.1-zts, 7.3, 7.4-zts-asan, 8.1, 8.3, 8.4-zts-asan and 8.5-zts-asan; integration on the vulnerable, fixed and 7.1 EOL-partial images (on the last, six memory-limit tests from master fail as they do on master); each commit builds and passes the PHP2Shell tests on its own. The same matrix, plus 8.0 and the jammy EOL-partial job, after the RINIT and safety-review commits..phptand the helper's unknown-mode test.🤖 Generated with Claude Code