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
- 01Patch clue
processUnwrappedCreatorProperties()The creator path checks
prop.visibleInView(activeView)before binding. - 02Sibling gap
UnwrappedPropertyHandler::processUnwrapped()The field/setter path loops over unwrapped properties and calls
deserializeAndSet()without that check. - 03Buffering path
BeanDeserializer::deserializeWithUnwrapped()Flattened fields are buffered because they do not directly match the container property, bypassing the normal direct-property view gate.
- 04Write sink
SettableBeanProperty::deserializeAndSet()The restricted container is instantiated and populated under the lower-privilege active view.
- 01Restricted reader
An application deserializes untrusted JSON with a less-privileged active
@JsonView. - 02Flattened input
Input targets a privileged container represented through
@JsonUnwrapped. - 03Buffered replay
The unwrapped handler replays the values through the regular property loop.
- 04Missing 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
- Create a Maven project with the vulnerable
jackson-databindversion and save the Java class below. - The
flagscontainer is restricted toAdminViewbut flattened with@JsonUnwrapped. The server-side reader intentionally usesPublicView. - Run the program. On a vulnerable release, the synthetic
rolevalue is bound and the container is non-null. - Run the included
OrdinaryAccountcontrol with nested JSON targeting the same restricted container. It remains null; this proves the normal property path enforces the active view. - 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=falseExpected evidence
- On the affected release,
flags_present=trueandrole=ADMINunderPublicView. - The
OrdinaryAccountcontrol receives correctly nested input for the sameflagsproperty and remains null underPublicView. - Serialization with
writerWithView(PublicView.class)does not disclose the restricted block; this is a write-side bug only.
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.
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
- Vendor advisory GHSA-5gvw-p9qm-jgwh
- Creator-path fix that prompted the sibling review
- Pinned vulnerable 2.x revision
- Fix pull request 6056
- Fix commit d627a8a
Disclosure boundary: only already-public technical detail is included here. Unpublished validation material, private communications, secrets, and weaponized payloads remain excluded.