Skip to content

Commit 9215690

Browse files
authored
Merge pull request from GHSA-72mj-mqhj-qh4w
GHSA-72mj-mqhj-qh4w Session tracking allows NVDA notifies of session lock state changes. However, a transition is required to set the initial state. This approach relied on the assumption that NVDA can't start while already on the lock screen. While the assumption is typically valid, it is possible (but unlikely) for NVDA to start after the session is locked. No user observable changes. Initialize the lock state by querying the session info.
1 parent fbf8109 commit 9215690

File tree

4 files changed

+467
-33
lines changed

4 files changed

+467
-33
lines changed

source/core.py

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -580,35 +580,16 @@ def __init__(self, windowName=None):
580580
self.handlePowerStatusChange()
581581

582582
# Call must be paired with a call to sessionTracking.unregister
583-
self._isSessionTrackingRegistered = sessionTracking.register(self.handle)
584-
585-
def warnIfSessionTrackingNotRegistered(self) -> None:
586-
if self._isSessionTrackingRegistered:
587-
return
588-
failedToRegisterMsg = _(
589-
# Translators: This is a warning to users, shown if NVDA cannot determine if
590-
# Windows is locked.
591-
"NVDA failed to register session tracking. "
592-
"While this instance of NVDA is running, "
593-
"your desktop will not be secure when Windows is locked. "
594-
"Restart NVDA? "
595-
)
596-
if wx.YES == gui.messageBox(
597-
failedToRegisterMsg,
598-
# Translators: This is a warning to users, shown if NVDA cannot determine if
599-
# Windows is locked.
600-
caption=_("NVDA could not start securely."),
601-
style=wx.ICON_ERROR | wx.YES_NO,
602-
):
603-
restart()
583+
if not sessionTracking.register(self.handle):
584+
import utils.security
585+
wx.CallAfter(utils.security.warnSessionLockStateUnknown)
604586

605587
def destroy(self):
606588
"""
607589
NVDA must unregister session tracking before destroying the message window.
608590
"""
609-
if self._isSessionTrackingRegistered:
610-
# Requires an active message window and a handle to unregister.
611-
sessionTracking.unregister(self.handle)
591+
# Requires an active message window and a handle to unregister.
592+
sessionTracking.unregister(self.handle)
612593
super().destroy()
613594

614595
def windowProc(self, hwnd, msg, wParam, lParam):
@@ -618,7 +599,6 @@ def windowProc(self, hwnd, msg, wParam, lParam):
618599
elif msg == winUser.WM_DISPLAYCHANGE:
619600
self.handleScreenOrientationChange(lParam)
620601
elif msg == WindowMessage.WTS_SESSION_CHANGE:
621-
# If we are receiving WTS_SESSION_CHANGE events, _isSessionTrackingRegistered should be True
622602
sessionTracking.handleSessionChange(sessionTracking.WindowsTrackedSession(wParam), lParam)
623603

624604
def handleScreenOrientationChange(self, lParam):
@@ -818,8 +798,6 @@ def _doPostNvdaStartupAction():
818798
queueHandler.queueFunction(queueHandler.eventQueue, _doPostNvdaStartupAction)
819799

820800
log.debug("entering wx application main loop")
821-
# Warn here as NVDA must be ready before providing a gui message
822-
messageWindow.warnIfSessionTrackingNotRegistered()
823801
app.MainLoop()
824802

825803
log.info("Exiting")

source/utils/security.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,50 @@ def isObjectAboveLockScreen(obj: "NVDAObjects.NVDAObject") -> bool:
170170
return True
171171

172172
return False
173+
174+
175+
_hasSessionLockStateUnknownWarningBeenGiven = False
176+
"""Track whether the user has been notified.
177+
"""
178+
179+
180+
def warnSessionLockStateUnknown() -> None:
181+
""" Warn the user that the lock state of the computer can not be determined.
182+
NVDA will not be able to determine if Windows is on the lock screen
183+
(LockApp on Windows 10/11), and will not be able to ensure privacy/security
184+
of the signed-in user against unauthenticated users.
185+
@note Only warn the user once.
186+
"""
187+
global _hasSessionLockStateUnknownWarningBeenGiven
188+
if _hasSessionLockStateUnknownWarningBeenGiven:
189+
return
190+
_hasSessionLockStateUnknownWarningBeenGiven = True
191+
192+
log.warning(
193+
"NVDA is unable to determine if Windows is locked."
194+
" While this instance of NVDA is running,"
195+
" your desktop will not be secure when Windows is locked."
196+
" Restarting Windows may address this."
197+
" If this error is ongoing then disabling the Windows lock screen is recommended."
198+
)
199+
200+
unableToDetermineSessionLockStateMsg = _(
201+
# Translators: This is the message for a warning shown if NVDA cannot determine if
202+
# Windows is locked.
203+
"NVDA is unable to determine if Windows is locked."
204+
" While this instance of NVDA is running,"
205+
" your desktop will not be secure when Windows is locked."
206+
" Restarting Windows may address this."
207+
" If this error is ongoing then disabling the Windows lock screen is recommended."
208+
)
209+
210+
import wx # Late import to prevent circular dependency.
211+
import gui # Late import to prevent circular dependency.
212+
log.debug("Presenting session lock tracking failure warning.")
213+
gui.messageBox(
214+
unableToDetermineSessionLockStateMsg,
215+
# Translators: This is the title for a warning dialog, shown if NVDA cannot determine if
216+
# Windows is locked.
217+
caption=_("Lock screen not secure while using NVDA"),
218+
style=wx.ICON_ERROR | wx.OK,
219+
)

source/winAPI/sessionTracking.py

Lines changed: 198 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
https://docs.microsoft.com/en-us/windows/win32/api/wtsapi32/nf-wtsapi32-wtsregistersessionnotification
1717
"""
1818

19+
from __future__ import annotations
20+
import contextlib
1921
import ctypes
22+
from contextlib import contextmanager
2023
from ctypes.wintypes import (
2124
HANDLE,
2225
HWND,
@@ -25,17 +28,36 @@
2528
from typing import (
2629
Dict,
2730
Set,
31+
Optional,
32+
)
33+
from winAPI.wtsApi32 import (
34+
WTSINFOEXW,
35+
WTSQuerySessionInformation,
36+
WTS_CURRENT_SERVER_HANDLE,
37+
WTS_CURRENT_SESSION,
38+
WTS_INFO_CLASS,
39+
WTSFreeMemory,
40+
WTS_LockState,
41+
WTSINFOEX_LEVEL1_W,
2842
)
29-
3043
from logHandler import log
3144

32-
3345
_currentSessionStates: Set["WindowsTrackedSession"] = set()
3446
"""
3547
Current state of the Windows session associated with this instance of NVDA.
3648
Maintained via receiving session notifications via the NVDA MessageWindow.
49+
Initial state will be set by querying the current status.
3750
"""
3851

52+
_sessionQueryLockStateHasBeenUnknown = False
53+
"""
54+
Track if any 'Unknown' Value when querying the Session Lock status has been encountered.
55+
"""
56+
57+
_isSessionTrackingRegistered = False
58+
"""
59+
Session tracking is required for NVDA to be notified of lock state changes for security purposes.
60+
"""
3961

4062
RPC_S_INVALID_BINDING = 0x6A6
4163
"""
@@ -61,7 +83,8 @@ class WindowsTrackedSession(enum.IntEnum):
6183
Windows Tracked Session notifications.
6284
Members are states which form logical pairs,
6385
except SESSION_REMOTE_CONTROL which requires more complex handling.
64-
86+
Values from: https://learn.microsoft.com/en-us/windows/win32/termserv/wm-wtssession-change
87+
Context:
6588
https://docs.microsoft.com/en-us/windows/win32/api/wtsapi32/nf-wtsapi32-wtsregistersessionnotification
6689
"""
6790
CONSOLE_CONNECT = 1
@@ -95,13 +118,91 @@ class WindowsTrackedSession(enum.IntEnum):
95118
"""
96119

97120

121+
def _hasLockStateBeenTracked() -> bool:
122+
"""
123+
Checks if NVDA is aware of a session lock state change since NVDA started.
124+
"""
125+
return bool(_currentSessionStates.intersection({
126+
WindowsTrackedSession.SESSION_LOCK,
127+
WindowsTrackedSession.SESSION_UNLOCK
128+
}))
129+
130+
131+
def _recordLockStateTrackingFailure(error: Optional[Exception] = None):
132+
log.error(
133+
"Unknown lock state, unexpected, potential security issue, please report.",
134+
exc_info=error
135+
) # Report error repeatedly, attention is required.
136+
##
137+
# For security it would be best to treat unknown as locked.
138+
# However, this would make NVDA unusable.
139+
# Instead, the user should be warned, and allowed to mitigate the impact themselves.
140+
# Reporting is achieved via _sessionQueryLockStateHasBeenUnknown exposed with
141+
# L{hasLockStateBeenUnknown}.
142+
global _sessionQueryLockStateHasBeenUnknown
143+
_sessionQueryLockStateHasBeenUnknown = True
144+
145+
98146
def isWindowsLocked() -> bool:
99147
"""
100148
Checks if the Window lockscreen is active.
101-
102149
Not to be confused with the Windows sign-in screen, a secure screen.
103150
"""
104-
return WindowsTrackedSession.SESSION_LOCK in _currentSessionStates
151+
lockStateTracked = _hasLockStateBeenTracked()
152+
if lockStateTracked:
153+
return WindowsTrackedSession.SESSION_LOCK in _currentSessionStates
154+
else:
155+
_recordLockStateTrackingFailure() # Report error repeatedly, attention is required.
156+
##
157+
# For security it would be best to treat unknown as locked.
158+
# However, this would make NVDA unusable.
159+
# Instead, the user should be warned via UI, and allowed to mitigate the impact themselves.
160+
# See usage of L{hasLockStateBeenUnknown}.
161+
return False # return False, indicating unlocked, to allow NVDA to be used
162+
163+
164+
def _setInitialWindowLockState() -> None:
165+
"""Ensure that session tracking state is initialized.
166+
If NVDA has started on a lockScreen, it needs to be aware of this.
167+
"""
168+
lockStateTracked = _hasLockStateBeenTracked()
169+
if lockStateTracked:
170+
raise RuntimeError("Can't set initial state if it is already tracked.")
171+
# Fall back to explicit query
172+
try:
173+
isLocked = _isWindowsLocked_checkViaSessionQuery()
174+
_currentSessionStates.add(
175+
WindowsTrackedSession.SESSION_LOCK
176+
if isLocked
177+
else WindowsTrackedSession.SESSION_UNLOCK
178+
)
179+
except RuntimeError as error:
180+
_recordLockStateTrackingFailure(error)
181+
182+
183+
def _isWindowsLocked_checkViaSessionQuery() -> bool:
184+
""" Use a session query to check if the session is locked
185+
@return: True is the session is locked.
186+
@raise: Runtime error if the lock state can not be determined via a Session Query.
187+
"""
188+
sessionQueryLockState = _getSessionLockedValue()
189+
if sessionQueryLockState == WTS_LockState.WTS_SESSIONSTATE_UNKNOWN:
190+
raise RuntimeError(
191+
"Unable to determine lock state via Session Query."
192+
f" Lock state value: {sessionQueryLockState!r}"
193+
)
194+
return sessionQueryLockState == WTS_LockState.WTS_SESSIONSTATE_LOCK
195+
196+
197+
def isLockStateSuccessfullyTracked() -> bool:
198+
"""Check if the lock state is successfully tracked.
199+
I.E. Registered for session tracking AND initial value set correctly.
200+
@return: True when successfully tracked.
201+
"""
202+
return (
203+
not _sessionQueryLockStateHasBeenUnknown
204+
or not _isSessionTrackingRegistered
205+
)
105206

106207

107208
def register(handle: HWND) -> bool:
@@ -121,6 +222,11 @@ def register(handle: HWND) -> bool:
121222
122223
https://docs.microsoft.com/en-us/windows/win32/api/wtsapi32/nf-wtsapi32-wtsregistersessionnotification
123224
"""
225+
##
226+
# Ensure that an initial state is set,
227+
# do this first to prevent a race condition with session tracking / message processing.
228+
_setInitialWindowLockState()
229+
124230
# OpenEvent handle must be closed with CloseHandle.
125231
eventObjectHandle: HANDLE = ctypes.windll.kernel32.OpenEventW(
126232
# Blocks until WTS session tracking can be registered.
@@ -154,7 +260,9 @@ def register(handle: HWND) -> bool:
154260
else:
155261
log.error("Unexpected error registering session tracking.", exc_info=error)
156262

157-
return registrationSuccess
263+
global _isSessionTrackingRegistered
264+
_isSessionTrackingRegistered = registrationSuccess
265+
return isLockStateSuccessfullyTracked()
158266

159267

160268
def unregister(handle: HWND) -> None:
@@ -164,6 +272,9 @@ def unregister(handle: HWND) -> None:
164272
165273
https://docs.microsoft.com/en-us/windows/win32/api/wtsapi32/nf-wtsapi32-wtsunregistersessionnotification
166274
"""
275+
if not _isSessionTrackingRegistered:
276+
log.info("Not unregistered session tracking, it was not registered.")
277+
return
167278
if ctypes.windll.wtsapi32.WTSUnRegisterSessionNotification(handle):
168279
log.debug("Unregistered session tracking")
169280
else:
@@ -187,6 +298,9 @@ def handleSessionChange(newState: WindowsTrackedSession, sessionId: int) -> None
187298

188299
log.debug(f"Windows Session state notification received: {newState.name}")
189300

301+
if not _isSessionTrackingRegistered:
302+
log.debugWarning("Session tracking not registered, unexpected session change message")
303+
190304
if newState not in _toggleWindowsSessionStatePair:
191305
log.debug(f"Ignoring {newState} event as tracking is not required.")
192306
return
@@ -207,3 +321,81 @@ def handleSessionChange(newState: WindowsTrackedSession, sessionId: int) -> None
207321
log.debug(f"NVDA started in state {oppositeState.name} or dropped a state change event")
208322

209323
log.debug(f"New Windows Session state: {_currentSessionStates}")
324+
325+
326+
@contextmanager
327+
def WTSCurrentSessionInfoEx() -> contextlib.AbstractContextManager[ctypes.pointer[WTSINFOEXW]]:
328+
"""Context manager to get the WTSINFOEXW for the current server/session or raises a RuntimeError.
329+
Handles freeing the memory when usage is complete.
330+
"""
331+
info = _getCurrentSessionInfoEx()
332+
try:
333+
yield info
334+
finally:
335+
WTSFreeMemory(
336+
ctypes.cast(info, ctypes.c_void_p),
337+
)
338+
339+
340+
def _getCurrentSessionInfoEx() -> ctypes.POINTER(WTSINFOEXW):
341+
""" Gets the WTSINFOEXW for the current server/session or raises a RuntimeError
342+
on failure.
343+
On RuntimeError memory is first freed.
344+
In other cases use WTSFreeMemory.
345+
Ideally use the WTSCurrentSessionInfoEx context manager which will handle freeing the memory.
346+
"""
347+
ppBuffer = ctypes.wintypes.LPWSTR(None)
348+
pBytesReturned = ctypes.wintypes.DWORD(0)
349+
350+
res = WTSQuerySessionInformation(
351+
WTS_CURRENT_SERVER_HANDLE, # WTS_CURRENT_SERVER_HANDLE to indicate the RD Session Host server on
352+
# which the application is running.
353+
WTS_CURRENT_SESSION, # To indicate the session in which the calling application is running
354+
# (or the current session) specify WTS_CURRENT_SESSION
355+
WTS_INFO_CLASS.WTSSessionInfoEx, # Indicates the type of session information to retrieve
356+
# Fetch a WTSINFOEXW containing a WTSINFOEX_LEVEL1 structure.
357+
ctypes.pointer(ppBuffer), # A pointer to a variable that receives a pointer to the requested information.
358+
# The format and contents of the data depend on the information class specified in the WTSInfoClass
359+
# parameter.
360+
# To free the returned buffer, call the WTSFreeMemory function.
361+
ctypes.pointer(pBytesReturned), # A pointer to a variable that receives the size, in bytes, of the data
362+
# returned in ppBuffer.
363+
)
364+
try:
365+
if not res:
366+
raise RuntimeError(f"Failure calling WTSQuerySessionInformationW: {res}")
367+
elif ctypes.sizeof(WTSINFOEXW) != pBytesReturned.value:
368+
raise RuntimeError(
369+
f"Returned data size failure, got {pBytesReturned.value}, expected {ctypes.sizeof(WTSINFOEXW)}"
370+
)
371+
info = ctypes.cast(
372+
ppBuffer,
373+
ctypes.POINTER(WTSINFOEXW)
374+
)
375+
if (
376+
not info.contents
377+
or info.contents.Level != 1
378+
##
379+
# Level value must be 1, see:
380+
# https://learn.microsoft.com/en-us/windows/win32/api/wtsapi32/ns-wtsapi32-wtsinfoexa
381+
):
382+
raise RuntimeError(
383+
f"Unexpected Level data, got {info.contents.Level}."
384+
)
385+
return info
386+
except Exception as e:
387+
log.exception("Unexpected WTSQuerySessionInformation value:", exc_info=e)
388+
WTSFreeMemory(
389+
ctypes.cast(ppBuffer, ctypes.c_void_p),
390+
)
391+
392+
393+
def _getSessionLockedValue() -> WTS_LockState:
394+
"""Get the WTS_LockState for the current server/session or raises a RuntimeError
395+
"""
396+
with WTSCurrentSessionInfoEx() as info:
397+
infoEx: WTSINFOEX_LEVEL1_W = info.contents.Data.WTSInfoExLevel1
398+
sessionFlags: ctypes.wintypes.LONG = infoEx.SessionFlags
399+
lockState = WTS_LockState(sessionFlags)
400+
log.debug(f"Query Lock state result: {lockState!r}")
401+
return lockState

0 commit comments

Comments
 (0)