Emerging threat: critical PHP security release on 21 October 2026, 14:00 UTC. The PHP project announced it on 20 October ( announcement ): two critical vulnerabilities, both rated CVSS v4.0 9.2. Details, affected versions and fixes come with the release, which may come earlier if details become public or exploitation starts. Prepare to update PHP now.
The announcement does not say what kind of flaws these are. If an attack makes PHP start programs, process-watcher shows each one with its command line and can optionally kill the ones you name. It does not look for these flaws: it prevents nothing and misses code that runs inside PHP without starting a process, so it adds a detection layer and does not replace the patch.
Start here: watching PHP with process-watcher , or read the full PHP guide .
See what a service starts. process-watcher follows a service (a web server, an application server, ...) and every process it creates, in real time, and logs each start, program change and exit as JSON, optionally with the command line. When your web server suddenly runs a shell or curl , you see what was started, by which process and from which service. It is one static binary for Linux ( amd64 , arm64 ): download, unpack, run.
It makes the activity visible; it does not judge it. Building a baseline or deciding what is an attack is left to you or to the system that receives the events (log file, SIEM, webhook). Optionally it can react to specific child processes with a targeted SIGKILL ( Kill rules ).
1. Download and unpack the latest release ( amd64 or arm64 , detected from your machine; the archive names contain the version, so the second line looks up the latest tag):
2. Run it against the service you want to watch. It needs root and Linux 5.15 or newer with BTF; sudo ./process-watcher doctor tells you whether your host qualifies.
nginx is a substring of the executable name of your service ( apache2 , php-fpm , java , ...). Stop the agent with Ctrl-C; it writes the events to ./process-watcher.jsonl . --capture-argv=true records command lines, which can contain secrets ( Argument capture ). Prefer a file? Edit process-watcher.yaml to the binary (it is found automatically) and run sudo ./process-watcher .
3. Watch what the service starts (second terminal, same directory; jq makes the JSON readable):
Example: a service called nginx is made to run a shell and a few programs (simulated with a stand-in program):
A web or application server that starts shells, download tools or interpreters ( sh , curl , wget , nc , python , ...) is a classic trace of a web shell or remote code execution. All generations are followed, also after the parent has ended. ( jq only joins the arguments for display; the event keeps them as an array. sh shows up as dash because the image is the program that actually ran.)
: keep it running as a systemd service · send the events to your SIEM or log pipeline with --tcp-address , --udp-address or --webhook-url ( Outputs ) · terminate suspicious child processes automatically ( Kill rules ).
If a requirement is missing, the agent stops with a concrete diagnosis and exit code 3 ; it never falls back to polling /proc , which would miss short-lived processes. sudo ./process-watcher doctor checks everything, loads the programs and runs a probe process of its own. Tested kernels: docs/compatibility.md .
Every archive holds the single static binary (eBPF programs embedded; nothing else is needed at run time), the commented example configuration process-watcher.yaml , the LICENSE , below php/ the two configurations of the PHP kit in emerging-threats/ , usable right away ( process-watcher --config php/php-observe.yaml ), and in packaging/ the systemd unit with the script that installs it. Only amd64 and arm64 are provided: the eBPF collector assumes 64-bit kernel pointers, so 32-bit kernels (x86, ARM) are not supported.
From the unpacked archive:
Details, installing from a clone of the repository and the reasons for the unit's settings: packaging/INSTALL.md . Without systemd, copy the binary and start it with root privileges.
Example configurations are in examples/ ; each one is checked by the strict loader in a test. Simple receivers for trying things out (Python 3 standard library only) are in examples/receivers/ ; nc -lk 9000 (TCP) and nc -lu 9001 (UDP) are enough for a first look.
Commands and exit codes
Operational diagnostics go to stderr; stdout stays empty for run .
Exactly one YAML file is loaded (files are never merged). order: --config (must exist and be readable), process-watcher.yaml in the working directory, process-watcher.yaml to the real binary (symlinks resolved), the built-in defaults. A file that is found but invalid is an error and never skipped. Precedence: defaults /cmdline ( snapshot ), verified against the kernel start time of the instance so that a reused PID is never mixed up (if that fails, argv is null with a reason). Environment variables are never captured.
An event is serialized once ; all outputs get the same bytes and the same event_id . The local JSONL log is mandatory and written first. TCP, UDP and webhook are optional and independent, each with its own worker and bounded queue ( delivery.queue_size ): a slow target blocks neither the local log nor the others, and when a queue is full only the new event is dropped for that output and counted.
Retries: at most delivery.max_retries + 1 attempts with backoff from 1 s to 30 s (±20 % jitter), then the event is dropped for that output and counted. There is no guaranteed or exactly-once delivery : after unclear transfers duplicates and incomplete lines are possible (deduplicate by event_id ), there is no replay across a restart, and the TCP and UDP counters mean "handed to the transport", not "acknowledged". Details: docs/outputs.md and internal/output/DELIVERY.md .
Reaction, not blocking. The feature reacts after detection. It prevents neither the execution of a program nor its side effects: a short-lived process such as useradd can be long finished before the agent reacts, and an already vanished process is an expected outcome. A signal accepted by the kernel ( signal_sent ) proves neither that the process died nor that side effects were prevented.
Reaction, not blocking. The feature reacts after detection. It prevents neither the execution of a program nor its side effects: a short-lived process such as useradd can be long finished before the agent reacts, and an already vanished process is an expected outcome. A signal accepted by the kernel ( signal_sent ) proves neither that the process died nor that side effects were prevented.
Scope. Off by default ( kill.enabled: false ). When on, the rules apply only to confirmed descendants of the observed roots (all generations), never to a root, PID 1 or the agent; there is no system-wide . A match leads directly to a SIGKILL attempt (no grace period) on exactly that process instance including its threads, not on its descendants or process group. Children that already exist at start or are found later ( process.discovered ) are checked too, so they can be killed as well. kill.dry_run: true evaluates and audits the same way but sends no signal. The console says at start whether the rules are in dry run or live and prints one kill decision line per decision (no arguments; the log file has the whole event).
Matching. Strings are literal, case-sensitive substrings (independent of watch.ignore_case ); regexes are Go RE2 and within the value (anchor with ^ and $ , (?i) ignores case). Strings, regexes and fields are OR-combined, and several hits give one action. Fields: image (file name of the executed program, default), image_path (complete path) and argv ( every argument individually , including argv[0] , never joined; needs capture.argv: true ). useradd in argv also matches sh -c 'useradd ...' ; to match only the program that was started, use fields: [image] with ^useradd$ . Only demonstrably complete values count: missing, presumed or truncated values are no basis for a match.
Inherited image. A new process carries its creator's image until its own exec, so a rule matching the image of an observed root (or an intermediate process) also matches every direct child already at process.fork . Aim rules at the image of the unwanted program. A service master forks its workers with its own image: a rule such as php to the watch pattern php-fpm kills the workers (measured on a real PHP-FPM: thousands of signals in seconds, requests answered with 502). check-config and agent.started warn when a rule matches a watch pattern, and kill.spare_root_image: true ( --kill-spare-root-image ) never kills a descendant that runs the executable of its root (audited as skipped / root_image_spared ).
Evaluation happens at process.exec , process.discovered and process.fork (inherited image and path only), before the output exclusions: exclude.* and watch.max_depth do not shield a target. If the assignment to a root is unclear after collection losses ( lineage.complete: false ), no action is taken on suspicion ( skipped , lineage_unconfirmed ): whoever causes a ring buffer overflow on purpose can withdraw processes from the rules, so monitor agent.gap and collector.lost_total .
Sequence and identity. A PID alone is no identity. One worker handles at most 256 waiting actions; network delivery never delays a kill attempt.
The decision ( response.kill_requested , outcome: pending ) is written locally before anything is signalled.
pidfd_open for the host TGID; then TGID and exact start time are checked against the kernel's table. On a mismatch, or if unprovable, the action is skipped ( identity_mismatch , identity_unverified ). There is no fallback to kill(pid, SIGKILL) .
Right before the signal the match fields are checked again ( /proc/ /exe , cmdline ); if the match can no longer be proven the action is skipped ( match_no_longer_verified ).
SIGKILL goes only through the verified pidfd, so a reused PID is never hit.
Identity check and signal are not atomic: the same instance can still exec in between. At most one attempt is made per (process_id, exec_index) and a failed attempt is never repeated in a loop.
Audit. response.kill_requested and exactly one response.kill_result details.action_id ( details.trigger_event_id names the trigger). Outcomes: dry_run , signal_sent , already_exited , permission_denied , error , skipped (with a reason). details.chain lists the ancestors from the root to the parent with their images and arguments, so the decision reads without the earlier events. The later process.exit carries details.response_action_id (a correlation, no causal claim); details.signal: 9 there, not signal_sent , confirms a death. A crash can leave an open pending .
Privileges. root, or CAP_BPF , CAP_PERFMON , CAP_KILL (other users' processes) and CAP_SYS_PTRACE (the re-check). Without permission the agent reports permission_denied and keeps observing; without pidfd_open / pidfd_send_signal it refuses to start with the feature enabled (exit code 3).
Reaction time (measured). On a native arm64 kernel 6.12.54 (100 own target processes, 20 ms apart) the decision was written locally after 0.13 ms and pidfd_send_signal returned after 0.27 ms on average (p95 0.47 ms, maximum 1.4 ms); the target keeps running until the signal takes effect. Values from emulated CPUs say nothing hardware. Full reference: docs/kill-rules.md ; measurements: docs/measurements.md .
agent.health (every runtime.health_interval ) reports roots, tracked instances, kernel-counted losses by cause, exclusion and depth counters, queue lengths and per-output counters for the current run. Ring buffer losses, a full process limit ( runtime.max_processes ) or a failed reconciliation produce agent.gap ; afterwards the living processes are compared again and what is found is marked lineage.complete: false and data_quality.collection_gap: true . A snapshot cannot restore lost short-lived processes. A restart begins a new run (new agent_instance_id ).
Memory is bounded by runtime.max_processes , all queues are bounded, and the RSS is 2 × ring_buffer_mb + 15 MiB ; with argument capture every tracked instance holds up to capture.argv_max_bytes of arguments ( measurements ), and the kernel keeps one exec buffer of 24 KiB per CPU. On shutdown (SIGINT/SIGTERM) waiting kill actions complete, captured events are processed, remote queues drain until runtime.shutdown_timeout and agent.stopped is stored locally ; no artificial exit events are created. A failing final write (full disk) ends the agent with exit code 1. More: docs/operation.md .
Linux only, amd64 / arm64 ; no operation in a container (the initial PID namespace is required).
No history from before the hooks were activated; after collection losses the lineage of affected processes is unconfirmed. Processes that were already orphaned before the start cannot be assigned to an earlier root.
One target per remote transport; no persistent remote queue; no guaranteed delivery.
Every root that is newly admitted at run time triggers a reconciliation (at most once per second); with very broad patterns such as sh it runs continuously ( cost ).
No monitoring of CPU, RAM, files or network of the observed processes; no automatic attack detection; no user interface; no Kubernetes integration.
The kill feature reacts afterwards and is no preventive protection.
The full list is in docs/operation.md .
Maintainers: pushing a vX.Y.Z tag builds and publishes a release ( release workflow ); make release-archives VERSION=v0.1.0 builds the same archives locally.
The eBPF objects are not built by the normal Go build: internal/collector/ebpf/pw_bpfel.{go,o} are checked in and embedded, and one object serves amd64 and arm64 (CO-RE, tp_btf ). make generate regenerates them reproducibly in a container ( build/ebpf/Dockerfile , pinned toolchain). Tests with a real kernel need root, the initial PID namespace and BPF; there are no mocks for the collector. Which runs actually took place is recorded in docs/acceptance.md , the tested kernels in docs/compatibility.md .
Everything beyond this page is in docs/ : the full event format ( events.md ), the complete kill-rule reference ( kill-rules.md ), outputs.md , operation.md , the tested kernels ( compatibility.md ), measurements ( measurements.md ) and the runs that took place ( acceptance.md ). For PHP (PHP-FPM, Apache with mod_php, CGI, CLI) there is a kit in emerging-threats/ : ready-made configurations and the full guide.
License: GPL-3.0, see LICENSE . The sources of the eBPF programs carry GPL-2.0-or-later (compatible with GPL-3.0); the object carries the license GPL , which the kernel requires for GPL-only helpers.
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
