The two halves of the pipe never agreed on what lc meant
LangChain Core ships two sides of a serialization roundtrip. langchain_core.load.dump writes objects to JSON. langchain_core.load.load reads them back. The objects in question are Serializable subclasses: chat models, prompt templates, messages, agents, vector stores. They serialize to dicts of a specific shape. The shape carries a marker.
The marker is the integer 1 under the key lc. Every serialized LangChain object emits {"lc": 1, "type": "constructor", "id": [...], "kwargs": {...}}. The Reviver, which is the object_hook callback json.loads calls on every nested dict, dispatches on that marker. A dict with lc == 1 and type == "constructor" is instantiated by importing the class at id and calling it with kwargs. A dict with lc == 1 and type == "secret" is replaced with the value of os.environ[id[0]]. A dict with lc == 1 and type == "not_implemented" raises or returns None.
That is the consumer contract. The producer side, before this patch, was five lines:
def dumps(obj: Any, *, pretty: bool = False, **kwargs: Any) -> str:
if "default" in kwargs:
msg = "`default` should not be passed to dumps"
raise ValueError(msg)
if pretty:
return json.dumps(obj, default=default, indent=2, **kwargs)
return json.dumps(obj, default=default, **kwargs)
The default callback is invoked by Python's JSON encoder only on objects that are not natively serializable. Dicts, lists, strings, and integers are natively serializable, so default was never called on them. They were forwarded through.
A user dict that happened to look like {"lc": 1, "type": "secret", "id": ["OPENAI_API_KEY"]}, sitting inside the metadata field of an AIMessage or the additional_kwargs of a HumanMessage, was written to disk verbatim. When the same blob was loaded back, the Reviver matched the three conditions, unpacked id, and returned os.environ["OPENAI_API_KEY"] in the message body.
This is a content-is-command shape with the boundary inside one library: the persistence sink writes user data, the persistence loader interprets that data as framework objects, and the only thing distinguishing the two roles is the four-character string lc.
The PoC publishes the dictionary
The PoC repository Ak-cybe/CVE-2025-68664-LangGrinch-PoC, referenced from Johnny Zhou's analysis writeup that the scanner caught, ships a fuzzer with a hardcoded list of environment variable names. The list is the disclosure. OPENAI_API_KEY, ANTHROPIC_API_KEY, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, DATABASE_URL, JWT_SECRET, REDIS_URL, SLACK_TOKEN, plus thirty more. Each is a string developers put in .env files and os.environ on production hosts. Each is what secrets_from_env reads when the Reviver hits a secret-typed dict.
The exfiltration payload is one dict literal:
{"lc": 1, "type": "secret", "id": ["OPENAI_API_KEY"]}
It is placed in any field a developer might serialize as part of an agent's state: a chat history record persisted to Redis, a workflow checkpoint written by RunnableWithMessageHistory, a vector store metadata column, a structured output cache. The placing happens through any input channel the agent accepts. A user message. A tool result. A retrieved document. The string {"lc": 1, "type": "secret", "id": ["OPENAI_API_KEY"]} makes it into the model's context, then into the model's reply, then into a dict the application caches. Next time the cache is loaded with loads(), the Reviver unwraps it.
The fuzzer also publishes a list of constructor-typed payloads that take the second branch of the Reviver. template_format="jinja2" on a PromptTemplate with a malicious template is RCE because Jinja2 is a template engine that evaluates expressions. PythonREPL is an explicit code-execution tool. RequestsWrapper with a configured base_url is SSRF. The fuzzer also includes a Unicode-escape variant of the marker, \u006c\u0063 for lc, which is the same string as lc after JSON parsing because JSON unescapes \uXXXX sequences before delivering them to the parser's consumer.
None of this required breaking the parser. None of it required smuggling instructions past a check. The attacker writes the same shape the framework writes.
The deserializer's job description was already a warning
Pre-patch, the top of load.py opened with this:
!!! warning
`load` and `loads` are vulnerable to remote code execution.
Never use with untrusted input.
The same warning, copy-pasted, sat above the function docstrings of load, loads, and an internal helper. The library said the thing the CVE record now says.
The patch deletes all three copies of the warning. In their place, the new module docstring at the top of load.py contains a longer threat-model section that names exactly which allowed_objects settings are unsafe with untrusted manifests, why secrets_from_env=False is now the default, and which message-only allowlist is the choice for genuinely external input. The warning got more specific because the new code is doing more of the work the warning was previously delegating to the user.
The user, we should be specific, was never reading the warning. The warning was on load and loads. Nothing was on dumps.
The framework called its own warned-against loader on its own state
Here is the move that widens the frame. While the patch was teaching dumps() to escape user dicts, it was also teaching seven framework-internal callsites that load() had been warning about itself for years.
runnables/history.py, _exit_history, line 542 pre-patch:
inputs = load(run.inputs)
The same call appears at line 551 (output_val = load(run.outputs)) of _exit_history. Then again at line 559 of _aexit_history. Then again at line 568 of _aexit_history. The patch changes all four to load(run.inputs, allowed_objects="all") and load(run.outputs, allowed_objects="all").
tracers/log_stream.py, _get_standardized_inputs, line 566:
inputs = load(run.inputs)
And _get_standardized_outputs, line 598:
outputs = load(run.outputs)
Both amended.
vectorstores/in_memory.py, line 531, the on-disk vector store loader:
store = load(json.load(f))
Amended to load(json.load(f), allowed_objects=[Document]).
These are LangChain's own pipelines roundtripping LangChain's own state through dumps() and load(). RunnableWithMessageHistory is the canonical wrapper for adding chat history to any chain. It is what every LangChain tutorial calling itself an "agent with memory" reaches for. Its exit hook serializes the run's inputs and outputs to the configured tracer, then deserializes them on the way out to populate the chat history store. The serializer wrote the warning's preconditions. The deserializer was the warning's target. The application never touched either function and never saw the warning.
The seventh callsite, the vector store loader, is the most direct version of the same thing. The user calls InMemoryVectorStore.load("path/to/store.json"). The "untrusted input" the warning gestures at is, in this codebase, any file on the filesystem that anything the user has run produced. The file format is JSON-of-LC-objects. The producer was unsafe. The consumer was warned-against. The application connected them.
The patch's choice of allowed_objects per callsite is the receipt. runnables/history.py and tracers/log_stream.py get "all", the broadest allowlist, because they are roundtripping arbitrary LangChain state and the maintainers cannot tighten without breaking the runtime. vectorstores/in_memory.py gets [Document], the most restrictive option available, because the only thing that file should contain is Document instances. The four-line file change in vectorstores/in_memory.py is what every other on-disk loader of a LangChain serialization should look like. The four-line file change in runnables/history.py is what the framework had to settle for because the surface was already too wide to constrain.
What the patch is, structurally
The patch is six things, in roughly this order of importance:
A new file, _validation.py, that defines _LC_ESCAPED_KEY = "__lc_escaped__" and a recursive _serialize_value walker. Every plain dict (not a Serializable instance) is checked with _needs_escaping, which returns true if the dict has an lc key or already has only the escape key. If escaping is needed, the dict is wrapped: {"__lc_escaped__": {...original...}}. The walker recurses into safe dicts, lists, and tuples. Strings, numbers, and None pass through. Anything else hits to_json_not_implemented.
dumps() and dumpd() are rewritten to call _serialize_value instead of json.dumps(..., default=default). The default callback is gone from the encoder. Pre-patch, dumpd() was literally return json.loads(dumps(obj)), a roundtrip through JSON for no reason; post-patch, both functions build the dict directly and dumps() is the JSON wrapper around dumpd's output.
The Reviver is rewritten to handle __lc_escaped__. When _unescape_value sees a dict with exactly that one key, it unwraps and returns the contents as-is, without further unescaping. The contents are user data. They will not be re-fed to the Reviver. Whatever lc looked like inside is just text.
secrets_from_env defaults from True to False. This is the one-line setting that determined whether the secret-typed Reviver branch reaches os.environ at all. Pre-patch, the setting flipped the read on by default. Post-patch, callers who want the read have to ask for it.
allowed_objects: Iterable[AllowedObject] | Literal["all", "core", "messages"] = "core" is added to load, loads, and the internal Reviver. The "core" default permits langchain-core classes only. The "all" mode permits every class in SERIALIZABLE_MAPPING, which is what the framework's internal callsites needed. The "messages" mode permits chat-message classes only and is the documentation's recommendation for genuinely untrusted input. The list form, with explicit class objects, is the strictest available and is what vectorstores/in_memory.py got.
Per-class init validators. A new file, validators.py, defines _bedrock_validator, which blocks endpoint_url and base_url kwargs on AWS Bedrock chat-model and LLM classes. The CLASS_INIT_VALIDATORS dict maps class paths (both serialized-mapping IDs and resolved import paths, to defend against direct-path bypass) to per-class kwarg validators. A separate default_init_validator blocks template_format == "jinja2" on every constructor-typed deserialization. The PoC's RCE branch and the PoC's SSRF branch both lose their primaries to these.
Each item names a thing the prior code was missing. The escape file is what dumps() did not do. The Reviver unwrap is what made the escape readable. The default flip is the secrets-read disabled by default. The allowlist is the constructor surface narrowed by default. The init validators are the per-class kwarg checks for the cases the allowlist could not eliminate without breaking the runtime.
What the producer-consumer asymmetry looks like in code
The framework's reserved keyword was lc. The framework's serializer wrote whatever the user gave it. A four-character string that the consumer parsed as a magic value, the producer treated as ordinary text. The PoC fuzzer's \u006c\u0063 variant is the same observation reduced to its smallest reproducible form: in JSON, \u006c\u0063 and lc are the same string after the parser is done. There is no encoding the producer can forbid that the consumer would not accept. The only place the asymmetry can be closed is at the producer, by wrapping the user's dicts before they leave the program.
The patch closes it there. The new escape file is 191 lines because it has to cover the recursion, the secret-skipping when serializing Serializable objects (their lc_secrets declarations identify which kwargs were already converted to secret-typed dicts by to_json and should not be re-escaped), and the unescaping path that the Reviver uses on the read side. None of those 191 lines were in the codebase before this commit.
The CVE is the gap between two design decisions made years apart. The lc marker was chosen as the framework-object signal early in langchain-core. The Serializable base class with its to_json method, which is the only legitimate producer of lc-marked dicts, came later. Between those two events, dumps() was a four-line function that called json.dumps with a default callback. The callback was for Serializable subclasses that the encoder did not know how to handle. It was never the gate it should have been. The gate was on the wrong end.
What to do if you operate a langchain-core deployment
langchain-core >= 0.3.x with the patch in PR #34455 is the floor. If you cannot upgrade, the operational mitigations are narrower than the patch's structural ones, but they exist:
- Set
secrets_from_env=False explicitly on every load() and loads() call your code makes. Pre-patch, this is the read your application was permitting unintentionally.
- Pass an explicit
allowed_objects list to every load() and loads() call. The shortest legitimate list is the message classes you actually receive. Anything broader is admission that you are deserializing more than you want to.
- Audit every persistence sink that reaches a LangChain deserializer. Chat history stores. Tracer outputs. Cached structured outputs. Vector store snapshots. Each is a place where a
dumps() write meets a load() read with no schema between them.
- Treat
dumps() output as untrusted on its own. Sign it, encrypt it, or store it on infrastructure that does not accept attacker input. The signature does not have to be sophisticated. It has to make the round trip not be a round trip through attacker reach.
The patch's choice to keep allowed_objects="all" on the framework's internal callsites is a constraint the maintainers had no way around without changing the framework's runtime semantics. It is also the constraint your application probably does not have. Your inputs and outputs are narrower than RunnableWithMessageHistory's. The list-of-classes form is available to you, and it is what the patch reaches for in the one place it could.