win_become: another option to support become flags for runas (#34551)

* win_become: another option to support become flags for runas

* removed uneeded entries

* fixed up whitespace issue

* Copy edit
This commit is contained in:
Jordan Borean 2018-01-20 07:58:10 +10:00 committed by Matt Davis
parent 1c22d82c5e
commit d0e6889f93
5 changed files with 318 additions and 69 deletions

View file

@ -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 <https://msdn.microsoft.com/en-au/library/windows/desktop/aa378184.aspx>`_.
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 <https://msdn.microsoft.com/en-us/library/windows/desktop/ms682434.aspx>`_.
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.

View file

@ -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':

View file

@ -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)

View file

@ -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<IntPtr> tokens = GetUserTokens(account, username, password);
List<IntPtr> 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<IntPtr> GetUserTokens(SecurityIdentifier account, string username, string password)
private static List<IntPtr> GetUserTokens(SecurityIdentifier account, string username, string password, LogonType logonType)
{
List<IntPtr> tokens = new List<IntPtr>();
List<String> service_sids = new List<String>()
@ -739,16 +744,20 @@ namespace Ansible
"S-1-5-20" // NT AUTHORITY\NetworkService
};
IntPtr hSystemToken = IntPtr.Zero;
string account_sid = "";
if (logonType != LogonType.LOGON32_LOGON_NEW_CREDENTIALS)
{
GrantAccessToWindowStationAndDesktop(account);
string account_sid = account.ToString();
// 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")))
}
Catch [System.Security.Principal.IdentityNotMappedException] {
} 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
}
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

View file

@ -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 }}"