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

Conteúdo

  • What the Automation Builder is
  • Multi-tenancy in a CPaaS, in 30 seconds
  • The clue
  • Two requests that expose the asymmetry
  • Who reads accountId
  • The exploit
  • The controls, which are the whole report
  • Proving it turns into money
  • Takeaways
Back to postsLer em português

Automation Builder context poisoning - $1337 bounty

9 min read
2026-09-01
writeupwebbug-bounty

A workflow of mine declares an output field named accountId. The engine writes that field into the run context, which is the same place the send step reads to decide which credential to use. Result: the platform sends an SMS with another customer's API key, with the destination and sender I chose, and debits their credit.

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

What the Automation Builder is

connect.8x8.com is 8x8's CPaaS console. Inside it there is an Automation Builder: a low-code workflow builder of the "when an event arrives, do A, then B, then C" kind. Each workflow is JSON with a list of steps. Step types include HttpRequest, SMS, VoiceMessage, ChatAppsMessage, Wait, ConsoleLog.

Every step has inputs (the parameters it takes) and outputs. The outputs map is name to value, and it is how a step passes information to later steps:

{"id":"s1","stepType":"Wait","inputs":{"duration":"0.00:00:02"},
 "outputs":{"my_field":"some value"},"nextStepId":"s2"}

After that, later steps can read data.my_field. That data is the center of this story. 8x8's own documentation in developer.8x8.com describes it as "workflow-level storage persisting across all steps": a single dictionary, alive for the whole run, shared by every step.

Multi-tenancy in a CPaaS, in 30 seconds

On a CPaaS platform, the tenant is who pays the bill. 8x8's hierarchy is:

  • accountId: the tenant. For example Hackerone9363_0fFBB.
  • subAccountId: a subdivision inside the tenant, usually per product or environment. For example Hackerone9363_0fFBB_hq.
  • the API key: the secret that authenticates that account's sends. It is what determines who pays for the SMS.

The "senderId" is the sender shown on the recipient's phone (a number, or an alphanumeric name like "NUBANK"). The sender picks it. That is why another company's senderId is worth a lot to anyone doing phishing.

When the engine fires a workflow, it puts the values it trusts into data:

"data": {"apiKeyId":"147636", "accountId":"Hackerone9363_0fFBB", "triggeredAt":"2026-07-12T07:14:31.57Z"}

Those first two fields come from the authentication of the request that fired the workflow. They are what the SMS step uses to decide which key to send with.

Now put the two halves together: data holds the identity the server trusts, and any step's outputs writes into data.

The clue

The clue did not come from a fuzzer or a clever payload. It came from reading a response carefully.

After a workflow runs, you read the execution through the API:

GET /api/v1/accounts/<accountId>/definitions/<definitionId>/workflows?subAccountId=<sub>

and the response hands you the data for that run. That is where I saw this:

"data": {"my_field":"some value",
         "apiKeyId":"147636","accountId":"Hackerone9363_0fFBB","triggeredAt":"2026-07-12T07:14:31.57Z"}

The field I declared and the fields the ENGINE declared, in the same dictionary, at the same level, no prefix, no separate namespace, nothing telling them apart. I was writing into the same drawer where the server keeps the identity it trusts.

The public documentation confirmed both halves. The context scripting page says data is "workflow-level storage persisting across all steps", that a step's outputs land in it, and, in so many words, that there are no documented reserved-key restrictions.

At that point the question was obvious and cheap to test: what if I declare an output with exactly the name of one of the engine's own fields?

Worth recording that I had looked at cross-tenant sending in this product before and dismissed it. Pointing the SMS step at another tenant's subaccount is correctly blocked, the send uses the caller's own credential. That was true, and it was irrelevant, because the right question was not "can I point at the victim's subaccount?" but "can I change which account the key comes from?".

Two requests that expose the asymmetry

Before building any exploit you can find out whether the engine validates these names at all, and it costs two requests.

A step with an INPUT under a made-up name:

"inputs": {"field_that_does_not_exist":"x"}

is rejected. The engine matches the input name against the known properties of that step type, and anything that does not match comes back as Invalid input.

The same step with an OUTPUT under a made-up name:

"outputs": {"field_that_does_not_exist":"x"}

is accepted with a 200, and after firing the workflow the field shows up in the run's data.

That is the asymmetry, and it is the entire finding: the engine validates the name of what goes INTO a step and does not validate the name of what comes OUT of it into the shared context. Input has an allowlist, output has nothing. The save-time validator inspects the VALUES in outputs, looking for forbidden script, and never the KEYS.

After that it is just swapping the made-up name for a real one. An output named accountId, holding another account's id:

"outputs": {"accountId":"Hackerone8736_0fF8B"}

Created with a 200, and in the run data.accountId comes back with MY value instead of the one the engine put there. The trusted field was overwritten. What was left was finding who reads that field to decide something that matters.

Who reads accountId

The send steps (SMS, VoiceMessage, ChatAppsMessage) take a subAccountId as an input, but they take no credential at all. They resolve the key from the run context, which means from the accountId sitting in data. Which is exactly the field I had just overwritten.

If that is right, the effect is not reading other people's data. It is making the server authenticate a send with another tenant's credential. Testing that is what comes next.

The exploit

Two accounts, both mine: attacker Hackerone9363_0fFBB (I use only its own JWT and its own API key) and victim Hackerone8736_0fF8B, a separate tenant I hold no credential for, only the account id (which is based on the account username).

Step 1 declares an output whose KEY is accountId. Step 2 sends an SMS targeting the victim's subaccount.

POST /api/v2/automation/workflows/definitions HTTP/1.1
Host: connect.8x8.com
Authorization: Bearer <attacker JWT>
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"}}]}}

The API returns 200 and stores the accountId output verbatim. That alone proves there is no reserved-key validation at create time.

The destination +447700900123 is in a range Ofcom reserves for drama and fiction, nobody receives it. That is deliberate: I wanted to prove the send without dropping an SMS on a real person's phone.

I fire the trigger and read the instance:

{"workflowId":"6a533ed7a23eae01a168ad42",
 "data":{"apiKeyId":"147636","accountId":"Hackerone8736_0fF8B","triggeredAt":"..."},
 "status":"Complete",
 "logs":[{"stepName":"carrier","status":"Complete"},{"stepName":"send","status":"Complete"}]}

The runtime data.accountId holds the VICTIM's account, and apiKeyId is still 147636, which is my own key (confirmed by listing my own account's keys). Both steps completed.

The controls, which are the whole report

A Complete by itself proves nothing. Maybe the send used my key and simply worked. So I ran three identical workflows, changing one variable at a time:

  • S (self): no poison, SMS to MY subaccount. Complete. Baseline, my key and my subaccount.
  • C (cross, no poison): SMS to the VICTIM's subaccount, without the accountId output. Fails at the send step: "Failed to send SMS because SubAccountId was entered in error or forbidden for specified credentials."
  • P (poison): same victim subaccount as C, but with the accountId output. Complete, smsStatus: QUEUED, "SMS is accepted and queued for processing", with a umid.

C and P target exactly the same victim subaccount. The ONLY difference between them is the poisoned output. C is forbidden (my credentials), P is accepted. So the poison swapped the credential authenticating the send to the victim's. And the message does not appear in my own account's SMS logs, because my account is not what sent it.

The instance's apiKeyId is still mine, 147636, while the send is accepted under the victim's account. That gives away the mechanics: the engine first tries to resolve the key from the (accountId, apiKeyId) pair, finds nothing, because my key id does not exist under the victim's account, and falls through to something that returns the DEFAULT key of whatever account was named. That fallback is the piece with no ownership check: it hands over the credential of any account you write into data.

That is the shape that gets a tenant-isolation report through: not the screenshot of the success, but the success/failure pair where only one variable moves.

Proving it turns into money

The original report was honest about the limit: the message was attributed to the victim and stopped at the credit check, and I wrote that on a funded tenant it would be billed and delivered, without having proven it.

The next day I proved it. I funded one of my accounts, used it as the victim, and sent to my own phone:

  • victim balance BEFORE: 10.0000 USD
  • fire the poison, data.accountId overwritten, smsStatus: QUEUED
  • victim balance 15 seconds LATER: 9.9467374 USD

Minus $0.0532626, debited automatically, stable on re-read. That figure is a realistic price for an SMS to a Brazilian mobile. The attacker account spent nothing. A sub-observation I sent along: two minutes later, the billed SMS was not visible in the victim's own message logs. They are charged for a send they cannot trace.

In that comment I argued Critical (CVSS 9.6), because you can repeat this until a tenant's balance is drained and their operation stops, since almost every Connect feature needs credit. It stayed High. Fine, what I had proven with clean controls was the tenant boundary crossing and the debit.

Takeaways

  • The bug is in no single step, it is at the boundary: one shared dictionary holding both user data and the identity the server trusts. When those two live in the same namespace, whoever writes the namespace picks the identity. And you can see it from outside, just read the data of one of your own runs.
  • Asymmetry between two paths that should be symmetric is a strong smell. Input name validated and output name not validated, at the two ends of the same step, was the entire finding. Two requests to discover it.
  • Every point that resolves a credential or an identity from an id carried inside the request is a candidate. If that resolution does not compare against the authenticated caller, it hands over anyone's credential.
  • Keep your negatives, but do not trust them. My "toll fraud ruled out" was right about the wrong question.
  • Spend your time on the controls. The C/P pair, one variable apart, is what got this accepted in hours.
  • A local lab proves mechanism, not production behavior. The set_Item bypass worked in the lab and died on the wire.

© 2026 · kyotozx