From bedfc5eac0fa3a925386748e15157ad038542225 Mon Sep 17 00:00:00 2001 From: dbuechel Date: Fri, 21 Sep 2018 11:33:32 +0200 Subject: [PATCH] SEBWIN-220: Added functionality to suspend / resume the explorer shell process for kiosk mode Create New Desktop, since setting of the working area does not succeed as long as the shell is running. --- .../ConfigurationRepository.cs | 2 +- .../WindowsApi/IExplorerShell.cs | 10 +++ .../WindowsApi/INativeMethods.cs | 12 ++++ .../Operations/KioskModeOperationTests.cs | 21 ++++-- SafeExamBrowser.Runtime/CompositionRoot.cs | 6 +- .../Operations/KioskModeOperation.cs | 6 +- .../Constants/ThreadAccess.cs | 29 ++++++++ SafeExamBrowser.WindowsApi/ExplorerShell.cs | 69 ++++++++++++++++++- SafeExamBrowser.WindowsApi/Kernel32.cs | 14 ++++ SafeExamBrowser.WindowsApi/NativeMethods.cs | 48 ++++++++++++- .../SafeExamBrowser.WindowsApi.csproj | 1 + 11 files changed, 205 insertions(+), 13 deletions(-) create mode 100644 SafeExamBrowser.WindowsApi/Constants/ThreadAccess.cs diff --git a/SafeExamBrowser.Configuration/ConfigurationRepository.cs b/SafeExamBrowser.Configuration/ConfigurationRepository.cs index af18ef18..280068db 100644 --- a/SafeExamBrowser.Configuration/ConfigurationRepository.cs +++ b/SafeExamBrowser.Configuration/ConfigurationRepository.cs @@ -88,7 +88,7 @@ namespace SafeExamBrowser.Configuration CurrentSettings = new Settings(); - CurrentSettings.KioskMode = KioskMode.None; + CurrentSettings.KioskMode = KioskMode.CreateNewDesktop; CurrentSettings.ServicePolicy = ServicePolicy.Optional; CurrentSettings.Browser.StartUrl = "https://www.safeexambrowser.org/testing"; diff --git a/SafeExamBrowser.Contracts/WindowsApi/IExplorerShell.cs b/SafeExamBrowser.Contracts/WindowsApi/IExplorerShell.cs index 768a1e5b..e46d7b6a 100644 --- a/SafeExamBrowser.Contracts/WindowsApi/IExplorerShell.cs +++ b/SafeExamBrowser.Contracts/WindowsApi/IExplorerShell.cs @@ -13,11 +13,21 @@ namespace SafeExamBrowser.Contracts.WindowsApi /// public interface IExplorerShell { + /// + /// Resumes the explorer shell process, if it was previously suspended. + /// + void Resume(); + /// /// Starts the Windows explorer shell, if it isn't already running. /// void Start(); + /// + /// Suspends the explorer shell process, if it is running. + /// + void Suspend(); + /// /// Gracefully terminates the Windows explorer shell, if it is running. /// diff --git a/SafeExamBrowser.Contracts/WindowsApi/INativeMethods.cs b/SafeExamBrowser.Contracts/WindowsApi/INativeMethods.cs index 9e37ca7c..e1351a5a 100644 --- a/SafeExamBrowser.Contracts/WindowsApi/INativeMethods.cs +++ b/SafeExamBrowser.Contracts/WindowsApi/INativeMethods.cs @@ -153,6 +153,12 @@ namespace SafeExamBrowser.Contracts.WindowsApi /// void RestoreWindow(IntPtr window); + /// + /// Attempts to resume the thread referenced by the given thread ID. Returns true if the thread was successfully resumed, + /// otherwise false. + /// + bool ResumeThread(int threadId); + /// /// Sends a close message to the given window. /// @@ -173,5 +179,11 @@ namespace SafeExamBrowser.Contracts.WindowsApi /// If the working area could not be set. /// void SetWorkingArea(IBounds bounds); + + /// + /// Attempts to suspend the thread referenced by the given thread ID. Returns true if the thread was successfully suspended, + /// otherwise false. + /// + bool SuspendThread(int threadId); } } diff --git a/SafeExamBrowser.Runtime.UnitTests/Operations/KioskModeOperationTests.cs b/SafeExamBrowser.Runtime.UnitTests/Operations/KioskModeOperationTests.cs index 19834db0..1b85d9d5 100644 --- a/SafeExamBrowser.Runtime.UnitTests/Operations/KioskModeOperationTests.cs +++ b/SafeExamBrowser.Runtime.UnitTests/Operations/KioskModeOperationTests.cs @@ -52,6 +52,7 @@ namespace SafeExamBrowser.Runtime.UnitTests.Operations var createNew = 0; var activate = 0; var setStartup = 0; + var suspend = 0; settings.KioskMode = KioskMode.CreateNewDesktop; @@ -59,6 +60,7 @@ namespace SafeExamBrowser.Runtime.UnitTests.Operations desktopFactory.Setup(f => f.CreateNew(It.IsAny())).Callback(() => createNew = ++order).Returns(newDesktop.Object); newDesktop.Setup(d => d.Activate()).Callback(() => activate = ++order); processFactory.SetupSet(f => f.StartupDesktop = It.IsAny()).Callback(() => setStartup = ++order); + explorerShell.Setup(s => s.Suspend()).Callback(() => suspend = ++order); sut.Perform(); @@ -66,11 +68,13 @@ namespace SafeExamBrowser.Runtime.UnitTests.Operations desktopFactory.Verify(f => f.CreateNew(It.IsAny()), Times.Once); newDesktop.Verify(d => d.Activate(), Times.Once); processFactory.VerifySet(f => f.StartupDesktop = newDesktop.Object, Times.Once); + explorerShell.Verify(s => s.Suspend(), Times.Once); Assert.AreEqual(1, getCurrrent); Assert.AreEqual(2, createNew); Assert.AreEqual(3, activate); Assert.AreEqual(4, setStartup); + Assert.AreEqual(5, suspend); } [TestMethod] @@ -92,6 +96,7 @@ namespace SafeExamBrowser.Runtime.UnitTests.Operations var activate = 0; var setStartup = 0; var close = 0; + var resume = 0; settings.KioskMode = KioskMode.CreateNewDesktop; @@ -100,6 +105,7 @@ namespace SafeExamBrowser.Runtime.UnitTests.Operations originalDesktop.Setup(d => d.Activate()).Callback(() => activate = ++order); processFactory.SetupSet(f => f.StartupDesktop = It.Is(d => d == originalDesktop.Object)).Callback(() => setStartup = ++order); newDesktop.Setup(d => d.Close()).Callback(() => close = ++order); + explorerShell.Setup(s => s.Resume()).Callback(() => resume = ++order); sut.Perform(); sut.Revert(); @@ -107,10 +113,12 @@ namespace SafeExamBrowser.Runtime.UnitTests.Operations originalDesktop.Verify(d => d.Activate(), Times.Once); processFactory.VerifySet(f => f.StartupDesktop = originalDesktop.Object, Times.Once); newDesktop.Verify(d => d.Close(), Times.Once); + explorerShell.Verify(s => s.Resume(), Times.Once); Assert.AreEqual(1, activate); Assert.AreEqual(2, setStartup); Assert.AreEqual(3, close); + Assert.AreEqual(4, resume); } [TestMethod] @@ -138,6 +146,8 @@ namespace SafeExamBrowser.Runtime.UnitTests.Operations explorerShell.Verify(s => s.Terminate(), Times.Never); explorerShell.Verify(s => s.Start(), Times.Never); + explorerShell.Verify(s => s.Resume(), Times.Never); + explorerShell.Verify(s => s.Suspend(), Times.Once); newDesktop.Verify(d => d.Activate(), Times.Once); newDesktop.Verify(d => d.Close(), Times.Never); originalDesktop.Verify(d => d.Activate(), Times.Never); @@ -145,7 +155,9 @@ namespace SafeExamBrowser.Runtime.UnitTests.Operations settings.KioskMode = KioskMode.DisableExplorerShell; sut.Repeat(); + explorerShell.Verify(s => s.Resume(), Times.Once); explorerShell.Verify(s => s.Terminate(), Times.Once); + explorerShell.Verify(s => s.Suspend(), Times.Once); explorerShell.Verify(s => s.Start(), Times.Never); newDesktop.Verify(d => d.Activate(), Times.Once); newDesktop.Verify(d => d.Close(), Times.Once); @@ -154,7 +166,9 @@ namespace SafeExamBrowser.Runtime.UnitTests.Operations settings.KioskMode = KioskMode.CreateNewDesktop; sut.Repeat(); + explorerShell.Verify(s => s.Resume(), Times.Once); explorerShell.Verify(s => s.Terminate(), Times.Once); + explorerShell.Verify(s => s.Suspend(), Times.Once); explorerShell.Verify(s => s.Start(), Times.Once); newDesktop.Verify(d => d.Activate(), Times.Exactly(2)); newDesktop.Verify(d => d.Close(), Times.Once); @@ -197,6 +211,7 @@ namespace SafeExamBrowser.Runtime.UnitTests.Operations desktopFactory.Verify(f => f.CreateNew(It.IsAny()), Times.Once); newDesktop.Verify(d => d.Activate(), Times.Once); processFactory.VerifySet(f => f.StartupDesktop = newDesktop.Object, Times.Once); + explorerShell.Verify(s => s.Suspend(), Times.Once); } [TestMethod] @@ -213,11 +228,5 @@ namespace SafeExamBrowser.Runtime.UnitTests.Operations explorerShell.Verify(s => s.Terminate(), Times.Once); } - - [TestMethod] - public void MustRestoreOriginalDesktopInCaseOfFailure() - { - - } } } diff --git a/SafeExamBrowser.Runtime/CompositionRoot.cs b/SafeExamBrowser.Runtime/CompositionRoot.cs index d69cd911..04b05fcf 100644 --- a/SafeExamBrowser.Runtime/CompositionRoot.cs +++ b/SafeExamBrowser.Runtime/CompositionRoot.cs @@ -41,7 +41,7 @@ namespace SafeExamBrowser.Runtime internal void BuildObjectGraph(Action shutdown) { - const int TEN_SECONDS = 10000; + const int STARTUP_TIMEOUT_MS = 30000; var args = Environment.GetCommandLineArgs(); var configuration = BuildConfigurationRepository(); @@ -73,9 +73,9 @@ namespace SafeExamBrowser.Runtime sessionOperations.Enqueue(new ConfigurationOperation(appConfig, configuration, logger, messageBox, resourceLoader, runtimeHost, text, uiFactory, args)); sessionOperations.Enqueue(new SessionInitializationOperation(configuration, logger, runtimeHost)); sessionOperations.Enqueue(new ServiceOperation(configuration, logger, serviceProxy, text)); - sessionOperations.Enqueue(new ClientTerminationOperation(configuration, logger, processFactory, proxyFactory, runtimeHost, TEN_SECONDS)); + sessionOperations.Enqueue(new ClientTerminationOperation(configuration, logger, processFactory, proxyFactory, runtimeHost, STARTUP_TIMEOUT_MS)); sessionOperations.Enqueue(new KioskModeOperation(configuration, desktopFactory, explorerShell, logger, processFactory)); - sessionOperations.Enqueue(new ClientOperation(configuration, logger, processFactory, proxyFactory, runtimeHost, TEN_SECONDS)); + sessionOperations.Enqueue(new ClientOperation(configuration, logger, processFactory, proxyFactory, runtimeHost, STARTUP_TIMEOUT_MS)); var bootstrapSequence = new OperationSequence(logger, bootstrapOperations); var sessionSequence = new OperationSequence(logger, sessionOperations); diff --git a/SafeExamBrowser.Runtime/Operations/KioskModeOperation.cs b/SafeExamBrowser.Runtime/Operations/KioskModeOperation.cs index 9f1e0caa..a75a36bf 100644 --- a/SafeExamBrowser.Runtime/Operations/KioskModeOperation.cs +++ b/SafeExamBrowser.Runtime/Operations/KioskModeOperation.cs @@ -6,9 +6,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -using SafeExamBrowser.Contracts.Core.OperationModel; using SafeExamBrowser.Contracts.Configuration; using SafeExamBrowser.Contracts.Configuration.Settings; +using SafeExamBrowser.Contracts.Core.OperationModel; using SafeExamBrowser.Contracts.I18n; using SafeExamBrowser.Contracts.Logging; using SafeExamBrowser.Contracts.UserInterface; @@ -110,6 +110,8 @@ namespace SafeExamBrowser.Runtime.Operations newDesktop.Activate(); processFactory.StartupDesktop = newDesktop; logger.Info("Successfully activated new desktop."); + + explorerShell.Suspend(); } private void CloseNewDesktop() @@ -134,6 +136,8 @@ namespace SafeExamBrowser.Runtime.Operations { logger.Warn($"No new desktop found when attempting to close new desktop!"); } + + explorerShell.Resume(); } private void TerminateExplorerShell() diff --git a/SafeExamBrowser.WindowsApi/Constants/ThreadAccess.cs b/SafeExamBrowser.WindowsApi/Constants/ThreadAccess.cs new file mode 100644 index 00000000..958b1e16 --- /dev/null +++ b/SafeExamBrowser.WindowsApi/Constants/ThreadAccess.cs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2018 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +using System; + +namespace SafeExamBrowser.WindowsApi.Constants +{ + /// + /// See https://docs.microsoft.com/en-us/windows/desktop/ProcThread/thread-security-and-access-rights. + /// + [Flags] + internal enum ThreadAccess : int + { + TERMINATE = 0x1, + SUSPEND_RESUME = 0x2, + GET_CONTEXT = 0x8, + SET_CONTEXT = 0x10, + SET_INFORMATION = 0x20, + QUERY_INFORMATION = 0x40, + SET_THREAD_TOKEN = 0x80, + IMPERSONATE = 0x100, + DIRECT_IMPERSONATION = 0x200 + } +} diff --git a/SafeExamBrowser.WindowsApi/ExplorerShell.cs b/SafeExamBrowser.WindowsApi/ExplorerShell.cs index b671619b..2ba83ca9 100644 --- a/SafeExamBrowser.WindowsApi/ExplorerShell.cs +++ b/SafeExamBrowser.WindowsApi/ExplorerShell.cs @@ -7,6 +7,8 @@ */ using System; +using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; @@ -19,11 +21,43 @@ namespace SafeExamBrowser.WindowsApi { private ILogger logger; private INativeMethods nativeMethods; + private IList suspendedThreads; public ExplorerShell(ILogger logger, INativeMethods nativeMethods) { this.logger = logger; this.nativeMethods = nativeMethods; + this.suspendedThreads = new List(); + } + + public void Resume() + { + const int MAX_ATTEMPTS = 3; + + logger.Debug($"Attempting to resume all {suspendedThreads.Count} previously suspended explorer shell threads..."); + + for (var attempts = 0; suspendedThreads.Any(); attempts++) + { + var thread = suspendedThreads.First(); + var success = nativeMethods.ResumeThread(thread.Id); + + if (success || attempts == MAX_ATTEMPTS) + { + attempts = 0; + suspendedThreads.Remove(thread); + + if (success) + { + logger.Debug($"Successfully resumed thread #{thread.Id} of explorer shell process."); + } + else + { + logger.Warn($"Failed to resume thread #{thread.Id} of explorer shell process within {MAX_ATTEMPTS} attempts!"); + } + } + } + + logger.Info($"Successfully resumed explorer shell process."); } public void Start() @@ -47,6 +81,39 @@ namespace SafeExamBrowser.WindowsApi process.Close(); } + public void Suspend() + { + var processId = nativeMethods.GetShellProcessId(); + var explorerProcesses = System.Diagnostics.Process.GetProcessesByName("explorer"); + var shellProcess = explorerProcesses.FirstOrDefault(p => p.Id == processId); + + if (shellProcess != null) + { + logger.Debug($"Found explorer shell processes with PID = {processId}."); + + foreach (ProcessThread thread in shellProcess.Threads) + { + var success = nativeMethods.SuspendThread(thread.Id); + + if (success) + { + suspendedThreads.Add(thread); + logger.Debug($"Successfully suspended thread #{thread.Id} of explorer shell process."); + } + else + { + logger.Warn($"Failed to suspend thread #{thread.Id} of explorer shell process!"); + } + } + + logger.Info($"Successfully suspended explorer shell process with PID = {processId}."); + } + else + { + logger.Info("The explorer shell can't be suspended, as it seems to not be running."); + } + } + public void Terminate() { var processId = nativeMethods.GetShellProcessId(); @@ -69,7 +136,7 @@ namespace SafeExamBrowser.WindowsApi } else { - logger.Info("The explorer shell seems to already be terminated. Skipping this step..."); + logger.Info("The explorer shell seems to already be terminated."); } } } diff --git a/SafeExamBrowser.WindowsApi/Kernel32.cs b/SafeExamBrowser.WindowsApi/Kernel32.cs index dacffd5e..f66ddbcf 100644 --- a/SafeExamBrowser.WindowsApi/Kernel32.cs +++ b/SafeExamBrowser.WindowsApi/Kernel32.cs @@ -8,6 +8,7 @@ using System; using System.Runtime.InteropServices; +using SafeExamBrowser.WindowsApi.Constants; using SafeExamBrowser.WindowsApi.Types; namespace SafeExamBrowser.WindowsApi @@ -17,6 +18,10 @@ namespace SafeExamBrowser.WindowsApi /// internal class Kernel32 { + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool CloseHandle(IntPtr hObject); + [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool CreateProcess( string lpApplicationName, @@ -36,7 +41,16 @@ namespace SafeExamBrowser.WindowsApi [DllImport("kernel32.dll", SetLastError = true)] internal static extern IntPtr GetModuleHandle(string lpModuleName); + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern IntPtr OpenThread(ThreadAccess dwDesiredAccess, bool bInheritHandle, uint dwThreadId); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern int ResumeThread(IntPtr hThread); + [DllImport("kernel32.dll", SetLastError = true)] internal static extern EXECUTION_STATE SetThreadExecutionState(EXECUTION_STATE esFlags); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern int SuspendThread(IntPtr hThread); } } diff --git a/SafeExamBrowser.WindowsApi/NativeMethods.cs b/SafeExamBrowser.WindowsApi/NativeMethods.cs index ded4adda..c8197c2c 100644 --- a/SafeExamBrowser.WindowsApi/NativeMethods.cs +++ b/SafeExamBrowser.WindowsApi/NativeMethods.cs @@ -291,6 +291,29 @@ namespace SafeExamBrowser.WindowsApi User32.ShowWindow(window, (int)ShowWindowCommand.Restore); } + public bool ResumeThread(int threadId) + { + const int FAILURE = -1; + var handle = Kernel32.OpenThread(ThreadAccess.SUSPEND_RESUME, false, (uint) threadId); + + if (handle == IntPtr.Zero) + { + return false; + } + + try + { + var result = Kernel32.ResumeThread(handle); + var success = result != FAILURE; + + return success; + } + finally + { + Kernel32.CloseHandle(handle); + } + } + public void SendCloseMessageTo(IntPtr window) { User32.SendMessage(window, Constant.WM_SYSCOMMAND, (IntPtr) SystemCommand.CLOSE, IntPtr.Zero); @@ -309,12 +332,35 @@ namespace SafeExamBrowser.WindowsApi public void SetWorkingArea(IBounds bounds) { var workingArea = new RECT { Left = bounds.Left, Top = bounds.Top, Right = bounds.Right, Bottom = bounds.Bottom }; - var success = User32.SystemParametersInfo(SPI.SETWORKAREA, 0, ref workingArea, SPIF.UPDATEANDCHANGE); + var success = User32.SystemParametersInfo(SPI.SETWORKAREA, 0, ref workingArea, SPIF.NONE); if (!success) { throw new Win32Exception(Marshal.GetLastWin32Error()); } } + + public bool SuspendThread(int threadId) + { + const int FAILURE = -1; + var handle = Kernel32.OpenThread(ThreadAccess.SUSPEND_RESUME, false, (uint) threadId); + + if (handle == IntPtr.Zero) + { + return false; + } + + try + { + var result = Kernel32.SuspendThread(handle); + var success = result != FAILURE; + + return success; + } + finally + { + Kernel32.CloseHandle(handle); + } + } } } diff --git a/SafeExamBrowser.WindowsApi/SafeExamBrowser.WindowsApi.csproj b/SafeExamBrowser.WindowsApi/SafeExamBrowser.WindowsApi.csproj index 15a43937..8ff0a0e4 100644 --- a/SafeExamBrowser.WindowsApi/SafeExamBrowser.WindowsApi.csproj +++ b/SafeExamBrowser.WindowsApi/SafeExamBrowser.WindowsApi.csproj @@ -53,6 +53,7 @@ +