When "ollama.js" Isn't Ollama

Jun 23, 2026 • 8 min read • Intermediate

  • Threat Hunting
  • Incident Response

Overview

A file named ollama.js came off a host that already had a working Ollama installation, which put both the file name and the application in line with ordinary setup activity. The one thing that did not fit was a winget command that installed Deno a few seconds earlier, since Ollama has no JavaScript runtime dependency and no supported install path that pulls Deno in alongside it.

Deno’s foreign function interface is what the rest of the chain depends on, because it lets a script named ollama.js load a Rust DLL directly into memory, decrypt and exfiltrate saved browser credentials, and install a browser extension that provides persistent remote access. The genuine Ollama install left behind by the same installer serves as cover, since the victim ends up with a working application and no reason to look at what ran next to it.

The technique matches the “Evil Deno” proof of concept that Taggart Tech published last year, though the DLL here was compiled on 2026-06-22, which puts it in active development rather than a replay of the original research.

Initial access

Delivery is a fake Ollama installer, a PowerShell script retrieved from ollama[.]lnk/install.ps1, promoted as far as I can tell through a YouTube video advertising fake ChatGPT and Claude tooling. The lure matches the fake AI installer campaigns that have been delivering Deno-based malware for the past several months, though there is no ClickFix prompt or terminal paste involved and the user is simply running an installer for software they intended to install.

From that one script the installer forks into two chains, one installing the genuine Ollama package so the host ends up with a functioning application, the other pulling Deno through winget and executing ollama.js. Running both from the same script is what makes the decoy hold, since the legitimate install is part of the delivery design rather than a side effect of it.

Loader

Obfuscation on the loader is a single 2.7 MB base64 string plus per-call reconstruction of API names from character codes, and stripping it leaves a short sequence of operations. The embedded blob is base64-decoded, raw-inflated into a PE32+ DLL, written to %APPDATA%, loaded through Deno’s foreign function interface, called once at a single exported function in-process, and deleted when the call returns.

The flow below has the runnable components removed.

1
2
3
4
5
6
7
8
// redacted: working loader neutered to prevent execution
const bytes = decodeEmbeddedBlob();              // ~2.7 MB base64
const dll   = await rawInflate(bytes);           // deflate-raw -> PE32+ DLL
writeFile(APPDATA + "\\WsLReports\\[REDACTED].dll", dll);
const lib = Deno.dlopen(path, { /* export, params, result redacted */ });
lib.symbols.[REDACTED]();                        // FFI -> native code, in-process
lib.close();
removeFile(path);                                // anti-forensics

Comparable loaders reach native code through LoadLibrary or by shelling out to rundll32 or regsvr32, all of which leave process artifacts worth pivoting on, so executing the DLL inside a signed and generally trusted JavaScript runtime removes the separate loader process from the picture entirely. The file is also gone from disk before most scheduled scans would reach the directory.

What remains to detect on is deno.exe writing a PE file into a user-writable directory and then loading that file, a sequence with no legitimate use case.

Credential theft

The DLL is written in Rust under the internal project name app_edge, with a leaked PDB path identifying the build user as root, and its purpose is browser credential theft. It reads each browser’s Local State file, extracts the encrypted key, and recovers it by defeating App-Bound Encryption, falling back to DPAPI where the newer scheme is not present. Debug strings left in the binary document that process, including references to retrieving the Comet v10 key and to falling back after an empty v20 DPAPI key. Locked cookie and credential databases are reached by force-closing the browser with taskkill, after which the contents are decrypted with AES-GCM using the recovered keys.

Chrome, Edge, Brave, and Perplexity Comet are all targeted, and the Comet entry is the first case I have handled where an AI browser is treated as a first-class credential target with its own dedicated key recovery path. I expect that to become normal in stealer development.

Browser extension and native messaging host

Alongside the credential theft, the DLL unpacks an MV3 browser extension into %APPDATA%\ext\ and registers a native messaging host, which bridges the extension to PowerShell through a run.bat that launches an obfuscated script of roughly 2 MB. That bridge is what allows commands issued from the extension to execute in a real shell on the host.

The extension maintains a WebSocket connection to its C2, reconnects every five seconds, and pulls per-victim configuration and DPAPI keys down at runtime. Decoding the background script gives the following command set.

  • Cookie theft, exported in Netscape format
  • Interval screenshots through the browser capture API
  • Web injects, form grabbing, and payment card capture
  • Remote shell through the native messaging host
  • Targeted file exfiltration
  • Host reconnaissance
  • Self-update

Persistent remote access inside the browser is a different problem from a stealer that runs once and leaves, since cookie and session theft from that position walks past MFA on any session that is already authenticated.

PowerShell backend

Most of the collection turned out to be implemented in the PowerShell component rather than in the extension. The native messaging host executes zqpbm.ps1, which took two passes to read: stripping the <#...#> comment injection produces roughly 640 KB of arithmetic character code assembly, that decodes to a second array, and the second array yields roughly 26 KB of readable PowerShell.

Reading the cleartext corrected my initial assessment of the C2 layout, since the WebSocket at qweuyquigwebq[.]com carries commands only and stolen data goes over plain HTTP to adyitegqbk[.]com instead. /gate receives browser database ZIPs and extension data, /filegrabber receives targeted file grabs, and /api/config/extensions returns a server-controlled list of extension IDs to collect. A reference to bestgrillde31[.]com remains as a commented-out earlier backend the developer never cleaned up, and every POST carries the host MachineGuid, read from HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid, in an id header, which is how the operator keys victims apart.

The script copies the Web Data and Login Data SQLite files from every Chrome, Edge, and Brave profile under every user account on the host, opening them with FileShare read-write so locked files do not interrupt collection, then compresses and uploads them, which is already broader than the extension command set suggested. It also retrieves the server-configured extension list and collects Local Extension Settings and IndexedDB for each ID, a pattern built for draining cryptocurrency wallets. The file grabber supports recursive globbing and uploads matches per configured path, and a hidden interactive shell, either PowerShell or cmd, runs with an async stderr reader to keep the pipe from deadlocking. Taken together it reads as a maintained backend rather than something assembled for one campaign.

The cleartext also retains Russian comments, #читать из экста + писать в экст (“read from ext + write to ext”) and #сепаратор (“separator”), pointing to a Russian-speaking developer.

No public reporting was identified for these domains or this build at the time of writing, so the indicators below are assessed as first-seen. All four domains are recent, and both HTTP backends have gone on the block list alongside the WebSocket C2.

Detection

The mutexes and PDB string survive rebuilds better than the domains do, so a YARA rule keyed on those plus the known hash catches re-packaged copies of the same DLL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
rule HackTool_WsxComponent_FakeOllama_Stealer
{
    meta:
        description = "Detects wsx_component.dll, the Rust stealer/RAT dropped by the fake ollama.js / Deno FFI loader chain"
        author      = "Elmer Phillips"
        date        = "2026-06-23"
        reference   = "https://elmerphillips.com/blog/when-ollama.js-isnt-ollama/"
        hash_sha256 = "1fdeaf2fc69208b3a021015971949494a69fbecb6885296df07ace0f08429570"

    strings:
        $pdb    = "app_edge.pdb" ascii
        $mutex1 = "Local\\keybedge" ascii wide
        $mutex2 = "Local\\keybrave" ascii wide
        $mutex3 = "Local\\keye" ascii wide
        $ext_id = "neikokiahjlchabokkaijchmbhafcdme" ascii wide
        $host   = "com.abcd" ascii wide

    condition:
        uint16(0) == 0x5A4D
        and filesize < 10MB
        and (
            hash.sha256(0, filesize) == "1fdeaf2fc69208b3a021015971949494a69fbecb6885296df07ace0f08429570"
            or 2 of ($mutex1, $mutex2, $mutex3, $pdb, $ext_id, $host)
        )
}

The 2-of-6 string threshold is there so a single coincidental match (com.abcd alone, say) doesn’t fire; the mutex names and the PDB path together are specific enough that a rebuild would need to change all of them to evade this.


Indicators of compromise

Network

1
2
3
4
5
6
ollama[.]lnk/install.ps1                    installer / initial access
eventlogerps1[.]ink/eventlog/ollama.js      stage 1 loader host
qweuyquigwebq[.]com                         WebSocket C2 (command and control only)
adyitegqbk[.]com                            HTTP exfil backend (/gate, /filegrabber,
                                            /api/config/extensions)
bestgrillde31[.]com                         prior exfil backend (commented-out dev artifact)

Files

1
2
3
4
%APPDATA%\WsLReports\wsx_component.dll
%APPDATA%\ext\  (manifest.json, background.js, content.js,
                 native-manifest.json, run.bat, zqpbm.ps1)
%LOCALAPPDATA%\deno\remote\https\...        cached remote loader

Registry (native messaging host)

1
2
HKCU\Software\Google\Chrome\NativeMessagingHosts\com.abcd
(Edge and Brave equivalents under each browser's NativeMessagingHosts\com.abcd)

Host artifacts

1
2
3
4
Chrome extension ID   neikokiahjlchabokkaijchmbhafcdme  (name: slueuk)
Native host name      com.abcd
Mutexes               Local\keybedge, Local\keybrave, Local\keye
PDB string            app_edge.pdb  (build user: root)

Hashes

1
2
3
4
ollama.js          SHA256   bfce6aa6d1b49c5ada05bc7254fc64219af63a8bf9c598615e6de77937cb1203
wsx_component.dll  SHA256   1fdeaf2fc69208b3a021015971949494a69fbecb6885296df07ace0f08429570
wsx_component.dll  MD5      f2f2fab719f560fdb80f5bcf27a3200c
wsx_component.dll  ImpHash  86e4d4e85066d9dfbedf093af785490d

Resources

Elmer Phillips, Security Analyst