1+ # Source: https://gist.github.com/jborean93/44f92e4dfa613c5a1e7889fa7a7c2563
2+
3+ # Copyright: (c) 2023, Jordan Borean (@jborean93) <jborean93@gmail.com>
4+ # MIT License (see LICENSE or https://opensource.org/licenses/MIT)
5+
6+ Function Get-SqlServerTlsCertificate {
7+ <#
8+ . SYNOPSIS
9+ Gets the MS SQL X509 Certificate.
10+
11+ . DESCRIPTION
12+ Gets the X509 Certificate that is being used by a remote MS SQL Server.
13+ This certificate contains information like the Subject, SAN entries, expiry and other useful information for debugging purposes.
14+
15+ . PARAMETER ComputerName
16+ The remote MS SQL Server to extract the certificate from.
17+
18+ . PARAMETER ConnectTimeout
19+ The timeout, in milliseconds, to wait until the connection was successful, defaults to 5000 (5 seconds).
20+ If the timeout is reached, the cmdlet will write an error.
21+
22+ . PARAMETER ConnectionType
23+ The connection type to use for retrieving the certificate, defaults to SQLBrowser.
24+ This can be set to SQLBrowser, NamedPipe, or TCP.
25+ The SQLBrowser option will use the SQL Browser service to find the named pipe or TCP port for the instance requested.
26+ The SQLBrowser needs access to the UDP port 1434 as well as the NamedPipe or TCP Port that is selected to work.
27+ The NamedPipe option will connect to the named pipe for the instance requested.
28+ The NamedPipe will need access to the TCP port 445 to work.
29+ The TCP option will connect to the TCP port requested.
30+
31+ . PARAMETER InstanceName
32+ The MS SQL instance to connect to.
33+ When used with '-ConnectionType SQLBrowser', it will only connect to the instance that matches this name.
34+ When used with '-ConnectionType NamedPipe', it will use this instance name to build the named pipe name.
35+ Set to an empty string to use the first instance found by the SQLBrowser.
36+
37+ . PARAMETER Port
38+ The TCP port to use for the connection, defaults to 1433.
39+ This is only used if '-ConnectionType TCP' is requested.
40+
41+ . PARAMETER StrictEncrypt
42+ Perform strict encryption that was introduced with TDS 8.0 (SQL Server 2022 and newer).
43+ Strict encryption simplifies the connection process but will only work if the server is new enough to support it.
44+
45+ . EXAMPLE
46+ PS> Get-SqlServerTlsCertificate -ComputerName sql01
47+ Gets the certificate of the first instance found on sql01 using the SQL Browser service to find the TCP port or Named Pipe.
48+
49+ . EXAMPLE
50+ PS> Get-SqlServerTlsCertificate -ComputerName sql01 -Instance MySQLInstance
51+ Gets the certificate for the instance 'sql01\MySQLInstance' using the SQL Browser service to find the TCP port or Named Pipe.
52+
53+ . EXAMPLE
54+ PS> Get-SqlServerTlSCertificate -ComputerName sql01 -Port 65334 -ConnectionType TCP
55+ Gets the certificate for server sql01 using the TCP port 65334
56+
57+ . EXAMPLE
58+ PS> Get-SqlServerTlsCertificate -ComputerName sql01 -ConnectionType NamedPipe
59+ Gets the certificate for the default instance on sql01 using the Named Pipe connection.
60+
61+ . EXAMPLE
62+ PS> Get-SqlServerTlsCertificate -ComputerName sql01 -InstanceName MySQLInstance -ConnectionType NamedPipe
63+ Gets the certificate for the instance 'sql01\MySQLInstance' using the Named Pipe connection.
64+
65+ . EXAMPLE
66+ PS> $cert = Get-SqlServerTlsCertificate -ComputerName sql01
67+ PS> $certBytes = $cert.Export("Cert")
68+ PS> $setParams = @{}
69+ PS> if ($PSVersionTable.PSVersion -lt [Version]'6.0') {
70+ ... $setParams.Raw = $true
71+ ... } else {
72+ ... $setParams.AsByteStream = $true
73+ ... }
74+ PS> Set-Content -Path sql01.crt -Value $certBytes @setParams
75+ Gets the certificate for the SQL server sql01 and exports it to a .crt file for use in Windows.
76+
77+ . OUTPUTS
78+ System.Security.Cryptography.X509Certificates.X509Certificate2
79+ This cmdlet will output the X509Certificate2 object retrieved from the server.
80+
81+ . NOTES
82+ Run with -Verbose to get a better understanding of how this cmdlet connects to the MS SQL server.
83+ A warning will be emitted if the remote certificate is not trusted and it will try to include the reasons why.
84+ #>
85+ [OutputType ([System.Security.Cryptography.X509Certificates.X509Certificate2 ])]
86+ param (
87+ [Parameter (Mandatory )]
88+ [string ]
89+ $ComputerName ,
90+
91+ [Parameter ()]
92+ [int ]
93+ $ConnectTimeout = 5000 ,
94+
95+ [Parameter ()]
96+ [ValidateSet (" SQLBrowser" , " TCP" , " NamedPipe" )]
97+ [string ]
98+ $ConnectionType = " SQLBrowser" ,
99+
100+ [Parameter ()]
101+ [AllowEmptyString ()]
102+ [string ]
103+ $InstanceName = " " ,
104+
105+ [Parameter ()]
106+ [int ]
107+ $Port = 1433 ,
108+
109+ [Parameter ()]
110+ [switch ]
111+ $StrictEncrypt
112+ )
113+
114+ class TdsTlsStream : System.IO.Stream {
115+ [System.IO.Stream ]$InnerStream
116+ [int ]$PayloadLength = 0
117+
118+ TdsTlsStream([System.IO.Stream ]$InnerStream ) {
119+ $this.InnerStream = $InnerStream
120+ }
121+
122+ [bool ] get_CanRead() { return $this.InnerStream.CanRead }
123+ [bool ] get_CanWrite() { return $this.InnerStream.CanWrite }
124+ [bool ] get_CanSeek() { return $this.InnerStream.CanSeek }
125+ [Int64 ] get_Length() { return $this.InnerStream.Length }
126+ [Int64 ] get_Position() { return $this.InnerStream.Position }
127+ [void ] set_Position([Int64 ]$Value ) { $this.InnerStream.Position = $Value }
128+ [int ] get_ReadTimeout() { return $this.InnerStream.ReadTimeout }
129+ [int ] get_WriteTimeout() { return $this.InnerStream.WriteTimeout }
130+
131+ [void ] Flush() { $this.InnerStream.Flush () }
132+ [Int64 ] Seek([Int64 ]$Offset , [System.IO.SeekOrigin ]$Origin ) { return $this.InnerStream.Seek ($Offset , $Origin ) }
133+ [void ] SetLength([Int64 ]$Value ) { $this.InnerStream.SetLength ($Value ) }
134+
135+ [int ] Read([byte []]$Buffer , [int ]$Offset , [int ]$Count ) {
136+ # We need to strip off the TDS header before setting the Buffer
137+ if ($this.PayloadLength -eq 0 ) {
138+ $header = [byte []]::new(8 )
139+ $read = 0
140+ while ($read -lt 8 ) {
141+ $read += $this.InnerStream.Read ($header , 0 , 8 )
142+ }
143+
144+ $lengthBeforeHeader = [System.BitConverter ]::ToUInt16([byte []]@ ($header [3 ], $header [2 ]), 0 )
145+ $lengthBeforeHeader -= 8
146+ $this.PayloadLength = $lengthBeforeHeader
147+ }
148+
149+ if ($Count -gt $this.PayloadLength ) {
150+ $Count = $this.PayloadLength
151+ }
152+ $read = $this.InnerStream.Read ($Buffer , $Offset , $Count )
153+ $this.PayloadLength -= $read
154+ return $read
155+ }
156+
157+ [void ] Write([byte []]$Buffer , [int ]$Offset , [int ]$Count ) {
158+ $newPayload = $this.GenerateTdsHeader ($Buffer , $Offset , $Count )
159+ $this.InnerStream.Write ($newPayload , 0 , $newPayload.Length )
160+ }
161+
162+ [byte []] GenerateTdsHeader([byte []]$Payload , [int ]$Offset , [int ]$Count ) {
163+ # The length is big endian encoded so it is inserted in reverse order
164+ $lengthBytes = [System.BitConverter ]::GetBytes([uint16 ]($Count + 8 ))
165+
166+ $newPayload = [byte []]::new(8 + $Count )
167+ $newPayload [0 ] = 0x12 # Type - Pre-Login
168+ $newPayload [1 ] = 0x01 # Status - End of message (EOM)
169+ $newPayload [2 ] = $lengthBytes [1 ]
170+ $newPayload [3 ] = $lengthBytes [0 ]
171+ $newPayload [4 ] = 0 # SPID
172+ $newPayload [5 ] = 0 # SPID
173+ $newPayload [6 ] = 0 # PacketID
174+ $newPayload [7 ] = 0 # Window
175+ [System.Array ]::Copy($Payload , $Offset , $newPayload , 8 , $Count )
176+
177+ return $newPayload
178+ }
179+ }
180+
181+ $udpClient = $socket = $targetStream = $sslStream = $null
182+ try {
183+ $pipeName = if ($InstanceName -and $InstanceName -ne ' MSSQLSERVER' ) {
184+ ' MSSQL${0}\sql\query' -f $InstanceName
185+ }
186+ else {
187+ ' sql\query'
188+ }
189+
190+ if ($ConnectionType -eq " SQLBrowser" ) {
191+ # Use the SQLBrowser
192+ # https://learn.microsoft.com/en-us/openspecs/windows_protocols/mc-sqlr/2e1560c9-5097-4023-9f5e-72b9ff1ec3b1
193+ $udpClient = [System.Net.Sockets.UdpClient ]::new($ComputerName , 1434 )
194+ $udpClient.Client.SendTimeout = $ConnectTimeout
195+ $udpClient.Client.ReceiveTimeout = $ConnectTimeout
196+ $null = $udpClient.Send ([byte []]@ (0x03 ), 1 ) # CLNT_UCAST_EX
197+ $resp = $udpClient.Receive ([ref ]$null )
198+
199+ $respSize = [System.BitConverter ]::ToUInt16($resp , 1 )
200+ $rawResponse = [System.Text.Encoding ]::UTF8.GetString($resp , 3 , $respSize )
201+ Write-Verbose - Message " Recieved SQL Browser response: '$rawResponse '"
202+ $response = $rawResponse -split ' ;'
203+
204+ $instanceInfo = [Ordered ]@ {}
205+ $remoteInstance = @ (
206+ for ($i = 0 ; $i -lt $response.Length ; $i += 2 ) {
207+ if ($response [$i ]) {
208+ $instanceInfo [$response [$i ]] = $response [$i + 1 ]
209+ }
210+ elseif ($i -eq $response.Length - 1 ) {
211+ break
212+ }
213+ else {
214+ $info = [PSCustomObject ]$instanceInfo
215+ Write-Verbose - Message " Processed SQL Browser Response:`n $ ( $info | Out-String ) "
216+
217+ $info
218+ $instanceInfo = [Ordered ]@ {}
219+ $i -= 1
220+ }
221+ }
222+ ) | Where-Object { -not $InstanceName -or $_.InstanceName -eq $InstanceName } | Select-Object - First 1
223+
224+ if ($remoteInstance.np ) {
225+ $ConnectionType = ' NamedPipe'
226+ $ComputerName = $remoteInstance.ServerName
227+ $pipeName = $remoteInstance.np -replace " \\\\.*?\\pipe\\(.*)" , ' $1'
228+ }
229+ elseif ($remoteInstance.tcp ) {
230+ $ConnectionType = ' TCP'
231+ $ComputerName = $remoteInstance.ServerName
232+ $Port = $remoteInstance.tcp
233+ }
234+ else {
235+ throw " Failed to receive any SQL Browser responses from $ ( $ComputerName ) :1434, cannot continue"
236+ }
237+ }
238+
239+ if ($ConnectionType -eq " TCP" ) {
240+ Write-Verbose - Message " Connecting to TCP/IP endpoint $ ( $ComputerName ) :$Port "
241+
242+ $socket = [System.Net.Sockets.TcpClient ]::new()
243+ $connectTask = $socket.ConnectAsync ($ComputerName , $Port )
244+ if (-not $connectTask.Wait ($ConnectTimeout )) {
245+ throw " Timed out connecting to TCP/IP endpoint $ ( $ComputerName ) :$Port "
246+ }
247+
248+ $null = $connectTask.GetAwaiter ().GetResult()
249+ $targetStream = $socket.GetStream ()
250+ }
251+ else {
252+ Write-Verbose - Message " Connecting to Named Pipe endpoint \\$ ( $ComputerName ) \pipe\$pipeName "
253+ $targetStream = [System.IO.Pipes.NamedPipeClientStream ]::new(
254+ $ComputerName ,
255+ $pipeName ,
256+ [System.IO.Pipes.PipeDirection ]::InOut)
257+ $targetStream.Connect ($ConnectTimeout )
258+ }
259+
260+ # Before TDS 8.0, TLS was done after the Pre-Login message after it was
261+ # negotiated with the server. It also needs to prepend a header to each TLS
262+ # payload making it more difficult. TDS 8.0 (Encrypt=strict) is a lot
263+ # simpler as the TLS handshake is done before anything.
264+ if ($StrictEncrypt ) {
265+ Write-Verbose - Message " Using TDS 8 TLS Handshake"
266+ $streamToWrap = $targetStream
267+ }
268+ else {
269+ Write-Verbose - Message " Using TDS 7.x Pre-Login method for the TLS handshake"
270+
271+ # This is a pre-calculated TDS Pre-Login payload with the ENCRYPTION
272+ # value of ENCRYPT_REQ (0x03).
273+ # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tds/60f56408-0188-4cd5-8b90-25c6f2423868
274+ $tdsPreLogin = [byte []]@ (
275+ 0x12 , 0x01 , 0x00 , 0x2f , 0x00 , 0x00 , 0x01 , 0x00 ,
276+ 0x00 , 0x00 , 0x1a , 0x00 , 0x06 , 0x01 , 0x00 , 0x20 ,
277+ 0x00 , 0x01 , 0x02 , 0x00 , 0x21 , 0x00 , 0x01 , 0x03 ,
278+ 0x00 , 0x22 , 0x00 , 0x04 , 0x04 , 0x00 , 0x26 , 0x00 ,
279+ 0x01 , 0xff , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 ,
280+ 0x03 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00
281+ )
282+ $targetStream.Write ($tdsPreLogin , 0 , $tdsPreLogin.Count )
283+
284+ $headerBytes = [byte []]::new(8 )
285+ $read = 0
286+ while ($read -ne $headerBytes.Length ) {
287+ $read += $targetStream.Read ($headerBytes , $read , $headerBytes.Length - $read )
288+ }
289+
290+ # Integer values are big endian encoded so swap them around. It also
291+ # includes the header length which we've already gotten
292+ $payloadLength = [System.BitConverter ]::ToUInt16([byte []]@ ($headerBytes [3 ], $headerBytes [2 ]), 0 )
293+ $payloadLength -= 8
294+
295+ $tdsPreLoginResp = [byte []]::new($payloadLength )
296+ $read = 0
297+ while ($read -ne $tdsPreLoginResp.Length ) {
298+ $read += $targetStream.Read ($tdsPreLoginResp , $read , $tdsPreLoginResp.Length - $read )
299+ }
300+
301+ # The TDS Pre-Login payload starts with a variable amount of headers
302+ # TYPE - BYTE
303+ # OFFSET - USHORT (offset in the payload of the value)
304+ # LENGTH - USHORT
305+ # The headers are terminated with the type 0xFF. We want to extract the
306+ # value for the ENCRYPT type (1) from the payload to see if the server
307+ # supported encryption.
308+ $serverEncrypt = 0
309+ $offset = 0
310+ while ($true ) {
311+ $plOptionType = $tdsPreLoginResp [$offset ]
312+ if ($plOptionType -eq 0xFF ) {
313+ break
314+ }
315+ elseif ($plOptionType -ne 1 ) {
316+ $offset += 5
317+ continue
318+ }
319+
320+ $valueOffset = [System.BitConverter ]::ToUInt16([byte []]@ ($tdsPreLoginResp [$offset + 2 ], $tdsPreLoginResp [$offset + 1 ]), 0 )
321+ $serverEncrypt = $tdsPreLoginResp [$valueOffset ]
322+ break
323+ }
324+
325+ # Strip off the extra flags, we only care about these specific bits
326+ $serverEncrypt = $serverEncrypt -band 0x0F
327+
328+ # ENCRYPT_OFF, ENCRYPT_NOT_SUP
329+ if ($serverEncrypt -in @ (0 , 2 )) {
330+ $msg = ' Server reported an encryption level of 0x{0:X2} which indicates it does not support TDS encryption.' -f $serverEncrypt
331+ throw $msg
332+ }
333+
334+ # Now we know the server supports TLS we need to wrap the raw stream
335+ # with a custom wrapper to ensure each TLS payload sent below is
336+ # preceeded with the TDS header as required. While not implemented
337+ # there is a note that TDS 7.1 or earlier (SQL Server 2000 or earlier)
338+ # should use the table response type (0x04) instead. As this is so old
339+ # I'm not going to implement that.
340+ $streamToWrap = [TdsTlsStream ]::new($targetStream )
341+ }
342+
343+ # Create the SslStream with a disable certificate verification callback.
344+ # This allows it to connect to a self signed or cert with different
345+ # hostname. The callback will also capture more information about the peer
346+ # Allows us to emit warnings if it was going to fail.
347+ $certState = @ {}
348+ $sslStream = [System.Net.Security.SslStream ]::new($streamToWrap , $false , {
349+ param ($Sender , $Certificate , $Chain , $SslPolicyErrors )
350+
351+ $certState.Chain = $chain
352+ $certState.SslPolicyErrors = $SslPolicyErrors
353+ $true
354+ })
355+ Write-Verbose - Message " Starting TLS Handshake"
356+ $sslStream.AuthenticateAsClient ($ComputerName )
357+ Write-Verbose - Message " TLS result: $ ( $certState.SslPolicyErrors ) "
358+
359+ if ($certState.SslPolicyErrors -ne ' None' ) {
360+ $msg = @ (
361+ " Client does not trust remote certificate: $ ( $certState.SslPolicyErrors ) "
362+ $certState.ChainStatus | ForEach-Object { $_.Status ; $_.StatusInformation }
363+ ) -join ([System.Environment ]::NewLine)
364+ Write-Warning - Message $msg.TrimEnd ()
365+ }
366+
367+ $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2 ]::new($sslStream.RemoteCertificate )
368+ Write-Verbose - Message " Found cert for $ ( $cert.Subject ) , Expires: $ ( $cert.NotAfter ) , SANs: $ ( $cert.DnsNameList -join " , " ) "
369+
370+ $cert
371+ }
372+ catch {
373+ $PSCmdlet.WriteError ($_ )
374+ }
375+ finally {
376+ if ($udpClient ) { $udpClient.Dispose () }
377+ if ($sslStream ) { $sslStream.Dispose () }
378+ if ($targetStream ) { $targetStream.Dispose () }
379+ if ($socket ) { $socket.Dispose () }
380+ }
381+ }
0 commit comments