{"id":195,"date":"2021-01-11T08:23:46","date_gmt":"2021-01-11T16:23:46","guid":{"rendered":"https:\/\/blog.mozilla.org\/attack-and-defense\/?p=195"},"modified":"2021-01-11T08:24:08","modified_gmt":"2021-01-11T16:24:08","slug":"leaking-silhouettes-of-cross-origin-images","status":"publish","type":"post","link":"https:\/\/blog.mozilla.org\/attack-and-defense\/2021\/01\/11\/leaking-silhouettes-of-cross-origin-images\/","title":{"rendered":"Guest Blog Post: Leaking silhouettes of cross-origin images"},"content":{"rendered":"<p><span><\/span><\/p>\n<p><em>This blog post is one of several guest blog posts, where we invite participants of our <a href=\"https:\/\/www.mozilla.org\/en-US\/security\/client-bug-bounty\/\">bug bounty program<\/a> to write about bugs they&#8217;ve reported to us.<\/em><\/p>\n<p>This is a writeup of a vulnerability I found in Chromium and Firefox that could allow a malicious page to read some parts of an image located on an origin it is not supposed to be able to access. Although technically interesting, it is quite limited in scope\u2014I am not aware of any major websites it could\u2019ve been used against. As of November 17th, 2020, the vulnerability has been fixed in the most recent versions of both browsers.<\/p>\n<h3>tl;dr<\/h3>\n<p>The time that it takes <code>CanvasRenderingContext2D.drawImage<\/code> to draw a pixel depends on whether it is fully transparent, opaque, or semi-transparent. By timing a bunch of calls to <code>drawImage<\/code>, we can reliably infer the transparency of each pixel in a cross-origin image, which is enough to, for example, read text on a transparent background, like this:<\/p>\n<div style=\"text-align:center\"><center><div style=\"width: 320px;\" class=\"wp-video\"><!--[if lt IE 9]><script>document.createElement('video');<\/script><![endif]-->\n<video class=\"wp-video-shortcode\" id=\"video-195-1\" width=\"320\" height=\"200\" autoplay=\"1\" preload=\"metadata\" controls=\"controls\"><source type=\"video\/webm\" src=\"http:\/\/blog.mozilla.org\/attack-and-defense\/files\/2021\/01\/exploit.webm?_=1\" \/><a href=\"http:\/\/blog.mozilla.org\/attack-and-defense\/files\/2021\/01\/exploit.webm\">http:\/\/blog.mozilla.org\/attack-and-defense\/files\/2021\/01\/exploit.webm<\/a><\/video><\/div><\/center><\/div>\n<p><\/p>\n<h3>Background: Same-Origin Policy and Canvas<\/h3>\n<p>JavaScript running on a website is not allowed to read the contents of documents located on other websites. For example, your bank might have a page <code>https:\/\/mybank.com\/get_balance\/<\/code> that the application at <code>https:\/\/mybank.com\/accounts\/<\/code> should be able to read, but this blog certainly shouldn\u2019t. This is formalized by the <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/Security\/Same-origin_policy\" rel=\"noopener noreferrer\" target=\"_blank\">Same-Origin Policy<\/a>, which allows resources that are located on a different origin from the current page to be embedded in it, but does not allow them to be read.<\/p>\n<p>The embedding\/reading distinction means that while I can, for example, include an image from a different origin on this page and your browser will display it for you, the page\u2014and any JavaScript running on it\u2014shouldn\u2019t be allowed to learn the image\u2019s contents. Unfortunately, there are side channels for the page to learn the existence or non-existence of an image at a URL, its size and dimensions, but if the contents of an image served from a well-known URL contain an authenticated user\u2019s private information, the contents should remain private because any third-party page will only be able to display it back to the user\u2014not read them.<\/p>\n<p>Canvas is an HTML feature that makes implementing this distinction tricky. A <code>&lt;canvas><\/code> element is kind of like an image, but it can be manipulated from JavaScript\u2014you can draw shapes, text, and other images onto it and, importantly, access individual pixels of the canvas for both reading and writing. Using the function <code>CanvasRenderingContext2D.drawImage<\/code>, any image that can be embedded onto a page can also be drawn onto a canvas, and this includes cross-origin images as well. In order to prevent an attacker from drawing a cross-origin image onto a canvas then reading the canvas to learn what the image looks like, a canvas that has ever had a cross-origin image drawn to it is marked as <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/HTML\/CORS_enabled_image#Security_and_tainted_canvases\" rel=\"noopener noreferrer\" target=\"_blank\">tainted<\/a> and can no longer be read from.<\/p>\n<h3>Some pixels are drawn faster than others<\/h3>\n<p>Pixels in an image can have varying levels of transparency, and a well-optimized image drawing library might use different code to draw an image depending on what kind of pixels it contains. There are three cases that arise naturally:<\/p>\n<ul>\n<li>If a pixel is fully transparent, drawing it onto a canvas is very easy: you don\u2019t need to do anything at all.<\/li>\n<li>If a pixel is fully opaque, it\u2019s pretty easy too: overwrite the destination pixel with it.<\/li>\n<li>If the pixel is semi-transparent, you actually need to do some math to figure out the resulting color after blending it onto whatever\u2019s already drawn on the canvas.<\/li>\n<\/ul>\n<p>Conveniently for us, <code>drawImage<\/code> lets us both crop and resize the image that will be drawn, so we can make a call like <code>drawImage(img, x, y, 1, 1, 0, 0, 1024, 1024)<\/code> to take just the one pixel located at coordinates <code>(x, y)<\/code> in img, scale it up to <code>1024\u00d71024<\/code>, and draw that onto our canvas.<\/p>\n<p>If there\u2019s any difference in performance between the three cases above, doing this will take a different amount of time depending on whether the given pixel is transparent, opaque, or semi-transparent, so by measuring the time the operation takes we can figure out which one it is. Repeat this for every pixel in img and you\u2019ll have a \u201csilhouette\u201d of the image, which might be enough to figure out what the image contains, especially if it\u2019s an image of text or a drawing on a transparent background.<\/p>\n<p>Indeed, both Firefox and Chromium use Google\u2019s <a href=\"https:\/\/skia.org\/\" rel=\"noopener noreferrer\" target=\"_blank\">Skia Graphics Library<\/a>, which used to handle the three cases separately in the function <code>blit_row_s32a_opaque<\/code>. A quick <a href=\"https:\/\/github.com\/aleksejspopovs\/cve-2020-16012\/blob\/main\/benchmark\/benchmark.html\" rel=\"noopener noreferrer\" target=\"_blank\">benchmark<\/a> I wrote confirmed that the performance of <code>drawImage<\/code> varied depending on the alpha-value of the pixels being drawn<sup>1<\/sup>:<\/p>\n<p><img decoding=\"async\" loading=\"lazy\" src=\"http:\/\/blog.mozilla.org\/attack-and-defense\/files\/2021\/01\/bench.png\" alt=\"A bar chart titled \u201cdrawImage performance when drawing a pixel scaled to 1024\u00d71024\u201d, subtitled \u201cFirefox 78.0.2 on Linux x86_64\u201d. The x-axis is \u201cTime to perform 1000 iterations, ms, average of 10 runs\u201d. Five rows are visible: #000000, 0%: 46 ms 1%: 404 ms 50%: 401 ms 99%: 402 ms 100%: 252 ms\" width=\"672\" height=\"247\" class=\"size-full wp-image-194\" srcset=\"https:\/\/blog.mozilla.org\/attack-and-defense\/files\/2021\/01\/bench.png 672w, https:\/\/blog.mozilla.org\/attack-and-defense\/files\/2021\/01\/bench-300x110.png 300w, https:\/\/blog.mozilla.org\/attack-and-defense\/files\/2021\/01\/bench-600x221.png 600w\" sizes=\"(max-width: 672px) 100vw, 672px\" \/><\/p>\n<h3>Caveat: GPU acceleration<\/h3>\n<p>All of the above only applies when image&#8217;s operations are performed using the CPU. When they are performed using GPU acceleration, no observable differences in performance seem to exist.<\/p>\n<p>Firefox does not support GPU acceleration in all configurations yet: the majority of Windows users are accelerated in the common case, but other platforms are likely affected. Chromium will use GPU acceleration on most hardware, so it is only affected when GPU rendering is disabled, either because the user is using a known-bad GPU or driver, or because GPU acceleration has been explicitly disabled<sup>2<\/sup>.<\/p>\n<h3>Exploitation<\/h3>\n<p>To exploit this, let\u2019s write a function that takes one pixel of an image and measures how long it takes to draw it, blown up to 1024\u00d71024 pixels, a hundred times:<\/p>\n<pre>const Iters = 100\r\nlet ScratchContext = document.createElement('canvas').getContext('2d')\r\n\r\nfunction zeroDelay() {\r\n  return new Promise(resolve => setTimeout(resolve, 0))\r\n}\r\n\r\nasync function timePixel(image, x, y) {\r\n  let startTime = performance.now()\r\n  for (let j = 0; j < Iters; j++) {\r\n    ScratchContext.drawImage(image, x, y, 1, 1, 0, 0, 1024, 1024)\r\n  }\r\n  \/* in Chromium, the draw operations aren't actually performed\r\n     immediately, but only after the JavaScript thread stops. we wait\r\n     on a timeout with a duration of zero to give the browser a chance\r\n     to do the drawing, as otherwise we'd just be measuring the time\r\n     taken to enqueue all of the draw operations. *\/\r\n  await zeroDelay()\r\n  let endTime = performance.now()\r\n\r\n  return endTime - startTime\r\n}<\/pre>\n<p>Looking at the performance chart above, we know that, in an image where all pixels are either fully transparent or fully opaque, <code>timePixel<\/code> will return a much higher value for the opaque ones. Now we can measure all of the pixels one-by-one and render the timings on a heatmap, obtaining the Hollywood hacker movie-worthy reconstruction we saw in the video at the beginning of the post.<\/p>\n<p>The full source code for the exploit is available <a href=\"https:\/\/github.com\/aleksejspopovs\/cve-2020-16012\" rel=\"noopener noreferrer\" target=\"_blank\">on my Github<\/a>.<\/p>\n<h3>Disclosure and vendor response<\/h3>\n<p>I reported this bug to Mozilla on May 29th, 2020 through the <a href=\"https:\/\/www.mozilla.org\/en-US\/security\/bug-bounty\/\" rel=\"noopener noreferrer\" target=\"_blank\">Mozilla Security Bug Bounty program<\/a> and to Google through the <a href=\"https:\/\/www.google.com\/about\/appsecurity\/chrome-rewards\/\" rel=\"noopener noreferrer\" target=\"_blank\">Chrome Vulnerability Reward<\/a> the next day. It took some time to figure out which  graphics backend is used in Firefox by default these days. With the help of a Google engineer and some profiling tools, we identified that the same piece of Skia code was responsible for this behavior in both browsers.<\/p>\n<p>Google <a href=\"https:\/\/skia.googlesource.com\/skia\/+\/5d3314c53ce5c966591f0b02349103f51f986e6e\" rel=\"noopener noreferrer\" target=\"_blank\">updated<\/a> Skia to remove branching on alpha value in <code>blit_row_s32a_opaque<\/code> completely on August 29th, 2020 and <a href=\"https:\/\/chromium.googlesource.com\/chromium\/src.git\/+\/c000744b0540f5a5617f8c986fac70d026e03008\" rel=\"noopener noreferrer\" target=\"_blank\">merged<\/a> that change into Chromium on the same day. Mozilla <a href=\"https:\/\/hg.mozilla.org\/mozilla-central\/rev\/48c0f5033c28\" rel=\"noopener noreferrer\" target=\"_blank\">merged<\/a> the change on October 6th, 2020.<\/p>\n<p>Google has issued <code>CVE-2020-16012<\/code> to notify users about this bug.<\/p>\n<p>Both vendors offered very generous bounties for my reports. It\u2019s been a pleasure working with Mozilla and Google to get this fixed, and I would like to take this opportunity to thank Mike Klein from Google and Lee Salzman from Mozilla for their work on diagnosing and fixing the bug. I would also like to thank Tom Ritter and Lee Salzman from Mozilla for their helpful feedback on drafts of this blog post.<\/p>\n<p><small><sup>1<\/sup> Although the behavior in Chromium is nearly identical, if you\u2019re not careful about how you set up the benchmark, you might get a surprising result there: while the performance for fully and semi-transparent pixels is the same as in Firefox, fully solid pixels are drawn much faster. This is caused by an additional optimization that is only hit in Chromium, where Skia detects that an opaque 1024\u00d71024 image is being drawn onto a canvas that\u2019s exactly the same size, so instead of doing it pixel-by-pixel it just moves the whole buffer over, which turns out to be even faster than doing nothing 10242 times. It appears that this optimization is only triggered when the entire source image is opaque, not just the pixel we crop out of it. If you use a source image that has pixels with different alpha-values, Chromium performs exactly the same as Firefox.<\/small><\/p>\n<p><small><sup>2\/<\/sup> Though my Intel GPU is not on the blocklist, during my experiments I found that hammering it with thousands of draw commands will crash the driver and force Chromium to revert to CPU rendering, so this might be one way of exploiting the vulnerability on some hardware\/OS combinations.<\/small><\/p>\n","protected":false},"excerpt":{"rendered":"<p>This blog post is one of several guest blog posts, where we invite participants of our bug bounty program to write about bugs they&#8217;ve reported to us. This is a &hellip; <a class=\"go\" href=\"https:\/\/blog.mozilla.org\/attack-and-defense\/2021\/01\/11\/leaking-silhouettes-of-cross-origin-images\/\">Read more<\/a><\/p>\n","protected":false},"author":1610,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[448815,4735,449523],"tags":[],"coauthors":[449537],"_links":{"self":[{"href":"https:\/\/blog.mozilla.org\/attack-and-defense\/wp-json\/wp\/v2\/posts\/195"}],"collection":[{"href":"https:\/\/blog.mozilla.org\/attack-and-defense\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blog.mozilla.org\/attack-and-defense\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blog.mozilla.org\/attack-and-defense\/wp-json\/wp\/v2\/users\/1610"}],"replies":[{"embeddable":true,"href":"https:\/\/blog.mozilla.org\/attack-and-defense\/wp-json\/wp\/v2\/comments?post=195"}],"version-history":[{"count":0,"href":"https:\/\/blog.mozilla.org\/attack-and-defense\/wp-json\/wp\/v2\/posts\/195\/revisions"}],"wp:attachment":[{"href":"https:\/\/blog.mozilla.org\/attack-and-defense\/wp-json\/wp\/v2\/media?parent=195"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blog.mozilla.org\/attack-and-defense\/wp-json\/wp\/v2\/categories?post=195"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blog.mozilla.org\/attack-and-defense\/wp-json\/wp\/v2\/tags?post=195"},{"taxonomy":"author","embeddable":true,"href":"https:\/\/blog.mozilla.org\/attack-and-defense\/wp-json\/wp\/v2\/coauthors?post=195"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}