Skip to content

Salesforce Attack Chain Correlation: Five New Chains and the Blind Spot That Hid Them

sf-audit's attack chain model could not see half its own checks. Fixing the capability grants unlocked five new Salesforce attack chains, taking it to sixteen.

TL;DR

  • @cclabsnz/sf-audit correlates findings into named attack chains on capabilities, not on check ids. A finding declares what reach it hands an attacker, and the engine looks for combinations that join an entry point to an outcome.
  • That design has a failure mode nobody sees: a finding with no capability grant is invisible to correlation. It can never be a step in a named chain and never appears in the emergent pass either. Nothing errors, because a missing grant is not a mistake the code can detect.
  • Forty-seven of the ninety-two checks were in that state. Eleven of them carry real attacker reach and now declare it.
  • Five new named chains follow from those grants: standing OAuth access, self-service portal registration, session ID egress, anonymous file exposure, and a Visualforce XSS pattern reaching a privileged session. That takes the catalogue from eleven named chains to sixteen.
  • Four of the five describe paths where nothing is bypassed. The login controls behave exactly as configured and do not object, because the attacker never reaches a login page or arrives through the front door with a real account.

What You'll Learn

  • How capability-based correlation works, and why a missing grant fails silently rather than loudly
  • Which eleven findings gained grants, and the two places where granting nothing was the correct answer
  • What each of the five new attack chains asserts, and the specific ingredients that trigger it
  • Why a chain that claims less is more useful than one that claims more
  • The current catalogue of sixteen named chains, with the trigger condition for each

The Problem

A security report that lists findings by severity hides the thing you most need to know. Three MEDIUM findings that combine into an unauthenticated route to bulk data matter far more than a lone HIGH that leads nowhere, and reading the report top to bottom will never tell you which is which. Correlating findings into named attack chains is the fix, and sf-audit has done it since June.

The problem is what happens when the correlation model and the check catalogue drift apart. Checks get written steadily: someone spots a gap, adds a check, wires it into the registry, tests it, ships it. The attack model lives somewhere else, in a table mapping finding ids to attacker capabilities. Adding a check without adding its grant produces no error, no warning, and no failing test. The check runs, the finding appears in the report at its own severity, and the correlation engine simply never considers it. Half the catalogue had ended up in that state without anyone deciding it should.

Common questions this article answers:

  • Why would a real security finding never appear in any attack chain?
  • Is a connected app with a never-expiring refresh token an attack path, or just untidy?
  • Does enforcing MFA on every user protect an org against standing OAuth access?
  • If a public Experience Cloud site allows self-registration, what has actually been given away?
  • Should an XSS pattern found in page markup be reported as exploitable?

Full disclosure: I build and maintain @cclabsnz/sf-audit. This post covers what changed in the correlation model and why the new chains are scoped more narrowly than they could have been.

Quick Answer

sf-audit's chain engine correlates on capabilities. Each finding declares what it grants an attacker, from a set of ten: unauth-foothold, low-trust-authenticated, data-read, data-read-bulk, data-write, code-exec, credential-theft, priv-esc, org-takeover, and external-egress. Named chains are hand-modelled combinations of those capabilities plus specific finding ids. An emergent pass then reports any remaining entry point to outcome pair as a lower confidence "potential attack path", so a combination nobody has modelled still surfaces.

Because correlation runs on grants rather than on check ids, a finding that grants nothing is invisible to it. Eleven findings that carry genuine reach now declare it, and five new named chains became expressible as a result. Run the audit and the chains appear in the report alongside the individual findings:

sf plugins install @cclabsnz/sf-audit

sf audit security --target-org myOrg

A chain is only reported when every one of its ingredients is actually present in the org, so a clean org produces none. Remediating any single step breaks the chain, and that is what separates an actionable chain finding from an alarming one.

A missing grant is not a bug the code can find

This failure shape is not specific to this plugin. Any model that reads from a side table, rather than from the thing it is modelling, can go quiet the same way.

The capability registry is a single map from finding id to the capabilities that finding grants. Everything the attack model knows lives there and nowhere else, so ninety-two check classes stay free of it. Two failure modes exist, and only one of them is catchable:

  • A typo in a key matches no finding, so the entry never grants anything. A chain ingredient referencing a finding id that no check emits means the chain can never fire. Both are now enforced in both directions by a registry integrity test.
  • A key that is simply absent is indistinguishable from a deliberate decision that the finding grants nothing. There is no signal to test against. The finding is real, it appears in the report, and the correlation engine treats it as carrying no attacker reach at all.

The second one is what happened. Forty-seven findings had no entry. Some of those absences are correct: a check that reports a retention window or inventories configuration hands an attacker nothing, and pretending otherwise would manufacture chains out of housekeeping. But eleven of the forty-seven described reach an attacker really does hold.

Finding What it grants, and why
custom-settings-credentials The third place secrets hide, after hardcoded literals and custom labels. Both of those already granted credential-theft
apex-rest-without-sharing The @RestResource door, where the portal and Flow variants of the same defect were already modelled
apex-crud-fls-without-sharing Same defect class, reached through a different entry point
apex-crud-fls-missing Code that reads or writes without enforcing object and field permissions
outbound-messages-session-id A live session ID posted to an external endpoint, which is credential theft by configuration
public-content-public-documents Fetched from a URL that never reaches a login
public-content-public-static-resources The same, and the usual home of configuration and endpoint lists nobody re-reads
content-links-no-expiry The same exposure, scoped to one shared file
content-links-no-password As above
experience-cloud-site-self-registration How an attacker obtains the account that every external-sharing grant already assumes
connected-app-full-scope API reach equal to the authorising user

Where granting nothing was the right answer

Two of the non-grants are restraints a later reader would be tempted to "fix", so here is why they stay.

Content links grant data-read but deliberately not unauth-foothold. A foothold grant would pair them with every bulk-read sink in the emergent pass and assert a path between entirely unrelated data. One shared file being fetchable does not put an attacker inside the object model.

connected-app-infinite-refresh-token, oauth-token-stale and oauth-token-unmatched-app grant nothing at all. Persistence and disuse are evidence about a token, not reach an attacker holds. A token that has gone unused for six months is not thereby more powerful. Those findings earn their place as named chain steps instead, which is the honest way to use them.

Both restraints are pinned by tests, since a non-grant is the easiest thing in the world to regress by helpfully adding a capability. The positive grants were mutation-checked rather than assumed: removing data-read-bulk from connected-app-full-scope fails exactly one test, so the assertion bites.

The five new chains

Standing OAuth access outside every login control

Fires when: a connected app holds broad standing API access (Full scope, a never-expiring refresh token, or both) and something in the org means nobody would notice it being used.

The distinction that matters is that a refresh token is not a session. Login controls engage once, at the moment the app is authorised, and never again. Every later API call exchanges the refresh token for an access token with no login involved, so an org can enforce MFA on every human being and still hand a token holder the same reach. Full scope makes that reach equal to the authorising user, including Modify All Data where that user is an admin. A refresh token with no expiry makes it permanent until somebody revokes it.

The token exchange writes no OAuth login row, which is why this access is absent from LoginHistory and invisible to an inactivity check that reads it. A token unused for months, or belonging to an app no longer in the connected app list, is the shape a forgotten or third-party integration leaves behind. That is the path the campaigns of the last two years took against Salesforce customers, covered in more detail in The Salesforce Data Theft Timeline: the tokens were not stolen from the org, they were taken from the integration vendor and replayed against it, with no login and no user interaction anywhere in the victim org.

Remediation: revoke first and tidy later. Any standing token whose app is not in the current connected app list, or that has not been used in months, should be revoked now rather than investigated first. Re-authorising a live integration is a minor inconvenience, and a token is a working credential for as long as it exists.

Self-service registration into an over-shared portal

Fires when: a live Experience Cloud site accepts self-registration and the external sharing model or portal-reachable code gives an account holder more than their own records.

Every other chain that starts from an authenticated external user assumes the attacker already has an account. This is the one that says anyone may have one, for the cost of an email address. The account is legitimate, so it arrives through the front door with a real session. MFA, login IP ranges and SSO all behave exactly as configured and none of them object, because nothing here is a bypass.

What the attacker gets is decided entirely by the external org-wide defaults, the portal profile, and whether the Apex or Flow reachable from the site enforces sharing. Self-registration on its own is a supported feature and not a finding. The sharing model on its own assumes an account the attacker may never obtain. The pair is the path.

One scoping note: this chain leaves public report folders out of the reach set. They are a real exposure for internal users, but a self-registered portal account is typically a Customer Community licence with no report access at all, so counting them would overstate what the account is worth.

Live session ID handed to an external endpoint

Fires when: a workflow outbound message is configured to include the Salesforce session ID, and something in the org means a replayed session would be neither blocked nor recorded.

This is a supported option rather than a flaw, and that is exactly why it survives. It was switched on to let the receiving system call back into Salesforce, and it never came off. The session it hands over acts as the outbound message's running user for as long as it is valid, and nothing about that request looks anomalous, because it is the org doing the sending.

Whoever holds the endpoint holds the credential, which makes the org's exposure equal to the weakest party that has ever operated it: a vendor, a subcontractor, or whoever registered the domain after the integration was retired. If the endpoint is cleartext http://, treat the session as already disclosed and change that first, because anything on the network path has seen it.

Org files served to anonymous callers, with no record of what left

Fires when: files are fetchable without authentication (public Documents or static resources, content distribution links with no expiry or password, or a site granting guests file access) and nothing in the org would establish what those files contained or who collected them.

Files are the blind spot in a sharing model. Org-wide defaults, sharing rules and field-level security all govern records, and not one of them applies to a static resource served from a URL. Static resources in particular accumulate the things front-end code needs and nobody re-reads: configuration, endpoint lists, and occasionally an API key that was only ever meant to reach the browser.

This chain claims less than the guest chains do, on purpose. It asserts no pivot into org data, because there is none. Anonymous file access returns the file and stops. What it asserts is that content left the org outside every record control, and that the absence of classification or monitoring means the question "what was in it" has no answer available after the fact.

Remediation: retrieve and read the exposed files before deciding how urgent this is. The exposure is whatever they actually contain, and that is knowable now in a way it will not be later. Treat any credential found in a static resource as disclosed and rotate it, rather than deleting the file, because the file has already been served and may be cached anywhere.

Visualforce XSS pattern reaching a privileged session

Fires when: custom Visualforce markup renders data without encoding it (escape="false", a merge field inside a <script> block without JSENCODE, or one in an href, src or action attribute), the browser-side protections that would blunt an injected script are weakened, and the org contains accounts whose session is worth stealing.

Script running in an administrator's session acts as that administrator. It inherits Modify All Data if they hold it, and the requests it makes are indistinguishable from theirs.

What this chain explicitly does not claim is that the XSS is exploitable. The scan reads page markup, so it can show that a page renders something unencoded but not whether an attacker can influence what is rendered. A page interpolating a hardcoded label is a false positive. A page interpolating a record field an external user can set is not. Only reading the page tells you which. The chain is a prioritisation of which pages to read first, ordered by the fact that a privileged population exists to be targeted.

Claiming less is the point

There is a pull, when you build this kind of tool, towards making every chain sound maximal. Resist it. A chain that asserts a pivot it cannot demonstrate teaches the reader to discount the next one, and a correlation engine that cries wolf is worse than no correlation at all, because it consumes attention that the real chains need.

So each of the five carries an explicit boundary:

  • The file chain asserts content left the org. It does not assert a route into records.
  • The XSS chain asserts a prioritisation. It does not assert exploitability.
  • The self-registration chain excludes report folders, because the licence in question cannot reach them.
  • The OAuth chain requires a second ingredient beyond broad scope, because broad scope alone is an authorisation decision somebody may have made deliberately.
  • Content links grant read and not a foothold, so the emergent pass cannot invent a path from one shared file to the object model.

Stating what a finding does not mean is the part that makes the part it does mean credible.

The full catalogue: sixteen named chains

Chain Severity Fires when
Unauthenticated bulk exfiltration CRITICAL A guest foothold combines with guest-reachable code execution, public external sharing, or a guest bulk-read surface. No login required
Active guest reconnaissance against an exposed data surface CRITICAL EventLogFile evidence of guests probing from anonymiser IPs or running object-enumeration sweeps, plus a confirmed bulk-readable guest surface. An incident likely already in progress
Standard user to org takeover CRITICAL A low-trust or unauthenticated entry point combines with a privilege-escalation path: escalation permissions, Author Apex, shadow admins, delegated admin, Login-As, or a toxic permission combination
Credential theft to external pivot CRITICAL Exposed secrets combine with an egress path: a named credential, a remote site, or a self-provisioned connected app
Prompt injection blast radius CRITICAL A guest-reachable Agentforce channel, an over-privileged agent run-as user, and write-capable agent actions are all present
ForcedLeak pattern CRITICAL Active agents, a stale or unresolvable CSP-trusted domain, and no Event Monitoring capture of agent activity
SOQL injection to mass read HIGH Injectable dynamic SOQL combines with a bulk-readable sink: broad sharing, weak field-level security, public report folders, or View All Data
MFA bypass to privileged compromise HIGH Weak MFA enforcement or trusted-IP bypass coincides with highly-privileged accounts
Unmasked production PII in a weakly-controlled sandbox HIGH A sandbox holds populated PII fields while running weaker authentication or broader sharing than the org it was refreshed from
Insider bulk export without monitoring HIGH Broad internal read, a profile or permission set that can export en masse, and no monitoring that would record it
Standing OAuth access outside every login control HIGH Broad standing API access, plus something that means nobody would notice it being used
Self-service registration into an over-shared portal HIGH A site accepting self-registration, plus a sharing model or portal code that gives any account holder more than their own records
Live session ID handed to an external endpoint HIGH An outbound message sends the session ID, plus a reason the replay would be neither blocked nor recorded
Org files served to anonymous callers HIGH Unauthenticated file access, plus no classification or monitoring to establish what left
Visualforce XSS pattern reaching a privileged session HIGH An unencoded markup pattern, weakened browser-side controls, and privileged accounts to target
Exploitable access with no detection coverage MEDIUM A real capability exists while two or more detection controls are absent. Adds no exposure, reports that existing exposure would go unobserved

The five in bold are the new ones. They ship in v1.13.0, alongside the eleven capability grants that made them expressible. sf audit list prints the live check set, and the plugin README always carries the current figures.

Frequently Asked Questions

Q: Why require a second ingredient for the OAuth chain? Is Full scope not bad enough on its own?

A: Full scope on a connected app is already a finding on its own, at its own severity, and it stays that way. But it is also, sometimes, a deliberate decision: an integration that genuinely needs broad reach, authorised by someone who understood what they were granting and who reviews it. A chain should describe the case where that decision has stopped being supervised, so the second ingredient has to be something that removes the containment or the observation: a token matching no current app, a token unused for months, or connected apps exempted from login IP enforcement. Broad access nobody is watching is a different claim from broad access.

Q: We enforce MFA on every user and restrict login IP ranges. Does the OAuth chain still apply to us?

A: Yes, and that is the uncomfortable part. Those are login controls, and a refresh token exchange is not a login. The controls engage once when the app is authorised and never again. Relaxing IP enforcement for connected apps removes the one control that would otherwise still apply after authorisation, which is why it appears in the chain's second ingredient set. The controls that actually govern a standing token are scope, refresh token expiry policy, and revocation.

Q: Our Experience Cloud site has had self-registration on since launch and nothing has happened. Is that a finding?

A: Self-registration alone is not a finding, and the plugin does not report it as one. It becomes a chain when the external sharing model or portal-reachable code means an account is worth more than the records its holder created. The question is not whether self-registration is on. It is what profile and licence the self-registration handler assigns, and what that profile can reach. That assignment is rarely reviewed after a site goes live.

Q: The XSS chain flagged pages that turn out to be fine. Is that a false positive?

A: It is expected, and the chain says so rather than hiding it. Markup can show that a page renders a value without encoding, but not whether an attacker controls that value. A page interpolating a hardcoded label is safe; a page interpolating a field an external user can set is not. The chain exists to order the reading list, not to declare a vulnerability. Where a value is influenced by someone other than you, encode at the output with HTMLENCODE, JSENCODE or URLENCODE according to where the value lands, since the correct function depends on the context and not on the value.

Q: How do I know a chain will not fire on an org that is basically fine?

A: Every ingredient has to be present. There is no partial credit and no scoring threshold that tips a chain into existence. The detection-coverage chain over-fires most easily, so it requires two or more missing detection controls rather than one, which keeps it from appearing on every org that has not bought Shield.

Key Takeaways

  • A correlation model can go blind silently. Findings drive chains through a capability grant, and an absent grant is indistinguishable from a deliberate decision. Forty-seven of ninety-two checks had no grant, and nothing in the codebase could tell you.
  • Eleven findings carried reach they never declared. Secrets in custom settings, the @RestResource variants of an already-modelled Apex defect, session IDs on outbound messages, publicly fetchable files, self-registration, and Full-scope connected apps.
  • Granting nothing is sometimes correct. Persistence and disuse are evidence about a token rather than reach an attacker holds, so those findings act as chain steps instead of capability sources. Pin that decision with a test, because it is the easiest thing to helpfully undo.
  • Four of the five new chains bypass nothing. A refresh token never reaches a login. A self-registered account comes through the front door. An outbound message is the org doing the sending. A static resource is served from a URL no control governs.
  • Scope the claim to the evidence. The file chain asserts no pivot, and the XSS chain asserts no exploitability, because reading configuration cannot establish either. That restraint is what makes the chains that do claim a path worth acting on.

What's Next?

Recommended reading:

Action items:

  1. Inventory your standing OAuth tokens and revoke any whose app is not in your current connected app list. Do that before investigating them.
  2. Check whether any workflow outbound message has "Send Session ID" enabled, and whether its endpoint is still operated by whoever you think it is.
  3. List your public Documents and static resources, then read them. The exposure is whatever they contain, and that is knowable now.
  4. Confirm what profile and licence your self-registration handler assigns, and audit what that profile can reach.
  5. Run sf audit security and read the chain findings before the severity list.

Resources & References

Responses

Checking your session.

Loading responses.