DL4J/ND4J SameDiffSerializer.loadInternal() unsafe Java deserialization (CWE-502) β RCE via .sdnb model manifest
Gated PoC β access granted to maintainers/triagers on request.
Target: github.com/eclipse/deeplearning4j, module nd4j-api, package
org.nd4j.autodiff.samediff.serde, file SameDiffSerializer.java,
private method loadInternal(File, boolean, SameDiff), called from the
public API SameDiffSerializer.load(File, boolean).
Summary
SameDiffSerializer.loadInternal() parses a .sdnb ("SameDiff Native
Blob") model file's header (magic bytes, version, and three offset/length
fields), performs real bounds/consistency validation on those offsets, then
reads the byte range it computed as the "manifest" and feeds those bytes
directly into ObjectInputStream.readObject() with no type filter, no
allow-list, and no ObjectInputFilter:
try (ByteArrayInputStream bais = new ByteArrayInputStream(
manifestNio.array(), manifestNio.position(), manifestNio.remaining());
ObjectInputStream ois = new ObjectInputStream(bais)) {
manifest = (Map<String, Pair<Long, Long>>) ois.readObject(); // <-- CWE-502
} catch (Exception e) {
throw new IOException("Failed to deserialize manifest from file: " + file.getAbsolutePath(), e);
}
readObject() executes before the checked cast to
Map<String, Pair<Long, Long>> is evaluated. Standard Java deserialization
semantics mean any class present on the victim application's classpath can
run arbitrary code during readObject()/readResolve()/finalize() etc.
(the classic "gadget chain" primitive, e.g. via ysoserial-style chains
using commons-collections, already a declared DL4J/nd4j transitive
dependency) β the eventual ClassCastException (because the attacker's
class isn't really a Map) happens after the malicious side effect has
already fired, so it does not prevent exploitation.
This is public API: any application that calls the documented
SameDiffSerializer.load(File, boolean) (or SameDiff.load(...), which
delegates to it) to load a .sdnb SameDiff model is directly exposed.
Not a duplicate
This is a separate, independent vulnerability from the two other already-known/already-filed DL4J deserialization issues:
- Not
CVE-2025-53001/GHSA-wfhj-v5g7-vr7gβ that advisory coversModelSerializer.loadZipData()deserializing thepreprocessor.binZIP entry (packageorg.deeplearning4j.util, moduledeeplearning4j-nn). Different file, different class, different module, different model format (MultiLayerNetwork/ComputationGraph.zipfiles vs. SameDiff.sdnbfiles). - Not the WordVectorSerializer finding β that one is
WordVectorSerializer.readWordVectors(File)inorg.deeplearning4j.models.embeddings.loader(moduledeeplearning4j-nlp), which deserializes the entire file with no header/format wrapper at all. Different file, different class, different module, different model type (word-vector models vs. SameDiff models).
SameDiffSerializer lives in nd4j-api (the ND4J math/autodiff library),
not deeplearning4j-nn or deeplearning4j-nlp β a third, independent
module with its own independent unsafe-deserialization bug.
Important scope note: SameDiffSerializer.java does not exist in the
latest released Maven Central artifact (org.nd4j:nd4j-api:1.0.0-M2.1,
2022-08-10) β it is present on the current master branch only (added
after that release, no newer release has been published since). This PoC
compiles and runs the real, unmodified master-branch source of the
vulnerable method directly (see "Methodology" below) since there is no
released jar to depend on. The bug is real and live in the current
upstream source that anyone building from master (or a future release
once cut) would ship.
Attacker input β sink
- Attacker crafts a
.sdnbfile with a valid 32-byte header (magic"SDNB", version,manifestOffset,manifestLength,metadataOffset) that passesloadInternal()'s own bounds checks (metadataOffset == HEADER_SIZE,manifestOffset >= metadataOffset,manifestOffset + manifestLength <= fileSize), followed by a Java serialization stream containing an attacker-controlled class instead of the expectedMap<String, Pair<Long, Long>>. - Victim application calls the public, documented
SameDiffSerializer.load(someUntrustedFile, false)(orSameDiff.load(...)) to load the "model". loadInternal()validates the header (all real checks pass β this is not a parsing bug, the format is well-formed), reads the manifest byte range, and callsObjectInputStream.readObject()on it directly.- The attacker's class is instantiated and its
readObject()method runs β arbitrary code execution, before any type check on the result.
Real, reproducible evidence (not just static reasoning)
Methodology
SameDiffSerializer.class is not present in any published nd4j-api jar
on Maven Central (confirmed: jar tf nd4j-api-1.0.0-M2.1.jar | grep -i samediffserializer finds nothing). To test the real, current, unmodified
source rather than a hand-written reproduction of the pattern, this PoC:
- Fetches
SameDiffSerializer.javaverbatim fromhttps://raw.githubusercontent.com/eclipse/deeplearning4j/master/nd4j/nd4j-backends/nd4j-api-parent/nd4j-api/src/main/java/org/nd4j/autodiff/samediff/serde/SameDiffSerializer.java(fetched 2026-07-09; included here asSameDiffSerializer_real_master_stubbed_for_standalone_build.java). - Compiles it against the real, unmodified, released
org.nd4j:nd4j-api:1.0.0-M2.1jar from Maven Central (which supplies every other class this file needs βSameDiff,Pair,FlatGraph, etc. β sincend4j-api's own package evolved additively around this file). - Seven unrelated methods (
loadSharded,deserializeFromFlatBuffers,createSubInstancesVector,serializeMetadataFlatBuffer,serializeSmallNdArrayToFlatBuffer,deserializeSmallNdArrayFromInlineBuffer,createGraphShard) fail to compile against the older 1.0.0-M2.1 jar because they call newer FlatBuffers-schema/SameDiffAPI methods added to other files after that release (e.g.SameDiff.variableNames(),FlatGraph.metadataKeysLength()). None of these methods are on the vulnerable code path βloadInternal()'s header-parsing andObjectInputStream.readObject()call (the actual vulnerability) is reached and fully executed before any of them would ever be called. Their bodies were replaced with a singlethrow new UnsupportedOperationException("STUBBED_FOR_POC_BUILD: ...")purely so the file compiles standalone;loadInternal()itself β including the vulnerablereadObject()call and all of its header validation β is byte-for-byte unmodified from the fetched upstream source. Diff the included file against upstreammasteryourself to confirm; every stub is clearly marked with aSTUBBED_FOR_POC_BUILDcomment/message and is trivially greppable. SameDiffPoc.javacrafts a malicious.sdnbfile whose manifest serializesEvilGadget, a class with areadObject()override that runsidviaProcessBuilderand captures the output to a marker file β standing in for a realysoserial/commons-collectionsgadget chain, exactly the same "provereadObject()fires unconditionally, using a controlled non-weaponized payload" methodology used for the other DL4J deserialization findings in this account.- Calls the real
SameDiffSerializer.load(file, false)and observes the payload's command execution.
Files:
SameDiffPoc.javaβ malicious harness (crafts the file, calls the realload()API).SameDiffControl.javaβ negative control (crafts a.sdnbfile with a legitimate, emptyHashMapmanifest β the actual expected type).SameDiffSerializer_real_master_stubbed_for_standalone_build.javaβ the real upstreammastersource, unmodified except for the 7 unrelated stubbed methods described above (place atorg/nd4j/autodiff/samediff/serde/SameDiffSerializer.javato rebuild).pom.xmlβ Maven project pulling the real, unmodified, releasedorg.nd4j:nd4j-api:1.0.0-M2.1from Maven Central.samediff_malicious_model.sdnbβ the crafted malicious file (79 bytes).run_output_malicious.txtβ captured console output of the real run against the malicious file.rce_evidence_id_output.txtβ the marker file written by the payload'sreadObject(), containing the real output ofid.run_output_control.txtβ captured console output of the negative control run.
Malicious file β real code execution through the real load() API
$ java -cp <deps> poc.SameDiffPoc samediff_malicious_model.sdnb
[*] Crafted malicious .sdnb file: .../samediff_rce_poc.sdnb (79 bytes)
[*] Calling the REAL, unmodified org.nd4j.autodiff.samediff.serde.SameDiffSerializer.load(File, boolean)
-- the actual public API an application uses to load a SameDiff model.
[*] load() threw (expected -- EvilGadget isn't actually the expected
Map<String,Pair<Long,Long>> type, so the checked cast fails AFTER
readObject() already ran and already executed the payload below): java.io.IOException: Failed to deserialize manifest from file: .../samediff_rce_poc.sdnb
Marker file written by the attacker class's readObject() during that
load() call (i.e. before the wrapping IOException was ever thrown):
$ cat rce_evidence_id_output.txt
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),101(netdev),102(scanner),118(wireshark),119(kaboxer),982(bluetooth),999(lpadmin)
RCE_VIA_SAMEDIFF_SERIALIZER_LOADINTERNAL_READOBJECT
This is the real output of the id command, spawned via ProcessBuilder
from inside EvilGadget.readObject(), run by the unmodified real
SameDiffSerializer.loadInternal() while processing the crafted file. The
subsequent IOException/ClassCastException is irrelevant to
exploitability β by the time it's thrown, the payload has already run.
Negative control (proves it's the malicious class, not environment/setup)
$ java -cp <deps> poc.SameDiffControl samediff_control.sdnb
[*] Crafted CONTROL .sdnb file (legit empty HashMap manifest): .../samediff_control.sdnb
[*] Calling the same real SameDiffSerializer.load(File, boolean)...
[*] load() threw: java.io.IOException: Cannot create new SameDiff instance: metadata is empty in file .../samediff_control.sdnb
With a legitimate empty HashMap manifest (the real expected type),
readObject() succeeds cleanly with no side effects, no marker file is
created, and execution proceeds into the real, unstubbed subsequent
validation logic in loadInternal() (a genuine, unrelated "metadata is
empty" check further down in the method) β proving the malicious run's
outcome is specifically caused by the attacker-controlled class in the
serialization stream, not by the harness, the header format, or the
stubbed-method scaffolding.
Impact
- Remote Code Execution: demonstrated here with a benign
id-spawning payload class standing in for a weaponized gadget chain, exactly as is standard practice for CWE-502 PoCs (a real attacker substitutes acommons-collections/ysoserial-style gadget already on the target's classpath). Any application that loads an untrusted.sdnbSameDiff model file β a documented, intended DL4J/ND4J feature β is directly exposed.
Fix suggestion
- Never call
ObjectInputStream.readObject()on data derived from an untrusted file without anObjectInputFilterallow-listing only the expected classes (java.util.HashMap,org.nd4j.common.primitives.Pair,java.lang.Long, etc.) β seeObjectInputFilter.Config/Validator-based filtering available since Java 9 (backport available for Java 8). - Alternatively, replace the ad hoc Java-serialized manifest with a
length-prefixed, schema-defined format (the file already uses FlatBuffers
elsewhere in this exact class β the manifest could trivially be encoded
the same way instead of via
ObjectOutputStream/ObjectInputStream).
Dedup / prior-art check performed
- Confirmed via GitHub Security Advisories that
eclipse/deeplearning4jhas exactly two published advisories:GHSA-rc39-g977-687w(unrelated S3 bucket issue) andGHSA-wfhj-v5g7-vr7g/CVE-2025-53001(ModelSerializer.loadZipData()/preprocessor.binβ confirmed by fetching the advisory directly β a different file/class/module fromSameDiffSerializer). - Confirmed
SameDiffSerializer.classis absent from every jar in the locally cached~/.m2repository and from the publishedorg.nd4j:nd4j-api:1.0.0-M2.1artifact β this class/bug is not covered by any existing released-artifact-based advisory or PoC. - Compared byte-for-byte against the sibling draft repos
EnigmaConsultant/deeplearning4j-deserialize-rce(confirmed to be the already-publicCVE-2025-53001/ModelSerializer.javabug β not filed as new here) andEnigmaConsultant/deeplearning4j-wordvector-deserialize-rce(confirmed byte-for-byte identical to the separately already-written-upEnigmaConsultant/dl4j-wordvector-deser-rceβ not filed as new here either). Neither overlaps with this finding's class/file/module.