Someone Enumerated an Experience Cloud Site 560,000 Times. Salesforce Says That Is Not a Bug.


What did the City-Forum campaign actually do to Experience Cloud sites?
Reco disclosed on 2026-08-12 a data theft campaign it names City-Forum, running since at least March 2025 from a single IP address, 158.220.87.79, against Salesforce Experience Cloud and ServiceNow portals. It drove a custom Go toolset at unauthenticated guest surfaces: Aura endpoints, and on LWR sites the GraphQL route at /webruntime/api/services/data/{version}/graphql. One organization logged more than 560,000 enumeration events.
The toolset also probed /SiteRegister and /CommunitiesSelfReg, which are the self-registration paths. That probe is not about reading records. It is about turning an anonymous visitor into an authenticated user, which moves the attacker off the guest profile entirely.
Salesforce's response: "These attacks are not exploiting a vulnerability in Salesforce. Instead, they steal data that organizations have mistakenly exposed to unauthenticated guest users through overly permissive sharing rules, permissions, or portal configurations." That statement is correct, and it is also the whole problem. There is no patch to apply and no vendor remediation coming. The fix is inside your org, written by whoever configured the site, possibly years ago.
Why can you not just delete the Guest User?
Every Experience Cloud site gets its own Guest User and its own guest profile when the site is created. That profile is permanent and non-deletable, and it keeps its object permissions, field permissions, and sharing rules even when the site is configured to require login. No setting removes this surface. Every available setting only narrows it.
This is a different remediation posture from "turn the feature off." You cannot close the door, so the work is inventory: knowing exactly which objects and fields answer to an unauthenticated request, and deciding for each one whether the public page actually needs it. In most orgs I have looked at, the guest profile accumulated read access during a site build in 2021 and nobody removed anything afterwards, because removing permissions breaks pages and nothing rewards you for trying.
How do you find out what your guest profile can read today?
Start from the users, not the profiles, because guest profiles are named after the site ("Acme Portal Profile"), not after the word guest, so a name filter misses them. Query the guest users first, then feed their profile IDs into the permission objects.
sf data query --target-org myorg \
--query "SELECT Id, Name, ProfileId, Profile.Name FROM User WHERE UserType = 'Guest'"Then the object level. PermissionsViewAllRecords on a guest profile is the finding that ends the conversation, because it defeats the sharing model completely for that object.
SELECT Parent.Profile.Name, SobjectType, PermissionsRead,
PermissionsViewAllRecords, PermissionsModifyAllRecords
FROM ObjectPermissions
WHERE Parent.ProfileId IN ('00exx0000000001','00exx0000000002')
AND PermissionsRead = true
ORDER BY Parent.Profile.Name, SobjectTypeThen the field level, which is where the damage usually sits. Object read on Contact is a design decision. Read on Contact.SSN__c or Contact.Birthdate is a leak, and it is invisible in Setup unless you go looking field by field.
SELECT Parent.Profile.Name, SobjectType, Field
FROM FieldPermissions
WHERE Parent.ProfileId IN ('00exx0000000001')
AND PermissionsRead = true
ORDER BY SobjectType, FieldFinally, test the self-registration paths against the site domain as an anonymous visitor. A 200 means the escalation path the toolset probes for is open on your site.
curl -s -o /dev/null -w "%{http_code} SiteRegister\n" https://portal.example.com/SiteRegister
curl -s -o /dev/null -w "%{http_code} CommunitiesSelfReg\n" https://portal.example.com/CommunitiesSelfRegWhich sharing settings actually matter here?
External org-wide defaults are the second control, and they operate independently of the guest profile. If external access on an object is anything other than Private, the guest profile's read permission has records to reach. Pull the org-wide defaults from the Tooling API and filter locally, since EntityDefinition rejects most WHERE clauses.
sf data query --use-tooling-api --target-org myorg --result-format csv \
--query "SELECT QualifiedApiName, ExternalSharingModel, InternalSharingModel FROM EntityDefinition WHERE IsCustomizable = true" \
> owd.csv
grep -v ',Private' owd.csvSince Winter '20, the "Secure guest user record access" setting forces external OWD to Private for guest access and caps guest sharing rules at read-only. That closed the worst default, and it does not touch a sharing rule someone wrote on purpose to make a page work. Read-only is exactly the access enumeration needs, so "the sharing rule is read-only" is not a control, it is the attack.
Was City-Forum the first campaign against this surface?
No. Salesforce updated its own post on securing Experience Cloud guest user access on 2026-03-11, after a 2026-03-07 report that ShinyHunters was exploiting misconfigured guest profiles using a modified build of the open-source Aura Inspector, extracting at scale through GraphQL and bypassing the roughly 2,000-record limit of older techniques. Same three preconditions, both times.
Those preconditions are worth memorizing, because they are what a review is looking for: guest profiles with excessive object or field permissions, external OWD not set to Private, and guest users allowed to reach public APIs. Two disclosed campaigns have now used them, and Salesforce's remediation checklist has been public since March. An org still exposed today has been exposed through both.
The timeline is the part to bring to the client. City-Forum ran for roughly 17 months before disclosure. That makes the useful question historical, not hypothetical. "What could be read" is a hardening exercise. "What was read between March 2025 and now" is an incident question, and answering it means Event Monitoring or site access logs, not a permissions audit.
What does this mean if you are putting an Agentforce agent on that site?
An Agentforce service agent deployed on a public Experience site answers unauthenticated visitors, and the site keeps the same guest surface the toolset hits whether the agent is there or not. The agent runs under its own user and permission set, so hardening that permission set is a second task, not the same one as hardening the guest profile.
Two practical consequences. First, an agent deployment on a guest-accessible site is a good forcing function for the audit above, since you are already in Setup looking at what unauthenticated visitors can reach. Do the guest profile at the same time, because a review that hardens the agent and leaves the guest profile untouched has secured the conversational path and left the HTTP one open.
Second, if you ground the agent on knowledge or Data 360 objects that the guest profile can also read, you have two unauthenticated read paths into the same data with different permission models. That is fine when it is a deliberate decision and a problem when nobody wrote it down. Grounding scope and guest profile scope should be reviewed as one list, not two.
What order do you fix this in?
Blast radius first, page breakage last. Every step below is reversible except the last one, which will break something visible if the site was built on permissions it should not have had. Do it in a sandbox, then promote.
- Remove
PermissionsViewAllRecordsandPermissionsModifyAllRecordsfrom every guest profile. There is no legitimate use for either on an unauthenticated profile. - Confirm external OWD is Private on every object the guest profile can read, and confirm "Secure guest user record access" is on.
- Turn off self-registration unless the site genuinely needs it, and re-run the two curl probes to confirm.
- On LWR sites, review guest access to the UI API in Experience Builder under Workspaces, Administration, Preferences.
- Strip field-level read from the guest profile for every field not rendered by a public page, then walk the site.
What the 560,000 figure does not say: it is one organization's event count, not a record count and not a breach count. The research comes from a commercial security vendor and no independent replication has been published. That changes how you cite it, not what you do about it, because the configuration checklist behind it is Salesforce's own and predates the campaign disclosure by five months.
If your answer to "what was reachable on that site last year" is "we would have to check," that is the first thing to fix, and it comes before the agent.
