This post is Part 2 of the Technical Blog post Auditing Salesforce Permission Hierarchies with ForceHound. Developed by NetSPI’s Weylon Solis, ForceHound is an open-source Python collector that builds a BloodHound CE identity graph allowing teams to audit their Salesforce environment.

Now let’s see what happens when ForceHound’s graph data is loaded into BloodHound. Below are two privilege escalation scenarios that show why identity relationships in Salesforce need the same graph-based analysis that Active Directory has had since 2016.

Scenario 1: From Standard User to ModifyAllData

Imagine an attacker has compromised a standard user’s session, maybe through a phishing campaign or a stolen sid cookie from a browser extension. The user’s profile is “Standard User” and they have no admin access. The attacker runs ForceHound in Aura mode using the stolen session:

python -m forcehound \
  --collector aura \
  --instance-url "https://targetorg.lightning.force.com" \
  --session-id "$STOLEN_SID" \
  --aura-context "$AURA_CONTEXT" \
  --aura-token "$AURA_TOKEN" \
  --new-format \
  --upload \
  --bh-url "http://localhost:8080" \
  --bh-token-id "$TOKEN_ID" \
  --bh-token-key "$TOKEN_KEY"

No ApiEnabled permission needed. The graph is now in BloodHound. The first question an attacker asks: does any path exist from my compromised user to a high-value capability like ModifyAllData?

MATCH p = (u:SF_User)-[:HasProfile|HasPermissionSet]->(ps)-[:ModifyAllData]->(org:SF_Organization) 
WHERE u.name = "JAMESON HOWELL" 
RETURN p LIMIT 10

If the compromised user’s profile or any of their assigned permission sets have ModifyAllData, this query returns the path. But the more interesting case is when the path isn’t direct. What if the user doesn’t have ModifyAllData, but they do have ManageUsers or AssignPermissionSets?

MATCH p = (u:SF_User)-[:HasProfile|HasPermissionSet]->(ps)-[:ManageUsers|AssignPermissionSets]->(org:SF_Organization) 
WHERE u.name = "JAMESON HOWELL" 
RETURN p LIMIT 10

If this query returns results, the attacker has found a privilege escalation path. ManageUsers allows creating and modifying user accounts. AssignPermissionSets allows assigning arbitrary permission sets to any user, including permission sets that grant ModifyAllData. The compromised user doesn’t need ModifyAllData directly. They just need the ability to give it to themselves.

This is transitive privilege escalation, and it’s exactly the kind of thing that spreadsheet audits miss. The admin who assigned that permission set three years ago probably didn’t consider it a path to full org compromise.

Scenario 2: Connected App Exposure via Implicit Access

In the Connected App post, I walked through a scenario where an attacker used a malicious Connected App to pivot from OAuth consent to AWS credential theft. The key enabler was an app that wasn’t restricted to specific profiles, meaning every user in the org could authorize it. While Salesforce’s September 2025 restrictions on Connected App installation significantly enhanced the default security posture of new integrations, ForceHound provides visibility into legacy app authorizations and profile access granted prior to those restrictions.

ForceHound models Connected App access explicitly. Non-admin-approved Connected Apps get a synthetic CanAccessApp edge from every profile in the org. Admin-approved apps only get CanAccessApp edges from profiles and permission sets that have explicit SetupEntityAccess grants.

This Cypher query shows which profiles can reach a specific Connected App:

MATCH (p:SF_Profile)-[r:CanAccessApp]->(app:SF_ConnectedApp) 
RETURN p, r, app LIMIT 10

Now combine that with the identity graph to find which users can reach a specific app through their profile:

MATCH p = (u:SF_User)-[:HasProfile]->(prof:SF_Profile)-[:CanAccessApp]->(app:SF_ConnectedApp)
RETURN p LIMIT 10

Recall the scenario from my previous post. The attack chain required the target user to have ApiEnabled, which was the key that unlocked the door. ForceHound lets you query exactly that:

MATCH p = (u:SF_User)-[:HasProfile|HasPermissionSet]->(ps)-[:ApiEnabled]->(org:SF_Organization) 
RETURN p LIMIT 10

Every user returned by this query can (or could, prior to Salesforce’s 2025 restrictions) authorize a Connected App and give an attacker REST API access to the org. If that list includes users with access to Custom Metadata Types containing hardcoded AWS credentials, you’ve just mapped the exact blast radius I described in that post. With ForceHound and BloodHound, you can see it before an attacker exploits it.

More Useful Cypher Queries

Once the graph is in BloodHound, the questions you can answer are limited only by your imagination and your Cypher syntax. Here are a few queries we’ve found useful during engagements:

Which entitlements grant ModifyAllData?

MATCH (ps)-[r:ModifyAllData]->(org:SF_Organization) 
RETURN ps, r, org LIMIT 10

Users who can reach AuthorApex through their profile or permission sets:

MATCH p = (u:SF_User)-[:HasProfile|HasPermissionSet]->(ps)-[:AuthorApex]->(org:SF_Organization) 
RETURN p LIMIT 10

Objects the current user can empirically create (from CRUD probing):

MATCH (u:SF_User)-[r:CrudCanCreate]->(o:SF_Object) 
RETURN u, r, o LIMIT 10

Full path from user to profile to object-level CRUD:

MATCH p = (u:SF_User)-[:HasProfile]->(prof:SF_Profile)-[:CanRead]->(o:SF_Object) 
RETURN p LIMIT 5

Role hierarchy, who reports to whom:

MATCH (child:SF_Role)-[r:ReportsTo]->(parent:SF_Role) 
RETURN child, r, parent LIMIT 5

Connected Apps and which profiles can access them:

MATCH (a)-[r:CanAccessApp]->(ca:SF_ConnectedApp) 
RETURN a, r, ca LIMIT 5

Objects with a Private sharing model:

MATCH (o:SF_Object) 
WHERE o.InternalSharingModel = "Private" 
RETURN o LIMIT 5

Organization-Wide Defaults, is the org running an open model?

MATCH (org:SF_Organization) 
RETURN org LIMIT 1

If DefaultAccountAccessDefaultContactAccess, and DefaultOpportunityAccess all show “ReadWrite,” the sharing model is open. Every user can read and write every record of those object types regardless of ownership. That’s the “open model” scenario from the Connected App post, and it’s more common than you’d expect.

Conclusion

ForceHound doesn’t fix misconfigurations. It makes them visible.

An admin who can see that every profile has an implicit CanAccessApp edge to 29 non-admin-approved Connected Apps can make an informed decision about whether to restrict those apps. A red teamer who can see that a standard user’s profile grants AuthorApex through a forgotten permission set can demonstrate real impact instead of describing theoretical risk. And when the CRUD prober proves that a low-privilege browser session can create and edit Account records, not because the metadata says so but because it actually did, that’s a finding that changes how an organization thinks about its Salesforce security posture.

Salesforce’s September 2025 restrictions on Connected App installation represent a significant milestone.

While these updates protect new configurations, defense-in-depth requires understanding what access already exists in your org, not just restricting new access. The permission sets assigned three years ago, the sharing rules nobody remembers creating, the connected apps authorized by a contractor who left. Those are still in the graph, still granting access, still invisible to anyone who isn’t running the right queries. 

ForceHound makes them visible. It’s open source and available at github.com/NetSPI/ForceHound.

While working on this tool, the SFHound tool was released by Kaibersec. Their tool accomplishes similar things in a different way. 

Does your organization need help assessing its Salesforce security posture? 

NetSPI’s proactive security testing services can help identify and remediate the attack paths that tools like ForceHound surface.