diff --git a/docs/docsite/rst/become.rst b/docs/docsite/rst/become.rst index f7b69ee2d1d..0b03854ad29 100644 --- a/docs/docsite/rst/become.rst +++ b/docs/docsite/rst/become.rst @@ -449,6 +449,79 @@ or with this Ansible task: to set the account's password under ``ansible_become_pass`` if the become_user has a password. +Become Flags +------------ +Ansible 2.5 adds the ``become_flags`` parameter to the ``runas`` become method. This parameter can be set using the ``become_flags`` task directive or set in Ansible's configuration using ``ansible_become_flags``. The two valid values that are initially supported for this parameter are ``logon_type`` and ``logon_flags``. + + +.. Note:: These flags should only be set when becoming a normal user account, not a local service account like LocalSystem. + +The key ``logon_type`` sets the type of logon operation to perform. The value +can be set to one of the following: + +* ``interactive``: The default logon type. The process will be run under a + context that is the same as when running a process locally. This bypasses all + WinRM restrictions and is the recommended method to use. + +* ``batch``: Runs the process under a batch context that is similar to a + scheduled task with a password set. This should bypass most WinRM + restrictions and is useful if the ``become_user`` is not allowed to log on + interactively. + +* ``new_credentials``: Runs under the same credentials as the calling user, but + outbound connections are run under the context of the ``become_user`` and + ``become_password``, similar to ``runas.exe /netonly``. The ``logon_flags`` + flag should also be set to ``netcredentials_only``. Use this flag if + the process needs to access a network resource (like an SMB share) using a + different set of credentials. + +* ``network``: Runs the process under a network context without any cached + credentials. This results in the same type of logon session as running a + normal WinRM process without credential delegation, and operates under the same + restrictions. + +* ``network_cleartext``: Like the ``network`` logon type, but instead caches + the credentials so it can access network resources. This is the same type of + logon session as running a normal WinRM process with credential delegation. + +For more information, see +`dwLogonType `_. + +The ``logon_flags`` key specifies how Windows will log the user on when creating +the new process. The value can be set to one of the following: + +* ``with_profile``: The default logon flag set. The process will load the + user's profile in the ``HKEY_USERS`` registry key to ``HKEY_CURRENT_USER``. + +* ``netcredentials_only``: The process will use the same token as the caller + but will use the ``become_user`` and ``become_password`` when accessing a remote + resource. This is useful in inter-domain scenarios where there is no trust + relationship, and should be used with the ``new_credentials`` ``logon_type``. + +For more information, see `dwLogonFlags `_. + +Here are some examples of how to use ``become_flags`` with Windows tasks: + +.. code-block:: yaml + + - name: copy a file from a fileshare with custom credentials + win_copy: + src: \\server\share\data\file.txt + dest: C:\temp\file.txt + remote_src: yex + vars: + ansible_become: yes + ansible_become_method: runas + ansible_become_user: DOMAIN\user + ansible_become_pass: Password01 + ansible_become_flags: logon_type=new_credentials logon_flags=netcredentials_only + + - name: run a command under a batch logon + win_command: whoami + become: yes + become_flags: logon_type=batch + + Limitations ----------- @@ -457,8 +530,8 @@ Be aware of the following limitations with ``become`` on Windows: * Running a task with ``async`` and ``become`` on Windows Server 2008, 2008 R2 and Windows 7 does not work. -* The become user logs on with an interactive session, so it must have the - ability to do so on the Windows host. If it does not inherit the +* By default, the become user logs on with an interactive session, so it must + have the right to do so on the Windows host. If it does not inherit the ``SeAllowLogOnLocally`` privilege or inherits the ``SeDenyLogOnLocally`` privilege, the become process will fail. diff --git a/lib/ansible/executor/module_common.py b/lib/ansible/executor/module_common.py index 1dc832ce872..fe0704aca16 100644 --- a/lib/ansible/executor/module_common.py +++ b/lib/ansible/executor/module_common.py @@ -599,7 +599,7 @@ def _is_binary(b_module_data): def _find_module_utils(module_name, b_module_data, module_path, module_args, task_vars, templar, module_compression, async_timeout, become, - become_method, become_user, become_password, environment): + become_method, become_user, become_password, become_flags, environment): """ Given the source of the module, convert it to a Jinja2 template to insert module code and return whether it's a new or old style module. @@ -787,6 +787,7 @@ def _find_module_utils(module_name, b_module_data, module_path, module_args, tas exec_manifest["actions"].insert(0, 'become') exec_manifest["become_user"] = become_user exec_manifest["become_password"] = become_password + exec_manifest['become_flags'] = become_flags exec_manifest["become"] = to_text(base64.b64encode(to_bytes(become_wrapper))) lines = b_module_data.split(b'\n') @@ -842,6 +843,7 @@ def _find_module_utils(module_name, b_module_data, module_path, module_args, tas exec_manifest["actions"].insert(0, 'become') exec_manifest["become_user"] = "SYSTEM" exec_manifest["become_password"] = None + exec_manifest['become_flags'] = None exec_manifest["become"] = to_text(base64.b64encode(to_bytes(become_wrapper))) # FUTURE: smuggle this back as a dict instead of serializing here; the connection plugin may need to modify it @@ -872,7 +874,7 @@ def _find_module_utils(module_name, b_module_data, module_path, module_args, tas def modify_module(module_name, module_path, module_args, task_vars=None, templar=None, module_compression='ZIP_STORED', async_timeout=0, become=False, - become_method=None, become_user=None, become_password=None, environment=None): + become_method=None, become_user=None, become_password=None, become_flags=None, environment=None): """ Used to insert chunks of code into modules before transfer rather than doing regular python imports. This allows for more efficient transfer in @@ -903,7 +905,7 @@ def modify_module(module_name, module_path, module_args, task_vars=None, templar (b_module_data, module_style, shebang) = _find_module_utils(module_name, b_module_data, module_path, module_args, task_vars, templar, module_compression, async_timeout=async_timeout, become=become, become_method=become_method, - become_user=become_user, become_password=become_password, + become_user=become_user, become_password=become_password, become_flags=become_flags, environment=environment) if module_style == 'binary': diff --git a/lib/ansible/plugins/action/__init__.py b/lib/ansible/plugins/action/__init__.py index 8160e13ce09..0b84550421f 100644 --- a/lib/ansible/plugins/action/__init__.py +++ b/lib/ansible/plugins/action/__init__.py @@ -157,6 +157,7 @@ class ActionBase(with_metaclass(ABCMeta, object)): become_method=self._play_context.become_method, become_user=self._play_context.become_user, become_password=self._play_context.become_pass, + become_flags=self._play_context.become_flags, environment=final_environment) return (module_style, module_shebang, module_data, module_path) diff --git a/lib/ansible/plugins/shell/powershell.py b/lib/ansible/plugins/shell/powershell.py index 4323f125b9f..22b9016d0ab 100644 --- a/lib/ansible/plugins/shell/powershell.py +++ b/lib/ansible/plugins/shell/powershell.py @@ -604,9 +604,14 @@ namespace Ansible private static extern int ResumeThread( SafeHandle hThread); - public static CommandResult RunAsUser(string username, string password, string lpCommandLine, string lpCurrentDirectory, string stdinInput) + public static CommandResult RunAsUser(string username, string password, string lpCommandLine, + string lpCurrentDirectory, string stdinInput, LogonFlags logonFlags, LogonType logonType) { - SecurityIdentifier account = GetBecomeSid(username); + SecurityIdentifier account = null; + if (logonType != LogonType.LOGON32_LOGON_NEW_CREDENTIALS) + { + account = GetBecomeSid(username); + } STARTUPINFOEX si = new STARTUPINFOEX(); si.startupInfo.dwFlags = (int)StartupInfoFlags.USESTDHANDLES; @@ -649,14 +654,14 @@ namespace Ansible PROCESS_INFORMATION pi = new PROCESS_INFORMATION(); // Get the user tokens to try running processes with - List tokens = GetUserTokens(account, username, password); + List tokens = GetUserTokens(account, username, password, logonType); bool launch_success = false; foreach (IntPtr token in tokens) { if (CreateProcessWithTokenW( token, - LogonFlags.LOGON_WITH_PROFILE, + logonFlags, null, new StringBuilder(lpCommandLine), startup_flags, @@ -729,7 +734,7 @@ namespace Ansible } } - private static List GetUserTokens(SecurityIdentifier account, string username, string password) + private static List GetUserTokens(SecurityIdentifier account, string username, string password, LogonType logonType) { List tokens = new List(); List service_sids = new List() @@ -739,16 +744,20 @@ namespace Ansible "S-1-5-20" // NT AUTHORITY\NetworkService }; - GrantAccessToWindowStationAndDesktop(account); - string account_sid = account.ToString(); + IntPtr hSystemToken = IntPtr.Zero; + string account_sid = ""; + if (logonType != LogonType.LOGON32_LOGON_NEW_CREDENTIALS) + { + GrantAccessToWindowStationAndDesktop(account); + // Try to get SYSTEM token handle so we can impersonate to get full admin token + hSystemToken = GetSystemUserHandle(); + account_sid = account.ToString(); + } bool impersonated = false; try { IntPtr hSystemTokenDup = IntPtr.Zero; - - // 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 need the SYSTEM token if we want to become one of those accounts, fail here @@ -780,12 +789,11 @@ namespace Ansible // might get a limited token in UAC-enabled cases, but better than nothing... } - LogonType logonType; string domain = null; if (service_sids.Contains(account_sid)) { - // We're using a well-known service account, do a service logon instead of interactive + // We're using a well-known service account, do a service logon instead of the actual flag set logonType = LogonType.LOGON32_LOGON_SERVICE; domain = "NT AUTHORITY"; password = null; @@ -805,7 +813,6 @@ namespace Ansible else { // We are trying to become a local or domain account - logonType = LogonType.LOGON32_LOGON_INTERACTIVE; if (username.Contains(@"\")) { var user_split = username.Split(Convert.ToChar(@"\")); @@ -876,7 +883,6 @@ namespace Ansible TokenAccessLevels.AssignPrimary | TokenAccessLevels.Impersonate; - // TODO: Find out why I can't see processes from Network Service and Local Service if (OpenProcessToken(hProcess, desired_access, out hToken)) { string sid = GetTokenUserSID(hToken); @@ -1144,16 +1150,84 @@ Function Dump-Error ($excep) { $eo.exception = $excep | Out-String $host.SetShouldExit(1) - $eo | ConvertTo-Json -Depth 10 + $eo | ConvertTo-Json -Depth 10 -Compress +} + +Function Parse-EnumValue($enum, $flag_type, $value, $prefix) { + $raw_enum_value = "$prefix$($value.ToUpper())" + try { + $enum_value = [Enum]::Parse($enum, $raw_enum_value) + } catch [System.ArgumentException] { + $valid_options = [Enum]::GetNames($enum) | ForEach-Object { $_.Substring($prefix.Length).ToLower() } + throw "become_flags $flag_type value '$value' is not valid, valid values are: $($valid_options -join ", ")" + } + return $enum_value +} + +Function Parse-BecomeFlags($flags) { + $logon_type = [Ansible.LogonType]::LOGON32_LOGON_INTERACTIVE + $logon_flags = [Ansible.LogonFlags]::LOGON_WITH_PROFILE + + if ($flags -eq $null -or $flags -eq "") { + $flag_split = @() + } elseif ($flags -is [string]) { + $flag_split = $flags.Split(" ") + } else { + throw "become_flags must be a string, was $($flags.GetType())" + } + + foreach ($flag in $flag_split) { + $split = $flag.Split("=") + if ($split.Count -ne 2) { + throw "become_flags entry '$flag' is in an invalid format, must be a key=value pair" + } + $flag_key = $split[0] + $flag_value = $split[1] + if ($flag_key -eq "logon_type") { + $enum_details = @{ + enum = [Ansible.LogonType] + flag_type = $flag_key + value = $flag_value + prefix = "LOGON32_LOGON_" + } + $logon_type = Parse-EnumValue @enum_details + } elseif ($flag_key -eq "logon_flags") { + $logon_flag_values = $flag_value.Split(",") + $logon_flags = 0 -as [Ansible.LogonFlags] + foreach ($logon_flag_value in $logon_flag_values) { + if ($logon_flag_value -eq "") { + continue + } + $enum_details = @{ + enum = [Ansible.LogonFlags] + flag_type = $flag_key + value = $logon_flag_value + prefix = "LOGON_" + } + $logon_flag = Parse-EnumValue @enum_details + $logon_flags = $logon_flags -bor $logon_flag + } + } else { + throw "become_flags key '$flag_key' is not a valid runas flag, must be 'logon_type' or 'logon_flags'" + } + } + + return $logon_type, [Ansible.LogonFlags]$logon_flags } Function Run($payload) { # NB: action popping handled inside subprocess wrapper + Add-Type -TypeDefinition $helper_def -Debug:$false + $username = $payload.become_user $password = $payload.become_password - - Add-Type -TypeDefinition $helper_def -Debug:$false + try { + $logon_type, $logon_flags = Parse-BecomeFlags -flags $payload.become_flags + } catch { + Dump-Error -excep $_ + return $null + } # 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") @@ -1161,24 +1235,29 @@ Function Run($payload) { $rc = 0 Try { - # allow (potentially unprivileged) target user access to the tempfile (NB: this likely won't work if traverse checking is enabled) - $acl = Get-Acl $temp + # do not modify the ACL if the logon_type is LOGON32_LOGON_NEW_CREDENTIALS + # as this results in the local execution running under the same user's token, + # otherwise we need to allow (potentially unprivileges) the become user access + # to the tempfile (NB: this likely won't work if traverse checking is enaabled). + if ($logon_type -ne [Ansible.LogonType]::LOGON32_LOGON_NEW_CREDENTIALS) { + $acl = Get-Acl -Path $temp - Try { - $acl.AddAccessRule($(New-Object System.Security.AccessControl.FileSystemAccessRule($username, "FullControl", "Allow"))) + Try { + $acl.AddAccessRule($(New-Object System.Security.AccessControl.FileSystemAccessRule($username, "FullControl", "Allow"))) + } Catch [System.Security.Principal.IdentityNotMappedException] { + throw "become_user '$username' is not recognized on this host" + } Catch { + throw "failed to set ACL on temp become execution script: $($_.Exception.Message)" + } + Set-Acl -Path $temp -AclObject $acl | Out-Null } - Catch [System.Security.Principal.IdentityNotMappedException] { - throw "become_user '$username' is not recognized on this host" - } - - Set-Acl $temp $acl | Out-Null $payload_string = $payload | ConvertTo-Json -Depth 99 -Compress $lp_command_line = New-Object System.Text.StringBuilder @("powershell.exe -NonInteractive -NoProfile -ExecutionPolicy Bypass -File $temp") $lp_current_directory = "$env:SystemRoot" - $result = [Ansible.BecomeUtil]::RunAsUser($username, $password, $lp_command_line, $lp_current_directory, $payload_string) + $result = [Ansible.BecomeUtil]::RunAsUser($username, $password, $lp_command_line, $lp_current_directory, $payload_string, $logon_flags, $logon_type) $stdout = $result.StandardOut $stderr = $result.StandardError $rc = $result.ExitCode diff --git a/test/integration/targets/win_become/tasks/main.yml b/test/integration/targets/win_become/tasks/main.yml index f0245852a1f..a2ec24b6040 100644 --- a/test/integration/targets/win_become/tasks/main.yml +++ b/test/integration/targets/win_become/tasks/main.yml @@ -9,6 +9,7 @@ password: "{{ gen_pw }}" update_password: always groups: Users + register: user_limited_result - name: create a privileged user win_user: @@ -16,13 +17,24 @@ password: "{{ gen_pw }}" update_password: always groups: Administrators + register: user_admin_result + +- name: add requisite logon rights for test user + win_user_right: + name: '{{item}}' + users: '{{become_test_username}}' + action: add + with_items: + - SeNetworkLogonRight + - SeInteractiveLogonRight + - SeBatchLogonRight - 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 + win_whoami: register: whoami_out - failed_when: whoami_out.stdout_lines[0].endswith(become_test_username) or whoami_out.stdout_lines[0].endswith(become_test_admin_username) + failed_when: whoami_out.account.sid == user_limited_result.sid or whoami_out.account.sid == user_admin_result.sid - name: get become user profile dir so we can clean it up later vars: &become_vars @@ -54,43 +66,31 @@ - name: test become runas via task vars (underprivileged user) vars: *become_vars - win_shell: whoami + win_whoami: register: whoami_out - name: verify output assert: 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' + - whoami_out.account.sid == user_limited_result.sid + - whoami_out.account.account_name == become_test_username + - whoami_out.label.account_name == 'Medium Mandatory Level' + - whoami_out.label.sid == 'S-1-16-8192' + - whoami_out.logon_type == 'Interactive' - name: test become runas via task vars (privileged user) vars: *admin_become_vars - win_shell: whoami + win_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' + - whoami_out.account.sid == user_admin_result.sid + - whoami_out.account.account_name == become_test_admin_username + - whoami_out.label.account_name == 'High Mandatory Level' + - whoami_out.label.sid == 'S-1-16-12288' + - whoami_out.logon_type == 'Interactive' - name: test become runas via task keywords vars: @@ -110,20 +110,24 @@ vars: *become_vars block: - name: ask who the current user is - win_shell: whoami + win_whoami: register: whoami_out - name: verify output assert: that: - - whoami_out.stdout_lines[0].endswith(become_test_username) + - whoami_out.account.sid == user_limited_result.sid + - whoami_out.account.account_name == become_test_username + - whoami_out.label.account_name == 'Medium Mandatory Level' + - whoami_out.label.sid == 'S-1-16-8192' + - whoami_out.logon_type == 'Interactive' - name: test with module that will return non-zero exit code (https://github.com/ansible/ansible/issues/30468) vars: *become_vars setup: - name: test become with SYSTEM account - win_command: whoami + win_whoami: become: yes become_method: runas become_user: SYSTEM @@ -132,10 +136,15 @@ - name: verify output assert: that: - - whoami_out.stdout_lines[0] == "nt authority\\system" + - whoami_out.account.sid == "S-1-5-18" + - whoami_out.account.account_name == "SYSTEM" + - whoami_out.account.domain_name == "NT AUTHORITY" + - whoami_out.label.account_name == 'System Mandatory Level' + - whoami_out.label.sid == 'S-1-16-16384' + - whoami_out.logon_type == 'System' - name: test become with NetworkService account - win_command: whoami + win_whoami: become: yes become_method: runas become_user: NetworkService @@ -144,10 +153,15 @@ - name: verify output assert: that: - - whoami_out.stdout_lines[0] == "nt authority\\network service" + - whoami_out.account.sid == "S-1-5-20" + - whoami_out.account.account_name == "NETWORK SERVICE" + - whoami_out.account.domain_name == "NT AUTHORITY" + - whoami_out.label.account_name == 'System Mandatory Level' + - whoami_out.label.sid == 'S-1-16-16384' + - whoami_out.logon_type == 'Service' - name: test become with LocalService account - win_command: whoami + win_whoami: become: yes become_method: runas become_user: LocalService @@ -156,11 +170,24 @@ - name: verify output assert: that: - - whoami_out.stdout_lines[0] == "nt authority\\local service" + - whoami_out.account.sid == "S-1-5-19" + - whoami_out.account.account_name == "LOCAL SERVICE" + - whoami_out.account.domain_name == "NT AUTHORITY" + - whoami_out.label.account_name == 'System Mandatory Level' + - whoami_out.label.sid == 'S-1-16-16384' + - whoami_out.logon_type == '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 } + win_shell: | + $version = [System.Environment]::OSVersion.Version + if ($version -ge [Version]"6.2") { + "async" + } elseif ($version -lt [Version]"6.1") { + "old-gramps" + } else { + "" + } register: os_version - name: test become + async on older hosts @@ -174,18 +201,85 @@ assert: that: - whoami_out is failed - when: os_version.stdout_lines[0] == "False" + when: os_version.stdout_lines[0] != "async" - name: verify newer hosts worked with become + async assert: that: - whoami_out is successful - when: os_version.stdout_lines[0] == "True" + when: os_version.stdout_lines[0] == "async" + + - name: test failure with string become invalid key + vars: *become_vars + win_whoami: + become_flags: logon_type=batch invalid_flags=a + become_method: runas + register: failed_flags_invalid_key + failed_when: failed_flags_invalid_key.msg != "become_flags key 'invalid_flags' is not a valid runas flag, must be 'logon_type' or 'logon_flags'" + + - name: test failure with invalid logon_type + vars: *become_vars + win_whoami: + become_flags: logon_type=invalid + register: failed_flags_invalid_type + failed_when: "failed_flags_invalid_type.msg != \"become_flags logon_type value 'invalid' is not valid, valid values are: interactive, network, batch, service, unlock, network_cleartext, new_credentials\"" + + - name: test failure with invalid logon_flag + vars: *become_vars + win_whoami: + become_flags: logon_flags=with_profile,invalid + register: failed_flags_invalid_flag + failed_when: "failed_flags_invalid_flag.msg != \"become_flags logon_flags value 'invalid' is not valid, valid values are: with_profile, netcredentials_only\"" + + # Server 2008 doesn't work with network and network_cleartext, there isn't really a reason why you would want this anyway + - name: become different types + vars: *become_vars + win_whoami: + become_flags: logon_type={{item.type}} + register: become_logon_type + when: not ((item.type == 'network' or item.type == 'network_cleartext') and os_version.stdout_lines[0] == "old-gramps") + failed_when: become_logon_type.logon_type != item.actual and become_logon_type.sid != user_limited_result.sid + with_items: + - type: interactive + actual: Interactive + - type: batch + actual: Batch + - type: network + actual: Network + - type: network_cleartext + actual: NetworkCleartext + + - name: become netcredentials with network user + vars: + ansible_become_user: fakeuser + ansible_become_password: fakepassword + ansible_become_method: runas + ansible_become: True + ansible_become_flags: logon_type=new_credentials logon_flags=netcredentials_only + win_whoami: + register: become_netcredentials + + - name: assert become netcredentials with network user + assert: + that: + # new_credentials still come up as the ansible_user so we can't test that + - become_netcredentials.label.account_name == 'High Mandatory Level' + - become_netcredentials.label.sid == 'S-1-16-12288' # 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: remove explicit logon rights for test user + win_user_right: + name: '{{item}}' + users: '{{become_test_username}}' + action: remove + with_items: + - SeNetworkLogonRight + - SeInteractiveLogonRight + - SeBatchLogonRight + - name: ensure underprivileged test user is deleted win_user: name: "{{ become_test_username }}"