Skip to content

Multiple valid JWT Signatures for HMAC Algorithms (base64 malleability) #365

Description

@rb-x

When using HMAC algorithms (HS256, HS384, HS512), python-jose and PyJWT exhibit different signature validation behavior compared to Node's jsonwebtoken library. Multiple valid signature endings are accepted when modifying the last character.

TLDR: We have a base64 malleability which could be problematic for revocation that relies on the whole jwt instead of jti.

Here's a minimal reproduction script:

from jose import jwt as jose_jwt
import jwt as pyjwt
import string
import uuid
from datetime import datetime, timedelta

# jose 3.3.0
# pyjwt 2.10.1

def test_jwt_signatures(algorithm):
    payload = {
        "sub": str(uuid.uuid4()),
        "exp": int((datetime.now() + timedelta(hours=1)).timestamp()),
        "jti": str(uuid.uuid4())
    }
    secret = "test_secret_key"
    
    jose_token = jose_jwt.encode(payload, secret, algorithm=algorithm)
    pyjwt_token = pyjwt.encode(payload, secret, algorithm=algorithm)
    
    print(f"\n=== Testing {algorithm} ===")
    
    print("\nPython-JOSE:")
    print(f"JWT: {jose_token}")
    print(f"Original signature ends with: {jose_token[-1]}")
    
    valid_chars_jose = []
    base64_chars = string.ascii_letters + string.digits + '-_'
    
    for c in base64_chars:
        modified = jose_token[:-1] + c
        try:
            jose_jwt.decode(modified, secret, algorithms=[algorithm])
            valid_chars_jose.append(c)
        except jose_jwt.JWTError:
            pass
    
    if valid_chars_jose:
        print(f"Found {len(valid_chars_jose)} valid endings: {valid_chars_jose}")
    
    print("\nPyJWT:")
    print(f"JWT: {pyjwt_token}")
    print(f"Original signature ends with: {pyjwt_token[-1]}")
    
    valid_chars_pyjwt = []
    
    for c in base64_chars:
        modified = pyjwt_token[:-1] + c
        try:
            pyjwt.decode(modified, secret, algorithms=[algorithm])
            valid_chars_pyjwt.append(c)
        except pyjwt.InvalidSignatureError:
            pass
    
    if valid_chars_pyjwt:
        print(f"Found {len(valid_chars_pyjwt)} valid endings: {valid_chars_pyjwt}")

if __name__ == "__main__":
    for alg in ["HS256", "HS384", "HS512"]:
        test_jwt_signatures(alg)

image

NodeJS JWT

const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');

// jsonwebtoken:  9.0.2

function testJwtSignature(algorithm) {
    const payload = {
        sub: uuidv4(),
        exp: Math.floor(Date.now() / 1000) + 3600,
        jti: uuidv4()
    };
    const secret = 'test_secret_key';
    
    const token = jwt.sign(payload, secret, { algorithm });
    const origSig = token.slice(-1);
    
    console.log(`\n=== Testing ${algorithm} ===`);
    console.log(`JWT: ${token}`);
    console.log(`Original signature ends with: ${origSig}`);
    
    const validChars = [];
    const base64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
    
    for (const c of base64chars) {
        const modified = token.slice(0, -1) + c;
        try {
            jwt.verify(modified, secret, { algorithms: [algorithm] });
            validChars.push(c);
        } catch (err) {}
    }
    
    if (validChars.length > 0) {
        console.log(`Found ${validChars.length} valid endings: ${JSON.stringify(validChars)}`);
    }
}

const algorithms = ['HS256', 'HS384', 'HS512'];
algorithms.forEach(testJwtSignature);

image

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions