#8938·langgraph

EncryptedSerializer silently accepts unauthenticated plaintext rows, voiding its at-rest integrity guarantee

Author: AUTHENSORCreated Sep 15, 2026Updated Sep 17, 2026
Labelsexternal

EncryptedSerializer silently accepts unauthenticated plaintext rows, voiding its at-rest integrity

Severity: medium. Structural invalidation of a shipped guarantee: checkpoint encryption is confidentiality-only and not even that per row, because any store-level writer can substitute plaintext forgeries that are deserialized with no decryption and no tag verification.

Affected versions: verified on langgraph-checkpoint 4.2.0 / langgraph 1.2.11 at commit 230927fb3a9ac9b2893a30322b4dfea7cdea9a8f (main head at time of report). EncryptedSerializer was introduced by PR #3852 (merged 2025-03-14), so the behavior has shipped since the first version that included it.

Summary

EncryptedSerializer.loads_typed treats any stored row whose type string contains no "+" as plaintext and passes it straight to the inner serializer without decryption or tag verification:

python
def loads_typed(self, data: tuple[str, bytes]) -> Any:
    enc_cipher, ciphertext = data
    # unencrypted data
    if "+" not in enc_cipher:
        return self.serde.loads_typed(data)

(libs/checkpoint/langgraph/checkpoint/serde/encrypted.py:26-30)

The AEAD tag checked in the decrypt path (encrypt_and_digest / decrypt_and_verify in the pycryptodome cipher, encrypted.py:87-105) is the only tampering detection this class provides, and an attacker who can write to the store simply never triggers it: they replace an encrypted row with a plaintext row, and the graph loads the forged state. No signature, MAC, or digest protects checkpoint rows in any shipped saver; for the encrypted serializer specifically, the mechanism that was supposed to detect rewrites is bypassed row by row.

We demonstrated this in the hardened posture, that is with LANGGRAPH_STRICT_MSGPACK=true AND EncryptedSerializer over AES-EAD, so the constructor-revival path documented in CVE-2026-28277 / GHSA-g48c-2wqr-h844 is blocked and only plain data survives deserialization. Even then the forged state is enough to drive the graph's own authorization logic.

Reproduction (executed at commit 230927fb3a9ac9b2893a30322b4dfea7cdea9a8f)

  1. Build a graph with InMemorySaver(serde=EncryptedSerializer.from_pycryptodome_aes(key=...)) and run it once; stored rows have type msgpack+aes.
  2. As the store-level writer (the same capability as database access, SQL injection in a co-located app, shared credentials, or a restored backup), replace the head checkpoint row and channel blobs with plaintext: type msgpack with no cipher suffix, where the forged checkpoint's channel_values contains approved=True and matching channel_versions.
  3. graph.get_state(config) returns the forged values: approved=True, no decryption error, no tag failure, no warning.
  4. The next invoke on the thread routes on the forged channel value through the graph's own conditional edge and executes the privileged node. Observed output:
stored row type before attack: 'msgpack+aes'
stored row type after attack: 'msgpack'
state read from forged plaintext row: approved=True
turn-2 result: {'approved': True, 'log': ['turn-2', 'benign-ran', 'PRIVILEGED-NODE-RAN']}
PRIVILEGED-ROUTE-TAKEN-FROM-FORGED-PLAINTEXT-ROW: True

Expected vs actual

Expected: a checkpointer configured with an encryption cipher refuses or at least flags rows that were not written under that cipher, so tampering with stored rows is detectable.

Actual: untagged rows are deserialized as-is, so a store-level writer controls every channel value the graph resumes with, including values that gate authorization routing, tool selection, and human-approval state. Encryption here is confidentiality-only against passive readers, and even that is opt-out per row by the attacker.

Impact

Deployments that enable checkpoint encryption (its documented purpose is protecting checkpoint contents at rest) do not obtain integrity. The SqliteSaver and PostgresSaver inherit the same loads_typed path, so the exposure is not specific to the in-memory saver used in the demonstration.

Recommended fix

Fail closed: when the serializer was constructed with a cipher, rows whose type tag carries no cipher suffix should raise rather than be trusted, with an explicit allow_plaintext=True migration flag for reading legacy rows written before encryption was enabled (and a documented migration helper that re-encrypts them). A ready two-file patch implementing exactly this is attached (PR form), including the strict-mode allowlist wrapper path in BaseCheckpointSaver.with_allowlist which reconstructs the serializer and must preserve the new flag.

Notes

  • The upstream test suite for this class (libs/checkpoint/tests/test_encrypted.py, 17 tests) passes both before and after the attached patch, which also shows the suite never exercises plaintext-row loading through a cipher-configured serializer; a regression test for the refusal behavior is included in the PR form materials.
  • This is distinct from CVE-2026-28277 / GHSA-g48c-2wqr-h844 (msgpack constructor revival): that issue is about deserializing crafted objects, this one is about accepting unauthenticated rows even with object revival blocked and encryption enabled.