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

Conteúdo

  • O que é o Automation Builder
  • Multi-tenancy num CPaaS, em 30 segundos
  • A pista
  • Duas requisições que expõem a assimetria
  • Quem lê o accountId
  • O exploit
  • Os controles, que são o report inteiro
  • Provando que vira dinheiro
  • Resumo
Back to postsRead in English

Envenenamento de contexto em um Automation Builder - $1337 bounty

9 min read
2026-09-01
writeupwebbug-bounty

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.accountId sobrescrito, 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 data de 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.

© 2026 · kyotozx