Generate Network Trace

Produces a native Windows network trace in etl format, downloads a signed Microsoft utility from GitHub and runs that to convert the etl file to a pcapng file that Wireshark can open.
After successful conversion the source etl trace will be deleted, leaving the converted file whose name and location will be in the output window.
Specifying output files on network shares may not work as the script needs to run as system so may not have access.
Address filters can be a comma separated list of IP addresses or resolveable names or leave blank to not filter.
Protocol filters can be a comma separated list of TCP, UDP, ICMP, IGMP or leave blank to not filter.
Ether type filters can be a comma separated list of IPv4, IPv6, ARP, etc or leave blank to not filter.
https://github.com/microsoft/etl2pcapng
Version 1.2.20
Created on 2024-10-02
Modified on 2025-01-12
Created by Guy Leech
Downloads: 9

The Script Copy Script Copied to clipboard
#requires -RunAsAdministrator

<#
.SYNOPSIS
    Use PowerShell to capture a network trace and look for TLS handshakes in the captured data

.NOTES
    https://devblogs.microsoft.com/scripting/packet-sniffing-with-powershell-getting-started/
    https://pcapng.com/

    Modification History:

    2024/09/30  Guy Leech  Script born
    2024/10/02  Guy Leech  Added filter parameters
    2024/10/03  Guy Leech  Added array splitting. Added code to get latest download. Changed code to keep etl if converter not downloaded
    2024/10/04  Guy Leech  TLS1.2 code added. Improved mechanism for getting latest download URL
    2024/10/09  Guy Leech  Workaround for trace file passed as empty string. Fixed bug where new session creation fails. Added -outputfile parameter
#>

[CmdletBinding()]

Param
(
    [int]$durationSeconds = 60 ,
    [ValidateSet('Yes','No','True','False')]
    [string]$overWrite = 'No' ,
    [string[]]$addresses ,
    [string[]]$protocols ,
    [string[]]$etherTypes ,
    [string]$traceName = 'ControlUp_trace' ,
    [string]$traceFile ,
    [string]$outputFile ,
    [int]$maxFileSizeMB = 512 ,
    [string]$fallbackURL = 'https://github.com/microsoft/etl2pcapng/releases/download/v1.11.0/etl2pcapng.exe' ,
    [string]$releasesPage = 'https://api.github.com/repos/microsoft/etl2pcapng/releases' ,
    [int]$truncationLength = 1500 ,
    [string]$expectedSignerCertificateSubjectRegex = '^CN=Microsoft Corporation,'
)

$VerbosePreference = $(if( $PSBoundParameters[ 'verbose' ] ) { $VerbosePreference } else { 'SilentlyContinue' })
$DebugPreference = $(if( $PSBoundParameters[ 'debug' ] ) { $DebugPreference } else { 'SilentlyContinue' })
$ErrorActionPreference = $(if( $PSBoundParameters[ 'erroraction' ] ) { $ErrorActionPreference } else { 'Stop' })
$ProgressPreference = 'SilentlyContinue'

[int]$outputWidth = 250
try
{
    if( ( $PSWindow = (Get-Host).UI.RawUI ) -and ( $WideDimensions = $PSWindow.BufferSize ) )
    {
        $WideDimensions.Width = $outputWidth
        $PSWindow.BufferSize = $WideDimensions
    }
}
catch
{
    ## not a showstopper, will just may not be wide enough to stop output wrapping
}

Function Resolve-RecurisveDNSName
{
    [CmdletBinding()]
    Param
    (
        [string]$Name
    )
    $resolved = $null
    $resolved = @( Resolve-DnsName -Name $Name -PipelineVariable resolvedItem | ForEach-Object -Process `
    {
        if( $resolvedItem.GetType().Name -ieq 'DnsRecord_PTR' )
        {
            Resolve-RecurisveDNSName -Name $resolvedItem.NameHost ## TODO could this go infintely recursive such that we need to record hosts already resolved?
        }
        else
        {
            $resolvedItem.Address
        }
    })
    $resolved | Sort-Object -Unique
}

$( "Script Parameters:" ; $PSBoundParameters.GetEnumerator())| Write-Verbose

## argument passing can flatten arrays so we need to reconstitute them
if( $null -ne $addresses )
{
    $addresses = @( $addresses -split ',' )
}
if( $null -ne $protocols )
{
    $protocols = @( $protocols -split ',' )
}
if( $null -ne $etherTypes )
{
    $etherTypes = @( $etherTypes -split ',' )
}
[string]$etl2pcapConverterPath = Join-Path -Path $env:Temp -ChildPath (Split-Path -Path $fallbackURL -Leaf)

Import-Module -Name NetEventPacketCapture -Verbose:$false -Debug:$false

## process early so if any errors we don't have stuff to undo/delete

$captureProvider = $null
[hashtable]$captureParameters = @{
    SessionName = $traceName
    TruncationLength = $truncationLength
}
if( $null -ne $addresses -and $addresses.Count -gt 0 )
{
    Import-Module -Name DnsClient -Verbose:$false -Debug:$false
    [string[]]$ipAddressList = @( ForEach( $address in $addresses) ## parameter passing can cause empty elements
    {
        if( -Not[string]::IsNullOrEmpty( $address ))
        {
            ## filter only takes IP addresses so look up DNS names if not an IP address
            if( -Not ( $address -as [ipaddress]))
            {
                Resolve-RecurisveDNSName -Name $address
            }
            else
            {
                $address
            }
        }
    })
    Write-Verbose -Message "IPAddresses: $($ipAddressList -join ' , ')"
    $captureParameters.Add( 'IPAddresses' , $ipAddressList )
}
if( $null -ne $etherTypes -and $ethertypes.Count -gt 0)
{
    [uint16[]]$etherTypesArray = @( ForEach( $etherType in $etherTypes)
    {
        switch ($etherType)
        {
            'IPv4' { 0x0800 }
            'IPv6' { 0x86DD }
            'ARP' { 0x0806 }
            'AppleTalk' { 0x0801 }
            'RARP' { 0x0805 }
            'MPLS unicast' { 0x8847 }
            'MPLS multicast' { 0x8848 }
            'VLAN' { 0x8100 }
            'LLDP' { 0x88CC }
            'MACsec' { 0x88E1 }
            'EAPOL' { 0x8915 }
            'PPPoE Discovery Stage' { 0x8863 }
            'PPPoE Session Stage' { 0x8864 }
            '' {}
            default { Throw "Unknown etherType $etherType" }
        }
    } )
    $captureParameters.Add( 'EtherType' , $etherTypesArray )
}
if( -Not [string]::IsNullOrEmpty( $protocols ))
{
    [byte[]]$protocolByteArray = @( ForEach( $protocol in $protocols )
    {
        switch( $protocol )
        {
            'TCP' { 6 }
            'UDP' { 17 }
            'ICMP' { 1 }
            'IGMP' { 2 }
            'IPv6 encapsulation' { 41 }
            'GRE' { 47 }
            'ESP' { 50 }
            'AH' { 51 }
            'ICMPv6' { 58 }
            'OSPF' { 89 }
            '' {}
            <#
            1: ICMP (Internet Control Message Protocol)
            2: IGMP (Internet Group Management Protocol)
            6: TCP (Transmission Control Protocol)
            17: UDP (User Datagram Protocol)
            41: IPv6 encapsulation (used in IPv6-in-IPv4 tunneling)
            47: GRE (Generic Routing Encapsulation)
            50: ESP (Encapsulating Security Payload)
            51: AH (Authentication Header)
            58: ICMPv6 (Internet Control Message Protocol for IPv6)
            89: OSPF (Open Shortest Path First)
            #>
            default { Throw "Unknown protocol $protocol" }
        }
    } )
    $captureParameters.Add( 'IPProtocols' , $protocolByteArray )
}

if( [string]::IsNullOrEmpty( $traceFile ) )
{
    $traceFile = Join-Path -Path $env:temp -ChildPath "ControlUpTrace-$([datetime]::now.Ticks)$pid.etl"
}

if( $fileProperties = Get-ItemProperty -Path $traceFile -ErrorAction SilentlyContinue)
{
    if( $fileProperties.Length -eq 0 )
    {
        Remove-Item -Path $newSession.LocalFilePath
    }
    elseif( $overWrite -notmatch 'Yes|True' )
    {
        Throw "Capture file `"$($fileProperties.FullName) already exists, is $([math]::Round( $fileProperties.Length / 1MB , 2))MB, created $($fileProperties.CreationTime.ToString('G'))"
    }
}

## try and get the latest download URL
[string]$downloadURL = $fallbackURL
try
{
    [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
    [string]$tryURL = ((Invoke-RestMethod -URI $releasesPage )|Select-Object -Property @{n='version';e={($_.tag_name -replace '^v') -as [version]}},assets|Sort-Object version -Descending|Select-Object -first 1 -ExpandProperty assets|Where-Object browser_download_url -match '\.exe$'|Select-Object -ExpandProperty browser_download_url)
    if( -Not[string]::IsNullOrEmpty( $tryURL ))
    {
        if( $tryURL -ine $fallbackURL )
        {
            Write-Verbose -Message "Trying $tryURL"
            (New-Object -TypeName System.Net.WebClient).Downloadfile( $tryURL , $etl2pcapConverterPath )
            if( $? )
            {
                Write-Verbose -Message "Downloaded newer binary from $tryURL"
                $downloadURL = $null ## so that later it won't download again
            }
        }
        else
        {
            Write-Verbose -Message "Not trying $tryURL as it is the same as the fallback URL"
        }
        ## else same URL as we already have so will use that
    }
}
catch
{
    Write-Verbose -Message "Dynamic download problem : $_"
    ## fallback to the hard coded URL
}

if( -Not [string]::IsNullOrEmpty($downloadURL))
{
    Write-Verbose -Message "$([datetime]::Now.ToString('G')): downloading from $downloadURL to $etl2pcapConverterPath"
    
    (New-Object -TypeName System.Net.WebClient).Downloadfile( $downloadURL , $etl2pcapConverterPath )
}

if( -Not $? -or $null -eq ($exeProperties = Get-ItemProperty -Path $etl2pcapConverterPath) -or $exeProperties.Length -lt 10KB)
{
    Write-Warning "Problem downloading from $downloadURL to $etl2pcapConverterPath"
}
else
{
    
    $signing = $null
    $signing = Get-AuthenticodeSignature -FilePath $etl2pcapConverterPath
    if( $null -eq $signing -or $signing.Status -ne 'Valid' )
    {
        Throw "No valid Authenticode signature found on $etl2pcapConverterPath"
    }
    if( $signing.SignerCertificate.Subject -notmatch $expectedSignerCertificateSubjectRegex )
    {
        Throw "Unexpected signer certificate subject $($signing.SignerCertificate.Subject) on $etl2pcapConverterPath"
    }
}

$newSessionError = $null
$newSession = $null
$newSession = New-NetEventSession -Name $traceName -LocalFilePath $traceFile -MaxFileSize $maxFileSizeMB -CaptureMode SaveToFile -ErrorAction SilentlyContinue -ErrorVariable newSessionError
if( -Not $? -or $null -eq $newSession )
{
    [array]$existingTraces = @( Get-NetEventSession )
    if( $null -ne $existingTraces -and $existingTraces.Count -gt 0 )
    {
        if( $existingTraces.Name -contains $traceName )
        {
            if( $overWrite -match 'Yes|True' )
            {
                Remove-NetEventSession -Name $traceName -Confirm:$false
                $newSession = New-NetEventSession -Name $traceName -LocalFilePath $traceFile -MaxFileSize $maxFileSizeMB -CaptureMode SaveToFile -ErrorAction SilentlyContinue -ErrorVariable newSessionError
                if( -Not $? -or $null -eq $newSession )
                {
                    Throw "Tracing session $traceName already existed and was removed but still failed to create new one: $newsSessionError"
                }
            }
            else
            {
                Throw "There are already $($existingTraces.Count) tracing sessions ($($existingTraces.Name -join ' , ')) - please remove them and rerun the script or use -overwrite"
            }
        }
        else
        {
            Throw "There are already $($existingTraces.Count) tracing sessions ($($existingTraces.Name -join ' , ')) - please remove them and rerun the script" ## -overwrite won't work as not traces we knowingly created
        }
    }
    else
    {
        Throw "Failed to create tracing session - $newsessionError"
    }
}

Write-Verbose "New session: $newSession"

try
{
    $( "Capture Parameters:" ; $captureParameters.GetEnumerator()|Format-Table | Out-String) | Write-Verbose
    $captureProvider = Add-NetEventPacketCaptureProvider @captureParameters
    if( -Not $? -or $null -eq $captureProvider )
    {
        Throw "Failed to add packet capture provider to session $traceName"
    }
    
    if( [string]::IsNullOrEmpty( $outputFile ) )
    {
        $outputfile = $newSession.LocalFilePath -replace '\.\w+$' , '.pcapng'
    }

    if( ( $fileProperties = Get-ItemProperty -Path $outputFile -ErrorAction SilentlyContinue) -and $fileProperties.Length -gt 0 )
    {
        ## see if open, eg Wireshark has previous trace open
        try
        {
            $fileStream = [System.IO.File]::Open($outputFile, 'Open', 'ReadWrite', 'None')
            $fileStream.Close()
            $fileStream.Dispose()
        }
        catch
        {
            Throw "Failed to open $outputFile - is it open in another application?"
        }
        if( $overWrite -notmatch 'Yes|True' )
        {
            Throw "Capture file `"$($outputFile)`" already exists, is $([math]::Round( $fileProperties.Length / 1MB , 2))MB, created $($fileProperties.CreationTime.ToString('G'))"
        }
        else
        {
            Remove-Item -Path $outputFile
        }
    }

    if( -Not ( Start-NetEventSession -Name $traceName -PassThru ))
    {
        Throw "Failed to start trace"
    }
    Write-Verbose -Message "$([datetime]::Now.ToString('G')): sleeping for $durationSeconds seconds"

    Start-Sleep -Seconds $durationSeconds

    Write-Verbose -Message "$([datetime]::Now.ToString('G')): back from sleep"

    $stoppedSession = $null
    $stoppedSession = Stop-NetEventSession -Name $traceName -PassThru
    if( $null -eq $stoppedSession )
    {
        Write-Warning "Possible problem stopping trace"
    }

    ## might have been unable to download the converter in which case we don't delete the trace file
    if( -Not (Test-Path -Path $etl2pcapConverterPath -PathType Leaf))
    {
        Write-Output -InputObject "Raw trace file `"$($newsession.LocalFilePath)`" created, size is $([math]::Round( $fileProperties.Length / 1MB , 2))MB"
        Write-Output -InputObject "Convert to pcapng file with etl2pcapng.exe utility from https://github.com/microsoft/etl2pcapng"
    }
    else
    {      
        ## not using start-process so we can get error output if required
        $processInfo = New-Object -TypeName System.Diagnostics.ProcessStartInfo
        $processInfo.FileName = $etl2pcapConverterPath
        $processInfo.Arguments = "`"$($newSession.LocalFilePath)`" `"$outputFile`""
        $processInfo.RedirectStandardError = $true
        $processInfo.RedirectStandardOutput = $true
        $processInfo.UseShellExecute = $false
        $processInfo.WindowStyle = 'Hidden'
        $processInfo.CreateNoWindow = $true
        $process = New-Object -TypeName System.Diagnostics.Process
        $process.StartInfo = $processInfo
        if( $process.Start() )
        {
            $process.WaitForExit()
            
                if( $process.ExitCode -ne 0 )
                {
                    Write-Warning -Message "$($processInfo.FileName) exited with status $($process.ExitCode)"
                    $process.StandardError.ReadToEnd()|Write-Warning
                    $process.StandardOutput.ReadToEnd()|Write-Warning
                }
        }
        else
        {
            Throw "Failed to run `"$($processInfo.FileName)`""
        }
        if( -Not ( $fileProperties = Get-ItemProperty -Path $outputFile ))
        {
            Throw "$($process.Path) failed to create capture file"
        }
        elseif( $fileProperties.Length -eq 0 )
        {
            Throw "$($process.Path) created an empty capture file"
        }

        Write-Output -InputObject "Capture file `"$($outputFile)`" created, size is $([math]::Round( $fileProperties.Length / 1MB , 2))MB"
        
        Remove-Item -Path $traceFile
        $traceFile = $null
    }
}
catch
{
    throw
}
finally
{
    if( ( $currentSession = Get-NetEventSession  -name $traceName -ErrorAction SilentlyContinue ) -and $currentSession.SessionStatus -eq 'Running' )
    {
        $stopError = $null
        $stoppedSession = Stop-NetEventSession -Name $traceName -PassThru -ErrorAction SilentlyContinue -ErrorVariable stopError
        if( $null -eq $stoppedSession)
        {
            Write-Warning "Problem stopping trace $traceName : $stopError"
        }
    }
    
    Remove-NetEventSession -Name $traceName

    if( $null -ne $etl2pcapConverterPath -and (Test-Path -Path $etl2pcapConverterPath -PathType Leaf))
    {
        ## get errror if we try to delete too soon
        [datetime]$endTime = [datetime]::Now.AddSeconds( 15 )  ## yuck, hard coding!
        do
        {
            Remove-Item -Path $etl2pcapConverterPath -ErrorAction SilentlyContinue
            if( -Not $?)
            {
                Write-Verbose -Message "$([datetime]::Now.ToString('G')): waiting for $etl2pcapConverterPath to delete"
                Start-Sleep -Seconds 1 ## yuck, hard coding!
            }
            ## else delete worked ok
        } while( (Test-Path -Path $etl2pcapConverterPath -PathType Leaf) -and [datetime]::Now -lt $endTime )
    }
}