diff --git a/private/functions/ConvertFrom-DbccPageDump.ps1 b/private/functions/ConvertFrom-DbccPageDump.ps1 new file mode 100644 index 00000000000..7284d1044ab --- /dev/null +++ b/private/functions/ConvertFrom-DbccPageDump.ps1 @@ -0,0 +1,133 @@ +function ConvertFrom-DbccPageDump { + <# + .SYNOPSIS + Internal function. + + .DESCRIPTION + Turns the hex memory dump that DBCC PAGE prints for one page into the raw bytes of that page. + + A dump line looks like this, an address, a colon, groups of eight hex digits, then an ASCII gutter + that renders the same bytes as characters: + + 0000000332FF4000: 01010400 00820100 78da1801 01001100 5d8e0600 .......x....].. + + Two properties of that format make the obvious parser wrong, and both fail silently: + + - The line width is not fixed. The same build of SQL Server emits 16 bytes per line for some pages + and 20 for others. + - The gutter can begin with a token of exactly eight hex digits, because a module definition easily + contains an eight character run of hex digits. The rule "a token of exactly eight hex digits is + data" then takes that gutter as one more group of four bytes, and a line contributes 24 bytes + instead of 20. + + Appending the bytes in order would shift everything after such a line by four, which leaves the + slot array at the end of the page decoding to rubbish while the result is still exactly 8192 bytes + and nothing throws. So each line's bytes are placed at the offset its own address gives. A stray + gutter token then lands where the next line's bytes go and is overwritten by them, and nothing else + moves, which also makes the line width irrelevant. + + Coverage is tracked and every byte of the page is required, because a line that went missing would + otherwise leave a hole of zeroes that nothing else would reveal. + + The address is a memory address rather than an offset into the page and does not start at zero, so + the first line that is accepted is what defines where the page begins. + + This is a function of its own, rather than part of the reader that calls it, so that it can be unit + tested without a SQL Server instance. Both of the properties above are reproducible from synthetic + dump text, and nothing else in the suite would catch a regression in them. + + This function is used by the following private functions: + - Get-EncryptedObjectImageValue + + .PARAMETER DumpLine + The lines of the memory dump section of one page, in the order DBCC PAGE returned them. + + .PARAMETER PageSize + The size of a page in bytes. Defaults to 8192. + + .NOTES + Tags: Page, DBCC + Author: the dbatools team + Claude + + Website: https://dbatools.io + Copyright: (c) 2018 by dbatools, licensed under MIT + License: MIT https://opensource.org/licenses/MIT + + .EXAMPLE + ConvertFrom-DbccPageDump -DumpLine $dumpLine + + Returns the 8192 bytes that the dump describes. + #> + [CmdletBinding()] + [OutputType([byte[]])] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [string[]]$DumpLine, + [int]$PageSize = 8192 + ) + + $image = New-Object byte[] $PageSize + $covered = New-Object bool[] $PageSize + $coveredCount = 0 + $baseAddress = $null + + foreach ($line in $DumpLine) { + if ($null -eq $line) { + continue + } + + $colonPosition = $line.IndexOf(":") + if ($colonPosition -le 0) { + continue + } + + $address = $line.Substring(0, $colonPosition).Trim() + if ($address.Length -lt 8 -or $address.Length -gt 16 -or $address -notmatch "^[0-9A-Fa-f]+$") { + continue + } + + $addressValue = [Convert]::ToUInt64($address, 16) + if ($null -eq $baseAddress) { + $baseAddress = $addressValue + } + if ($addressValue -lt $baseAddress) { + continue + } + + $imageOffset = [int]($addressValue - $baseAddress) + if ($imageOffset -ge $PageSize) { + continue + } + + foreach ($token in ($line.Substring($colonPosition + 1).Trim() -split "\s+")) { + # The first token that is not exactly eight hex digits is the ASCII gutter. + if ($token -notmatch "^[0-9A-Fa-f]{8}$") { + break + } + if (($imageOffset + 4) -gt $PageSize) { + break + } + + # One parse per group of four bytes rather than one per byte. The group prints its bytes in + # order, so the most significant byte of the parsed value is the first. + $word = [Convert]::ToUInt32($token, 16) + for ($shift = 24; $shift -ge 0; $shift -= 8) { + $image[$imageOffset] = [byte](($word -shr $shift) -band 0xFF) + if (-not $covered[$imageOffset]) { + $covered[$imageOffset] = $true + $coveredCount++ + } + $imageOffset++ + } + } + } + + if ($coveredCount -lt $PageSize) { + throw "Incomplete page dump. Only $coveredCount of $PageSize bytes were accounted for." + } + + # -NoEnumerate keeps PowerShell from unrolling the byte array into the pipeline, so the caller + # receives one byte[] rather than 8192 separate bytes and can still bind it to [Array]::Copy. + Write-Output -NoEnumerate $image +} diff --git a/private/functions/ConvertFrom-EncryptedObjectChunk.ps1 b/private/functions/ConvertFrom-EncryptedObjectChunk.ps1 new file mode 100644 index 00000000000..10b76ff1eab --- /dev/null +++ b/private/functions/ConvertFrom-EncryptedObjectChunk.ps1 @@ -0,0 +1,90 @@ +function ConvertFrom-EncryptedObjectChunk { + <# + .SYNOPSIS + Internal function. + + .DESCRIPTION + Turns the ciphertext chunks of one encrypted object into its definition text. + + A definition can in principle span several sys.sysobjvalues rows, one per subobjid, which is why + the chunks are ordered by colid and concatenated rather than one row being assumed. SQL Server + appears never to do this in practice, using a single row and letting the off row machinery deal + with size instead. + + That has a consequence worth being explicit about: no fixture can produce a multi chunk definition, + so a byte for byte comparison against a created object cannot reach this code with more than one + chunk. The multi chunk behaviour of this function is covered by its unit tests and by nothing else. + + Two mistakes are easy to make here and both produce text of exactly the right length, which is the + one failure mode that the rest of the suite cannot see: + + - Concatenating in the order the rows were read rather than in colid order swaps the parts of a + definition around. + - Deriving one keystream for the whole object leaves the first chunk readable and everything after + it mojibake. colid is an input to the key, so every chunk has its own keystream. + + This function is used by the following public functions: + - Invoke-DbaDbDecryptObject + + .PARAMETER FamilyGuid + The family GUID of the database that holds the object. + + .PARAMETER ObjectId + The object id of the encrypted object. + + .PARAMETER Chunk + The ciphertext chunks, as objects with a ColId and a Cipher property. Order does not matter. + + .NOTES + Tags: Encryption, Decrypt + Author: the dbatools team + Claude + + Website: https://dbatools.io + Copyright: (c) 2018 by dbatools, licensed under MIT + License: MIT https://opensource.org/licenses/MIT + + .EXAMPLE + ConvertFrom-EncryptedObjectChunk -FamilyGuid $familyGuid -ObjectId 1253579504 -Chunk $chunk + + Returns the definition text of object 1253579504. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [guid]$FamilyGuid, + [Parameter(Mandatory)] + [int]$ObjectId, + [Parameter(Mandatory)] + [object[]]$Chunk + ) + + $scriptBuilder = New-Object System.Text.StringBuilder + + foreach ($piece in ($Chunk | Sort-Object -Property ColId)) { + # The definition is UCS-2, so a chunk's ciphertext has to be an even number of bytes. An odd length + # means a bad in row slice or a bad off row reassembly, and the decode below would silently drop the + # trailing byte and hand back plausible text, so it is refused here instead. + if (($piece.Cipher.Length % 2) -ne 0) { + throw "Chunk $($piece.ColId) of object $ObjectId is $($piece.Cipher.Length) bytes, which is not a whole number of UCS-2 characters." + } + + $keystreamParams = @{ + FamilyGuid = $FamilyGuid + ObjectId = $ObjectId + ColId = $piece.ColId + Length = $piece.Cipher.Length + } + $keystream = Get-EncryptedObjectKeystream @keystreamParams + + $plainText = New-Object byte[] $piece.Cipher.Length + for ($cipherOffset = 0; $cipherOffset -lt $plainText.Length; $cipherOffset++) { + $plainText[$cipherOffset] = $piece.Cipher[$cipherOffset] -bxor $keystream[$cipherOffset] + } + + # The definition is stored as UCS-2, so it is decoded as such rather than with an encoding of choice. + $null = $scriptBuilder.Append([System.Text.Encoding]::Unicode.GetString($plainText)) + } + + return $scriptBuilder.ToString() +} diff --git a/private/functions/Get-EncryptedObjectImageValue.ps1 b/private/functions/Get-EncryptedObjectImageValue.ps1 new file mode 100644 index 00000000000..57616968093 --- /dev/null +++ b/private/functions/Get-EncryptedObjectImageValue.ps1 @@ -0,0 +1,1107 @@ +function Get-EncryptedObjectImageValue { + <# + .SYNOPSIS + Internal function. + + .DESCRIPTION + Returns the raw ciphertext of the requested encrypted objects by reading the pages of + sys.sysobjvalues directly. + + The definition of a module created WITH ENCRYPTION is stored in the imageval column of + sys.sysobjvalues. That column can only be selected over a dedicated admin connection, but the pages + that hold it can be read with DBCC PAGE, which needs sysadmin and no dedicated admin connection. + + EVERY OFFSET AND CONSTANT HERE IS THE ON DISK FORMAT, derived by experiment against live instances + rather than read from documentation, and a wrong one returns plausible rubbish instead of failing. + + There are two routes to an object's rows, and they differ by orders of magnitude: + + - Seek. sys.sysobjvalues is a clustered index, so the leaf page holding an object's rows is found by + descending from the index root. That is a handful of page reads whatever the size of the database. + - Scan. Every page of the rowset is examined. The number of pages tracks every module in the + database rather than the encrypted ones, so a database with a few thousand modules has thousands + of pages, and it grows even where nothing is encrypted. Measured on a database of 7,360 modules, + 29 of them encrypted: 9,669 pages to find 29 rows, against 143 page reads by seek. + + The seek is tried first and the scan is the fallback. A seek that lands on the wrong leaf finds no + rows, which is a loud failure rather than a silent one, but no rows is also what a genuinely absent + row looks like. So the seek is never allowed to report absence, and anything it cannot answer with + confidence hands the whole batch to the scan, which has the final say. That is what makes it safe to + use by default rather than as an opt in: it can make the answer faster but never wronger. One object + coming back empty abandons the seek for every object, not just that one. + + Off row values are recorded and resolved together at the end rather than descended into as they are + found, so that the pages of every off row value in every requested object share the same batches. + + The helpers are nested rather than separate private functions because nothing outside this file + calls them. The two parts that are unit tested from outside, the dump parser and the chunk + concatenation, are separate functions for that reason and that reason alone. + + Where a collection is built in one pass instead of appended to, the loop runs once per page, per + dump line or per blob link, which are the counts that grow with the database or with the size of a + definition, and appending copies the whole array every time. The appends that remain run once per + batch of pages, per object or per blob node, which are small and bounded, so they are left as they + read most clearly. Each of the one pass collections says so where it is. + + This function is used by the following public functions: + - Invoke-DbaDbDecryptObject + + .PARAMETER Database + The SMO database object that holds the encrypted objects. All queries run in the context of this database. + + .PARAMETER ObjectId + The object ids to return the ciphertext for. Rows of any other object are skipped. + + .PARAMETER BatchSize + How many pages to ask for per command. Defaults to 32. Measured over 200 real pages: the cost per + page falls steeply up to about 25 and is flat beyond it, so a larger batch buys nothing and only + makes one command return several megabytes of text. + + .PARAMETER ForceScan + Skips the seek and uses the page scan. The two routes share almost no code below the row parse, so + running both and requiring the same answer is the strongest check available that the seek is finding + the right rows rather than plausible ones. This switch exists for that comparison. + + .PARAMETER ForceChainWalk + Skips the seek and the page list and walks the page chain instead, which is the route taken on an + instance without sys.dm_db_database_page_allocations. Nothing reaches that route by accident on any + instance from SQL Server 2012 onwards, so without this switch the fallback that the 2005 support + rests on would never be exercised at all. Like ForceScan, this exists so the routes can be compared. + + .NOTES + Tags: Encryption, Decrypt + Author: the dbatools team + Claude + + Website: https://dbatools.io + Copyright: (c) 2018 by dbatools, licensed under MIT + License: MIT https://opensource.org/licenses/MIT + + .EXAMPLE + Get-EncryptedObjectImageValue -Database $db -ObjectId 1253579504 + + Returns one object per sys.sysobjvalues row that holds ciphertext of object 1253579504. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + $Database, + [Parameter(Mandatory)] + [int[]]$ObjectId, + [int]$BatchSize = 32, + [switch]$ForceScan, + [switch]$ForceChainWalk + ) + + $pageSize = 8192 + + # Rowset id of sys.sysobjvalues. It is a system base table, so this is the same in every database. + $sysobjvaluesContainerId = 281474980642816 + + # Page header fields. m_type is the only trustworthy statement of what a page is, because the DMV that + # lists pages has been measured omitting pages outright. m_nextPage holds a 4 byte page id followed by a + # 2 byte file id, and m_slotCnt is the number of slots. + $pageTypeOffset = 1 + $pageTypeData = 1 + $pageTypeIndex = 2 + $nextPageOffset = 16 + $slotCountOffset = 22 + + # Index record of the clustered index, 20 bytes: + # [status 1][valclass 1][objid 4][subobjid 4][valnum 4][childPage 4][childFile 2] + # The key is (valclass, objid, subobjid, valnum) and each entry's key is the minimum key of the page it + # points at, verified against rows read over a dedicated admin connection. + $indexRecordSize = 20 + $indexKeyValueClassOffset = 1 + $indexKeyObjectIdOffset = 2 + $indexChildPageOffset = 14 + $indexChildFileOffset = 18 + + # Layout of the in row blob root and of a blob record. + $lobRootHeaderSize = 12 + $lobLinkSize = 12 + $blobTypeOffset = 12 + $blobTypeData = 3 + $blobTypeInternal = 2 + $blobDataOffset = 14 + $internalEntryCountOffset = 16 + $internalEntriesOffset = 20 + $internalEntrySize = 16 + + # valclass 1 marks the rows that hold a module definition. + $moduleValueClass = 1 + + # The extent checks below add a length read straight off the page to an offset. In PowerShell an Int32 + # sum that overflows promotes to Double rather than wrapping, so a corrupt length cannot come out + # negative and pass a check it should fail. A language that wraps instead needs an explicit 64 bit cast + # there, so do not copy those checks anywhere without one. + + # Bounds. Each one exists because the thing it bounds follows pointers read out of page bytes, so bytes + # that are not what they are taken for can point in a circle. + $maximumSeekDepth = 16 + $maximumForwardPage = 64 + $maximumPageListRound = 8 + $maximumLobDepth = 32 + + function Get-PageImage { + <# + Returns the raw image of one or more pages, keyed "fileId:pageId". + + Several pages go in one command, because a page costs a round trip and returns only about 39 KB + of hex, so the round trip is what the time goes on. Every page comes back as its own result set + carrying its own m_pageId, so each image is stored under the identity the server reported rather + than the position it arrived in, which makes a mismatch impossible to mistake for data. + + Soft mode lets a page that will not dump be absent instead of throwing. The blob walk uses it, + because there a missing page is attributed to the one object it belongs to and fails that object + alone, where throwing would lose every object being resolved in the batch. + #> + param( + [Parameter(Mandatory)] + [object[]]$Page, + [switch]$Soft + ) + + # A closing bracket inside the name has to be doubled up to stay inside the quoting, because the + # name is spliced into DBCC text rather than passed as a parameter. + $escapedDatabase = $Database.Name -replace "\]", "]]" + $pageImage = New-Object System.Collections.Hashtable + + # De-duplicated: several blob nodes routinely share a page at different slots. + $requested = @() + $seen = New-Object System.Collections.Hashtable + foreach ($item in $Page) { + $key = "$([int]$item.FileId):$([int]$item.PageId)" + if (-not $seen.ContainsKey($key)) { + $seen[$key] = $true + $requested += $item + } + } + + for ($batchStart = 0; $batchStart -lt $requested.Count; $batchStart += $BatchSize) { + $batchEnd = [Math]::Min($batchStart + $BatchSize, $requested.Count) - 1 + $batch = @($requested[$batchStart..$batchEnd]) + + $commandText = "" + foreach ($item in $batch) { + $commandText += "DBCC PAGE([$escapedDatabase], $([int]$item.FileId), $([int]$item.PageId), 2) WITH TABLERESULTS;`n" + } + + foreach ($table in $Database.ExecuteWithResults($commandText).Tables) { + $identityRow = @($table | Where-Object Field -eq "m_pageId") + if ($identityRow.Count -eq 0) { + continue + } + $identity = [regex]::Match([string]$identityRow[0].VALUE, "\((\d+):(-?\d+)\)") + if (-not $identity.Success) { + continue + } + $identityKey = "$([int]$identity.Groups[1].Value):$([int]$identity.Groups[2].Value)" + if ($pageImage.ContainsKey($identityKey)) { + continue + } + + # Collected in one pass rather than by appending, because appending copies the whole array + # every time and a page is roughly 410 lines. + $dumpLine = @( + foreach ($row in $table.Rows) { + if ($row["Object"] -like "Memory Dump*") { + [string]$row["VALUE"] + } + } + ) + + # In soft mode a dump that will not parse is left absent, exactly as a page that did not + # come back at all is, so the caller faults the one object it belongs to. Letting the parse + # failure escape would isolate a missing page but not a malformed one. + try { + $pageImage[$identityKey] = ConvertFrom-DbccPageDump -DumpLine $dumpLine -PageSize $pageSize + } catch { + if (-not $Soft) { + throw "Could not read page ($identityKey) of database $($Database.Name). $($PSItem.Exception.Message)" + } + Write-Message -Level Verbose -Message "The dump of page ($identityKey) in database $($Database.Name) could not be parsed. $($PSItem.Exception.Message)" + } + } + + # A DBCC error for one page arrives as a message rather than an exception, so it would otherwise + # be one result set fewer, and a page missing from a scan or a blob reassembly is silent + # corruption rather than a visible failure. + if (-not $Soft) { + foreach ($item in $batch) { + $key = "$([int]$item.FileId):$([int]$item.PageId)" + if (-not $pageImage.ContainsKey($key)) { + throw "DBCC PAGE did not return page ($key) of database $($Database.Name)." + } + } + } + } + + return $pageImage + } + + function Get-RowFromPage { + <# + Parses every row on one data page, whatever object it belongs to and whatever its valclass, + because both routes need more than the rows they are looking for. + + Row layout, offsets relative to the record: + [status 2][fixedLength 2][valclass 1][objid 4][subobjid 4] ... fixed columns ... + [columnCount 2 @ fixedLength][null bitmap][variable count 2][offset array] + + The ids are read signed, because an object id uses the full 32 bit range and SQL Server reports + it signed as well. Reading them through a checked conversion threw on ids above int.MaxValue and + silently skipped those rows. + + Everything past the fixed columns is walked using lengths read out of the row itself, so a row + whose bytes are not the layout expected is skipped rather than trusted, and must never throw: + this runs inside both routes, so one exception would lose every object in the batch. + #> + param( + [Parameter(Mandatory)] + [byte[]]$Page + ) + + $slotCount = [int][BitConverter]::ToUInt16($Page, $slotCountOffset) + for ($slot = 0; $slot -lt $slotCount; $slot++) { + $recordOffset = [int][BitConverter]::ToUInt16($Page, $pageSize - 2 - $slot * 2) + if ($recordOffset -le 0 -or ($recordOffset + 17) -ge $pageSize) { + continue + } + + $fixedLength = [int][BitConverter]::ToUInt16($Page, $recordOffset + 2) + $valueClass = $Page[$recordOffset + 4] + $rowObjectId = [BitConverter]::ToInt32($Page, $recordOffset + 5) + $subObjectId = [BitConverter]::ToInt32($Page, $recordOffset + 9) + + $columnCountOffset = $recordOffset + $fixedLength + if ($fixedLength -lt 4 -or ($columnCountOffset + 2) -gt $pageSize) { + continue + } + + $columnCount = [int][BitConverter]::ToUInt16($Page, $columnCountOffset) + $nullBitmapSize = [int][Math]::Floor(($columnCount + 7) / 8) + $variableCountOffset = $columnCountOffset + 2 + $nullBitmapSize + if (($variableCountOffset + 2) -gt $pageSize) { + continue + } + + $variableCount = [int][BitConverter]::ToUInt16($Page, $variableCountOffset) + $offsetArray = $variableCountOffset + 2 + if ($variableCount -lt 1 -or ($offsetArray + $variableCount * 2) -gt $pageSize) { + continue + } + + # imageval is the last variable length column, so its end offset is the last entry of the offset + # array and its start is the entry before. The single column fallback is the start of the + # variable region, and the offset array position is absolute while the stored offsets are row + # relative, so the record offset is taken back off to match them. + $columnEnd = [int][BitConverter]::ToUInt16($Page, $offsetArray + ($variableCount - 1) * 2) + if ($variableCount -ge 2) { + $previousEnd = [int][BitConverter]::ToUInt16($Page, $offsetArray + ($variableCount - 2) * 2) + } else { + $previousEnd = $offsetArray + $variableCount * 2 - $recordOffset + } + + # The high bit of the end offset marks a value held off row. + $isLob = ($columnEnd -band 0x8000) -ne 0 + $columnStart = $previousEnd -band 0x7FFF + $columnEndOffset = $columnEnd -band 0x7FFF + if ($columnStart -gt $columnEndOffset -or ($recordOffset + $columnEndOffset) -gt $pageSize) { + continue + } + + $columnLength = $columnEndOffset - $columnStart + $columnValue = New-Object byte[] $columnLength + [Array]::Copy($Page, $recordOffset + $columnStart, $columnValue, 0, $columnLength) + + [PSCustomObject]@{ + ValueClass = [int]$valueClass + ObjectId = $rowObjectId + ColId = $subObjectId + IsLob = $isLob + Value = $columnValue + } + } + } + + function Test-PagePastObject { + <# + True when a page holds a row sorting after every possible row of one object, so no later page + can hold one either and the forward walk can stop. + + TESTING ANY SLOT IS CORRECT, and it reads as though it ought to be the highest key on the page. + Leaf pages are in key order, so every key here is at or below every key on the next page. If any + slot is past the target then this page's maximum is past it, the target's contiguous range has + already ended, and its rows were harvested from this page before this was asked. Slot order + within the page is irrelevant, and comparing only the highest key is equivalent, not safer. + + This reads valclass and objid straight out of the fixed part, deliberately, so that it can judge + a row the full parse would reject. A page whose rows all failed that parse would otherwise never + trip the stop and the walk would carry on past the end of the range. + #> + param( + [Parameter(Mandatory)] + [byte[]]$Page, + [Parameter(Mandatory)] + [int]$TargetObjectId + ) + + $slotCount = [int][BitConverter]::ToUInt16($Page, $slotCountOffset) + for ($slot = 0; $slot -lt $slotCount; $slot++) { + $recordOffset = [int][BitConverter]::ToUInt16($Page, $pageSize - 2 - $slot * 2) + if ($recordOffset -le 0 -or ($recordOffset + 17) -ge $pageSize) { + continue + } + + $valueClass = [int]$Page[$recordOffset + 4] + $rowObjectId = [BitConverter]::ToInt32($Page, $recordOffset + 5) + + if ($valueClass -gt $moduleValueClass) { + return $true + } + if ($valueClass -eq $moduleValueClass -and $rowObjectId -gt $TargetObjectId) { + return $true + } + } + + return $false + } + + function Get-LeafPageForObject { + <# + Descends the clustered index to the leaf page that should hold (valclass 1, objectId), choosing + at each level the last entry whose key is not past the target. Returns nothing when the descent + cannot be trusted, which sends the caller to the scan. + + TWO THINGS MAKE THIS CORRECT and getting either wrong sends every object to the wrong leaf, + which looks exactly like a decode failure: + + - Compare the whole key, in order, and valclass leads it. A module definition is valclass 1 + while the bulk of sys.sysobjvalues is valclass 60, so every module row sorts into the leftmost + subtree. Comparing the object id alone picks the last entry by object id and sends every target + to the final leaf. + - The leftmost entry of an index page has no meaningful stored key. It decodes to whatever was + left in those bytes, which reads as a plausible key, and it covers everything below the key of + the entry after it. It has to be treated as negative infinity. + + Only the leading two key columns are needed, because the target is the first key the object + could have, (1, objectId, 0, 0), so an entry with the same valclass and object id is already at + or past it. + #> + param( + [Parameter(Mandatory)] + $RootPage, + [Parameter(Mandatory)] + [int]$TargetObjectId + ) + + $currentPage = $RootPage + $indexPageRead = 0 + + for ($depth = 0; $depth -lt $maximumSeekDepth; $depth++) { + $currentKey = "$([int]$currentPage.FileId):$([int]$currentPage.PageId)" + + # The upper levels of the index are the same handful of pages for every object, so without a + # cache a run over many objects re-reads the root once per object. The cache holds index pages + # only, which is a few pages whatever the size of the database, and it is discarded with this + # invocation so no page is ever read from a stale image. + if ($indexPageCache.ContainsKey($currentKey)) { + $page = $indexPageCache[$currentKey] + } else { + $pageRef = @( + [PSCustomObject]@{ + FileId = [int]$currentPage.FileId + PageId = [int]$currentPage.PageId + } + ) + $page = (Get-PageImage -Page $pageRef)[$currentKey] + $indexPageRead++ + + # Only an index page is worth keeping. A leaf is read once per object at most and holding + # leaves would grow with the size of the rowset. + if ($page[$pageTypeOffset] -eq $pageTypeIndex) { + $indexPageCache[$currentKey] = $page + } + } + + $pageType = $page[$pageTypeOffset] + if ($pageType -eq $pageTypeData) { + return [PSCustomObject]@{ + FileId = [int]$currentPage.FileId + PageId = [int]$currentPage.PageId + IndexPageRead = $indexPageRead + } + } + + if ($pageType -ne $pageTypeIndex) { + Write-Message -Level Verbose -Message "Page ($([int]$currentPage.FileId):$([int]$currentPage.PageId)) of database $($Database.Name) is type $pageType rather than an index or data page, so the seek is abandoned." + return + } + + $chosen = $null + $slotCount = [int][BitConverter]::ToUInt16($page, $slotCountOffset) + for ($slot = 0; $slot -lt $slotCount; $slot++) { + $recordOffset = [int][BitConverter]::ToUInt16($page, $pageSize - 2 - $slot * 2) + if ($recordOffset -le 0 -or ($recordOffset + $indexRecordSize) -gt $pageSize) { + continue + } + + $childEntry = [PSCustomObject]@{ + FileId = [int][BitConverter]::ToUInt16($page, $recordOffset + $indexChildFileOffset) + PageId = [BitConverter]::ToInt32($page, $recordOffset + $indexChildPageOffset) + } + + if ($slot -eq 0) { + $chosen = $childEntry + continue + } + + $keyValueClass = [int]$page[$recordOffset + $indexKeyValueClassOffset] + $keyObjectId = [BitConverter]::ToInt32($page, $recordOffset + $indexKeyObjectIdOffset) + + if ($keyValueClass -lt $moduleValueClass) { + $chosen = $childEntry + continue + } + + # Strictly less than, not less than or equal. The target is the object's FIRST key, + # (1, objid, 0, 0), so an entry whose minimum key is (1, objid, subobjid > 0, ...) is + # already past the target even though its (valclass, objid) prefix matches. Choosing it + # would land the descent on a LATER leaf, and because the walk from there only follows + # m_nextPage forward, a chunk of the definition on the earlier page would never be seen - + # silently, as rows are still found and every other check passes. Stopping here instead + # lands on the page before the object's first row and the forward walk collects from + # there, which costs at most one extra page read. + # + # Nothing exercises this. SQL Server writes one valclass 1 row per module and lets the off + # row machinery carry the size, so a definition spanning two leaf pages cannot be created + # to test against. That is an observation rather than a documented guarantee, which is why + # the comparison is written for the case that has never been seen. + if ($keyValueClass -eq $moduleValueClass -and $keyObjectId -lt $TargetObjectId) { + $chosen = $childEntry + continue + } + + # The entries are in key order, so everything after this one is past the target too. + break + } + + if ($null -eq $chosen) { + Write-Message -Level Verbose -Message "No child entry of page ($([int]$currentPage.FileId):$([int]$currentPage.PageId)) in database $($Database.Name) covers object $TargetObjectId, so the seek is abandoned." + return + } + + $currentPage = $chosen + } + + Write-Message -Level Verbose -Message "The index of sys.sysobjvalues in database $($Database.Name) is deeper than $maximumSeekDepth levels, so the seek is abandoned." + } + + function Add-NodePayload { + <# + Writes the payload of a resolved blob node, and everything below it, into a stream in depth + first order. The tree is read breadth first so a level's pages can be fetched together, but the + ciphertext is only correct when the leaves are concatenated depth first, and keeping those two + apart is what makes the batched read safe. + #> + param( + [Parameter(Mandatory)] + $Node, + [Parameter(Mandatory)] + $Stream + ) + + if ($null -ne $Node.Payload) { + $Stream.Write($Node.Payload, 0, $Node.Payload.Length) + return + } + if ($null -eq $Node.Children) { + return + } + foreach ($child in $Node.Children) { + Add-NodePayload -Node $child -Stream $Stream + } + } + + function Get-LobData { + <# + Resolves off row values into their complete ciphertext, many at once. + + The in row bytes are a LARGE_ROOT_YUKON root: a 12 byte header then 12 byte links of cumulative + length, page id, file id and slot id. Every link points at a blob record whose 14 byte header + carries its type at offset 12: type 3 is a data leaf whose payload is the record length minus + the header, type 2 an internal node with its entry count at offset 16 and 16 byte entries from + offset 20. + + Walked breadth first, one level at a time, so every page a level needs goes in a few commands. A + depth first walk cannot batch, because a child's page id is only known when it is about to be + read. Taking every value together means the batches span objects as well as levels. + + THE THING TO GET RIGHT is that the ciphertext is assembled depth first, from the finished tree, + rather than appended as pages arrive. Getting that wrong produces ciphertext of exactly the + right length that decrypts to plausible rubbish, which only a byte for byte comparison catches. + + A page that will not dump, a record that is not the shape expected, or a value that reassembles + to no bytes at all faults only the value it belongs to, which is then absent from the result. + Off row storage was used because there was data that would not fit in row, so nothing coming + back means a root or a leaf did not resolve. + #> + param( + [Parameter(Mandatory)] + [object[]]$Root + ) + + $faulted = New-Object System.Collections.Hashtable + $rootNode = New-Object System.Collections.Hashtable + $currentLevel = @() + + foreach ($item in $Root) { + $rootBytes = $item.LobRoot + $linkCount = [int][Math]::Floor(($rootBytes.Length - $lobRootHeaderSize) / $lobLinkSize) + if ($linkCount -lt 0) { + Write-Message -Level Verbose -Message "The blob root of value $($item.Key) in database $($Database.Name) is only $($rootBytes.Length) bytes, which is shorter than its own header." + $faulted[$item.Key] = $true + $rootNode[$item.Key] = @() + continue + } + + # Built in one pass and added to the level once, because appending per link copies both arrays + # every time and a root can hold hundreds of links. + $childNode = @( + for ($link = 0; $link -lt $linkCount; $link++) { + $linkOffset = $lobRootHeaderSize + $link * $lobLinkSize + [PSCustomObject]@{ + FileId = [int][BitConverter]::ToUInt16($rootBytes, $linkOffset + 8) + PageId = [BitConverter]::ToInt32($rootBytes, $linkOffset + 4) + SlotId = [int][BitConverter]::ToUInt16($rootBytes, $linkOffset + 10) + Owner = $item.Key + Payload = $null + Children = $null + } + } + ) + + $currentLevel += $childNode + $rootNode[$item.Key] = $childNode + } + + $depth = 0 + while ($currentLevel.Count -gt 0) { + $depth++ + if ($depth -gt $maximumLobDepth) { + throw "The blob tree in database $($Database.Name) is deeper than $maximumLobDepth levels, which means its pointers do not terminate." + } + + $nextLevel = @() + + # Each batch is discarded once its records are parsed, so the page images of a large definition + # are never all held at once. + for ($batchStart = 0; $batchStart -lt $currentLevel.Count; $batchStart += $BatchSize) { + $batchEnd = [Math]::Min($batchStart + $BatchSize, $currentLevel.Count) - 1 + $batch = @($currentLevel[$batchStart..$batchEnd]) + $pageImage = Get-PageImage -Page $batch -Soft + + foreach ($node in $batch) { + # A sibling under the same owner may already have faulted it, so its subtree is moot. + if ($faulted.ContainsKey($node.Owner)) { + continue + } + + $nodeKey = "$($node.FileId):$($node.PageId)" + if (-not $pageImage.ContainsKey($nodeKey)) { + Write-Message -Level Verbose -Message "Page ($nodeKey) of database $($Database.Name) did not dump, so the off row value $($node.Owner) cannot be reassembled." + $faulted[$node.Owner] = $true + continue + } + $page = $pageImage[$nodeKey] + + $recordOffset = [int][BitConverter]::ToUInt16($page, $pageSize - 2 - $node.SlotId * 2) + if ($recordOffset -le 0 -or ($recordOffset + $internalEntriesOffset) -gt $pageSize) { + Write-Message -Level Verbose -Message "Bad blob record pointer ($nodeKey) slot $($node.SlotId) in database $($Database.Name), record offset $recordOffset." + $faulted[$node.Owner] = $true + continue + } + + $recordType = [int][BitConverter]::ToUInt16($page, $recordOffset + $blobTypeOffset) + + if ($recordType -eq $blobTypeData) { + $recordLength = [BitConverter]::ToInt32($page, $recordOffset + 2) + $dataLength = $recordLength - $blobDataOffset + if ($dataLength -lt 0 -or ($recordOffset + $blobDataOffset + $dataLength) -gt $pageSize) { + Write-Message -Level Verbose -Message "Bad blob data leaf ($nodeKey) slot $($node.SlotId) in database $($Database.Name), record length $recordLength." + $faulted[$node.Owner] = $true + continue + } + + $leafData = New-Object byte[] $dataLength + [Array]::Copy($page, $recordOffset + $blobDataOffset, $leafData, 0, $dataLength) + $node.Payload = $leafData + } elseif ($recordType -eq $blobTypeInternal) { + $entryCount = [BitConverter]::ToInt32($page, $recordOffset + $internalEntryCountOffset) + if ($entryCount -lt 0 -or ($recordOffset + $internalEntriesOffset + $entryCount * $internalEntrySize) -gt $pageSize) { + Write-Message -Level Verbose -Message "Bad blob internal node ($nodeKey) slot $($node.SlotId) in database $($Database.Name), entry count $entryCount." + $faulted[$node.Owner] = $true + continue + } + + # Built in one pass and added to the level once, for the same reason as the root's + # links above: an internal node holds up to about five hundred entries, so appending + # per entry would copy the whole level that many times. The append below therefore + # runs once per internal node, not once per child, which reads like the thing being + # avoided and is the result of avoiding it. + $childNode = @( + for ($entry = 0; $entry -lt $entryCount; $entry++) { + $entryOffset = $recordOffset + $internalEntriesOffset + $entry * $internalEntrySize + [PSCustomObject]@{ + FileId = [int][BitConverter]::ToUInt16($page, $entryOffset + 12) + PageId = [BitConverter]::ToInt32($page, $entryOffset + 8) + SlotId = [int][BitConverter]::ToUInt16($page, $entryOffset + 14) + Owner = $node.Owner + Payload = $null + Children = $null + } + } + ) + + $nextLevel += $childNode + $node.Children = $childNode + } else { + Write-Message -Level Verbose -Message "Unknown blob record type $recordType at ($nodeKey) slot $($node.SlotId) in database $($Database.Name)." + $faulted[$node.Owner] = $true + } + } + + $pageImage = $null + } + + $currentLevel = $nextLevel + } + + $cipher = New-Object System.Collections.Hashtable + foreach ($item in $Root) { + if ($faulted.ContainsKey($item.Key)) { + continue + } + + $lobStream = New-Object System.IO.MemoryStream + try { + foreach ($node in $rootNode[$item.Key]) { + Add-NodePayload -Node $node -Stream $lobStream + } + $value = $lobStream.ToArray() + } finally { + $lobStream.Dispose() + } + + if ($value.Length -eq 0) { + Write-Message -Level Verbose -Message "The off row value $($item.Key) in database $($Database.Name) reassembled to no bytes, so it is treated as unresolved." + continue + } + + $cipher[$item.Key] = $value + } + + return $cipher + } + + # ---- identities, refused before any page is read ---------------------------------------------- + + $requestedObjectId = @($ObjectId | Sort-Object -Unique) + + # Two separate refusals here, and each catches what the other cannot. + # + # sys.sysobjvalues holds the text of every module, not only the encrypted ones. Pointed at a plain + # module the reader would find its row, XOR plaintext against a keystream and return rubbish of exactly + # the right length with nothing else to show it was wrong. A NULL means the property could not be + # evaluated, which is not the same as a no, so only an explicit 0 is refused on that test. + # + # A T-SQL module also has to exist at all, which is a fact rather than a judgement. SMO reports + # IsEncrypted as true for a CLR function, and a CLR function has no T-SQL body to encrypt, so asking + # for one sends this reader hunting rows that cannot exist. OBJECTPROPERTY cannot save it: for a CLR + # module the answer is NULL, which is the case that is deliberately allowed through. A CLR module has + # no row in sys.sql_modules either, since that view covers T-SQL modules only, and that is the test + # this uses. Without it the missing rows read as "the seek could not answer" and hand the whole batch + # to a scan of every page of the rowset, so a handful of CLR functions in a large database cost a few + # page reads to find nothing and thousands to confirm it. + $queryIsEncrypted = @" +SELECT o.object_id AS ObjectId, + OBJECTPROPERTY(o.object_id, 'IsEncrypted') AS IsEncrypted, + CASE WHEN m.object_id IS NULL THEN 0 ELSE 1 END AS HasSqlModule +FROM sys.objects AS o +LEFT JOIN sys.sql_modules AS m ON m.object_id = o.object_id +WHERE o.object_id IN ($($requestedObjectId -join ", ")) +"@ + + $wantedObjectId = New-Object System.Collections.Hashtable + foreach ($id in $requestedObjectId) { + $wantedObjectId[$id] = $true + } + foreach ($row in @($Database.Query($queryIsEncrypted))) { + if ([int]$row.HasSqlModule -eq 0) { + Write-Message -Level Verbose -Message "Object $($row.ObjectId) in database $($Database.Name) has no T-SQL module, so there is nothing here to decrypt and it is skipped." + $wantedObjectId.Remove([int]$row.ObjectId) + continue + } + + if ($row.IsEncrypted -isnot [DBNull] -and [int]$row.IsEncrypted -eq 0) { + Write-Message -Level Verbose -Message "Object $($row.ObjectId) in database $($Database.Name) is not encrypted, so it is skipped." + $wantedObjectId.Remove([int]$row.ObjectId) + } + } + if ($wantedObjectId.Count -eq 0) { + return + } + + # ---- the allocation unit, which both routes need ---------------------------------------------- + + $queryAllocationUnit = @" +SELECT au.allocation_unit_id AS AllocationUnitId, au.first_page AS FirstPage, au.root_page AS RootPage, au.data_pages AS DataPages +FROM sys.system_internals_allocation_units AS au +WHERE au.container_id = $sysobjvaluesContainerId +AND au.type = 1 +"@ + + $allocationUnit = @($Database.Query($queryAllocationUnit)) + if ($allocationUnit.Count -eq 0 -or $allocationUnit[0].FirstPage -isnot [byte[]]) { + throw "Could not find the in row allocation unit of sys.sysobjvalues in database $($Database.Name)." + } + + # first_page and root_page are both binary(6) of a 4 byte page id then a 2 byte file id, so they read + # back to front from the familiar form. first_page is the head of the leaf level and root_page the top + # of the index, and using one where the other belongs walks the wrong level. + $firstPage = $allocationUnit[0].FirstPage + $firstPageEntry = [PSCustomObject]@{ + FileId = [int][BitConverter]::ToUInt16($firstPage, 4) + PageId = [BitConverter]::ToInt32($firstPage, 0) + } + + $rootPageEntry = $null + if ($allocationUnit[0].RootPage -is [byte[]]) { + $rootPageEntry = [PSCustomObject]@{ + FileId = [int][BitConverter]::ToUInt16($allocationUnit[0].RootPage, 4) + PageId = [BitConverter]::ToInt32($allocationUnit[0].RootPage, 0) + } + } + + $expectedPage = 0 + if ($allocationUnit[0].DataPages -isnot [DBNull]) { + $expectedPage = [int]$allocationUnit[0].DataPages + } + + $chunkIndex = 0 + $chunk = $null + + # ---- route one: seek the index for each object ------------------------------------------------ + + if (-not $ForceScan -and -not $ForceChainWalk -and $null -ne $rootPageEntry -and $rootPageEntry.PageId -ne 0) { + $seekChunk = @() + $seekAbandoned = $false + $indexPageRead = 0 + $leafPageRead = 0 + $indexPageCache = New-Object System.Collections.Hashtable + + foreach ($targetObjectId in @($wantedObjectId.Keys | Sort-Object)) { + # The descent and the forward walk are both inside this try, deliberately. The seek's contract + # is that it is never wronger than the scan, only faster, so any surprise here has to hand over + # to the scan rather than fail the batch. That includes a page that will not dump: the same page + # may well read fine as part of a scan, and even if it does not, it is the scan that says so. + $objectChunk = $null + try { + $leaf = Get-LeafPageForObject -RootPage $rootPageEntry -TargetObjectId $targetObjectId + if ($null -eq $leaf) { + $seekAbandoned = $true + break + } + $indexPageRead += $leaf.IndexPageRead + + $visitedLeaf = New-Object System.Collections.Hashtable + $seenColId = New-Object System.Collections.Hashtable + $harvested = @() + $currentLeaf = [PSCustomObject]@{ + FileId = [int]$leaf.FileId + PageId = [int]$leaf.PageId + } + + for ($forward = 0; $forward -lt $maximumForwardPage; $forward++) { + if ($null -eq $currentLeaf -or $currentLeaf.PageId -eq 0) { + break + } + + $leafKey = "$($currentLeaf.FileId):$($currentLeaf.PageId)" + + # A cycle would harvest the same rows again and concatenate a definition into a multiple + # of its real length. The page bound alone would not catch that. + if ($visitedLeaf.ContainsKey($leafKey)) { + Write-Message -Level Verbose -Message "The forward walk of sys.sysobjvalues in database $($Database.Name) revisited page ($leafKey), so the seek is abandoned." + $seekAbandoned = $true + break + } + $visitedLeaf[$leafKey] = $true + + $page = (Get-PageImage -Page @($currentLeaf))[$leafKey] + $leafPageRead++ + + if ($page[$pageTypeOffset] -ne $pageTypeData) { + break + } + + foreach ($row in (Get-RowFromPage -Page $page)) { + if ($row.ValueClass -ne $moduleValueClass -or $row.ObjectId -ne $targetObjectId) { + continue + } + + # Each row of an object has a distinct subobjid, and subobjid is the colid the + # keystream is built from. A repeat means the same row was harvested twice, and + # concatenating it twice gives a definition of the wrong length that still decrypts + # to readable text. + if ($seenColId.ContainsKey($row.ColId)) { + Write-Message -Level Verbose -Message "Object $targetObjectId in database $($Database.Name) yielded colid $($row.ColId) twice, so the seek is abandoned." + $seekAbandoned = $true + break + } + $seenColId[$row.ColId] = $true + $harvested += $row + } + + if ($seekAbandoned) { + break + } + if (Test-PagePastObject -Page $page -TargetObjectId $targetObjectId) { + break + } + + $currentLeaf = [PSCustomObject]@{ + FileId = [int][BitConverter]::ToUInt16($page, $nextPageOffset + 4) + PageId = [BitConverter]::ToInt32($page, $nextPageOffset) + } + } + + if (-not $seekAbandoned) { + # Nothing but the requested object may have been collected. The filter above makes this + # unreachable, which is why it is worth asserting rather than assuming: if it ever + # fires, the alternative is decrypting one module's bytes as another's. + $foreign = @($harvested | Where-Object { $PSItem.ObjectId -ne $targetObjectId }) + if ($foreign.Count -gt 0) { + Write-Message -Level Verbose -Message "The forward walk in database $($Database.Name) collected rows for an object other than $targetObjectId, so the seek is abandoned." + $seekAbandoned = $true + } else { + $objectChunk = @( + foreach ($row in $harvested) { + $chunkIndex++ + $chunkEntry = [PSCustomObject]@{ + Key = $chunkIndex + ObjectId = $row.ObjectId + ColId = $row.ColId + Cipher = $row.Value + LobRoot = $null + } + if ($row.IsLob) { + $chunkEntry.Cipher = $null + $chunkEntry.LobRoot = $row.Value + } + $chunkEntry + } + ) + } + } + } catch { + Write-Message -Level Verbose -Message "The seek for object $targetObjectId in database $($Database.Name) could not complete, so the page scan is used instead. $($PSItem.Exception.Message)" + $seekAbandoned = $true + } + + if ($seekAbandoned) { + break + } + + # Absence is not the seek's to report. The scan decides. + if ($objectChunk.Count -eq 0) { + Write-Message -Level Verbose -Message "The seek found no rows for object $targetObjectId in database $($Database.Name), so the page scan settles whether they exist." + $seekAbandoned = $true + break + } + + $seekChunk += $objectChunk + } + + if ($seekAbandoned) { + $chunkIndex = 0 + } else { + Write-Message -Level Verbose -Message "Seeked the index of sys.sysobjvalues in database $($Database.Name) for $($wantedObjectId.Count) objects, reading $indexPageRead index pages and $leafPageRead leaf pages." + $chunk = $seekChunk + } + } + + # ---- route two: scan every page of the rowset ------------------------------------------------- + + if ($null -eq $chunk) { + # Testing for the object tests the thing actually needed rather than using a version number as a + # proxy for it, so an instance without the DMV for any other reason falls back cleanly. + $queryDmv = @" +SELECT OBJECT_ID('sys.dm_db_database_page_allocations') AS DmvObjectId +"@ + $dmv = @($Database.Query($queryDmv)) + $dmvPresent = $dmv.Count -ge 1 -and $dmv[0].DmvObjectId -isnot [DBNull] -and $null -ne $dmv[0].DmvObjectId + if ($ForceChainWalk) { + $dmvPresent = $false + } + + $candidate = @() + if ($dmvPresent) { + # DETAILED is required. Under LIMITED the page type is left NULL, the filter matches nothing, + # and every object would read as never having been encrypted. + $queryDataPage = @" +SELECT allocated_page_file_id AS FileId, allocated_page_page_id AS PageId +FROM sys.dm_db_database_page_allocations(DB_ID(), NULL, NULL, NULL, 'DETAILED') +WHERE allocation_unit_id = $($allocationUnit[0].AllocationUnitId) +AND page_type_desc = 'DATA_PAGE' +"@ + $candidate = @( + foreach ($row in @($Database.Query($queryDataPage))) { + [PSCustomObject]@{ + FileId = [int]$row.FileId + PageId = [int]$row.PageId + } + } + ) + + # An empty list means the DMV did not answer, not that the rowset has no pages. + if ($candidate.Count -eq 0) { + Write-Message -Level Verbose -Message "The page list of sys.sysobjvalues came back empty in database $($Database.Name), so the page chain is walked instead." + $dmvPresent = $false + } + } + + # The head of the chain is always a candidate, in case the page list left it out. The leaf level is + # a linked list, so a set containing first_page and closed under the next pointer is the whole + # chain, which makes the page list provably complete rather than hopefully complete. The DMV has + # been measured omitting genuine data pages of the rowset, and a missing page means an object that + # reads as never having been encrypted. This is also why a page is only parsed as data when its own + # header says so. + $candidate += $firstPageEntry + + Write-Message -Level Verbose -Message "Scanning the sys.sysobjvalues pages of database $($Database.Name) using $(if ($dmvPresent) { "the page list" } else { "the page chain" })." + + $maximumPage = $expectedPage * 2 + 1000 + + # The two routes reach the same loop with completely different round counts. The page list arrives + # whole in the first round and needs at most a round or two of gap filling, so a low cap is what + # catches a set that will not close. The chain walk learns exactly one page per round by design, + # because a page is what names the next one, so its rounds are bounded by the number of pages + # instead - which the visited cap below is already the guard for. Holding the chain walk to the page + # list's cap made it fail on any rowset longer than that many pages, which is nearly all of them. + $maximumRound = $maximumPage + 1 + if ($dmvPresent) { + $maximumRound = $maximumPageListRound + } + + $visited = New-Object System.Collections.Hashtable + $scanChunk = @() + $round = 0 + + while ($candidate.Count -gt 0) { + $round++ + if ($round -gt $maximumRound) { + throw "The page set of sys.sysobjvalues in database $($Database.Name) did not close after $maximumRound rounds, so it is not safe to continue." + } + + $pending = @( + foreach ($item in $candidate) { + $key = "$($item.FileId):$($item.PageId)" + if ($item.PageId -ne 0 -and -not $visited.ContainsKey($key)) { + $visited[$key] = $true + $item + } + } + ) + + if ($pending.Count -eq 0) { + break + } + if ($visited.Count -gt $maximumPage) { + throw "Reading sys.sysobjvalues in database $($Database.Name) reached $($visited.Count) pages, well past the $expectedPage it reports, so its page pointers do not terminate." + } + + # Keyed rather than appended to, because there is one next pointer per page read and appending + # copies the array every time. Keying also collapses the duplicates that arrive when the page + # list and the next pointers name the same page. + $nextCandidate = New-Object System.Collections.Hashtable + + for ($batchStart = 0; $batchStart -lt $pending.Count; $batchStart += $BatchSize) { + $batchEnd = [Math]::Min($batchStart + $BatchSize, $pending.Count) - 1 + $batch = @($pending[$batchStart..$batchEnd]) + $pageImage = Get-PageImage -Page $batch + + foreach ($item in $batch) { + $page = $pageImage["$($item.FileId):$($item.PageId)"] + + if ($page[$pageTypeOffset] -ne $pageTypeData) { + continue + } + + $nextEntry = [PSCustomObject]@{ + FileId = [int][BitConverter]::ToUInt16($page, $nextPageOffset + 4) + PageId = [BitConverter]::ToInt32($page, $nextPageOffset) + } + $nextCandidate["$($nextEntry.FileId):$($nextEntry.PageId)"] = $nextEntry + + foreach ($row in (Get-RowFromPage -Page $page)) { + if ($row.ValueClass -ne $moduleValueClass -or -not $wantedObjectId.ContainsKey($row.ObjectId)) { + continue + } + + $chunkIndex++ + $chunkEntry = [PSCustomObject]@{ + Key = $chunkIndex + ObjectId = $row.ObjectId + ColId = $row.ColId + Cipher = $row.Value + LobRoot = $null + } + if ($row.IsLob) { + $chunkEntry.Cipher = $null + $chunkEntry.LobRoot = $row.Value + } + $scanChunk += $chunkEntry + } + } + + $pageImage = $null + } + + $candidate = @($nextCandidate.Values) + } + + Write-Message -Level Verbose -Message "Scanned $($visited.Count) pages of sys.sysobjvalues in database $($Database.Name)." + $chunk = $scanChunk + } + + # ---- resolve the off row values, then hand back the chunks -------------------------------------- + + $deferred = @($chunk | Where-Object { $null -ne $PSItem.LobRoot }) + Write-Message -Level Verbose -Message "Found $($chunk.Count) rows for $($wantedObjectId.Count) objects in database $($Database.Name), $($deferred.Count) of them stored off row." + + if ($deferred.Count -ge 1) { + $lobCipher = Get-LobData -Root $deferred + foreach ($chunkEntry in $deferred) { + if ($lobCipher.ContainsKey($chunkEntry.Key)) { + $chunkEntry.Cipher = $lobCipher[$chunkEntry.Key] + } + } + } + + # A chunk without ciphertext is an off row value whose blob tree could not be reassembled. It is handed + # back as it is, with a null cipher, so that the caller can tell the difference between an object with + # no rows and an object whose body could not be read, and can say so to the user. What must not happen + # is decrypting it from part of itself, which would return a definition built out of a fragment. + foreach ($chunkEntry in $chunk) { + if ($null -eq $chunkEntry.Cipher) { + Write-Message -Level Verbose -Message "Chunk $($chunkEntry.ColId) of object $($chunkEntry.ObjectId) in database $($Database.Name) has no ciphertext, because its off row body could not be reassembled." + } + + [PSCustomObject]@{ + ObjectId = $chunkEntry.ObjectId + ColId = $chunkEntry.ColId + Cipher = $chunkEntry.Cipher + } + } +} diff --git a/private/functions/Get-EncryptedObjectKeystream.ps1 b/private/functions/Get-EncryptedObjectKeystream.ps1 new file mode 100644 index 00000000000..a9a170612e3 --- /dev/null +++ b/private/functions/Get-EncryptedObjectKeystream.ps1 @@ -0,0 +1,108 @@ +function Get-EncryptedObjectKeystream { + <# + .SYNOPSIS + Internal function. + + .DESCRIPTION + Returns the keystream that obfuscates the stored definition of a module created WITH ENCRYPTION. + + WITH ENCRYPTION is a reversible XOR obfuscation rather than encryption. SQL Server stores the + definition text as UCS-2 XORed with an RC4 keystream, and the RC4 key is derived only from + metadata that a sysadmin can already read, so there is no secret involved: + + key = SHA1(familyGuid(16 bytes) + objectId(4 bytes LE) + colId(2 bytes LE)) + keystream = RC4(key) + plaintext = ciphertext XOR keystream + + The keystream is specific to the object id, so two objects with identical source text produce + different ciphertext. Because RC4 is a stream cipher the keystream depends only on the key and + not on the data, so exactly as many bytes as the ciphertext are generated. + + This function is used by the following public functions: + - Invoke-DbaDbDecryptObject + + .PARAMETER FamilyGuid + The family GUID of the database that holds the object, as reported by dbi_familyGUID of DBCC DBINFO. + One value per database, stable for the life of the database. + + .PARAMETER ObjectId + The object id of the encrypted object. + + .PARAMETER ColId + The subobjid of the sys.sysobjvalues row that holds this chunk of ciphertext. Almost always 1. + + .PARAMETER Length + The number of keystream bytes to generate, which is the length of the ciphertext. + + .NOTES + Tags: Encryption, Decrypt + Author: the dbatools team + Claude + + Website: https://dbatools.io + Copyright: (c) 2018 by dbatools, licensed under MIT + License: MIT https://opensource.org/licenses/MIT + + .EXAMPLE + Get-EncryptedObjectKeystream -FamilyGuid $familyGuid -ObjectId 1253579504 -ColId 1 -Length 128 + + Returns the 128 keystream bytes that decrypt the first chunk of object 1253579504. + #> + [CmdletBinding()] + [OutputType([byte[]])] + param( + [Parameter(Mandatory)] + [guid]$FamilyGuid, + [Parameter(Mandatory)] + [int]$ObjectId, + [Parameter(Mandatory)] + [int]$ColId, + [Parameter(Mandatory)] + [int]$Length + ) + + # The RC4 key is a SHA1 over 22 bytes of public metadata. The GUID has to be laid out in the byte + # order that System.Guid uses, where the first three fields are little endian. The string order of + # the GUID does not produce a working key. + $seed = New-Object byte[] 22 + [Array]::Copy($FamilyGuid.ToByteArray(), 0, $seed, 0, 16) + [Array]::Copy([BitConverter]::GetBytes([int]$ObjectId), 0, $seed, 16, 4) + [Array]::Copy([BitConverter]::GetBytes([int16]$ColId), 0, $seed, 20, 2) + + $sha1 = [System.Security.Cryptography.SHA1]::Create() + try { + $key = $sha1.ComputeHash($seed) + } finally { + $sha1.Dispose() + } + + # RC4 key scheduling algorithm. + $state = New-Object byte[] 256 + for ($i = 0; $i -lt 256; $i++) { + $state[$i] = [byte]$i + } + + $j = 0 + for ($i = 0; $i -lt 256; $i++) { + $j = ($j + $state[$i] + $key[$i % $key.Length]) -band 0xFF + $swap = $state[$i] + $state[$i] = $state[$j] + $state[$j] = $swap + } + + # RC4 pseudo random generation algorithm, one keystream byte per ciphertext byte. + $keystream = New-Object byte[] $Length + $x = 0 + $y = 0 + for ($k = 0; $k -lt $Length; $k++) { + $x = ($x + 1) -band 0xFF + $y = ($y + $state[$x]) -band 0xFF + $swap = $state[$x] + $state[$x] = $state[$y] + $state[$y] = $swap + $keystream[$k] = $state[([int]$state[$x] + [int]$state[$y]) -band 0xFF] + } + + # -NoEnumerate keeps PowerShell from unrolling the byte array into the pipeline, so the caller + # receives one byte[] rather than a stream of separate bytes. + Write-Output -NoEnumerate $keystream +} diff --git a/public/Invoke-DbaDbDecryptObject.ps1 b/public/Invoke-DbaDbDecryptObject.ps1 index 310d2658115..158afda2642 100644 --- a/public/Invoke-DbaDbDecryptObject.ps1 +++ b/public/Invoke-DbaDbDecryptObject.ps1 @@ -1,15 +1,25 @@ function Invoke-DbaDbDecryptObject { <# .SYNOPSIS - Decrypts encrypted stored procedures, functions, views, and triggers using Dedicated Admin Connection (DAC) + Decrypts encrypted stored procedures, functions, views, and triggers .DESCRIPTION - Recovers the original source code from encrypted database objects when the original scripts have been lost or are unavailable. This command uses the Dedicated Admin Connection (DAC) to access binary data from sys.sysobjvalues and performs XOR decryption to retrieve the original T-SQL code. + Recovers the original source code from encrypted database objects when the original scripts have been lost or are unavailable. WITH ENCRYPTION stores the source text combined with a keystream using XOR rather than really encrypting it, so the original T-SQL can be recovered. This is particularly useful in disaster recovery scenarios where you need to recreate objects but only have access to the encrypted versions in the database. The function can decrypt stored procedures, user-defined functions (scalar, inline, table-valued), views, and triggers. The command outputs results to the console by default, with an option to export all decrypted objects to organized .sql files in a folder structure. + Two methods are available and are selected with DataPages. + + By default the command uses a Dedicated Admin Connection (DAC) to read the binary definition from sys.sysobjvalues. It then alters the object to a known placeholder inside a transaction that is rolled back, which produces a known plaintext together with its matching ciphertext, and recovers the original text from those three values. + + With -DataPages no DAC is used and the object is never altered. The binary definition is read straight from the raw data pages with DBCC PAGE and the keystream is rebuilt from database and object metadata, so nothing is written to the database at any point. This method needs sysadmin, and it returns the definition exactly as it is stored, so object definitions that contain Unicode characters come back intact. + + Both methods need SQL Server 2008 or later. Encrypted definitions have been stored this way since SQL Server 2005, but SMO cannot work against 2005 at all, so no dbatools command reaches that version. + + The following paragraphs only apply to the default method that uses the DAC. + To connect to a remote SQL instance, the remote dedicated administrator connection option must be configured. The binary versions of encrypted objects can only be retrieved using a DAC connection. You can check the remote DAC connection with: 'Get-DbaSpConfigure -SqlInstance [yourinstance] -ConfigName RemoteDacConnectionsEnabled' @@ -43,10 +53,25 @@ function Invoke-DbaDbDecryptObject { Determines the text encoding used during the XOR decryption process to convert binary data back to readable T-SQL code. Defaults to ASCII. Use UTF8 when dealing with databases that contain Unicode characters in object definitions or when ASCII decryption produces garbled text. + Only applies to the default method that uses the DAC. With -DataPages the definition is decoded with the encoding that SQL Server actually stores it in, so this parameter is not used. + .PARAMETER ExportDestination Specifies the folder path where decrypted T-SQL scripts will be saved as individual .sql files. When specified, creates an organized folder structure by instance, database, and object type (e.g., C:\temp\decrypt\SQLDB1\DB1\StoredProcedure). When omitted, results are displayed in the console only. + .PARAMETER DataPages + Reads the binary definition from the raw data pages with DBCC PAGE instead of through a Dedicated Admin Connection. No DAC is needed, the instance does not have to allow remote DAC connections, only one connection is used, and no object is ever altered, so no write happens against the database. This needs sysadmin. + + Leave it off to keep the original behaviour of the command, where a Dedicated Admin Connection reads sys.sysobjvalues and each object is briefly altered inside a transaction that is rolled back to obtain a known plaintext. + + Two cases need -DataPages rather than merely preferring it, and both come from the default method having to alter the object. + + An INSTEAD OF trigger defined on a view cannot be decrypted by the default method at all, because that method obtains its known plaintext by rewriting the object as an AFTER trigger and a view only accepts INSTEAD OF. Reading the data pages has no such restriction. + + A read-only database is the other. A database snapshot and an availability group readable secondary can both be read with -DataPages, while the default method fails against either because it cannot alter anything there. + + Not available on Azure SQL Database or Azure SQL Managed Instance, neither of which supports DBCC PAGE. The command refuses -DataPages on both rather than failing part way through reading the pages. + .PARAMETER EnableException By default, when something goes wrong we try to catch it, interpret it and give you a friendly warning message. This avoids overwhelming you with "sea of red" exceptions, but is inconvenient because it basically disables advanced scripting. @@ -105,6 +130,11 @@ function Invoke-DbaDbDecryptObject { Decrypt objects "Function1" and "Function2" and output the data to the user using a pipeline for the instance. + .EXAMPLE + PS C:\> Invoke-DbaDbDecryptObject -SqlInstance SQLDB1 -Database DB1 -DataPages + + Decrypt all objects in DB1 of instance SQLDB1 without using a dedicated admin connection and without altering any of the objects. + #> [CmdletBinding()] param( @@ -117,6 +147,7 @@ function Invoke-DbaDbDecryptObject { [ValidateSet('ASCII', 'UTF8')] [string]$EncodingType = 'ASCII', [string]$ExportDestination, + [switch]$DataPages, [switch]$EnableException ) @@ -160,9 +191,6 @@ function Invoke-DbaDbDecryptObject { return $decryptedData } - # Create array list to hold the results - $objectCollection = New-Object System.Collections.ArrayList - # Set the encoding if ($EncodingType -eq 'ASCII') { $encoding = [System.Text.Encoding]::ASCII @@ -170,6 +198,13 @@ function Invoke-DbaDbDecryptObject { $encoding = [System.Text.Encoding]::UTF8 } + # EncodingType only means anything to the method that uses the DAC, which decodes the low byte of + # each character. Reading the data pages returns the definition in the encoding SQL Server actually + # stored it in, so say so rather than letting the parameter look like it did something. + if ($DataPages -and (Test-Bound -ParameterName EncodingType)) { + Write-Message -Level Warning -Message "EncodingType is ignored when DataPages is used, because the definition is decoded as the UCS-2 that SQL Server stores." + } + # Check the export parameter if ($ExportDestination -and -not (Test-Path $ExportDestination)) { try { @@ -190,200 +225,494 @@ function Invoke-DbaDbDecryptObject { foreach ($instance in $SqlInstance) { # Check the configuration of the intance to see if the DAC is enabled - $config = Get-DbaSpConfigure -SqlInstance $instance -SqlCredential $SqlCredential -ConfigName RemoteDacConnectionsEnabled - if ($config.ConfiguredValue -ne 1) { + if (-not $DataPages) { + $config = Get-DbaSpConfigure -SqlInstance $instance -SqlCredential $SqlCredential -ConfigName RemoteDacConnectionsEnabled + } + if (-not $DataPages -and $config.ConfiguredValue -ne 1) { Stop-Function -Message "DAC is not enabled for instance $instance.`nPlease use 'Set-DbaSpConfigure -SqlInstance $instance -SqlCredential -ConfigName RemoteDacConnectionsEnabled -Value 1' to configure the instance to allow DAC connections" -Target $instance -Continue } + $dacOpened = $false + + # Cleared per instance, because the finally below restores whatever these hold and a capture + # that failed for this instance would otherwise put the previous instance's fields on it. + $savedInitFieldsProcedure = $null + $savedInitFieldsView = $null + $savedInitFieldsFunction = $null + # Try to connect to instance try { - # Do we have a dedicated admin connection already? - $dacConnected = Test-DacConnection -InputObject $instance - $dacOpened = $false - if ($dacConnected) { - Write-Message -Level Verbose -Message "Reusing dedicated admin connection." - $server = $instance.InputObject + if (-not $DataPages) { + # Do we have a dedicated admin connection already? + $dacConnected = Test-DacConnection -InputObject $instance + if ($dacConnected) { + Write-Message -Level Verbose -Message "Reusing dedicated admin connection." + $server = $instance.InputObject + } else { + Write-Message -Level Verbose -Message "Opening dedicated admin connection." + $server = Connect-DbaInstance -SqlInstance $instance -SqlCredential $SqlCredential -DedicatedAdminConnection -WarningAction SilentlyContinue + $dacOpened = $true + } } else { - Write-Message -Level Verbose -Message "Opening dedicated admin connection." - $server = Connect-DbaInstance -SqlInstance $instance -SqlCredential $SqlCredential -DedicatedAdminConnection -WarningAction SilentlyContinue - $dacOpened = $true + # Reading the data pages needs no dedicated admin connection, so the connection of + # the caller is used as it is and is left alone afterwards. + # + # sys.sysobjvalues, which holds the encrypted definitions, arrived with SQL Server + # 2005, but the floor here is 2008. SMO cannot work against 2005 at all: enumerating + # any collection issues CONNECTIONPROPERTY and reading the databases asks for + # is_cdc_enabled, both of which arrived in 2008, so the command fails on + # $server.Databases before a page is read. 2008 itself is verified, seek route and + # page chain walk both, with every storage shape byte exact. + # + # Note that this does not actually refuse a 2005 instance. Connect-DbaInstance only + # applies MinimumVersion when it can read VersionMajor, and on 2005 that property + # comes back empty for the same reason everything else does, so the connection is + # allowed and fails later in SMO. The floor is here to say what is supported, not + # because it can be enforced at the one version where it would matter. + Write-Message -Level Verbose -Message "Opening a regular connection to read the data pages." + $connectParams = @{ + SqlInstance = $instance + SqlCredential = $SqlCredential + MinimumVersion = 10 + } + $server = Connect-DbaInstance @connectParams } } catch { Stop-Function -Message "Error occurred while establishing connection to $instance" -Category ConnectionError -ErrorRecord $_ -Target $instance -Continue } - # Get all the databases that compare to the database parameter - $databaseCollection = $server.Databases | Where-Object { $_.Name -in $Database } - - # Use the table's schema for the trigger's schema. The schema name is not returned as a property for triggers (except in the URN). - $triggerSchema = @{label = "Schema"; expression = { $_.Parent.Schema } } - - # Loop through each of databases - foreach ($db in $databaseCollection) { + # An instance allows only one dedicated admin connection, so one that is left behind blocks the + # next run until the session is killed. A terminating error anywhere below would leak the one + # this command opened, so the disconnect sits in a finally rather than at the end of the body. + try { + # Neither Azure SQL Database nor Azure SQL Managed Instance supports DBCC PAGE, so reading + # the data pages cannot work on either. MinimumVersion does not catch them, because both + # report a version this command is happy with, and without this the run gets as far as the + # first DBCC and fails on something that does not name the real problem. + # + # Both checks are needed. DatabaseEngineType only distinguishes Azure SQL Database, so a + # Managed Instance is identified by its engine edition, and that is tested first so the + # message names the right one whichever way the engine type reads. + if ($DataPages) { + $azurePlatform = $null + if ($server.DatabaseEngineEdition -eq "SqlManagedInstance") { + $azurePlatform = "Azure SQL Managed Instance" + } elseif ($server.DatabaseEngineType -eq "SqlAzureDatabase") { + $azurePlatform = "Azure SQL Database" + } - $triggers = @($db.Tables | Where-Object { $_.IsSystemObject -eq $false } | ForEach-Object { $_.Triggers }) + if ($azurePlatform) { + Stop-Function -Message "Reading the encrypted objects on $instance without a dedicated admin connection uses DBCC PAGE, which $azurePlatform does not support." -Target $instance -Continue + } - # Get the objects - if ($ObjectName) { - $storedProcedures = @($db.StoredProcedures | Where-Object { $_.Name -in $ObjectName -and $_.IsEncrypted -eq $true } | Select-Object Name, Schema, @{N = "ObjectType"; E = { 'StoredProcedure' } }, @{N = "SubType"; E = { '' } }) - $functions = @($db.UserDefinedFunctions | Where-Object { $_.Name -in $ObjectName -and $_.IsEncrypted -eq $true } | Select-Object Name, Schema, @{N = "ObjectType"; E = { "UserDefinedFunction" } }, @{N = "SubType"; E = { $_.FunctionType.ToString().Trim() } }) - $views = @($db.Views | Where-Object { $_.Name -in $ObjectName -and $_.IsEncrypted -eq $true } | Select-Object Name, Schema, @{N = "ObjectType"; E = { 'View' } }, @{N = "SubType"; E = { '' } }) - $triggers = @($triggers | Where-Object { $_.Name -in $ObjectName -and $_.IsEncrypted -eq $true } | Select-Object Name, $triggerSchema, Parent, @{N = "ObjectType"; E = { 'Trigger' } }, @{N = "SubType"; E = { '' } }) - } else { - # Get all encrypted objects - $storedProcedures = @($db.StoredProcedures | Where-Object { $_.IsEncrypted -eq $true } | Select-Object Name, Schema, @{N = "ObjectType"; E = { 'StoredProcedure' } }, @{N = "SubType"; E = { '' } }) - $functions = @($db.UserDefinedFunctions | Where-Object { $_.IsEncrypted -eq $true } | Select-Object Name, Schema, @{N = "ObjectType"; E = { "UserDefinedFunction" } }, @{N = "SubType"; E = { $_.FunctionType.ToString().Trim() } }) - $views = @($db.Views | Where-Object { $_.IsEncrypted -eq $true } | Select-Object Name, Schema, @{N = "ObjectType"; E = { 'View' } }, @{N = "SubType"; E = { '' } }) - $triggers = @($triggers | Where-Object { $_.IsEncrypted -eq $true } | Select-Object Name, $triggerSchema, Parent, @{N = "ObjectType"; E = { 'Trigger' } }, @{N = "SubType"; E = { '' } }) + # DBCC PAGE and DBCC DBINFO are limited to sysadmin, so checking up front gives a clear + # message instead of a permission error in the middle of reading the pages. + $querySysadmin = @" +SELECT IS_SRVROLEMEMBER('sysadmin') AS IsSysadmin +"@ + $sysadmin = @($server.Query($querySysadmin)) + if ($sysadmin[0].IsSysadmin -ne 1) { + Stop-Function -Message "Reading the encrypted objects without a dedicated admin connection uses DBCC PAGE, which requires sysadmin on $instance. Connect as a sysadmin or drop -DataPages." -Target $instance -Continue + } } - # Check if there are any objects - if ($storedProcedures.Count -ge 1) { - $objectCollection += $storedProcedures + # Finding the encrypted objects reads IsEncrypted on every stored procedure, function and view + # in the database. SMO does not include that property in the set it fetches when it enumerates + # a collection, so it goes back to the instance once per object, and on a database with a + # couple of thousand modules that costs far more than the decryption itself. Asking for it up + # front makes SMO fetch it as part of the enumeration. This mirrors what Connect-DbaInstance + # already does for databases, logins and jobs. + # + # ID rides along with it for the same reason. Both decrypt methods below select an object's + # rows by id rather than by name, and SMO's own ID property is already the object id, so + # fetching it here means neither method ever needs a name based lookup to get one. + # + # The setting belongs to the connection and outlives this command on one the caller owns, so + # whatever is there is captured first and put back in the finally below. Without that, a + # caller who had chosen their own fields for these three types would find them replaced for + # the rest of their session. GetDefaultInitFields hands back a copy rather than the live + # collection, which is what makes capturing it work. + try { + $savedInitFieldsProcedure = $server.GetDefaultInitFields([Microsoft.SqlServer.Management.Smo.StoredProcedure]) + $savedInitFieldsView = $server.GetDefaultInitFields([Microsoft.SqlServer.Management.Smo.View]) + $savedInitFieldsFunction = $server.GetDefaultInitFields([Microsoft.SqlServer.Management.Smo.UserDefinedFunction]) + + $initFieldsModule = New-Object System.Collections.Specialized.StringCollection + [void]$initFieldsModule.AddRange([string[]]@("Name", "Schema", "IsEncrypted", "IsSystemObject", "ID")) + $initFieldsFunction = New-Object System.Collections.Specialized.StringCollection + [void]$initFieldsFunction.AddRange([string[]]@("Name", "Schema", "IsEncrypted", "IsSystemObject", "FunctionType", "ID")) + $server.SetDefaultInitFields([Microsoft.SqlServer.Management.Smo.StoredProcedure], $initFieldsModule) + $server.SetDefaultInitFields([Microsoft.SqlServer.Management.Smo.View], $initFieldsModule) + $server.SetDefaultInitFields([Microsoft.SqlServer.Management.Smo.UserDefinedFunction], $initFieldsFunction) + } catch { + Write-Message -Level Debug -Message "SetDefaultInitFields failed with $_" + # Only a performance measure, so the command carries on with whatever SMO fetches by default. } - if ($functions.Count -ge 1) { - $objectCollection += $functions - } - if ($views.Count -ge 1) { - $objectCollection += $views - } - if ($triggers.Count -ge 1) { - $objectCollection += $triggers - } - # Loop through all the objects - foreach ($object in $objectCollection) { - # Setup the query to get the secret. Include the schema name to find the object. Exclude null values in sys.sysobjvalues for triggers. - $querySecret = "SELECT imageval AS Value FROM sys.sysobjvalues WHERE objid = OBJECT_ID('$($object.Schema).$($object.Name)') AND imageval IS NOT NULL" + # Get all the databases that compare to the database parameter + $databaseCollection = $server.Databases | Where-Object { $_.Name -in $Database } + + # Use the table's schema for the trigger's schema. The schema name is not returned as a property for triggers (except in the URN). + $triggerSchema = @{label = "Schema"; expression = { $_.Parent.Schema } } + + # Loop through each of databases + foreach ($db in $databaseCollection) { + + # Create array list to hold the results. This starts empty for every database, because the + # objects of one database must not be looked up again in the next one. + $objectCollection = @() + + # A trigger's collection hangs off its parent table, so asking every table for its triggers + # costs one round trip per table: on a database of a thousand tables that is a thousand + # queries, run even when the object asked for is a stored procedure. The tables that own an + # encrypted trigger are identified in one query first, and only those are asked. A database + # with no encrypted triggers touches no tables at all. + $queryTriggerParent = @" +SELECT DISTINCT SCHEMA_NAME(p.schema_id) AS ParentSchema, p.name AS ParentName +FROM sys.sql_modules AS m +INNER JOIN sys.objects AS t ON t.object_id = m.object_id +INNER JOIN sys.objects AS p ON p.object_id = t.parent_object_id +WHERE m.definition IS NULL +AND t.type = 'TR' +AND p.is_ms_shipped = 0 +"@ + + $triggers = @( + foreach ($triggerParent in @($db.Query($queryTriggerParent))) { + # A trigger's parent is a table or a view, and an INSTEAD OF trigger on a view is + # just as encryptable as one on a table. Looking only at tables silently skipped + # those, so both collections are asked. + $parentObject = $db.Tables[$triggerParent.ParentName, $triggerParent.ParentSchema] + if ($null -eq $parentObject) { + $parentObject = $db.Views[$triggerParent.ParentName, $triggerParent.ParentSchema] + } + if ($null -ne $parentObject) { + $parentObject.Triggers + } + } + ) + + # Get the objects + if ($ObjectName) { + $storedProcedures = @($db.StoredProcedures | Where-Object { $_.Name -in $ObjectName -and $_.IsEncrypted -eq $true } | Select-Object Name, Schema, ID, @{N = "ObjectType"; E = { "StoredProcedure" } }, @{N = "SubType"; E = { "" } }) + $functions = @($db.UserDefinedFunctions | Where-Object { $_.Name -in $ObjectName -and $_.IsEncrypted -eq $true } | Select-Object Name, Schema, ID, @{N = "ObjectType"; E = { "UserDefinedFunction" } }, @{N = "SubType"; E = { $_.FunctionType.ToString().Trim() } }) + $views = @($db.Views | Where-Object { $_.Name -in $ObjectName -and $_.IsEncrypted -eq $true } | Select-Object Name, Schema, ID, @{N = "ObjectType"; E = { "View" } }, @{N = "SubType"; E = { "" } }) + $triggers = @($triggers | Where-Object { $_.Name -in $ObjectName -and $_.IsEncrypted -eq $true } | Select-Object Name, $triggerSchema, ID, Parent, @{N = "ObjectType"; E = { "Trigger" } }, @{N = "SubType"; E = { "" } }) + } else { + # Get all encrypted objects + $storedProcedures = @($db.StoredProcedures | Where-Object { $_.IsEncrypted -eq $true } | Select-Object Name, Schema, ID, @{N = "ObjectType"; E = { "StoredProcedure" } }, @{N = "SubType"; E = { "" } }) + $functions = @($db.UserDefinedFunctions | Where-Object { $_.IsEncrypted -eq $true } | Select-Object Name, Schema, ID, @{N = "ObjectType"; E = { "UserDefinedFunction" } }, @{N = "SubType"; E = { $_.FunctionType.ToString().Trim() } }) + $views = @($db.Views | Where-Object { $_.IsEncrypted -eq $true } | Select-Object Name, Schema, ID, @{N = "ObjectType"; E = { "View" } }, @{N = "SubType"; E = { "" } }) + $triggers = @($triggers | Where-Object { $_.IsEncrypted -eq $true } | Select-Object Name, $triggerSchema, ID, Parent, @{N = "ObjectType"; E = { "Trigger" } }, @{N = "SubType"; E = { "" } }) + } - # Get the result of the secret query - try { - $secret = $server.Databases[$db.Name].Query($querySecret) - } catch { - Stop-Function -Message "Couldn't retrieve secret from $instance" -ErrorRecord $_ -Target $instance -Continue + # Check if there are any objects + if ($storedProcedures.Count -ge 1) { + $objectCollection += $storedProcedures + } + if ($functions.Count -ge 1) { + $objectCollection += $functions + } + if ($views.Count -ge 1) { + $objectCollection += $views + } + if ($triggers.Count -ge 1) { + $objectCollection += $triggers } - # Check if at least a value came back - if ($secret) { + # Both methods identify an object by its id rather than by its name. The method that + # reads the data pages needs the id to pick the rows out of sys.sysobjvalues, and the + # method that uses the dedicated admin connection needs it because an object name is + # allowed to contain a single quote: splicing the name into an OBJECT_ID('...') literal + # would let a crafted name end the literal early and run whatever followed it as a + # further statement in the same batch, as sysadmin over the dedicated admin connection. + # An id is a number, so nothing a name contains can reach the query text. + # + # The id is SMO's own ID property on each object, fetched as part of the enumeration + # above rather than looked up afterwards by schema and name. A lookup keyed by + # "$schema.$name" cannot be unambiguous, because either half may itself contain a dot: + # schema [a], object [b.c] and schema [a.b], object [c] both key as "a.b.c", so whichever + # of the two is read last silently overwrites the other's id in the map, and the object + # that lost the race is then decrypted with the wrong ciphertext. Reading the id SMO + # already carries on the object needs no key at all, so two objects sharing a dotted name + # can no longer collide. + + # Without a dedicated admin connection the ciphertext of every requested object is read in + # a single pass over the data pages of sys.sysobjvalues, because reading those pages is by + # far the most expensive part. The family GUID of the database and the object ids are the + # only other inputs that rebuilding the keystream needs. + if ($DataPages) { + $familyGuid = $null + $imageValueMap = @{ } - # Setup a known plain command and get the binary version of it - switch ($object.ObjectType) { + try { + $familyGuidRow = @($db.Query("DBCC DBINFO WITH TABLERESULTS") | Where-Object Field -eq "dbi_familyGUID") + } catch { + Stop-Function -Message "Couldn't read dbi_familyGUID of database $($db.Name) on $instance" -ErrorRecord $_ -Target $instance -Continue + } - 'StoredProcedure' { - $queryKnownPlain = (" " * $secret.Value.Length) + "ALTER PROCEDURE [$($object.Schema)].[$($object.Name)] WITH ENCRYPTION AS RETURN 0;" - } - 'UserDefinedFunction' { + if ($familyGuidRow.Count -eq 0) { + Stop-Function -Message "Couldn't read dbi_familyGUID of database $($db.Name) on $instance" -Target $instance -Continue + } + $familyGuid = [guid]$familyGuidRow[0].VALUE - switch ($object.SubType) { - 'Inline' { - $queryKnownPlain = (" " * $secret.value.length) + "ALTER FUNCTION [$($object.Schema)].[$($object.Name)]() RETURNS TABLE WITH ENCRYPTION AS RETURN SELECT 0 i;" - } - 'Scalar' { - $queryKnownPlain = (" " * $secret.value.length) + "ALTER FUNCTION [$($object.Schema)].[$($object.Name)]() RETURNS INT WITH ENCRYPTION AS BEGIN RETURN 0 END;" - } - 'Table' { - $queryKnownPlain = (" " * $secret.value.length) + "ALTER FUNCTION [$($object.Schema)].[$($object.Name)]() RETURNS @r TABLE(i INT) WITH ENCRYPTION AS BEGIN RETURN END;" + $wantedObjectId = @($objectCollection.ID) + + if ($wantedObjectId.Count -ge 1) { + try { + foreach ($imageValue in (Get-EncryptedObjectImageValue -Database $db -ObjectId $wantedObjectId)) { + if (-not $imageValueMap.ContainsKey($imageValue.ObjectId)) { + $imageValueMap[$imageValue.ObjectId] = @() } + $imageValueMap[$imageValue.ObjectId] += $imageValue } + } catch { + Stop-Function -Message "Couldn't read the encrypted definitions from the data pages of database $($db.Name) on $instance" -ErrorRecord $_ -Target $instance -Continue } - 'View' { - $queryKnownPlain = (" " * $secret.Value.Length) + "ALTER VIEW [$($object.Schema)].[$($object.Name)] WITH ENCRYPTION AS SELECT NULL AS [Value];" + } + } + + # Loop through all the objects + foreach ($object in $objectCollection) { + + $result = $null + + # Both methods below select this object's rows by id, taken from SMO's own ID + # property rather than looked up by schema and name. + $decryptObjectId = [int]$object.ID + + # Without a dedicated admin connection the ciphertext that was collected for this object + # is combined with the keystream that its own metadata produces. + if ($DataPages) { + # Asked for a key it does not hold, a hashtable answers with null, and wrapping + # null in @() gives an array of one null rather than an empty one. That reads as + # a chunk with no ciphertext, so an object the reader deliberately skipped - a + # CLR module, say - would be reported as one whose body could not be recovered. + $chunkCollection = @() + if ($imageValueMap.ContainsKey($decryptObjectId)) { + $chunkCollection = @($imageValueMap[$decryptObjectId]) } - 'Trigger' { - $queryKnownPlain = (" " * $secret.Value.Length) + "ALTER TRIGGER [$($object.Schema)].[$($object.Name)] ON $($object.Parent) WITH ENCRYPTION AFTER INSERT AS RAISERROR (''Invoke-DbaDbDecryptObject'', 16, 10);" + + # A chunk without ciphertext means an off row body that could not be reassembled. + # Decrypting the rest would return a definition built from a fragment, so this + # object is reported and skipped while the others carry on. + $unresolvedChunk = @($chunkCollection | Where-Object { $null -eq $PSItem.Cipher }) + + if ($unresolvedChunk.Count -gt 0) { + Write-Message -Level Warning -Message "Couldn't recover the off row body of $($object.Schema).$($object.Name) in database $($db.Name) on $instance, so it is not returned." + } elseif ($chunkCollection.Count -ge 1) { + $chunkParams = @{ + FamilyGuid = $familyGuid + ObjectId = $decryptObjectId + Chunk = $chunkCollection + } + + # Anything wrong with this object's ciphertext fails this object and lets the + # rest of the run stand, rather than ending the command part way through. + try { + $result = ConvertFrom-EncryptedObjectChunk @chunkParams + } catch { + Stop-Function -Message "Couldn't decrypt $($object.Schema).$($object.Name) in database $($db.Name) on $instance" -ErrorRecord $_ -Target $instance -Continue + } } } - # Convert the known plain into binary - if ($queryKnownPlain) { + # Only the DAC can read imageval directly, so none of this runs for the method that reads + # the data pages, and the block below is skipped with it. + $secret = $null + if (-not $DataPages) { + # Setup the query to get the secret. Select by object id rather than by name. Exclude null values in sys.sysobjvalues for triggers. + $querySecret = @" +SELECT imageval AS Value FROM sys.sysobjvalues WHERE objid = $decryptObjectId AND imageval IS NOT NULL +"@ + + # Get the result of the secret query try { - $knownPlain = $encoding.GetBytes(($queryKnownPlain)) + $secret = $server.Databases[$db.Name].Query($querySecret) } catch { - Stop-Function -Message "Couldn't convert the known plain to binary" -ErrorRecord $_ -Target $instance -Continue + Stop-Function -Message "Couldn't retrieve secret from $instance" -ErrorRecord $_ -Target $instance -Continue } - } else { - Stop-Function -Message "Something went wrong setting up the known plain" -Target $instance -Continue } - # Setup the query to change the object in SQL Server and roll it back getting the encrypted version - # Exclude null values in sys.sysobjvalues for triggers and include the full schema and object name. - $queryKnownSecret = " - BEGIN TRANSACTION; - EXEC ('$queryKnownPlain'); - SELECT imageval AS Value - FROM sys.sysobjvalues - WHERE objid = OBJECT_ID('$($object.Schema).$($object.Name)') - AND imageval IS NOT NULL; - ROLLBACK; - " - - # Get the result for the known encrypted - try { - $knownSecret = $server.Databases[$db.Name].Query($queryKnownSecret) - } catch { - Stop-Function -Message "Couldn't retrieve known secret from $instance" -ErrorRecord $_ -Target $instance -Continue - } + # Check if at least a value came back + if ($secret) { - # Get the result - $result = Invoke-DecryptData -Secret $secret.value -KnownPlain $knownPlain -KnownSecret $knownSecret.value + # A schema, object or parent name is allowed to contain a closing bracket, which + # would end the identifier early and leave the rest of the name standing as + # statement text. Doubling it keeps the whole name inside the brackets. + $bracketedSchema = $object.Schema -replace "\]", "]]" + $bracketedName = $object.Name -replace "\]", "]]" - # Check if the results need to be exported - $filePath = $null - if ($ExportDestination) { - # make up the file name - $filename = "$($object.Schema).$($object.Name).sql" + # Cleared per object, because it survives the loop otherwise and an object type + # that matched no branch below would be altered with the previous object's + # statement instead of reaching the check that says the known plain is missing. + $queryKnownPlain = $null - # Check the export destination - if ($ExportDestination.EndsWith("\")) { - $destinationFolder = "$ExportDestination$instance\$($db.Name)\$($object.ObjectType)\" - } else { - $destinationFolder = "$ExportDestination\$instance\$($db.Name)\$($object.ObjectType)\" + # Setup a known plain command and get the binary version of it + switch ($object.ObjectType) { + + 'StoredProcedure' { + $queryKnownPlain = (" " * $secret.Value.Length) + "ALTER PROCEDURE [$bracketedSchema].[$bracketedName] WITH ENCRYPTION AS RETURN 0;" + } + 'UserDefinedFunction' { + + switch ($object.SubType) { + 'Inline' { + $queryKnownPlain = (" " * $secret.value.length) + "ALTER FUNCTION [$bracketedSchema].[$bracketedName]() RETURNS TABLE WITH ENCRYPTION AS RETURN SELECT 0 i;" + } + 'Scalar' { + $queryKnownPlain = (" " * $secret.value.length) + "ALTER FUNCTION [$bracketedSchema].[$bracketedName]() RETURNS INT WITH ENCRYPTION AS BEGIN RETURN 0 END;" + } + 'Table' { + $queryKnownPlain = (" " * $secret.value.length) + "ALTER FUNCTION [$bracketedSchema].[$bracketedName]() RETURNS @r TABLE(i INT) WITH ENCRYPTION AS BEGIN RETURN END;" + } + } + } + 'View' { + $queryKnownPlain = (" " * $secret.Value.Length) + "ALTER VIEW [$bracketedSchema].[$bracketedName] WITH ENCRYPTION AS SELECT NULL AS [Value];" + } + 'Trigger' { + # A trigger names its parent as well as itself, so the parent gets the same + # treatment: taken from the object rather than left to however the SMO + # object renders as a string, and bracketed like every other identifier. + $bracketedParentSchema = $object.Parent.Schema -replace "\]", "]]" + $bracketedParentName = $object.Parent.Name -replace "\]", "]]" + + # The quotes around the message are single, not doubled. This statement is + # written as it should reach SQL Server, and the doubling that puts it + # inside the EXEC literal happens in one place below, so that a quote in + # an object name is escaped by the same pass rather than being missed. + $statementTrigger = @" +ALTER TRIGGER [$bracketedSchema].[$bracketedName] ON [$bracketedParentSchema].[$bracketedParentName] WITH ENCRYPTION AFTER INSERT AS RAISERROR ('Invoke-DbaDbDecryptObject', 16, 10); +"@ + $queryKnownPlain = (" " * $secret.Value.Length) + $statementTrigger + } } - # Check if the destination folder exists - if (-not (Test-Path $destinationFolder)) { + # Convert the known plain into binary + if ($queryKnownPlain) { try { - # Create the new destination - New-Item -Path $destinationFolder -ItemType Directory -Force:$Force | Out-Null + $knownPlain = $encoding.GetBytes(($queryKnownPlain)) } catch { - Stop-Function -Message "Couldn't create destination folder $destinationFolder" -ErrorRecord $_ -Target $instance -Continue + Stop-Function -Message "Couldn't convert the known plain to binary" -ErrorRecord $_ -Target $instance -Continue } + } else { + Stop-Function -Message "Something went wrong setting up the known plain" -Target $instance -Continue } - # Combine the destination folder and the file name to get the path - $filePath = $destinationFolder + $filename - - # Export the result + # Setup the query to change the object in SQL Server and roll it back getting the encrypted version + # Exclude null values in sys.sysobjvalues for triggers and select the object by id. + # + # The whole statement goes inside the string literal that EXEC runs, so every + # single quote in it has to be doubled or the literal ends early and whatever + # follows runs as a further statement in the same batch - as sysadmin, over the + # dedicated admin connection. An object name may contain a quote, so this is the + # escape that has to be in place, not only the doubling of the quotes written + # above. Character 39 is the quote itself, named so that this line does not + # need one of its own. + $quoteCharacter = [string][char]39 + $queryKnownPlainLiteral = $queryKnownPlain -replace $quoteCharacter, ($quoteCharacter * 2) + + $queryKnownSecret = @" +BEGIN TRANSACTION; + EXEC ($quoteCharacter$queryKnownPlainLiteral$quoteCharacter); + SELECT imageval AS Value + FROM sys.sysobjvalues + WHERE objid = $decryptObjectId + AND imageval IS NOT NULL; +ROLLBACK; +"@ + + # Get the result for the known encrypted try { - $result | Out-File -FilePath $filePath -Force + $knownSecret = $server.Databases[$db.Name].Query($queryKnownSecret) } catch { - Stop-Function -Message "Couldn't export the results of $($object.Name) to $filePath" -ErrorRecord $_ -Target $instance -Continue + Stop-Function -Message "Couldn't retrieve known secret from $instance" -ErrorRecord $_ -Target $instance -Continue } + # Get the result + $result = Invoke-DecryptData -Secret $secret.value -KnownPlain $knownPlain -KnownSecret $knownSecret.value } - # Add the results to the custom object - [PSCustomObject]@{ - ComputerName = $instance.ComputerName - InstanceName = $server.ServiceName - SqlInstance = $server.DomainInstanceName - Database = $db.Name - Type = $object.ObjectType - Schema = $object.Schema - Name = $object.Name - FullName = "$($object.Schema).$($object.Name)" - Script = $result - OutputFile = $filePath + # Both methods arrive at the decrypted script here, so exporting it and returning it is + # shared between them. + if ($null -ne $result) { + # Check if the results need to be exported + $filePath = $null + if ($ExportDestination) { + # make up the file name + $filename = "$($object.Schema).$($object.Name).sql" + + # Check the export destination + if ($ExportDestination.EndsWith("\")) { + $destinationFolder = "$ExportDestination$instance\$($db.Name)\$($object.ObjectType)\" + } else { + $destinationFolder = "$ExportDestination\$instance\$($db.Name)\$($object.ObjectType)\" + } + + # Check if the destination folder exists + if (-not (Test-Path $destinationFolder)) { + try { + # Create the new destination + # Plain -Force, matching the call in begin. It must not be written as + # -Force:$Force: this command has no Force parameter, so that binds + # $null and silently turns the switch off, and under StrictMode it + # throws instead. + New-Item -Path $destinationFolder -ItemType Directory -Force | Out-Null + } catch { + Stop-Function -Message "Couldn't create destination folder $destinationFolder" -ErrorRecord $_ -Target $instance -Continue + } + } + + # Combine the destination folder and the file name to get the path + $filePath = $destinationFolder + $filename + + # Export the result + try { + $result | Out-File -FilePath $filePath -Force + } catch { + Stop-Function -Message "Couldn't export the results of $($object.Name) to $filePath" -ErrorRecord $_ -Target $instance -Continue + } + + } + + # Add the results to the custom object + [PSCustomObject]@{ + ComputerName = $instance.ComputerName + InstanceName = $server.ServiceName + SqlInstance = $server.DomainInstanceName + Database = $db.Name + Type = $object.ObjectType + Schema = $object.Schema + Name = $object.Name + FullName = "$($object.Schema).$($object.Name)" + Script = $result + OutputFile = $filePath + } } } } - } - if ($dacOpened) { - $null = $server | Disconnect-DbaInstance -WhatIf:$false + } finally { + # The init fields belong to the connection, not to this command, so they go back however + # the run ended. Its own try/catch, because a failure here must not replace whatever error + # sent us to this finally in the first place. + if ($null -ne $savedInitFieldsProcedure) { + try { + $server.SetDefaultInitFields([Microsoft.SqlServer.Management.Smo.StoredProcedure], $savedInitFieldsProcedure) + $server.SetDefaultInitFields([Microsoft.SqlServer.Management.Smo.View], $savedInitFieldsView) + $server.SetDefaultInitFields([Microsoft.SqlServer.Management.Smo.UserDefinedFunction], $savedInitFieldsFunction) + } catch { + Write-Message -Level Debug -Message "Restoring the default init fields failed with $_" + } + } + + if ($dacOpened) { + $null = $server | Disconnect-DbaInstance -WhatIf:$false + } } } } end { Write-Message -Message "Finished decrypting data" -Level Verbose } -} \ No newline at end of file +} diff --git a/tests/Invoke-DbaDbDecryptObject.Tests.ps1 b/tests/Invoke-DbaDbDecryptObject.Tests.ps1 index 09a276248e2..699e78b3006 100644 --- a/tests/Invoke-DbaDbDecryptObject.Tests.ps1 +++ b/tests/Invoke-DbaDbDecryptObject.Tests.ps1 @@ -6,6 +6,32 @@ param( ) Describe $CommandName -Tag UnitTests { + BeforeAll { + # These tests call private functions, which are only reachable inside the module. Get-Module + # returns one object for every loaded copy of dbatools, and a session readily holds more than + # one: Invoke-ManualPester imports dbatools.psd1 and dbatools.psm1, which leaves a binary module + # and a script module both named dbatools. Handing that array to the call operator makes + # PowerShell join the names and look for a command called "dbatools dbatools", and the call + # operator refuses a binary module outright, so the script copy that actually carries the + # private functions is resolved once here and reused. + $dbatoolsModule = $null + foreach ($candidate in @(Get-Module dbatools | Where-Object ModuleType -eq "Script")) { + $hasPrivateFunction = $false + try { + $hasPrivateFunction = & $candidate { [bool](Get-Command ConvertFrom-DbccPageDump -ErrorAction SilentlyContinue) } + } catch { + $hasPrivateFunction = $false + } + if ($hasPrivateFunction) { + $dbatoolsModule = $candidate + break + } + } + if ($null -eq $dbatoolsModule) { + throw "No loaded dbatools script module exposes the private functions these tests call. Import dbatools.psm1 before running them." + } + } + Context "Parameter validation" { It "Should have the expected parameters" { $hasParameters = (Get-Command $CommandName).Parameters.Values.Name | Where-Object { $PSItem -notin ("WhatIf", "Confirm") } @@ -17,12 +43,205 @@ Describe $CommandName -Tag UnitTests { "ExportDestination", "ObjectName", "SqlCredential", - "SqlInstance" + "SqlInstance", + "DataPages" ) Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty } } + Context "Page dump parsing" { + BeforeDiscovery { + # DBCC PAGE does not use one fixed line width, and the ASCII gutter at the end of a line can + # begin with a token of exactly eight hex digits, which the rule "eight hex digits is data" + # would take as four more bytes of the page. + $dumpCase = @( + @{ + LineWidth = 16 + HexGutter = $false + Label = "16 bytes per line" + }, + @{ + LineWidth = 20 + HexGutter = $false + Label = "20 bytes per line" + }, + @{ + LineWidth = 16 + HexGutter = $true + Label = "16 bytes per line and a gutter that starts with hex digits" + }, + @{ + LineWidth = 20 + HexGutter = $true + Label = "20 bytes per line and a gutter that starts with hex digits" + } + ) + } + + BeforeAll { + # A deterministic page, so that a failure is reproducible. + $expectedPage = New-Object byte[] 8192 + for ($pageOffset = 0; $pageOffset -lt 8192; $pageOffset++) { + $expectedPage[$pageOffset] = [byte](($pageOffset * 37 + 11) % 256) + } + + function Format-TestPageDump { + param( + [byte[]]$Page, + [int]$LineWidth, + [switch]$HexGutter + ) + + # The address DBCC prints is a memory address, so it does not start at zero. + $baseAddress = [Convert]::ToUInt64("0000000332FF4000", 16) + $dumpLine = @() + + for ($offset = 0; $offset -lt $Page.Length; $offset += $LineWidth) { + $take = [Math]::Min($LineWidth, $Page.Length - $offset) + $hex = "" + for ($group = 0; $group -lt $take; $group += 4) { + $token = "" + for ($byteIndex = 0; $byteIndex -lt 4 -and ($group + $byteIndex) -lt $take; $byteIndex++) { + $token += $Page[$offset + $group + $byteIndex].ToString("x2") + } + $hex += "$token " + } + + # A real gutter renders the page bytes as characters, so a space byte splits it into + # tokens and a run of hex digit characters makes the first token look like page data. + if ($HexGutter) { + $gutter = "deadbeef ......." + } else { + $gutter = "................" + } + + $dumpLine += "$(($baseAddress + $offset).ToString("X16")): $hex $gutter" + } + + return $dumpLine + } + } + + It "Parses a page dump with