ping CTF 2023

Path Traversal 101 [50 pts]

Can you pass the path traversal exam? πŸ‘€ There might be some requirements tho for your specie…

https://path-traversal-101.knping.pl

ec96a2a4ee45dd97dfa37319f21468a3.zip


We’re given a zip file containing the source files for the website. Let’s take a look.

Firstly, robot.js:

export default (req, res, next) => {
    const userAgent = req.get("User-Agent");
    if (userAgent == "robot") {
        next();
    } else {
        res.render("robot", { error: "πŸ€–πŸ€–πŸ€–πŸ€–πŸ€–" });
    }
};

Seems like we need to change our User-Agent to robot. This can easily be done with the Google Chrome extensions Requestly or with BurpSuite. I chose to do it with Requestly.

To do it with Requestly, create a new rule to modify HTTP Request Headers, and modify User-Agent to robot. Reloading the page now gets us in!

Now take a look at index.js:

import express from "express";
import { randomUUID } from "crypto";
import withCatch from "./withCatch.js";
import { task1, task2, task3, tasks } from "./tasks.js";
import cookieParser from "cookie-parser";

import rl from "./ratelimit.js";
import robot from "./robot.js";

const { FLAG } = process.env;

const app = express();
const port = 3000;

const sessions = [];

const createSession = () => {
    const id = randomUUID();
    return {
        id,
        task1: false,
        task2: false,
        task3: false,
    };
};

app.set("view engine", "ejs");
app.use(express.static("static"));
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(cookieParser());

app.get("/", (req, res) => {
    res.render("robot", {
        error: "",
    });
});

app.get("/robots.txt", (req, res) => {
    res.type("text/plain");
    res.send("User-agent: *\nDisallow: /πŸ€–");
});

app.get("/%F0%9F%A4%96", robot, (req, res) => {
    const token = req.cookies.token;
    if (!token) {
        const session = createSession();
        sessions.push(session);
        res.cookie("token", session.id);
        res.render("exam", {
            task: tasks[0],
        });
    } else {
        const session = sessions.find((session) => session.id === token);
        if (!session) {
            const session = createSession();
            sessions.push(session);
            res.cookie("token", session.id);
            res.render("exam", {
                task: tasks[0],
            });
        } else {
            if (!session.task1) {
                res.render("exam", {
                    task: tasks[0],
                });
            } else if (!session.task2) {
                res.render("exam", {
                    task: tasks[1],
                });
            } else if (!session.task3) {
                res.render("exam", {
                    task: tasks[2],
                });
            } else {
                res.render("exam", {
                    task: FLAG,
                });
            }
        }
    }
});

app.get("/*", (req, res) => {
    res.render("robot", {
        error: "",
    });
});

app.post(
    "/%F0%9F%A4%96",
    rl,
    robot,
    withCatch(async (req, res) => {
        const token = req.cookies.token;
        if (!token) {
            throw new Error("Unauthorized");
        }
        const session = sessions.find((session) => session.id === token);
        if (!session) {
            throw new Error("Unauthorized");
        }
        const { solution } = req.body;
        if (!session.task1) {
            const result = task1(solution);
            if (result) {
                session.task1 = true;
                res.render("exam", {
                    task: tasks[1],
                });
            } else {
                res.render("exam", {
                    task: tasks[0],
                    error: "Try again!",
                });
            }
        } else if (!session.task2) {
            const result = task2(solution);
            if (result) {
                session.task2 = true;
                res.render("exam", {
                    task: tasks[2],
                });
            } else {
                res.render("exam", {
                    task: tasks[1],
                    error: "Try again!",
                });
            }
        } else if (!session.task3) {
            const result = task3(solution);
            if (result) {
                session.task3 = true;
                res.render("exam", {
                    task: FLAG,
                });
            } else {
                res.render("exam", {
                    task: tasks[2],
                    error: "Try again!",
                });
            }
        }
    })
);

app.listen(port, () => {
    console.log(`Server is running on http://localhost:${port}`);
});

Immediately, we can notice that at /robots.txt, /πŸ€– is disallowed from web crawling. Visiting that site presents us with our first task of tasks.js. Here’s tasks.js for reference:

import path from "path";

const preTask = (solution) => {
    if (typeof solution !== "string") {
        throw new Error("Solution must be a string");
    }
    if (solution.length > 512) {
        throw new Error("Solution must be less than 512 characters");
    }
    if (solution === "flag") {
        throw new Error("Your solution can't be 'flag'");
    }
    if (solution === "./flag") {
        throw new Error("Your solution can't be './flag'");
    }
};

export const task1 = (solution) => {
    preTask(solution);
    if (!solution.startsWith("/robot") || solution.endsWith("/flag")) {
        throw new Error(
            "You cannot access the flag!!! You are UNAUTHORIZED!!! πŸ€–πŸ€–πŸ€–πŸ€–πŸ€–"
        );
    }

    const solutionPath = path.join("/", solution);
    return solutionPath === "/flag";
};

export const task2 = (solution) => {
    preTask(solution);
    solution = solution.replaceAll("../", "");
    if (solution === "/flag") {
        throw new Error(
            "You cannot ACCESS the flag!!! You are UNAUTHORIZED!!! πŸ€–πŸ€–πŸ€–πŸ€–πŸ€–"
        );
    }

    const solutionPath = path.join("/", solution);
    return solutionPath === "/flag";
};

export const task3 = (solution) => {
    preTask(solution);
    if (solution.includes("../") || solution === "/flag") {
        throw new Error(
            "You CANNOT ACCESS the flag!!! You are UNAUTHORIZED!!! πŸ€–πŸ€–πŸ€–πŸ€–πŸ€–"
        );
    }

    const solutionPath = path.join("/", solution);
    return solutionPath === "/flag";
};

export const tasks = [
    `if (!solution.startsWith("/robot") || solution.endsWith("/flag")) {
    throw new Error(
        "You cannot access the flag!!! You are UNAUTHORIZED!!! πŸ€–πŸ€–πŸ€–πŸ€–πŸ€–"
    );
}

const solutionPath = path.join("/", solution);
return solutionPath === "/flag";`,
    `solution = solution.replaceAll("../", "");
    if (solution === "/flag") {
        throw new Error(
            "You cannot ACCESS the flag!!! You are UNAUTHORIZED!!! πŸ€–πŸ€–πŸ€–πŸ€–πŸ€–"
        );
    }

    const solutionPath = path.join("/", solution);
    return solutionPath === "/flag";`,
    `if (solution.includes("../") || solution === "/flag") {
    throw new Error(
        "You CANNOT ACCESS the flag!!! You are UNAUTHORIZED!!! πŸ€–πŸ€–πŸ€–πŸ€–πŸ€–"
    );
}

const solutionPath = path.join("/", solution);
return solutionPath === "/flag";`,
];

Let’s take a look at task 1:

export const task1 = (solution) => {
    preTask(solution);
    if (!solution.startsWith("/robot") || solution.endsWith("/flag")) {
        throw new Error(
            "You cannot access the flag!!! You are UNAUTHORIZED!!! πŸ€–πŸ€–πŸ€–πŸ€–πŸ€–"
        );
    }

    const solutionPath = path.join("/", solution);
    return solutionPath === "/flag";
};

Firstly, to locally test our solution, it’s easy to use a Node.js compiler like this and run each task there. Just note that importing the path module requires the use of this statement: var path = require('path');, not the one they provide in tasks.js.

Now let’s develop our solution. The first one requires that our path start with /robot and not end with /flag. This can be pretty easily done with .., which allows us to traverse backwards. Thus, we can construct the following string:

/robot/../flag/a/..

The idea here is that /robot/.. travels into and then out of the robot directory, while /a/.. travels into and then out of the a directory, leaving us with just /flag, our desired outcome!

Here’s task 2:

export const task2 = (solution) => {
    preTask(solution);
    solution = solution.replaceAll("../", "");
    if (solution === "/flag") {
        throw new Error(
            "You cannot ACCESS the flag!!! You are UNAUTHORIZED!!! πŸ€–πŸ€–πŸ€–πŸ€–πŸ€–"
        );
    }

    const solutionPath = path.join("/", solution);
    return solutionPath === "/flag";
};

Task two seems to just be removing all ../ and replacing them with black spaces. Once the removal is done it simply checks if the result of the replacement is /flag. After solving this and moving onto task 3, I realized that the intended solution was probably the following payload:

/flag/a/....//

The reason this would work is because the replace operation would actually result in the following:

/flag/a/../

Which would evaluate as a path to the flag. However, I actually found a different solution:

//flag

The first forward slash doesn’t actually do anything to change the path, so this works just fine to pass task 2!

Here’s task 3:

export const task3 = (solution) => {
    preTask(solution);
    if (solution.includes("../") || solution === "/flag") {
        throw new Error(
            "You CANNOT ACCESS the flag!!! You are UNAUTHORIZED!!! πŸ€–πŸ€–πŸ€–πŸ€–πŸ€–"
        );
    }

    const solutionPath = path.join("/", solution);
    return solutionPath === "/flag";
};

Conveniently, because I found the unintended solution to task 2, my solution there worked completely fine here as well.

//flag

Once I completed the final task, the website presented me with the flag!

ping{p4th_tr4V3Rs4L_06c22f693acd46015891c98cb72f45e3}