diff --git a/lib/ansible/plugins/shell/powershell.py b/lib/ansible/plugins/shell/powershell.py index ed9985845ea..1c536251ab0 100644 --- a/lib/ansible/plugins/shell/powershell.py +++ b/lib/ansible/plugins/shell/powershell.py @@ -171,9 +171,12 @@ Set-StrictMode -Version 2 $ErrorActionPreference = "Stop" $helper_def = @" +using Microsoft.Win32.SafeHandles; using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; @@ -212,9 +215,9 @@ namespace Ansible public Int16 wShowWindow; public Int16 cbReserved2; public IntPtr lpReserved2; - public IntPtr hStdInput; - public IntPtr hStdOutput; - public IntPtr hStdError; + public SafeFileHandle hStdInput; + public SafeFileHandle hStdOutput; + public SafeFileHandle hStdError; public STARTUPINFO() { cb = Marshal.SizeOf(this); @@ -254,6 +257,42 @@ namespace Ansible public SID_AND_ATTRIBUTES User; } + [StructLayout(LayoutKind.Sequential)] + public struct IO_COUNTERS + { + public UInt64 ReadOperationCount; + public UInt64 WriteOperationCount; + public UInt64 OtherOperationCount; + public UInt64 ReadTransferCount; + public UInt64 WriteTransferCount; + public UInt64 OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + public struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public UInt64 PerProcessUserTimeLimit; + public UInt64 PerJobUserTimeLimit; + public LimitFlags LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public UInt32 ActiveProcessLimit; + public UIntPtr Affinity; + public UInt32 PriorityClass; + public UInt32 SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + [Flags] public enum StartupInfoFlags : uint { @@ -263,6 +302,7 @@ namespace Ansible [Flags] public enum CreationFlags : uint { + CREATE_BREAKAWAY_FROM_JOB = 0x01000000, CREATE_DEFAULT_ERROR_MODE = 0x04000000, CREATE_NEW_CONSOLE = 0x00000010, CREATE_NEW_PROCESS_GROUP = 0x00000200, @@ -353,11 +393,35 @@ namespace Ansible TokenImpersonation } + enum JobObjectInfoType + { + AssociateCompletionPortInformation = 7, + BasicLimitInformation = 2, + BasicUIRestrictions = 4, + EndOfJobTimeInformation = 6, + ExtendedLimitInformation = 9, + SecurityLimitInformation = 5, + GroupInformation = 11 + } + + [Flags] + enum ThreadAccessRights : uint + { + SUSPEND_RESUME = 0x0002 + } + + [Flags] + public enum LimitFlags : uint + { + JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x00000800, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 + } + class NativeWaitHandle : WaitHandle { public NativeWaitHandle(IntPtr handle) { - this.Handle = handle; + this.SafeWaitHandle = new SafeWaitHandle(handle, false); } } @@ -380,6 +444,69 @@ namespace Ansible public uint ExitCode { get; internal set; } } + public class Job : IDisposable + { + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr CreateJobObject( + IntPtr lpJobAttributes, + string lpName); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetInformationJobObject( + IntPtr hJob, + JobObjectInfoType JobObjectInfoClass, + IntPtr lpJobObjectInfo, + UInt32 cbJobObjectInfoLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AssignProcessToJobObject( + IntPtr hJob, + IntPtr hProcess); + + [DllImport("kernel32.dll")] + private static extern bool CloseHandle( + IntPtr hObject); + + private IntPtr handle; + + public Job() + { + handle = CreateJobObject(IntPtr.Zero, null); + if (handle == IntPtr.Zero) + throw new Win32Exception("CreateJobObject() failed"); + + JOBOBJECT_BASIC_LIMIT_INFORMATION jobInfo = new JOBOBJECT_BASIC_LIMIT_INFORMATION(); + jobInfo.LimitFlags = LimitFlags.JOB_OBJECT_LIMIT_BREAKAWAY_OK | LimitFlags.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + JOBOBJECT_EXTENDED_LIMIT_INFORMATION extendedJobInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + extendedJobInfo.BasicLimitInformation = jobInfo; + + int length = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + IntPtr pExtendedJobInfo = Marshal.AllocHGlobal(length); + Marshal.StructureToPtr(extendedJobInfo, pExtendedJobInfo, false); + + if (!SetInformationJobObject(handle, JobObjectInfoType.ExtendedLimitInformation, pExtendedJobInfo, (UInt32)length)) + throw new Win32Exception("SetInformationJobObject() failed"); + } + + public void AssignProcess(IntPtr processHandle) + { + if (!AssignProcessToJobObject(handle, processHandle)) + throw new Win32Exception("AssignProcessToJobObject() failed"); + } + + public void Dispose() + { + if (handle != IntPtr.Zero) + { + CloseHandle(handle); + handle = IntPtr.Zero; + } + + GC.SuppressFinalize(this); + } + } + public class BecomeUtil { [DllImport("advapi32.dll", SetLastError = true)] @@ -407,14 +534,14 @@ namespace Ansible [DllImport("kernel32.dll")] private static extern bool CreatePipe( - out IntPtr hReadPipe, - out IntPtr hWritePipe, + out SafeFileHandle hReadPipe, + out SafeFileHandle hWritePipe, SECURITY_ATTRIBUTES lpPipeAttributes, uint nSize); [DllImport("kernel32.dll", SetLastError = true)] private static extern bool SetHandleInformation( - IntPtr hObject, + SafeFileHandle hObject, HandleFlags dwMask, int dwFlags); @@ -431,7 +558,8 @@ namespace Ansible private static extern IntPtr GetProcessWindowStation(); [DllImport("user32.dll", SetLastError = true)] - private static extern IntPtr GetThreadDesktop(int dwThreadId); + private static extern IntPtr GetThreadDesktop( + int dwThreadId); [DllImport("kernel32.dll", SetLastError = true)] private static extern int GetCurrentThreadId(); @@ -480,17 +608,27 @@ namespace Ansible out IntPtr phNewToken); [DllImport("advapi32.dll", SetLastError = true)] - public static extern bool ImpersonateLoggedOnUser( + private static extern bool ImpersonateLoggedOnUser( IntPtr hToken); [DllImport("advapi32.dll", SetLastError = true)] - public static extern bool RevertToSelf(); + private static extern bool RevertToSelf(); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern SafeFileHandle OpenThread( + ThreadAccessRights dwDesiredAccess, + bool bInheritHandle, + int dwThreadId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern int ResumeThread( + SafeHandle hThread); public static CommandResult RunAsUser(string username, string password, string lpCommandLine, string lpCurrentDirectory, string stdinInput) { SecurityIdentifier account = GetBecomeSid(username); - CreationFlags startup_flags = CreationFlags.CREATE_UNICODE_ENVIRONMENT; + CreationFlags startup_flags = CreationFlags.CREATE_UNICODE_ENVIRONMENT | CreationFlags.CREATE_BREAKAWAY_FROM_JOB | CreationFlags.CREATE_SUSPENDED; STARTUPINFOEX si = new STARTUPINFOEX(); si.startupInfo.dwFlags = (int)StartupInfoFlags.USESTDHANDLES; @@ -499,7 +637,7 @@ namespace Ansible pipesec.bInheritHandle = true; // Create the stdout, stderr and stdin pipes used in the process and add to the startupInfo - IntPtr stdout_read, stdout_write, stderr_read, stderr_write, stdin_read, stdin_write = IntPtr.Zero; + SafeFileHandle stdout_read, stdout_write, stderr_read, stderr_write, stdin_read, stdin_write; if (!CreatePipe(out stdout_read, out stdout_write, pipesec, 0)) throw new Win32Exception("STDOUT pipe setup failed"); if (!SetHandleInformation(stdout_read, HandleFlags.INHERIT, 0)) @@ -521,7 +659,7 @@ namespace Ansible // Setup the stdin buffer UTF8Encoding utf8_encoding = new UTF8Encoding(false); - FileStream stdin_fs = new FileStream(stdin_write, FileAccess.Write, true, 32768); + FileStream stdin_fs = new FileStream(stdin_write, FileAccess.Write, 32768); StreamWriter stdin = new StreamWriter(stdin_fs, utf8_encoding, 32768); // Create the environment block if set @@ -554,26 +692,44 @@ namespace Ansible if (!launch_success) throw new Win32Exception("Failed to start become process"); - // Setup the output buffers and get stdout/stderr - FileStream stdout_fs = new FileStream(stdout_read, FileAccess.Read, true, 4096); - StreamReader stdout = new StreamReader(stdout_fs, utf8_encoding, true, 4096); - CloseHandle(stdout_write); + // If 2012/8+ OS, create new job with JOB_OBJECT_LIMIT_BREAKAWAY_OK + // so that async can work + Job job = null; + if (Environment.OSVersion.Version >= new Version("6.2")) + { + job = new Job(); + job.AssignProcess(pi.hProcess); + } + ResumeProcessById(pi.dwProcessId); + + CommandResult result = new CommandResult(); + try + { + // Setup the output buffers and get stdout/stderr + FileStream stdout_fs = new FileStream(stdout_read, FileAccess.Read, 4096); + StreamReader stdout = new StreamReader(stdout_fs, utf8_encoding, true, 4096); + stdout_write.Close(); - FileStream stderr_fs = new FileStream(stderr_read, FileAccess.Read, true, 4096); - StreamReader stderr = new StreamReader(stderr_fs, utf8_encoding, true, 4096); - CloseHandle(stderr_write); + FileStream stderr_fs = new FileStream(stderr_read, FileAccess.Read, 4096); + StreamReader stderr = new StreamReader(stderr_fs, utf8_encoding, true, 4096); + stderr_write.Close(); - stdin.WriteLine(stdinInput); - stdin.Close(); + stdin.WriteLine(stdinInput); + stdin.Close(); - string stdout_str, stderr_str = null; - GetProcessOutput(stdout, stderr, out stdout_str, out stderr_str); - uint rc = GetProcessExitCode(pi.hProcess); + string stdout_str, stderr_str = null; + GetProcessOutput(stdout, stderr, out stdout_str, out stderr_str); + UInt32 rc = GetProcessExitCode(pi.hProcess); - CommandResult result = new CommandResult(); - result.StandardOut = stdout_str; - result.StandardError = stderr_str; - result.ExitCode = rc; + result.StandardOut = stdout_str; + result.StandardError = stderr_str; + result.ExitCode = rc; + } + finally + { + if (job != null) + job.Dispose(); + } return result; } @@ -604,71 +760,64 @@ namespace Ansible GrantAccessToWindowStationAndDesktop(account); string account_sid = account.ToString(); + bool impersonated = false; + IntPtr hSystemTokenDup = IntPtr.Zero; - if (service_sids.Contains(account_sid)) + // Try to get SYSTEM token handle so we can impersonate to get full admin token + IntPtr hSystemToken = GetSystemUserHandle(); + if (hSystemToken == IntPtr.Zero && service_sids.Contains(account_sid)) { - // We are trying to become to a service account - IntPtr hToken = GetUserHandle(); - if (hToken == IntPtr.Zero) - throw new Exception("Failed to get token for NT AUTHORITY\\SYSTEM"); - - IntPtr hTokenDup = IntPtr.Zero; - try - { - if (!DuplicateTokenEx( - hToken, - TokenAccessLevels.MaximumAllowed, - IntPtr.Zero, - SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation, - TOKEN_TYPE.TokenPrimary, - out hTokenDup)) - { - throw new Win32Exception("Failed to duplicate the SYSTEM account token"); - } - } - finally + // We need the SYSTEM token if we want to become one of those accounts, fail here + throw new Win32Exception("Failed to get token for NT AUTHORITY\\SYSTEM"); + } + else if (hSystemToken != IntPtr.Zero) + { + // We have the token, need to duplicate and impersonate + bool dupResult = DuplicateTokenEx( + hSystemToken, + TokenAccessLevels.MaximumAllowed, + IntPtr.Zero, + SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation, + TOKEN_TYPE.TokenPrimary, + out hSystemTokenDup); + int lastError = Marshal.GetLastWin32Error(); + CloseHandle(hSystemToken); + + if (!dupResult && service_sids.Contains(account_sid)) + throw new Win32Exception(lastError, "Failed to duplicate token for NT AUTHORITY\\SYSTEM"); + else if (dupResult && account_sid != "S-1-5-18") { - CloseHandle(hToken); + if (ImpersonateLoggedOnUser(hSystemTokenDup)) + impersonated = true; + else if (service_sids.Contains(account_sid)) + throw new Win32Exception("Failed to impersonate as SYSTEM account"); } + } - string lpszDomain = "NT AUTHORITY"; - string lpszUsername = null; + LogonType logonType; + string domain = null; + if (service_sids.Contains(account_sid)) + { + logonType = LogonType.LOGON32_LOGON_SERVICE; + domain = "NT AUTHORITY"; + password = null; switch (account_sid) { case "S-1-5-18": - tokens.Add(hTokenDup); + tokens.Add(hSystemTokenDup); return tokens; case "S-1-5-19": - lpszUsername = "LocalService"; + username = "LocalService"; break; case "S-1-5-20": - lpszUsername = "NetworkService"; + username = "NetworkService"; break; } - - if (!ImpersonateLoggedOnUser(hTokenDup)) - throw new Win32Exception("Failed to impersonate as SYSTEM account"); - - IntPtr newToken = IntPtr.Zero; - if (!LogonUser( - lpszUsername, - lpszDomain, - null, - LogonType.LOGON32_LOGON_SERVICE, - LogonProvider.LOGON32_PROVIDER_DEFAULT, - out newToken)) - { - throw new Win32Exception("LogonUser failed"); - } - - RevertToSelf(); - tokens.Add(newToken); - return tokens; } else { // We are trying to become a local or domain account - string domain = null; + logonType = LogonType.LOGON32_LOGON_INTERACTIVE; if (username.Contains(@"\")) { var user_split = username.Split(Convert.ToChar(@"\")); @@ -679,31 +828,35 @@ namespace Ansible domain = null; else domain = "."; + } - // Logon and get the token - IntPtr hToken = IntPtr.Zero; - if (!LogonUser( - username, - domain, - password, - LogonType.LOGON32_LOGON_INTERACTIVE, - LogonProvider.LOGON32_PROVIDER_DEFAULT, - out hToken)) - { - throw new Win32Exception("LogonUser failed"); - } + IntPtr hToken = IntPtr.Zero; + if (!LogonUser( + username, + domain, + password, + logonType, + LogonProvider.LOGON32_PROVIDER_DEFAULT, + out hToken)) + { + throw new Win32Exception("LogonUser failed"); + } - // Get the elevate token + if (!service_sids.Contains(account_sid)) + { + // Try and get the elevated token for local/domain account IntPtr hTokenElevated = GetElevatedToken(hToken); - tokens.Add(hTokenElevated); - tokens.Add(hToken); - - return tokens; } + tokens.Add(hToken); + + if (impersonated) + RevertToSelf(); + + return tokens; } - private static IntPtr GetUserHandle() + private static IntPtr GetSystemUserHandle() { uint array_byte_size = 1024 * sizeof(uint); IntPtr[] pids = new IntPtr[1024]; @@ -864,6 +1017,44 @@ namespace Ansible security.Persist(safeHandle, AccessControlSections.Access); } + private static void ResumeThreadById(int threadId) + { + var threadHandle = OpenThread(ThreadAccessRights.SUSPEND_RESUME, false, threadId); + if (threadHandle.IsInvalid) + throw new Win32Exception(String.Format("Thread ID {0} is invalid", threadId)); + + try + { + if (ResumeThread(threadHandle) == -1) + throw new Win32Exception(String.Format("Thread ID {0} cannot be resumed", threadId)); + } + finally + { + threadHandle.Dispose(); + } + } + + private static void ResumeProcessById(int pid) + { + var proc = Process.GetProcessById(pid); + + // wait for at least one suspended thread in the process (this handles possible slow startup race where + // primary thread of created-suspended process has not yet become runnable) + var retryCount = 0; + while (!proc.Threads.OfType().Any(t => t.ThreadState == System.Diagnostics.ThreadState.Wait && + t.WaitReason == ThreadWaitReason.Suspended)) + { + proc.Refresh(); + Thread.Sleep(50); + if (retryCount > 100) + throw new InvalidOperationException(String.Format("No threads were suspended in target PID {0} after 5s", pid)); + } + + foreach (var thread in proc.Threads.OfType().Where(t => t.ThreadState == System.Diagnostics.ThreadState.Wait && + t.WaitReason == ThreadWaitReason.Suspended)) + ResumeThreadById(thread.Id); + } + private class GenericSecurity : NativeObjectSecurity { public GenericSecurity(bool isContainer, ResourceType resType, SafeHandle objectHandle, AccessControlSections sectionsRequested) @@ -969,8 +1160,7 @@ Function Run($payload) { $username = $payload.become_user $password = $payload.become_password - # FUTURE: convert to SafeHandle so we can stop ignoring warnings? - Add-Type -TypeDefinition $helper_def -Debug:$false -IgnoreWarnings + Add-Type -TypeDefinition $helper_def -Debug:$false # NB: CreateProcessWithTokenW commandline maxes out at 1024 chars, must bootstrap via filesystem $temp = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName() + ".ps1") diff --git a/test/integration/targets/win_become/tasks/main.yml b/test/integration/targets/win_become/tasks/main.yml index c31bda92af9..9d046c44e8c 100644 --- a/test/integration/targets/win_become/tasks/main.yml +++ b/test/integration/targets/win_become/tasks/main.yml @@ -1,5 +1,6 @@ - set_fact: become_test_username: ansible_become_test + become_test_admin_username: ansible_become_admin gen_pw: password123! + {{ lookup('password', '/dev/null chars=ascii_letters,digits length=8') }} - name: create unprivileged user @@ -9,16 +10,19 @@ update_password: always groups: Users +- name: create a privileged user + win_user: + name: "{{ become_test_admin_username }}" + password: "{{ gen_pw }}" + update_password: always + groups: Administrators + - name: execute tests and ensure that test user is deleted regardless of success/failure block: - name: ensure current user is not the become user win_shell: whoami register: whoami_out - - - name: verify output - assert: - that: - - not whoami_out.stdout_lines[0].endswith(become_test_username) + failed_when: whoami_out.stdout_lines[0].endswith(become_test_username) or whoami_out.stdout_lines[0].endswith(become_test_admin_username) - name: get become user profile dir so we can clean it up later vars: &become_vars @@ -34,7 +38,21 @@ that: - become_test_username in profile_dir_out.stdout_lines[0] - - name: test become runas via task vars + - name: get become admin user profile dir so we can clean it up later + vars: &admin_become_vars + ansible_become_user: "{{ become_test_admin_username }}" + ansible_become_password: "{{ gen_pw }}" + ansible_become_method: runas + ansible_become: yes + win_shell: $env:USERPROFILE + register: admin_profile_dir_out + + - name: ensure profile dir contains admin test username + assert: + that: + - become_test_admin_username in admin_profile_dir_out.stdout_lines[0] + + - name: test become runas via task vars (underprivileged user) vars: *become_vars win_shell: whoami register: whoami_out @@ -44,6 +62,36 @@ that: - whoami_out.stdout_lines[0].endswith(become_test_username) + - name: test become runas to ensure underprivileged user has medium integrity level + vars: *become_vars + win_shell: whoami /groups + register: whoami_out + + - name: verify output + assert: + that: + - '"Mandatory Label\Medium Mandatory Level" in whoami_out.stdout' + + - name: test become runas via task vars (privileged user) + vars: *admin_become_vars + win_shell: whoami + register: whoami_out + + - name: verify output + assert: + that: + - whoami_out.stdout_lines[0].endswith(become_test_admin_username) + + - name: test become runas to ensure privileged user has high integrity level + vars: *admin_become_vars + win_shell: whoami /groups + register: whoami_out + + - name: verify output + assert: + that: + - '"Mandatory Label\High Mandatory Level" in whoami_out.stdout' + - name: test become runas via task keywords vars: ansible_become_password: "{{ gen_pw }}" @@ -51,7 +99,6 @@ become_method: runas become_user: "{{ become_test_username }}" win_shell: whoami - register: whoami_out - name: verify output @@ -111,17 +158,54 @@ that: - whoami_out.stdout_lines[0] == "nt authority\\local service" + # Test out Async on Windows Server 2012+ + - name: get OS version + win_shell: if ([System.Environment]::OSVersion.Version -ge [Version]"6.2") { $true } else { $false } + register: os_version + + - name: test become + async on older hosts + vars: *become_vars + win_command: whoami + async: 10 + register: whoami_out + ignore_errors: yes + + - name: verify older hosts failed with become + async + assert: + that: + - whoami_out|failed + when: os_version.stdout_lines[0] == "False" + + - name: verify newer hosts worked with become + async + assert: + that: + - whoami_out|success + when: os_version.stdout_lines[0] == "True" + # FUTURE: test raw + script become behavior once they're running under the exec wrapper again # FUTURE: add standalone playbook tests to include password prompting and play become keywords always: - - name: ensure test user is deleted + - name: ensure underprivileged test user is deleted win_user: name: "{{ become_test_username }}" state: absent - - name: ensure test user profile is deleted + + - name: ensure privileged test user is deleted + win_user: + name: "{{ become_test_admin_username }}" + state: absent + + - name: ensure underprivileged test user profile is deleted # NB: have to work around powershell limitation of long filenames until win_file fixes it win_shell: rmdir /S /Q {{ profile_dir_out.stdout_lines[0] }} args: executable: cmd.exe when: become_test_username in profile_dir_out.stdout_lines[0] + + - name: ensure privileged test user profile is deleted + # NB: have to work around powershell limitation of long filenames until win_file fixes it + win_shell: rmdir /S /Q {{ admin_profile_dir_out.stdout_lines[0] }} + args: + executable: cmd.exe + when: become_test_admin_username in admin_profile_dir_out.stdout_lines[0]