Skip to content
Investigating Malware

Investigating Malware

andrii.ro September 3, 2026

On Tuesday, I received a message from someone posing as a recruiter at a tech company. The message seemed genuine, like a typical recruiter message. It seemed like a typical dialogue with a recruiter - discussing my background, job details, salary expectations, and so on. After a few messages, they talked me through the interview process, and one of the first requirements was to familiarise myself with their demo codebase so we'd be able to it with the "hiring manager".

They sent me a link to a Google Drive (🚩🚩) containing their code repository. Inside of it was a typical repo - with a README and some random code.

All of the files in the repository were empty, and the README file said " master branch is just a project structure, please check out the dev branch".

And this is where the fun begins - if you check out the dev branch, you'll see some code of some legitimate project - nothing special. I assumed the attack would happen either by some malicious library that would execute on a postinstall script, or by some malicious code that gets triggered whenever someone runs the project.

But it's all legit. Normally I would just have a look at it, to the recruiter, the call would never get scheduled or they'd say the position has been filled. I'd forget it and move on.

But at this point I'd had already been compromised.

Stage 1: The Entry Point

In Git there's a feature called "hooks". It's a way to run a script whenever a certain event happens in the Git repository. For example, you can run a script whenever a new commit is pushed to the repository, or whenever a pull request is created.

It's an extremely useful feature since it allows you to automate things like running tests or lints before you commit the code to make sure it's good.

This feature is intended to be good, but in reality it allows anyone to create a hook with custom code that gets executed whenever someone runs a Git command.

And this is exactly what happened in this case. Normally, Git hooks are not transferred when you clone a repository. The trick here was that the Google Drive download contained the full repo folder, including the .git directory with custom hook files.

This repository was infected with a malicious hook that would execute a script whenever someone runs the git checkout or git commit command.

The injector script determines what OS it is running on ( uname -s ) and based on that it downloads the appropriate payload from the remote server.

Based on the OS, it downloads the payload from server.

The domain itself and its server appear to be hosted on Hostinger, and at the time of writing this article, both are still live. A takedown request has been submitted to Hostinger.

The interesting part of this domain is that if you visit it from a browser, it returns IP address geolocation information, pretending to be a legitimate service.

But accessing it via curl or wget returns the actual dropper payload that the Git hook then pipes to sh to execute.

The script creates a hidden ~/.vscode directory and places the real payload ( vscode-bootstrap.sh ) inside of it. It then runs it silently in the background. The payload itself is executed by the nohup command, meaning that the process ignores hangup signals and can keep running after the parent shell, Git hook, terminal, or SSH session exits.

Stage 2: The Malware Dropper

The file downloaded by Stage 1 is not the final payload, but another script whose job is to prepare the machine for a JavaScript-based payload.

At a high level, this script does four things:

Checks whether Node.js is already installed.

If not, downloads it and verifies it works.

Downloads malware dropper into ~/.vscode , installs dependencies.

Executes the downloaded JavaScript payload ( env-setup.js ).

The vscode-bootstrap.sh accesses two more URLs:

- the main JavaScript payload - env-setup.js . Depending on the flag value, it will download a different payload.

- a snippet of package.json with needed dependencies.

The env-setup.js compares the flag value with possible values, and depending on that, downloads and executes a different payload.

All of the download URLs are Base64-encoded. There are 6 different payloads available:

(used for flag #7 as well)

Every payload hosted on the JSONKeeper server looks like the following:

The model key contains a Base64-encoded gzip-compressed JavaScript blob. Stage 2 decodes it, decompresses it, and immediately executes it:

Instead of using eval() directly, the loader uses new Function() , which is another way of dynamic code execution. In this sample, it passes Node’s require into the generated function, giving the decoded payload access to Node modules and all the dependencies it installed earlier.

In this case, the installed dependencies were:

Stage 3: Analysing the payload

The payload downloaded from the JSONKeeper server is compressed and encoded. Using a simple Python script, I was able to decode it into a "readable" format.

The decoded file is 3.4 MB in size, an obfuscated JavaScript executable:

With the help of GPT, I was able to identify the obfuscation pattern used by the payload. The malware was not simply minified, and most meaningful strings and identifiers were hidden behind a runtime string-decoding system.

At a high level, the obfuscation works like this:

Store all important strings in one huge encoded array.

Rotate that array at startup until it is in the correct order.

Decode strings only when the program needs them.

Use wrapper functions and dynamic property access to hide what the code is really doing.

This means that strings such as URLs, module names, file paths, HTTP headers, etc. do not appear directly in the source code.

String array obfuscation

The payload contains a large function named c() which returns a string array. In this sample, the rotated array contains 20,844 entries.

Instead of writing readable code like:

the malware uses decoder calls that look like this:

The readable string is only produced at runtime.

Before the decoder functions can work correctly, the malware rotates the string array. The first wrapper around the payload repeatedly moves the first array element to the end:

After each rotation, it calculates a numeric checksum from several decoded values. When the checksum matches the expected target (in this case it was 371224 ), the loop stops and the array is in the correct order.

The logic looks like:

Without reproducing this rotation step, the string indexes point to the wrong array entries and decode into incorrect values.

The payload uses two main decoder functions.

The first decoder, N(index, key) , uses a custom Base64 alphabet followed by an RC4-style decryption routine. This is used for many of the more important strings.

The second decoder, b(index, key) , performs only the custom Base64 decoding layer.

The custom Base64 alphabet is:

abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=

This differs from the normal Base64 alphabet, which makes simple Base64 decoding fail (the regular alphabet starts with ABCD... uppercased letters. The character set is the same, but the numeric value assigned to each letter is different, meaning decoding them with a regular Base64 decoder produces incorrect bytes).

The main decoder works like this:

There is no single global string key. Many decoder calls provide their own short key string.

Wrapper functions and indirection

The malware also creates many small wrappers around the real function calls; I assume this is to make the code harder to debug:

It also uses dynamic property access, instead of socket.emit("message", data) , it does something like socket[decode(...)](decode(...), data) - it hides method names like spawn , writeFileSync , etc.

There was also a lot of dead code, deliberate or not, which makes it harder to understand the code as well.

The malware itself seems to be an infostealer and a remote control tool. It's made of three main components which are spawned as child processes, independently of each other.

For each of the processes spawned, it creates temporary lock files - /tmp/pid.2677.1.lock , /tmp/pid.2677.2.lock , /tmp/pid.2677.3.lock , which contain JSON with process ID and start timestamp - { pid: 2677, startedAt: 1778351000000 } .

1. File discovery and upload component

It's the main infostealing component, which crawls the filesystem for sensitive files like .env* , id_ed25519* , .db , crypto-wallet related files, certificates, etc.

Separately, it also scans "priority paths": Desktop , Documents , Downloads , /mnt , etc. and avoids common paths like node_modules , .git or dist .

All files matching the criteria and less than 10 MB in size are automatically uploaded to the endpoint.

Before uploading a file, it creates a HMAC validation token using the hardcoded secret ( SuperStr0ngSecret@)@^ ) inside the payload. On the attacker's backend, this token is probably used for validation against unauthenticated uploads.

Along with the file, in the same request it appears to be sending what appears to be the campaign ID ( 2677 ), file path, user key ( 1000 ) and the hostname.

2. On-demand file upload component

This helper works in a similar way to the file discovery component, but instead of automatically scanning the filesystem, it listens for commands from the remote control component and uploads the requested files to the endpoint.

It handles file validation (the code appears to check against a 25 MB limit), HMAC token generation and sending the file to the endpoint.

It uses the axios and form-data dependencies installed earlier to send the requests.

3. Remote control component

Another spawned process is a remote control component. It uses the Socket.IO dependency installed earlier to connect to and send/receive messages. It connects to the attacker's server and simply waits for any commands from it.

On connection, it collects some identifiers like machine name and username (e.g. andrii@Andrii-MacBook-Pro ), OS info using the node:os module and sends it to the attacker on demand via the whour event.

The command handler supports several actions:

Show directory files for a provided path, which returns a JSON like { name, path, type: "dir" | "file", size, date } back to the upload endpoint

Read a specific file and upload it to the endpoint

Upload all child files from a provided directory path

Execute any shell command (via Node's child_process.exec ) and return the output to the endpoint

The remote control process also starts a clipboard watcher (using the clipboardy module installed earlier), polling any changes every second and uploading the content to the endpoint if it's not empty.

Interesting observations

The backend is hosted on three different ports, each used for different purposes:

216.126.225.243:8085 - on-demand file uploads service

216.126.225.243:8086 - automatic file uploads service

216.126.225.243:8087 - Command & Control service

It does not attempt to persist itself after the initial execution. It's only running as a child process, and if killed, it does not recover itself. There are no registry key modifications, cron jobs or anything like that.

Although it is possible for the attacker to execute any code on the machine, meaning that they can run additional malware or scripts that would target other attack vectors.

There is a lot of obfuscation used in this payload - it's shipped as a compressed, encoded payload. It's minified and uglified JavaScript code; all the strings, object properties and methods are encoded (and decoded at runtime).

Most of the strings are stored separately in a huge array, so a simple module import looks like the following example, making it harder to reverse engineer.

There's also a lot of unused or fake code present:

The signs of compromise are:

Temporary lock files matching the /tmp/pid.2677.*.lock pattern ( find /tmp -name 'pid.2677.*.lock' -print )

Any active connections to port 8087 , especially if there are connections to ports 8085 or 8086 on the same address (in this campaign, the server is on 216.126.225.243 ; you can check with lsof -i | grep '216.126.225.243' )

The Node subprocesses launched by the script use the --max-old-space-size=4096 --no-warnings - arguments - it's worth checking if there are any processes like this with ps aux

I also think it's a good idea to block JSONKeeper traffic for now since it is actively used for this campaign: