From edd32c357afcee63f94c7938c08c14f06d57de96 Mon Sep 17 00:00:00 2001 From: Peter Pan Date: Wed, 5 Aug 2026 13:43:10 +0200 Subject: [PATCH 1/2] Translate NTSTATUS into the last-error slot instead of discarding it NTSTATUS routines report failure in their return value and never call SetLastError. The five wrappers here collapsed that to a bool, which threw away the only copy of the error: nt_status = _NtUnmapViewOfSection(process_handle, base_address) return nt_status == STATUS_SUCCESS A Win32Exception() raised afterwards then reads whatever the *previous* call left in the thread-local slot. Measured: NtUnmapViewOfSection(bad addr): NTSTATUS=0xC0000019 GetLastError=0 after SetLastError(1234), same call: NTSTATUS=0xC0000019 GetLastError=1234 CloseHandle(pseudo) succeeds: GetLastError=1234 Both outcomes are wrong, in different ways. SharedMemory.destroy() shows it: UnmapViewOfFile and CloseHandle run before NtUnmapViewOfSection, so if they succeed on a fresh thread the slot is 0 and FormatMessageW(0) yields "The operation completed successfully." inside a raised exception. If they failed, step three reports *their* code -- wrong but plausible, and harder to spot. _nt_ok() translates via RtlNtStatusToDosError and publishes the result, so 0xC0000019 surfaces as 487 "Attempt to access invalid address." regardless of what ran before. Success leaves the slot untouched, matching how a successful Win32 call behaves. Also exports SetLastError and RtlNtStatusToDosError, which the module needed internally and callers can reasonably want. tests/test_ntstatus_last_error.py covers the translation table, both last-error states around a real NtUnmapViewOfSection failure, and that success does not clobber the slot. Three of the five fail without this change. --- MemLib/windows.py | 73 ++++++++++++++++++++++++++++--- tests/test_ntstatus_last_error.py | 64 +++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 tests/test_ntstatus_last_error.py diff --git a/MemLib/windows.py b/MemLib/windows.py index d7208a8..9c88299 100644 --- a/MemLib/windows.py +++ b/MemLib/windows.py @@ -154,6 +154,61 @@ def GetLastError() -> int: """ return _GetLastError() +# noinspection PyPep8Naming +# pylint: disable=invalid-name +def SetLastError(error_code: int) -> None: + """ + Sets the last-error code for the calling thread. + + Args: + error_code (int): The last-error code to publish. + + See also: + https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-setlasterror + """ + _SetLastError(error_code) + +# noinspection PyPep8Naming +# pylint: disable=invalid-name +def RtlNtStatusToDosError(nt_status: int) -> int: + """ + Converts an NTSTATUS code into the equivalent Win32 error code. + + Args: + nt_status (int): The NTSTATUS value to translate. + + Returns: + int: The matching Win32 error code, or ERROR_MR_MID_NOT_FOUND (317) if the + status has no Win32 equivalent. + + See also: + https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-rtlntstatustodoserror + """ + return _RtlNtStatusToDosError(nt_status) + +def _nt_ok(nt_status: int) -> bool: + """ + Converts an NTSTATUS return value into a bool, publishing the failure via SetLastError. + + NTSTATUS routines report failure in their return value and do not call SetLastError. + Collapsing one to a bool therefore discards the only copy of the error: a following + ``Win32Exception()`` reads whatever the *previous* call left in the thread-local + last-error slot, which is 0 on a fresh thread ("The operation completed successfully") + and a stale, unrelated code otherwise. Translating the status keeps the real failure + reachable at the call site. + + Args: + nt_status (int): The NTSTATUS value returned by the routine. + + Returns: + bool: True if the status is STATUS_SUCCESS, otherwise False. + """ + if nt_status == STATUS_SUCCESS: + return True + + _SetLastError(_RtlNtStatusToDosError(nt_status)) + return False + # noinspection PyPep8Naming # pylint: disable=invalid-name def FormatMessage( @@ -1075,7 +1130,7 @@ def NtMapViewOfSection( win32_protect ) - return nt_status == STATUS_SUCCESS + return _nt_ok(nt_status) # noinspection PyPep8Naming # pylint: disable=invalid-name @@ -1095,7 +1150,7 @@ def NtUnmapViewOfSection(process_handle: int, base_address: int) -> bool: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-zwunmapviewofsection """ nt_status: int = _NtUnmapViewOfSection(process_handle, base_address) - return nt_status == STATUS_SUCCESS + return _nt_ok(nt_status) # noinspection PyPep8Naming # pylint: disable=invalid-name @@ -1127,7 +1182,7 @@ def NtQueryInformationProcess( process_information_length, 0 ) - return nt_status == STATUS_SUCCESS + return _nt_ok(nt_status) # noinspection PyPep8Naming # pylint: disable=invalid-name @@ -1145,7 +1200,7 @@ def NtSuspendProcess(process_handle: int) -> bool: https://cyberstoph.org/posts/2021/05/fun-with-processes-suspend-and-resume/ """ nt_status: int = _NtSuspendProcess(process_handle) - return nt_status == STATUS_SUCCESS + return _nt_ok(nt_status) # noinspection PyPep8Naming # pylint: disable=invalid-name @@ -1163,7 +1218,7 @@ def NtResumeProcess(process_handle: int) -> bool: https://cyberstoph.org/posts/2021/05/fun-with-processes-suspend-and-resume/ """ nt_status: int = _NtResumeProcess(process_handle) - return nt_status == STATUS_SUCCESS + return _nt_ok(nt_status) # noinspection PyPep8Naming # pylint: disable=invalid-name @@ -1732,6 +1787,14 @@ def MessageBoxW(window_handle: int, text: str, caption: str, type_flags: int) -> _GetLastError.argtypes = [] _GetLastError.restype = DWORD +_SetLastError = windll.kernel32.SetLastError +_SetLastError.argtypes = [DWORD] +_SetLastError.restype = None + +_RtlNtStatusToDosError = windll.ntdll.RtlNtStatusToDosError +_RtlNtStatusToDosError.argtypes = [ULONG] +_RtlNtStatusToDosError.restype = ULONG + _FormatMessageA = windll.kernel32.FormatMessageA _FormatMessageA.argtypes = [DWORD, LPVOID, DWORD, DWORD, LPSTR, DWORD, LPVOID] _FormatMessageA.restype = DWORD diff --git a/tests/test_ntstatus_last_error.py b/tests/test_ntstatus_last_error.py new file mode 100644 index 0000000..0d7ddd9 --- /dev/null +++ b/tests/test_ntstatus_last_error.py @@ -0,0 +1,64 @@ +"""NTSTATUS routines report failure in their return value, not via SetLastError. + +Without translation, a `Win32Exception()` raised after one of them reads whatever the +previous call left behind: 0 on a fresh thread ("The operation completed successfully"), +or a stale unrelated code otherwise. These tests pin the translated behaviour. +""" + +import ctypes + +from MemLib.Constants import STATUS_SUCCESS +from MemLib.Process import Process +from MemLib.windows import ( + GetLastError, NtUnmapViewOfSection, RtlNtStatusToDosError, SetLastError, Win32Exception, _nt_ok, +) + +# STATUS_CONFLICTING_ADDRESSES: what NtUnmapViewOfSection returns for an address that is +# not the base of a mapped view. Translates to ERROR_INVALID_ADDRESS (487). +STATUS_CONFLICTING_ADDRESSES: int = 0xC0000019 +ERROR_INVALID_ADDRESS: int = 487 +UNMAPPED_ADDRESS: int = 0xDEAD0000 + + +def test_status_translates_to_the_matching_win32_code(): + assert RtlNtStatusToDosError(STATUS_CONFLICTING_ADDRESSES) == ERROR_INVALID_ADDRESS + assert RtlNtStatusToDosError(0xC0000008) == 6 # STATUS_INVALID_HANDLE -> ERROR_INVALID_HANDLE + assert RtlNtStatusToDosError(0xC000000D) == 87 # STATUS_INVALID_PARAMETER -> ERROR_INVALID_PARAMETER + + +def test_nt_ok_leaves_last_error_alone_on_success(): + SetLastError(1234) + + assert _nt_ok(STATUS_SUCCESS) is True + # A successful Win32 call does not clear the last error either, so neither does this. + assert GetLastError() == 1234 + + +def test_nt_ok_publishes_the_translated_failure(): + SetLastError(0) + + assert _nt_ok(STATUS_CONFLICTING_ADDRESSES) is False + assert GetLastError() == ERROR_INVALID_ADDRESS + + +def test_failure_is_reported_even_from_a_clean_last_error(): + """Regression: GetLastError() == 0 here made Win32Exception() say + 'The operation completed successfully.' inside a raised exception.""" + process = Process(ctypes.windll.kernel32.GetCurrentProcessId()) + SetLastError(0) + + assert NtUnmapViewOfSection(process.handle, UNMAPPED_ADDRESS) is False + + error = Win32Exception() + assert error.code == ERROR_INVALID_ADDRESS + assert "completed successfully" not in error.message + + +def test_failure_is_not_masked_by_a_stale_last_error(): + """Regression: the stale code from an earlier call was reported instead, which is + wrong but plausible -- harder to spot than the 'success' message.""" + process = Process(ctypes.windll.kernel32.GetCurrentProcessId()) + SetLastError(1234) + + assert NtUnmapViewOfSection(process.handle, UNMAPPED_ADDRESS) is False + assert GetLastError() == ERROR_INVALID_ADDRESS From 66456d5e9be0707f40ba7f9b5ce967a47d995853 Mon Sep 17 00:00:00 2001 From: Peter Pan Date: Wed, 5 Aug 2026 16:57:18 +0200 Subject: [PATCH 2/2] Address review: correct the NTSTATUS name, call the public wrappers 0xC0000019 is STATUS_NOT_MAPPED_VIEW, not STATUS_CONFLICTING_ADDRESSES (0xC0000018) -- confirmed against ntstatus.h. Both translate to ERROR_INVALID_ADDRESS (487), so the test passed either way and the wrong name would have quietly misled the next reader about which failure is asserted. _nt_ok() now calls the public SetLastError/RtlNtStatusToDosError rather than the private bindings, matching how Win32Exception already uses GetLastError(), and keeping a future swap to ctypes.get_last_error() local to the wrappers. Full suite: 182 passed, 2 skipped. The same three tests still fail without _nt_ok's body. --- MemLib/windows.py | 2 +- tests/test_ntstatus_last_error.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/MemLib/windows.py b/MemLib/windows.py index 9c88299..bf37cad 100644 --- a/MemLib/windows.py +++ b/MemLib/windows.py @@ -206,7 +206,7 @@ def _nt_ok(nt_status: int) -> bool: if nt_status == STATUS_SUCCESS: return True - _SetLastError(_RtlNtStatusToDosError(nt_status)) + SetLastError(RtlNtStatusToDosError(nt_status)) return False # noinspection PyPep8Naming diff --git a/tests/test_ntstatus_last_error.py b/tests/test_ntstatus_last_error.py index 0d7ddd9..290f1b2 100644 --- a/tests/test_ntstatus_last_error.py +++ b/tests/test_ntstatus_last_error.py @@ -13,15 +13,15 @@ GetLastError, NtUnmapViewOfSection, RtlNtStatusToDosError, SetLastError, Win32Exception, _nt_ok, ) -# STATUS_CONFLICTING_ADDRESSES: what NtUnmapViewOfSection returns for an address that is -# not the base of a mapped view. Translates to ERROR_INVALID_ADDRESS (487). -STATUS_CONFLICTING_ADDRESSES: int = 0xC0000019 +# What NtUnmapViewOfSection returns for an address that is not the base of a mapped +# view. Translates to ERROR_INVALID_ADDRESS (487). +STATUS_NOT_MAPPED_VIEW: int = 0xC0000019 ERROR_INVALID_ADDRESS: int = 487 UNMAPPED_ADDRESS: int = 0xDEAD0000 def test_status_translates_to_the_matching_win32_code(): - assert RtlNtStatusToDosError(STATUS_CONFLICTING_ADDRESSES) == ERROR_INVALID_ADDRESS + assert RtlNtStatusToDosError(STATUS_NOT_MAPPED_VIEW) == ERROR_INVALID_ADDRESS assert RtlNtStatusToDosError(0xC0000008) == 6 # STATUS_INVALID_HANDLE -> ERROR_INVALID_HANDLE assert RtlNtStatusToDosError(0xC000000D) == 87 # STATUS_INVALID_PARAMETER -> ERROR_INVALID_PARAMETER @@ -37,7 +37,7 @@ def test_nt_ok_leaves_last_error_alone_on_success(): def test_nt_ok_publishes_the_translated_failure(): SetLastError(0) - assert _nt_ok(STATUS_CONFLICTING_ADDRESSES) is False + assert _nt_ok(STATUS_NOT_MAPPED_VIEW) is False assert GetLastError() == ERROR_INVALID_ADDRESS