Skip to content

Instantly share code, notes, and snippets.

@tylerapplebaum
Last active August 7, 2024 15:37
Show Gist options
  • Save tylerapplebaum/dc527a3bd875f11871e2 to your computer and use it in GitHub Desktop.
Save tylerapplebaum/dc527a3bd875f11871e2 to your computer and use it in GitHub Desktop.
MTR for Powershell
<#
.SYNOPSIS
An MTR clone for PowerShell.
Written by Tyler Applebaum.
Version 2.1
.LINK
https://gist.github.com/tylerapplebaum/dc527a3bd875f11871e2
http://www.team-cymru.org/IP-ASN-mapping.html#dns
.DESCRIPTION
Runs a traceroute to a specified target; sends ICMP packets to each hop to measure loss and latency.
Big shout out to Team Cymru for the ASN resolution.
Thanks to DrDrrae for a bugfix on PowerShell v5
.PARAMETER Target
Input must be in the form of an IP address or FQDN. Should be compatible with most TLDs.
.PARAMETER PingCycles
Specifies the number of ICMP packets to send per hop. Default is 10.
.PARAMETER DNSServer
An optional parameter to specify a different DNS server than configured on your network adapter.
.INPUTS
System.String, System.Int32
.OUTPUTS
PSObject containing the traceroute results. Also saves a file to the desktop.
.EXAMPLE
PS C:\> Get-Traceroute 8.8.4.4 -b 512
Runs a traceroute to 8.8.4.4 with 512-byte ICMP packets.
.EXAMPLE
PS C:\> Get-Traceroute amazon.com -s 75.75.75.75 -f amazon.com -h 45
Runs a traceroute to amazon.com using 75.75.75.75 as the DNS resolver and saves the output as amazon.com.txt.
Increase max hop count from default of 30 to 45.
#>
#Requires -version 4
[CmdletBinding()]
param(
[Parameter(Mandatory=$True,ValueFromPipeline=$True)]
[String]$Target,
[Parameter(ValueFromPipeline)]
[Alias("c")]
[ValidateRange(5,100)]
[int]$PingCycles = 10, #Default to 10 pings per hop; minimum of 5, maximum of 100
[Parameter(ValueFromPipeline)]
[Alias("b")]
[ValidateRange(32,1000)]
[int]$BufLen = 32, #Default to 32 bytes of data in the ICMP packet, maximum of 1000 bytes
[Parameter(ValueFromPipeline)]
[Alias("s")]
[IPAddress]$DNSServer = $Null,
[Parameter(ValueFromPipeline)]
[Alias("h")]
[ValidateRange(30,120)]
[Int]$Hops = 60,
[Parameter(ValueFromPipeline)]
[Alias("f")]
[String]$Filename = "Traceroute_$Target"
)
Function script:Set-Variables {
$PerTraceArr = @()
$script:ASNOwnerArr = @()
$ASNOwnerObj = New-Object PSObject
$ASNOwnerObj | Add-Member NoteProperty "ASN"("AS0")
$ASNOwnerObj | Add-Member NoteProperty "ASN Owner"("EvilCorp")
$ASNOwnerArr += $ASNOwnerObj #Add some values so the array isn't empty when first checked.
$script:i = 0
$script:x = 0
$script:z = 0
$script:WHOIS = ".origin.asn.cymru.com"
$script:ASNWHOIS = ".asn.cymru.com"
} #End Set-Variables
Function script:Set-WindowSize {
$Window = $Host.UI.RawUI
If ($Window.BufferSize.Width -lt 175 -OR $Window.WindowSize.Width -lt 175) {
$NewSize = $Window.BufferSize
$NewSize.Height = 3000
$NewSize.Width = 175
$Window.BufferSize = $NewSize
$NewSize = $Window.WindowSize
$NewSize.Height = 50
$NewSize.Width = 175
$Window.WindowSize = $NewSize
}
} #End Set-WindowSize
Function script:Get-Traceroute {
$script:TraceResults = Test-NetConnection $Target -Hops $Hops -InformationLevel Detailed -TraceRoute | Select -ExpandProperty TraceRoute
} #End Get-Traceroute
Function script:Resolve-ASN {
$HopASN = $null #Reset to null each time
$HopASNRecord = $null #Reset to null each time
If ($Hop -notlike "TimedOut" -AND $Hop -notmatch "^(?:10|127|172\.(?:1[6-9]|2[0-9]|3[01])|192\.168)\..*") { #Don't waste a lookup on RFC1918 IPs
$HopSplit = $Hop.Split('.')
$HopRev = $HopSplit[3] + '.' + $HopSplit[2] + '.' + $HopSplit[1] + '.' + $HopSplit[0]
$HopASNRecord = Resolve-DnsName -Server $DNSServer -Type TXT -Name $HopRev$WHOIS -ErrorAction SilentlyContinue | Select Strings
}
Else {
$HopASNRecord = $null
}
If ($HopASNRecord.Strings -AND $HopASNRecord.Strings.GetType().IsArray){ #Check for array;
$HopASN = "AS"+$HopASNRecord.Strings[0].Split('|').Trim()[0]
Write-Verbose "Object found $HopASN"
}
ElseIf ($HopASNRecord.Strings -AND $HopASNRecord.Strings.GetType().FullName -like "System.String"){ #Check for string; normal case.
$HopASN = "AS"+$HopASNRecord.Strings[0].Split('|').Trim()[0]
Write-Verbose "String found $HopASN"
}
Else {
$HopASN = "-"
}
} #End Resolve-ASN
Function script:Resolve-ASNOwner {
If ($HopASN -notlike "-") {
$IndexNo = $ASNOwnerArr.ASN.IndexOf($HopASN)
Write-Verbose "Current object: $ASNOwnerObj"
If (!($ASNOwnerArr.ASN.Contains($HopASN)) -OR ($ASNOwnerArr."ASN Owner"[$IndexNo].Contains('-'))){ #Keep "ASNOwnerArr.ASN" in double quotes so it will be treated as a string and not an object
Write-Verbose "ASN $HopASN not previously resolved; performing lookup" #Check the previous lookups before running this unnecessarily
$HopASNOwner = Resolve-DnsName -Server $DNSServer -Type TXT -Name $HopASN$ASNWHOIS -ErrorAction SilentlyContinue | Select Strings
If ($HopASNOwner.Strings -AND $HopASNOwner.Strings.GetType().IsArray){ #Check for array;
$HopASNOwner = $HopASNOwner.Strings[0].Split('|').Trim()[4].Split('-')[0]
Write-Verbose "Object found $HopASNOwner"
}
ElseIf ($HopASNRecord.Strings -AND $HopASNRecord.Strings.GetType().FullName -like "System.String"){ #Check for string; normal case.
$HopASNOwner = $HopASNOwner.Strings[0].Split('|').Trim()[4].Split('-')[0]
Write-Verbose "String found $HopASNOwner"
}
Else {
$HopASNOwner = "-"
}
$ASNOwnerObj | Add-Member NoteProperty "ASN"($HopASN) -Force
$ASNOwnerObj | Add-Member NoteProperty "ASN Owner"($HopASNOwner) -Force
$ASNOwnerArr += $ASNOwnerObj #Add our new value to the cache
}
Else { #We get to use a cached entry and save Team Cymru some lookups
Write-Verbose "ASN Owner found in cache"
$HopASNOwner = $ASNOwnerArr[$IndexNo]."ASN Owner"
}
}
Else {
$HopASNOwner = "-"
Write-Verbose "ASN Owner lookup not performed - RFC1918 IP found or hop TimedOut"
}
} #End Resolve-ASNOwner
Function script:Resolve-DNS {
$HopNameArr = $null
$script:HopName = New-Object psobject
If ($Hop -notlike "TimedOut" -and $Hop -notlike "0.0.0.0") {
$z++ #Increment the count for the progress bar
$script:HopNameArr = Resolve-DnsName -Server $DNSServer -Type PTR $Hop -ErrorAction SilentlyContinue | Select NameHost
Write-Verbose "Hop = $Hop"
If ($HopNameArr.NameHost -AND $HopNameArr.NameHost.GetType().IsArray) { #Check for array first; sometimes resolvers are stupid and return NS records with the PTR in an array.
$script:HopName | Add-Member -MemberType NoteProperty -Name NameHost -Value $HopNameArr.NameHost[0] #If Resolve-DNS brings back an array containing NS records, select just the PTR
Write-Verbose "Object found $HopName"
}
ElseIf ($HopNameArr.NameHost -AND $HopNameArr.NameHost.GetType().FullName -like "System.String") { #Normal case. One PTR record. Will break up an array of multiple PTRs separated with a comma.
$script:HopName | Add-Member -MemberType NoteProperty -Name NameHost -Value $HopNameArr.NameHost.Split(',')[0].Trim() #In the case of multiple PTRs select the first one
Write-Verbose "String found $HopName"
}
ElseIf ($HopNameArr.NameHost -like $null) { #Check for null last because when an array is returned with PTR and NS records, it contains null values.
$script:HopName | Add-Member -MemberType NoteProperty -Name NameHost -Value $Hop #If there's no PTR record, set name equal to IP
Write-Verbose "HopNameArr apparently empty for $HopName"
}
Write-Progress -Activity "Resolving PTR Record" -Status "Looking up $Hop, Hop #$z of $($TraceResults.length)" -PercentComplete ($z / $($TraceResults.length)*100)
}
Else {
$z++
$script:HopName | Add-Member -MemberType NoteProperty -Name NameHost -Value $Hop #If the hop times out, set name equal to TimedOut
Write-Verbose "Hop = $Hop"
}
} #End Resolve-DNS
Function script:Get-PerHopRTT {
$PerHopRTTArr = @() #Store all RTT values per hop
$SAPSObj = $null #Clear the array each cycle
$SendICMP = New-Object System.Net.NetworkInformation.Ping
$i++ #Advance the count
$x = 0 #Reset x for the next hop count. X tracks packet loss percentage.
$BufferData = "a" * $BufLen #Send the UTF-8 letter "a"
$ByteArr = [Text.Encoding]::UTF8.GetBytes($BufferData)
If ($Hop -notlike "TimedOut" -and $Hop -notlike "0.0.0.0" -and $Hop -notlike "::") { #Normal case, attempt to ping hop
For ($y = 1; $y -le $PingCycles; $y++){
$HopResults = $SendICMP.Send($Hop,1000,$ByteArr) #Send the packet with a 1 second timeout
$HopRTT = $HopResults.RoundtripTime
$PerHopRTTArr += $HopRTT #Add RTT to HopRTT array
If ($HopRTT -eq 0) {
$x = $x + 1
}
Write-Progress -Activity "Testing Packet Loss to Hop #$z of $($TraceResults.length)" -Status "Sending ICMP Packet $y of $PingCycles to $Hop - Result: $HopRTT ms" -PercentComplete ($y / $PingCycles*100)
} #End for loop
$PerHopRTTArr = $PerHopRTTArr | Where-Object {$_ -gt 0} #Remove zeros from the array
$HopRTTMin = "{0:N0}" -f ($PerHopRTTArr | Measure-Object -Minimum).Minimum
$HopRTTMax = "{0:N0}" -f ($PerHopRTTArr | Measure-Object -Maximum).Maximum
$HopRTTAvg = "{0:N0}" -f ($PerHopRTTArr | Measure-Object -Average).Average
$HopLoss = "{0:N1}" -f (($x / $PingCycles) * 100) + "`%"
$HopText = [string]$HopRTT + "ms"
If ($HopLoss -like "*100*") { #100% loss, but name resolves
$HopResults = $null
$HopRTT = $null
$HopText = $null
$HopRTTAvg = "-"
$HopRTTMin = "-"
$HopRTTMax = "-"
}
} #End main ping loop
Else { #Hop TimedOut - no ping attempted
$HopResults = $null
$HopRTT = $null
$HopText = $null
$HopLoss = "100.0%"
$HopRTTAvg = "-"
$HopRTTMin = "-"
$HopRTTMax = "-"
} #End TimedOut condition
$script:SAPSObj = [PSCustomObject]@{
"Hop" = $i
"Hop Name" = $HopName.NameHost
"ASN" = $HopASN
"ASN Owner" = $HopASNOwner
"`% Loss" = $HopLoss
"Hop IP" = $Hop
"Avg RTT" = $HopRTTAvg
"Min RTT" = $HopRTTMin
"Max RTT" = $HopRTTMax
}
$PerTraceArr += $SAPSObj #Add the object to the array
} #End Get-PerHopRTT
. Set-Variables
. Set-WindowSize
. Get-Traceroute
ForEach ($Hop in $TraceResults) {
. Resolve-ASN
. Resolve-ASNOwner
. Resolve-DNS
. Get-PerHopRTT
}
$PerTraceArr | Format-Table -Autosize
$PerTraceArr | Format-Table -Autosize | Out-File $env:UserProfile\Desktop\$Filename.txt -encoding UTF8
@tylerapplebaum
Copy link
Author

Added some Write-Verbose lines for debugging.

In some cases, DNS resolvers will return some extra garbage in the record, causing it to be interpreted as an object instead of a string. This is now accounted for with some conditional statements when resolving DNS records.

@tylerapplebaum
Copy link
Author

Next step is to convert Get-PerHopRTT to a job that runs in parallel. Spawn one job for each hop so that the traceroute finishes faster.

@tylerapplebaum
Copy link
Author

Added ICMP payload parameter (-b)

@tylerapplebaum
Copy link
Author

Added REGEX to prevent ASN lookups for RFC1918 IP addresses.

@tylerapplebaum
Copy link
Author

Feature requests:

  • Parameterize output filename
  • Add ASN Owner to output (requires another DNS query to Cymru)
  • Remove regex for IP/domain validation (it is unnecessary)

@tylerapplebaum
Copy link
Author

tylerapplebaum commented Jan 4, 2017

Features above added! Output now looks like below:

PS C:\> Get-Traceroute.ps1 github.com

Hop Hop Name                               ASN     ASN Owner  % Loss Hop IP         Avg RTT Min RTT Max RTT
--- --------                               ---     ---------  ------ ------         ------- ------- -------
  1 vlan255-switch01.supersecretdomain.org -       -          0.0%   172.31.255.1   3       2       4
  2 v106-switch02.supersecretdomain.org    -       -          0.0%   10.98.0.3      3       3       4
  3 switch-edge.otherdomain.org            AS00000 ECORP      0.0%   85.95.202.17   24      4       117
  4 rtr-inet1.otherdomain.org              AS00000 ECORP      0.0%   85.95.202.1    3       3       4
  5 rtr-inet2.otherdomain.org              AS00000 ECORP      0.0%   85.95.202.3    4       3       4
  6 v224.core0.pdx2.he.net                 AS6939  HURRICANE  0.0%   65.60.226.224  9       3       18
  7 100ge14-1.core1.sea1.he.net            AS6939  HURRICANE  0.0%   184.105.64.137 17      7       66
  8 206.111.7.69.ptr.us.xo.net             AS2828  XO         0.0%   206.111.7.69   8       7       9
  9 vb2000d1.rar3.seattle-wa.us.xo.net     AS2828  XO         0.0%   207.88.13.142  9       7       11
 10 ae0.rcb1.saltlake2-ut.us.xo.net        AS2828  XO         100.0% 207.88.12.77   -       -       -
 11 207.88.12.144.ptr.us.xo.net            AS2828  XO         100.0% 207.88.12.144  -       -       -
 12 207.88.12.190.ptr.us.xo.net            AS2828  XO         100.0% 207.88.12.190  -       -       -
 13 te0-12-0-0.rar3.sanjose-ca.us.xo.net   AS2828  XO         100.0% 207.88.12.189  -       -       -
 14 207.88.12.164.ptr.us.xo.net            AS2828  XO         100.0% 207.88.12.164  -       -       -
 15 207.88.12.213.ptr.us.xo.net            AS2828  XO         100.0% 207.88.12.213  -       -       -
 16 207.88.12.214.ptr.us.xo.net            AS2828  XO         100.0% 207.88.12.214  -       -       -
 17 207.88.14.191.ptr.us.xo.net            AS2828  XO         0.0%   207.88.14.191  75      75      77
 18 209.48.43.106                          AS2828  XO         100.0% 209.48.43.106  -       -       -
 19 TimedOut                               -       -          100.0% TimedOut       -       -       -
 20 TimedOut                               -       -          100.0% TimedOut       -       -       -
 21 192.30.253.112                         AS36459 GITHUB     0.0%   192.30.253.112 74      74      74

@tylerapplebaum
Copy link
Author

An important fact pointed out by a dedicated user:

Get-Traceroute.ps1 requires PS 4.0 under Windows 8 or higher

An untested workaround by Anton Krouglov here: https://stackoverflow.com/questions/21252824/how-do-i-get-powershell-4-cmdlets-such-as-test-netconnection-to-work-on-windows may be of interest.

@tylerapplebaum
Copy link
Author

tylerapplebaum commented Nov 12, 2017

Going to either fork or update this to be Win7 / PowerShellv2 compatible.

Starting by eliminating Test-NetConnection and writing my own traceroute implementation in .Net.

  $Target = "github.com"
  $TraceArr = @() #Store all RTT values per hop
  $SendICMP = New-Object System.Net.NetworkInformation.Ping
  $BufferData = "a" * $BufLen #Send the UTF-8 letter "a"
  $ByteArr = [Text.Encoding]::UTF8.GetBytes($BufferData)
  $PingOptions = New-Object System.Net.NetworkInformation.PingOptions
  $z = 1
  
  Do {
    $PingOptions.TTL = $z
    $ICMPResults = $SendICMP.Send($Target,1000,$ByteArr,$PingOptions)
    Write-Output $ICMPResults.Address.IPAddressToString
    Write-Output $ICMPResults.Status
    $TraceArr += $ICMPResults.Address.IPAddressToString
    $z++
    Write-Host $z
  }
  Until ($ICMPResults.Status -eq "Success")
  
  Write-Output $TraceArr

@tylerapplebaum
Copy link
Author

tylerapplebaum commented Nov 14, 2017

Resolving DNS records in a timely manner is not easy. New function for the PSv2 compatible fork -

Function script:Resolve-DNS {

[CmdletBinding()]
  param(
    [Parameter(ValueFromPipeline=$True)]
    [Int]$Timeout = 15
  )

$ResolveDNSSource = @"
//https://web.archive.org/web/20110207162807/http://www.codekeep.net/snippets/de6ba175-0dad-41e3-8f8f-9d652572329c.aspx
using System;
using System.Net;

public class ResolveDNS {

	public delegate IPHostEntry GetHostEntryHandler(string ip);
	public string ReverseDNS(string ip, int timeout)
	{
		try
		{
			GetHostEntryHandler callback = new GetHostEntryHandler(Dns.GetHostEntry);
			IAsyncResult result = callback.BeginInvoke(ip, null, null);
			if (result.AsyncWaitHandle.WaitOne(timeout * 1000, false))
			{
				// Received response within timeout limit
				return callback.EndInvoke(result).HostName;
			}
			else
			{
				// Did not receive response within timeout limit, 
				// send back IP Address instead of hostname
				return ip;
			}
		}
		catch (Exception)
		{
			// Error occurred, send back IP Address instead of hostname
			return ip;
		}
	}

}

"@

Add-Type -TypeDefinition $ResolveDNSSource

$Resolver = New-Object ResolveDNS

  If ($Hop -notlike "TimedOut" -and $Hop -notlike "0.0.0.0") {
	$z++ #Increment the count for the progress bar
	Write-Progress -Activity "Resolving PTR Record" -Status "Looking up $Hop, Hop #$z of $($TraceResults.length)" -PercentComplete ($z / $($TraceResults.length)*100)
	$HopName = $Resolver.ReverseDNS($Hop,$Timeout)
	Write-Verbose "HopName = $HopName"
	Write-Verbose "Hop = $Hop"
  }

  Else {
    $z++
    $HopName = $Hop #If the hop times out, set name equal to TimedOut
    Write-Verbose "Hop = $Hop"
  }
} #End Resolve-DNS

Output -

image

@dexit
Copy link

dexit commented Oct 5, 2018

Hi, is it possible to get the every sent packet as a row in a table with timestamp as first column and the HOPs are the column headers ?

@tylerapplebaum
Copy link
Author

Hey, @dexit. Maybe so. I will take a look at that.

@tylerapplebaum
Copy link
Author

@dexit can you provide a mockup of what exactly you're looking for?

@cosmicle0
Copy link

It seems like this script doesn't have IPv6 support?

@tylerapplebaum
Copy link
Author

@catcosmicice - correct. I'm happy to take PRs and give contributors credit!

@cosmicle0
Copy link

@catcosmicice - correct. I'm happy to take PRs and give contributors credit!

I'd be glad to but I don't really know PowerShell nor Networking 😅

@tylerapplebaum
Copy link
Author

Did a tiny bit of work to get IPv6 working more smoothly. Also added new Hops parameter to go beyond the standard default of 30 hops.

@tylerapplebaum
Copy link
Author

@catcosmicice - IPv6 should be working. Please give it a shot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment