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)](https://images.seebug.org/1726714820317-w331s)
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](https://twitter.com/frycos?ref=labs.watchtowr.com) with [Code White Gmbh](https://code-white.com/?ref=labs.watchtowr.com), who state on their website that the bug is an unauthenticated RCE. Veeam themselves have released a fix, and their [advisory](https://www.veeam.com/kb4649?ref=labs.watchtowr.com) 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:
![](https://images.seebug.org/1726714822751-w331s)
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.
![](https://images.seebug.org/1726714830046-w331s)
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:
![](https://images.seebug.org/1726714832344-w331s)
Veeam also published [a table](https://www.veeam.com/kb2680?ref=labs.watchtowr.com) containing all affected Veeam Backup & Replication releases and their respective build numbers.
![](https://images.seebug.org/1726714835209-w331s)
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!
![](https://images.seebug.org/1726714837778-w331s)
__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?
![](https://images.seebug.org/1726714844389-w331s)
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!
![](https://images.seebug.org/1726714847962-w331s)
Here is a close look at this change:
![](https://images.seebug.org/1726714850642-w331s)
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](https://code-white.com/blog/leaking-objrefs-to-exploit-http-dotnet-remoting/?ref=labs.watchtowr.com) that was created by [Markus Wulftange](https://twitter.com/mwulftange?ref=labs.watchtowr.com), and indeed, is one of his 'signature' attacks. Given that the bug itself was discovered by [Florian Hauser](https://x.com/frycos?ref=labs.watchtowr.com), 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](https://x.com/frycos?ref=labs.watchtowr.com) 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.
![](https://images.seebug.org/1726714853809-w331s)
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”](https://www.tiraniddo.dev/2014/11/stupid-is-as-stupid-does-when-it-comes.html?ref=labs.watchtowr.com) 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.
![](https://images.seebug.org/1726714859213-w331s)
Veeam has done just this, implementing their custom .NET Remoting server in the `Veeam.Common.Remoting.CBinaryServerFormatterSink` class:
![](https://images.seebug.org/1726714866098-w331s)
As you can see this class implements the interface `IServerChannelSink` :
![](https://images.seebug.org/1726714879371-w331s)
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”.
![](https://images.seebug.org/1726714882018-w331s)
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` .
![](https://images.seebug.org/1726714884511-w331s)
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` .
![](https://images.seebug.org/1726714887238-w331s)
Let's also take a look at the important methods in the binder class, `RestrictedSerializationBinder`.
![](https://images.seebug.org/1726714889953-w331s)
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.
![](https://images.seebug.org/1726714894315-w331s)
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.
![](https://images.seebug.org/1726714901223-w331s)
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.
![](https://images.seebug.org/1726714910911-w331s)
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:
![](https://images.seebug.org/1726714918231-w331s)
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?
![](https://images.seebug.org/1726714925088-w331s)
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](https://en.wikipedia.org/wiki/Hanlon%27s_razor?ref=labs.watchtowr.com)).
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:
![](https://images.seebug.org/1726714927245-w331s)
### 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
1×
We'd also like to take the opportunity to thank [Soroush Dalili](https://x.com/irsdl?ref=labs.watchtowr.com) for his help with this exploit.
At [watchTowr](https://www.watchtowr.com/?ref=labs.watchtowr.com), 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**](https://www.watchtowr.com/?ref=labs.watchtowr.com)**, our Attack Surface Management and Continuous Automated Red Teaming solution**, please get in touch.
Unavailable Comments