Node2Shell: Google Brotli Decoder Out-of-Bounds Write (CVE-2026-111497)
Executive Summary
What the attacker wants: code execution as the service account of an internet-facing Node.js (or Bun, .NET, Envoy, Python) workload, reached without credentials by getting the application to run a crafted Brotli stream through the Google Brotli decoder. CVE-2026-111497 is an out-of-bounds write caused by an unchecked return value (CWE-252 leading to CWE-787): the decoder treated a block switch that the stream's own structure makes invalid as "needs more input" instead of a format error, so it kept appending bytes to an 8-byte internal buffer and wrote past its end into neighbouring decoder state. Node2Shell, Broccoli2Shell, Brotli2Shell and HTTP2Shell are nicknames for this single CVE, not separate threats, and there is no named threat actor in any source.
Status: CISA added the CVE to the Known Exploited Vulnerabilities catalog on 2026-10-27 (federal due date 2026-11-10, ransomware use "Unknown") and gave no attack details. The concrete in-the-wild evidence is from ELLIO: a public exposure checker, node2shell-check, swept its sensors, and on 2026-10-26 a real exploit request arrived from 94.154.43.7. ELLIO tested that request and it did not produce a shell in 50 attempts, so treat it as a failed first-generation attempt by an operator who is now clearly trying, not as proof of working mass exploitation.
Exploitability, stated plainly: a bare Node.js HTTP server does not decode incoming Brotli request bodies on its own, so exposure depends on the application: Express 5 with body-parser 2.x, Koa body parsers and Fastify with @fastify/compress decode request bodies, and built-in fetch() decodes Brotli responses from any server an attacker influences. Imperva showed code execution on the official Node.js 24.22.0 Linux arm64 image, and not on every attempt. ELLIO reproduced a shell on x86-64 against four setups with ASLR left on, but each Node.js build needs its own memory offset (the 24.22.0 value gave nothing in 10,000 attempts against 22.23.3 and 26.12.0), and most of its Express attempts crashed the worker. Other researchers reported crashes only, and Sysdig records that blind remote execution with ASLR on did not work for one team. The honest default is that a crash is the likely outcome everywhere and a shell is a credible risk wherever attacker-influenced Brotli reaches an unpatched decoder.
Highest-value defensive angle: the exploit request is invisible to endpoint sensors, so the pack hunts what the runtime does afterwards: a shell, downloader or interpreter under node, a node PID that is replaced in place by /bin/sh, restart loops, odd outbound connections and reverse-shell command lines. Patch, then remove the decoder path, then constrain the process.
Defender priority: inventory every Node.js, Bun, Python and Envoy workload that decodes Brotli the attacker can influence, and patch internet-facing ones first: Node.js 22.23.4, 24.22.1 or 26.12.1; Brotli 1.0.10, 1.1.1 or 1.2.1; Bun 1.4.4. Node.js 20, 23 and 25 are end of life and will not be fixed, so replace them. Until patched, reject Content-Encoding: br on inbound request bodies where no client needs it.
Source Review & Web Hunter Notes
| Tier | Source | Key finding | Carry forward |
|---|---|---|---|
| 1 | CISA Known Exploited Vulnerabilities feed (snapshot 01) | Catalog 2026.10.27. Entry for CVE-2026-111497: Google Brotli "Unchecked Return Value Vulnerability", added 2026-10-27, due 2026-11-10, ransomware use Unknown, CWE-252 and CWE-787. Notes say the flaw is in an open-source component used by different products. | Yes |
| 1 | Node.js October 2026 security releases (snapshot 11) | Vendor statement of the mechanism (invalid block switch reported as a need for more input, 8-byte buffer), affected lines 22.x, 24.x, 26.x, fixed versions. | Yes |
| 1 | google/brotli v1.2.1 release (snapshot 12) | Upstream release titled with the CVE and the out-of-bounds write; published 21 Oct 19:27. | Partial (page is thin) |
| 1 | ELLIO, Node2Shell on stock Node.js builds (snapshot 02) | Only source for the in-the-wild indicators: node2shell-check sweep, the 2026-10-26 exploit request, its SHA-256 and source IP, the failed-chain finding, x86-64 reproduction, per-build offsets, crash and core-dump behaviour, the in-place execvp ending. | Yes (single source for all atomic IOCs) |
| 1 | Sysdig Threat Research, detecting the Node.js Brotli RCE (snapshot 03) | Reachability table by framework, demonstrated versus claimed exploitation, runtime detection idea (crash followed by in-place program execution), mitigation list, Debian system-library caveat. | Yes |
| 1 | Tenable FAQ (snapshot 04) | Beyond Node.js: .NET fixed versions (8.0.33, 9.0.22, 10.0.14), PHP for Windows, FFmpeg via libjxl, Apache Arrow, ClickHouse; CVSS 4.0 9.2 from Google and 8.1 CVSS 3.1 from Microsoft for .NET. | Yes |
| 2 | Wiz (05), Cycode (06), Aikido (07) | Affected and patched version matrices for Node.js, Bun, Envoy, Istio, Python Brotli and brotlicffi, Go andybalholm/brotli, distro status, framework reachability. | Yes (versions) |
| 2 | Help Net Security (08), The Hacker News (09), SecurityWeek (10) | Independent confirmation of ELLIO's source IP, of the failed-exploit finding and of the KEV addition; Previdian logged one attempt on 2026-10-27 from a US address (no address published); Broadcom SDDC Manager and PAN-OS hotfix notices. | Yes (corroboration; not independent of ELLIO for the IOC) |
| - | Akamai root-cause analysis | The page returned HTTP 403 to an automated fetch, so no snapshot exists and nothing in this pack is attributed to it. | No |
Decisions. (1) Every atomic indicator is single-sourced to ELLIO, so both ship as detect with a 3-month expiry and nothing ships as prevent. (2) The SHA-256 is the hash of an HTTP request body, not of a file, so it can only match endpoint telemetry if the body was written to disk. It is kept for completeness and for any proxy or WAF that logs body hashes. (3) ELLIO's truncated hash of the Node.js 24.22.0 x86-64 release binary (67ca5783...) is not usable as an indicator and is not shipped. (4) No coined campaign or actor name is used; the only names are those in the snapshots.
The raw fetched text of every source above is saved in Node2Shell-CVE-2026-111497-Hunt-sources/ next to this file. No fetched page contained instructions aimed at an automated agent, and no source was disqualified.
Hunt Brief & Attack Chain
Hunt hypotheses (ordered by fidelity)
| # | Hypothesis | MITRE | Telemetry | Conf | Queries |
|---|---|---|---|---|---|
| H1 | A successful exploit ends in a shell: the runtime process is replaced in place by /bin/sh (stdin wired to the attacker's socket), or spawns a shell or interpreter child. | T1190, T1059.004 | Process creation with command line, image path and parent (CrowdStrike: ProcessRollup2. SentinelOne: Process Creation. Defender: DeviceProcessEvents) | Medium | Q1, Q2 |
| H2 | Failed attempts crash the worker and the supervisor restarts it, so a burst of identical starts marks an exploitation run. | T1190, T1499.004 | Process creation (as above) | Medium | Q3 |
| H3 | The operator pivots to a reverse shell or a staged download. | T1059.004, T1095, T1105 | Process creation with command line; outbound network connection with owning process (CrowdStrike: NetworkConnectIP4. SentinelOne: IP Connect. Defender: DeviceNetworkEvents) | High (command line) / Medium (network) | Q4, Q5 |
| H4 | The operator runs discovery and reads secrets from the process environment, files or the cloud metadata address. | T1033, T1082, T1552.001, T1552.005 | Process creation with command line | Medium | Q6 |
| H5 | Unpatched or end-of-life runtimes and decoder hosts exist and are reachable. | T1190 | Process creation with file hash and path as an inventory source (CrowdStrike: ProcessRollup2. SentinelOne: Process Creation. Defender: DeviceProcessEvents) | Medium | Q7, Q9 |
| H6 | The known exploit source address touched the estate. | T1071 | Outbound network connection (as above); web and proxy logs for the inbound side | Low | Q8, Section 7 N1 |
Attack chain
| Step | Behaviour | Telemetry | Hunt angle |
|---|---|---|---|
| 0 | Reconnaissance: node2shell-check sends a GET then 20 POST bodies with Content-Encoding: br in two bursts to find hosts that decode Brotli | Web server, proxy or WAF logs (outside endpoint telemetry) | Section 7 N1. Gap for endpoint sensors. |
| 1 | Delivery: crafted Brotli stream reaches the decoder via a request body (Express 5, Koa, Fastify), via an attacker-influenced fetch() response, or via direct node:zlib use | Web, proxy or WAF logs; application logs (outside endpoint telemetry) | Section 7 N1, N2. Gap for endpoint sensors. |
| 2 | Memory corruption: out-of-bounds write into decoder state; a miss usually segfaults the worker | Process creation for the restart (CrowdStrike: ProcessRollup2. SentinelOne: Process Creation. Defender: DeviceProcessEvents); host crash logs and core dumps | Q3; Section 7 N3 |
| 3 | Code execution: ROP chain ends in execvp of /bin/sh with stdin on the accepted socket | Process creation with parent and PID | Q1, Q2 |
| 4 | Post-exploitation: discovery, secrets, download, reverse shell | Process creation with command line; outbound connections | Q4, Q5, Q6 |
| 5 | Impact: crash-driven denial of service; core files holding other users' credentials where core dumps are written | Host logs; coredump listing | Section 7 N3. Gap. |
Consolidated IOC Table
| Type | Value | Confidence | Action | Context | Expiry |
|---|---|---|---|---|---|
| SHA-256 | 7fcbdaaecd4419e2930927844f3b772668204737a4e7602a2290c991c93c5c4b | Medium (single source) | detect | Hash of the 23,069,983-byte Brotli-coded request body observed by ELLIO at 15:08 UTC on 2026-10-26, built for Node.js v24.22.0 x86-64. Request body hash, not a file hash. | 2027-01-28 |
| IPv4 | 94.154.43.7 | Medium (single source; ELLIO rates it malicious, high confidence) | detect | Source of that request. First observed by ELLIO 2025-12-15, active on 47 of the last 90 days, also seen on PHP2Shell, VPN and Kubernetes API scanning. Shared mass-exploitation infrastructure, so treat hits as scanner contact, not proof of compromise. | 2027-01-28 |
| User-Agent | Mozilla/5.0 (compatible; node2shell-check/1.0) | Medium | hunt (web logs) | Public exposure checker. First observed 2026-06-13 from AS44620 (Turkey). Web, proxy and WAF logs only. | 2027-01-28 |
| ASN | AS44620 | Low | enrich | Origin of the checker sweep. Context only: an ASN is not a blocking indicator. | n/a |
| Request shape | POST with Content-Encoding: br; first block 1031 bytes, stage two 256 bytes; body about 23 MB | Medium | hunt (web logs) | From ELLIO's analysis of the exploit request. The checker sent malformed 1032-byte bodies of repeated A characters and valid bodies up to 65,743 bytes. | n/a |
| Log string | ERR__ERROR_FORMAT_BLOCK_SWITCH / BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH (-17) | High | hunt (app logs) | A patched server rejects the trigger immediately with a 400 and this error. Seeing it in application logs means someone sent the trigger to a patched host. | n/a |
No prevent actions. Both atomic indicators are single-sourced to ELLIO (Help Net Security and The Hacker News repeat the IP, but only by citing ELLIO), so they are detect only. The hash describes a network payload, so a prevent action on it would protect nothing. Nothing in this pack blocks node, node.exe, bun or any other stock product binary.
Affected Surface & Telemetry Matrix
| Surface | Telemetry | Priority | Gap |
|---|---|---|---|
| Linux hosts and containers running Node.js 22, 24, 26 (and EOL 20, 23, 25) behind Express 5 / body-parser 2, Koa, Fastify | Process creation with command line and parent (CrowdStrike: ProcessRollup2. SentinelOne: Process Creation. Defender: DeviceProcessEvents) | Critical | Exploit request is outside endpoint telemetry. Container hosts without a sensor are blind. |
Services that call fetch(), axios, got or SuperAgent on attacker-influenced URLs (webhooks, link previews, crawlers, agent tools) | Outbound network connection with owning process (CrowdStrike: NetworkConnectIP4. SentinelOne: IP Connect. Defender: DeviceNetworkEvents) | High | The malicious Brotli arrives in a response body; no endpoint telemetry carries it. |
Bun (below 1.4.4), including executables from bun build --compile | Process creation (as above) | High | Compiled executables need a rebuild, so a host-level package inventory misses them. |
| .NET apps using ASP.NET Core request decompression, HttpClient decompression, or BrotliStream | Process creation (as above); Windows and Linux | Medium | Decompression is off by default; presence of the runtime is not exposure. |
| Envoy and Istio with the Brotli decompressor configured; Python services with the Brotli or brotlicffi package | Process creation (as above) | Medium | Code execution not shown for the Python packages; hang and denial-of-service behaviour reported. |
| Appliances that embed Node.js (Broadcom SDDC Manager in VMware Cloud Foundation, PAN-OS hotfixes rolling out) | Vendor advisories; appliance logs | Medium | Usually no endpoint sensor. Patches pending for the VMware product at the time of the SecurityWeek report. |
| Linux hosts that link the system libbrotli (distro-built Node.js) | Package inventory (outside endpoint telemetry) | High | The runtime version does not reveal the library version; the distro package does. |
ATT&CK Mapping
| Tactic | Technique | Observed behaviour | Query or control |
|---|---|---|---|
| Reconnaissance | T1595.002 Vulnerability Scanning | node2shell-check sweeps with Brotli bodies | Section 7 N1 |
| Initial Access | T1190 Exploit Public-Facing Application | Crafted Brotli request body or fetched response | Section 7 N1; H1, H5 (M1051, M1048) |
| Execution | T1059.004 Unix Shell | execvp of /bin/sh from the runtime; shell children | Q1, Q2, Q5 |
| Discovery | T1033, T1082, T1083 | id, uname, environment and file reads | Q6 |
| Credential Access | T1552.001, T1552.005 | Secrets in environment, files, cloud metadata | Q6; H7 (M1026) |
| Command and Control | T1095, T1571, T1105 | Reverse shell, staged download, odd ports | Q4, Q5 |
| Impact | T1499.004 Application or System Exploitation | Crash and hang as denial of service | Q3; Section 7 N3 |
Native Audit-Log Hunts (non-CQL)
Why this section matters. Steps 0 and 1 of the chain (the sweep and the exploit request) and the crash-and-core-dump consequences leave no trace in endpoint process telemetry. They are in web and proxy logs, application logs and host crash records. Treat these checks as first-class hunt work.
N1 · Web server, proxy and WAF logs
Most default access-log formats do not record request headers. Add $http_content_encoding and $request_length to the nginx log format (or the equivalent in your proxy or WAF) and search from 2026-10-21, or earlier for the checker, which was first observed in June.
# The checker user agent grep -aEi 'node2shell-check' /var/log/nginx/access.log* /var/log/apache2/access.log* # The ELLIO source address grep -aF '94.154.43.7' /var/log/nginx/access.log* /var/log/apache2/access.log* # Requests carrying a Brotli-encoded request body (needs content_encoding in the log format) grep -aE 'content_encoding="?br"?' /var/log/nginx/access.log* | grep -aE '"(POST|PUT|PATCH)' # Very large request bodies on Brotli requests (the exploit body was about 23 MB) awk '/content_encoding="?br"?/ && $NF+0 > 5000000' /var/log/nginx/access.log* # A patched server rejecting the trigger answers 400 with this error name in the app log grep -aE 'ERR__ERROR_FORMAT_BLOCK_SWITCH|ERROR_FORMAT_BLOCK_SWITCH' /var/log/app/*.log
Reading the results. A br request body is unusual: browsers do not send compressed request bodies. Any POST with Content-Encoding: br to a public route is worth a look. The exact awk field depends on your log format, so adjust the field number before trusting a zero.
N2 · Application and fetch paths
- Search the codebase for Express 5 body parsers (
express.json,express.raw,express.text,express.urlencoded), Koa body parsers (@koa/bodyparser,koa-body,co-body),@fastify/compress, directzlib.brotliDecompressandcreateBrotliDecompress, and server-sidefetch(), axios and got calls on user-supplied URLs. - Express 4 and body-parser 1.x do not decode Brotli request bodies (Sysdig, Cycode); they are not exposed on that path, but other middleware may be.
- List every feature that fetches a URL a user can influence: link previews, webhook delivery, crawlers, image proxies, agent tools. These are the outbound path.
N3 · Crash evidence on the host
# systemd hosts: recent node crashes and the cores they left
coredumpctl list node 2>/dev/null | tail -50
journalctl -k --since "2026-10-21" | grep -aEi 'node\[[0-9]+\].*(segfault|general protection)'
# Containers: restart counts and last exit reason
kubectl get pods -A -o wide | awk '$5+0 > 3'
docker ps -a --format '{{.Names}}\t{{.Status}}' | grep -i restarting
# Who can read core files, and how long are they kept
ls -l /var/lib/systemd/coredump/ 2>/dev/null
cat /proc/sys/kernel/core_pattern
ELLIO notes that where core dumps are written, every attacker-forced crash leaves a core holding other visitors' session cookies, bearer tokens and key material, and that core files are root-readable and often kept. If you find cores from node crashes after 2026-10-21, restrict access and rotate what they could contain.
N4 · Version and library inventory
# Runtime version. Do NOT rely on process.versions.brotli: it reads 1.2.0 on patched and vulnerable builds. node --version # fixed: 22.23.4, 24.22.1, 26.12.1 bun --version # fixed: 1.4.4 dotnet --list-runtimes # fixed: 8.0.33, 9.0.22, 10.0.14 # Python bindings (fixed: brotli 1.2.1, brotlicffi 1.2.1.0) pip list 2>/dev/null | grep -iE '^(brotli|brotlicffi) ' # System library (distro-built Node.js links it): check the package, not the runtime dpkg -l | grep -i brotli ; rpm -qa | grep -i brotli ; apk info | grep -i brotli # Images: does the pinned tag still point at a vulnerable build docker run --rm --entrypoint node IMAGE:TAG --version
Node.js cherry-picked the fix without bumping the bundled version string, so the release number is the check. A Dockerfile that pins 24.22.0 keeps shipping the vulnerable build until that line changes, and Homebrew still shipped unfixed formulae about ten hours after release (Sysdig). Cover staging, CI, build images, serverless runtimes and developer machines, not just production.
Hunt Queries: CrowdStrike CQL, SentinelOne PowerQuery and Defender XDR KQL
Use your tenant's own address. SentinelOne does not document a link that pre-fills a query, so each "Copy & open SentinelOne" button copies the query and opens this console: paste it into PowerQuery.
Pick your portal. Microsoft documents a Share link in advanced hunting but not its URL format, so each "Copy & open Defender" button copies the query and opens Advanced hunting: paste it into the editor.
Nine queries. Q1, Q2 and Q5 are the primary detections; Q9 is the baseline to run first and Q7 is the inventory. Every card declares its rating and tuning. Lookback is set in the console time picker, not in the query.
Looks for: the most common post-exploitation shape for a runtime-hosted RCE: the application runtime becomes the parent of a shell, a download utility or a scripting interpreter. This is the broad net. Production web tiers should show close to zero of these, so a hit on a web-facing host is a lead. FP: npm run and yarn scripts, build and CI hosts, process managers and health-check wrappers all legitimately launch a shell from node. Run Q9 first to baseline which parent-child pairs are normal for each host group.
Known limitations and deployment notes
- PowerQuery: A child started by a runtime process the sensor did not observe starting is not captured.
- PowerQuery assumes: On a Process Creation event src.process.name is the creator and tgt.process.name the created process.
- PowerQuery assumes: src.process.user is the creator's account; the CQL reports the created process's account.
- PowerQuery assumes: in:anycase and matches are case-insensitive, which reproduces the CQL /i intent.
- KQL – Defender XDR: A child started by a runtime process the sensor did not observe starting is not captured.
- KQL – Defender XDR assumes: FileName and InitiatingProcessFileName hold bare image names on both Linux and Windows sensors.
// HUNT: Application runtime (Node.js, Bun, .NET, Envoy) spawning a shell, downloader or interpreter // MITRE: T1190, T1059.004, T1059.003, T1105 | CONF: medium FP: medium COST: low // REQUIRES: ProcessRollup2 with parent lineage on Linux and Windows // FALSE POSITIVES: npm and yarn scripts, build and CI hosts, process managers, health-check wrappers // TUNING: exclude build and CI hosts by ComputerName and developer workstations by UserName // TUNING: baseline production web tiers for 7 days with Q9, then allowlist exact CommandLine values // TUNING: rather than binary names, because the binary names are the signal // LOOKBACK: 14d (set in the console time picker) #event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/ | ParentBaseFileName=/^(node|nodejs|node\.exe|bun|bun\.exe|dotnet|dotnet\.exe|envoy)$/i | FileName=/^(sh|bash|dash|ash|zsh|ksh|busybox|curl|wget|nc|ncat|socat|python[0-9.]*|perl|ruby|cmd\.exe|powershell\.exe|pwsh\.exe)$/i | table([@timestamp, ComputerName, UserName, ParentBaseFileName, FileName, CommandLine, aid]) | sort(@timestamp, order=desc, limit=200)
4 required fields · View requirements
Required telemetry
- SentinelOne Process Creation events
Required fields
event.type: Event typeevent.time: Event timesrc.process.name: Creator process nametgt.process.name: Created process name
Recommended fields
tgt.process.cmdline: Created command linesrc.process.user: Creator accountendpoint.name: Host
Optional fields
agent.uuid: Agent identifier
Fallbacks
endpoint.name→agent.uuid: Changes the displayed host identifier only.
Readiness test
event.type = 'Process Creation' tgt.process.name = * | limit 5 | columns event.time, src.process.name, tgt.process.name
// HUNT: Application runtime (Node.js, Bun, .NET, Envoy) spawning a shell, downloader or interpreter
// SCHEMA PROFILE: sentinelone-endpoint-ocsf
// MITRE: T1190, T1059.004, T1059.003, T1105
// CONF: medium | FP: medium
// FALSE POSITIVES: npm and yarn scripts, build and CI hosts, process managers, health-check wrappers
// EQUIVALENCE: equivalent-with-schema-assumptions - Same process-creation predicate: a runtime creator (Node.js, Bun, .NET or Envoy by name) and a created shell, downloader or interpreter. On a Process Creation event the creator is src.process.* and the created process is tgt.process.*, so the CQL parent maps to src.process.name and the child to tgt.process.name.
// REQUIRES: SentinelOne EDR process-creation events with creator lineage on Linux and Windows
// TUNING: Exclude build and CI hosts by endpoint.name and developer workstations by src.process.user, then allowlist exact tgt.process.cmdline values after a 7 day baseline with Q9.
// LOOKBACK: 14d (set in the console time picker)
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: A child started by a runtime process the sensor did not observe starting is not captured.
// VALIDATION LEVEL: static-reviewed
event.type = 'Process Creation' src.process.name in:anycase ('node', 'nodejs', 'node.exe', 'bun', 'bun.exe', 'dotnet', 'dotnet.exe', 'envoy') tgt.process.name matches '^(sh|bash|dash|ash|zsh|ksh|busybox|curl|wget|nc|ncat|socat|python[0-9.]*|perl|ruby|cmd\\.exe|powershell\\.exe|pwsh\\.exe)$'
| columns event.time, endpoint.name, src.process.user, src.process.name, tgt.process.name, tgt.process.cmdline, agent.uuid
| sort -event.time
| limit 200Automated validation: static-passed
3 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeInitiatingProcessFileName: Creator process nameFileName: Created process name
Recommended fields
ProcessCommandLine: Created command lineAccountName: Account
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, InitiatingProcessFileName, FileName | take 5
// HUNT: Application runtime (Node.js, Bun, .NET, Envoy) spawning a shell, downloader or interpreter
// SCHEMA PROFILE: microsoft-defender-xdr
// MITRE: T1190, T1059.004, T1059.003, T1105
// CONF: medium | FP: medium
// FALSE POSITIVES: npm and yarn scripts, build and CI hosts, process managers, health-check wrappers
// EQUIVALENCE: equivalent-with-schema-assumptions - Same process-creation predicate: a runtime creator by name and a created shell, downloader or interpreter. The CQL parent is InitiatingProcessFileName and the child is FileName. in~ and the (?i) flag reproduce the CQL case-insensitive intent.
// REQUIRES: Defender for Endpoint process-creation telemetry (DeviceProcessEvents) on Linux and Windows
// TUNING: Exclude build and CI hosts by DeviceName and developer workstations by AccountName, then allowlist exact ProcessCommandLine values after a 7 day baseline with Q9.
// LOOKBACK: 14d
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: A child started by a runtime process the sensor did not observe starting is not captured.
// VALIDATION LEVEL: schema-aligned
// AUTOMATED VALIDATION: static-passed; static=passed; fixtures=0/0
DeviceProcessEvents
| where Timestamp >= ago(14d)
| where InitiatingProcessFileName in~ ("node", "nodejs", "node.exe", "bun", "bun.exe", "dotnet", "dotnet.exe", "envoy")
| where FileName matches regex @"(?i)^(sh|bash|dash|ash|zsh|ksh|busybox|curl|wget|nc|ncat|socat|python[0-9.]*|perl|ruby|cmd\.exe|powershell\.exe|pwsh\.exe)$"
| project Timestamp, ReportId, DeviceId, DeviceName, AccountName, InitiatingProcessFileName, FileName, ProcessCommandLine
| sort by Timestamp desc
| take 200Looks for: the exploit chain ELLIO describes ends by calling the runtime's own execvp on /bin/sh. An exec replaces the process image rather than forking a child, so the shell inherits the node PID and its parent is whatever launched node (a supervisor, tini, a container shim), not node. Q1 keys on a node parent and would never see this shape. This query keys on the OS process ID instead: a PID that ran node or Bun and then, within an hour, ran a shell. The order matters, because a container entrypoint that runs exec node server.js goes shell then node, which this query excludes. FP: PID reuse after a wrap on a busy host, and wrapper scripts that exec a shell after node exits. Validate in your tenant: confirm the sensor emits a new process event for an in-place exec on the same RawProcessId by running a harmless test app that calls exec on a shell; this behaviour is inferred from how exec works and is not stated in any source.
Known limitations and deployment notes
- PowerQuery: The CQL's measured gap below one hour and its runtime-before-shell ordering are not expressed: a pair that straddles a clock-hour boundary is missed and PID reuse inside the hour can pair unrelated processes.
- PowerQuery assumes: SentinelOne reports an in-place exec as a second Process Creation event carrying the same tgt.process.pid; this is unverified on a tenant.
- PowerQuery assumes: timebucket('1h') buckets on the event's own time key.
- PowerQuery assumes: matches is case-insensitive, which reproduces the CQL /i intent.
- KQL – Defender XDR: The runtime-before-shell ordering is not checked.
- KQL – Defender XDR assumes: An in-place exec is recorded as a second DeviceProcessEvents row with the same ProcessId; this is unverified on a tenant.
- KQL – Defender XDR assumes: PID reuse inside the 60 minute span can pair unrelated processes.
// HUNT: Runtime PID re-executed as a shell (in-place exec after Node.js or Bun) // MITRE: T1190, T1059.004 | CONF: medium FP: medium COST: medium // REQUIRES: ProcessRollup2 on Linux with RawProcessId populated // FALSE POSITIVES: PID reuse after wrap on busy hosts, wrapper scripts that exec a shell after node exits // TUNING: shorten the gap threshold from one hour to 5 minutes on hosts with high process churn // TUNING: exclude ComputerName values of build agents; validate with a benign in-place exec test first // LOOKBACK: 7d (set in the console time picker) #event_simpleName=/^ProcessRollup2$/ | FileName=/^(node|nodejs|bun|sh|bash|dash|ash|zsh|busybox)$/i | runtimeTs := if(FileName=/^(node|nodejs|bun)$/i, then=@timestamp, else=0) | shellTs := if(FileName=/^(sh|bash|dash|ash|zsh|busybox)$/i, then=@timestamp, else=99999999999999) | groupBy([aid, ComputerName, RawProcessId], function=[max(runtimeTs, as=lastRuntime), min(shellTs, as=firstShell), collect([UserName, ImageFileName, ParentBaseFileName, CommandLine])], limit=max) | gap := firstShell - lastRuntime | gap>0 | gap<3600000 | table([ComputerName, aid, RawProcessId, UserName, ImageFileName, ParentBaseFileName, CommandLine, gap]) | sort(gap, order=asc, limit=200)
5 required fields · View requirements
Required telemetry
- SentinelOne Process Creation events
Required fields
event.type: Event typeevent.time: Event timetgt.process.pid: Process identifiertgt.process.name: Created process nameagent.uuid: Agent identifier
Recommended fields
endpoint.name: Hosttgt.process.cmdline: Created command line
Optional fields
src.process.user: Creator account
Readiness test
event.type = 'Process Creation' tgt.process.pid = * | limit 5 | columns event.time, agent.uuid, tgt.process.pid, tgt.process.name
// HUNT: Runtime PID re-executed as a shell (in-place exec after Node.js or Bun)
// SCHEMA PROFILE: sentinelone-endpoint-ocsf
// MITRE: T1190, T1059.004
// CONF: medium | FP: medium
// FALSE POSITIVES: PID reuse after wrap on busy hosts, wrapper scripts that exec a shell after node exits
// EQUIVALENCE: approximate-analytic - Hunts the same behaviour (one PID seen as a runtime and then as a shell) by a different analytic: PowerQuery has no arithmetic or conditional timestamp aggregation here, so the pair is matched on the same PID inside one clock hour instead of a measured gap, and the order of the two events is not checked.
// REQUIRES: SentinelOne process-creation events on Linux with tgt.process.pid populated
// TUNING: Exclude build agents by endpoint.name and validate with a benign in-place exec test first. Narrow the bucket to 5 minutes on hosts with high process churn.
// LOOKBACK: 7d (set in the console time picker)
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: The CQL's measured gap below one hour and its runtime-before-shell ordering are not expressed: a pair that straddles a clock-hour boundary is missed and PID reuse inside the hour can pair unrelated processes.
// VALIDATION LEVEL: static-reviewed
event.type = 'Process Creation' tgt.process.pid = * tgt.process.name matches '^(node|nodejs|bun|sh|bash|dash|ash|zsh|busybox)$'
| group runtime_events = count(tgt.process.name matches '^(node|nodejs|bun)$'), shell_events = count(tgt.process.name matches '^(sh|bash|dash|ash|zsh|busybox)$'), first_seen = min(event.time), last_seen = max(event.time), user = any(src.process.user), parent = any(src.process.name), cmdline = any(tgt.process.cmdline) by agent.uuid, endpoint.name, tgt.process.pid, bucket = timebucket('1h')
| filter runtime_events > 0 and shell_events > 0
| sort -last_seen
| limit 200Automated validation: static-passed
4 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeProcessId: Process identifierFileName: Created process nameDeviceId: Device identifier
Recommended fields
ProcessCommandLine: Created command lineInitiatingProcessFileName: Creator process name
Optional fields
AccountName: Account
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, DeviceId, ProcessId, FileName | take 5
// HUNT: Runtime PID re-executed as a shell (in-place exec after Node.js or Bun)
// SCHEMA PROFILE: microsoft-defender-xdr
// MITRE: T1190, T1059.004
// CONF: medium | FP: medium
// FALSE POSITIVES: PID reuse after wrap on busy hosts, wrapper scripts that exec a shell after node exits
// EQUIVALENCE: approximate-analytic - Hunts the same behaviour (one PID seen as a runtime and then as a shell) by a different analytic: rows are grouped by ProcessId per device, both name sets must be present, and the span between first and last row must be under 60 minutes. The order of the two events is not checked.
// REQUIRES: Defender for Endpoint process-creation telemetry (DeviceProcessEvents) on Linux
// TUNING: Exclude build agents by DeviceName and validate with a benign in-place exec test first. Shorten the 60 minute span on hosts with high process churn.
// LOOKBACK: 7d
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: The runtime-before-shell ordering is not checked.
// VALIDATION LEVEL: schema-aligned
// AUTOMATED VALIDATION: static-passed; static=passed-with-warnings; fixtures=0/0
DeviceProcessEvents
| where Timestamp >= ago(7d)
| where FileName in~ ("node", "nodejs", "bun", "sh", "bash", "dash", "ash", "zsh", "busybox")
| summarize RuntimeEvents = countif(FileName in~ ("node", "nodejs", "bun")), ShellEvents = countif(FileName in~ ("sh", "bash", "dash", "ash", "zsh", "busybox")), FirstSeen = min(Timestamp), LastSeen = max(Timestamp), Account = any(AccountName), Parent = any(InitiatingProcessFileName), SampleCmd = any(ProcessCommandLine) by DeviceId, DeviceName, ProcessId
| where RuntimeEvents > 0 and ShellEvents > 0
| extend SpanMinutes = datetime_diff("minute", LastSeen, FirstSeen)
| where SpanMinutes < 60
| project DeviceName, DeviceId, ProcessId, Account, Parent, SampleCmd, RuntimeEvents, ShellEvents, FirstSeen, LastSeen, SpanMinutes
| sort by SpanMinutes asc
| take 200Looks for: ELLIO reports that most exploitation attempts in its lab crashed the worker instead of returning a shell, and that a supervisor restart hands the attacker a fresh memory layout for the next try. Imperva also needed several attempts. A burst of identical node or Bun starts on one host is therefore a plausible footprint of an exploitation run, whether or not any attempt lands. The sensor does not give you a crash event here, so this counts restarts of the same command line instead. FP: autoscaling, nodemon and watch-mode development servers, cluster managers that start many identical workers at once, and rolling deploys all produce repeated starts.
Known limitations and deployment notes
- PowerQuery: The span in minutes is not computed; read it from first_seen and last_seen.
- PowerQuery assumes: first_seen and last_seen replace the CQL's computed span in minutes; the analyst compares them.
- PowerQuery assumes: in:anycase is case-insensitive, which reproduces the CQL /i intent.
- KQL – Defender XDR: A start the sensor did not observe is not counted.
- KQL – Defender XDR assumes: FolderPath holds the full image path of the created process.
// HUNT: Repeated starts of the same Node.js or Bun command line on one host (crash-restart loop) // MITRE: T1190, T1499.004 | CONF: medium FP: medium COST: low // REQUIRES: ProcessRollup2 for node and bun on Linux and Windows // FALSE POSITIVES: watch-mode dev servers, cluster workers started together, rolling deploys, autoscaling // TUNING: exclude developer hosts by ComputerName; for cluster managers, require the restarts to be // TUNING: spread over time (spanMin above 2) rather than started within the same few seconds // LOOKBACK: 1h (set in the console time picker) and re-run on a schedule #event_simpleName=/^ProcessRollup2$/ | FileName=/^(node|nodejs|node\.exe|bun|bun\.exe)$/i | groupBy([aid, ComputerName, ImageFileName, CommandLine, ParentBaseFileName], function=[count(as=starts), min(@timestamp, as=firstStart), max(@timestamp, as=lastStart), collect([UserName])], limit=max) | starts>=5 | spanMin := (lastStart - firstStart) / 60000 | table([ComputerName, aid, ImageFileName, CommandLine, ParentBaseFileName, UserName, starts, spanMin]) | sort(starts, order=desc, limit=100)
6 required fields · View requirements
Required telemetry
- SentinelOne Process Creation events
Required fields
event.type: Event typeevent.time: Event timetgt.process.name: Created process nametgt.process.image.path: Created image pathtgt.process.cmdline: Created command lineagent.uuid: Agent identifier
Recommended fields
src.process.name: Creator process nameendpoint.name: Host
Optional fields
src.process.user: Creator account
Readiness test
event.type = 'Process Creation' tgt.process.name = * | limit 5 | columns event.time, agent.uuid, tgt.process.name, tgt.process.image.path, tgt.process.cmdline
// HUNT: Repeated starts of the same Node.js or Bun command line on one host (crash-restart loop)
// SCHEMA PROFILE: sentinelone-endpoint-ocsf
// MITRE: T1190, T1499.004
// CONF: medium | FP: medium
// FALSE POSITIVES: watch-mode dev servers, cluster workers started together, rolling deploys, autoscaling
// EQUIVALENCE: equivalent-with-schema-assumptions - Same aggregation: process starts of Node.js or Bun grouped by host, image path, command line and creator, kept when there are five or more. The CQL's computed span in minutes is shown as first_seen and last_seen instead.
// REQUIRES: SentinelOne process-creation events for node and bun on Linux and Windows
// TUNING: Exclude developer hosts by endpoint.name. For cluster managers require the restarts to be spread over time by comparing first_seen and last_seen rather than started within the same few seconds.
// LOOKBACK: 1h (set in the console time picker) and re-run on a schedule
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: The span in minutes is not computed; read it from first_seen and last_seen.
// VALIDATION LEVEL: static-reviewed
event.type = 'Process Creation' tgt.process.name in:anycase ('node', 'nodejs', 'node.exe', 'bun', 'bun.exe')
| group starts = count(), first_seen = min(event.time), last_seen = max(event.time), user = any(src.process.user) by agent.uuid, endpoint.name, tgt.process.image.path, tgt.process.cmdline, src.process.name
| filter starts >= 5
| sort -starts
| limit 100Automated validation: static-passed
5 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeFileName: Created process nameFolderPath: Created image pathProcessCommandLine: Created command lineDeviceId: Device identifier
Recommended fields
InitiatingProcessFileName: Creator process name
Optional fields
AccountName: Account
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, DeviceId, FileName, FolderPath, ProcessCommandLine | take 5
// HUNT: Repeated starts of the same Node.js or Bun command line on one host (crash-restart loop)
// SCHEMA PROFILE: microsoft-defender-xdr
// MITRE: T1190, T1499.004
// CONF: medium | FP: medium
// FALSE POSITIVES: watch-mode dev servers, cluster workers started together, rolling deploys, autoscaling
// EQUIVALENCE: equivalent-with-schema-assumptions - Same aggregation: process starts of Node.js or Bun grouped by device, image path, command line and creator, kept when there are five or more. The CQL's span in minutes is computed as SpanMinutes.
// REQUIRES: Defender for Endpoint process-creation telemetry (DeviceProcessEvents) for node and bun
// TUNING: Exclude developer hosts by DeviceName. For cluster managers require SpanMinutes above 2 rather than starts within the same few seconds.
// LOOKBACK: 1h; re-run on a schedule
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: A start the sensor did not observe is not counted.
// VALIDATION LEVEL: schema-aligned
// AUTOMATED VALIDATION: static-passed; static=passed-with-warnings; fixtures=0/0
DeviceProcessEvents
| where Timestamp >= ago(1h)
| where FileName in~ ("node", "nodejs", "node.exe", "bun", "bun.exe")
| summarize Starts = count(), FirstStart = min(Timestamp), LastStart = max(Timestamp), Account = any(AccountName) by DeviceId, DeviceName, FolderPath, ProcessCommandLine, InitiatingProcessFileName
| where Starts >= 5
| extend SpanMinutes = datetime_diff("minute", LastStart, FirstStart)
| project DeviceName, DeviceId, FolderPath, ProcessCommandLine, InitiatingProcessFileName, Account, Starts, SpanMinutes, FirstStart, LastStart
| sort by Starts desc
| take 100Looks for: new outbound connections from the runtime to somewhere an application would not normally talk. A reverse shell, a staged download or a callback from an exploited node process usually leaves on an arbitrary port, while the application's own API traffic is on 80 and 443. Private ranges are excluded with cidr(). FP: mail submission on 587 and 465, SaaS databases on custom ports, webhook targets on 8080 and 8443, and package registries on internal mirrors. Reverse shells on 443 are not caught here; Q5 covers the command-line shapes.
Known limitations and deployment notes
- PowerQuery: A connection by a process whose name the sensor reports differently from the CQL's process record is not matched.
- PowerQuery assumes: On an IP Connect event the acting process is src.process.*.
- PowerQuery assumes: dst.port.number is numeric on this tenant.
- PowerQuery assumes: An IPv6 destination is not excluded by the private-range tests, so a public IPv6 connection also appears; the CQL covers IPv4 only.
- KQL – Defender XDR: A failed or blocked connection attempt is not captured.
- KQL – Defender XDR assumes: ConnectionSuccess is the action type recorded for completed outbound connections.
- KQL – Defender XDR assumes: ipv4_is_in_range returns null for an IPv6 address, so IPv6 destinations are excluded, matching the IPv4-only CQL.
// HUNT: Node.js or Bun outbound to a public address on a non-web port // MITRE: T1071, T1095, T1571, T1105 | CONF: medium FP: medium COST: medium // REQUIRES: NetworkConnectIP4 and ProcessRollup2 for the same process // FALSE POSITIVES: SMTP submission, SaaS databases on custom ports, webhook targets, internal registries // TUNING: allowlist the RemoteAddressIP4 and RemotePort pairs your applications use, and exclude // TUNING: 587, 465, 8080 and 8443 only on the host groups that legitimately use them // LOOKBACK: 7d (set in the console time picker) #event_simpleName=NetworkConnectIP4 | RemotePort!=443 | RemotePort!=80 | RemotePort!=53 | !cidr(RemoteAddressIP4, subnet="10.0.0.0/8") | !cidr(RemoteAddressIP4, subnet="172.16.0.0/12") | !cidr(RemoteAddressIP4, subnet="192.168.0.0/16") | !cidr(RemoteAddressIP4, subnet="127.0.0.0/8") | !cidr(RemoteAddressIP4, subnet="169.254.0.0/16") | rename(field=ContextProcessId_decimal, as=TargetProcessId_decimal) | join(query={#event_simpleName=ProcessRollup2 | FileName=/^(node|nodejs|node\.exe|bun|bun\.exe)$/i}, field=[aid, TargetProcessId_decimal], include=[FileName, CommandLine, ParentBaseFileName, UserName]) | table([@timestamp, ComputerName, UserName, FileName, CommandLine, RemoteAddressIP4, RemotePort, aid]) | sort(@timestamp, order=desc, limit=200)
5 required fields · View requirements
Required telemetry
- SentinelOne IP Connect events
Required fields
event.type: Event typeevent.time: Event timesrc.process.name: Acting process namedst.ip.address: Destination addressdst.port.number: Destination port
Recommended fields
src.process.cmdline: Acting process command lineendpoint.name: Host
Optional fields
src.process.user: Acting process accountagent.uuid: Agent identifier
Readiness test
event.type = 'IP Connect' dst.ip.address = * | limit 5 | columns event.time, src.process.name, dst.ip.address, dst.port.number
// HUNT: Node.js or Bun outbound to a public address on a non-web port
// SCHEMA PROFILE: sentinelone-endpoint-ocsf
// MITRE: T1071, T1095, T1571, T1105
// CONF: medium | FP: medium
// FALSE POSITIVES: SMTP submission, SaaS databases on custom ports, webhook targets, internal registries
// EQUIVALENCE: equivalent-with-schema-assumptions - Same predicate: a Node.js or Bun process connecting outbound to a non-private address on a port other than 443, 80 or 53. SentinelOne carries the acting process on the IP Connect event itself, so the CQL's join to the process record is not needed.
// REQUIRES: SentinelOne IP Connect events with the acting process populated
// TUNING: Allowlist the dst.ip.address and dst.port.number pairs your applications use, and exclude 587, 465, 8080 and 8443 only on the host groups that legitimately use them.
// LOOKBACK: 7d (set in the console time picker)
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: A connection by a process whose name the sensor reports differently from the CQL's process record is not matched.
// VALIDATION LEVEL: static-reviewed
event.type = 'IP Connect' src.process.name in:anycase ('node', 'nodejs', 'node.exe', 'bun', 'bun.exe') dst.ip.address = * dst.port.number != 443 dst.port.number != 80 dst.port.number != 53 !(net_ipsubnet(dst.ip.address, '10.0.0.0/8') or net_ipsubnet(dst.ip.address, '172.16.0.0/12') or net_ipsubnet(dst.ip.address, '192.168.0.0/16') or net_ipsubnet(dst.ip.address, '127.0.0.0/8') or net_ipsubnet(dst.ip.address, '169.254.0.0/16'))
| columns event.time, endpoint.name, src.process.user, src.process.name, src.process.cmdline, dst.ip.address, dst.port.number, agent.uuid
| sort -event.time
| limit 200Automated validation: static-passed
5 required fields · View requirements
Required telemetry
- DeviceNetworkEvents
Required fields
Timestamp: Event timeActionType: Action typeInitiatingProcessFileName: Initiating process nameRemoteIP: Remote addressRemotePort: Remote port
Recommended fields
InitiatingProcessCommandLine: Initiating process command line
Optional fields
InitiatingProcessAccountName: Initiating process account
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceNetworkEvents | where Timestamp > ago(1d) | project Timestamp, ActionType, InitiatingProcessFileName, RemoteIP, RemotePort | take 5
// HUNT: Node.js or Bun outbound to a public address on a non-web port
// SCHEMA PROFILE: microsoft-defender-xdr
// MITRE: T1071, T1095, T1571, T1105
// CONF: medium | FP: medium
// FALSE POSITIVES: SMTP submission, SaaS databases on custom ports, webhook targets, internal registries
// EQUIVALENCE: equivalent-with-schema-assumptions - Same predicate: a Node.js or Bun process connecting outbound to a non-private IPv4 address on a port other than 443, 80 or 53. DeviceNetworkEvents carries the initiating process, so the CQL's join to the process record is not needed.
// REQUIRES: Defender for Endpoint network telemetry (DeviceNetworkEvents)
// TUNING: Allowlist the RemoteIP and RemotePort pairs your applications use, and exclude 587, 465, 8080 and 8443 only on the host groups that legitimately use them.
// LOOKBACK: 7d
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: A failed or blocked connection attempt is not captured.
// VALIDATION LEVEL: schema-aligned
// AUTOMATED VALIDATION: static-passed; static=passed; fixtures=0/0
DeviceNetworkEvents
| where Timestamp >= ago(7d)
| where ActionType == "ConnectionSuccess"
| where InitiatingProcessFileName in~ ("node", "nodejs", "node.exe", "bun", "bun.exe")
| where RemotePort !in (443, 80, 53)
| where not(ipv4_is_in_range(RemoteIP, "10.0.0.0/8") or ipv4_is_in_range(RemoteIP, "172.16.0.0/12") or ipv4_is_in_range(RemoteIP, "192.168.0.0/16") or ipv4_is_in_range(RemoteIP, "127.0.0.0/8") or ipv4_is_in_range(RemoteIP, "169.254.0.0/16"))
| project Timestamp, ReportId, DeviceId, DeviceName, InitiatingProcessAccountName, InitiatingProcessFileName, InitiatingProcessCommandLine, RemoteIP, RemotePort
| sort by Timestamp desc
| take 200Looks for: command-line shapes that connect a shell to a network socket: the bash /dev/tcp redirect, nc with -e, a named-pipe relay, socat with an exec address, and a Python one-liner that imports socket and starts a shell. Not limited to a node parent, because the in-place exec in Q2 removes node from the lineage. The pattern is generic and keeps its value for every other RCE on the same host. FP: security testing and authorised red-team activity.
Known limitations and deployment notes
- PowerQuery: A reverse shell started from a process the sensor did not report as a creation event is not captured.
- PowerQuery assumes: The pattern is evaluated by the SentinelOne regex engine, which is assumed to read these constructs as the CQL engine does.
- KQL – Defender XDR: A reverse shell started by a process the sensor did not report is not captured.
- KQL – Defender XDR assumes: The pattern is evaluated by the Kusto regex engine, which is assumed to read these constructs as the CQL engine does.
// HUNT: Reverse-shell and shell-over-socket command lines (post-exploitation, any parent) // MITRE: T1059.004, T1095, T1571 | CONF: high FP: low COST: low // REQUIRES: ProcessRollup2 with CommandLine // FALSE POSITIVES: authorised red-team and penetration-test activity // LOOKBACK: 14d (set in the console time picker) #event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/ | CommandLine=/dev.tcp.[0-9]|mkfifo.*(nc|ncat|telnet)|(^|\s)(nc|ncat)\s.*\s-e\s|socat.*exec:|bash\s+-i\s*>&|python[0-9.]*\s.*socket.*(pty|sh|bash)/i | table([@timestamp, ComputerName, UserName, ParentBaseFileName, FileName, CommandLine, aid]) | sort(@timestamp, order=desc, limit=200)
3 required fields · View requirements
Required telemetry
- SentinelOne Process Creation events
Required fields
event.type: Event typeevent.time: Event timetgt.process.cmdline: Created command line
Recommended fields
tgt.process.name: Created process namesrc.process.name: Creator process nameendpoint.name: Host
Optional fields
src.process.user: Creator accountagent.uuid: Agent identifier
Readiness test
event.type = 'Process Creation' tgt.process.cmdline = * | limit 5 | columns event.time, tgt.process.cmdline
// HUNT: Reverse-shell and shell-over-socket command lines (post-exploitation, any parent) // SCHEMA PROFILE: sentinelone-endpoint-ocsf // MITRE: T1059.004, T1095, T1571 // CONF: high | FP: low // FALSE POSITIVES: authorised red-team and penetration-test activity // EQUIVALENCE: equivalent-with-schema-assumptions - Same predicate: the CQL's reverse-shell regex is applied unchanged to the created process's command line on Process Creation events, with any parent. matches is case-insensitive, which reproduces the CQL /i intent. // REQUIRES: SentinelOne process-creation events with tgt.process.cmdline populated // TUNING: Exclude authorised red-team and penetration-test hosts by endpoint.name. // LOOKBACK: 14d (set in the console time picker) // CONFIGURATION: No lookup objects required. // KNOWN GAPS: A reverse shell started from a process the sensor did not report as a creation event is not captured. // VALIDATION LEVEL: static-reviewed event.type = 'Process Creation' tgt.process.cmdline matches 'dev.tcp.[0-9]|mkfifo.*(nc|ncat|telnet)|(^|\\s)(nc|ncat)\\s.*\\s-e\\s|socat.*exec:|bash\\s+-i\\s*>&|python[0-9.]*\\s.*socket.*(pty|sh|bash)' | columns event.time, endpoint.name, src.process.user, src.process.name, tgt.process.name, tgt.process.cmdline, agent.uuid | sort -event.time | limit 200
Automated validation: static-passed
2 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeProcessCommandLine: Created command line
Recommended fields
FileName: Created process nameInitiatingProcessFileName: Creator process name
Optional fields
AccountName: Account
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, ProcessCommandLine | take 5
// HUNT: Reverse-shell and shell-over-socket command lines (post-exploitation, any parent) // SCHEMA PROFILE: microsoft-defender-xdr // MITRE: T1059.004, T1095, T1571 // CONF: high | FP: low // FALSE POSITIVES: authorised red-team and penetration-test activity // EQUIVALENCE: equivalent-with-schema-assumptions - Same predicate: the CQL's reverse-shell regex is applied unchanged to the process command line. The (?i) flag reproduces the CQL /i intent. // REQUIRES: Defender for Endpoint process-creation telemetry (DeviceProcessEvents) // TUNING: Exclude authorised red-team and penetration-test hosts by DeviceName. // LOOKBACK: 14d // CONFIGURATION: No lookup objects required. // KNOWN GAPS: A reverse shell started by a process the sensor did not report is not captured. // VALIDATION LEVEL: schema-aligned // AUTOMATED VALIDATION: static-passed; static=passed; fixtures=0/0 DeviceProcessEvents | where Timestamp >= ago(14d) | where ProcessCommandLine matches regex @"(?i)dev.tcp.[0-9]|mkfifo.*(nc|ncat|telnet)|(^|\s)(nc|ncat)\s.*\s-e\s|socat.*exec:|bash\s+-i\s*>&|python[0-9.]*\s.*socket.*(pty|sh|bash)" | project Timestamp, ReportId, DeviceId, DeviceName, AccountName, InitiatingProcessFileName, FileName, ProcessCommandLine | sort by Timestamp desc | take 200
Looks for: what an operator types once the shell answers: identity and system discovery, environment dumps, the cloud metadata address, and common credential file paths. ELLIO notes the process heap holds session cookies, bearer tokens and key material, and Sysdig lists theft of secrets and cloud tokens from the container as a typical next step. The query is limited to hosts that ran node or Bun in the lookback and to shells and runtimes as the parent. FP: interactive administrators run id, uname and env constantly on the same hosts.
Known limitations and deployment notes
- PowerQuery: The restriction to hosts where node or bun started is not applied, so hosts without a runtime also appear and the result is noisier than the CQL tab.
- PowerQuery assumes: On a Process Creation event src.process.name is the creator and tgt.process.name the created process.
- PowerQuery assumes: matches is case-insensitive, which reproduces the CQL /i intent.
- KQL – Defender XDR: The restriction to hosts where node or bun started is not applied, so hosts without a runtime also appear and the result is noisier than the CQL tab.
- KQL – Defender XDR assumes: FileName and InitiatingProcessFileName hold bare image names on both Linux and Windows sensors.
// HUNT: Discovery and secret-access commands on hosts running Node.js or Bun // MITRE: T1033, T1082, T1083, T1552.001, T1552.005 | CONF: medium FP: medium COST: medium // REQUIRES: ProcessRollup2 with CommandLine and parent // FALSE POSITIVES: interactive administrators, configuration management, container health checks // TUNING: exclude admin jump-host accounts by UserName and your monitoring agent by CommandLine // TUNING: then rank by rarity: a command seen on one host once is worth more than one seen daily // LOOKBACK: 7d (set in the console time picker) #event_simpleName=/^ProcessRollup2$/ | ParentBaseFileName=/^(node|nodejs|bun|sh|bash|dash|ash|busybox)$/i | FileName=/^(id|whoami|uname|hostname|env|printenv|cat|ifconfig|ip|ps|ss|netstat|curl|wget)$/i | CommandLine=/(^|\s)(id|whoami|uname|hostname|printenv|env|ifconfig)(\s|$)|environ|169\.254\.169\.254|passwd|shadow|\.aws|credentials|\.kube|\.npmrc|\.ssh/i | join(query={#event_simpleName=ProcessRollup2 | FileName=/^(node|nodejs|bun)$/i | groupBy([aid], function=count(as=runtimeStarts))}, field=aid, include=[runtimeStarts]) | groupBy([aid, ComputerName, UserName, ParentBaseFileName, FileName, CommandLine], function=[count(as=runs), min(@timestamp, as=firstSeen), max(@timestamp, as=lastSeen)], limit=max) | sort(runs, order=asc, limit=200)
6 required fields · View requirements
Required telemetry
- SentinelOne Process Creation events
Required fields
event.type: Event typeevent.time: Event timesrc.process.name: Creator process nametgt.process.name: Created process nametgt.process.cmdline: Created command lineagent.uuid: Agent identifier
Recommended fields
endpoint.name: Hostsrc.process.user: Creator account
Readiness test
event.type = 'Process Creation' tgt.process.cmdline = * | limit 5 | columns event.time, agent.uuid, src.process.name, tgt.process.name, tgt.process.cmdline
// HUNT: Discovery and secret-access commands on hosts running Node.js or Bun // SCHEMA PROFILE: sentinelone-endpoint-ocsf // MITRE: T1033, T1082, T1083, T1552.001, T1552.005 // CONF: medium | FP: medium // FALSE POSITIVES: interactive administrators, configuration management, container health checks // EQUIVALENCE: approximate-analytic - Hunts the same behaviour (discovery and secret-access commands run by a runtime or a shell) but drops the CQL's join that restricts results to hosts where Node.js or Bun ran, because the offline engine and the shipped fixtures cannot exercise a subquery. The parent, child and command-line predicates and the per-command run counts are unchanged. // REQUIRES: SentinelOne process-creation events with creator lineage // TUNING: Exclude admin jump-host accounts by src.process.user and your monitoring agent by tgt.process.cmdline, then rank by rarity: a command seen on one host once is worth more than one seen daily. Restrict to hosts that run node or bun by pivoting from Q1 or Q9. // LOOKBACK: 7d (set in the console time picker) // CONFIGURATION: No lookup objects required. // KNOWN GAPS: The restriction to hosts where node or bun started is not applied, so hosts without a runtime also appear and the result is noisier than the CQL tab. // VALIDATION LEVEL: static-reviewed event.type = 'Process Creation' src.process.name matches '^(node|nodejs|bun|sh|bash|dash|ash|busybox)$' tgt.process.name matches '^(id|whoami|uname|hostname|env|printenv|cat|ifconfig|ip|ps|ss|netstat|curl|wget)$' tgt.process.cmdline matches '(^|\\s)(id|whoami|uname|hostname|printenv|env|ifconfig)(\\s|$)|environ|169\\.254\\.169\\.254|passwd|shadow|\\.aws|credentials|\\.kube|\\.npmrc|\\.ssh' | group runs = count(), first_seen = min(event.time), last_seen = max(event.time) by agent.uuid, endpoint.name, src.process.user, src.process.name, tgt.process.name, tgt.process.cmdline | sort +runs | limit 200
Automated validation: static-passed
4 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeInitiatingProcessFileName: Creator process nameFileName: Created process nameProcessCommandLine: Created command line
Recommended fields
AccountName: AccountDeviceId: Device identifier
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, InitiatingProcessFileName, FileName, ProcessCommandLine | take 5
// HUNT: Discovery and secret-access commands on hosts running Node.js or Bun
// SCHEMA PROFILE: microsoft-defender-xdr
// MITRE: T1033, T1082, T1083, T1552.001, T1552.005
// CONF: medium | FP: medium
// FALSE POSITIVES: interactive administrators, configuration management, container health checks
// EQUIVALENCE: approximate-analytic - Hunts the same behaviour (discovery and secret-access commands run by a runtime or a shell) but drops the CQL's join that restricts results to hosts where Node.js or Bun ran, since a KQL card here reads one table with no second source. The parent, child and command-line predicates and the per-command run counts are unchanged.
// REQUIRES: Defender for Endpoint process-creation telemetry (DeviceProcessEvents)
// TUNING: Exclude admin jump-host accounts by AccountName and your monitoring agent by ProcessCommandLine, then rank by rarity. Restrict to hosts that run node or bun by pivoting from Q1 or Q9.
// LOOKBACK: 7d
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: The restriction to hosts where node or bun started is not applied, so hosts without a runtime also appear and the result is noisier than the CQL tab.
// VALIDATION LEVEL: schema-aligned
// AUTOMATED VALIDATION: static-passed; static=passed-with-warnings; fixtures=0/0
DeviceProcessEvents
| where Timestamp >= ago(7d)
| where InitiatingProcessFileName in~ ("node", "nodejs", "bun", "sh", "bash", "dash", "ash", "busybox")
| where FileName in~ ("id", "whoami", "uname", "hostname", "env", "printenv", "cat", "ifconfig", "ip", "ps", "ss", "netstat", "curl", "wget")
| where ProcessCommandLine matches regex @"(?i)(^|\s)(id|whoami|uname|hostname|printenv|env|ifconfig)(\s|$)|environ|169\.254\.169\.254|passwd|shadow|\.aws|credentials|\.kube|\.npmrc|\.ssh"
| summarize Runs = count(), FirstSeen = min(Timestamp), LastSeen = max(Timestamp) by DeviceId, DeviceName, AccountName, InitiatingProcessFileName, FileName, ProcessCommandLine
| sort by Runs asc
| take 200Looks for: the vulnerable-version inventory angle, and an honest statement of its limit. Falcon process telemetry carries the binary hash and path but not an ELF file version, and the Node.js project cherry-picked the fix without changing the bundled version string, so process.versions.brotli reports 1.2.0 on both patched and vulnerable builds (ELLIO). This query lists every distinct node, Bun, .NET and Envoy binary and how many hosts run it. Map each hash to a version offline against the official release hashes, or run the version commands in Section 7 N4 on the hosts. Container images appear under their in-container path.
Known limitations and deployment notes
- PowerQuery: A runtime that did not start inside the window is not listed.
- PowerQuery assumes: tgt.process.image.sha256 is populated for Process Creation events on this tenant.
- PowerQuery assumes: estimate_distinct is an approximate count of hosts.
- PowerQuery assumes: in:anycase is case-insensitive, which reproduces the CQL /i intent.
- KQL – Defender XDR: A runtime that did not start inside the window is not listed.
- KQL – Defender XDR assumes: FolderPath holds the full image path of the created process.
- KQL – Defender XDR assumes: SHA256 may be empty; SHA1 is the safer pivot.
// HUNT: Inventory of Node.js, Bun, .NET and Envoy binaries by hash, path and host count // MITRE: T1190 | CONF: medium FP: low COST: medium // REQUIRES: ProcessRollup2 with SHA256HashData for runtime binaries that have executed in the window // FALSE POSITIVES: not a detection; every running runtime is listed by design // LOOKBACK: 30d (set in the console time picker); only binaries that ran in the window appear #event_simpleName=/^ProcessRollup2$/ | FileName=/^(node|nodejs|node\.exe|bun|bun\.exe|dotnet|dotnet\.exe|envoy|envoy\.exe)$/i | groupBy([FileName, SHA256HashData, ImageFileName], function=[count(aid, distinct=true, as=hosts), count(as=starts), min(@timestamp, as=firstSeen), max(@timestamp, as=lastSeen)], limit=max) | sort(hosts, order=desc, limit=500)
6 required fields · View requirements
Required telemetry
- SentinelOne Process Creation events
Required fields
event.type: Event typeevent.time: Event timetgt.process.name: Created process nametgt.process.image.sha256: Created image hashtgt.process.image.path: Created image pathagent.uuid: Agent identifier
Optional fields
tgt.process.image.sha1: Hash. SHA-1 is an alternative pivot where SHA-256 is unpopulated.
Readiness test
event.type = 'Process Creation' tgt.process.image.sha256 = * | limit 5 | columns event.time, agent.uuid, tgt.process.name, tgt.process.image.sha256, tgt.process.image.path
// HUNT: Inventory of Node.js, Bun, .NET and Envoy binaries by hash, path and host count
// SCHEMA PROFILE: sentinelone-endpoint-ocsf
// MITRE: T1190
// CONF: medium | FP: low
// FALSE POSITIVES: not a detection; every running runtime is listed by design
// EQUIVALENCE: equivalent-with-schema-assumptions - Same inventory: runtime binaries that executed in the window, grouped by name, SHA-256 and image path with a host count, a start count and first and last seen. The host count is an estimate where the CQL counts exactly.
// REQUIRES: SentinelOne process-creation events with tgt.process.image.sha256 populated
// TUNING: Not a detection. Compare each hash with the vendor release hashes for your installed runtimes and investigate any hash seen on a single host.
// LOOKBACK: 30d (set in the console time picker); only binaries that ran in the window appear
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: A runtime that did not start inside the window is not listed.
// VALIDATION LEVEL: static-reviewed
event.type = 'Process Creation' tgt.process.name in:anycase ('node', 'nodejs', 'node.exe', 'bun', 'bun.exe', 'dotnet', 'dotnet.exe', 'envoy', 'envoy.exe')
| group hosts = estimate_distinct(agent.uuid), starts = count(), first_seen = min(event.time), last_seen = max(event.time) by tgt.process.name, tgt.process.image.sha256, tgt.process.image.path
| sort -hosts
| limit 500Automated validation: static-passed
4 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeFileName: Created process nameFolderPath: Created image pathDeviceId: Device identifier
Recommended fields
SHA1: Hash
Optional fields
SHA256: Hash. SHA256 is often unpopulated in DeviceProcessEvents; SHA1 is the safer pivot.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, DeviceId, FileName, FolderPath, SHA1 | take 5
// HUNT: Inventory of Node.js, Bun, .NET and Envoy binaries by hash, path and host count
// SCHEMA PROFILE: microsoft-defender-xdr
// MITRE: T1190
// CONF: medium | FP: low
// FALSE POSITIVES: not a detection; every running runtime is listed by design
// EQUIVALENCE: equivalent-with-schema-assumptions - Same inventory: runtime binaries that executed in the window, grouped by name, hash and path with a device count, a start count and first and last seen. SHA1 is added because SHA256 is often unpopulated in DeviceProcessEvents.
// REQUIRES: Defender for Endpoint process-creation telemetry (DeviceProcessEvents)
// TUNING: Not a detection. Compare each hash with the vendor release hashes for your installed runtimes and investigate any hash seen on a single device.
// LOOKBACK: 30d (Advanced Hunting retention); only binaries that ran in the window appear
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: A runtime that did not start inside the window is not listed.
// VALIDATION LEVEL: schema-aligned
// AUTOMATED VALIDATION: static-passed; static=passed-with-warnings; fixtures=0/0
DeviceProcessEvents
| where Timestamp >= ago(30d)
| where FileName in~ ("node", "nodejs", "node.exe", "bun", "bun.exe", "dotnet", "dotnet.exe", "envoy", "envoy.exe")
| summarize Hosts = dcount(DeviceId), Starts = count(), FirstSeen = min(Timestamp), LastSeen = max(Timestamp) by FileName, SHA1, SHA256, FolderPath
| sort by Hosts desc
| take 500Looks for: a sweep for the single source address ELLIO attributes to the 2026-10-26 exploit attempt. That address connected to ELLIO's sensors, so the useful telemetry on your side is an inbound connection, which this query cannot see because no inbound-accept event is confirmed in the reference set. It will only match if one of your hosts connects out to that address. Treat it as a cheap supplement to the Falcon IOC row and to the web and proxy log hunt in Section 7 N1, which is where the real signal is. Single-sourced and perishable.
Known limitations and deployment notes
- PowerQuery: An inbound connection from the address is not distinguished from an outbound one.
- PowerQuery assumes: dst.ip.address holds the remote address of an outbound IP Connect event.
- KQL – Defender XDR: Connection direction is not filtered, so an inbound connection from the address also matches.
- KQL – Defender XDR assumes: RemoteIP holds the remote address for the connection rows.
// HUNT: Outbound connection to the source address in the 2026-10-26 exploit attempt (ELLIO) // MITRE: T1071 | CONF: low FP: low COST: low // REQUIRES: NetworkConnectIP4 // FALSE POSITIVES: none expected; this is an atomic indicator sweep // LOOKBACK: 30d (set in the console time picker) #event_simpleName=NetworkConnectIP4 | RemoteAddressIP4="94.154.43.7" | table([@timestamp, ComputerName, aid, RemoteAddressIP4, RemotePort, ContextProcessId_decimal]) | sort(@timestamp, order=desc, limit=200)
3 required fields · View requirements
Required telemetry
- SentinelOne IP Connect events
Required fields
event.type: Event typeevent.time: Event timedst.ip.address: Destination address
Recommended fields
src.process.name: Acting process nameendpoint.name: Host
Optional fields
dst.port.number: Destination portagent.uuid: Agent identifier
Readiness test
event.type = 'IP Connect' dst.ip.address = * | limit 5 | columns event.time, dst.ip.address
// HUNT: Outbound connection to the source address in the 2026-10-26 exploit attempt (ELLIO) // SCHEMA PROFILE: sentinelone-endpoint-ocsf // MITRE: T1071 // CONF: low | FP: low // FALSE POSITIVES: none expected; this is an atomic indicator sweep // EQUIVALENCE: equivalent-with-schema-assumptions - Same atomic sweep: any connection event whose remote address is the published source address. The acting process name and command line replace the CQL's numeric process identifier because SentinelOne carries them on the event. // REQUIRES: SentinelOne IP Connect events // TUNING: Atomic indicator sweep; no tuning expected. The address is the source of an observed exploit attempt, so a hit on an outbound connection is unusual and should be triaged. // LOOKBACK: 30d (set in the console time picker) // CONFIGURATION: No lookup objects required. // KNOWN GAPS: An inbound connection from the address is not distinguished from an outbound one. // VALIDATION LEVEL: static-reviewed event.type = 'IP Connect' dst.ip.address = '94.154.43.7' | columns event.time, endpoint.name, src.process.name, src.process.cmdline, dst.ip.address, dst.port.number, agent.uuid | sort -event.time | limit 200
Automated validation: static-passed
2 required fields · View requirements
Required telemetry
- DeviceNetworkEvents
Required fields
Timestamp: Event timeRemoteIP: Remote address
Recommended fields
InitiatingProcessFileName: Initiating process name
Optional fields
RemotePort: Remote port
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceNetworkEvents | where Timestamp > ago(1d) | project Timestamp, RemoteIP | take 5
// HUNT: Outbound connection to the source address in the 2026-10-26 exploit attempt (ELLIO) // SCHEMA PROFILE: microsoft-defender-xdr // MITRE: T1071 // CONF: low | FP: low // FALSE POSITIVES: none expected; this is an atomic indicator sweep // EQUIVALENCE: equivalent-with-schema-assumptions - Same atomic sweep: any network event whose remote address is the published source address. The initiating process name and command line replace the CQL's numeric process identifier. // REQUIRES: Defender for Endpoint network telemetry (DeviceNetworkEvents) // TUNING: Atomic indicator sweep; no tuning expected. // LOOKBACK: 30d (Advanced Hunting retention) // CONFIGURATION: No lookup objects required. // KNOWN GAPS: Connection direction is not filtered, so an inbound connection from the address also matches. // VALIDATION LEVEL: schema-aligned // AUTOMATED VALIDATION: static-passed; static=passed; fixtures=0/0 DeviceNetworkEvents | where Timestamp >= ago(30d) | where RemoteIP == "94.154.43.7" | project Timestamp, ReportId, DeviceId, DeviceName, InitiatingProcessFileName, InitiatingProcessCommandLine, RemoteIP, RemotePort | sort by Timestamp desc | take 200
Looks for: not a detection. It lists every parent and child pair for node, Bun, .NET and Envoy with the number of hosts that show it, rarest first. Run it for seven to thirty days before enabling Q1, Q2 and Q6 as alerts, and build the exclusion lists from this output instead of guessing. A pair seen on one host is a lead; a pair seen on three hundred is your deploy tooling.
Known limitations and deployment notes
- PowerQuery: Children of a runtime process the sensor did not observe starting are not listed.
- PowerQuery assumes: On a Process Creation event src.process.name is the creator and tgt.process.name the created process.
- PowerQuery assumes: estimate_distinct is an approximate count of hosts.
- PowerQuery assumes: in:anycase is case-insensitive, which reproduces the CQL /i intent.
- KQL – Defender XDR: Children of a runtime process the sensor did not observe starting are not listed.
- KQL – Defender XDR assumes: FileName and InitiatingProcessFileName hold bare image names on both Linux and Windows sensors.
- KQL – Defender XDR assumes: dcount is an approximate count of devices.
// HUNT: Baseline of child processes launched by Node.js, Bun, .NET and Envoy (rarest first) // MITRE: T1059.004 | CONF: medium FP: low COST: low // REQUIRES: ProcessRollup2 with parent lineage // FALSE POSITIVES: not a detection; benign tooling is listed by design // LOOKBACK: 30d (set in the console time picker) #event_simpleName=/^ProcessRollup2$/ | ParentBaseFileName=/^(node|nodejs|node\.exe|bun|bun\.exe|dotnet|dotnet\.exe|envoy)$/i | groupBy([ParentBaseFileName, FileName], function=[count(aid, distinct=true, as=hosts), count(as=execs)], limit=max) | sort(hosts, order=asc, limit=300)
4 required fields · View requirements
Required telemetry
- SentinelOne Process Creation events
Required fields
event.type: Event typesrc.process.name: Creator process nametgt.process.name: Created process nameagent.uuid: Agent identifier
Readiness test
event.type = 'Process Creation' tgt.process.name = * | limit 5 | columns agent.uuid, src.process.name, tgt.process.name
// HUNT: Baseline of child processes launched by Node.js, Bun, .NET and Envoy (rarest first)
// SCHEMA PROFILE: sentinelone-endpoint-ocsf
// MITRE: T1059.004
// CONF: medium | FP: low
// FALSE POSITIVES: not a detection; benign tooling is listed by design
// EQUIVALENCE: equivalent-with-schema-assumptions - Same baseline: child processes whose creator is a runtime (Node.js, Bun, .NET or Envoy), grouped by creator and child with a host count and an execution count, rarest first. On a Process Creation event the creator is src.process.name and the child is tgt.process.name. The host count is an estimate where the CQL counts exactly.
// REQUIRES: SentinelOne process-creation events with creator lineage
// TUNING: Not a detection. Review the rarest rows first and allowlist the child names your applications legitimately launch before alerting on Q1.
// LOOKBACK: 30d (set in the console time picker)
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: Children of a runtime process the sensor did not observe starting are not listed.
// VALIDATION LEVEL: static-reviewed
event.type = 'Process Creation' src.process.name in:anycase ('node', 'nodejs', 'node.exe', 'bun', 'bun.exe', 'dotnet', 'dotnet.exe', 'envoy')
| group hosts = estimate_distinct(agent.uuid), execs = count() by src.process.name, tgt.process.name
| sort +hosts
| limit 300Automated validation: static-passed
4 required fields · View requirements
Required telemetry
- DeviceProcessEvents
Required fields
Timestamp: Event timeInitiatingProcessFileName: Creator process nameFileName: Created process nameDeviceId: Device identifier
Fallbacks
DeviceName→DeviceId: Changes the displayed host identifier only.
Readiness test
DeviceProcessEvents | where Timestamp > ago(1d) | project Timestamp, DeviceId, InitiatingProcessFileName, FileName | take 5
// HUNT: Baseline of child processes launched by Node.js, Bun, .NET and Envoy (rarest first)
// SCHEMA PROFILE: microsoft-defender-xdr
// MITRE: T1059.004
// CONF: medium | FP: low
// FALSE POSITIVES: not a detection; benign tooling is listed by design
// EQUIVALENCE: equivalent-with-schema-assumptions - Same baseline: child processes whose creator is a runtime, grouped by creator and child with a device count and an execution count, rarest first. The CQL parent is InitiatingProcessFileName and the child is FileName.
// REQUIRES: Defender for Endpoint process-creation telemetry (DeviceProcessEvents)
// TUNING: Not a detection. Review the rarest rows first and allowlist the child names your applications legitimately launch before alerting on Q1.
// LOOKBACK: 30d (Advanced Hunting retention)
// CONFIGURATION: No lookup objects required.
// KNOWN GAPS: Children of a runtime process the sensor did not observe starting are not listed.
// VALIDATION LEVEL: schema-aligned
// AUTOMATED VALIDATION: static-passed; static=passed-with-warnings; fixtures=0/0
DeviceProcessEvents
| where Timestamp >= ago(30d)
| where InitiatingProcessFileName in~ ("node", "nodejs", "node.exe", "bun", "bun.exe", "dotnet", "dotnet.exe", "envoy")
| summarize Hosts = dcount(DeviceId), Execs = count() by InitiatingProcessFileName, FileName
| sort by Hosts asc
| take 300Detection Rules by Platform
Custom IOA candidates
CrowdStrike: Custom IOA
| IOA name | Pattern | Exclusions | Action | From |
|---|---|---|---|---|
| RUNTIME - Application Runtime Spawns Shell or Downloader | Process creation where the parent image is node, bun, dotnet or envoy and the child image is a shell, curl, wget, nc, socat or a scripting interpreter. Promote Q1. | Build and CI host groups; developer workstations; documented process-manager wrappers (derive from the Q9 baseline) | Detect (High) on production web host groups; Monitor for 7 days first | Q1 |
| RUNTIME - Reverse Shell Command Line | Command line matches a /dev/tcp redirect, nc -e, named-pipe relay, socat exec address or Python socket-to-shell one-liner. Promote Q5. | Authorised red-team host groups | Detect (Critical); low volume, safe to promote quickly | Q5 |
| RUNTIME - Node Process Re-Executed as Shell | A PID that ran node or bun later runs a shell within an hour. Promote Q2 only after the in-place exec test in the query card passes in your tenant, because an IOA pattern cannot express "same PID" and this is a scheduled search. | Build agents; PID-churn-heavy hosts | Scheduled search, Monitor first | Q2 |
Not IOA candidates: Q3 (restart loops) and Q4 (non-web outbound) are volume hunts that need per-application baselines; Q6 needs admin-account tuning; Q7 and Q9 are inventory and baseline; Q8 is a perishable single-source indicator sweep. No IOA in this pack uses a Prevent action: the behaviours are real but application runtimes spawning children is legitimate in enough places that an automatic kill would break deploys, and no corroborated source describes a fixed prevention rule.
Scheduled Search packaging (for the promoted IOAs)
| Field | Value |
|---|---|
| Scheduled Search name | DETECT - Application Runtime Spawns Shell (Node2Shell / CVE-2026-111497) |
| Description | Fires when node, Bun, .NET or Envoy becomes the parent of a shell, downloader or interpreter. This is the expected post-exploitation shape of a successful Brotli decoder exploit and of runtime RCE in general, so it keeps its value after this CVE is patched. |
| Schedule | Every 15 minutes, 30-minute lookback with overlap |
| Alert subject | [HIGH] Detection: Node2Shell - application runtime spawned a shell |
Alert email body (template)
WHAT FIRED
An application runtime (node, Bun, .NET or Envoy) started a shell, downloader or
scripting interpreter on a host in the runtime server group.
Host : {ComputerName}
Account : {UserName}
Parent : {ParentBaseFileName}
Child : {FileName}
Command line : {CommandLine}
WHY IT MATTERS
CVE-2026-111497 (Node2Shell) can give an attacker a shell as the service account
through a crafted Brotli stream. This alert is the post-exploitation footprint.
It can also be build tooling, so check the host role first.
IMMEDIATE ACTIONS
1. Confirm the host role. A build agent or developer machine is likely benign.
2. Check the runtime version (Section 7 N4). Patched or not?
3. Pull the process tree and the 10 minutes of network connections around the event.
4. If it is a production web tier and the command line is not a documented script,
isolate the host (Section 14) and do not restart the service before capturing state.
ESCALATE TO IR WHEN
- the shell is interactive or the command line is a download or reverse-shell shape
- the same host shows a node restart burst (Q3) before the shell
- the runtime is an unpatched 22.x, 24.x or 26.x build or an end-of-life line
Triage checklist (L1)
- Identify the host role and owner. Build, CI and developer hosts are expected sources.
- Read the full command line. Is it a documented npm script or deploy hook?
- Check the runtime version and whether the application decodes Brotli (Section 7 N2, N4).
- Look for restart bursts of the same command line in the previous hour (Q3).
- Review outbound connections from the same host (Q4) and reverse-shell shapes (Q5).
- Look for discovery or secret-access commands afterwards (Q6).
- Check web and proxy logs for a
br-encoded request in the preceding minutes (Section 7 N1). - If unexplained, escalate and isolate; record the Falcon aid and the process start time.
Pivot queries
// Host-scoped: everything a runtime did on one host around the alert #event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/ | aid=REPLACE_WITH_AID | ParentBaseFileName=/^(node|nodejs|bun|sh|bash|dash)$/i | table([@timestamp, ParentBaseFileName, FileName, CommandLine, UserName]) | sort(@timestamp, order=asc, limit=500) // Environment-wide: same child command line on other hosts #event_simpleName=/^(ProcessRollup2|SyntheticProcessRollup2)$/ | CommandLine="REPLACE_WITH_COMMAND_LINE" | groupBy([ComputerName, aid], function=count())
SentinelOne: custom detection rules
| Rule | Source query | Run as | Notes |
|---|---|---|---|
| RUNTIME - Application Runtime Spawns Shell or Downloader | Q1, PowerQuery tab | Scheduled custom detection rule | Bind entities so the alert names the host instead of "Unknown Device". This pack's own text mentions a baseline or pilot; follow it as written |
| RUNTIME - Reverse Shell Command Line | Q5, PowerQuery tab | Scheduled custom detection rule | Bind entities so the alert names the host instead of "Unknown Device". This pack's own text mentions a baseline or pilot; follow it as written |
| RUNTIME - Node Process Re-Executed as Shell | Q2, PowerQuery tab | Hunt only until the gap is closed | Q2 is only an approximation here. Hunts the same behaviour (one PID seen as a runtime and then as a shell) by a different analytic: PowerQuery has no arithmetic or conditional timestamp aggregation here, so the pair is matched on the same PID inside one clock hour instead of a measured gap, and the order of the two events is not.... This pack's own text mentions a baseline or pilot; follow it as written |
Create the rule with POST /web/api/v2.1/cloud-detection/rules, queryType: "scheduled" and queryLang: "2.0", with the query in data.scheduledParams.query. Use 2.0 operator spellings: a rule written with the 1.0 token ContainsCIS was accepted and never fired on a tenant. The API does not warn, so lint the body before deploying.
Microsoft Defender: custom detection rules
| Rule | Source query | Run as | Notes |
|---|---|---|---|
| RUNTIME - Application Runtime Spawns Shell or Downloader | Q1, KQL – Defender XDR tab | Custom detection rule, alert only, every hour | The query returns Timestamp, ReportId and DeviceId, which Microsoft recommends for Defender for Endpoint tables so alerts carry the device group scope and the process tree. Remove the Timestamp filter and the final sort and take before saving the rule: the service applies the rule's lookback itself. This pack's own text mentions a baseline or pilot; follow it as written |
| RUNTIME - Reverse Shell Command Line | Q5, KQL – Defender XDR tab | Custom detection rule, alert only, every hour | The query returns Timestamp, ReportId and DeviceId, which Microsoft recommends for Defender for Endpoint tables so alerts carry the device group scope and the process tree. Remove the Timestamp filter and the final sort and take before saving the rule: the service applies the rule's lookback itself. This pack's own text mentions a baseline or pilot; follow it as written |
| RUNTIME - Node Process Re-Executed as Shell | Q2, KQL – Defender XDR tab | Hunt only until the gap is closed | Q2 is only an approximation here. Hunts the same behaviour (one PID seen as a runtime and then as a shell) by a different analytic: rows are grouped by ProcessId per device, both name sets must be present, and the span between first and last row must be under 60 minutes. The order of the two events is not checked.. This pack's own text mentions a baseline or pilot; follow it as written |
Q3, Q4, Q6, Q7, Q8 and Q9 stay a hunt in this pack, not a rule. A rule can generate only 150 alerts per run. Continuous (NRT) frequency needs a single table and no comment lines, so delete the generated comment header from the query first.
Machine-Readable IOC Appendix
type,value,action,severity,expiration,description,tags sha256,7fcbdaaecd4419e2930927844f3b772668204737a4e7602a2290c991c93c5c4b,detect,medium,2027-01-28,Node2Shell exploit request body observed by ELLIO 2026-10-26 (single source; network payload hash),campaign:Node2Shell ipv4,94.154.43.7,detect,medium,2027-01-28,Node2Shell exploit attempt source observed by ELLIO 2026-10-26 (single source; shared mass-exploitation infrastructure),campaign:Node2Shell # No prevent rows. Both indicators are single-sourced to ELLIO. No domains, URLs or file hashes # of dropped payloads have been published, so none are listed; do not invent placeholders as detect rows.
indicatorType,indicatorValue,action,title,description,expirationTime,severity,recommendedActions,rbacGroups,category,mitretechniques,GenerateAlert FileSha256,7fcbdaaecd4419e2930927844f3b772668204737a4e7602a2290c991c93c5c4b,Audit,Node2Shell exploit request body observed by ELLIO 2026-10-26,Node2Shell exploit request body observed by ELLIO 2026-10-26 (single source; network payload hash),2027-01-28T00:00:00.0Z,Medium,Investigate the device and pivot into the pack's queries,,,,True IpAddress,94.154.43.7,Audit,Node2Shell exploit attempt source observed by ELLIO 2026-10-,Node2Shell exploit attempt source observed by ELLIO 2026-10-26 (single source; shared mass-exploitation infrastructure),2027-01-28T00:00:00.0Z,Medium,Investigate the device and pivot into the pack's queries,,,,True
Column names and allowed values are from Microsoft's documented parameter table. Check each expirationTime before importing: an indicator whose date has passed is already expired. Download the sample CSV from the Import page to confirm the column order before a bulk import. Each file takes at most 500 indicators. Learn describes the import per entity type, so if a mixed file is rejected, split it by indicatorType. An imported indicator does nothing unless the matching Advanced features are on (for example Custom network indicators and Allow or block file).
This pack does not ship a SentinelOne import block: the import format could not be verified against SentinelOne's own documentation. Export a sample from your console and the block can be added with confidence.
PROCESS LINEAGE (endpoint)
node | nodejs | node.exe | bun | bun.exe | dotnet | envoy
-> sh | bash | dash | ash | zsh | busybox | curl | wget | nc | ncat | socat | python | perl | ruby
same PID: node or bun, then within an hour a shell (in-place exec; shell parent is the supervisor)
burst of identical node or bun starts on one host (crash and respawn)
node or bun connecting to a public address on a non-web port
reverse-shell command lines: /dev/tcp redirect, nc -e, mkfifo relay, socat exec, python socket+pty
WEB / PROXY / WAF (outside endpoint telemetry)
POST, PUT or PATCH with Content-Encoding: br to a public route
very large (multi-megabyte) Brotli request bodies; exploit body was about 23 MB
User-Agent: Mozilla/5.0 (compatible; node2shell-check/1.0)
checker pattern: GET / then 20 POST bodies with Content-Encoding: br in two bursts about two minutes apart
source address 94.154.43.7
patched server answering 400 with ERR__ERROR_FORMAT_BLOCK_SWITCH (decoder error -17)
HOST
node segfault or general protection fault in kernel log after 2026-10-21
new core files under /var/lib/systemd/coredump from node
container restart counts rising on Node.js workloads
THREAT Node2Shell (nicknames: Broccoli2Shell, Brotli2Shell, HTTP2Shell). One CVE.
CVE CVE-2026-111497 Google Brotli decoder out-of-bounds write (CWE-787) from an
unchecked return value (CWE-252). CVSS 4.0 9.2 (Google). Microsoft rates .NET 8.1 (CVSS 3.1).
ACTOR none named in any source
KEV added 2026-10-27, due 2026-11-10, known ransomware use: Unknown
DISCLOSED 2026-10-21 (Node.js fixes). Upstream fix commit 0ed4268a, committed 2026-02-27.
NEW ERROR BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH (-17)
TOOLING node2shell-check (public exposure checker, first observed 2026-06-13, AS44620)
CREDITS reported by OpenAI Codex Security (Khai Tran, Sylvie Mayer); fixed by Matteo Collina
and Antoine du Hamel; arm64 exploitation by Imperva Red Team; x86-64 by ELLIO
RELIABILITY
Imperva: official Node.js 24.22.0 Linux arm64 image, several attempts; other architectures,
22.x and 26.x not tested.
ELLIO: x86-64, four setups, ASLR on, shell around attempt 5 with ASLR on; most Express
attempts crashed; offset is per Node.js build (24.22.0 value gave nothing in
10,000 attempts on 22.23.3 and 26.12.0).
Others: crashes only on standard builds; blind RCE with ASLR on did not work for one team.
ITW: the 2026-10-26 exploit request scored 0 of 50 shells in ELLIO's own tests.
FIXED VERSIONS Node.js 22.23.4 | 24.22.1 | 26.12.1 (20.x, 23.x, 25.x: no fix, replace) Brotli 1.0.10 | 1.1.1 | 1.2.1 Bun 1.4.4 .NET 8.0.33 | 9.0.22 | 10.0.14 PyPI brotli 1.2.1 | brotlicffi 1.2.1.0 Envoy 1.40.0 | 1.39.3 | 1.38.6 | 1.37.8 | 1.36.12 Istio 1.31.2 | 1.30.6 | 1.29.9 AUDIT CHECKLIST [ ] node --version on every host, container and CI image; pinned Dockerfile tags updated [ ] process.versions.brotli is NOT a valid check (reads 1.2.0 on patched and vulnerable) [ ] bun build --compile executables rebuilt, not just Bun upgraded [ ] distro Node.js: check the system brotli package, not the runtime version [ ] Express 5 / body-parser 2.x, Koa, Fastify compress: which routes decode br [ ] server-side fetch() on user-influenced URLs inventoried [ ] Content-Encoding: br rejected at the edge where no client needs it [ ] services run unprivileged, secrets out of process environment, core dumps restricted
Detection Validation Gates
| Gate | Check | Pass condition |
|---|---|---|
| 1 · Telemetry ready | Confirm process creation with command line, image path and parent (CrowdStrike: ProcessRollup2. SentinelOne: Process Creation. Defender: DeviceProcessEvents) reaches your platform from every Node.js, Bun, .NET and Envoy host group. Falcon check: #event_simpleName=/^ProcessRollup2$/ | FileName=/^(node|bun)$/i | groupBy([ComputerName], limit=500) | Every runtime host you believe you own appears. A missing host, and any container host with no sensor, is a blind spot to record, not a clean result. |
| 2 · Parent and PID populated | Confirm the parent image and the OS process ID are populated on Linux and container hosts | Q1, Q2 and Q9 key on the parent; Q2 keys on the PID. If either is blank on containerised hosts, those queries are silently useless there. |
| 3 · In-place exec behaviour | On an isolated non-production host, run a harmless test application that calls exec on a shell, then run Q2 | Q2 returns the PID. If it does not, record Q2 as unvalidated for that sensor version and rely on Q1, Q5 and Q6. |
| 4 · Benign baseline | Run Q9 over 30 days, then Q1 and Q3 over 14 days before alerting | You can name every recurring parent-child pair and every restart pattern. The IOA exclusion lists come from this run. |
| 5 · Web log path | Confirm the proxy or web-server log format records Content-Encoding and request length, and that the logs reach a searchable platform | Section 7 N1 returns data. Without it the exploit request and the checker sweep are permanently invisible. |
| 6 · Positive test | On a non-production host, an authorised operator runs a benign shell command through a test node script and confirms Q1 returns it | Q1 returns the event within expected latency. This validates the detection path without using any exploit. |
| 7 · Promotion | Move Q1 and Q5 to Scheduled Searches in Monitor mode for 7 days, then Detect | Fewer than roughly 5 unexplained hits per week per 100 runtime hosts. Above that, tune before promoting. |
Hardening: Tiered
H1 · Patch to a fixed release. MITRE M1051 Update Software. Node.js 22.23.4, 24.22.1 or 26.12.1; Bun 1.4.4; Brotli 1.0.10, 1.1.1 or 1.2.1; .NET 8.0.33, 9.0.22 or 10.0.14; Python brotli 1.2.1 and brotlicffi 1.2.1.0; Envoy and Istio per Section 10. Rebuild images from a patched base, update pinned Dockerfile tags and lockfiles, restart processes, and rebuild bun build --compile executables. Authority: Node.js October 2026 security releases; CISA KEV BOD 26-04 due date 2026-11-10 for federal agencies. Verify with node --version, never process.versions.brotli.
H2 · Reject Brotli request bodies at the edge. MITRE M1042 Disable or Remove Feature or Program, M1031 Network Intrusion Prevention. Browsers do not send compressed request bodies, so dropping Content-Encoding: br on inbound requests costs most services nothing. Playbook P2. It does not cover Brotli responses your services fetch. Authority: ELLIO and Sysdig mitigation lists; Sysdig attributes the proxy rule to Brandefense as its own suggestion, not vendor guidance, and says to test it before relying on it. best-practice, no formal benchmark
H3 · Retire end-of-life Node.js lines. MITRE M1051. Node.js 20.x, 23.x and 25.x get no fix. Move to 22.x or 24.x. Authority: Node.js release schedule (Sysdig table of end-of-life dates).
H4 · Disable request decompression where unused. MITRE M1042. In Express use express.json({ inflate: false }); it rejects compressed bodies with 415 and also rejects gzip, so test clients first. Remove @fastify/compress request decompression and direct zlib.brotli* calls on untrusted data. Playbook P3. Authority: Sysdig mitigation guidance, Node.js and Express documentation.
H5 · Run Node.js as an unprivileged user in a hardened container. MITRE M1026 Privileged Account Management, M1048 Application Isolation and Sandboxing, M1038 Execution Prevention. Non-root user, read-only root filesystem, no added capabilities, no privilege escalation, default seccomp. Code execution lands with the process permissions, and ELLIO's lab shell ran as root in the official image. Playbook P4. Authority: CIS Docker Benchmark (container user, read-only root filesystem, no-new-privileges), Kubernetes Pod Security Standards "restricted" profile, NIST SP 800-190.
H6 · Restrict core dumps and who reads them. MITRE M1022 Restrict File and Directory Permissions. A crash copies live memory including session cookies, tokens and key material (ELLIO). Playbook P5. Authority: CIS Linux benchmarks (core dump restriction). Trade-off: you lose crash diagnostics on those hosts.
H7 · Keep secrets out of the process environment and heap lifetime. MITRE M1026, M1027 Password Policies. Use a secrets manager with short-lived credentials so a stolen environment expires quickly. Authority: Sysdig contain-the-blast-radius guidance; cloud-provider least-privilege guides. best-practice, no formal benchmark
H8 · Software bill of materials for every runtime and bundled native library. MITRE M1051, M1016 Vulnerability Scanning. A Node.js upgrade fixes only the copy Node.js bundles; DuckDB, .NET, PHP extensions, distro libbrotli and compiled Bun executables each carry their own (ELLIO). Without an SBOM you cannot answer "where is Brotli" in an hour. Authority: NIST SP 800-218 SSDF; CISA BOD 26-04 as cited in the KEV entry.
H9 · Egress control for application workloads. MITRE M1030 Network Segmentation, M1037 Filter Network Traffic. Default-deny egress with an allowlist per service removes the reverse-shell and staged-download options after exploitation, and bounds the outbound-fetch path. Playbook P4 includes the Kubernetes policy. Authority: Kubernetes NetworkPolicy, CIS Kubernetes Benchmark network-policy controls.
H10 · Treat decompression as a trust boundary in design review. MITRE M1048. Cap decoded size and input size, and put untrusted decoding in a separate low-privilege process. The next native-library bug will arrive on a similar short clock (Sysdig). best-practice, no formal benchmark
Deployable Playbooks
Each step lists its prerequisites, whether it needs a reboot, and how to undo it. Content is defensive configuration only.
P1 · Inventory runtimes and libraries (read-only)
Prerequisites: Shell access with read rights to the filesystem; Docker CLI if you want the image check. Run on a canary host first.
Reboot required: No
Rollback: none required: the script only reads and prints. Delete the output if it contains hashes you do not want retained.
#!/usr/bin/env bash
# READ-ONLY inventory of Node.js and Bun binaries on this host. Nothing is modified and
# no discovered binary is executed; versions are mapped from the hash offline.
set -u
echo "== binaries on PATH =="
for b in $(command -v node nodejs bun 2>/dev/null); do
echo "$b $("$b" --version 2>&1 | head -1) $(sha256sum "$b" | cut -d' ' -f1)"
done
echo "== every node or bun executable on local filesystems =="
find / -xdev -type f \( -name node -o -name nodejs -o -name bun \) -perm -u+x 2>/dev/null |
while read -r f; do echo "$f $(sha256sum "$f" | cut -d' ' -f1)"; done
echo "== system brotli packages (distro Node.js links these) =="
dpkg -l 2>/dev/null | grep -i brotli; rpm -qa 2>/dev/null | grep -i brotli; apk info 2>/dev/null | grep -i brotli
echo "== local images that contain node (runs each image with --version; only for images you own) =="
command -v docker >/dev/null && for i in $(docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>'); do
printf '%s ' "$i"; docker run --rm --entrypoint node "$i" --version 2>/dev/null || echo "no node"
done
P2 · Reject Brotli request bodies at the proxy
Prerequisites: nginx or HAProxy in front of the Node.js tier; confirm no client of yours legitimately sends Content-Encoding: br (grep the new access_enc.log for a week in log-only mode first).
Reboot required: No (reload only)
Rollback: delete the include and the if line, then nginx -t && systemctl reload nginx; for HAProxy remove the http-request deny line and reload.
# nginx: reject Brotli-encoded request bodies (http context, e.g. /etc/nginx/conf.d/reject-br.conf)
map $http_content_encoding $reject_br_body {
default 0;
"~*(^|[\s,])br($|[\s,])" 1;
}
log_format enc '$remote_addr [$time_local] "$request" $status $request_length content_encoding="$http_content_encoding" ua="$http_user_agent"';
# in each public server or location block:
# access_log /var/log/nginx/access_enc.log enc;
# if ($reject_br_body) { return 415; }
# HAProxy equivalent (frontend section)
# http-request deny deny_status 415 if { req.hdr(content-encoding) -m reg -i (^|[\s,])br($|[\s,]) }
# Validate and apply
# nginx -t && systemctl reload nginx
P3 · Disable request decompression in the application
Prerequisites: Application owner sign-off; a staging run with every real client type (mobile apps, partner integrations) because this also rejects gzip bodies.
Reboot required: Application restart only
Rollback: revert the commit or redeploy the previous image; the change is one option per parser.
// Express 5: do not decompress request bodies (also rejects gzip and deflate with 415)
app.use(express.json({ inflate: false }));
app.use(express.urlencoded({ extended: false, inflate: false }));
app.use(express.text({ inflate: false }));
app.use(express.raw({ inflate: false }));
// Fastify: do not register request decompression for public routes
// await app.register(require('@fastify/compress'), { global: false }); // review each route that opts in
// Direct use: never pass client-supplied bytes to zlib.brotliDecompress / createBrotliDecompress
P4 · Constrain the workload and its egress (Kubernetes)
Prerequisites: Cluster at 1.25 or later for Pod Security; a staging namespace; the real list of egress destinations per service (otherwise the policy breaks the application).
Reboot required: No node reboot; pods roll
Rollback: kubectl rollout undo deployment/REPLACE_WITH_APP and kubectl delete networkpolicy REPLACE_WITH_APP-egress.
# Kubernetes: constrain the Node.js workload so a shell is low-privilege and has nowhere to call
apiVersion: apps/v1
kind: Deployment
metadata: { name: REPLACE_WITH_APP }
spec:
template:
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile: { type: RuntimeDefault }
containers:
- name: app
image: REPLACE_WITH_PATCHED_IMAGE_DIGEST
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
volumeMounts: [{ name: tmp, mountPath: /tmp }]
volumes: [{ name: tmp, emptyDir: {} }]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: REPLACE_WITH_APP-egress }
spec:
podSelector: { matchLabels: { app: REPLACE_WITH_APP } }
policyTypes: [Egress]
egress:
- to: [{ namespaceSelector: {}, podSelector: { matchLabels: { k8s-app: kube-dns } } }]
ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }]
- to: [{ ipBlock: { cidr: REPLACE_WITH_ALLOWED_EGRESS_CIDR } }]
ports: [{ protocol: TCP, port: 443 }]
P5 · Restrict core dumps on hosts running Node.js
Prerequisites: Root access; agree with the platform team that you can lose crash diagnostics (Option A) or accept root-only 2-day retention (Option B).
Reboot required: No
Rollback: delete the drop-in or the tmpfiles line, then systemctl daemon-reload; previously deleted cores are not recoverable, so copy any cores you need as evidence first.
# /etc/systemd/coredump.conf.d/10-no-cores.conf (Option A: no core files at all) [Coredump] Storage=none ProcessSizeMax=0 # /etc/tmpfiles.d/coredump-retention.conf (Option B: keep cores, root only, 2 days) d /var/lib/systemd/coredump 0700 root root 2d # Apply # systemctl daemon-reload # sysctl fs.suid_dumpable # expect 0 # ls -ld /var/lib/systemd/coredump
P6 · Audit execve by the service account (hosts without an EDR sensor)
Prerequisites: auditd installed; the service account UID; disk budget for the log (a busy service can generate many events, so scope to the one UID).
Reboot required: No
Rollback: remove the rule file and run augenrules --load.
# /etc/audit/rules.d/60-node-exec.rules : log every execve by the Node.js service account # Replace 1001 with the service account UID. A node process that execs /bin/sh logs here with its PID. -a always,exit -F arch=b64 -S execve -F uid=1001 -k node_exec # Apply and read # augenrules --load # ausearch -k node_exec -i | grep -E 'exe="/bin/(ba|da)?sh"' # Hosts with a Falcon sensor already record this as process creation; use the rule where there is none.
Containment Runbook
| Phase | Actions | Owner | Evidence |
|---|---|---|---|
| Triage | Confirm the host role and the runtime version (Section 7 N4). Check for a br-encoded request in the preceding minutes (N1), a restart burst (Q3) and a shell, in-place exec or reverse-shell shape (Q1, Q2, Q5). Decide if this is a build host, a failed attempt (crash only) or probable code execution. | SOC L1/L2 | Alert, query output, runtime version, relevant log lines |
| Isolate | Network-contain the host in the EDR console. On Kubernetes apply a deny-all NetworkPolicy to the pod label and cordon the node, and remove the instance from the load balancer. Do not restart or delete the process or pod yet: a restart discards the live memory and the crash cores that show what an attacker could have read. Block 94.154.43.7 at the edge as a convenience only; it is shared scanning infrastructure and blocking it does not contain a compromise. | IR lead, platform | Containment confirmation, timestamps, pod and node names |
| Isolate: by platform | CrowdStrike: network-contain the host. SentinelOne: network quarantine (Disconnect from Network; confirm the label in your console version). Microsoft Defender: Isolate device (full network isolation). Keep the host powered on for memory capture on all of them. | IR lead, platform | Containment confirmation, timestamps, pod and node names |
| Preserve | Copy any core files and the process environment (as root, read-only), the container filesystem diff, web and proxy logs covering the Brotli requests, and Falcon process trees for the window. Record the Node.js build hash. | IR / forensics | Hashes of preserved artefacts, chain-of-custody note |
| Eradicate | Rebuild from a patched base image (Section 12 H1); do not patch the live container in place. Rotate every secret that was in the process environment, files, heap or reachable cloud role: session signing keys, bearer and API tokens, database and cloud credentials, TLS keys. Check for persistence (cron, systemd units, authorized_keys, new users) and for lateral movement from the host. | Platform, app owner, IAM | Image digest, rotation log, persistence sweep result |
| Recover | Redeploy the patched image with the Section 13 P2 to P5 controls, restore traffic, and watch Q1, Q2, Q3 and Q5 for 14 days. Confirm the patched host rejects the trigger with the format error (N1). | Platform, SOC | Version proof, clean query runs, change record |
| Review | Add the Section 7 N2 reachability findings to the application inventory, set the SBOM task (H8), and record whether the web-log gap in the coverage map was closed. | Security engineering | Updated inventory, closed gaps |
Detection Coverage Map
| Technique | Behaviour | CQL | IOA | CrowdStrike coverage | SentinelOne | Defender XDR |
|---|---|---|---|---|---|---|
| T1595.002 | Brotli exposure sweep by node2shell-check | - | - | GAP Web, proxy and WAF logs only (Section 7 N1). Outside endpoint telemetry. | GAP | GAP |
| T1190 | Crafted Brotli request body or fetched response reaches the decoder | - | - | GAP Outside endpoint telemetry. Web and proxy logs (N1) and application logs only, and only if the log format records the encoding and body size. | GAP | GAP |
| T1190 / T1499.004 | Out-of-bounds write: failed attempts crash the worker and the supervisor restarts it | Q3 | - | PARTIAL Restart count is a proxy; no crash event is used. Host crash logs and cores are Section 7 N3. | Partial: same limit as CrowdStrike | Partial: same limit as CrowdStrike |
| T1059.004 | Runtime spawns a shell, downloader or interpreter | Q1 | Yes | PARTIAL Misses the in-place exec shape, where the shell is not a child of node. | Partial: same limit as CrowdStrike | Partial: same limit as CrowdStrike |
| T1059.004 | Runtime PID replaced in place by /bin/sh | Q2 | Scheduled | PARTIAL Depends on the sensor emitting a new process event for an in-place exec; validate with Gate 3. | Partial: Q2 approximate | Partial: Q2 approximate |
| T1059.004 / T1095 | Reverse shell or shell-over-socket command line | Q5 | Yes | GOOD Parent-independent. Does not see a pure socket shell with no telltale command line. | Good, with assumptions | Good, with assumptions |
| T1071 / T1571 | Runtime connecting to a public address on a non-web port | Q4 | - | PARTIAL Misses callbacks on 80 or 443. | Partial: same limit as CrowdStrike | Partial: same limit as CrowdStrike |
| T1033 / T1082 / T1552 | Discovery and secret access after the shell | Q6 | - | PARTIAL Noisy on hosts with interactive administrators; tune by account. | Partial: Q6 approximate | Partial: Q6 approximate |
| T1190 | Vulnerable runtime and library versions present | Q7, Q9 | - | PARTIAL Hash-based only; telemetry has no ELF file version, and distro builds that link the system libbrotli cannot be judged from the runtime hash. | Partial: same limit as CrowdStrike | Partial: same limit as CrowdStrike |
| T1071 | Contact with the ELLIO-reported exploit source address | Q8 | - | GAP The address connected inbound to ELLIO's sensors; no inbound-accept event is confirmed in the reference set, so only outbound contact is checkable. Use Section 7 N1. | GAP: same limit as CrowdStrike | GAP: same limit as CrowdStrike |
| T1005 | Memory disclosure from a crash or partial exploit | - | - | GAP No telemetry shows what memory was read; handled by secret rotation (Section 14). | GAP | GAP |
| T1190 | Appliances and products embedding Node.js or Brotli | - | - | GAP Vendor advisories only (Broadcom SDDC Manager, PAN-OS hotfixes). | GAP | GAP |
Honest read on this map. The first two steps of the chain and the memory-disclosure outcome are outside endpoint telemetry, and that is a property of the threat: a decompression bug is triggered inside the application before any process event exists. The pack therefore covers what the runtime does after a successful exploit, and treats web-log recording of Content-Encoding and request size as a prerequisite for the earliest detection. Validation gates 1 to 7 in Section 11 define when each query is trustworthy.
Hunt Summary Ticket
TITLE Node2Shell - Google Brotli decoder out-of-bounds write (CVE-2026-111497)
SEVERITY High - on CISA KEV 2026-10-27; exploit attempts observed; reliability varies by build
SCOPE Every Node.js, Bun, .NET, Envoy and Python workload that decodes Brotli the
attacker can influence (request bodies, fetched responses, direct zlib calls),
internet-facing first, including containers, CI images and appliances.
Fixed: Node.js 22.23.4 | 24.22.1 | 26.12.1, Brotli 1.0.10 | 1.1.1 | 1.2.1,
Bun 1.4.4. Node.js 20, 23, 25 get no fix. Hunt window: 2026-10-21 onward
(checker traffic from June 2026 in web logs).
HYPOTHESIS A crafted Brotli stream reached an unpatched decoder. A miss crashes the worker and
the supervisor restarts it; a hit replaces the runtime with a shell as the service
account. If so, endpoint telemetry shows a restart burst, a shell or in-place exec
under the runtime, and then discovery, secret access or a reverse shell.
QUERIES Q1 Runtime spawns shell or downloader PRIMARY
Q2 Runtime PID re-executed as a shell PRIMARY, VALIDATE FIRST
Q3 Restart loop of node or bun CONDITIONAL
Q4 Runtime to public address, non-web port
Q5 Reverse-shell command lines PRIMARY
Q6 Discovery and secret access on runtime hosts
Q7 Runtime and decoder inventory by hash
Q8 Connections to the reported exploit source LOW YIELD BY DESIGN
Q9 Child-process baseline RUN FIRST
DO FIRST 1. Patch internet-facing Node.js, Bun and Python Brotli users; replace EOL lines.
2. Add Content-Encoding and request length to proxy logs; run Section 7 N1.
3. Q9 baseline, then Q1, Q5 and Q2 over 14 days.
4. Edge rule to reject br request bodies (Section 13 P2).
FINDINGS [ ] Unpatched or EOL runtimes found: ____
[ ] Services decoding attacker-influenced br: ____
[ ] br request bodies in web logs: ____
[ ] Q1 / Q2 / Q5 hits requiring triage: ____
[ ] Node crash cores found and secured: ____
GAPS Exploit request and the checker sweep are outside endpoint telemetry (web, proxy
and WAF logs only). Memory disclosure is unobservable. Inbound contact from the
reported source address cannot be checked from endpoint events. File version of
ELF runtimes is not in process telemetry (hash mapping only).
ACTIONS Patch; reject br bodies at the edge; run as non-root with restricted egress;
restrict core dumps; rotate secrets for any host with node crash cores or a hit.
OWNER HuntPack
VERSION v0.1 2026-10-28
Changelog
References
| Tier | Source | Used for | Snapshot | Accessed |
|---|---|---|---|---|
| 1 | CISA Known Exploited Vulnerabilities feed | KEV entry, dates, CWEs, due date | 01-cisa-kev.txt | 2026-10-28 |
| 1 | ELLIO: Node2Shell on stock Node.js builds | All atomic indicators; checker sweep; x86-64 reproduction; failed chain; reliability findings | 02-ellio.txt | 2026-10-28 |
| 1 | Sysdig: Detecting the Node.js Brotli RCE | Reachability table; demonstrated versus claimed; detection and mitigation | 03-sysdig.txt | 2026-10-28 |
| 1 | Tenable: Brotli2Shell FAQ | .NET and other affected products; fixed versions; CVSS | 04-tenable.txt | 2026-10-28 |
| 2 | Wiz: Broccoli2Shell | Version matrix; cloud exposure | 05-wiz.txt | 2026-10-28 |
| 2 | Cycode: Node2Shell | Exposure conditions; framework reach; Python and proxy notes | 06-cycode.txt | 2026-10-28 |
| 2 | Aikido: Python packages vulnerable to Node2Shell | Python, distro and library remediation | 07-aikido.txt | 2026-10-28 |
| 2 | Help Net Security | Corroboration of source IP; aliases; KEV update | 08-helpnetsecurity.txt | 2026-10-28 |
| 2 | The Hacker News | Imperva reliability; ELLIO attempt failed; version-specific exploit | 09-thehackernews.txt | 2026-10-28 |
| 2 | SecurityWeek | KEV addition; honeypot attempts; Broadcom and PAN-OS notices | 10-securityweek.txt | 2026-10-28 |
| 1 | Node.js October 2026 security releases | Vendor description; affected lines; fixed versions | 11-nodejs-october-2026-security-releases.txt | 2026-10-28 |
| 1 | google/brotli v1.2.1 release | Upstream fixed release | 12-brotli-v1.2.1-release.txt | 2026-10-28 |
| 2 | Akamai: Inside Node2Shell | Listed in the request; not used (fetch returned HTTP 403) | none | 2026-10-28 |
| Std | MITRE ATT&CK T1190 | Technique mapping | n/a (standard) | 2026-10-28 |
| Std | MITRE ATT&CK M1051 Update Software | Mitigation mapping | n/a (standard) | 2026-10-28 |
| Std | CISA BOD 26-04 | Federal remediation framing, cited in the KEV entry | n/a (cited in snapshot 01) | 2026-10-28 |
| Std | CIS Docker Benchmark | Container hardening controls | n/a (standard) | 2026-10-28 |
| Std | Kubernetes Pod Security Standards | Restricted profile | n/a (standard) | 2026-10-28 |
| Std | NIST SP 800-190 | Container security guide | n/a (standard) | 2026-10-28 |
The raw fetched text of every snapshotted source is saved in Node2Shell-CVE-2026-111497-Hunt-sources/ next to this file. Standards pages are cited for control names only and were not snapshotted.