Blog post

Code Vulnerabilities Put Skiff Emails at Risk

Paul Gerste photo

Paul Gerste

Vulnerability Researcher

8 min read

  • Security

Executive Summary / Key Information

  • In June 2022, the Sonar Research team discovered critical code vulnerabilities in multiple encrypted email solutions, including Proton Mail, Skiff, and Tutanota.
  • These privacy-oriented webmail services provide end-to-end encryption, making communications safe in transit and at rest. Our findings affect their web clients, where the messages are decrypted with the user's keys; mobile clients were unaffected.
  • The vulnerabilities would have allowed attackers to steal emails and impersonate victims if they interacted with malicious messages.
  • Thanks to our report, the issue has been fixed, and there are no signs of in-the-wild exploitation.


Introduction

Last week's article discussed the risks of end-to-end encrypted mail providers and showcased the details of a Cross-Site Scripting vulnerability we found in Proton Mail.


In this blog post, we present the technical details of the vulnerabilities we found in Skiff. We show how an innocent-looking piece of code led to a Cross-Site Scripting issue that made it possible for attackers to steal decrypted emails and impersonate victims.


As part of our 3-post series, we will cover another severe vulnerability we found in Tutanota Desktop next week. Attackers could have used that vulnerability to steal emails and even execute arbitrary code on the victims' machines.


We also presented the content of this blog post series as a talk at Black Hat Asia 2023; the video recording will is available here.

Impact

The Sonar Research team discovered a Cross-Site Scripting vulnerability in the open-source code of Skiff's web client. Since the web client is where the decryption of emails happens after the user enters their password, it is also the place where the emails exist in their decrypted form. Attackers can therefore steal decrypted emails and impersonate their victims, bypassing the end-to-end encryption.


This time, attackers have to send two emails, both of which must be viewed by the victim. The second email contains a link that the victim has to click.


We responsibly disclosed the vulnerabilities to the vendor in June 2022, and they were fixed shortly after. The following proof-of-concept shows how attackers could have exploited the vulnerability:

Technical Details

Dealing with user-controlled HTML in a web application always increases the risk of Cross-Site Scripting (XSS). While senders may want to style their message and include images, other HTML tags like <script> may have unwanted effects and compromise the reader's security. This is already dangerous for regular webmail services, where anybody could send a malicious email to a user just by knowing their email address.


It is even more dangerous for end-to-end encrypted and privacy-oriented web mailers, where users put much more trust into the service. If an attacker can execute arbitrary JavaScript in the context of such an application, they could potentially steal decrypted emails and private keys, deanonymize users, and impersonate victims.


To avoid all this, web mailers put a lot of effort into ensuring no malicious HTML can get through. Most use state-of-the-art HTML sanitizers, such as DOMPurify, to eliminate malicious HTML. This is an excellent first step, but even the sanitized data is so fragile that subtle mistakes in handling it can jeopardize the security of the whole application.


The following sections will explain the code vulnerability we found in Skiff. We will also highlight the importance of modern web defense mechanisms, how they make attackers' lives harder, and how they can still be bypassed when the right conditions align. Finally, we examine how the Skiff team fixed these issues and how to avoid such vulnerabilities in your code.


Prepare for a story about mXSS, sandbox bypasses, and CSP gadgets!

Skiff

To ensure the security of their service, Skiff employs multiple defenses. They start by sanitizing the HTML of an email body using DOMPurify. After that, they perform a few more steps, including the following transformation:


skiff-mail-web/components/Thread/MailHTMLView/injectIDs.ts:

const injectShowPreviousContainer = (dom: Document) => {
  const quote = dom.querySelector('[data-injected-id=last-email-quote]');
  if (quote) {
    const div = document.createElement('div');
    div.setAttribute(INJECTED_ID_ATTR, InjectedIDs.ShowPreviousContainer);
    quote.parentElement?.insertBefore(div, quote);
  }
};


This function marks the beginning of the previously quoted emails in an email thread. It inserts an empty <div> element before the first element that has a data-injected-id attribute with the value last-email-quote. This modification looks innocent at first glance because only an empty element is inserted.


However, the insertion of an element leads to a case of mutation-based Cross-Site Scripting (mXSS). Let's look at the following payload:



This payload passes the sanitization just fine because the <img> tag with the event handler is hidden in an attribute value. The content of the <style> element is parsed as HTML instead of raw text here because it is located within an <svg> element, so the SVG parsing rules apply. 


After that, the injectShowPreviousContainer function inserts the <div> tag, resulting in the following HTML tree:

 

If we consult the HTML specification, we can see that <div> elements are not valid children of <svg> elements. Since this was an explicit modification of the DOM, no error is thrown, and the element stays at the position it was inserted.

Reparsing Triggers Mutations

At a later stage in the email handling code, the sanitized DOM tree gets serialized back to its string representation again by reading its innerHTML property:


skiff-mail-web/components/Thread/MailHTMLView/injectIDs.ts:

export const injectIDs = (html) => {
  const dom = document.implementation.createHTMLDocument();
  // ...
  injectShowPreviousContainer(dom);
  // ...
  return dom.body.innerHTML;
};


To finally render the processed email, the resulting HTML is parsed again by assigning it to an element's innerHTML property via React's dangerouslySetInnerHTML attribute:


skiff-mail-web/components/Thread/MailHTMLView/MailHTMLView.tsx:

const MailHTMLView: FC<MailViewProps> = ({ email }) => {
  // ...
  return (
    // ...
        <div
          className='ProseMirror'
          dangerouslySetInnerHTML={{ __html: purifiedContent }}
          ref={setEmailDivRef}
          style={{ fontFamily: "'Skiff Sans Text'" }}
        />
      </div>,
    // ...
  );
};


During the re-parsing, the browser will try to correct any errors in the HTML. In general, HTML parsers are very lenient and try to cover up any mistakes by developers. How nice of them, right? The parser will, for example, try to close elements with missing closing tags, normalize attribute delimiters, and much more.


In the case of Skiff, this mutation of the input leads to the following HTML being inserted into the page:


We can observe that the <div> element was moved outside the <svg> element, which is plausible since it was not a valid child. 


However, the <style> element was also moved outside the <svg> element along with its predecessor. This situation is familiar from last week's Proton Mail vulnerability! During sanitization, the <style> element is parsed with SVG rules, while it is parsed with HTML rules during the re-parsing.


This parsing difference can be abused the same way as before, resulting in the <img> tag being inserted into the DOM and triggering its onerror event handler during the rendering of the email. So, with a similar payload as the one for Proton Mail, we found a bypass of Skiff's sanitization process that allows inserting arbitrary HTML into the page.

Escaping the Sandbox

As mentioned earlier, there are multiple defenses in place. After the sanitizer, the next one is an iframe sandbox like the one we covered for Proton Mail. We can find the same directives for Skiff, but there is no special case for Safari:

  • allow-same-origin
  • allow-popups
  • allow-popups-to-escape-sandbox


This means that the only way to escape the sandbox is to open a payload in a new tab, which in turn requires the victim to click a link.


An attacker can use a CSS leak technique to get a same-origin link that the victim can click. Skiff uses blob URLs to render inline images in emails. This allows attackers to create such URLs with arbitrary content and type by sending them as attachments. Blob URLs inherit the origin of the page they are created on, so they will be able to access data from the original page.


An attacker can then include a CSS payload in their email alongside the attachment that will leak the blob URL back to them. They would then use this to send a follow-up email with a link to that blob URL. By setting target="_blank" on that link, the URL will always be opened in a new tab.


Check out last week's blog post to learn about the details of the blob URL creation and CSS leak technique!

Bypassing the CSP with Cloudflare's Help

The final line of defense is Skiff's Content Security Policy (CSP). Here, we have many directives, with the most interesting ones being the following:

  • default-src 'self'
  • ​​img-src https://*
  • style-src 'unsafe-inline'
  • script-src 'unsafe-eval' http://hcaptcha.com


The first three are similar to Proton Mail and allow for remote images and inline styles in emails. The last directive is the interesting one again: it allows scripts from hCaptcha, a captcha service, and it allows scripts to use the eval() function.


Attackers can bypass this directive with a known gadget. We observed that hcaptcha.com is hosted behind Cloudflare, a popular content delivery network and DDoS protection provider. This means hcaptcha.com will serve a few utility scripts under the /cdn-cgi/scripts/ path. Some of those scripts contain gadgets that allow bypassing a site's CSP when unsafe-eval is allowed. This technique was discovered by Pepe Vila in 2020, and it perfectly fits our scenario. Check out Pepe's page for the details about this method!

Putting it all together

With that, the exploit strategy is complete. The attacker first sends an email with an attachment that causes a blob URL to be created. The email also contains CSS that leaks this URL to the attacker server with the previously described method. Once the blob URL is known, the attacker sends a follow-up email, this time containing a link that the victim has to click. When that happens, the blob URL is opened in a new tab where the hCaptcha/Cloudflare script gadget bypasses the CSP and executes arbitrary JavaScript in the context of the Skiff web application.

Patch

Since the code vulnerability we found led to serious impact, let's find out how it was fixed and how you can avoid similar issues in your code.


The Skiff team went for a generic approach that can be applied to all similar situations. They moved the sanitizer pass after all the modifications to make sure the final HTML is safe:

const bodyContent = getEmailBody(email);
const dom = new DOMParser().parseFromString(bodyContent, 'text/html');
proxyAttributes(dom, disableRemoteContent);
rewriteCSSAttribute(dom, originUrl, disableRemoteContent);
const sanitizedContent = DOMPurify.sanitize(dom.documentElement.outerHTML);
return getIframeHtml(sanitizedContent, extraStyle);



To avoid these kinds of sanitizer bypasses in general, we have a few recommendations:

  • If possible, sanitize on the client instead of the server. HTML parsers are complex beasts; using two different ones is like asking for parser differentials.
  • Use state-of-the-art sanitizers. This can be DOMPurify, but also the upcoming Sanitizer API that will be built into browsers in the future. If you use obscure or outdated sanitizers, they may miss weird quirks and leave you vulnerable.
  • Never modify data after sanitizing it. This is not specific to HTML but to any data that needs to be sanitized. The more complex the data structure, the more dangerous it becomes to modify it after sanitization.
  • If possible, don't even re-parse HTML after sanitizing it. DOMPurify can be configured to return the sanitized DOM tree instead of a string. If you directly insert this tree into the page's DOM, the browser will not mutate its contents, leaving less opportunity for mXSS.

Timeline

DateAction
2022-06-28We send our detailed report to Skiff
2022-06-30Skiff deploys the fix to production

Summary

In this article, we explained how an innocent-looking mistake in the code could significantly impact the security of an application. We showed how we found and exploited a Cross-Site Scripting vulnerability in Skiff, a popular end-to-end encrypted webmail service.


We also discussed how the flaw was fixed and how to avoid such problems in your code. Remember to use client-side sanitization with a state-of-the-art sanitizer, and don't modify or re-parse HTML after it has been sanitized.


Kudos to the Skiff team for handling our report fast and professionally. They fixed the vulnerability in two days, proving they care greatly about their product's security!


Next Wednesday, we will complete our 3-part series with a blog post on Tutanota Desktop, where we found an XSS issue that leads to Remote Code Execution. If you don't want to miss it, follow us on Twitter or Mastodon!

Related Blog Posts

Get new blogs delivered directly to your inbox!

Stay up-to-date with the latest Sonar content. Subscribe now to receive the latest blog articles.