-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Fix large-page ESE tag-state parsing for Windows Server 2025 NTDS.dit (issue #1924) #2158
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -58,6 +58,11 @@ | |||||||||||||||||||||||||
| FLAGS_NEW_FORMAT = 0x2000 | ||||||||||||||||||||||||||
| FLAGS_NEW_CHECKSUM = 0x2000 | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| # On 16 KiB and 32 KiB pages, the raw 16-bit tag state appears to store the total | ||||||||||||||||||||||||||
| # tag count in the lower 12 bits. The upper 4 bits seem to represent a reserved tag count. | ||||||||||||||||||||||||||
| FIRST_AVAILABLE_PAGE_TAG_MASK = 0x0fff | ||||||||||||||||||||||||||
| FIRST_AVAILABLE_PAGE_TAG_RESERVED_SHIFT = 12 | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| # Tag Flags | ||||||||||||||||||||||||||
| TAG_UNKNOWN = 0x1 | ||||||||||||||||||||||||||
| TAG_DEFUNCT = 0x2 | ||||||||||||||||||||||||||
|
|
@@ -436,8 +441,17 @@ def __init__(self, db, data=None): | |||||||||||||||||||||||||
| self.__DBHeader = db | ||||||||||||||||||||||||||
| self.data = data | ||||||||||||||||||||||||||
| self.record = None | ||||||||||||||||||||||||||
| self.tagCount = 0 | ||||||||||||||||||||||||||
| self.tagReserved = 1 | ||||||||||||||||||||||||||
| if data is not None: | ||||||||||||||||||||||||||
| self.record = ESENT_PAGE_HEADER(self.__DBHeader['Version'], self.__DBHeader['FileFormatRevision'], self.__DBHeader['PageSize'], data) | ||||||||||||||||||||||||||
| self.tagCount = self.record['FirstAvailablePageTag'] | ||||||||||||||||||||||||||
| if self.__DBHeader['FileFormatRevision'] >= 0x11 and self.__DBHeader['PageSize'] > 8192: | ||||||||||||||||||||||||||
| # TODO: The upper 4 bits may encode how many leading tags are reserved on large pages. | ||||||||||||||||||||||||||
| # Logical node counts should be derived from the effective reserved-tag count | ||||||||||||||||||||||||||
| # instead of assuming only tag 0 is reserved, the logical node count should be tagCount - tagReserved. | ||||||||||||||||||||||||||
| self.tagReserved = (self.record['FirstAvailablePageTag'] >> FIRST_AVAILABLE_PAGE_TAG_RESERVED_SHIFT) or 1 | ||||||||||||||||||||||||||
| self.tagCount = self.record['FirstAvailablePageTag'] & FIRST_AVAILABLE_PAGE_TAG_MASK | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
| # Logical node counts should be derived from the effective reserved-tag count | |
| # instead of assuming only tag 0 is reserved, the logical node count should be tagCount - tagReserved. | |
| self.tagReserved = (self.record['FirstAvailablePageTag'] >> FIRST_AVAILABLE_PAGE_TAG_RESERVED_SHIFT) or 1 | |
| self.tagCount = self.record['FirstAvailablePageTag'] & FIRST_AVAILABLE_PAGE_TAG_MASK | |
| # Logical node counts should be derived from the effective reserved-tag count | |
| # instead of assuming only tag 0 is reserved, the logical node count should be tagCount - tagReserved. | |
| raw_tag_field = self.record['FirstAvailablePageTag'] | |
| self.tagReserved = (raw_tag_field >> FIRST_AVAILABLE_PAGE_TAG_RESERVED_SHIFT) or 1 | |
| physicalTagCount = raw_tag_field & FIRST_AVAILABLE_PAGE_TAG_MASK | |
| # On large pages, adjust tagCount so it represents the logical node count (excluding all reserved tags). | |
| # When tagReserved == 1 (the legacy assumption), this reduces to the original behavior. | |
| self.tagCount = physicalTagCount - (self.tagReserved - 1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Intentional. This PR fixes the confirmed #1924 crash by correcting the large-page
tag count parsing. tagReserved is modeled for parity with dissect.esedb, but
I am not changing logical-node traversal without a sample exposing effective
tagReserved > 1, since that would require a broader change than this bug fix.
Copilot
AI
Mar 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change fixes a specific crash/regression for 32 KiB pages by masking FirstAvailablePageTag to 12 bits. Please add a regression test (unit-level if possible) that builds/parses a page header where FirstAvailablePageTag has high bits set (e.g., 0x100c) and asserts tagCount == 0x000c and tag iteration does not raise.
Copilot
AI
Mar 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The large-page condition in ESENT_PAGE.__init__ only checks FileFormatRevision/PageSize, but other large-page parsing logic in this module (e.g., getTag()) also gates on Version == 0x620. Consider aligning the predicate here with getTag() to avoid masking FirstAvailablePageTag on database versions that don’t use the 12-bit tag-count encoding.
Copilot
AI
Mar 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In dump(), the large-page tag decoding check uses FileFormatRevision > 11, but elsewhere the Windows 7+ boundary is treated as >= 0x11 / >= 17 (see getTag() and ESENT_PAGE_HEADER). To keep behavior consistent and avoid applying the large-page decoding to revisions 0x0c–0x10, update this condition to match the same threshold used elsewhere.
| if self.__DBHeader['Version'] == 0x620 and self.__DBHeader['FileFormatRevision'] > 11 and self.__DBHeader['PageSize'] > 8192: | |
| if self.__DBHeader['Version'] == 0x620 and self.__DBHeader['FileFormatRevision'] >= 0x11 and self.__DBHeader['PageSize'] > 8192: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree the condition is inconsistent with the rest of the module, but it is outside the crash path fixed here and I do not have a sample showing that revisions 0x0c..0x10 are mishandled by dump(). I’d prefer to keep this PR scoped
to the reproducible
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment block under the large-page
ifis over-indented (lines after theTODOhave extra indentation). This makes the code harder to read; align the comment indentation with the rest of the block.