Um workflow meu declara um campo de saída chamado accountId. O engine escreve esse campo no contexto da execução, que é o mesmo lugar de onde o passo de envio tira qual credencial usar. Resultado: a plataforma manda um SMS usando a chave de API de outro cliente, com destino e remetente escolhidos por mim, e debita o crédito dele.
connect.8x8.com: Automation Builder - Input Validation Issue in Workflow Step Outputs (https://hackerone.com/reports/3858504) - $1337 bounty
7.7 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:N

O que é o Automation Builder
connect.8x8.com é o console de CPaaS da 8x8. Dentro dele tem um Automation Builder: um construtor de workflows low-code, do tipo "quando chegar um evento, faz A, depois B, depois C". Cada workflow é um JSON com uma lista de passos. Os tipos de passo incluem HttpRequest, SMS, VoiceMessage, ChatAppsMessage, Wait, ConsoleLog.
Cada passo tem inputs (os parâmetros que ele recebe) e outputs. O outputs é um mapa de nome para valor, e é assim que um passo passa informação para os passos seguintes:
{"id":"s1","stepType":"Wait","inputs":{"duration":"0.00:00:02"},
"outputs":{"meu_campo":"algum valor"},"nextStepId":"s2"}
Depois disso, os passos seguintes conseguem ler data.meu_campo. Esse data é o ponto central da história. A própria documentação da 8x8 descreve ele como "workflow-level storage persisting across all steps": um dicionário único, que vive durante toda a execução do workflow, compartilhado por todos os passos.
Multi-tenancy num CPaaS, em 30 segundos
Numa plataforma de CPaaS, quem paga a conta é o tenant. A hierarquia da 8x8 é:
accountId: o tenant. Ex:Hackerone9363_0fFBB.subAccountId: uma subdivisão dentro do tenant, normalmente por produto ou por ambiente. Ex:Hackerone9363_0fFBB_hq.- a chave de API: o segredo que autentica os envios daquela conta. Ela é quem determina quem vai pagar pelo SMS.
O "senderId" é o remetente que aparece no celular de quem recebe o SMS (um número, ou um nome alfanumérico tipo "NUBANK"). Quem envia escolhe. Por isso o senderId de outra empresa vale muito para quem faz phishing.
Quando o engine dispara um workflow, ele coloca no data os valores em que ele confia:
"data": {"apiKeyId":"147636", "accountId":"Hackerone9363_0fFBB", "triggeredAt":"2026-07-12T07:14:31.57Z"}
Esses dois primeiros campos vêm da autenticação da request que disparou o workflow. É a partir deles que o passo de SMS decide com qual chave enviar.
Agora junte as duas metades: o data guarda a identidade em que o servidor confia, e o outputs de qualquer passo escreve no data.
A pista
A pista não veio de fuzzer nem de payload esperto. Veio de ler uma resposta com atenção.
Depois que um workflow roda, você lê a execução pela API:
GET /api/v1/accounts/<accountId>/definitions/<definitionId>/workflows?subAccountId=<sub>
e a resposta te devolve o data daquela execução. Foi aí que eu vi isto:
"data": {"meu_campo":"algum valor",
"apiKeyId":"147636","accountId":"Hackerone9363_0fFBB","triggeredAt":"2026-07-12T07:14:31.57Z"}
O campo que EU declarei e os campos que o ENGINE declarou, no mesmo dicionário, no mesmo nível, sem prefixo, sem namespace separado, sem nada distinguindo um do outro. Eu estava escrevendo dentro da mesma gaveta onde o servidor guarda a identidade em que ele confia.
A documentação pública confirmou as duas pontas. A página de context scripting diz que o data é "workflow-level storage persisting across all steps", que os outputs de um passo caem lá dentro e, textualmente, que não há restrição documentada de chave reservada.
Aí a pergunta ficou óbvia e barata de testar: e se eu declarar um output com exatamente o nome de um dos campos do engine?
Vale registrar que eu já tinha olhado envio cross-tenant nesse produto antes e descartado. Apontar o passo de SMS para o subaccount de outro tenant é corretamente barrado, o envio usa a credencial de quem chamou. Isso era verdade, e era irrelevante, porque a pergunta certa não era "posso apontar para o subaccount da vítima?" e sim "posso mudar de qual conta vem a chave?".
Duas requisições que expõem a assimetria
Antes de montar exploit nenhum dá pra descobrir se o engine valida esses nomes, e custa duas requisições.
Um passo com um INPUT de nome inventado:
"inputs": {"campo_que_nao_existe":"x"}
é rejeitado. O engine casa o nome do input contra as propriedades conhecidas daquele tipo de passo, e o que não bate volta como Invalid input.
O mesmo passo com um OUTPUT de nome inventado:
"outputs": {"campo_que_nao_existe":"x"}
é aceito com 200, e depois de disparar o workflow o campo aparece no data da execução.
Essa é a assimetria, e ela é o achado inteiro: o engine valida o nome do que ENTRA num passo e não valida o nome do que SAI do passo para o contexto compartilhado. Input tem allowlist, output não tem nada. O validador de save olha os VALORES do outputs, procurando script proibido, e nunca olha as CHAVES.
Depois disso é só trocar o nome inventado pelo nome real. Um output chamado accountId, com o id de outra conta:
"outputs": {"accountId":"Hackerone8736_0fF8B"}
Criado com 200, e na execução o data.accountId aparece com o MEU valor no lugar do que o engine tinha colocado. O campo confiável foi sobrescrito. Faltava achar quem lê esse campo pra decidir alguma coisa que importe.
Quem lê o accountId
Os passos de envio (SMS, VoiceMessage, ChatAppsMessage) recebem um subAccountId como input, mas não recebem credencial nenhuma. Eles resolvem a chave a partir do contexto da execução, ou seja, a partir do accountId que está no data. Que é exatamente o campo que eu acabei de sobrescrever.
Se isso estiver certo, o efeito não é ler dado dos outros. É fazer o servidor autenticar um envio com a credencial de outro tenant. O teste disso é a próxima parte.
O exploit
Duas contas, as duas minhas: atacante Hackerone9363_0fFBB (uso só o JWT e a chave de API dela) e vítima Hackerone8736_0fF8B, outro tenant, do qual eu não tenho nenhuma credencial, só o id da conta (que é baseado no user da conta).
Passo 1 declara um output cuja CHAVE é accountId. Passo 2 manda um SMS mirando o subaccount da vítima.
POST /api/v2/automation/workflows/definitions HTTP/1.1
Host: connect.8x8.com
Authorization: Bearer <JWT do atacante>
Content-Type: application/json
{"subAccountId":"Hackerone9363_0fFBB_hq","trigger":"http_request","status":"enabled",
"definition":{"name":"poc","steps":[
{"id":"carrier","stepType":"Wait","inputs":{"duration":"0.00:00:02"},
"outputs":{"accountId":"Hackerone8736_0fF8B"},"nextStepId":"send"},
{"id":"send","stepType":"SMS","inputs":{"subAccountId":"Hackerone8736_0fF8B_hq",
"destination":"+447700900123","source":"H1TEST","text":"HackerOne authorized test asyx6"}}]}}
A API devolve 200 e guarda o output accountId do jeito que veio. Já aí está provado que não existe validação de chave reservada na criação.
O destino +447700900123 é uma faixa que a Ofcom reserva para drama e ficção, ninguém recebe. Isso é de propósito: eu queria provar o envio sem cair no celular de uma pessoa real.
Disparo o trigger, leio a instância:
{"workflowId":"6a533ed7a23eae01a168ad42",
"data":{"apiKeyId":"147636","accountId":"Hackerone8736_0fF8B","triggeredAt":"..."},
"status":"Complete",
"logs":[{"stepName":"carrier","status":"Complete"},{"stepName":"send","status":"Complete"}]}
O data.accountId em runtime está com a conta da VÍTIMA, e o apiKeyId continua sendo 147636, que é a minha chave (confirmei listando as chaves da minha própria conta). Os dois passos completaram.
Os controles, que são o report inteiro
Um Complete sozinho não prova nada. Talvez o envio tenha usado a minha chave e simplesmente funcionado. Então rodei três workflows idênticos, mudando uma variável de cada vez:
- S (self): sem poison, SMS para o MEU subaccount. Complete. Baseline, minha chave e meu subaccount.
- C (cross, sem poison): SMS para o subaccount da VÍTIMA, sem o output
accountId. Falha no passo de envio:"Failed to send SMS because SubAccountId was entered in error or forbidden for specified credentials." - P (poison): mesmo subaccount da vítima do C, mas com o output
accountId. Complete,smsStatus: QUEUED,"SMS is accepted and queued for processing", com umid.
C e P miram exatamente o mesmo subaccount da vítima. A ÚNICA diferença entre eles é o output envenenado. C é proibido (minhas credenciais), P é aceito. Logo, o poison trocou a credencial que autentica o envio pela da vítima. E a mensagem não aparece nos logs de SMS da minha conta, porque não foi minha conta que enviou.
O apiKeyId da instância continua sendo o meu, 147636, enquanto o envio é aceito sob a conta da vítima. Isso entrega a mecânica: o engine primeiro tenta resolver a chave pelo par (accountId, apiKeyId), não acha nada, porque o meu id de chave não existe embaixo da conta da vítima, e cai num fallback que devolve a chave PADRÃO da conta nomeada. Esse fallback é a peça sem check de posse: ele entrega a credencial de qualquer conta que você escrever no data.
Esse é o formato que faz um report de isolamento de tenant passar: não a captura de tela do sucesso, mas o par sucesso/fracasso onde só uma variável muda.
Provando que vira dinheiro
O report original foi honesto sobre o limite: a mensagem era atribuída à vítima e parada no check de crédito, e eu escrevi que numa conta com saldo ela seria cobrada e entregue, sem ter provado isso.
No dia seguinte eu provei. Coloquei crédito numa conta minha, usei ela como vítima e mandei para o meu próprio celular:
- saldo da vítima ANTES:
10.0000 USD - disparo do poison,
data.accountIdsobrescrito,smsStatus: QUEUED - saldo da vítima 15 segundos DEPOIS:
9.9467374 USD
Menos $0,0532626, debitado automaticamente, e estável na releitura. Esse valor é preço realista de SMS para móvel no Brasil. A conta do atacante não gastou nada. Sub-observação que mandei junto: dois minutos depois, o SMS cobrado não aparecia nos logs de mensagens da vítima. Ela é debitada por um envio que não consegue rastrear.
Nesse comentário eu argumentei Critical (CVSS 9.6), porque dá pra repetir até zerar o saldo do tenant e derrubar a operação dele, já que quase todo recurso do Connect precisa de crédito. Ficou em High. Tudo bem, o que eu tinha provado com controle limpo era o cruzamento da fronteira de tenant e o débito.
Resumo
- O bug não está em nenhum passo isolado, está na fronteira: um dicionário compartilhado guardando ao mesmo tempo dados do usuário e a identidade em que o servidor confia. Quando esses dois moram no mesmo namespace, quem escreve no namespace escolhe a identidade. E dá pra ver isso de fora, é só ler o
datade uma execução sua. - Assimetria entre dois caminhos que deviam ser simétricos é um cheiro forte. Nome de input validado e nome de output não validado, nas duas pontas do mesmo passo, foi o achado inteiro. Custou duas requisições descobrir.
- Todo ponto que resolve credencial ou identidade a partir de um id que veio de dentro da request é candidato. Se essa resolução não compara com o chamador autenticado, ela entrega a credencial de qualquer um.
- Guarde os negativos, mas não confie neles. Meu "toll fraud descartado" estava certo sobre a pergunta errada.
- Reserve seu tempo para os controles. O par C/P, com uma única variável de diferença, é o que fez o triager aceitar em horas.