skip to content

A service decodes JSON with a general-purpose object mapper, and someone turns on the mapper's polymorphic type handling globally so that interface-typed fields round-trip. Explain what that configuration changes about which types an incoming document can cause to be constructed, why a global switch differs from declaring permitted subtypes on one field, what a YAML library's "safe load" entry point actually is in the same terms, and what a team has and has not achieved by migrating an endpoint from a native object stream to JSON.

level: middleimportance: should knowfreq 40%

answer

  1. binding policy, not file format
  2. global typing = open-world type selection
  3. per-field subtype table = closed-world, code-owned
  4. safe load = enumeration, not sanitizer
  5. hash/equals run while filling a decoded map

basics

~20 s

Polymorphic type handling lets the document name the type to construct. Globally, every polymorphic field becomes an open-world type-selection channel; per-field with an enumerated subtype list is closed-world. A format migration that keeps the global switch changes syntax, not exposure.

solid answer

~60 s

The dangerous thing is the **binding policy**, not the file format. - **Global polymorphic handling** tells the mapper to read a type name out of the document and load it. That is open-world: the permitted set is "whatever the runtime can resolve", which no one enumerated and no one reviews. Reconstruction then runs code — a custom read routine, an object-resolution or restore callback, constructors and setters, and the hash/equality methods of decoded elements when a decoded map or set is populated. Selecting the type is therefore enough; the attacker never needs a call site. - **Per-field with an enumerated subtype table** is closed-world: a short tag in the document indexes a finite, code-owned list. The document influences the choice; it never supplies the name. - A YAML **safe-load** entry point is exactly that closed-world enumeration, shipped as a second function: core scalars and collections only. So migrating to JSON buys nothing while global typing stays on — same channel, nicer syntax. It buys everything the moment target types come from code.

code

text · 10 lines
text
open world  (global polymorphic handling)
  document: { "@type": "<any resolvable name>", ... }
  binder  : resolve(name) -> construct -> run read routine / setters / restore callback
  permitted set = whatever the runtime can load   <- nobody wrote this down

closed world (per-field, enumerated)
  document: { "kind": "card" | "bank" | "voucher", ... }
  code    : TAGS = { card: CardPayment, bank: BankPayment, voucher: VoucherPayment }
  binder  : TAGS[kind] or reject 400
  permitted set = 3, in the source tree, visible in review

go deeper

for a junior

Know the one-liner: JSON itself cannot name a type, but a mapper configured for polymorphic types can be told to read a type name out of the document — and then the document, not your code, picks the class.

for a middle

Be able to contrast the global switch with a per-field enumerated subtype table, and explain why decoding alone runs code (read routines, restore callbacks, setters, and hash/equality while filling a decoded map).

for a senior

Frame safety as a property of the decode configuration and say where the permitted set is written down; audit every decode entry point including caches and queues, and enforce resource budgets separately from type policy.

for a principal

Argue the open-world/closed-world principle as the invariant, make the code-owned type table the reviewable artefact, and reject migrations that report a format change as remediation without evidence that target types now come from code.

## What the setting actually does A plain JSON document cannot name an application type: the grammar has objects, arrays, strings, numbers, booleans and null, and nothing else. The type-selection channel is created one layer up, by the **binder** — the component that turns the parsed tree into typed objects. Polymorphic type handling is the binder feature that says: when a field is declared as an interface or abstract base, look inside the document for a type discriminator and construct whatever it names. That is the whole change. No byte of the format gained power; the *policy governing the decoder* did. Once that policy is on, the document is choosing the class to instantiate, which is the same authority a runtime's native object stream has always had. ## Global versus per-field: open-world against closed-world This is the distinction interviewers are probing, and it is the same open-world/closed-world argument that governs every injection defence. - **Global** means: any field typed loosely enough is a selection point, and the permitted set is defined negatively — everything the class loader can find that the binder can instantiate. Nobody wrote that set down. It grows every time a dependency is added, including transitively. Its size is unknown to the team by construction. - **Per-field with declared subtypes** means: this one field accepts one of, say, three tags, and a table in code maps each tag to a concrete type. The set is finite, owned by the source tree, and visible in review when someone adds a fourth entry. A filter that blocks "dangerous-looking" type names is not a substitute. A deny pattern is open-world — it still admits every name nobody thought of, exactly as a character pattern over a column name still admits every real column including the sensitive ones. Enumeration is finite; a pattern is not. ## Why type selection alone is sufficient Candidates often assume an attacker needs the application to *use* the decoded object. Usually not, because reconstruction itself executes application code: - a custom read routine the type defines to rebuild its own state; - an object-resolution or restore callback the runtime invokes on a freshly decoded instance; - ordinary constructors, property setters and initialisers the binder calls to populate fields; - lifecycle or cleanup callbacks that fire later, on garbage collection or shutdown; - **hash and equality methods of decoded elements**, invoked while a decoded map or set is being filled — the easiest one to forget, and a favourite because it needs no cooperation from the surrounding code at all. So "we decode it and then validate it" is too late: the interesting work happened during decoding, before any validation statement ran. Validation is a rung below the structural fix for precisely this reason. ## "Safe load" is an enumeration wearing a different name YAML libraries that honour type tags typically ship two entry points: the permissive loader that constructs application types named by the document, and a safe loader restricted to core scalars and collections. Read that safe loader in the vocabulary above and it is not a sanitizer, not an escaper, and not a validator — it is a **closed-world enumeration of permitted types**, hard-coded by the library author. Its safety comes from the same property as the per-field subtype table: someone wrote the finite list, and the document cannot extend it. The practical consequence is that safety here is one function call wide. Two call sites in one codebase, on identical syntax, can sit on opposite sides of the boundary, and only the call site tells you which. ## What the migration buys, and what it does not A team that replaces a native object-stream endpoint with JSON and leaves global polymorphic handling enabled has re-implemented the same defect in nicer syntax. Worse, the migration is usually *reported* as remediation, so the finding closes while the sink stays live. Two conditions make the migration real: 1. **Target types come from code.** Either the endpoint binds to one fixed shape, or a finite code-owned tag→type table supplies the alternatives on the specific field that needs them. 2. **Resource budgets are enforced independently** — nesting depth, element counts, declared sizes, decompression ratio, decode timeout — because none of that changed with the format, and a decoder can still be made to exhaust memory or CPU without constructing a single application type. One more caveat worth a sentence: some decoders carry capabilities that need no type resolution at all, such as an XML processor that resolves external entities or expands nested ones. Same lesson from the other direction — enumerate what your decoder is permitted to do and switch off what you do not need, rather than reasoning about payloads. ## Where this sits on the ladder The ordering is structural separation, then escaping or transformation, then validation, then detection, weakening from guarantee to heuristic at each step. For decoding, the top rung means the wire never selects a type. Where some choice genuinely must be data-driven, closed-world enumeration takes the top rung in its place — for the open-world/closed-world reason above. Post-decode validation and monitoring stay useful, but they operate after the moment that mattered. ## Interview framing "Ask two questions of any decode path: can the document name the type, and does reconstruction run code? Then ask where the permitted set is written down. If the answer is 'wherever the class loader can reach', that is the finding — regardless of whether the payload is JSON, YAML or a native stream."

  • A team enables a mapper's global polymorphic type handling so an interface-typed field can round-trip. What do you propose instead?
    Declare the polymorphism on that one field: the document supplies a short tag, and a table in code maps that tag to one of a small number of permitted concrete types. Keep the table next to the type it serves so a reviewer sees additions. The global switch is unbounded by construction — it accepts any name the runtime can resolve, including types pulled in transitively by dependencies — and that unbounded set is the channel you are trying to remove.
  • Someone proposes keeping global typing but adding a deny-list of known-dangerous type names. Why is that weaker?
    A deny-list is open-world: it constrains the names you thought of and still admits every one you did not, which grows silently as dependencies change. An enumerated allow-list is finite and owned by your source tree, so its size is known and its growth is a reviewable diff. This is the same reason an enumerated column map beats a character pattern when parameterisation cannot cover an identifier.
  • How do you find these decode sinks in a large codebase?
    Search for decode entry points rather than HTTP endpoints: native stream readers, YAML loader calls, mapper configuration that turns on type handling, cache and session codecs, message-queue payload converters, config and plugin loaders, and anything decoding an upload. For each, ask who can influence the bytes, including second-order producers such as a cache or queue another service writes to.

saying these in an interview costs you the question

  • "We use JSON, so deserialization attacks don't apply" — ignores the binding layer entirely.
  • Treating a format migration as remediation without checking who chooses the type.
  • Believing a YAML library is safe by default because it happens to offer a safe loader.
  • "We validate the object after decoding" — the hooks already ran during decoding.
  • Proposing a deny-list of type names instead of an enumerated allow-list.

context