Unsafe deserialization in agent-written code
Some save formats are not data but instructions for rebuilding an object, and the instructions can say "run this command first".
Aevral,
In plain words
Imagine a flat-pack delivery assembled by a robot that follows the instruction sheet in the box, whatever it says. A normal sheet says "attach leg A to panel B". A forged sheet says "before assembly, unlock the back door". Unsafe deserialization is the robot. Formats like Python's pickle do not store plain data; they store instructions for rebuilding objects, and loading one from an untrusted source lets the sender choose the instructions, including running a command.
The Python documentation says it directly: the pickle module is not secure; only unpickle data you trust. The same holds for YAML loaders that build arbitrary objects, and for model files saved with pickle inside.
Why agents write it: pickle makes the failing test pass
JSON refuses datetimes, sets, decimals, dataclasses and arrays. When an agent's round-trip test fails with "Object of type datetime is not JSON serializable", the one-line fix that makes everything pass is pickle, which serializes almost any Python object. The change is local and the test goes green; the danger arrives when the pickled blob crosses a trust boundary, such as a form field, a cookie, an upload or a cache other services can write to.
Machine-learning code has its own version. Recent PyTorch releases load checkpoints with weights_only=True by default, and an older or unusual checkpoint then fails to load with an error. The quickest change that makes the error go away is weights_only=False, which switches back to full unpickling of whatever file the user uploaded.
The shapes it takes in a pull request
pickle on data from outside
pickle.loads, joblib.load or numpy.load(..., allow_pickle=True) on a request body, cookie, upload, message queue or shared cache.
A YAML loader that builds objects
yaml.load with yaml.Loader or yaml.UnsafeLoader, or yaml.unsafe_load, on user-supplied YAML, where yaml.safe_load was the safe choice.
A model file loaded with code execution on
torch.load with weights_only=False, or a pickle-based model format, on a checkpoint the user uploaded or a URL they supplied.
State shipped to the client and back
Server-side objects serialized into a hidden field or cookie so they can be restored later, which hands the serialized form to whoever holds the browser.
Worked example, an illustration
Editor drafts restored with pickle after JSON failed on a datetime.
The editor keeps unsaved draft state in the browser and posts it back to restore. The draft gained a saved_at datetime, JSON restore failed, and the agent switched both sides to base64-encoded pickle so the round trip works.
@app.post("/api/drafts/restore")@login_requireddef restore_draft(): draft = json.loads(request.form["state"]) draft = pickle.loads(base64.b64decode(request.form["state"])) return render_editor(draft)What an Aevral finding on it looks like
On a reviewed pull request, a finding is an inline comment pinned to the added line, next to an advisory Check that never blocks the pull request. This is an illustration of that shape for the diff above.
app/drafts.py, added line: `draft = pickle.loads(base64.b64decode(request.form["state"]))`
request.form["state"] is client-controlled and is unpickled on the server. A pickle can instruct the loader to call any function during loading, so this line lets a signed-in user run commands on the server.
Missing control: A data-only format with schema validation for anything that comes from the client.
Fix with your agent
In app/drafts.py, restore_draft unpickles request.form["state"], which the client controls. Go back to JSON: serialize saved_at as an ISO 8601 string on the client side, and parse the posted state with a pydantic model (title: str, body: str, saved_at: datetime) using model_validate_json. Remove the pickle import if unused. Add a test that posts a pickle payload and expects a 400.
Check this finding against the code before changing anything. Make the smallest change that restores the control, add a test for it, and stop before commit or push.A finding is a lead with evidence, not a confirmation. You or your agent check it against the code; Aevral never applies or merges a change. See handing a finding to your coding agent.
How to fix it
Go back to a format that can only hold data, and fix the datetime the boring way: send it as an ISO 8601 string and parse it on the way in. A schema model does the parsing and the validation in one step, so the handler receives a known shape or an error.
If a blob really must round-trip through the client, keep the state on the server and send only an id. Signing a pickle proves who made it; it does not make loading it safe if the signing key ever leaks.
from datetime import datetime
from pydantic import BaseModel, ValidationError
class DraftState(BaseModel):
title: str
body: str
saved_at: datetime
@app.post("/api/drafts/restore")
@login_required
def restore_draft():
try:
draft = DraftState.model_validate_json(request.form["state"])
except ValidationError:
abort(400)
return render_editor(draft)What this page does not claim
- Aevral's PR security review, live on install, looks for unsafe deserialization on the diff of the pull requests it reviews, as one class among several. Looking for a class does not mean finding each instance of it: a review can miss one, and a quiet review is not proof that the code is clean.
- The whole-repo scan reads authorization, IDOR, and business-logic access control only. It does not read unsafe deserialization; on this class, the pull request is where Aevral looks.
- No class-specific performance evidence for unsafe deserialization is published. The receipts page reports named runs of the whole PR review eval suite, not a result for this class.
- The diff, the finding and the fix on this page are illustrations written for this page. They are not output from a customer repository.
- This page uses Python for its example. Java, .NET, PHP and Ruby have their own native serialization formats with the same class of risk and different libraries; the example does not describe them.
Sources
CWE-502: Deserialization of Untrusted Data; OWASP Deserialization Cheat Sheet; Python documentation: pickle; PyTorch documentation: torch.load; PyYAML documentation.
Read next
Command injection in agent-written code; Handing a security finding to your coding agent; PR security review.
Other classes