YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
DL4J coefficients.bin short data buffer decision flip PoC
Summary
DL4J / ND4J ModelSerializer loads model archives containing configuration.json and coefficients.bin. The coefficients.bin file contains an ND4J shapeInfo buffer (declaring element count and shape) followed by a data buffer (containing the actual parameter values).
A crafted model archive keeps configuration.json and the shapeInfo buffer byte-identical to the baseline, but shortens the data buffer from 13 values to 6. The ND4J deserialization path (Nd4j.read -> createArrayFromShapeBuffer) does not validate that the data buffer length matches the shapeInfo-declared element count. MultiLayerNetwork.init() checks parameters.length() against the configuration-expected parameter count, but length() returns the shape-derived value (13), not the actual data buffer size (6).
As a result, restore succeeds, init passes, and inference completes without any validation warning or exception โ producing deterministically different output.
- Baseline output:
0.9975(decision: ALLOW) - Mutated output:
0.5000(decision: DENY) - Decision flip: ALLOW -> DENY (diff = 0.4975)
- Repeatability: 3/3 consistent
Target
- Project: Deeplearning4j (DL4J) โ Eclipse Foundation
- Version tested:
1.0.0-M2.1 - Runtime: ND4J native CpuBackend (OpenBLAS)
- Format: DL4J ModelSerializer
.ziparchive (configuration.json+coefficients.bin)
PoC files
| File | Purpose |
|---|---|
baseline_decision_model.zip |
Baseline model (Dense 2->3->1, hand-set weights, output=0.9975) |
mut_short_data_decision_flip.zip |
Mutated model (data buffer shortened 13->6, output=0.5000) |
build_decision_flip_model.py |
Documents how the baseline model was constructed |
generate_short_data_buffer_model.py |
Generates the mutated model from the baseline |
test_runtime.py |
3-run repeatability test (restore + inference + decision comparison) |
inspect_coefficients.py |
Binary inspection tool comparing coefficients.bin fields |
results.json |
Machine-readable test results |
SHA256SUMS.txt |
SHA256 hashes for model files |
Vulnerability mechanics
Baseline model
The baseline model is a simple Dense(2->3, ReLU) + Output(3->1, Sigmoid, MSE) network with hand-set weights (all 1.0 for weight matrices, all 0.0 for biases), producing 13 total parameters.
| Property | Value |
|---|---|
| configuration.json expected params | 13 |
| shapeInfo product | 13 |
| data buffer length | 13 |
model.params().length() |
13 |
Output for ones(1,2) |
0.9975 (ALLOW) |
Mutated model
The mutated model has byte-identical configuration.json and shapeInfo. Only the data buffer is shortened from 13 to 6 values (containing only the dense layer weights).
| Property | Value |
|---|---|
| configuration.json expected params | 13 (unchanged) |
| shapeInfo product | 13 (unchanged) |
| data buffer length | 6 (shortened) |
model.params().length() |
13 (shape-derived, hides the mismatch) |
Output for ones(1,2) |
0.5000 (DENY) |
Why this happens
ModelSerializer.restoreMultiLayerNetwork()readscoefficients.binviaNd4j.read(), which callscreateArrayFromShapeBuffer(data, shapeInfo).createArrayFromShapeBufferdoes not validate thatdata.length() >= product(shape). It creates an INDArray with the shape-declared dimensions regardless of actual data buffer size.MultiLayerNetwork.init()checksparameters.length()against the configuration-expected count (13). Sincelength()is shape-derived, it returns 13 even though only 6 values exist in the data buffer.- During
model.output(), the BLAS/native inference path accesses the underlying off-heap memory via native pointer arithmetic, without going through JavaCPPIndexer.checkIndex. The native path reads whatever is at the memory address beyond the data buffer (typically zeros in freshly allocated off-heap memory). - With zeros in the output layer weights and bias, the pre-sigmoid activation becomes 0, producing
sigmoid(0) = 0.5.
Note: Accessing parameters via model.params().getDouble(i) for indices 6-12 raises IndexOutOfBoundsException (JavaCPP Indexer.checkIndex), but this bounds check is not triggered during the BLAS/native inference path.
Reproduction
Prerequisites
- Java 11+
- Python 3.8+
pyjnius(pip install pyjnius)- DL4J / ND4J 1.0.0-M2.1 JARs (from Maven Central)
Required JARs include: deeplearning4j-core, deeplearning4j-nn, nd4j-api, nd4j-common, nd4j-native, nd4j-native-api, nd4j-native-preset, platform-specific nd4j-native-<platform>.jar, javacpp, openblas, commons-io, commons-lang3, commons-math3, shaded guava/jackson/protobuf under org.nd4j, flatbuffers, and slf4j-api.
Setup classpath
Before running test_runtime.py, configure the classpath in the script header:
import os, jnius_config
jar_dir = '<JAR_DIR>' # path to directory containing DL4J/ND4J JARs
jars = [os.path.join(jar_dir, f) for f in os.listdir(jar_dir) if f.endswith('.jar')]
jnius_config.set_classpath(*jars)
Run tests
# Inspect coefficients.bin fields (pure Python, no JVM needed)
python3 inspect_coefficients.py baseline_decision_model.zip mut_short_data_decision_flip.zip
# Run decision-flip runtime test (requires JVM + DL4J JARs)
python3 test_runtime.py .
Generate mutated model from baseline
python3 generate_short_data_buffer_model.py baseline_decision_model.zip ./output
Expected result
BASELINE: restore=ACCEPTED inference=ACCEPTED output=0.997527 decision=ALLOW params=13
MUTATED: restore=ACCEPTED inference=ACCEPTED output=0.500000 decision=DENY params=13 oob=7
DECISION FLIP CONFIRMED: ALLOW -> DENY (3/3 consistent)
Key observations:
- Baseline restore: ACCEPTED (no warning)
- Mutated restore: ACCEPTED (no warning)
- Baseline output: 0.997527 (decision: ALLOW)
- Mutated output: 0.500000 (decision: DENY)
configuration.jsonSHA256: identical between baseline and mutated- shapeInfo SHA256: identical between baseline and mutated
- Data buffer length: 13 (baseline) vs 6 (mutated)
model.params().length(): reports 13 for both (shape-derived)- No validation warning or exception during restore or inference
Invariant comparison
| Field | Baseline | Mutated | Same? |
|---|---|---|---|
| configuration.json SHA256 | a7dbe3f5...f5fc9e6c | a7dbe3f5...f5fc9e6c | YES |
| shapeInfo SHA256 | baf06adb...2b9cd418 | baf06adb...2b9cd418 | YES |
| shape product | 13 | 13 | YES |
| data buffer length | 13 | 6 | NO |
| data dtype | FLOAT | FLOAT | YES |
| config expected param count | 13 | 13 | YES |
| model.params().length() | 13 | 13 | YES (shape-derived) |
| shape==data invariant | PASS | FAIL | NO |
Non-claims
- This PoC does not demonstrate code execution.
- This PoC does not demonstrate memory disclosure.
- This PoC does not claim ASAN-confirmed memory corruption.
- This PoC does not claim a scanner bypass.
- This PoC is limited to the tested DL4J/ND4J CpuBackend runtime path.
SHA256
a3c9aa028fa97b9e44d735fcd5915326c66a4ca27767ea62ce6c08fdb7a0f140 baseline_decision_model.zip
40ef3de7d37eaac241e4bece8e6524616cf3cc18c1a24d73e6affc1fefdf3e81 mut_short_data_decision_flip.zip