Guide 114 worked guide

How to assess unsafe deserialization in application code

Assess unsafe deserialization by tracing data origin, decoder behavior, trust boundaries, and the evidence required to retire legacy formats without hidden fallback.

To assess deserialization, identify the bytes being decoded, who can influence them before decoding, and what the decoder is allowed to create or do. A call to pickle.loads, Java ObjectInputStream, or a polymorphic object mapper is an investigation lead. It is not by itself proof that an outside actor can reach the call. Conversely, “the bytes came from our queue” is not a sufficient trust argument if a public request can place them there. Prefer a bounded data format with explicit field validation when the workflow only needs data. If native object deserialization is unavoidable, review the exact source, authentication before decoding, allowed types and graph limits, deployment library configuration, and remaining risk.

CWE-502 concerns deserializing untrusted data without adequately assuring the result is valid. OWASP's deserialization guidance emphasizes reducing reliance on native object formats and constraining unavoidable decoding. Python's pickle documentation explicitly warns against unpickling untrusted data. The review still has to establish how untrusted bytes reach the decoder in the application at hand.

An internal queue that may not be internal enough

Illustrative application and all names are invented. A document service, Harbor Forms, accepts a user's “resume draft” request. The API writes a compact blob to a job record. A background worker loads the blob with pickle.loads to reconstruct a DraftJob object, then renders a PDF. A scanner sees the pickle call and reports remote code execution. The actual security question is whether the user can alter the serialized blob or the object fields before the worker decodes it. The queue is merely a transport; it does not make its contents trusted.

Sketch the path before assigning a verdict:

Illustrative data flow; no serialized attack object is created here.

public request -> API validation -> job writer -> queue/storage
                                                  |
                                          can bytes be replaced?
                                                  |
                                      worker -> object decoder
                                                  |
                                      rendered document / side effects

There are at least three plausible outcomes. If the API itself creates the entire serialized object from fixed server-owned types and no lower-trust principal can change the stored bytes, the external attack path may be defeated, though a native object format remains a maintenance concern. If the API copies a client-supplied blob into the job, the path is direct. If the blob is written by a third-party integration or another service account, the reviewer must assess that principal and its authority rather than calling it either “public” or “trusted” by default.

Record the precise claim: “An ordinary Harbor Forms user can cause bytes of their choice to reach the worker's pickle decoder before a control that authenticates and constrains the serialized object.” A source citation for pickle.loads establishes only the sink. The claim fails if the bytes are demonstrably produced only by an appropriately trusted writer, protected from later alteration, and never derived from user-controlled serialized content. That falsifier should be actively checked.

Review the decoder's capabilities and the input's provenance

Native object formats can reconstruct types and invoke behavior during or around decoding. Python's pickle warning is particularly direct: unpickling data from an untrusted source can execute arbitrary code. JSON is usually a better wire format when the job only needs fields such as document ID, requested template, and owner ID. But “uses JSON” is not a complete security verdict; the application must still bound input size, validate type and range, authorize the referenced document, and handle later use safely. Python's JSON documentation also cautions about resource use when parsing untrusted input.

This is an illustrative data-only replacement shape, not a complete secure implementation:

{
  "schema_version": 1,
  "document_id": "doc_illustrative_42",
  "template": "standard"
}

The worker accepts only the three named fields and known values; it derives account and authorization from trusted job metadata and current policy, not from a client-supplied account field. It loads the document through an authorized data access path. It does not reconstruct an arbitrary DraftJob class from the wire. The same pattern can be used for queue messages: send a versioned instruction and identifiers, then obtain authoritative objects through checked application code.

When the product genuinely needs Java native serialization, inspect whether an ObjectInputFilter is configured for the relevant stream, what types it allows, and what limits it imposes on depth, references, arrays and bytes. Oracle's serialization-filter documentation documents the configured JVM-wide filter, filter factory and stream-specific behavior; a filter must actually govern this stream to provide the claimed restriction. A filter narrows the accepted object graph; it is not a substitute for reducing the trust boundary or validating the eventual operation. Do not infer a JVM-wide setting from a developer's local environment or from a configuration file not used by the selected deployment.

Put authentication before object construction

The ordering error can be tiny. Compare two illustrative sequences:

unsafe order:
  object = native_decode(bytes)
  verify object.signature
  authorize object.document_id

required order for authenticated legacy bytes:
  bound outer envelope size
  select trusted producer/key by server configuration
  verify exact payload bytes and signed context
  only then consider the narrowly admitted legacy decoder

The second sequence is not a recommendation to accept public pickle. Authentication reduces a particular tampering path; it does not make every signed object safe. A signer capable of producing arbitrary objects still holds powerful authority, and a compromised signer can defeat that trust assumption.

Harbor's import route accepts a signed envelope. Its signature covers the payload bytes, but the route chooses the decoding purpose from an unsigned field. That means a blob authenticated for one workflow could be presented to another decoder. The repair must bind the intended purpose and schema/version alongside the bytes using an unambiguous documented signing input. Include the receiving application/environment and account or job context when the same signing authority spans them; authenticate a payload for one consumer without making it reusable authorization for every consumer. Do not invent an ad hoc delimiter-based format that different implementations parse differently. Existing protocol/library support is preferable; its exact contract is part of the evidence.

Replace the wire object without breaking old jobs

Harbor chooses the JSON record shown above for new jobs. The migration inventory contains these synthetic classes:

Existing work Chosen handling Why
New public requests Create schema-1 data messages only No native object bytes accepted from clients
Old job with verifiable trusted producer, immutable bytes and known purpose Narrow migration path under restricted worker authority Provenance established before any legacy decoding
Old job with unknown writer or mutable unauthenticated blob Hold or recreate from authoritative document metadata Unknown provenance is not repaired by age
Failed new JSON job Retry the data path or fail Never fall back to pickle on parse error

The last row is a useful review trap. A broad except that retries the old decoder turns malformed JSON into access to the very boundary being removed. The new contract should fail clearly for unknown schema, unexpected fields or unavailable document authorization.

The worker's first meaningful business action loads document_id using trusted job/account context and current permissions. JSON syntax validation does not grant access to that document. A correctly authenticated message can also be stale, duplicated or refer to a removed object; those outcomes need an explicit job policy.

Treat filters as configured behavior

For Java, a dependency list containing ObjectInputFilter is weaker evidence than the effective filter on the stream being reviewed. Oracle's Java 25 configuration API documents graph/resource limits and returns no JVM-wide filter when one is not configured. It also distinguishes JVM-wide configuration from the factory and stream-specific choices.

A review packet should therefore show the selected runtime, filter/factory initialization, allowed classes, unmatched-class behavior and graph limits for the exact decoder. A class allowlist without work bounds leaves a different risk; a small graph of an unsafe allowed type can still be problematic. The useful conclusion names what the filter rejects and what the application still authorizes afterward. No Java stream or serialized object has been executed here.

Choose a remedy that fits the trust boundary

The cleanest remedy for Harbor Forms is to replace object serialization with a narrow, versioned data message and construct application objects after schema and authorization checks. This also makes message evolution explicit. If a transition period requires reading old pickle jobs, restrict that path to existing, authenticated, server-produced records under a short migration window and a limited worker identity. Do not accept a new public pickle field just to keep compatibility. Record when the legacy path can be removed.

Signing or authenticating a serialized blob can prevent unauthorized modification only if key access and verification are sound, the signature covers the exact bytes and context, and verification happens before decoding. It does not protect against a compromised signer or a legitimately signed but unsafe object design. Likewise, hiding the endpoint or base64-encoding the blob does not establish trust. Risk depends on writer authority, byte provenance and decoder capability.

Validate without constructing a live exploit

An authorized team can validate the boundary using harmless, synthetic messages in an isolated environment: a valid versioned message; a message with an unexpected field or type; an oversized message; an unauthenticated or modified legacy blob; and a job created through each real writer. Observe whether rejection occurs before the native decoder, whether the worker stays within resource limits, and whether no side effect occurs for rejected messages. For a Java filter, verify the selected runtime actually applies the filter to that stream. For a replacement JSON flow, verify that the referenced document is authorized at processing time, not only at enqueue time.

This validation checks the chosen cases, not every possible serialized graph. Do not build or run gadget payloads against a live service. A source-only review can establish that a risky decoder is reachable from a lower-trust path if the data flow is complete; it cannot claim an executed exploit. If the storage ACL, deployed filter or writer set is unknown, leave those facts explicit.

Harbor's initial note at H8, before the authenticated-envelope and migration decisions above, might read:

Subject: draft-job decoding at illustrative revision H8. Observed: the worker calls pickle.loads on a persisted job blob. Supported input path: the API appears to assemble the blob from validated fields, but one import route writes a caller-provided blob without the same transformation. Unknown: production import-route availability and job-record write permissions. Decision: investigate those two facts; do not publish a confirmed RCE claim from the decoder call alone. Remedy direction: a versioned data message with server-side object construction.

Make legacy retirement verifiable

Harbor's migration is complete only when the remaining writers and consumers no longer require the old object format. Record how many admitted legacy jobs remain, which narrow migration path may read them, and which condition disables that path. The count should come from the actual job inventory in a real implementation, not an assumed age cutoff.

A malformed new message must stay on the new schema's rejection path. A legitimate old job needs established provenance before legacy decoding. An unknown blob belongs in a hold or reconstruction decision. These three cases should remain distinguishable in operational status so an owner does not restore the old decoder broadly to clear a confusing backlog.

The final repair note can then state which input classes were removed and which bounded transition remains. That is stronger than reporting that new requests use JSON while an undocumented compatibility route still accepts arbitrary native objects.

Next action

Pick one deserializer and list every writer of the bytes it consumes. If any writer is lower trust, identify the check that runs before decoding. For a wider input-to-sink method, read the scanner-alert claim guide. For partial source evidence that needs deployment confirmation, continue with source-only findings needing runtime proof.