Abstract database identity lattice showing a JSON Patch path moving an identity token from one protected record lane to another.

The ID Was in the Body: CVE-2026-47849 and JSON Patch

PATCH /accounts/41 returned 204 No Content. The request was authenticated, the account in the URI belonged to the caller, and the audit record looked ordinary. One detail did not: the JSON Patch document contained {"op":"replace","path":"/id","value":73}.

That is the useful starting point for CVE-2026-47849. The bug is not a missing login check and not a conventional IDOR in the route. It is a disagreement about which identifier is authoritative. Spring Data REST first resolves object 41 from the URI, then the vulnerable JSON Patch path permits the request body to rewrite the entity’s @Id or @Version property before the repository saves it. Under the conditions documented by Spring, the result can be a horizontal overwrite of another principal’s record or a bypass of optimistic locking.[1]

The property gate that missed identity

JSON Patch is a sequence of operations against JSON Pointer paths. A client can add, remove, replace, move, copy, or test a value, and order matters.[3] In an API, that makes the patch-path verifier an authorization boundary. It must answer a narrower question than “does this field deserialize?”: may this caller mutate this property on this object through this operation?

Spring’s advisory identifies affected applications precisely. They expose a Spring Data REST repository, allow PATCH, and use an entity whose identifier or version property is visible to Jackson, typically through a public setter and without @JsonIgnore. A client already needs PATCH access. The flaw then composes field-level authorization failure with object-level authorization failure, the same seam described by OWASP API3 and API1.[5][4]

The operator’s first hypothesis should therefore be mass assignment, but the route changes the stakes. Ordinary over-posting modifies a sensitive attribute on the selected entity. Here, mutating id changes which entity persistence may target. Mutating version can suppress the stale-write signal that should stop one actor from replacing another actor’s newer state.

A safe mechanism lab against both releases

I validated the property gate locally against the upstream Spring Data REST source. The lab does not start a network listener, touch a database, or attempt a cross-tenant overwrite. It instantiates the same JsonPointerMapping used to authorize patch paths, registers an entity with @Id, @Version, and a regular property, then asks whether each path is writable.

boolean blocked(JsonPointerMapping mapping, String path) {
    try {
        mapping.forWrite(path, AccountRecord.class);
        return false;
    } catch (PatchException expected) {
        return true;
    }
}

blocked(mapping, "/id");          // vulnerable 5.0.6: false
blocked(mapping, "/version");     // vulnerable 5.0.6: false
blocked(mapping, "/displayName"); // expected writable field: false

The same JUnit test passed on both sides of the patch with inverted security expectations:

Spring Data REST 5.0.6
LAB_RESULT idBlocked=false versionBlocked=false regularPropertyBlocked=false
Tests run: 1, Failures: 0, Errors: 0

Spring Data REST 5.0.7
LAB_RESULT idBlocked=true versionBlocked=true regularPropertyBlocked=false
Tests run: 1, Failures: 0, Errors: 0

This proves the vulnerable primitive and the fixed guard. It does not claim that every repository implementation will silently overwrite another row. Persistence behavior depends on the store, entity lifecycle, identifier strategy, and transaction state. That boundary matters: a mechanism-level reproduction is evidence, not permission to generalize a full impact path across every Spring Data backend.

What changed in 5.0.7

The 5.0.6 to 5.0.7 diff adds the missing invariant at the mapping layer. MappedJacksonProperties.isWritableField() now rejects a persistent property when isIdProperty() or isVersionProperty() is true. New tests require forWrite("/id", ...) and forWrite("/version", ...) to throw PatchException, while reads and writes to ordinary properties continue to work.[2]

The patch also hardens PUT. Before deserializing a replacement body, DomainObjectReader copies the persisted identifier and version into the incoming object node. This closes a sibling path for mutable and immutable domain types. The security invariant is now explicit: request data may alter business state, but it cannot redefine persistence identity or concurrency state.

Five-stage CVE-2026-47849 flow from an authenticated client and JSON Patch document through property binding and identity mutation to a cross-record database overwrite, with fixed identifier and version guards.
The URI selects one object, but the vulnerable patch path can rewrite the identity used at persistence. The fixed guard rejects identifier and version writes.

Attack chain: from owned object to foreign row

A realistic chain begins with legitimate low-privilege API access. The attacker enumerates media types and methods, then notices application/json-patch+json on an entity endpoint. A normal patch to /displayName establishes that the route accepts operations. The decisive test is not a destructive overwrite. In an authorized lab, send a test operation against /id, or replace the identifier on a disposable record and observe whether the API rejects the path before persistence.

curl -i -X PATCH 'http://127.0.0.1:8080/accounts/41' \
  -H 'Authorization: Bearer LAB_TOKEN' \
  -H 'Content-Type: application/json-patch+json' \
  --data '[
    {"op":"replace","path":"/id","value":73},
    {"op":"replace","path":"/version","value":0},
    {"op":"replace","path":"/displayName","value":"lab-canary"}
  ]'

On a vulnerable binding path, all three properties pass the write verifier. If the repository subsequently treats 73 as the save target, the attacker has crossed from an object they were authorized to patch into a record the route-level check never evaluated. If only version changes, the attacker may defeat a lost-update defense and force stale state over a newer write. The HTTP status can still look clean because the abnormal transition occurs after the obvious authorization decision.

This is why API testing should model identity as a trust boundary, not merely a URL parameter. The same principle appears in attack-chain threat modeling: authorization must remain valid across every transformation, not just at ingress. For hands-on request mutation, the workflow in Burp Suite extensions for API security is useful, but add semantic checks for identifier and concurrency fields instead of relying only on response codes.

Detection: log the path, then correlate the state change

Generic WAF signatures are weak here. The payload is valid JSON Patch, the caller is authenticated, and /id is not inherently malicious outside the domain model. A WAF can reduce exposure by denying JSON Patch where it is not required, but the durable signal belongs closer to binding and persistence. This is the same limit discussed in a defensive WAF validation: protocol acceptance is not application authorization.

  • Record the patch operation, JSON Pointer path, route identifier, authenticated principal, and request correlation ID. Redact values where fields may contain secrets.
  • Alert when PATCH or PUT bodies name persistence-controlled properties such as id, version, tenant keys, owner IDs, creation timestamps, or authorization roles.
  • At the repository boundary, compare the identifier loaded from the route with the identifier immediately before save. A mismatch should fail closed and produce a high-signal security event.
  • Correlate optimistic-lock exceptions, unexpected version regressions, and updates where the actor’s tenant differs from the final row owner.
  • Hunt historical access logs for application/json-patch+json requests containing paths that terminate in /id or /version. Absence of request-body logging limits retrospective certainty.

Hardening beyond the upgrade

Upgrade to the fixed release line listed by Spring. For the current OSS 5.0 branch, that is 5.0.7; Spring also lists fixed enterprise builds for older maintained lines.[1] Then make the invariant independent of framework behavior.

  • Do not expose persistence entities as write DTOs. Define explicit patchable properties and map them server-side.
  • Mark identifiers, versions, tenant ownership, and audit fields read-only in the serialization model.
  • Authorize the final resource identity immediately before mutation, especially when request processing can transform keys.
  • Use repository methods scoped by both object ID and principal or tenant, not a global lookup followed by a separate check.
  • Add negative tests for every alternate representation: JSON Patch, merge patch, PUT, form binding, GraphQL input, and bulk update endpoints.

The strategic lesson is small enough to fit in a code review comment: an identifier may be visible, but it is not client-owned state. Once a framework lets the body redefine the key selected by the URI, a field-binding defect becomes an authorization bypass. The 5.0.7 guard fixes the known path. Treating identity and version as immutable server invariants prevents the next one.

Sources

💜 Enjoyed this content? Support the blog with USDT (TRC20):

TX7obcjHQbDUXb4mGqoASEu1QFTKT2CFGG

View support page

Paulo Rigonato

Security Engineer | Red Team | Pentest

Offensive security specialist with experience in assessments, pentesting, and Red Team operations. He works in enterprise cybersecurity and continues to share knowledge through this blog.

Certifications: OSCP | eWPTXv2 | ITILv4

💻 GitHub 🔗 LinkedIn

Similar Posts