Skip to content

quarkslab/QAZPT

Repository files navigation

QAZPT

Table of Contents

What is QAZPT?

QAZPT (Quarkslab Azure Permission Tracker) is a tool designed to analyze and track permissions — EntraID roles, application roles, and delegated permissions — through direct or indirect inheritance within Microsoft EntraID (formerly Azure Active Directory) environments. Currently, it does not take Azure Resource Manager (ARM) into account. It helps identify how permissions are propagated through various entities such as users, applications, and service principals, enabling security teams to better understand and manage access controls.

Caution: QAZPT is still a work in progress (WIP) and its results should be interpreted carefully. Additionally, inheritance computation is complex and cannot cover all edge cases. Please refer to the documentation for more details on how inheritance is computed and its limitations.

Features

  • Analyze and visualize inheritance between EntraID entities (Users, AppRegistrations, ServicePrincipals), uncovering complex relationships and potential permission escalation paths.
  • Identify and visualize gained permissions through inheritance, helping to detect over-privileged entities.
  • Export detailed reports in CSV and JSON formats for further analysis, containing entities, their own permissions, and inherited permissions.
  • Export the full graph to a Neo4j database for interactive exploration and advanced Cypher queries.

Use Cases

QAZPT can assist both offensive and defensive security work in EntraID environments:

  • Evaluate the security posture of EntraID environments by uncovering effective permissions for each entity
  • Identify potential privilege escalation paths across EntraID entities
  • Ensure no entity gains unintended permissions and that access controls are properly configured
  • Assist in detecting post-exploitation persistence techniques by mapping out inherited permissions
  • Monitor and audit permission changes over time to detect anomalies by comparing reports

Installation

uv sync
uv run qazpt --help

or with pip:

pip install .
qazpt --help

Usage

qazpt --help
usage: QAZPT [-h] [-v] [-d] {inheritance} ...

positional arguments:
  {inheritance}
    inheritance         Analyze inheritance

options:
  -h, --help            show this help message and exit
  -v, --verbose
  -d, --debug           Enable debug mode (more verbose)
  -C CACHE_DIR, --local-cache-dir CACHE_DIR
                        Use the specified local directory for caching data or load cached data. Default is ~/.qazpt_cache

Inheritance

usage: QAZPT inheritance [-h] [-t | -p | -g DB_NAME | -c CSV_NAME | --as-json [JSON_PATH] | --as-neo4j] [--neo4j-uri BOLT_URI] [-A] [-P] [-f TYPE,TYPE,...] [-x TYPE,TYPE,...]
                         [--hide-delegated-permissions] [--neo4j-user USERNAME] [--neo4j-password PASSWORD] [-r] [--legacy-dfs] [--direct-origin] [--true-origin]

options:
  -h, --help            show this help message and exit
  -t, --inheritance-tree
                        [Default] Output inheritance as trees
  -p, --show-gained-permissions
                        Output entities with their gained permissions
  -c, --as-csv CSV_NAME
                        Output entities, permissions and indirects permissions gained through inheritance as CSV
  --as-json [JSON_PATH]
                        Output entities, permissions and indirects permissions gained through inheritance as JSON file if specified, else to stdout
  --as-neo4j            Export the graph to a Neo4j database.
  --neo4j-uri BOLT_URI  Bolt URI of the Neo4j instance (used with --as-neo4j, default: bolt://127.0.0.1:7687)
  -A, --show-all        When analyzing inheritance, show also entities without any permissions
  -P, --privileged-only
                        When analyzing inheritance, only show privileged entities
  -f, --filter-by-type TYPE,TYPE,...
                        When analyzing inheritance, only show entities of type included in the comma separated list. Available types are: User, ServicePrincipal,
                        ManagedIdentityServicePrincipal, AppRegistration
  -x, --exclude-by-type TYPE,TYPE,...
                        Same as --filter-by-type but exclude the types instead of including them
  --hide-delegated-permissions
                        When analyzing inheritance, do not show delegated permissions
  --neo4j-user USERNAME
                        Neo4j username (used with --as-neo4j, default: neo4j)
  --neo4j-password PASSWORD
                        Neo4j password (used with --as-neo4j, default: qazpt)
  -r, --refresh         When analyzing inheritance, refresh the data
  --legacy-dfs          When computing inheritance, use legacy cycle-safe DFS algorithm instead of DAG condensation algorithm (default is condensed). Computation is faster butwill
                        produce non-exhaustive results in cyclic graphs. Use this flag if your environment is acyclic or if you encounter issues with the condensation algorithm.
  --direct-origin       When distributing condensed inheritance, inherited permissions are shown with their direct neighbors as the origin. If A(X)->B(Y)->C(Z), A will get (YZ)
                        with B as origin.Use this flag if you prefer to see the path of inheritance more explicitly.Using this flag may cause more verbose outputs in graphs with a
                        lot of strongly connected components (several nodes with cyclic inheritance).This flag is mutually exclusive with --true-origin.
  --true-origin         [Default] When distributing condensed inheritance, inherited permissions are shown with their original owners as the origin. If A(X)->B(Y)->C(Z), A will get (Y) with
                        B as origin and (Z) with C as origin.Use this flag if you prefer to see the original source of inherited permissions.Showing true origin permissions may
                        result in more verbose outputs in graphs with populated strongly connected components.This flag is mutually exclusive with --direct-origin. This is the
                        default behavior.

Inheritance tree

To print the inheritance tree of the EntraID entities, use the --inheritance-tree option, which is the default behavior:

qazpt inheritance

### Users ###

alice/alice@contoso.onmicrosoft.com/00000000-0000-0000-0000-000000000001
   --[ownership]--> AppRegistration/general_admin_app/00000000-0000-0000-0000-000000000002
      --[sp_of_app]--> ServicePrincipal/general_admin_app/00000000-0000-0000-0000-000000000003 [HAS PRIVILEGED ROLE]
         --[Entraid_Role (Global Administrator)]--> All entities

### Applications ###

AppRegistration/general_admin_app/00000000-0000-0000-0000-000000000002
   --[sp_of_app]--> ServicePrincipal/general_admin_app/00000000-0000-0000-0000-000000000003 [HAS PRIVILEGED ROLE]
      --[Entraid_Role (Global Administrator)]--> All entities

Add -P, --privileged-only to only show entities that hold a privileged built-in role within EntraID.

Inherited permission

Use --direct-origin to show the immediate neighbors as the origin of inherited permissions. By default, the original source (e.g. the owner) of inherited permissions is shown.

To get the effective gained inherited permissions:

qazpt inheritance --show-gained-permissions [--hide-delegated-permissions]

### User ###

alice/alice@contoso.onmicrosoft.com/00000000-0000-0000-0000-000000000001
  Entra ID Role :
	 - Global Administrator inherited from AppRegistration/general_admin_app/00000000-0000-0000-0000-000000000002

### ServicePrincipal ###

ServicePrincipal/AppTest_userpasswordprofile_readwrite/00000000-0000-0000-0000-000000000004
  Entra ID Role :
	 - Global Administrator inherited from bob/bob@contoso.onmicrosoft.com/00000000-0000-0000-0000-000000000005 [HAS PRIVILEGED ROLE]
	 - Global Administrator inherited from alice/alice@contoso.onmicrosoft.com/00000000-0000-0000-0000-000000000001

  AppRoleAssignments :
	 - Graph/Toto.Titi inherited from bob/bob@contoso.onmicrosoft.com/00000000-0000-0000-0000-000000000005 [HAS PRIVILEGED ROLE]

Entities that are already highly privileged — meaning those that hold an EntraID role or a Microsoft Graph permission enabling them to control all other entities — will not appear here, as they virtually gain nothing and their inherited permissions are not computed.

If an entity inherits from a highly privileged entity, the inherited permissions are limited to that highly privileged entity's own permissions.

To get a full list of entities, their own permissions, and their inherited permissions, the data can be exported as a CSV file using --as-csv, or in JSON format using the --as-json option. Note that if no file path is provided for the JSON output, it will be printed to standard output.

qazpt inheritance --as-csv inheritance_output.csv
qazpt inheritance --as-json [file_name.json]

Neo4j graph export

QAZPT can export the full inheritance graph to a Neo4j database for interactive exploration.

Prerequisites

Start a Neo4j instance. A docker-compose.yml is provided at the root of the project:

docker compose up -d

This starts Neo4j 5 with the Browser UI at http://localhost:7474 and the Bolt endpoint at bolt://127.0.0.1:7687 (credentials: neo4j / qazpt).

Export
qazpt inheritance --as-neo4j

By default, ServicePrincipals with no permissions and no concrete ownership/control links are omitted to keep the graph readable. Pass --show-all to include them:

qazpt inheritance --as-neo4j --show-all

Connection options (all have defaults matching the provided docker-compose.yml):

Option Default Description
--neo4j-uri bolt://127.0.0.1:7687 Bolt URI of the Neo4j instance
--neo4j-user neo4j Neo4j username
--neo4j-password qazpt Neo4j password

Note: Each export clears prior QAZPT data by deleting only nodes with QAZPT labels (User, ServicePrincipal, ManagedIdentityServicePrincipal, AppRegistration, EntraIDRole, AppRole, DelegatedPermission). Other nodes and data in the same database are not affected.

Combined with other filters:

# Only export Users and ServicePrincipals
qazpt inheritance --as-neo4j --filter-by-type User,ServicePrincipal

# Only show privileged entities
qazpt inheritance --as-neo4j --privileged-only
Graph schema

Entity nodes — one label per type, filtered with --filter-by-type / --exclude-by-type:

Label Key properties
:User id, display_name, user_principal_name, scope_level
:ServicePrincipal id, display_name, app_id, sp_type, scope_level
:ManagedIdentityServicePrincipal id, display_name, app_id, scope_level
:AppRegistration id, display_name, app_id, scope_level

The scope_level property reflects the direct privilege level of the entity (e.g. SERVICE_PRINCIPAL, USER, ALL, NONE_OR_DEFAULT) as computed by QAZPT's inheritance resolvers.

Permission nodes:

Label Key properties
:EntraIDRole id, display_name, is_privileged, is_built_in
:AppRole id, display_name, value, resource_display_name, resource_id
:DelegatedPermission id, scope, resource_id, resource_display_name, principal_id, principal_display_name

Relationships:

Relationship Meaning Properties
CONTROLS Concrete inheritance link (ownership, SP of app, FIC, …) type, detail
HAS_ENTRAID_ROLE Entity holds an EntraID role directly resource_scope, directory_scope
HAS_APP_ROLE Entity holds a Microsoft Graph application role
HAS_DELEGATED_PERMISSION Entity holds a delegated OAuth2 permission consent_type
Useful Cypher queries

Search by name (case-insensitive):

MATCH (e) WHERE toLower(e.display_name) CONTAINS toLower('alice') RETURN e

All entities with a specific EntraID role (direct holders):

MATCH (e)-[:HAS_ENTRAID_ROLE]->(r:EntraIDRole {display_name: 'Global Administrator'})
RETURN e.display_name, e.entity_type

All entities that lead to Global Administrator (direct + inherited via concrete links + via virtual scope):

// Direct holders
MATCH p=(e)-[:HAS_ENTRAID_ROLE]->(r:EntraIDRole {display_name: 'Global Administrator'})
RETURN p
UNION
// Via concrete inheritance chain
MATCH p=(e)-[:CONTROLS*1..]->(target)-[:HAS_ENTRAID_ROLE]->(:EntraIDRole {display_name: 'Global Administrator'})
RETURN p

Shortest paths to Global Administrator:

MATCH (target)-[:HAS_ENTRAID_ROLE]->(r:EntraIDRole {display_name: "Global Administrator"})
MATCH (e) WHERE NOT (e)-[:HAS_ENTRAID_ROLE]->(r) AND e <> target

WITH e, r, target
MATCH p = allShortestPaths((e)-[:CONTROLS*1..6]->(target))
WHERE NONE(n IN nodes(p)[1..-1] WHERE (n)-[:HAS_ENTRAID_ROLE]->(r))

MATCH q = (target)-[:HAS_ENTRAID_ROLE]->(r)

RETURN p, q

Entities with an elevated direct scope level:

MATCH (e) WHERE e.scope_level IN ['USER', 'APPLICATION', 'ALL']
RETURN e.display_name, e.entity_type, e.scope_level ORDER BY e.scope_level

Shortest paths to entities with elevated direct scope level:

MATCH q = (target {scope_level: "ALL"})-[:HAS_APP_ROLE|:HAS_ENTRAID_ROLE]->(r)
MATCH (e) WHERE NOT e.scope_level = "ALL" AND e <> target

WITH e, target, q
MATCH p = allShortestPaths((e)-[:CONTROLS*1..6]->(target))
WHERE NONE(n IN nodes(p)[1..-1] WHERE n.scope_level = "ALL")

RETURN p, q

Documentation

Required permissions

In order to properly analyze inheritance, QAZPT requires sufficient permissions to read the entities and their permissions within the target EntraID environment.

Minimal Microsoft Graph required permissions include:

  • User.Read.All
  • Application.Read.All
  • RoleManagement.Read.Directory

Directory.Read.All can also be used to cover every required read permissions.

Note that every member user of the directory can read most of the directory and delegate this permissions successfully. Using a guest account or a service principal will require above permissions to be explicitly granted.

How inheritance is computed

The inheritance is computed thanks to resolvers (InheritanceResolverRule) located in qazpt/core/graph/resolvers/.

These resolvers are responsible for determining the relationships between entities and how permissions are inherited.

Currently, the resolvers execute one pass to establish inheritance relationships before computing the effective permissions for each entity. While this approach ensures accurate tree views, it does not account for potential new inheritance paths that could emerge from EntraID or Microsoft Graph permissions after the initial computation.

The use of Tarjan's algorithm addresses this issue by identifying and condensing cycles in the graph into single nodes. This ensures that transitive permissions are computed exhaustively, even in graphs with cyclic inheritance. By treating cycles as unified entities, the algorithm enables a more precise and complete calculation of effective permissions.

Currently, 5 resolvers are used in order to find inheritance between entities:

Direct ownership

An entity is set as the owner of another one (e.g a User is the owner of an AppRegistration)

ServicePrincipal of an AppRegistration

ServicePrincipals are living instances of AppRegistration. Owning an AppRegistration means you control its corresponding ServicePrincipal.

Federated Identity Credentials

An Application provided with a Federated Identity Credential (FIC) allows the configured, potential external identity provider to request tokens for the application. Therefore, any identity able to authenticate against the configured identity provider can potentially gain access to the application and its permissions.

⚠️ Caveat: Only INTERNAL identities are currently supported. External identity providers (e.g., GitHub, Google) are not yet handled. A warning level log is generated when an unsupported identity type is encountered.

EntraID roles

Several EntraID roles enable to control different kind of entities:

⚠️ Caveat: The list below is incomplete and covers only a subset of highly privileged roles commonly used in assessments. Additional roles with security implications exist but are not yet included.

  • Global Administrator: All entities
  • Application Administrator: All AppRegistration and ServicePrincipals (And consent to applications roles different than Microsoft Graph)
  • Cloud Application Administrator: Same as above but not for App Proxy

Microsoft Graph application roles

Several Microsoft Graph application roles enable to control different kind of entities.

  • RoleManagement.ReadWrite.Directory: All entities

    • This permissions enables to assign EntraID roles, such as Global Administrator and modify PIM configurations.
  • AppRoleAssignment.ReadWrite.All: All entities

    • This permissions enables to assign any other application role to any other entity, without needing an admiministrator to consent. It can be used to assign RoleManagement.ReadWrite.Directory, which can be used to assign General Administrator role.
  • User-PasswordProfile.ReadWrite.All: Users by changing their password. True only if no MFA is enforced.

  • User.ReadWrite.All: Some users. Can be used to edit user profile that are less privileged. (Unclear)

  • UserAuthMethod-Passkey.ReadWrite.All: All users.

    • This permissions can be used to add a passkey as authentication way for any users. Passkeys enable to authenticate without password and 2FA.
  • UserAuthenticationMethod.ReadWrite.All: Same as above, but include every other way to authenticate.

  • Application.ReadWrite.All: All applications and servicePrincipals.

  • Application.ReadUpdate.All: Same as above. Difference is that this one doesn't allow to create new applications.

  • ServicePrincipalEndpoint.ReadWrite.All: All servicePrincipals.

How transitive permissions are computed

After nodes have been linked thanks to the different resolvers, the graph is either analyzed in order to compute the effective permissions of each entities using a DFS, cycle-safe approach (--legacy-dfs), or the graph is condensed leveraging Tarjan's algorithm to obtain a Directed Acyclic Graph (DAG) and then a bottom-up traversal is performed to compute inherited permissions.

Each node inherits the permissions of its childs nodes, except if the node is already highly privileged (e.g has an EntraID role or a Microsoft Graph permission that enable it to control every other entities).

Limitations and future work

  • Groups are not yet supported. However, transitive EntraID roles obtained through groups are taken into account.
  • Support for Azure Resource Manager (ARM) permissions and inheritance is not yet implemented, and therefore inheritance through ARM RBAC is not computed for managed identities and user-assigned managed identities.
  • EntraID roles scopes are not yet supported (EntraID v1/v2).
  • Administrative Units are not yet supported (EntraID v1/v2).
  • PIM (Privileged Identity Management) is not yet supported (EntraID v1/v2).
  • Federated Identity Credentials (FIC): Only INTERNAL identities are currently supported. External identity providers (e.g., GitHub, Google) are not yet handled.
  • EntraID roles coverage: The tool covers only a subset of highly privileged roles commonly used in assessments. Additional roles with security implications exist but are not yet included.

Comparison with other tools

You may wonder why QAZPT when there are already some tools that can analyze permissions in EntraID environments, such as Bloodhound (With AzureHound), ROADtools, ScoutSuite, etc..

First, QAZPT is not meant to be a replacement for these tools, but rather a complementary tool that focuses on a specific aspect of EntraID security: permission inheritance. While other tools may provide a broader range of features and analyses, QAZPT aims to provide a deeper and more comprehensive analysis of permission inheritance, which is a critical aspect of EntraID security that can often be overlooked.

It seems that no other tool currently provides an in-depth analysis of permission inheritance in EntraID environments, especially when it comes to the different inheritance paths (ownership, FIC, EntraID roles, Microsoft Graph permissions abuses) and the effective permissions gained through inheritance. QAZPT fills this gap by providing a dedicated tool for analyzing and understanding permission inheritance in EntraID environments.

About

Quarkslab Azure Permission Tracker

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages