Veeam Backup & Response - RCE With Auth, But Mostly Without Auth (CVE-2024-40711)

Veeam Backup & Response - RCE With Auth, But Mostly Without Auth (CVE-2024-40711)

Every sysadmin is familiar with Veeam’s enterprise-oriented backup solution, ‘Veeam Backup & Replication’. Unfortunately, so is every ransomware operator, given it's somewhat 'privileged position' in the storage world of most enterprise's networks. There's no point deploying cryptolocker malware on a target unless you can also deny access to backups, and so, this class of attackers absolutely loves to break this particular software.

With so many eyes focussed on it, then, it is no huge surprise that it has a rich history of CVEs. Today, we're going to look at the latest episode - CVE-2024-40711.

This vulnerability was reported by Florian Hauser with Code White Gmbh, who state on their website that the bug is an unauthenticated RCE. Veeam themselves have released a fix, and their advisory tells us that it affects version 12.1.2.172 and below.

Code White didn't release details, though, and we were curious about such a powerful bug in such a hot target. We went to find the root-cause ourselves, and found a technically-interesting set of vulnerabilities - and also a patching situation somewhat more involved than first appears.

We start at the beginning, at the advisory, reproduced here for your convenience:

Our first curiosity starts here. Looking at the CVSS score closely, you’ll notice that the “Privilege Required” option has been set to “Low”, denoting that authentication is required, even though the text claims the bug to be 'unauthenticated'. Perhaps this is an error.

Taking notice of Code White's work, we can see that they released a video of exploitation via Twitter (uh, via X, I mean):

The published PoC video demonstrated exploitation on version 12.1.0.2131, which seemed a bit strange - why not the latest-affected version, 12.1.2.172? Perhaps that's all they had at hand. Either way, here's the full thing, should you like to take a look:

Veeam also published a table containing all affected Veeam Backup & Replication releases and their respective build numbers.

Code White didn’t include any information regarding the vulnerability type, and so our first step is to start the patch diffing process to identify the root cause of the vulnerabilities.

Usually, when one decides to start patch diffing for vulnerabilities, they will get the latest version and one version before, so the number of differences will be minimized. This allows the researcher to focus on security-related areas and discount changes related to functionality. That’s exactly what we did - but it soon turned out to be a mistake.

Patch Diffing Chaos

Hoping this was going to be a quick process as usual, we jumped in and started diffing the latest version, which is 12.2.0.334, against the previous version, 12.1.2.172. Quickly, though, we realized the scale of changes was more than expected - to be precise, 2,600 files have been changed!

Luckily for us, 700 of these files contained only minor changes and could be quickly discounted - trivial things, like version numbers being updated. This left us with 1,900 changed files, including .NET class files, configuration files, and resources. This is a huge number - does the security patch really affect that many files!? Surely not!

An excerpt of changed files

Another interesting thing to mention here is that the latest version didn’t just fix the vulnerabilities related to the bug we're looking for, CVE-2024-40711; rather, according to Veeam, it fixed multiple security issues, which made it even more difficult to connect the pieces together. Which bugs are related to the Code White exploit, and which ones aren’t?

It could appear, to a suspicious onlooker, that the folks at Veeam are deliberately making an effort to make our lives difficult - mixing in security updates with functionality-related changes. Or perhaps the large amount of changes are coincidental, and the vulnerability fix simply lined up with a planned feature release.

After many hours of reviewing all of these changes, we started noticing traces of fixes for different vulnerabilities. Let’s begin with the changes that appear to be related to the Code White vulnerabilities.

A new entry to the blacklist

While patch-diffing between 12.2.0.334 and 12.1.2.172, one thing that stood out to us was an embedded resource file that was changed inside the Veeam.Backup.Common.dll . This isn’t the usual place to look for security-related patches!

Here is a close look at this change:

It is a very simple change - we can see that that Veeam have added a new line for the System.Runtime.Remoting.ObjRef .NET class type. This is a well-known .NET deserialization gadget that was created by Markus Wulftange, and indeed, is one of his 'signature' attacks. Given that the bug itself was discovered by Florian Hauser, who also works at Code White, one can begin to connect the dots - this must be one of the patches related to their exploit!

This blacklist file is a list of prohibited .NET class types, known to be used in deserialization attacks. This addition suggests that CVE-2024-40711 is related to a deserialization attack.

But is it that simple? Just a new gadget entry in a blacklist? The logical next step is for us to find where this blacklist is being used, and simply reach what it is protecting via this missing gadget.. then we get RCE, right?

Not so fast - it’s wayyyy more complicated than that!

Veeam .NET Remoting internals

Veeam Backup & Replication relies heavily on .NET Remoting, with numerous Veeam services (all running as NT Authority/System, you'll be interested to know) listening for .NET Remoting communications. Over the years, no one has managed to exploit these interfaces due to their strong ‘deserialization binder’ (more on that shortly) - perhaps Florian Hauser has managed to do so here. Before we go further, though, let’s look at how Veeam has protected itself historically against this class of attack.

Veeam implements its core .NET Remoting architecture inside the library Veeam.Common.Remoting.dll . This class does hundreds of things, but we are interested only in seeing how Veeam’s .NET Remoting implementation handles serialized remoting requests.

We’ve frequently seen people trying to implement their own custom .NET Remoting servers, and in order to do so, they follow ancient documentation about this topic (because all the kewl kids use WCF instead!). In order for one to have their own custom .NET Remoting implementation they must create classes that are derived from IServerChannelSink or IClientChannelSink .

James Forshaw talked about this exact topic in “Stupid is as Stupid Does When It Comes to .NET Remoting” back in 2014. As you can see from the below diagram, borrowed from James’ blogpost, there exist two key elements here:

  • Transport Sink
  • Formatter Sink

A “Transport Sink” is simply a class that is derived either from IServerChannelSink or IClientChannelSink (depending on which side of the communication you are taking care of). This will handle the receiving and processing of a .NET Remoting packet by implementing certain methods such as ProcessMessage . These methods are responsible for interacting with the “Formatter Sink” class which does the bulk of the deserialization.

Veeam has done just this, implementing their custom .NET Remoting server in the Veeam.Common.Remoting.CBinaryServerFormatterSink class:

As you can see this class implements the interface IServerChannelSink :

As expected, it has its own implementation of the ProcessMessage method. This method is huge, and so instead of a picture, we include a summarized version of it here and explain based on line numbers. We’ll need to examine this code in order to really understand what steps are taken by Veeam’s remoting implementation in order to deserialise a remoting request.

This method is called whenever a .NET Remoting message is received. It might look complicated at first, but it is mostly boilerplate code, handing the boring things that a developer needs to take care of when they receive a .NET Remoting message. Let’s dive in.

At line 45, the __RequestUri header is extracted to make sure the correct ObjectUri is being accessed. The __RequestUri is part of a .NET Remoting packet that is used to map URIs to specific Objects, this condition needs to be satisfied which is very easy to do so

Then, at line 49, the requestStream object which simply contains our serialized data is passed to the DeserializeBinaryRequestMessage method to be deserialized, and its return value assigned to the requestMsg variable. This is where the deserialization happens. Next, we need to look into this method and figure out exactly how is it done.

1:  public ServerProcessing ProcessMessage(IServerChannelSinkStack sinkStack, IMessage requestMsg, ITransportHeaders requestHeaders, Stream requestStream, out IMessage responseMsg, out ITransportHeaders responseHeaders, out Stream responseStream)
2:  {

[..SNIP..]

43:  				try
44:  				{
45:  					if (RemotingServices.GetServerTypeForUri((string)requestHeaders["__RequestUri"]) == null)
46:  					{
47:  						throw new RemotingException(string.Format("Remoting Channel Sink UriNotPublished. RequestUri is '{0}'", requestHeaders["__RequestUri"]));
48:  					}
49:  					requestMsg = CBinaryServerFormatterSink.DeserializeBinaryRequestMessage(requestStream, requestHeaders);
50:  					if (requestMsg == null)
51:  					{
52:  						throw new RemotingException("Remoting Deserialize Error");
53:  					}
54:  					IMethodMessage methodMessage = requestMsg as IMethodMessage;
55:  					if (methodMessage != null)
56:  					{
57:  						string text3 = requestHeaders["access_token"] as string;
58:  						Dictionary<string, object> dictionary;
59:  						EJwtValidationResult ejwtValidationResult = this._mfaProvider.ValidateToken(text3, out dictionary); // (*_*)
60:  						if (ejwtValidationResult == EJwtValidationResult.Empty || ejwtValidationResult == EJwtValidationResult.Invalid)
61:  						{
62:  							this.EnsureMfa(requestHeaders);
63:  						}
64:  						this.EnsureAccessIsAllowed(methodMessage);
65:  					}

This is where this method is implemented:

Veeam.Common.Remoting.CBinaryServerFormatterSink.DeserializeBinaryRequestMessage(Stream, ITransportHeaders)

Looking at the code, it looks simple, and apparently its doing exactly what we expected it to do. It creates a FormatterSink just like we mentioned earlier to deserialize our requestStream object. Lets see how they’ve implemented this “Formatter Sink”.

The formatter sink is implemented at the following location

Veeam.Common.Remoting.CBinaryServerFormatterSink.CreateFormatter(bool)

First, it will create an instance of the BinaryFormatter which is usually how .NET Remoting formatter sinks are made. Then, though, it does something very interesting - it assigns the Binder property of the binaryFormatter variable to a custom binder class. This is exactly what we would expect from Veeam, in order to protect against deserialization attacks. They are utilizing the concept of binders to control what types are allowed to be deserialized. They also assign the FilterLevel property of the binary formatter to TypeFilterLevel.Low .

Now normally, we need to look at the RestrictedSerializationBinder but before we go into its implementation, note a very important argument being passed to this custom binder class - the second argument mentions RestrictedSerializationBinder.Modes.FilterByWhitelist.

Whitelist? huh? Wasn’t it the blacklist change in our patch? Why is a whitelist being passed here? Remember that question going on as we dig in further!

This custom binder is implemented in another completely separate library veeam.backup.common.dll , called Veeam.Backup.Common.RestrictedSerializationBinder. This is how the class constructor looks. It expects two arguments, with the second argument being used for the _mode property. If not specified it will default to FilterByWhiteList .

Let's also take a look at the important methods in the binder class, RestrictedSerializationBinder.

We first start by looking at the EnsuredBlackWhitelistsAreLoaded method. It appears that this method is responsible for loading the whitelist and blacklist files, invoking the CWhiteList() and CBlackList() classes in order to do so.

Continuing our digging, we'll look at these two classes. The CWhitelist class is implemented in a straightforward manner, adding some allowed types manually and then invoking the FillFromEmbeddedResource method which will load class names from the whitelist.txt file, and use them to populate the this._allowedTypeFullNames property.

We have tried to minimize this code to keep it easy to read here. There are some other methods inside this class (and the CBlacklist) that will parse the .txt files in a certain format but the salient point is that we are populating entries based on the content of a text file.

namespace Veeam.Backup.Common
{
	// Token: 0x020003BC RID: 956
	public class CWhitelist
	{
		// Token: 0x060017AD RID: 6061 RVA: 0x0003EA6C File Offset: 0x0003CC6C
		public CWhitelist()
		{
			this._allowedTypeFullNames.Add(typeof(LogicalCallContext).AssemblyQualifiedName);
			this._allowedTypeFullNames.Add("System.UnitySerializationHolder, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089");
			this._allowedTypeFullNames.Add(typeof(EndPoint).AssemblyQualifiedName);
			this._allowedTypeFullNames.Add(typeof(DnsEndPoint).AssemblyQualifiedName);
			this._allowedTypeFullNames.Add(typeof(IPEndPoint).AssemblyQualifiedName);
			this._allowedTypeFullNames.Add(typeof(AddressFamily).AssemblyQualifiedName);
			this._allowedTypeFullNames.Add(typeof(IPAddress).AssemblyQualifiedName);
			this._allowedTypeFullNames.Add(typeof(SocketAddress).AssemblyQualifiedName);
			this.FillFromEmbeddedResource();
			string text = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "whitelist.txt");
			if (File.Exists(text))
			{
				this.FillFromFile(text);
			}
		}

		// Token: 0x060017AE RID: 6062 RVA: 0x0003EB88 File Offset: 0x0003CD88
		private void FillFromEmbeddedResource()
		{
			Assembly executingAssembly = Assembly.GetExecutingAssembly();
			string text = "Veeam.Backup.Common.Sources.System.IO.BinaryFormatter.whitelist.txt";
			using (Stream manifestResourceStream = executingAssembly.GetManifestResourceStream(text))
			{
				using (StreamReader streamReader = new StreamReader(manifestResourceStream))
				{
					this.FillInternal(streamReader);
				}
			}
		}
		
[..SNIP..]

For completion's sake, we also look at the CBlacklist implementation. It uses the same approach, loading the blacklist.txt file to populate the _notAllowedTypeFullNames property with explicitly disallowed types.

using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;

namespace Veeam.Backup.Common
{
	// Token: 0x0200038C RID: 908
	public class CBlacklist
	{
		// Token: 0x06001695 RID: 5781 RVA: 0x0003C7BC File Offset: 0x0003A9BC
		public CBlacklist()
		{
			this.FillFromEmbeddedResource();
			string text = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "blacklist.txt");
			if (File.Exists(text))
			{
				this.FillFromFile(text);
			}
		}

		// Token: 0x06001696 RID: 5782 RVA: 0x0003C80C File Offset: 0x0003AA0C
		private void FillFromEmbeddedResource()
		{
			Assembly executingAssembly = Assembly.GetExecutingAssembly();
			string text = "Veeam.Backup.Common.Sources.System.IO.BinaryFormatter.blacklist.txt";
			using (Stream manifestResourceStream = executingAssembly.GetManifestResourceStream(text))
			{
				using (StreamReader streamReader = new StreamReader(manifestResourceStream))
				{
					this.FillInternal(streamReader);
				}
			}
		}
		
[..SNIP..]

Whew! That was a lot of code, and a lot of analysis. What we've eventually found is straightforward, though. The two classes do the following things:

  • Load the whitelist.txt file and use it to populate the _allowedTypeFullNames property, and
  • Load the blacklist.txt file and use it to populate the _notAllowedTypeFullNames property.

But how are these properties then used? Well, to find out, we need to go back to the RestrictedSerializationBinder class once again. The code is below - we have omitted the class implementation and included only two important methods here.

The ResolveType type method is called for different class types during the deserialization. Every time this method is called, it will first call the EnsureTypeIsAllowed method, as you can see at line 3.

The EnsureTypeIsAllowed method do exactly what it sounds like - it will use either the whitelist or the blacklist to check if deserialization of a given type is permitted. The property that decides which list to check - the blacklist, or the whitelist - is named _mode, and set by the constructor.

[..SNIP..]

 1:  protected override Type ResolveType([TupleElementNames(new string[] { "assemblyName", "typeName" })] ValueTuple<string, string> key)
 2:  {
 3:  	this.EnsureTypeIsAllowed(key);
 4:  	Type type = base.ResolveType(key);
 5:  	RestrictedSerializationBinder.CheckIsRestrictedType(type);
 6:  	return type;
 7:  }
 8:  
 9:  
10:  private void EnsureTypeIsAllowed([TupleElementNames(new string[] { "assemblyName", "typeName" })] ValueTuple<string, string> key)
11:  {
12:  	if (!this._serializingResponse && SOptions.Instance.ShouldWhitelistingRemoting)
13:  	{
14:  		this.EnsuredBlackWhitelistsAreLoaded();
15:  		string text = key.Item2 + ", " + key.Item1;
16:  		if (this._mode == RestrictedSerializationBinder.Modes.FilterByWhitelist)
17:  		{
18:  			RestrictedSerializationBinder._allowedTypeFullnames.EnsureIsAllowed(text);
19:  			return;
20:  		}
21:  		if (this._mode == RestrictedSerializationBinder.Modes.FilterByBlacklist)
22:  		{
23:  			RestrictedSerializationBinder._notAllowedTypeFullnames.EnsureIsAllowed(text);
24:  		}
25:  	}
26:  }

[..SNIP..]

This sounds like it's all coming together, at this point. We saw earlier that the ObjRef type was added to the blacklist, so all we need to do is to hit the “blacklist” branch (line 21), and supply the deserializer with a serialized ObjRef - then we can get the coveted RCE and it's game over, right?!

Well yes - but this is slightly more complex than it seems.

We saw earlier that when the “Formatter Sink” was being created, the binary formatter instance supplies FilterByWhiteList to the binder constructor. This means that .NET Remoting, as implemented by Veeam, is always using the whitelist - not the blacklist, where our gadget lies!

Does that mean we need to bypass the whitelist? But the whitelist isn’t changed in the latest version, so what is going on here?! It gets waaaay more interesting!

Not So RestrictedSerialization Binder

Once we realized the Veeam .NET Remoting code is using the whitelist instead of the blacklist - where our bypass lies - we thought maybe things aren’t so simple, and maybe there isn’t a direct path to exploitation here, perhaps there are more hoops to jump through fist.

One important detail is that the RestrictedSerializationBinder type is implemented was inside the Veeam.Backup.common.dll assembly, and not the Veeam.Common.Remoting.dll as we'd expect. This implies that Veeam isn’t using the binder solely for their .NET Remoting implementation, but also for other purposes.

A quick search for references to the RestrictedSerializationBinder shows us that the following classes use this binder:

\\Veeam.Backup.Common\\Common\\RestrictedSerializationBinder.cs
\\Veeam.Common.Remoting\\Common\\Remoting\\CBinaryServerFormatterSink.cs
\\Veeam.Common.Remoting\\Common\\Remoting\\CCoreChannel.cs
\\Veeam.Common.Remoting\\Common\\Remoting\\CImpersonationServerSink.cs
\\Veeam.Backup.Common\\Core\\CProxyBinaryFormatter.cs

After analyzing all of these classes, we noticed some interesting methods on the CProxyBinaryFormatter class - namely, that it has some promising method names indicating that it is performing deserialization and serialization.

Some examination reveals that this class has methods to serialize and deserialize data, and it utilizes the same RestrictedSerializationBinder binder that we talked about earlier. With many static methods exposed, it acts as a 'helper' class for the developers to use when they want to take care of de/serialization. It's methods are very interesting to us on our quest to find our CVE.

For example, at line 18, there exists a static method named CreateWithRestrictedBinder that will instantiate the class, specifying the FilterByWhiteList mode as we saw before. However, if we keep searching. there is another more interesting static method - take a look at line 83, where you'll find a method simply named Deserialize .

This method expects one argument of type string , which it will then base64 decode (at line 88) to create an array of bytes. Next, it will create an instance of the BinaryFormatter class, and Finally, it will deserialize the array of bytes at line 93 and return the object at line 100.

Close examination reveals one critical detail, however, the BinaryFormatter is instantiated with the FilterByBlackList argument!

This is great for us! We finally found a method which is using the binder with the blacklist mode - which we’ve seen earlier that we can bypass, by supplying an ObjRef class to be deserialized!

  1:  using System;
  2:  using System.IO;
  3:  using System.Runtime.Serialization.Formatters.Binary;
  4:  using Veeam.Backup.Common;
  5:  
  6:  namespace Veeam.Backup.Core
  7:  {
  8:  	public class CProxyBinaryFormatter
  9:  	{
 10:  		private CProxyBinaryFormatter(RestrictedSerializationBinder binder)
 11:  		{
 12:  			this._formatter = new BinaryFormatter
 13:  			{
 14:  				Binder = binder
 15:  			};
 16:  		}
 17:  
 18:  		public static CProxyBinaryFormatter CreateWithRestrictedBinder()
 19:  		{
 20:  			return new CProxyBinaryFormatter(new RestrictedSerializationBinder(false, RestrictedSerializationBinder.Modes.FilterByWhitelist));
 21:  		}
 22:  
 23:  		public T[] DeserializeCustom<T>(string[] itemsArray)
 24:  		{
 25:  			T[] array = new T[itemsArray.Length];
 26:  			for (int i = 0; i < itemsArray.Length; i++)
 27:  			{
 28:  				array[i] = CProxyBinaryFormatter.Deserialize<T>(itemsArray[i]);
 29:  			}
 30:  			return array;
 31:  		}
 32:  
 33:  		public T DeserializeCustom<T>(string input)
 34:  		{
 35:  			T t;
 36:  			try
 37:  			{
 38:  				t = CProxyBinaryFormatter.BinaryDeserializeObject<T>(Convert.FromBase64String(input), this._formatter);
 39:  			}
 40:  			catch (Exception ex)
 41:  			{
 42:  				Log.Exception(ex, "Binary deserialization failed", Array.Empty<object>());
 43:  				throw;
 44:  			}
 45:  			return t;
 46:  		}
 47:  
 48:  		public static string Serialize(object obj)
 49:  		{
 50:  			string text;
 51:  			try
 52:  			{
 53:  				text = Convert.ToBase64String(CProxyBinaryFormatter.BinarySerializeObject(obj));
 54:  			}
 55:  			catch (Exception ex)
 56:  			{
 57:  				Log.Exception(ex, "Binary serialization failed", Array.Empty<object>());
 58:  				throw;
 59:  			}
 60:  			return text;
 61:  		}
 62:  
 63:  		public static string[] Serialize<T>(T[] itemsArray)
 64:  		{
 65:  			string[] array = new string[itemsArray.Length];
 66:  			for (int i = 0; i < itemsArray.Length; i++)
 67:  			{
 68:  				array[i] = CProxyBinaryFormatter.Serialize(itemsArray[i]);
 69:  			}
 70:  			return array;
 71:  		}
 72:  
 73:  		public static T[] Deserialize<T>(string[] itemsArray)
 74:  		{
 75:  			T[] array = new T[itemsArray.Length];
 76:  			for (int i = 0; i < itemsArray.Length; i++)
 77:  			{
 78:  				array[i] = CProxyBinaryFormatter.Deserialize<T>(itemsArray[i]);
 79:  			}
 80:  			return array;
 81:  		}
 82:  
 83:  		public static T Deserialize<T>(string input)
 84:  		{
 85:  			T t;
 86:  			try
 87:  			{
 88:  				byte[] array = Convert.FromBase64String(input);
 89:  				BinaryFormatter binaryFormatter = new BinaryFormatter
 90:  				{
 91:  					Binder = new RestrictedSerializationBinder(false, RestrictedSerializationBinder.Modes.FilterByBlacklist)
 92:  				};
 93:  				t = CProxyBinaryFormatter.BinaryDeserializeObject<T>(array, binaryFormatter);
 94:  			}
 95:  			catch (Exception ex)
 96:  			{
 97:  				Log.Exception(ex, "Binary deserialization failed", Array.Empty<object>());
 98:  				throw;
 99:  			}
100:  			return t;
101:  		}
102:  
103:  		private static T BinaryDeserializeObject<T>(byte[] serializedType, BinaryFormatter deserializer)
104:  		{
105:  			if (serializedType == null)
106:  			{
107:  				throw new ArgumentNullException("serializedType");
108:  			}
109:  			if (serializedType.Length.Equals(0))
110:  			{
111:  				throw new ArgumentException("serializedType");
112:  			}
113:  			T t;
114:  			using (MemoryStream memoryStream = new MemoryStream(serializedType))
115:  			{
116:  				object obj = deserializer.Deserialize(memoryStream);
117:  				t = ((obj == DBNull.Value) ? default(T) : ((T)((object)obj)));
118:  			}
119:  			return t;
120:  		}
121:  
122:  		private static byte[] BinarySerializeObject(object objectToSerialize)
123:  		{
124:  			byte[] array;
125:  			using (MemoryStream memoryStream = new MemoryStream())
126:  			{
127:  				new BinaryFormatter().Serialize(memoryStream, objectToSerialize ?? DBNull.Value);
128:  				array = memoryStream.ToArray();
129:  			}
130:  			return array;
131:  		}
132:  
133:  		private readonly BinaryFormatter _formatter;
134:  	}
135:  }
136:  

Okay, so we’re almost there. The next question is, how can we reach this method? To answer this question, we need look at all the places that this specific Deserialize method is used. A search reveals 547 classes - a very large number.

This number in itself is interesting. If there are that many places using this method, what if one of those places is a class that exists in the Veeam whitelist? If that’s the case, we can abuse a type allowed by the whitelist, in order to reach a restricted serialization binder that has been configured to use the blacklist mode. This allows us to perform a bridge technique, jumping from a restricted deserialization to an unrestricted deserialization, and eventually end up deserializing our ObjRef gadget for RCE!

We cross-referenced all the classes that are both using the Deserialize method and are also present in the Veeam whitelist. This resulted in only 3 classes - we’re getting close!

CEpContainerSaveInfo
CDbCryptoKeyInfo
CUserRequestSpecificationConfigurationBackup

Let’s first look into the CDbCryptoKeyInfo class. Remember, this class is also whitelisted, meaning that it is reachable via .NET Remoting. It's a huge class, so we've included only the relevant parts below. As you may notice, at line 75 our vulnerable the Deserialize method being called. Since the class is marked as Serializable (line 16), the method is automatically called when the object of this class is being deserialized. Fantastic! This is a custom gadget that we can use!

 1:  using System;
 2:  using System.Collections.Generic;
 3:  using System.Linq;
 4:  using System.Runtime.Serialization;
 5:  using System.Security.Cryptography;
 6:  using System.Xml;
 7:  using Veeam.Backup.Common;
 8:  using Veeam.Backup.Configuration.DataProtection;
 9:  using Veeam.Backup.Core;
10:  using Veeam.Backup.Logging;
11:  using Veeam.Backup.Meta;
12:  using Veeam.Framework.Basic.DateAndTime;
13:  
14:  namespace Veeam.Backup.Model
15:  {
16:  	[Serializable]
17:  	public class CDbCryptoKeyInfo : ISerializable, IConcurentTracking, IEquatable<CDbCryptoKeyInfo>, ILoggable, IMetaRecoveryKeyInfo, IMetaCryptoKey, IMetaEntity, IMetaElement, IMetaVisitable
18:  	{
19:  		private CDbCryptoKeyInfo(Guid id, CKeySetId keySetId, EDbCryptoKeyType keyType, ECryptoAlg cryptoAlg, byte[] encryptedKeyValue, string hint, DateTimeUtc modificationDateUtc, long version, Guid backupId, bool isImported, string tag)
20:  		{
21:  			this.BackupId = backupId;
22:  			this.Id = id;
23:  			this.KeySetId = keySetId;
24:  			this.KeyType = keyType;
25:  			this.EncryptedKeyValue = encryptedKeyValue;
26:  			this.Hint = hint;
27:  			this.ModificationDateUtc = modificationDateUtc;
28:  			this.CryptoAlg = cryptoAlg;
29:  			this.Version = version;
30:  			this.IsImported = isImported;
31:  			this.Tag = tag;
32:  		}
33:  
34:  		[..SNIP..]
35:  
36:  		private CDbCryptoKeyInfo(Guid id, CKeySetId keySetId, EDbCryptoKeyType keyType, ECryptoAlg cryptoAlg, byte[] encryptedKeyValue, string hint, DateTimeUtc modificationDateUtc, CRepairRec[] repairRecs, long version, Guid backupId, bool isImported, string tag)
37:  			: this(id, keySetId, keyType, cryptoAlg, encryptedKeyValue, hint, modificationDateUtc, version, backupId, isImported, tag)
38:  		{
39:  			this._repairRecs.AddRange(repairRecs);
40:  		}
41:  
42:  		public CDbCryptoKeyInfo(byte[] encryptedKeyValue, Guid backupId)
43:  		{
44:  			this.BackupId = backupId;
45:  			this.EncryptedKeyValue = encryptedKeyValue;
46:  		}
47:  
48:  		[..SNIP..]
49:  
50:  		public void GetObjectData(SerializationInfo info, StreamingContext context)
51:  		{
52:  			info.AddValue("Id", this.Id);
53:  			info.AddValue("KeySetId", this.KeySetId.Value);
54:  			info.AddValue("KeyType", (int)this.KeyType);
55:  			info.AddValue("DecryptedKeyValue", Convert.ToBase64String(this.EncryptedKeyValue));
56:  			info.AddValue("Hint", this.Hint);
57:  			info.AddValue("ModificationDateUtc", this.ModificationDateUtc.Value);
58:  			info.AddValue("CryptoAlg", (int)this.CryptoAlg);
59:  			info.AddValue("RepairRecs", CProxyBinaryFormatter.Serialize<CRepairRec>(this._repairRecs.ToArray()));
60:  			info.AddValue("Version", this.Version);
61:  			info.AddValue("BackupId", this.BackupId);
62:  			info.AddValue("IsImported", this.IsImported);
63:  		}
64:  
65:  		protected CDbCryptoKeyInfo(SerializationInfo info, StreamingContext context)
66:  		{
67:  			this.Id = (Guid)info.GetValue("Id", typeof(Guid));
68:  			byte[] array = (byte[])info.GetValue("KeySetId", typeof(byte[]));
69:  			this.KeySetId = new CKeySetId(array);
70:  			this.KeyType = (EDbCryptoKeyType)((int)info.GetValue("KeyType", typeof(int)));
71:  			this.EncryptedKeyValue = Convert.FromBase64String(info.GetString("DecryptedKeyValue"));
72:  			this.Hint = info.GetString("Hint");
73:  			this.ModificationDateUtc = info.GetDateTime("ModificationDateUtc").SpecifyDateTimeUtc();
74:  			this.CryptoAlg = (ECryptoAlg)info.GetInt32("CryptoAlg");
75:  			this._repairRecs = CProxyBinaryFormatter.Deserialize<CRepairRec>((string[])info.GetValue("RepairRecs", typeof(string[]))).ToList<CRepairRec>();
76:  			this.Version = info.GetInt64("Version");
77:  			this.BackupId = (Guid)info.GetValue("BackupId", typeof(Guid));
78:  			this.IsImported = info.GetBoolean("IsImported");
79:  		}
80:  
81:  [..SNIP..]

Putting (Almost) Everything Together

We've looked pretty deep in to some pretty hairy code, so now let's take a step back. What have we achieved thus far?

Well, we've found a serializable class that is whitelisted, and thus reachable through .NET Remoting deserialization.

We've found that for this particular class, during deserialisation, the class will then call the CProxyBinaryFormatter.Deserialize method a second time, but this time with the blacklist mode enabled rather than whitelist.

Finally, we've found that the ObjRef gadget was previously missing from the blacklist - allowing code execution on deserialisation.

Putting it all together, we have created a bridge gadget, meaning a binaryformatter nested inside another binaryformatter . The outer layer will satisfy the .NET Remoting constraints, such as the low type filter and the whitelist, and then once the outer layer is deserialized, the second binary formatted payload will be base64-decoded and deserialized. This is done using the binder with a blacklist, which is exploitable with the ObjRef gadget.

Let’s quickly build a gadget for this! We’ve only included parts relevant to our analysis and deliberately left out other parts required for the gadget's correct operation. This is to prevent wide-scale exploitation by script kiddies (see explanation at the end of the post for details of our reasoning).

[Serializable]
public class CDbCryptoKeyInfoWrapper : ISerializable
{
    private string[] _fakeList;

    public CDbCryptoKeyInfoWrapper(string[] _fakeList)
    {
        this._fakeList = _fakeList;
    }

    public void GetObjectData(SerializationInfo info)
    {
        info.SetType(typeof(CDbCryptoKeyInfo));
        info.AddValue("Id", Guid.NewGuid());
        info.AddValue("KeySetId", null);
        info.AddValue("KeyType", 1);
        info.AddValue("Hint", "aaaaa");
        info.AddValue("DecryptedKeyValue", "AAAA");
        info.AddValue("ModificationDateUtc", new DateTime());
        info.AddValue("CryptoAlg", 1);
        info.AddValue("RepairRecs", _fakeList);
    }
}

Pretty simple once you know how! But there's actually one more hurdle left to overcome.

Trying to deploy our shiny new custom gadget against a target installation is unsuccessful - much to our chagrin. We are unable to connect to the remoting interface at all! Why not?! What gives?! To find out, we must examine yet another body of .NET code.

.NET Remoting Authentication

The reason that our connection fails is that Veeam wisely uses the “secure” flag for their .NET Remoting implementation when registering their channel. The “secure” flag forces authentication to be required for access to the channel to occur at all.

So does this mean the authentication is secure? Let’s take a quick look at Veeam’s implementation for validating user’s identities.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Remoting.Channels;
using System.Runtime.Remoting.Channels.Tcp;
using Veeam.Backup.Common;

namespace Veeam.Common.Remoting
{
	public sealed class CCliTcpChannelRegistration : IDisposable
	{
		public string ChannelName { get; }

		private CCliTcpChannelRegistration(string channelName, CCliTcpChannelOptions options, IClientChannelSinkProvider routerSinkProvider, bool whitelistResponse)
		{
			this.ChannelName = channelName;
			if (ChannelServices.GetChannel(channelName) != null)
			{
				return;
			}
			Log.Message("Registering TCP client channel [" + channelName + "].", Array.Empty<object>());
			CBinaryClientFormatterSinkProvider cbinaryClientFormatterSinkProvider = new CBinaryClientFormatterSinkProvider(whitelistResponse);
			routerSinkProvider.Next = cbinaryClientFormatterSinkProvider;
			Dictionary<string, string> dictionary = new Dictionary<string, string>();
			dictionary["name"] = channelName;
			dictionary["tokenImpersonationLevel"] = "Impersonation";
			dictionary["secure"] = "true";
			dictionary["timeout"] = "900000";
			Dictionary<string, string> dictionary2 = dictionary;
			Log.Message("tokenImpersonationLevel: [" + dictionary2["tokenImpersonationLevel"] + "].", Array.Empty<object>());
			if (options != null)
			{
				options.ApplyChannelProperties(dictionary2);
			}

According to the .NET Remoting documentation, when the “secure” flag is set, one can also implement their own identity check class. This is done by making a class which implements the IAuthorizeRemotingConnection interface. Without this class implemented, an attacker can simply connect as the Anonymous Logon identity.

Taking a look at the Veeam class CConnectionInterceptor, we can see that Veeam have followed the documentation to a tee, implementiong the correct interfaces and checking that the provided identity is not anonymous windowsIdentity.IsAnonymous .

using System;
using System.Linq;
using System.Net;
using System.Runtime.Remoting.Channels;
using System.Runtime.Remoting.Messaging;
using System.Security.Principal;
using Veeam.Backup.Common;
using Veeam.Backup.Logging;

namespace Veeam.Common.Remoting
{
	internal sealed class CConnectionInterceptor : IAuthorizeRemotingConnection
	{
		public CConnectionInterceptor(IAccessCheckProvider accessChecker, Permissions minimalAllowedPermission = Permissions.BasicView)
		{
			this._addreses = Dns.GetHostAddresses(Dns.GetHostName());
			this._accessChecker = accessChecker;
			this._minimalAllowedPermission = minimalAllowedPermission;
			Log.Message("CConnectionInterceptor was initialized with " + accessChecker.GetType().Name, Array.Empty<object>());
		}

		public bool IsConnectingEndPointAuthorized(EndPoint endPoint)
		{
			CallContext.LogicalSetData("ClientEndPoint", endPoint);
			if (!this._accessChecker.IsEntCheckProvider)
			{
				return true;
			}
			if (this.IsLocalhostEndPoint(endPoint))
			{
				return true;
			}
			Log.Error(string.Format("CConnectionInterceptor {0} is not localhost. Access denied.", endPoint), Array.Empty<object>());
			return false;
		}

		public bool IsConnectingIdentityAuthorized(IIdentity identity)
		{
			if (this._accessChecker == null)
			{
				Log.Error("AccessCheckProvider was not set. Access denied.", Array.Empty<object>());
				return false;
			}
			if (!identity.IsAuthenticated)
			{
				Log.Error("CConnectionInterceptor " + ((identity != null) ? identity.Name : null) + " is not authenticated. Access denied.", Array.Empty<object>());
				return false;
			}
			WindowsIdentity windowsIdentity = identity as WindowsIdentity;
			if (windowsIdentity != null && windowsIdentity.IsAnonymous)
			{
				Log.Error("CConnectionInterceptor " + windowsIdentity.Name + " is Anonymous. Access denied.", Array.Empty<object>());
				return false;
			}
			if (windowsIdentity != null && windowsIdentity.IsSystem)
			{
				Log.Message(LogLevels.HighDetailed, "CConnectionInterceptor " + windowsIdentity.Name + " is System. Access granted.", Array.Empty<object>());
				return true;
			}
			if (this._accessChecker.IsVbrCheckProvider && this._accessChecker.VbrAccessChecker.HasAccess(identity, this._minimalAllowedPermission))
			{
				Log.Message(LogLevels.HighDetailed, "CConnectionInterceptor " + identity.Name + " is VBR user. Access granted.", Array.Empty<object>());
				return true;
			}
			if (this._accessChecker.IsEntCheckProvider && this._accessChecker.EnterpriseAccessChecker.HasAccess(EnterprisePermissions.PortalView))
			{
				Log.Message(LogLevels.HighDetailed, "CConnectionInterceptor " + identity.Name + " is VBR user. Access granted.", Array.Empty<object>());
				return true;
			}
			Log.Error("CConnectionInterceptor " + identity.Name + " is not a VBR user. Access denied.", Array.Empty<object>());
			return false;
		}

Everything appears to be solid here, and we were left scratching our heads. How can this be bypassed? We tried everything we could think of, and wasted a good few hours here trying to think of a way to bypass the check. Those with a suspicious demeanor might think this is exactly what Veeam intended us to do.

Veeam Silent Patching

Way back at the beginning of this post, we mentioned that Veeam warned that all versions up to and including 12.1.2.172 are vulnerable to unauthenticated RCE. Here's the table, in case you forget:

You may also remember the detail that our patch diffing (and thus exploitation attempts) were focussed on version 12.1.2.172, the latest vulnerable version. However, in a flash of cynicism, we decided to try our exploit against the same version that Code White exploited - 12.1.0.2131.

Our cold-hearted nature was soon validated, as - to our surprise - the connection to the .NET Remoting object succeeded, our gadget was deserialised, and up popped a shell!

What's going on here? Was the bug silently patched? Let's patch diff between 12.1.0.2131 and 12.1.2.172. What does that IsConnectingIdentityAuthorized method look like?

What?! We almost fell off our chairs at this point. Even though Veeam has indeed made a custom class for authorization checks, it simply returns “true” when asked if an identity is authorized. This has the effect that any connection is allowed, including anonymous, totally-unauthenticated connections!

It seems at this point that the patching situation is more nuanced than it first appears.

What we're seeing is actually the effect of two separate bugs - one deserialisation bug (the ObjRef was omitted from the blacklist) and one improper authorization bug (anonymous connections were permitted by IsConnectingIdentityAuthorized ).

It appears, interestingly, that Veeam patched these two separate bugs in two separate releases (despite Code White notifying them of both at the same time).

In the beginning, there was simply 12.1.0.2131. It contained an unauthenticated remote code execution vulnerability, CVE-2024-40711, comprised of two separate bugs.

Veeam then patched the improper authorization component, and released 12.1.2.172. This had the effect of preventing anonymous exploitation, downgrading CVE-2024-40711 to an authenticated-only vulnerability.

Then, three months later, they patched the deserialisation bug, creating 12.2.0.334. This fixes CVE-2024-40711 completely, preventing exploitation (spoiler: actually it doesn't, but that's a subject for a further blog post, since details are still under embargo).

Why Veeam chose to patch twice is anyone's guess - perhaps the modification of the two different components required different QA processes, and Veeam pushed a release as early as possible, when only one fix was ready for production, in order to protect their customers as soon as possible.

Perhaps, though - get ready for something of a conspiracy theory - perhaps Veeam released two different fixes bugs in an attempt to downplay the vulnerability.

Perhaps Veeam first fixed the improper authorization component 'silently', without releasing any security advisory. This had the effect of downgrading the 'real' bug, the deserialisation, to a vulnerability which requires authentication, meaning that when they patched it at a later date, they could announce a big with only a CVSS 9.8 vulnerability, and not a 10.0 - as you'll recall, their advisory does exactly this.

This theory should be taken with a pinch of salt, though, as Veeam's advisory also specifies that CVE-2024-40711 requires no authentication, and as such is an unauthenticated RCE. Perhaps the CVSS score of 9.8, as opposed to 10.0, is simply an accident created by an eagerness to fix and an alignment of release schedules (or perhaps just a good example of Hanlon's Razor).

The ultimate result of this staggered, two-part patch is a CVE-2024-40711 which is somewhat fixed in one release. Those running 12.1.2.172 are exposed to an authenticated RCE, while those on 12.1.1.56 and below are vulnerable to the entire unauthenticated bug chain.

Here’s an annotated version of the release vulnerability status table from the Veeam advisory:

Conclusions And Wrap-Up

Well, that was a complex vulnerability, requiring a lot of code-reading! We’ve successfully shown how multiple bugs can be chained together to gain RCE in a variety of versions of Veeam Backup & Replication.

We’re a little confused by Veeam’s advisory, however, which seems to be contradictory. As you may recall from the very start of the blogpost, Veeam’s advice was that versions up to and including 12.1.2.172 are vulnerable. While the title of the bug states that “A vulnerability allowing unauthenticated remote code execution (RCE)“, suggesting a world-ending CVSS 10 bug, they then proceed to label the bug as a less-serious CVSS 9.8, requiring user authentication before exploitation is possible. This is confusing, because all versions beneath 12.1.2.172 don’t require authentication to exploit, and only a change made in 12.1.2.172 made it so authentication was required (see above analysis).

Perhaps Veeam simply made an error in their advisory, as we (and Code White) clearly demonstrate that authentication is not required. Hopefully, a pre-emptive change wasn’t made in 12.1.2.172 to downgrade the eventual severity of this vulnerability.

Regardless of CVSS, the actual situation, as you can see above, is somewhat more nuanced than ‘RCE before 12.1.2.172':

Version Status
12.2.0.334 Fully patched. Not affected by the vulnerabilities in this blogpost.
12.1.2.172 Affected, but exploitation requires authentication. Low privilege users are able to execute arbitrary code.
12.1.1.56 and earlier Vulnerable to unauthenticated RCE.

Speaking of exploitation, we’re breaking with tradition on this bug by not releasing a full exploit chain (sorry, folks!). We’re a little worried by just how valuable this bug is to malware operators, and so are (on this occasion only) refraining from dropping a working exploit. The most we’re going to drop is this tantalizing video of exploitation, which will have to tide you over until our next post:

0:00
/0:15

We'd also like to take the opportunity to thank Soroush Dalili for his help with this exploit.

At watchTowr, we believe continuous security testing is the future, enabling the rapid identification of holistic high-impact vulnerabilities that affect your organisation.

It's our job to understand how emerging threats, vulnerabilities, and TTPs affect your organisation.

If you'd like to learn more about the watchTowr Platform, our Attack Surface Management and Continuous Automated Red Teaming solution, please get in touch.