Public/Invoke-OffsetThreatScan.ps1
|
function Invoke-OffsetThreatScan { <# .SYNOPSIS Locates the earliest content prefix that remains detected by AMSI or Microsoft Defender. .DESCRIPTION Performs a lower-bound binary search over progressively sized content prefixes. The resulting boundary is the earliest detected prefix for the selected provider and scan mode; it is not asserted to be an exact signature byte or complete malicious range. AMSI supports raw-byte and text scanning. Microsoft Defender scans temporary prefix files with remediation disabled and never changes Defender configuration or exclusions. .PARAMETER Engine Auto selects AMSI for recognized script/text extensions and Defender for other files. .PARAMETER ScanMode Text uses AmsiScanString and reports both a character boundary and the mapped source-file byte offset. RawBytes preserves byte-for-byte prefix semantics. .PARAMETER ProbeLogPath Optional path. When supplied, the per-probe audit trail (ProbeLog) is also written to this file as a JSON array, independent of the selected output mode, for attaching to a report. .EXAMPLE Invoke-OffsetThreatScan .\sample.ps1 -Engine AMSI -ScanMode Text -PassThru .EXAMPLE Invoke-OffsetThreatScan .\sample.bin -Engine Defender -RepeatCount 3 -Json #> [CmdletBinding(DefaultParameterSetName = 'Human')] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateNotNullOrEmpty()] [string]$FilePath, [ValidateSet('Auto', 'AMSI', 'Defender')] [string]$Engine = 'Auto', [ValidateSet('Auto', 'RawBytes', 'Text')] [string]$ScanMode = 'Auto', [ValidateSet('Auto', 'Default', 'UTF8', 'UTF16LE', 'UTF16BE', 'ASCII')] [string]$Encoding = 'Auto', [ValidateRange(1, 10)] [int]$RepeatCount = 2, [ValidateRange(1, 600)] [int]$TimeoutSeconds = 30, [ValidateRange(0, 4096)] [int]$ByteWindow = 64, [ValidateRange(0, 100)] [int]$ContextLines = 3, [ValidateRange(1024, 16777216)] [int]$MaxLineBytes = 1048576, [ValidateScript({ $_ -ge 1 })] [int64]$MaxScanBytes = 268435456, [switch]$Force, [switch]$NoProgress, [switch]$IncludeProviderOutput, # No [ValidateNotNullOrEmpty()]: an unbound [string] is '' (empty) under # Windows PowerShell 5.1, and the scanner closures capture this scope via # .GetNewClosure(), which rejects an empty value carrying that attribute. # The empty case is handled by the IsNullOrWhiteSpace guard before export. [string]$ProbeLogPath, [Parameter(Mandatory = $true, ParameterSetName = 'Object')] [switch]$PassThru, [Parameter(Mandatory = $true, ParameterSetName = 'Json')] [switch]$Json, [Parameter(Mandatory = $true, ParameterSetName = 'Csv')] [switch]$Csv, [Parameter(Mandatory = $true, ParameterSetName = 'CsvFile')] [string]$CsvPath, [switch]$FailOnError ) $mode = if ($PassThru) { 'Object' } elseif ($Json) { 'Json' } elseif ($Csv) { 'Csv' } elseif ($PSCmdlet.ParameterSetName -eq 'CsvFile') { 'CsvFile' } else { 'Human' } if ( $mode -eq 'CsvFile' -and [string]::IsNullOrWhiteSpace($CsvPath) ) { throw 'CsvPath cannot be null, empty, or whitespace.' } $watch = [System.Diagnostics.Stopwatch]::StartNew() $warnings = New-Object 'System.Collections.Generic.List[string]' $resolvedPath = $null $providerMetadata = $null $search = $null $inspection = $null $signatureName = $null $providerOutput = $null $selectedEngine = $Engine $selectedScanMode = $ScanMode $amsiSession = $null $temporaryDirectory = $null $sourceStream = $null $defenderCopyBuffer = $null $errorMessage = $null $fileSize = $null $initialLastWriteTimeUtc = $null $fileSha256 = $null $scanTimestampUtc = [DateTime]::UtcNow.ToString('o') $encodingInfo = $null $textContent = $null $textEncoding = $null $scalarMap = $null $detectionUtf16Index = $null $boundaryUnit = $null $defenderTimeoutSeconds = $TimeoutSeconds $convertToUtf16PrefixLengthCommand = Get-Command ` -Name ConvertTo-OIUtf16PrefixLength ` -CommandType Function ` -ErrorAction Stop $convertFromAmsiResponseCommand = Get-Command ` -Name ConvertFrom-OIAmsiResponse ` -CommandType Function ` -ErrorAction Stop $copyStreamPrefixCommand = Get-Command ` -Name Copy-OIStreamPrefix ` -CommandType Function ` -ErrorAction Stop $invokeDefenderFileScanCommand = Get-Command ` -Name Invoke-OIDefenderFileScan ` -CommandType Function ` -ErrorAction Stop try { if (-not (Test-OIIsWindows)) { throw 'Threat-provider scanning is supported only on Windows. Offset inspection remains cross-platform.' } $resolvedPath = (Resolve-Path -LiteralPath $FilePath -ErrorAction Stop).Path $item = Get-Item -LiteralPath $resolvedPath -ErrorAction Stop if ($item.PSIsContainer) { throw "Path is a directory, not a file: $resolvedPath" } $initialLastWriteTimeUtc = $item.LastWriteTimeUtc $sourceStream = [System.IO.File]::Open( $resolvedPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read ) $fileSize = [int64]$sourceStream.Length if ($fileSize -eq 0) { throw "File is empty: $resolvedPath" } if (-not $Force -and $fileSize -gt $MaxScanBytes) { throw "File size $fileSize exceeds MaxScanBytes ($MaxScanBytes). Increase the limit or use -Force after considering provider cost." } $textExtensions = @('.ps1', '.psm1', '.psd1', '.vbs', '.vbe', '.js', '.jse', '.wsf', '.wsh', '.hta', '.cmd', '.bat', '.txt') $extension = [System.IO.Path]::GetExtension($resolvedPath).ToLowerInvariant() $isRecognizedText = $textExtensions -contains $extension if ($selectedEngine -eq 'Auto') { $selectedEngine = if ($isRecognizedText) { 'AMSI' } else { 'Defender' } } if ($selectedScanMode -eq 'Auto') { $selectedScanMode = if ($selectedEngine -eq 'AMSI' -and $isRecognizedText) { 'Text' } else { 'RawBytes' } } if ($selectedEngine -eq 'Defender' -and $selectedScanMode -ne 'RawBytes') { throw 'Microsoft Defender supports RawBytes prefix scanning only. Use AMSI for Text mode.' } if ($selectedEngine -eq 'AMSI') { if ($fileSize -gt [int]::MaxValue) { throw 'AMSI scanning currently supports files up to 2 GB because scan content is represented by managed arrays or strings.' } $fileSha256 = Get-OIStreamSha256 -Stream $sourceStream $providerMetadata = Get-OIAmsiProviderMetadata Initialize-OIAmsiInterop try { $amsiSession = New-Object ` OffsetInspect.Interop.AmsiSession('OffsetInspect/2.0') } catch { $providerError = $_.Exception.Message if ( $null -ne $_.Exception.InnerException -and -not [string]::IsNullOrWhiteSpace( $_.Exception.InnerException.Message ) ) { $providerError = $_.Exception.InnerException.Message } throw ( 'AMSI initialization failed. No active AMSI-capable ' + 'antimalware provider may be available. Test with an ' + 'enabled provider in an isolated Windows environment. ' + "Provider error: $providerError" ) } if ($selectedScanMode -eq 'Text') { $encodingInfo = Resolve-OIEncoding -Stream $sourceStream -Name $Encoding $textEncoding = Get-OIStrictEncoding -Encoding $encodingInfo.Encoding $allBytes = Read-OIFileRange -Stream $sourceStream -Start 0 -Length ([int]$fileSize) $contentOffset = [int]$encodingInfo.PreambleLength $contentLength = $allBytes.Length - $contentOffset try { $textContent = $textEncoding.GetString($allBytes, $contentOffset, $contentLength) } catch { throw "Text decoding failed with the selected encoding. Use the correct encoding or RawBytes mode. $($_.Exception.Message)" } if ($textContent.IndexOf([char]0) -ge 0) { throw 'Text mode does not accept embedded NUL characters because AmsiScanString uses null-terminated text semantics. Use RawBytes mode.' } $scalarMap = New-OIUnicodeScalarMap -Text $textContent $unitCount = [int64]$scalarMap.UnicodeScalarCount if ($unitCount -eq 0) { throw 'The decoded text content is empty.' } $scanner = { param([int64]$PrefixLength) if ($PrefixLength -eq 0) { return [pscustomobject]@{ Status = 'Clean' ProviderResult = 0 HResult = '0x00000000' SignatureName = $null Message = 'Synthetic AMSI empty-prefix baseline.' RawOutput = $null } } $utf16PrefixLength = & $convertToUtf16PrefixLengthCommand -ScalarMap $scalarMap -UnicodeScalarPrefixLength $PrefixLength $prefix = if ($utf16PrefixLength -eq 0) { [string]::Empty } else { $textContent.Substring(0, $utf16PrefixLength) } $response = $amsiSession.ScanString($prefix, $resolvedPath) & $convertFromAmsiResponseCommand -Response $response }.GetNewClosure() } else { $allBytes = Read-OIFileRange -Stream $sourceStream -Start 0 -Length ([int]$fileSize) $unitCount = [int64]$allBytes.Length $scanner = { param([int64]$PrefixLength) if ($PrefixLength -eq 0) { return [pscustomobject]@{ Status = 'Clean' ProviderResult = 0 HResult = '0x00000000' SignatureName = $null Message = 'Synthetic AMSI empty-prefix baseline.' RawOutput = $null } } $response = $amsiSession.ScanBytePrefix($allBytes, [int]$PrefixLength, $resolvedPath) & $convertFromAmsiResponseCommand -Response $response }.GetNewClosure() } } else { $fileSha256 = Get-OIStreamSha256 -Stream $sourceStream $commandPath = Get-OIDefenderCommandPath $providerMetadata = Get-OIDefenderProviderMetadata -CommandPath $commandPath $antivirusEnabledProperty = ` $providerMetadata.PSObject.Properties['AntivirusEnabled'] if ( $null -ne $antivirusEnabledProperty -and $providerMetadata.AntivirusEnabled -eq $false ) { throw ( 'Microsoft Defender Antivirus is disabled or operating ' + 'in passive mode. Use an isolated Windows test system ' + 'where Microsoft Defender Antivirus is enabled.' ) } $unitCount = $fileSize $temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ('OffsetInspect-' + [guid]::NewGuid().ToString('N')) $null = New-Item -ItemType Directory -Path $temporaryDirectory -ErrorAction Stop $temporaryExtension = if ($extension -and $extension.Length -le 32) { $extension } else { '.bin' } if ($extension -and $temporaryExtension -eq '.bin') { $warnings.Add('The original extension exceeded 32 characters; Defender prefixes use the .bin extension.') } $temporaryFile = Join-Path $temporaryDirectory ('prefix' + $temporaryExtension) $defenderCopyBuffer = New-Object byte[] (1024 * 1024) $scanner = { param([int64]$PrefixLength) if ($PrefixLength -eq 0) { return [pscustomobject]@{ Status = 'Clean' ProviderResult = $null HResult = $null SignatureName = $null Message = 'Synthetic empty-prefix baseline.' RawOutput = $null ExitCode = $null } } & $copyStreamPrefixCommand -SourceStream $sourceStream -DestinationPath $temporaryFile -Length $PrefixLength -Buffer $defenderCopyBuffer & $invokeDefenderFileScanCommand -CommandPath $commandPath -FilePath $temporaryFile -TimeoutSeconds $defenderTimeoutSeconds }.GetNewClosure() } $search = Invoke-OIPrefixBoundarySearch ` -UnitCount $unitCount ` -Scanner $scanner ` -RepeatCount $RepeatCount ` -Activity "OffsetInspect $selectedEngine boundary search" ` -NoProgress:$NoProgress if (-not $search.Success) { throw $search.Error } $initialStatus = $search.InitialScan.Status if ($search.InitialScan.SignatureName) { $signatureName = $search.InitialScan.SignatureName } if ($IncludeProviderOutput) { $providerOutput = $search.InitialScan.RawOutput } $detectionPrefixLength = $search.KnownDetected $knownCleanPrefixLength = $search.KnownClean $boundaryOffset = $null $boundaryHex = $null $characterBoundary = $null $boundaryUnit = if ($selectedScanMode -eq 'Text') { 'UnicodeScalar' } else { 'Byte' } if ($null -ne $detectionPrefixLength) { if ($selectedScanMode -eq 'Text') { $characterBoundary = [int64]$detectionPrefixLength - 1 $utf16PrefixLength = & $convertToUtf16PrefixLengthCommand -ScalarMap $scalarMap -UnicodeScalarPrefixLength $detectionPrefixLength $detectionUtf16Index = [int64]$utf16PrefixLength - 1 $detectedText = $textContent.Substring(0, $utf16PrefixLength) $byteCount = [int64]$textEncoding.GetByteCount($detectedText) + [int64]$encodingInfo.PreambleLength $boundaryOffset = [Math]::Max([int64]0, $byteCount - 1) $warnings.Add('Text-mode boundary search uses Unicode scalar prefixes; the reported byte offset is mapped through the validated source encoding.') } else { $boundaryOffset = [int64]$detectionPrefixLength - 1 } $boundaryHex = Format-OIOffsetHex -Value $boundaryOffset $inspectionEncodingInfo = if ($null -ne $encodingInfo) { $encodingInfo } else { Resolve-OIEncoding -Stream $sourceStream -Name $Encoding } $inspectionContext = [pscustomobject]@{ Path = $resolvedPath Length = $fileSize Stream = $sourceStream EncodingInfo = $inspectionEncodingInfo } $inspection = Get-OISingleInspectionFromContext ` -Context $inspectionContext ` -Offset $boundaryOffset ` -ByteWindow $ByteWindow ` -ContextLines $ContextLines ` -MaxLineBytes $MaxLineBytes $warnings.Add('The boundary is the earliest tested prefix that remained detected; it is not necessarily an exact signature byte or the full malicious range.') $warnings.Add('The binary search assumes a monotonic negative-to-positive prefix transition. Context-sensitive or non-monotonic detections require manual validation.') if (-not $search.Stable) { $warnings.Add('Repeated scans around the boundary were inconsistent. Treat the result as low-confidence and retest in an isolated environment.') } if ($initialStatus -eq 'Blocked') { $warnings.Add('AMSI classified the full content as blocked by administrator policy rather than malware-detected.') } foreach ($check in @($search.HighChecks)) { if (-not $signatureName -and $check.SignatureName) { $signatureName = $check.SignatureName } } } try { $finalItem = Get-Item -LiteralPath $resolvedPath -ErrorAction Stop if ([int64]$finalItem.Length -ne $fileSize -or $finalItem.LastWriteTimeUtc -ne $initialLastWriteTimeUtc) { $warnings.Add('The source file metadata changed during scanning. Treat the hash and boundary as potentially inconsistent and rescan a stable copy.') } } catch { $warnings.Add("The source file could not be revalidated after scanning: $($_.Exception.Message)") } $boundaryValidation = [pscustomobject]@{ FullContentStatuses = @($search.FullChecks | ForEach-Object { $_.Status }) KnownCleanStatuses = @($search.LowChecks | ForEach-Object { $_.Status }) KnownDetectedStatuses = @($search.HighChecks | ForEach-Object { $_.Status }) } $watch.Stop() $result = [pscustomobject]@{ Success = $true File = $resolvedPath FileSize = $fileSize FileSha256 = $fileSha256 ScanTimestampUtc = $scanTimestampUtc Engine = $selectedEngine ScanMode = $selectedScanMode BoundaryUnit = $boundaryUnit SearchModel = 'MonotonicPrefixTransition' Encoding = if ($null -ne $encodingInfo) { $encodingInfo.DetectedName } else { $null } InitialStatus = $initialStatus DetectionPrefixLength = $detectionPrefixLength DetectionBoundaryOffset = $boundaryOffset DetectionBoundaryHex = $boundaryHex DetectionCharacterIndex = $characterBoundary DetectionUtf16CodeUnitIndex = $detectionUtf16Index KnownCleanPrefixLength = $knownCleanPrefixLength Stable = $search.Stable Confidence = $search.Confidence ScanCount = $search.ScanCount SignatureName = $signatureName ProviderResult = $search.InitialScan.ProviderResult ProviderHResult = $search.InitialScan.HResult ProviderMetadata = $providerMetadata BoundaryValidation = $boundaryValidation ProviderOutput = $providerOutput ProbeLog = if ($null -ne $search -and $null -ne $search.PSObject.Properties['ProbeLog']) { $search.ProbeLog } else { @() } Inspection = $inspection DurationMs = [Math]::Round($watch.Elapsed.TotalMilliseconds, 3) Warnings = $warnings.ToArray() Error = $null } $result.PSObject.TypeNames.Insert(0, 'OffsetInspect.ThreatScanResult') } catch { $watch.Stop() $errorMessage = $_.Exception.Message if ($IncludeProviderOutput -and $null -ne $search -and $null -ne $search.InitialScan) { $providerOutput = $search.InitialScan.RawOutput } $result = [pscustomobject]@{ Success = $false File = if ($resolvedPath) { $resolvedPath } else { $FilePath } FileSize = $fileSize FileSha256 = $fileSha256 ScanTimestampUtc = $scanTimestampUtc Engine = $selectedEngine ScanMode = $selectedScanMode BoundaryUnit = $boundaryUnit SearchModel = 'MonotonicPrefixTransition' Encoding = if ($null -ne $encodingInfo) { $encodingInfo.DetectedName } else { $null } InitialStatus = if ($null -ne $search -and $null -ne $search.InitialScan) { $search.InitialScan.Status } else { $null } DetectionPrefixLength = $null DetectionBoundaryOffset = $null DetectionBoundaryHex = $null DetectionCharacterIndex = $null DetectionUtf16CodeUnitIndex = $null KnownCleanPrefixLength = if ($null -ne $search) { $search.KnownClean } else { $null } Stable = $false Confidence = 'None' ScanCount = if ($null -ne $search) { $search.ScanCount } else { 0 } SignatureName = if ($null -ne $search -and $null -ne $search.InitialScan) { $search.InitialScan.SignatureName } else { $null } ProviderResult = if ($null -ne $search -and $null -ne $search.InitialScan) { $search.InitialScan.ProviderResult } else { $null } ProviderHResult = if ($null -ne $search -and $null -ne $search.InitialScan) { $search.InitialScan.HResult } else { $null } ProviderMetadata = $providerMetadata BoundaryValidation = if ($null -ne $search) { [pscustomobject]@{ FullContentStatuses = @($search.FullChecks | ForEach-Object { $_.Status }) KnownCleanStatuses = @($search.LowChecks | ForEach-Object { $_.Status }) KnownDetectedStatuses = @($search.HighChecks | ForEach-Object { $_.Status }) } } else { $null } ProviderOutput = $providerOutput ProbeLog = if ($null -ne $search -and $null -ne $search.PSObject.Properties['ProbeLog']) { $search.ProbeLog } else { @() } Inspection = $null DurationMs = [Math]::Round($watch.Elapsed.TotalMilliseconds, 3) Warnings = $warnings.ToArray() Error = $errorMessage } $result.PSObject.TypeNames.Insert(0, 'OffsetInspect.ThreatScanResult') } finally { if ($null -ne $amsiSession) { try { $amsiSession.Dispose() } catch { Write-Verbose "AMSI session cleanup failed: $($_.Exception.Message)" } } if ($null -ne $sourceStream) { try { $sourceStream.Dispose() } catch { Write-Verbose "Source-stream cleanup failed: $($_.Exception.Message)" } } if ($temporaryDirectory -and (Test-Path -LiteralPath $temporaryDirectory)) { try { Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction Stop } catch { Write-Warning "Unable to remove temporary scan directory '$temporaryDirectory': $($_.Exception.Message)" } } } if (-not [string]::IsNullOrWhiteSpace($ProbeLogPath)) { $exportedProbeLogPath = Export-OIProbeLog -ProbeLog $result.ProbeLog -Path $ProbeLogPath Write-Verbose "Probe log written to $exportedProbeLogPath" } $output = Write-OIThreatOutput -Result $result -Mode $mode -CsvPath $CsvPath if ($null -ne $output) { Write-Output $output } if ($FailOnError -and -not $result.Success) { throw "Offset threat scan failed: $($result.Error)" } } |