Skip to content

Commit bc835de

Browse files
committed
Initial
1 parent 97d03be commit bc835de

2 files changed

Lines changed: 499 additions & 0 deletions

File tree

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
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

Comments
 (0)