Skip to content

appsec: mitigate PHP2Shell on unpatched PHP (CVE-2026-109618/109619) - #4383

Draft
cataphract wants to merge 29 commits into
masterfrom
glopes/appsec-oom-soft-limit
Draft

cataphract wants to merge 29 commits into
masterfrom
glopes/appsec-oom-soft-limit

Conversation

@cataphract

@cataphract cataphract commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

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_limit error 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 other char *-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.

  • Parse windows. The startup treat_data parse (before RINIT) and request_parse_body() (8.4+) get a window of their own. Once the error is pending, an input_filter wrapper drops later variables, an rfc1867 FILE_START wrapper skips later file parts and a read_post wrapper 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), with L upstream's limit (2S, or S + 1 GiB from 1 GiB), B the input bound and k = 6 (11 for multipart).
  • Request level (PHP 7.1+). The whole request is armed from the start of appsec's RINIT (with the previous request's WAF decision, reconciled with this request's at the end of RINIT) to the start of its RSHUTDOWN, so appsec's own request-startup work is covered too. A pending error is raised at the next VM interrupt, the way max_execution_time is 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).
  • Above a hard limit the process ends with _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_deactivate or 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 runtime memory_limit changes. 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 a sapi_module.deactivate wrapper; 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 sends php.php2shell.vulnerable in every request_init: full, or partial when only the minimal fix of the EOL branches is present (zend_mm_safe_error_ex exported, 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.

  • The default ruleset carries a rule (dd-php2shell-001, waf module, its own type) matching that address, with a custom non-blocking action (php2shell_mitigation, mode: block|monitor).
  • The helper evaluates the address first, in its own run with no timeout (skipped and answered from a cache while the ruleset and the value are unchanged), strips the rule from the normal results (no event, no trace keep, no WAF metrics) and returns the decision none|monitor|block. An unknown mode counts as none.
  • The extension keeps the decision per thread: block arms as above; monitor does not arm and only detects native memory errors (zend_error_cb); none does 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.
  • on forces the block behaviour on any runtime without asking; off is a hard off.
  • A deferred fatal tags the root span _dd.appsec.php2shell.deferred=<phase>; a native memory error seen in monitor mode tags _dd.appsec.php2shell.memory_error and .phase. Both keep the trace.

Gating.

  • No hook at all when the configuration at startup rules it out (appsec or the tracer explicitly disabled, mode off, auto on a patched runtime) or under the cli, phpdbg, embed and micro SAPIs, 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's env[] cannot re-enable what the master ruled out.
  • Otherwise it follows the WAF decision (or on), and appsec's own state (RINIT, remote config).
  • Two metrics (soft-deferred, tagged with its phase, and hard-exit) are flushed synchronously to the sidecar; that needs two small exports in 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_ERROR could 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's post_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 where php_error_cb's reset would leave none (7.2–7.4.20, 8.0); and every limit restore ends with memory_limit or 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-001 rule; without it every process receives none and 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.c is a facade: lifecycle sequencing, the design overview and the coverage list, where the remaining gaps are documented by consequence.

Commits:

  1. docs: libc++-21 for the appsec native tests (matches CI).
  2. ext: exports so another extension can flush telemetry to the sidecar early.
  3. appsec: the mitigation, its tests, and a CI job for the profiler integration test.
  4. appsec: WAF-driven activation (address, rule, helper decision, monitor mode, span tags).
  5. appsec: review fixes for the activation (FrankenPHP worker mode per HTTP request, per-thread decision, unknown mode).
  6. appsec: a worker disarm keeps a pending error; no raise inside the tracer's hook sandbox.
  7. appsec: the PHP2Shell tests on fixed and EOL-partial PHP.
  8. appsec: two hot-path reads in the guard (heap usage and the engine limit read directly).
  9. appsec: compatibility fixes, one commit per decision (memprof/Snuffleupagus/micro off, object-store reset order, explicit bailout, interrupt re-entry, replaced-hook reports, finite restore fallback, breached-limit message, test gates and a CI job on a real EOL-partial engine, docs).
  10. appsec: arming from the start of appsec's RINIT, and the fixes from the EOL-partial safety review.

Testing

On amd64:

  • Full appsec .phpt suite on the 22 cells of the CI job "test appsec extension" (debug 7.0–8.5, debug-zts 7.0–7.3, debug-zts-asan 7.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.
  • Helper unit tests (cargo test, fmt): pass.
  • Integration (Php2ShellMitigationTests, Php2ShellProfilerTests with -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.
  • EOL minimal fix: the .phpt suite through the integration images on PHP 7.1 with php-src 1d7dec178 backported, and on stock 7.1.
  • Lint (clang-format/clang-tidy 21.1.8) clean; Alpine/musl GCC -O2 -Werror builds on 7.0 and 8.3.
  • After the compatibility commits: the full .phpt suite 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.
  • Red/green: each regression test added during review failed on the code before its fix and passed after it, e.g. the FrankenPHP worker cases, the hook-sandbox .phpt and the helper's unknown-mode test.

🤖 Generated with Claude Code

@datadog-prod-us1-4

datadog-prod-us1-4 Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Tests  Pipelines

⚠️ Warnings

Your PR has warnings. Please review the issues below.

❄️ 1 New flaky test detected

e_waf_activation: under auto, the PHP2Shell rule of the ruleset decides block, monitor or off() from com.datadog.appsec.php.integration.Php2ShellMitigationTests
hp?alloc=58720256), (http.method, GET), (span.kind, server), (http.useragent, Java-http-client/17.0.20.1), (error.type, E_ERROR), (error.message, Allowed memory size of 33554432 bytes exhausted (tried to allocate 58720288 bytes)), (error.stack, #0 /var/www/public/php2shell_runtime_oom.php(9): str_repeat()
#1 {main}), (_dd.appsec.s.req.cookies, [[],{&#34;len&#34;:0}]), (_dd.appsec.fp.http.header, hdr-0100000000-d1d7834f-3-c81f0d70), (_dd.appsec.fp.http.endpoint, http-get-750a29c5-bcd3b184-), (_dd.appsec.s.res.headers, [{&#34;x-powered-by&#34;:[[[8]],{&#34;len&#34;:1}]}]), (_dd.appsec.s.req.params, [[[8]],{&#34;len&#34;:1}]), (_dd.appsec.s.req.body, [[],{&#34;len&#34;:0}]), (_dd.appsec.s.req.headers, [{&#34;upgrade&#34;:[8],&#34;user-agent&#34;:[8],&#34;connection&#34;:[8],&#34;host&#34;:[8],&#34;http2-settings&#34;:[8],&#34;x-datadog-trace-id&#34;:[8]}]), (_dd.appsec.fp.http.network, net-0-0000000000), (_dd.appsec.s.req.query, [{&#34;alloc&#34;:[8]}]), (_dd.appsec.event_rules.version, 1.18.0), (_dd.runtime_family, php), (http.client_ip, 192.168.0.1), (http.request.headers.user-agent, Java-http-client/17.0.20.1), (http.endpoint, /{param:str}), (_dd.p.ksr, 1), (_dd.p.dm, -1), (http.status_code, 500), (env, integr. Expression: log1.contains(Allowed memory size of $WAF_LIMIT bytes exhausted ($DEFERRED_SUFFIX)

java.lang.AssertionError: expected the deferred fatal; got: [31-Oct-2026 11:54:23 UTC] PHP Fatal error:  Allowed memory size of 33554432 bytes exhausted (tried to allocate 58720288 bytes) in /var/www/public/php2shell_runtime_oom.php on line 9
[31-Oct-2026 11:54:23 UTC] [ddtrace] [span] [753] Encoding span: Span { service: appsec_int_tests, name: web.request, resource: GET /php2shell_runtime_oom.php, type: web, trace_id: 12200891684856157375, span_id: 15076670652330072093, parent_id: 0, start: 1793447663419911022, duration: 3022727, error: 1, meta: VecMap { data: [(_dd.sdk.otlp_export, false), (runtime-id, f5e6ffb2-6c09-4e1f-b4de-c0f2b54feb69), (http.url, http://docker:32774/php2shell_runtime_oom.php?alloc=58720256), (http.method, GET), (span.kind, server), (http.useragent, Java-http-client/17.0.20.1), (error.type, E_ERROR), (error.message, Allowed memory size of 33554432 bytes exhausted (tried to allocate 58720288 bytes)), (error.stack, #0 /var/www/public/php2shell_runtime_oom.php(9): str_repeat()
#1 {main}), (_dd.appsec.s.req.cookies, [[],{&#34;len&#34;:0}]), (_dd.appsec.fp.http.header, hdr-0100000000-d1d7834f-3-c81f0d70), (_dd.appsec.fp.http.endpoint, http-get-750a29c5-bcd3b184-), (_dd.appsec.s.res.headers, [{&#34;x-powered-by&#34;:[[[8]],{&#34;len&#34;:1}]}]), (_dd.appsec.s.req.params, [[[8]],{&#34;len&#34;:1}]), (_dd.appsec.s.req.body, [[],{&#34;len&#34;:0}]), (_dd.appsec.s.req.headers, [{&#34;upgrade&#34;:[8],&#34;user-agent&#34;:[8],&#34;connection&#34;:[8],&#34;host&#34;:[8],&#34;http2-settings&#34;:[8],&#34;x-datadog-trace-id&#34;:[8]}]), (_dd.appsec.fp.http.network, net-0-0000000000), (_dd.appsec.s.req.query, [{&#34;alloc&#34;:[8]}]), (_dd.appsec.event_rules.version, 1.18.0), (_dd.runtime_family, php), (http.client_ip, 192.168.0.1), (http.request.headers.user-agent, Java-http-client/17.0.20.1), (http.endpoint, /{param:str}), (_dd.p.ksr, 1), (_dd.p.dm, -1), (http.status_code, 500), (env, integr. Expression: log1.contains(Allowed memory size of $WAF_LIMIT bytes exhausted ($DEFERRED_SUFFIX)
	at org.codehaus.groovy.runtime.InvokerHelper.createAssertError(InvokerHelper.java:416)
	at com.datadog.appsec.php.integration.Php2ShellMitigationTests$_e_waf_activation__under_auto,_the_PHP2Shell_rule_of_the_ruleset_decides_block,_monitor_or_off_closure5.doCall(Php2ShellMitigationTests.groovy:272)
	at com.datadog.appsec.php.integration.Php2ShellMitigationTests$_e_waf_activation__under_auto,_the_PHP2Shell_rule_of_the_ruleset_decides_block,_monitor_or_off_closure5.doCall(Php2ShellMitigationTests.groovy)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
...

View in Flaky Test Management

ℹ️ Info

No other issues found (see more)

🧪 All tests passed

🎯 Code Coverage (details)
• Patch Coverage: 79.15%
• Overall Coverage: 66.09% (-10.56%)

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 105da59 | Docs | View more details | Give us feedback!

@cataphract
cataphract force-pushed the glopes/appsec-oom-soft-limit branch 2 times, most recently from ef9d585 to 89bfe1e Compare October 27, 2026 10:37
@pr-commenter

pr-commenter Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Benchmarks [ appsec ]

Benchmark execution time: 2026-11-03 13:33:02

Comparing candidate commit f9adaa5 in PR branch glopes/appsec-oom-soft-limit with baseline commit aae7184 in branch master.

📊 Benchmarking dashboard

Found 0 performance improvements and 2 performance regressions! Performance is the same for 10 metrics, 0 unstable metrics.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

scenario:LaravelBench/benchLaravelOverhead-appsec

  • 🟥 execution_time [+458.625µs; +714.475µs] or [+3.340%; +5.204%]

scenario:WordPressBench/benchWordPressOverhead-appsec

  • 🟥 execution_time [+1.749ms; +2.019ms] or [+5.239%; +6.046%]

@bwoebi

bwoebi commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

Fwiw: this does nothing if has_prev, and the profiler does install its own forwarding handlers.

@pr-commenter

pr-commenter Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Benchmarks [ tracer ]

Benchmark execution time: 2026-11-03 14:20:53

Comparing candidate commit f9adaa5 in PR branch glopes/appsec-oom-soft-limit with baseline commit aae7184 in branch master.

📊 Benchmarking dashboard

Found 1 performance improvements and 2 performance regressions! Performance is the same for 190 metrics, 1 unstable metrics.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

scenario:ComposerTelemetryBench/benchTelemetryParsing-opcache

  • 🟩 execution_time [-1.842µs; -0.358µs] or [-10.466%; -2.034%]

scenario:SamplingRuleMatchingBench/benchRegexMatching1

  • 🟥 execution_time [+54.777ns; +128.623ns] or [+3.649%; +8.568%]

scenario:SamplingRuleMatchingBench/benchRegexMatching4

  • 🟥 execution_time [+53.805ns; +98.995ns] or [+3.627%; +6.673%]

Unstable benchmarks

These benchmarks have a confidence interval too wide to call a change; treat them as noise rather than signal.

scenario:LaravelBench/benchLaravelDdprof-opcache

  • unstable execution_time [-236.119µs; +1474.319µs] or [-1.538%; +9.605%]

@cataphract
cataphract force-pushed the glopes/appsec-oom-soft-limit branch from 89bfe1e to cc09c06 Compare October 27, 2026 11:49
@bwoebi

bwoebi commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

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?
php/php-src@1d7dec1

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).

@cataphract
cataphract force-pushed the glopes/appsec-oom-soft-limit branch 3 times, most recently from bedd9cf to aae407b Compare October 28, 2026 16:24
@cataphract
cataphract requested a balanced review from Copilot October 31, 2026 11:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread ext/sidecar.c Resolved
@cataphract
cataphract force-pushed the glopes/appsec-oom-soft-limit branch from 7990a55 to 6453152 Compare October 31, 2026 11:34
cataphract and others added 2 commits October 31, 2026 13:42
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>
@cataphract
cataphract force-pushed the glopes/appsec-oom-soft-limit branch from 6453152 to e0eeca1 Compare October 31, 2026 14:00
cataphract and others added 3 commits October 31, 2026 14:20
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>
@cataphract
cataphract force-pushed the glopes/appsec-oom-soft-limit branch from e0eeca1 to 5b37138 Compare October 31, 2026 14:23
cataphract and others added 4 commits November 2, 2026 10:17
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>
@cataphract
cataphract force-pushed the glopes/appsec-oom-soft-limit branch from a419743 to 8ec91d4 Compare November 2, 2026 10:23
cataphract and others added 3 commits November 2, 2026 20:50
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>
cataphract and others added 17 commits November 2, 2026 20:52
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants