Tags: web url_parse 

Rating:

# Introduction

Flag Portal is an interesting challange that was meant to be based on request smuggling through a reverse proxy, but due to misconfiguration of said proxy turned out to be quite a bit easier and a fixed version of the challange was released later.

#### Challenge description:
> Welcome to the flag portal! Only admins can view the flag for now. Too bad you're behind a reverse proxy ¯\(ツ)/¯
>
> Note: There are two flags for this challenge.
>
> http://flagportal.chall.seetf.sg:10001

#### Challenge author: zeyu2001

# What are we looking at

The first thing we see after visiting the flag portal is a simple website that makes a request to an api to get the number of flags and displays it as text without even any styling:

![index page](https://cdn.hashnode.com/res/hashnode/image/upload/v1654459299087/0oVMxH1n0.png)

If we go into the sources we can see we have 3 services in total, and the docker-compose file clarifies that only the proxy is public, flagportal is a server that contains the first flag and has some admin key, and backend contains the second flag and presumably the same admin key:
```yaml
version: "3"
services:
proxy:
build: ./proxy
restart: always
ports:
- 8000:8080
flagportal:
build: ./flagportal
restart: always
environment:
- ADMIN_KEY=FAKE_KEY
- FIRST_FLAG=SEE{FAKE_FLAG}
backend:
build: ./backend
restart: always
environment:
- ADMIN_KEY=FAKE_KEY
- SECOND_FLAG=SEE{FAKE_FLAG}
```

# Frontend - the flagportal

The source for flagportal is a simple ruby web server based on rack and puma, and even without knowing ruby it's quite simple to see what we have to do to get the first flag:
```ruby
elsif path == '/admin'
params = req.params
flagApi = params.fetch("backend", false) ? params.fetch("backend") : "http://backend/flag-plz"
target = "https://bit.ly/3jzERNa"

uri = URI(flagApi)
req = Net::HTTP::Post.new(uri)
req['Admin-Key'] = ENV.fetch("ADMIN_KEY")
req['First-Flag'] = ENV.fetch("FIRST_FLAG")
req.set_form_data('target' => target)

res = Net::HTTP.start(uri.hostname, uri.port) {|http|
http.request(req)
}

resp = res.body

return [200, {"Content-Type" => "text/html"}, [resp]]
```

We can see that it makes some request to the backend that includes the first flag, the admin key we saw in the docker environment earlier and a URL for a "target". Then it returns the body of the response.

Since usually flags are ordered from the easier to the harder ones, let's just start by exfiltrating the first one: we can see that the backend URL is actually determined by a parameter in the request, and as such we can point this function to an endpoint we control and the flagportal will just happily send us the flag.

However, when we try that, the endpoint simply returns a 403 error with the following note:

![unsuccessful attempt to go to /admin](https://cdn.hashnode.com/res/hashnode/image/upload/v1654459275975/5_wv4hkJ-.png)

Well then, how about the api server, perhaps there is a way through there?

# Backend

On the backend we have a simple flask app with 4 endpoints, but it's quite easy to see that `/flag-plz` (or `/api/flag-plz`) is the endpoint we were looking for, and it's the one we saw the flagserver make requests to before.

```python
@app.route('/flag-plz', methods=['POST'])
def flag():
if request.headers.get('ADMIN_KEY') == os.environ['ADMIN_KEY']:
if 'target' not in request.form:
return 'Missing URL'

requests.post(request.form['target'], data={
'flag': os.environ['SECOND_FLAG'],
'congrats': 'Thanks for playing!'
})

return 'OK, flag has been securely sent!'

else:
return 'Access denied'
```

It takes a `POST` request and needs an `ADMIN_KEY` in the headers. It also needs us to send form data with a `target` property that it will then send the flag to in the form of another `POST` request.

But if we try to make a request there, we don't even get the `Access denied` message, but a 405 `Method Not Allowed` error.

![attempt at sending POST to /api/flag-plz resulting with Method Not Allowed error](https://cdn.hashnode.com/res/hashnode/image/upload/v1654459394735/PNUviBtz9.png)

Huh, what if we use `GET` then:

![GET on /api/flag-plz showing Forbidden as the response](https://cdn.hashnode.com/res/hashnode/image/upload/v1654459436726/RbFgtEmZX.png)

Ah, that explains it. It's blocked somewhere. To get to the bottom of this we probably need to look at the proxy server then...

# Reverse proxy - Traffic Server

Apache Traffic Server is an HTTP caching proxy server. In this challenge all it does is act as a reverse proxy and maps endpoints to specific services that aren't exposed. The `remap.config` file is quite easy to read:
```config
map /api/flag-plz http://backend/forbidden
map /api http://backend/
map /admin http://flagportal/forbidden
map / http://flagportal/
```

So we can see that the index and API are routed properly, but any attempt to get at the admin or the flag-specific API endpoint will redirect to `/forbidden`.
There is one thing that you may notice here, that's even more obvious if you remember the code for the index page that fetches the flag count:
```js
fetch('/api/flag-count').then(resp => resp.text()).then(data => document.getElementById('count').innerText = data)
```
The paths here map everything that's under them too. So `/api` really means `/api*`, while `/` is `/*`. And we can even test that it doesn't require slashes between paths:

`http://flagportal.chall.seetf.sg:10001/shoulderror` shows us the Not Found error from the flagserver
![404 on /shoulderror](https://cdn.hashnode.com/res/hashnode/image/upload/v1654459517155/7LnVQ0__i.png)

But `http://flagportal.chall.seetf.sg:10001/apishoulderror` gives a different looking Not Found message from the backend.
![/apishoulderror showing a longer Not Found message](https://cdn.hashnode.com/res/hashnode/image/upload/v1654459553012/-_aGEj91Z.png)

Well then, if we assume that Traffic Server is just doing a dumb comparison and doesn't get how paths work, we can try to add something that should be meaningless, like another slash, and see if it still redirects us. For example we can check if `http://flagportal.chall.seetf.sg:10001/api//flag-count` still returns our flag count:
![GET /api//flag-count returning 2 as it should](https://cdn.hashnode.com/res/hashnode/image/upload/v1654459616666/HWE6IDeeg.png)

Well, look at that - we have an error message and as we checked earlier it seems to come from the flagserver and not the backend. So what if we just go to `//admin`? Will the ruby server also mess up the path?
![GET //admin returning "OK, flag has been securely sent!" message](https://cdn.hashnode.com/res/hashnode/image/upload/v1654459653928/ci_emvGEa.png)

Doesn't seem like it! So now all that's left is to get the servers to send us what we need.

# Exploiting the flaw

We'll need some publicly routable IP/domain on which we can listen to requests on - witch we can easily get by either using some virtual server or something like [`ngrok`]( https://ngrok.com/) that give us a tunnel with a public endpoint so that we can expose a service on our own PC. For the CTF I used the former, but since `ngrok` has a nice request inspector built in, let's use it here. Just run `ngrok http --scheme=http 0` (use `--scheme=http` because the CTF server doesn't speak TLS and upgrades break it too :) to get the endpoint and we can use the web GUI to inspect incoming requests.

So let's send it:

![obraz.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1654458443608/zH6nwnY67.png)

We get an ngrok error back, but that's just because we aren't actually hosting anything. If we now check the web interface we can see the request the server sent:

![obraz.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1654458750740/BucQUg1z-.png)

So now we have the first flag and the admin key that the backend server needs. Let's just craft another request using the same trick then:

![obraz.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1654458885151/ROQ5JFGwk.png)

And check back on ngrok:
![obraz.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1654458912728/0UB3xXH-D.png)

Now just to decode the URL-encoded `%7` and `%21` to `{` and `}` respectively and we have our second flag!

> This is a writeup for [SEETF 2022](https://play.seetf.sg/) which I participated in as a member of [DistributedLivelock](https://ctftime.org/team/187094) team. You can find my other writeups for this CTF [here](https://blog.opliko.dev/series/seetf-2022)

Original writeup (https://blog.opliko.dev/seetf-2022-flag-portal-writeup).