White-box penetration testing: How to debug for JavaScript vulnerabilities

October 22, 2024

JS text stands for JavaScript, with insect bugs signifying vulnerability hunting, with yellow theme, in article about how to debug for JavaScript vulnerabilities

This is a guide to performing white box penetration testing on a JavaScript web application running within a Docker container. In testing a web application vulnerable to prototype pollution, we will demonstrate how to debug JavaScript inside Visual Studio Code in order to track our payloads throughout the code process and learn how security filters can hide vulnerabilities from view.

Outline

  • Necessary resources
  • Install necessary resources
  • Setup
    • File structure
  • Verify our setup
  • Hunt for JavaScript vulnerabilities
  • Common weaknesses related to JavaScript
  • Improve future black-box testing with code analysis
  • Conclusion
  • References

Necessary resources

  • Visual Studio Code (VS Code) (or your preferred IDE)
  • JavaScript debugger (built-in inside VS Code)
  • Vulnerable web application
  • Docker

Install necessary resources

Since Visual Studio Code has an effective JavaScript debugger built in, we only need to install Visual Studio Code and Docker.

Consult Docker’s official documentation to install Docker. The installation process may differ depending on your operating system.

The vulnerable web application that we will use can be found at YesWeHack's Github repository: Vulnerable Code Snippets here.

Setup

File structure

Our project will use the following file structure:

.
├── config
│ └── supervisord.conf
├── docker-compose.yml
├── Dockerfile
└── vsnippet
├── package.json
├── pp-classic.js
└── views
└── index.ejs


Certain particularly important files must be added or modified. The files below contain the final file content:

pp-classic.js

This is the main JavaScript file of our vulnerable web application, which we will run in a dockerised environment.


/**
* YesWeHack - Vulnerable code snippets
*/

const express = require('express');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const ejs = require('ejs');
const app = express()
app.locals.SOURCECODE = fs.readFileSync(__filename).toString()

// Set the view engine to EJS
app.set('view engine', 'ejs');

// Set the views directory
app.set('views', path.join(__dirname, 'views'));

app.use(express.raw({ type: '*/*', limit: '10mb' }));

const API_KEY = crypto.randomBytes(32).toString('hex');

class Config {
  constructor(owner, apikey) {
    this.owner = owner
    this.apikey = apikey
    this.public
  }
  // Code...
}

function merge(target, source) {
  for (const attr in source) {
    if (typeof target[attr] === "object" && typeof source[attr] === "object") {
      merge(target[attr], source[attr]);
    } else {
      target[attr] = source[attr];
    }
  }
  return target;
}

app.get('/', (req, res) => {
  return res.render('index', { status: 'No config data given' });
})

// Define a POST route to accept JSON data
app.post('/', (req, res) => {
  data = req.body.toString('UTF-8')
  
  let configDraft = new Config("guest", undefined)
  merge(configDraft, JSON.parse(data))
  
  const config = new Config("admin", API_KEY)
  if ( config.public === true ) {
    return res.render('index', { status: 'Access granted' });
  }
  // Send a response back to the client
  return res.render('index', { status: 'You do not have access to the system configurations' });
});

//Start the server:
const PORT = 1337
app.listen(PORT, '0.0.0.0', () => {
  console.log(`Server is running on http://0.0.0.0:${PORT}`);
});

supervisord.conf

The only thing we need to modify in our supervisord.conf file is the ‘command’ argument in the program ‘node-app’. We will set the command to ‘npm run start:debug’ to ensure that both our web application and debugger start running.

conf
[supervisord]
user=root
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/run/supervisord.pid

[program:node-app]
command=npm run start:debug
directory=/app
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

launch.json

This will serve as the configuration that tells VS Code how to connect to JavaScript’s debugger, which is running in a docker container.

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "attach",
      "name": "Attach to Docker",
      "port": 9229,
      "address": "localhost",
      "localRoot": "${workspaceFolder}/vsnippet",
      "remoteRoot": "/app",
      "protocol": "inspector",
      "restart": true
    }
  ]
}

Dockerfile

As well as building the docker image, the Dockerfile installs the dependencies used by the vulnerable web application.

FROM node:14

#Install and update system dependencies
RUN apt update -y && apt install -y supervisor npm
RUN npm install express ejs

#Prepare and setup the working directory
WORKDIR /app
COPY vsnippet .

#Copy configs
COPY config/supervisord.conf /etc/supervisord.conf

EXPOSE 1337
EXPOSE 9229

ENTRYPOINT [ "/usr/bin/supervisord", "-c", "/etc/supervisord.conf" ]

docker-compose.yml

This file allows us to connect and communicate with JavaScript’s debugger when it is running in the Docker container. Port 1337 makes it possible for us to access the web application through our host, while port 9229 will be used for our debugging communication.

services:
  node-express:
  container_name: vsnippet-pp-classic
  build:
    context: .
    dockerfile: Dockerfile
  ports:
    - "127.0.0.1:1337:1337"
    - "127.0.0.1:9229:9229"

Verify our setup

To make sure our setup works as intended, we should launch our docker container, set up breakpoints in VS code and start the debugger. Then we can submit a request to the web application to see if the debugger stops at our breakpoints.

Navigate to the project file’s root folder (see header: File structure) and run the following command:

docker compose up --build

Ordinarily you would begin this command in the background (with the -d argument). However, if you’re fairly new to Docker, we recommend using the command above, which omits the -d argument, so that the logs are visible.

When the docker has finished the build process and is up and running, we’re ready to send a request to our vulnerable web application on the port running on http://localhost:1337/:

The response above means that our Docker launched successfully and that the JavaScript debugger is now listening for a connection.

Time to set up some breakpoints inside VS Code. You can do this by clicking on the red dot that appears when you hover over the relevant line number. Breakpoints can be inserted inside the web application's main file: pp-classic.js.

Go to VS Code’s debugger section on the left-side panel (or press CTRL+SHIFT+D for a shortcut), then press the green arrow icon at the top:

With our debugger connected, it’s time to perform a HTTP request to trigger the Visual Studio Code breakpoints. We can use the command line interface (CLI) tool cURL to send a GET request to our web application:

curl http://localhost:1337/

Unless something goes wrong, you should not get a response back directly. Instead, some code will be flagged inside VS Code that shows the debugger stopped at the first breakpoint and is therefore functioning properly:

Hunt for JavaScript vulnerabilities

With everything up and running and seemingly functioning correctly, it’s time to start probing the application for JavaScript vulnerabilities!

First, we will focus on how the web application handles our JSON data. This is an important step since it allows us to properly perform our attack.

We start by setting up our breakpoints in VS Code:

Once we have setup our breakpoints, we start our debugger inside VS Code by pressing F5 or CTRL+SHIFT+D and pressing the green arrow in the debug window.

Our debugger is now listening for incoming connections. We trigger it by sending a HTTP POST request to our vulnerable web application with the following cURL command:

curl http://localhost:1337/ -X POST -d '{"public":true}'

Our debugger intercepts the request. We can now see how our vulnerable web application is handling the JSON data provided in the post body by analysing the code workflow:

In the debug window to the left, we can see that the config object related to the admin user does not include the public variable, which we tried to set to the value: true. This means we won’t be able to access the restricted area with the JSON data that we provided in our HTTP POST request.

With this in mind, we now need to perform a new JavaScript debug where we carefully analyse how the merge function handling our JSON data works. First, we need to set up new breakpoints to properly analyse the code workflow in the merge function:

We restart our debugger with CTRL+SHIFT+F5 so we can prepare our next attack.

We know the web application is vulnerable to prototype pollution, so we use a simple prototype pollution payload: {"__proto__":{"public":true}}. Hence we send a HTTP POST request with this command:

curl http://localhost:1337/ -X POST -d '{"__proto__":{"public":true}}'

In theory, our payload should be added to the config object with user guest user. Since it's a prototype pollution attack, the public property with the value true should appear in all object prototypes used by the application.

Let's analyse the merge function to see how it handles our new payload. We continue debugging the JavaScript until we spot our first JSON element: __proto__:

In the code, we can see that the if statement triggered was checking whether our provided value was an object property. The code determined that __proto__ is an object and called the merge function inside itself again, but this time using the __proto__ object as the argument.

We continue the debugging process until we see our public value holding the value true.

This time, we triggered the else statement, which adds the public property with the value true to the prototype object __proto_, which is a part of the target object.

Since we were able to add the public property using the object __proto__, we should now have polluted other objects within the application with our public property and its value. Let's continue the debugging process and see if we succeeded:

As expected, we did pollute the next defined config object, which holds the admin configurations. It now contains the public property, which is set to true.

We succeeded: we polluted the vulnerable web application, which granted us access to the admin configurations!

As a side note, if we run the process again with JSON data, we still have a successful prototype pollution!

Common weaknesses related to JavaScript

Below are three of the most common weaknesses associated with the JavaScript programming language.

CWE-1321: Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')

Prototype pollution occurs when user input is mishandled and inserted as a property of an object in an incorrect way. This allows attackers to exploit and ‘pollute’ JavaScript's prototype structure, which is used by objects. A successful exploit allows the attacker to modify or overwrite properties of objects, which can lead to serious consequences such as improper access, file access or full server compromise.

CWE-843: Access of Resource Using Incompatible Type ('Type Confusion')

Type confusion occurs when a program assumes that a resource is a particular type, but actually handles it as another type.As a result, type confusion can result in improper access by bypassing certain restrictions. Dynamically typed languages, such as JavaScript, are particularly vulnerable to type confusion.

CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

Cross-Site Scripting (XSS) allows attackers to inject malicious JavaScript code into web pages. XSS vulnerabilities mainly occur in the front-end and execute in the victim's browser. This type of vulnerability can enable attackers to hijack a user’s sessions or steal their data.

Improve future black-box testing with code analysis

This white-box testing process offers lessons that can also enhance your black-box testing. Most notably, you can see the value of testing multiple, subtly different payloads in attempts to unearth the same vulnerability. Trying a range of payloads is an effective means of discovering changes from the back-end server and to prevent false negatives.

This writeup also shows that a proper understanding of how a web application handles our payloads is essential to deploying them effectively. These are useful lessons to recall next time you face a similar scenario but in a black-box environment.

JavaScript debug takeaway

We’ve shown how you can more effectively track executed code triggered by the client (attacker) by effectively debugging an application’s source code. Replicate the steps detailed above to optimise your testing flow and uncover behaviours that can reveal how user input is being handled or mishandled.

Related articles

References