Skip to content

Instantly share code, notes, and snippets.

@jborean93
Last active December 7, 2023 14:49
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jborean93/b494d64dcfba5e8187fbcc77b576599a to your computer and use it in GitHub Desktop.
Save jborean93/b494d64dcfba5e8187fbcc77b576599a to your computer and use it in GitHub Desktop.
Debug TLS Handshakes using .NET
# Copyright: (c) 2022, Jordan Borean (@jborean93) <jborean93@gmail.com>
# MIT License (see LICENSE or https://opensource.org/licenses/MIT)
Function Trace-TlsHandshake {
<#
.SYNOPSIS
TLS Handshake Diagnostics.
.DESCRIPTION
Performs a TLS handshake and returns diagnostic information about that
exchange. It can also be used to capture the raw TLS packets used in the
exchange through the -CapturePacket switch.
.PARAMETER HostName
The hostname to connect to.
.PARAMETER Port
The port to connect to, defaults to 443 which is commonly used for https.
.PARAMETER Protocol
The TLS protocol to restrict the handshake to. The default will use the
OS defaults but this can be used to use a specific protocol.
.PARAMETER CipherSuite
Override the cipher suite selection policy. The default will use the OS
defaults. Currently only Linux and macOS can define an explicit cipher
suite policy. The dotnet API does not support this for Windows
https://github.com/dotnet/runtime/issues/23818.
.PARAMETER CapturePacket
Capture the raw TLS packets and store them in the output Packets property.
This is experimental but can be used to analyze data not available through
PowerShell/.NET.
.EXAMPLE
Trace-TlsHandshake -HostName google.com
.OUTPUTS
This cmdlet outputs an object with the following properties:
Protocol
The TLS protocol that was negotiated. The values correspond to the
SslProtocols enum
https://docs.microsoft.com/en-us/dotnet/api/system.security.authentication.sslprotocols?view=net-6.0
Cipher
The cipher suite which was negotiated. The values correspond to the
TlsCipherSuite enum
https://docs.microsoft.com/en-us/dotnet/api/system.net.security.tlsciphersuite?view=net-6.0
KeyStrength
The strength of the key exchange algorithm that was negotiated.
Certificate
The certificate of the target host.
Chain
A chain of certificate authorities associated with the remote
certificate.
VerificationStatus
The cert verifcation result. The values can be set to the following:
None - No errors, verification succeeded
RemoteCertificateChainErrors - Errors in the chain
RemoteCertificateNameMismatch - Certificate name mismatched
RemoteCertificateNotAvailable - No certificate was available
The enums are based off
https://docs.microsoft.com/en-us/dotnet/api/system.net.security.sslpolicyerrors?view=net-6.0
Packets:
The raw TLS packets that were exchanged from the client and host. This
will be empty if -CapturePacket was not set.
The object in the Packets list contain the following properties:
Direction:
The direction of the packet; Outgoing for packets sent from the client
and Incoming for packets sent from the server.
ContentType:
The raw content type of the TLS record.
Version:
The raw version id of the TLS record.
Length:
The length of the TLS record payload
Data:
The full TLS record packet, this contains both the TLS record header
(first 5 bytes) as well as the remaining payload (size of Length).
.NOTES
This cmdlet is designed to replicate some of the same functionality that
exists in the openssl s_client -connect command.
This must run with PowerShell 7+.
#>
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[string]
$HostName,
[Parameter()]
[int]
$Port = 443,
[Parameter()]
[System.Security.Authentication.SslProtocols]
$Protocol = [System.Security.Authentication.SslProtocols]::None,
[Parameter()]
[System.Net.Security.CipherSuitesPolicy]
$CipherSuite = $null,
[Parameter()]
[switch]
$CapturePacket
)
$bufferSize = 16KB
$pipeName = "tls-test-$([Guid]::NewGuid())"
try {
Write-Verbose -Message "Connecting to $($HostName):$Port"
$socket = [System.Net.Sockets.TcpClient]::new($HostName, $Port)
$socketStream = $socket.GetStream()
Write-Verbose -Message "Creating named pipe for packet capture called '$pipeName'"
$serverPipe = [System.IO.Pipes.NamedPipeServerStream]::new(
$pipeName,
[System.IO.Pipes.PipeDirection]::InOut,
1,
[System.IO.Pipes.PipeTransmissionMode]::Byte,
[System.IO.Pipes.PipeOptions]"Asynchronous, CurrentUserOnly",
$bufferSize,
$bufferSize
)
$clientPipe = [System.IO.Pipes.NamedPipeClientStream]::new(
".",
$pipeName,
[System.IO.Pipes.PipeDirection]::InOut,
[System.IO.Pipes.PipeOptions]"Asynchronous, CurrentUserOnly"
)
Write-Verbose -Message "Connecting client and server named pipes"
$waitTask = $serverPipe.WaitForConnectionAsync()
$clientPipe.Connect()
while (-not $waitTask.AsyncWaitHandle.WaitOne(200)) { }
$null = $waitTask.GetAwaiter().GetResult()
$tlsPipeRecorder = {
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[string]
$Id,
[Parameter(Mandatory)]
[System.IO.Stream]
$Incoming,
[Parameter(Mandatory)]
[System.IO.Stream]
$Outgoing,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[System.Collections.Generic.List[PSObject]]
$MessageList,
[Parameter(Mandatory)]
[System.Threading.CancellationToken]
$CancelToken
)
try {
$buffer = [byte[]]::new(16KB)
while ($true) {
$task = $Incoming.ReadAsync($buffer, 0, 5, $CancelToken)
while (-not $task.AsyncWaitHandle.WaitOne(200)) { }
$null = $task.GetAwaiter().GetResult()
# The length of the TLS Record is a big endian UInt16 value
$msgLength = (([uint16]$buffer[3]) -shl 8) -bor $buffer[4]
$null = $Incoming.Read($buffer, 5, $msgLength)
$Outgoing.Write($buffer, 0, $msgLength + 5)
$Outgoing.Flush()
$msgObj = [PSCustomObject]@{
PSTypeName = 'Tls.Record'
Direction = $Id
ContentType = $buffer[0]
Version = (([uint16]$buffer[1]) -shl 8) -bor $buffer[2]
Length = $msgLength
Data = [byte[]]($buffer[0..($msgLength + 4)])
}
$MessageList.Add($msgObj)
}
}
catch [System.OperationCanceledException] {}
catch {
[Console]::WriteLine("$Id Failed - $_")
throw
}
}
$cancelToken = [System.Threading.CancellationTokenSource]::new()
$msgList = [System.Collections.Generic.List[PSObject]]::new()
$backgroundTasks = @(if ($CapturePacket) {
$psRead = [PowerShell]::Create()
$psRead.AddScript($tlsPipeRecorder).AddParameters(@{
Id = "Outgoing"
Incoming = $clientPipe
Outgoing = $socketStream
MessageList = $msgList
CancelToken = $cancelToken.Token
}).InvokeAsync()
$psWrite = [PowerShell]::Create()
$psWrite.AddScript($tlsPipeRecorder).AddParameters(@{
Id = "Incoming"
Incoming = $socketStream
Outgoing = $clientPipe
MessageList = $msgList
CancelToken = $cancelToken.Token
}).InvokeAsync()
})
$sslOptions = @{
EnabledSslProtocols = $Protocol
TargetHost = $HostName
}
if ($CipherSuite) {
$sslOptions.CipherSuitesPolicy = $CipherSuite
}
$certCallback = {
[CmdletBinding()]
param (
[object]$SenderObj,
[System.Security.Cryptography.X509Certificates.X509Certificate]$Certificate,
[System.Security.Cryptography.X509Certificates.X509Chain]$Chain,
[System.Net.Security.SslPolicyErrors]$PolicyErrors
)
$SenderObj._CertCallbackBag.Certificate = $Certificate
$SenderObj._CertCallbackBag.Chain = $Chain
$SenderObj._CertCallbackBag.PolicyErrors = $PolicyErrors
$true
}
$targetStream = $CapturePacket ? $serverPipe : $socketStream
$tlsStream = [System.Net.Security.SslStream]::new($targetStream, $true, $certCallback)
# This is used in the callback to smuggle back the callback params
$callbackInfo = @{}
$tlsStream.PSObject.Properties.Add(
[System.Management.Automation.PSNoteProperty]::new('_CertCallbackBag', $callbackInfo))
Write-Verbose "Starting TLS Handshake"
try {
$tlsStream.AuthenticateAsClient($sslOptions)
Write-Verbose "TLS Handshake complete"
}
catch {
# The exception here may contain an unhelpful see inner exception,
# raise the inner exception to display a more helpful error.
$exp = $_.Exception.InnerException
if ($exp.Message -like "*see inner exception*") {
$exp = $exp.InnerException
}
$err = [System.Management.Automation.ErrorRecord]::new(
$exp,
"TLSHandsakeFailure",
[System.Management.Automation.ErrorCategory]::ProtocolError,
$HostName)
$err.ErrorDetails = "TLS Handshake Failure: $($exp.Message)"
$PSCmdlet.WriteError($err)
}
finally {
$cancelToken.Cancel()
foreach ($task in $backgroundTasks) {
while (-not $task.AsyncWaitHandle.WaitOne(200)) { }
$task.GetAwaiter().GetResult()
}
}
[PSCustomObject]@{
PSTypeName = 'Tls.Handshake'
Protocol = $tlsStream.SslProtocol
Cipher = $tlsStream.NegotiatedCipherSuite
KeyStrength = $tlsStream.KeyExchangeStrength
Certificate = $callbackInfo.Certificate
Chain = $callbackInfo.Chain
VerificationStatus = $callbackInfo.PolicyErrors
Packets = $msgList
}
}
finally {
${socketStream}?.Dispose()
${socket}?.Dispose()
${tlsStream}?.Dispose()
${clientPipe}?.Dispose()
${serverPipe}?.Dispose()
}
}
@nskerl
Copy link

nskerl commented Sep 30, 2022

Thanks for this great script -- are you aware if CipherSuitesPolicy is available on Windows installations of Powershell 7+ ? So far, everywhere I have tried reports "CipherSuitesPolicy is not supported on this platform."

Do you have an example call to this function passing in a cipher suite?

@jborean93
Copy link
Author

According to the docs it’s been in dotnet since core 3.0

Core 3.0, Core 3.1, 5, 6, 7 RC 1

Is the error around missing types or is it an error that actually says this is not available on this platform. If it’s the latter I’ll have to find a way to get that working for Windows then.

@nskerl
Copy link

nskerl commented Sep 30, 2022

Yes - that is the error but I suspect it is something environmental on my Windows resources.

Does this below look ok to you? Even on machines without error, I negotiate TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256

[System.Collections.Generic.List[System.Net.Security.TlsCipherSuite]] $ciphers = @()

$ciphers.Add([System.Net.Security.TlsCipherSuite]::TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384)

$cipherSuite = [System.Net.Security.CipherSuitesPolicy]::new($ciphers)

Trace-TlsHandshake -HostName google.com -CipherSuite $cipherSuite

@jborean93
Copy link
Author

It seems sane to me, will have to try it out some more to test it though sorry.

@jborean93
Copy link
Author

I've had a further look and it seems like this is just not implemented on Windows with dotnet dotnet/runtime#23818.

Note: OpenSsl 1.1.1 or OSX is required. Windows is not supported at the moment.

I haven't seen any action for bringing support for this to Windows and even knowing the SSPI APIs to do so sounds a bit complex.

I'll just have to update the docstring to reflection this information.

@tjmoore
Copy link

tjmoore commented Jun 1, 2023

What would be involved to make this work with a proxy? (basic auth authenticated ideally)

Guess have to do the CONNECT request etc.

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