|
<# |
|
.SYNOPSIS |
|
Enumerates all registered URI/URL protocol handlers ("schemes") on a |
|
Windows endpoint and statically risk-scores them for potential |
|
execution-primitive abuse (cf. search-ms:, ms-officecmd:, and similar |
|
scheme-hijack findings). |
|
|
|
.DESCRIPTION |
|
Does NOT invoke any handler. Purely reads HKEY_CLASSES_ROOT and |
|
HKEY_CURRENT_USER\Software\Classes for keys that carry the |
|
"URL Protocol" value, then resolves each one's shell\open\command |
|
to see what actually runs when the scheme is invoked (e.g. from a |
|
browser, Office document, or Explorer). |
|
|
|
For each handler it reports: |
|
- the scheme name (e.g. "mailto", "search-ms", "ms-officecmd") |
|
- the literal command line that Windows will execute |
|
- whether that command line passes attacker-controlled data |
|
through unsanitized (presence of %1 / %L placeholders) |
|
- whether the target executable is a known dual-use / LOLBIN-style |
|
binary (rundll32, mshta, wscript, cscript, powershell, msbuild, |
|
installutil, regsvr32, cmd, msiexec, etc.) |
|
- whether the target executable path exists and is signed |
|
|
|
Risk scoring is a heuristic triage aid, not a verdict. Manual |
|
validation (in an isolated VM, never against a live/shared endpoint) |
|
is the only way to confirm real exploitability of a flagged handler. |
|
|
|
.PARAMETER Json |
|
Emit JSON instead of a formatted table. |
|
|
|
.PARAMETER Html |
|
Path to write an HTML report. When set, a self-contained HTML file |
|
is written to this path in addition to console output. |
|
|
|
.PARAMETER IncludeLowRisk |
|
Also list handlers that scored no risk flags (default: hidden, since |
|
most of the ~150 default schemes are benign, e.g. "https", "mailto" |
|
pointed at a normal mail client). |
|
|
|
.EXAMPLE |
|
.\Enumerate-UrlSchemes.ps1 |
|
|
|
.EXAMPLE |
|
.\Enumerate-UrlSchemes.ps1 -Json > schemes.json |
|
|
|
.EXAMPLE |
|
.\Enumerate-UrlSchemes.ps1 -Html C:\temp\scheme_report.html -IncludeLowRisk |
|
#> |
|
|
|
[CmdletBinding()] |
|
param( |
|
[switch]$Json, |
|
[string]$Html, |
|
[switch]$IncludeLowRisk |
|
) |
|
|
|
# Executables that are well-documented as dual-use / LOLBIN-capable. |
|
# A scheme handler that shells out to one of these, especially with |
|
# attacker-influenced parameters, deserves manual follow-up. |
|
$DualUseBinaries = @( |
|
"rundll32.exe", "mshta.exe", "wscript.exe", "cscript.exe", |
|
"powershell.exe", "powershell_ise.exe", "pwsh.exe", |
|
"msbuild.exe", "installutil.exe", "regsvr32.exe", "regasm.exe", |
|
"cmd.exe", "msiexec.exe", "certutil.exe", "bitsadmin.exe", |
|
"wmic.exe", "control.exe", "explorer.exe", "forfiles.exe", |
|
"scriptrunner.exe", "diskshadow.exe", "ieexec.exe", "presentationhost.exe" |
|
) |
|
|
|
function Get-CommandTarget { |
|
param([string]$CommandLine) |
|
|
|
if (-not $CommandLine) { return $null } |
|
|
|
# Handle quoted and unquoted leading executable paths. |
|
if ($CommandLine -match '^\s*"([^"]+)"') { |
|
return $matches[1] |
|
} elseif ($CommandLine -match '^\s*(\S+\.exe)') { |
|
return $matches[1] |
|
} |
|
return $null |
|
} |
|
|
|
function Test-DualUse { |
|
param([string]$TargetPath) |
|
if (-not $TargetPath) { return $false } |
|
$leaf = Split-Path $TargetPath -Leaf |
|
return ($DualUseBinaries -contains $leaf.ToLower()) |
|
} |
|
|
|
function Get-SchemeHandlers { |
|
$roots = @( |
|
"Registry::HKEY_CLASSES_ROOT", |
|
"Registry::HKEY_CURRENT_USER\Software\Classes" |
|
) |
|
|
|
$results = @() |
|
|
|
foreach ($root in $roots) { |
|
$keys = Get-ChildItem -Path $root -ErrorAction SilentlyContinue |
|
foreach ($key in $keys) { |
|
$keyPath = $key.PSPath |
|
$props = $null |
|
try { |
|
$props = Get-ItemProperty -Path $keyPath -ErrorAction Stop |
|
} catch { continue } |
|
|
|
$hasUrlProtocol = $props.PSObject.Properties.Name -contains "URL Protocol" |
|
if (-not $hasUrlProtocol) { continue } |
|
|
|
$schemeName = $key.PSChildName |
|
$commandPath = Join-Path $keyPath "shell\open\command" |
|
$commandLine = $null |
|
try { |
|
$cmdProps = Get-ItemProperty -Path $commandPath -ErrorAction Stop |
|
$commandLine = $cmdProps.'(default)' |
|
if (-not $commandLine) { $commandLine = $cmdProps.'(Default)' } |
|
} catch { } |
|
|
|
$results += [PSCustomObject]@{ |
|
Scheme = $schemeName |
|
RegistryHive = ($root -replace '^Registry::','') |
|
CommandLine = $commandLine |
|
} |
|
} |
|
} |
|
|
|
# De-dupe by scheme name, preferring HKCR (merged view already handles |
|
# precedence in practice, but explicit HKCU entries shadow HKCR). |
|
return $results | Sort-Object Scheme -Unique |
|
} |
|
|
|
function Get-RiskAssessment { |
|
param([PSCustomObject]$Handler) |
|
|
|
$flags = @() |
|
$target = Get-CommandTarget -CommandLine $Handler.CommandLine |
|
|
|
if (-not $Handler.CommandLine) { |
|
$flags += "no command registered (informational only, not executable via this key)" |
|
} else { |
|
if ($Handler.CommandLine -match '%1|%L') { |
|
$flags += "passes invocation argument through to target command (%1/%L present)" |
|
} |
|
if (Test-DualUse -TargetPath $target) { |
|
$flags += "target is a known dual-use / LOLBIN-capable binary: $(Split-Path $target -Leaf)" |
|
} |
|
if ($Handler.CommandLine -match '-enc|-EncodedCommand|-nop|-noprofile|-w hidden|-windowstyle hidden') { |
|
$flags += "command line contains obfuscation/execution-policy-bypass style flags" |
|
} |
|
if ($target -and -not (Test-Path $target)) { |
|
$flags += "target executable path does not resolve on this system (stale/unreliable entry)" |
|
} |
|
} |
|
|
|
$signed = $null |
|
if ($target -and (Test-Path $target)) { |
|
try { |
|
$sig = Get-AuthenticodeSignature -FilePath $target -ErrorAction Stop |
|
$signed = $sig.Status |
|
} catch { $signed = "Unknown" } |
|
} |
|
|
|
$riskLevel = "Low" |
|
if ($flags.Count -ge 2) { $riskLevel = "High" } |
|
elseif ($flags.Count -eq 1) { $riskLevel = "Medium" } |
|
|
|
return [PSCustomObject]@{ |
|
Scheme = $Handler.Scheme |
|
RegistryHive = $Handler.RegistryHive |
|
CommandLine = $Handler.CommandLine |
|
Target = $target |
|
SignatureStatus = $signed |
|
RiskLevel = $riskLevel |
|
Flags = $flags |
|
} |
|
} |
|
|
|
function New-HtmlReport { |
|
param( |
|
[array]$AllFindings, |
|
[array]$DisplayedFindings, |
|
[string]$OutPath |
|
) |
|
|
|
Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue |
|
|
|
$generated = Get-Date -Format "yyyy-MM-dd HH:mm:ss" |
|
$totalCount = $AllFindings.Count |
|
$flaggedCount = (@($AllFindings | Where-Object { $_.RiskLevel -ne "Low" })).Count |
|
|
|
$rowsHtml = New-Object System.Text.StringBuilder |
|
|
|
foreach ($h in $DisplayedFindings) { |
|
$riskClass = switch ($h.RiskLevel) { |
|
"High" { "risk-high" } |
|
"Medium" { "risk-medium" } |
|
default { "risk-low" } |
|
} |
|
|
|
$flagsList = "" |
|
if ($h.Flags -and $h.Flags.Count -gt 0) { |
|
$items = ($h.Flags | ForEach-Object { "<li>$([System.Web.HttpUtility]::HtmlEncode($_))</li>" }) -join "" |
|
$flagsList = "<ul class='flags'>$items</ul>" |
|
} |
|
|
|
$cmdEncoded = [System.Web.HttpUtility]::HtmlEncode([string]$h.CommandLine) |
|
$targetEncoded = [System.Web.HttpUtility]::HtmlEncode([string]$h.Target) |
|
$schemeEncoded = [System.Web.HttpUtility]::HtmlEncode([string]$h.Scheme) |
|
$hiveEncoded = [System.Web.HttpUtility]::HtmlEncode([string]$h.RegistryHive) |
|
$sigEncoded = [System.Web.HttpUtility]::HtmlEncode([string]$h.SignatureStatus) |
|
|
|
[void]$rowsHtml.Append(@" |
|
<tr class="$riskClass"> |
|
<td><span class="badge $riskClass">$($h.RiskLevel)</span></td> |
|
<td class="scheme">${schemeEncoded}://</td> |
|
<td>$hiveEncoded</td> |
|
<td class="cmd">$cmdEncoded</td> |
|
<td>$targetEncoded</td> |
|
<td>$sigEncoded</td> |
|
<td>$flagsList</td> |
|
</tr> |
|
"@) |
|
} |
|
|
|
$htmlTemplate = @" |
|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<title>URL Scheme Handler Risk Report</title> |
|
<style> |
|
body { font-family: Segoe UI, Arial, sans-serif; margin: 0; padding: 24px; background: #f4f5f7; color: #1f2328; } |
|
h1 { font-size: 20px; margin-bottom: 4px; } |
|
.meta { color: #57606a; font-size: 13px; margin-bottom: 20px; } |
|
.summary { display: flex; gap: 16px; margin-bottom: 20px; } |
|
.summary-card { background: #fff; border: 1px solid #d0d7de; border-radius: 6px; padding: 12px 18px; } |
|
.summary-card .num { font-size: 22px; font-weight: 600; } |
|
.summary-card .label { font-size: 12px; color: #57606a; } |
|
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #d0d7de; border-radius: 6px; overflow: hidden; } |
|
th { background: #f0f2f4; text-align: left; padding: 10px 12px; font-size: 12px; text-transform: uppercase; color: #57606a; border-bottom: 1px solid #d0d7de; } |
|
td { padding: 10px 12px; font-size: 13px; border-bottom: 1px solid #eaeef2; vertical-align: top; } |
|
tr:last-child td { border-bottom: none; } |
|
td.scheme { font-weight: 600; white-space: nowrap; } |
|
td.cmd { font-family: Consolas, monospace; font-size: 12px; word-break: break-all; max-width: 320px; } |
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; color: #fff; } |
|
.risk-high .badge, .badge.risk-high { background: #cf222e; } |
|
.risk-medium .badge, .badge.risk-medium { background: #9a6700; } |
|
.risk-low .badge, .badge.risk-low { background: #57606a; } |
|
tr.risk-high { background: #fff5f5; } |
|
tr.risk-medium { background: #fffbe6; } |
|
ul.flags { margin: 0; padding-left: 16px; font-size: 12px; } |
|
.notes { margin-top: 24px; background: #fff; border: 1px solid #d0d7de; border-radius: 6px; padding: 16px 20px; font-size: 13px; } |
|
.notes h2 { font-size: 14px; margin-top: 0; } |
|
.notes ul { margin: 0; padding-left: 18px; } |
|
.notes li { margin-bottom: 6px; } |
|
</style> |
|
</head> |
|
<body> |
|
<h1>URL Scheme Handler Risk Report</h1> |
|
<div class="meta">Generated $generated on $(if ($env:COMPUTERNAME) { $env:COMPUTERNAME } else { "unknown host" })</div> |
|
|
|
<div class="summary"> |
|
<div class="summary-card"><div class="num">$totalCount</div><div class="label">Total schemes enumerated</div></div> |
|
<div class="summary-card"><div class="num">$flaggedCount</div><div class="label">Flagged medium or high</div></div> |
|
<div class="summary-card"><div class="num">$($DisplayedFindings.Count)</div><div class="label">Rows shown below</div></div> |
|
</div> |
|
|
|
<table> |
|
<thead> |
|
<tr> |
|
<th>Risk</th> |
|
<th>Scheme</th> |
|
<th>Hive</th> |
|
<th>Command Line</th> |
|
<th>Resolved Target</th> |
|
<th>Signature</th> |
|
<th>Flags</th> |
|
</tr> |
|
</thead> |
|
<tbody> |
|
$($rowsHtml.ToString()) |
|
</tbody> |
|
</table> |
|
|
|
<div class="notes"> |
|
<h2>Notes</h2> |
|
<ul> |
|
<li>This is a static inventory only. No handler was invoked as part of this scan.</li> |
|
<li>Percent-1 or percent-L present means the OS will pass the full URI, including anything after the scheme, to the target process. Whether that becomes attacker-controlled arguments depends on how the target application parses it.</li> |
|
<li>To confirm real exploitability of a flagged entry, test manually in an isolated VM: craft a benign URI and observe process creation via Procmon or Sysmon Event ID 1, rather than assuming from the registry alone.</li> |
|
<li>Cross-reference flagged scheme names against public writeups, such as the search-ms, ms-officecmd, and ms-msdt disclosures, to see if a given handler is already a documented finding.</li> |
|
</ul> |
|
</div> |
|
</body> |
|
</html> |
|
"@ |
|
|
|
Set-Content -Path $OutPath -Value $htmlTemplate -Encoding UTF8 |
|
} |
|
|
|
# --- Main -------------------------------------------------------------- |
|
|
|
Write-Verbose "Enumerating registered URL protocol schemes..." |
|
$handlers = Get-SchemeHandlers |
|
|
|
Write-Verbose "Assessing $($handlers.Count) handlers..." |
|
$assessed = $handlers | ForEach-Object { Get-RiskAssessment -Handler $_ } |
|
|
|
$assessed = $assessed | Sort-Object @{Expression={ |
|
switch ($_.RiskLevel) { "High" {0} "Medium" {1} default {2} } |
|
}}, Scheme |
|
|
|
if (-not $IncludeLowRisk) { |
|
$display = $assessed | Where-Object { $_.RiskLevel -ne "Low" } |
|
} else { |
|
$display = $assessed |
|
} |
|
|
|
if ($Html) { |
|
New-HtmlReport -AllFindings $assessed -DisplayedFindings $display -OutPath $Html |
|
Write-Output "HTML report written to: $Html" |
|
} |
|
|
|
if ($Json) { |
|
$display | ConvertTo-Json -Depth 5 |
|
return |
|
} |
|
|
|
Write-Output "Registered URL protocol schemes found: $($handlers.Count)" |
|
Write-Output "Flagged for review (Medium/High): $((@($assessed | Where-Object {$_.RiskLevel -ne 'Low'})).Count)" |
|
Write-Host "" |
|
|
|
foreach ($h in $display) { |
|
$color = switch ($h.RiskLevel) { |
|
"High" { "Red" } |
|
"Medium" { "Yellow" } |
|
default { "Gray" } |
|
} |
|
|
|
Write-Host "=== " -NoNewline |
|
Write-Host "$($h.Scheme)://" -NoNewline -ForegroundColor Cyan |
|
Write-Host " [" -NoNewline |
|
Write-Host $h.RiskLevel -NoNewline -ForegroundColor $color |
|
Write-Host "] ($($h.RegistryHive))" |
|
|
|
Write-Host " command: " -NoNewline -ForegroundColor DarkGray |
|
Write-Host $h.CommandLine |
|
|
|
if ($h.Target) { |
|
Write-Host " target: " -NoNewline -ForegroundColor DarkGray |
|
Write-Host "$($h.Target) " -NoNewline |
|
Write-Host "(signature: $($h.SignatureStatus))" -ForegroundColor DarkGray |
|
} |
|
foreach ($f in $h.Flags) { |
|
Write-Host " - $f" -ForegroundColor $color |
|
} |
|
Write-Host "" |
|
} |
|
|
|
Write-Output "Notes:" |
|
Write-Output " - This is a STATIC inventory only. No handler was invoked." |
|
Write-Output " - '%1/%L present' means the OS will pass the full URI (including anything" |
|
Write-Output " after the scheme) to the target process; whether that translates into" |
|
Write-Output " attacker-controlled arguments depends on how the target application parses it." |
|
Write-Output " - To confirm real exploitability of a flagged entry, test manually in an" |
|
Write-Output " isolated VM: craft a benign URI (e.g. scheme:test) and observe process" |
|
Write-Output " creation via Procmon/Sysmon (Event ID 1) rather than assuming from the" |
|
Write-Output " registry alone." |
|
Write-Output " - Cross-reference flagged scheme names against public writeups (e.g. the" |
|
Write-Output " search-ms / ms-officecmd / ms-msdt disclosures) to see if a given handler" |
|
Write-Output " is already a documented finding." |