Destructuring CVE-2023-26045: NodeBB Code Execution Vulnerability
Technical analysis of CVE-2023-26045 to achieve code execution on NodeBB <2.8.7.
Introduction
Back in August, my talk was accepted to present at ThreatCon on the topic “Diving Into the Realm of Source Code Review”. I wanted a cool vulnerability to present to the conference for the CVE reversing demo. Driven by that motivation, I decided to look for some cool issues released recently that do not have any public PoC or walkthrough available at all.
And then, I stumbled across CVE-2023-26045 “Path Traversal and Code Execution via Prototype Vulnerability” on NodeBB. NodeBB is a pretty popular and widely used open-source software. According to official NodeBB page, “NodeBB is next-generation forum software – powerful, mobile-ready and easy to use”. Let’s find the root cause of this vulnerability and try to reproduce it by analyzing the patch.
Note: This blog does not contain the full 1-click exploit.
Initial Analysis
The GitHub advisory summarized the vulnerability as:
Due to the use of the object destructuring assignment syntax in the user export code path, combined with a path traversal vulnerability, a specially crafted payload could invoke the user export logic to arbitrarily execute javascript files on the local disk.
The particularly interesting part of this description was the mentioning of the object destructuring assignment syntax. What exactly is it?
Object Destructuring Assignment
It is very important to learn about this term because it is the root cause of the vulnerability. In simple words, it is the syntax in JavaScript that assigns the elements of array or objects with the variables.
let a, b, rest;
[a, b] = [10, 20];
console.log(a);
// Expected output: 10
console.log(b);
// Expected output: 20
In this above example, a and b are assigned the value of 10 and 20 corrospondingly with the use of destructuring assignment.
Destructuring assignment has a special syntax of ...var
that allows the developers to assign the rest of the value of an object/array to a specific variable. An example:
[a, b, ...rest] = [10, 20, 30, 40, 50];
console.log(rest);
// Expected output: Array [30, 40, 50]
This works the same with objects as well.
let obj = {var1:"test",
var2:"example",
var3: 1
};
let {var1, ...rest} = obj
console.log(var1);
// Expected output: "test"
console.log(rest);
// Expected output: Object { var2: "example", var3: 1 }
More about it at Mozilla Docs and GeeksForGeeks.
Patch Review
The advisory provided us with the patch commit that fixes this vulnerability. Fortunately, only two files are changed.
The first file that is changed is src/api/users.js
. Four additional lines of code are added that is basically checking whether the value of type
parameter is profile
, posts
or uploads
. If not, an error is returned.
The full unpatched vulnerable function looks like this:
At line 451, child_process
is being called to execute a js file on the server. It is clear that if we can control the type
variable, we can execute whatever file we want on the server. But how do we overwrite the type value?
In src/socket.io/user/profile.js
, only one line of code has been changed. The use of object destructuring syntax { type, ...data }
is removed from the code while calling the api.users.generateExport()
function. Only uid
is extracted from the data
parameter instead of the whole data passed onto the data
variable. Interesting!
We can now roughly guess from the advisory and these patches that we should be able to overwrite the type variable with the object destructuring syntax.
Tracing the Code
Now that we roughly have an idea of how we might be able to exploit the vulnerability, we need to figure out how to call that specific code path. Tracing the vulnerable sink all the way to the source, we can find that the code path is callabe through a websocket request.
Sink to Source:
usersAPI.generateExport
-> doExport
-> SocketUser.exportProfile
-> user.exportProfile
Source to Sink:
Whenever the user calls user.exportProfile
in the websocket request, the function SocketUser.exportProfile
basically takes the user-input of the websocket request as data
parameter and sends it all the way to the sink.
SocketUser.exportProfile = async function (socket, data) {
await doExport(socket, data, 'profile');
};
The doExport()
function is now called with (socket, data, 'profile')
parameters. The type variable is hardcoded to 'profile'
which makes it seem like impossible to control the var.
async function doExport(socket, data, type) {
sockets.warnDeprecated(socket, 'POST /api/v3/users/:uid/exports/:type');
if (!socket.uid) {
throw new Error('[[error:invalid-uid]]');
}
if (!data || parseInt(data.uid, 10) <= 0) {
throw new Error('[[error:invalid-data]]');
}
await user.isAdminOrSelf(socket.uid, data.uid);
api.users.generateExport(socket, { type, ...data });
};
But the doExport()
function calls api.users.generateExport(socket, { type, ...data })
with ...data
that is user-controlled. Even though the type variable is not controllable directly, ...data
means that if we assign a {"type":"changed"}
value in the data variable that we pass, it might take that value overwriting the original type
value.
We finally come down to usersAPI.generateExport()
which is a plainsight sink of the code execution if we change the type
variable.
usersAPI.generateExport = async (caller, { uid, type }) => {
const count = await db.incrObjectField('locks', `export:${uid}${type}`);
if (count > 1) {
throw new Error('[[error:already-exporting]]');
}
const child = require('child_process').fork(`./src/user/jobs/export-${type}.js`, [], {
env: process.env,
});
//Code Stripped//
Connecting the Dots
Now that we have all the pieces, let’s try and send the websocket request through Burp.
Sending the above websocket request, we got a hit in the breakpoint.
This is what the type
variale looked like:
The above uid: 2
value confirmed that the value we passed is working.
Sending the above websocket request with the data value {"uid":2, "type":"changedtype"}
, we can confirm that it changed the type value in the below breakpoint local variable.
I received the error in the debugging console that mentioned export-changedtype.js
could not be found. This confirms the exploitation as the server is trying to execute any js file of our choice.
Now that we can control the type variable, we can path traverse to execute any file on the server as well. Sending a request with 421["user.exportProfile",{"uid":2,"type":"/../../../../eviljs"}]
would traverse the path and execute the eviljs
file on the root of the server. Simple as that!
Dead End
While we are now able to execute any js file in the server, I could not find a way to upload the js file given the limited time for the talk. In the default configuration of NodeBB, the application did not allow users to upload the js files, as far as I tested it. Uploading any attachment is only accessible from the administrator’s account which is not feasible in our attack scenario. Maybe there is a feature/bug that I am missing?
The CVSS score being 10.0 means that there should be a way in the default configuration as well(?). There is also a possibility of executing the js file that comes with NodeBB to cause some unexpected behavior. But for now, this is left as an exercise for the audience.
Conclusion
Even though I could not make this a magical one-click exploit which I really wanted to, the journey to find the root cause was very interesting and I learned a lot of stuffs along the way. I hope you guys learned a thing or two out of this blog post.
See you next time with a magical one-click exploit!