
Cache Me If You Can: XSS in Cloudflare's /CDN-CGI/Image/
Knowing this, the CSP added by /cdn-cgi/image/ can be excluded from Chromium's cached representation using the following source response header:
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
Knowing this, the CSP added by /cdn-cgi/image/ can be excluded from Chromium's cached representation using the following source response header:
Cache-Control: no-cache="Content-Security-Policy"
The initial response still contains the CSP, so the script remains blocked during the first navigation. However, when Chromium stores the response in its HTTP cache, it keeps the body while excluding the Content-Security-Policy header.
The final step is to load this cached response without its CSP. This requires forcing Chromium to use its HTTP disk cache during a history navigation.
This technique has been known in CTFs since 2022. It was demonstrated by @arkark_ in the SECCON 2022 spanote write-up. The idea is to open and control a new window:
Normally, a back navigation may restore the original document from the Back/forward cache cache, including its original CSP. However, because the opened window retains a window.opener relationship, it is not eligible for BFCache. Chromium therefore falls back to its HTTP cache, where the response body is stored without the Content-Security-Policy header.
Here is a short Flask application that demonstrates this CSP-removal behavior in Chromium:
from flask import Flask, request, Response app = Flask(name)
@app.route('/') def index(): res = Response('<script>alert("Hello, World!");</script>', status=200, mimetype='text/html') res.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self';" res.headers['Cache-Control'] = request.args.get('cache', 'no-cache') return res
@app.route('/poc') def poc(): return '''<p id="poc">Click to start the PoC</p>
<script> document.getElementById("poc").addEventListener("click", function() { var tab = window.open(`/?cache=no-cache="Content-Security-Policy"`); setTimeout(function() { tab.location.href = "/poc?trigger.history.back"; }, 1000); }); if (location.search.includes("trigger.history.back")) { history.back(); } </script>'''if name == 'main': app.run("127.0.0.1", 5000)
Bypassing source restrictions with Cloudflare Access
With the CSP and sanitizer bypasses, we had almost everything required to trigger the XSS through /cdn-cgi/image/. The last missing piece was finding a way to load our own image through it on other users' domains.
To understand how this could be achieved, we first need to look at the Cloudflare Image Transformations source configuration:
Three different configurations are available:
The most relevant configuration is This zone only, since it is the default. With Any origin, the complete XSS chain already works because images can be loaded from any domain.
However, we did not want to stop there. Our goal was to make the XSS work with more restrictive configurations.
To push this further, we relied on another /cdn-cgi/image/ behavior: the image fetcher follows HTTP redirects without checking whether the redirect destination is still allowed by the source configuration. Consequently, an open redirect on any allowed origin is enough to load an image from an arbitrary domain!
This was better, but still not satisfying. We wanted the XSS to work even when the zone did not contain an open redirect, including with the most restrictive source configuration. A single gadget would make this possible: an open redirect exposed under /cdn-cgi/.
While looking for one, we reached several dead ends, including:
After spending hours trying to understand every part of Cloudflare's /cdn-cgi/ internals, we discovered an interesting behavior: when Cloudflare Access is enabled on any hostname in a zone, /cdn-cgi/access/login and /cdn-cgi/access/logout become available on every proxied hostname in that zone.
More specifically, the /cdn-cgi/access/logout endpoint accepts a returnTo parameter that allows redirects to:
The second case was the most interesting. If an allowed cloudflare.com hostname contained an open redirect, it could be chained through /cdn-cgi/access/logout to reach a domain we control.
After investigating the available Cloudflare endpoints, we found the following redirect:
https://discord-mock-oauth.research.cloudflare.com/authorize?redirect_uri=http%3A%2F%2F<domain>%2Fpoc.svg&state=mizu
This gave us the final XSS chain for zones using a restricted Image Transformations source configuration, as long as Cloudflare Access was enabled on any hostname in the zone:
The final result was an XSS through /cdn-cgi/image/ on https://www.cloudflare.com:
After achieving XSS through Cloudflare's /cdn-cgi/image/ endpoint, we wondered whether the same Cache-Control primitive could be used elsewhere. Looking into mainstream frameworks offering similar image-optimization features, such as Next.js, we quickly encountered limitations. Next.js, for example, only retrieves the s-maxage or max-age directive before converting its value to an integer.
However, after pushing the research further, one service stood out: Netlify Image CDN.
For Netlify, the conditions under which a remote image can be loaded depend on the remote_images configuration. We identified three potential scenarios:
[images] remote_images = [".*"]
remote_images contains an incorrectly escaped regular expression. For example, the following configuration also accepts https://aab.com/ because
[images] remote_images = ["^https://a.b.com/.*$"]
An allowed origin contains an open redirect. As with /cdn-cgi/image/, the redirect destination is not checked against the remote_images configuration.
The public Netlify image handler follows the same general structure: it validates the initial source URL against remote_images, then passes it to IPX with allowAllDomains enabled.
When loading an image/svg+xml image, the response looked like this:
HTTP/2 200 Content-Security-Policy: script-src 'none' Content-Type: image/svg+xml Cache-Status: "Netlify Edge"; fwd=miss; fwd-status=200; stored Server: Netlify Content-Length: 125
Netlify's open-source Image CDN implementation, used by Netlify Dev to emulate the production endpoint, passes SVG images to IPX. IPX then invokes SVGO's removeScripts plugin to sanitize them.
The hosted implementation is not public, so we cannot confirm that it uses the exact same code path.
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。