Skip to content

Instantly share code, notes, and snippets.

@AlloySecureGroup
Last active July 18, 2026 15:44
Show Gist options
  • Select an option

  • Save AlloySecureGroup/5062355dc48f1e333223f0dda90e7cda to your computer and use it in GitHub Desktop.

Select an option

Save AlloySecureGroup/5062355dc48f1e333223f0dda90e7cda to your computer and use it in GitHub Desktop.
Scheme Hunter - Enumerate URI schemes and prototype invocation

WDAC / AppLocker Bypass Recon Scripts

Static, read-only triage tools. Nothing here executes payloads or invokes handlers.

Scan-LolbinPrimitives.ps1

Scans binaries for signed-binary-proxy-execution primitives (CLR hosting, scripting engines, dynamic compilation, etc).

.\Scan-LolbinPrimitives.ps1 -Path C:\Windows\System32
.\Scan-LolbinPrimitives.ps1 -Path C:\Windows\System32 -Json > results.json

Enumerate-UrlSchemes.ps1

Enumerates registered URL protocol handlers and risk-scores them based on command line, target binary, and signature.

.\Enumerate-UrlSchemes.ps1
.\Enumerate-UrlSchemes.ps1 -IncludeLowRisk
.\Enumerate-UrlSchemes.ps1 -Json > schemes.json
.\Enumerate-UrlSchemes.ps1 -Html report.html

Flags: -Json (machine-readable output), -Html <path> (report file), -IncludeLowRisk (show all schemes, not just flagged ones).

Next steps for flagged findings

  1. Cross-reference against LOLBAS (lolbas-project.github.io) and Microsoft's WDAC recommended block list.
  2. Confirm behavior manually in an isolated VM. Watch process creation via Procmon or Sysmon Event ID 1.
<#
.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."
<#
.SYNOPSIS
Static triage tool for finding Windows binaries that contain the
"primitives" commonly abused for AppLocker / WDAC bypass
(Signed Binary Proxy Execution, MITRE ATT&CK T1218).
.DESCRIPTION
Does NOT execute anything. Parses each PE's import table via
System.Reflection.PortableExecutable (built into .NET, no
external modules required) and scans embedded ASCII/Unicode
strings for indicators that a binary can:
- host the .NET CLR and load arbitrary assemblies at runtime
- host a Windows Script Host / COM scripting engine
- compile and run code on the fly (Roslyn / CodeDom)
- accept a URL / config / project file as an execution source
- perform classic injection (relevant combined with the above)
Use it to triage System32, SysWOW64, or Program Files and
prioritize which signed binaries deserve deeper dynamic analysis
(Procmon / API Monitor) or a diff against the LOLBAS project and
Microsoft's recommended WDAC block rules.
.PARAMETER Path
File or directory to scan.
.PARAMETER Json
Emit JSON instead of a formatted table.
.EXAMPLE
.\Scan-LolbinPrimitives.ps1 -Path C:\Windows\System32
.EXAMPLE
.\Scan-LolbinPrimitives.ps1 -Path C:\Windows\System32 -Json > results.json
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$Path,
[switch]$Json
)
Add-Type -AssemblyName System.Reflection.Metadata -ErrorAction SilentlyContinue
# --- Indicator definitions --------------------------------------------------
# Each category is additive. Binaries hitting multiple categories are the
# highest-value leads for manual follow-up.
$Categories = [ordered]@{
"clr_hosting" = @{
Why = "Can host the .NET CLR and load/execute arbitrary managed assemblies at runtime."
Imports = @("mscoree.dll", "clr.dll", "CorBindToRuntimeEx", "CorBindToRuntime", "CLRCreateInstance")
Strings = @("System.Reflection.Assembly", "Assembly.Load", "Assembly.LoadFrom", "Assembly.LoadFile", "AppDomain.CreateDomain", "mscorlib")
}
"scripting_engine" = @{
Why = "Can host JScript/VBScript/COM scriptlets, enabling in-memory 'squiblydoo'-style execution."
Imports = @("scrobj.dll", "jscript.dll", "vbscript.dll")
Strings = @("scrobj.dll", "script:http", ".sct", "GetObject", "ScriptletFile")
}
"dynamic_compilation" = @{
Why = "Can compile and execute code supplied at runtime (Roslyn / CodeDom / MSBuild inline tasks)."
Imports = @("csc.exe", "vbc.exe")
Strings = @("RoslynCodeTaskFactory", "CodeTaskFactory", "Microsoft.CodeDom.Providers", "CSharpCodeProvider", "CompileAssemblyFromSource", "UsingTask")
}
"remote_or_config_driven_input" = @{
Why = "Accepts a URL, moniker, or external config/project file as an execution source (attacker-controlled input surface)."
Imports = @()
Strings = @("http://", "https://", "/i:", "InstallerAttribute", ".csproj", "moniker", "URLDownloadToFile")
}
"process_injection_primitives" = @{
Why = "Classic injection/loader APIs; relevant if combined with any category above."
Imports = @("VirtualAllocEx", "WriteProcessMemory", "CreateRemoteThread", "NtCreateThreadEx", "QueueUserAPC", "LoadLibraryA", "LoadLibraryW", "GetProcAddress")
Strings = @()
}
}
$StringMinLen = 6
function Get-EmbeddedStrings {
param([byte[]]$Bytes)
$asciiPattern = "[\x20-\x7e]{$StringMinLen,}"
$text = [System.Text.Encoding]::ASCII.GetString($Bytes)
$matches = [regex]::Matches($text, $asciiPattern)
$set = New-Object System.Collections.Generic.HashSet[string]
foreach ($m in $matches) { [void]$set.Add($m.Value) }
return $set
}
function Get-PEImports {
param([byte[]]$Bytes)
$imports = New-Object System.Collections.Generic.HashSet[string]
try {
$stream = New-Object System.IO.MemoryStream(,$Bytes)
$peReader = New-Object System.Reflection.PortableExecutable.PEReader($stream)
if (-not $peReader.HasMetadata -and $peReader.PEHeaders.PEHeader -eq $null) {
return $imports
}
# PEReader does not expose the import table directly for native PE.
# Fall back to a lightweight manual parse of the Import Directory.
$peHeaders = $peReader.PEHeaders
$importDir = $peHeaders.PEHeader.ImportTableDirectory
if ($importDir.Size -eq 0) { return $imports }
$sectionData = $peReader.GetEntireImage()
$rawBytes = $sectionData.GetContent().ToArray()
# RVA -> file offset conversion using section headers
function Convert-RvaToOffset {
param($Rva, $Sections)
foreach ($s in $Sections) {
$start = $s.VirtualAddress
$end = $start + [Math]::Max($s.VirtualSize, $s.SizeOfRawData)
if ($Rva -ge $start -and $Rva -lt $end) {
return $s.PointerToRawData + ($Rva - $start)
}
}
return -1
}
$sections = $peHeaders.SectionHeaders
$offset = Convert-RvaToOffset -Rva $importDir.RelativeVirtualAddress -Sections $sections
if ($offset -lt 0) { return $imports }
$pos = $offset
while ($true) {
if ($pos + 20 -gt $rawBytes.Length) { break }
$originalFirstThunk = [BitConverter]::ToUInt32($rawBytes, $pos)
$nameRva = [BitConverter]::ToUInt32($rawBytes, $pos + 12)
$firstThunk = [BitConverter]::ToUInt32($rawBytes, $pos + 16)
if ($originalFirstThunk -eq 0 -and $nameRva -eq 0 -and $firstThunk -eq 0) { break }
if ($nameRva -ne 0) {
$nameOffset = Convert-RvaToOffset -Rva $nameRva -Sections $sections
if ($nameOffset -ge 0) {
$len = 0
while (($nameOffset + $len) -lt $rawBytes.Length -and $rawBytes[$nameOffset + $len] -ne 0) { $len++ }
$dllName = [System.Text.Encoding]::ASCII.GetString($rawBytes, $nameOffset, $len)
if ($dllName) { [void]$imports.Add($dllName.ToLower()) }
}
}
$thunkRva = if ($originalFirstThunk -ne 0) { $originalFirstThunk } else { $firstThunk }
if ($thunkRva -ne 0) {
$thunkOffset = Convert-RvaToOffset -Rva $thunkRva -Sections $sections
$tpos = $thunkOffset
while ($tpos -ge 0 -and ($tpos + 4) -le $rawBytes.Length) {
$thunkVal = [BitConverter]::ToUInt32($rawBytes, $tpos)
if ($thunkVal -eq 0) { break }
if (($thunkVal -band 0x80000000) -eq 0) {
$hintNameOffset = Convert-RvaToOffset -Rva $thunkVal -Sections $sections
if ($hintNameOffset -ge 0) {
$fnOffset = $hintNameOffset + 2
$flen = 0
while (($fnOffset + $flen) -lt $rawBytes.Length -and $rawBytes[$fnOffset + $flen] -ne 0) { $flen++ }
if ($flen -gt 0) {
$fnName = [System.Text.Encoding]::ASCII.GetString($rawBytes, $fnOffset, $flen)
if ($fnName) { [void]$imports.Add($fnName) }
}
}
}
$tpos += 4
}
}
$pos += 20
}
$peReader.Dispose()
} catch {
# Parsing failed (packed, corrupt, or non-standard PE). Leave imports empty;
# string-based signatures will still catch most indicators.
}
return $imports
}
function Test-EmbeddedSignature {
param([byte[]]$Bytes)
try {
# Cheap heuristic: check the Security Directory entry (index 4) in the
# Optional Header data directories. Not full Authenticode validation.
$peOffset = [BitConverter]::ToInt32($Bytes, 0x3C)
$optHeaderMagicOffset = $peOffset + 24
$magic = [BitConverter]::ToUInt16($Bytes, $optHeaderMagicOffset)
$isPE32Plus = ($magic -eq 0x20B)
$dataDirOffset = if ($isPE32Plus) { $peOffset + 24 + 112 } else { $peOffset + 24 + 96 }
$secDirOffset = $dataDirOffset + (4 * 8) # Security directory is entry index 4
$secRva = [BitConverter]::ToUInt32($Bytes, $secDirOffset)
$secSize = [BitConverter]::ToUInt32($Bytes, $secDirOffset + 4)
return ($secRva -ne 0 -and $secSize -ne 0)
} catch {
return $false
}
}
function Test-Binary {
param([string]$FilePath)
try {
$bytes = [System.IO.File]::ReadAllBytes($FilePath)
} catch {
return $null
}
if ($bytes.Length -lt 2 -or $bytes[0] -ne 0x4D -or $bytes[1] -ne 0x5A) {
return $null # not "MZ", not a PE
}
$imports = Get-PEImports -Bytes $bytes
$strings = Get-EmbeddedStrings -Bytes $bytes
$signed = Test-EmbeddedSignature -Bytes $bytes
$hits = [ordered]@{}
foreach ($catName in $Categories.Keys) {
$spec = $Categories[$catName]
$matchedImports = @($spec.Imports | Where-Object {
$target = $_
$imports | Where-Object { $_ -like "*$($target.ToLower())*" }
})
$matchedStrings = @($spec.Strings | Where-Object {
$target = $_
$strings | Where-Object { $_ -like "*$target*" }
})
if ($matchedImports.Count -gt 0 -or $matchedStrings.Count -gt 0) {
$hits[$catName] = [ordered]@{
Why = $spec.Why
MatchedImports = $matchedImports
MatchedStrings = $matchedStrings
}
}
}
if ($hits.Count -eq 0) { return $null }
return [ordered]@{
Path = $FilePath
Signed = $signed
Hits = $hits
}
}
# --- Main --------------------------------------------------------------
$targets = @()
if (Test-Path $Path -PathType Leaf) {
$targets = @($Path)
} else {
$targets = Get-ChildItem -Path $Path -Recurse -Include *.exe, *.dll -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty FullName
}
$findings = @()
foreach ($t in $targets) {
$r = Test-Binary -FilePath $t
if ($r) { $findings += $r }
}
$findings = $findings | Sort-Object { $_.Hits.Count } -Descending
if ($Json) {
$findings | ConvertTo-Json -Depth 6
return
}
if ($findings.Count -eq 0) {
Write-Output "No matches."
return
}
Write-Output "Scanned target: $Path"
Write-Output "Binaries with one or more hits: $($findings.Count)"
Write-Output ""
foreach ($r in $findings) {
$flag = if ($r.Hits.Count -gt 1) { " <== MULTI-CATEGORY" } else { "" }
Write-Output "=== $($r.Path)$flag"
Write-Output " embedded signature present: $($r.Signed)"
foreach ($catName in $r.Hits.Keys) {
$detail = $r.Hits[$catName]
Write-Output " [$catName] $($detail.Why)"
if ($detail.MatchedImports.Count -gt 0) {
Write-Output " imports: $($detail.MatchedImports -join ', ')"
}
if ($detail.MatchedStrings.Count -gt 0) {
Write-Output " strings: $($detail.MatchedStrings -join ', ')"
}
}
Write-Output ""
}
Write-Output "Next steps for high-value leads (multi-category hits):"
Write-Output " 1. Diff filenames against lolbas-project.github.io to see if already documented."
Write-Output " 2. Check against Microsoft's recommended WDAC block rules."
Write-Output " 3. Run under Procmon/API Monitor with varied inputs to confirm real execution paths."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment