Secure Coding and Application Security Questions
Writing and reviewing code that resists attack. Covers the OWASP Top Ten and common web vulnerabilities (XSS, SQL injection, CSRF), input validation, secure coding practices and security code review, static application security testing (SAST), API and HTTP security, database and frontend security, and mobile app security. The application-layer defense discipline for engineers building software.
Evaluate the feasibility of using mutual TLS (mTLS) between mobile clients and backend APIs. Explain how client certificates or keys would be provisioned and protected on iOS and Android (key generation in the Secure Enclave/KeyStore), platform support constraints, certificate rotation strategies, revocation mechanisms, and the risk-mitigation steps to take if a private key is extracted from a compromised device.
Sample Answer
Direct answer
Mutual TLS (mTLS), where the client presents a certificate and the server verifies it in addition to the usual server-to-client certificate check, is feasible for mobile and gives a cryptographic proof of device identity that a bearer token alone cannot: a stolen access token is directly usable by an attacker, while a stolen mTLS connection is not, because the private key backing the certificate never needs to leave hardware-backed storage. The cost is real operational complexity around provisioning, rotation, and revocation at a scale and with a device population (varied Android hardware, users who never open the app for months) that server-to-server mTLS deployments do not usually have to deal with, so it is worth the investment for high-value flows and rarely worth it as a blanket default.
Structured elaboration
Provisioning and protection on iOS and Android.
- iOS: the device generates a key pair using
SecKeyCreateRandomKeywithkSecAttrTokenIDset tokSecAttrTokenIDSecureEnclave, which keeps the private key inside the Secure Enclave, a separate hardware coprocessor, non-exportable by construction. The app builds a certificate signing request (CSR) from the public key and sends it to your backend's enrollment endpoint, which returns a signed client certificate. The certificate and a reference to the Secure Enclave key are stored in the Keychain;URLSessioncan present this identity for mTLS viaSecIdentity. - Android:
KeyPairGeneratorwith aKeyGenParameterSpecthat sets the key as hardware-backed and non-exportable generates the key pair inside the Android Keystore, backed by a Trusted Execution Environment (TEE) or, on supporting devices, StrongBox (a dedicated secure element, a stronger guarantee than a TEE alone). The same CSR-and-enroll flow as iOS applies; the resulting certificate is stored alongside the Keystore key alias. - In both cases, the key that matters is generated on-device and never transmitted; only the CSR (public key plus identifying attributes) and the resulting signed certificate travel over the network, so there is never a moment where the private key exists outside secure hardware to be intercepted.
Platform support constraints. iOS support is uniformly strong on any device capable of running a current iOS version: the Secure Enclave has been present since the iPhone 5s generation, and URLSession's client-identity APIs are stable and well-documented. Android is meaningfully more fragmented: hardware-backed Keystore is broadly available, but StrongBox (the higher security bar) is only present on a subset of devices, and older Android versions or budget OEM hardware may fall back to software-backed keys, which are exportable in principle if the device's OS is compromised, a materially weaker guarantee than the iOS baseline. Some HTTP client stacks and, notably, many corporate or public network proxies and some captive portals do not transparently pass through client-certificate negotiation, which can break connectivity in ways that are hard to diagnose from the client alone.
Certificate rotation strategies. Short-lived certificates (days to a few weeks, not years) are strongly preferred over long-lived ones, because a short lifetime bounds the damage from a certificate that should have been revoked but was not yet, for whatever operational reason. Rotation happens automatically and silently: before the current certificate's expiry, the app generates a new CSR (a fresh key pair, generated the same hardware-backed way) and re-enrolls, authenticated by the still-valid existing certificate or by a separate authentication factor (a refresh token) if the app design keeps the two systems partially independent. The app should never be left in a state where it has no valid certificate because a rotation failed silently; a monitoring signal on rotation failure and a defined retry/backoff policy are part of the design, not an afterthought.
Revocation mechanisms. Standard Certificate Revocation Lists (CRLs) are heavy for mobile (large lists to fetch and check) and Online Certificate Status Protocol (OCSP) checks add a network round trip per connection, both awkward fits for a mobile client's intermittent connectivity. The practical mechanism most mobile mTLS deployments actually rely on is a server-side denylist keyed by certificate serial number, device id, or key fingerprint, checked at the application layer immediately after the Transport Layer Security (TLS) handshake completes and before any request is processed; this is fast (a cache lookup, not a certificate-authority round trip) and does not depend on the revoked device's own connectivity the way client-side CRL fetching would. Combined with short certificate lifetimes, this gives both immediate denylist-based revocation for known-compromised devices and a hard natural expiry backstop for anything the denylist missed.
Risk-mitigation steps if a private key is extracted from a compromised device. In the properly hardware-backed case (Secure Enclave, or Android Keystore with hardware backing), the private key cannot be extracted even with full operating-system compromise, since it never leaves the secure coprocessor and all cryptographic operations happen inside it; what an attacker with a compromised, unlocked device can do instead is use the key through the OS's own APIs for as long as they control the device. If that is discovered:
- Immediately add the certificate's serial number (or device identity) to the server-side denylist, effective on the next handshake check, independent of the compromised device's own state.
- Revoke any associated bearer tokens or session state tied to that device, since mTLS is typically layered alongside, not instead of, user-level authentication.
- Force re-enrollment: the legitimate user, on a trusted device or channel, re-authenticates and the app generates a fresh key pair and certificate; the old certificate remains permanently denylisted rather than reused.
- Raise the device's risk score in whatever fraud/anomaly system is in place, and increase monitoring on the account for a period, since a device compromise is often not an isolated event.
- In the rarer case of a software-backed (not hardware-backed) key on a device where the OS itself is fully compromised, treat the exposure as potentially including the raw key material, not just usage capability, and prioritize the denylist step accordingly.
Worked example
A banking app choosing between token-only authentication and adding mTLS for its highest-value action, wire transfers over a set threshold:
- Without mTLS. A malicious app or a compromised network position that captures the bearer access token can directly replay it against the transfer endpoint until the token expires or is revoked. The only defense is the token's own short lifetime and revocation checking.
- With mTLS layered on top, scoped to just this endpoint. The transfer endpoint additionally requires the TLS handshake to present a client certificate matching this specific device's enrolled identity. An attacker who captured the bearer token over the network (say, through a misconfigured proxy the token happened to transit) still cannot complete the handshake, because they do not have the private key, which never left the Secure Enclave or Keystore of the legitimate user's specific device. The attacker's request fails at the TLS layer, before the application even sees the bearer token.
- Rotation in this scenario. The transfer-specific client certificate is issued with a two-week lifetime. Fourteen days before expiry (a threshold, not the exact expiry moment, to leave room for retries), the app silently re-enrolls using its still-valid certificate as proof of identity for the new CSR, so the user never experiences an interruption from a routine rotation.
- If the device is later found to be compromised, the bank's security team adds the certificate serial to the denylist; any in-flight or future transfer attempt from that device, mTLS-authenticated or not, is rejected at the next handshake check, and the account owner is prompted to re-enroll a trusted device before transfers are available again.
Trade-offs and pitfalls
- Deploying mTLS as a blanket requirement for every API call. The operational cost (provisioning, rotation, proxy compatibility issues, and the fragmentation on Android described above) is high enough that applying it everywhere is rarely worth it; scope it to the highest-value actions and rely on token-based authentication plus short expiry elsewhere.
- Treating software-backed keys as equivalent to hardware-backed ones. On Android especially, a key that falls back to software backing (older devices, or an OEM implementation gap) is a meaningfully weaker guarantee, since it can in principle be extracted given full OS compromise. The design should detect and report which backing tier a given device actually achieved, not assume hardware backing uniformly.
- Relying on CRL or OCSP as the primary revocation path. Both add latency and connectivity dependencies that are a poor fit for mobile; a fast server-side denylist check, backed by short certificate lifetimes as the ultimate backstop, is the more practical combination.
- Ignoring proxy and network-middlebox compatibility. mTLS failures caused by a corporate proxy or captive portal stripping client-certificate negotiation are hard for a user to self-diagnose and can look like a generic connectivity failure; plan for graceful degradation or a clear error message rather than a silent hang.
- Forgetting that mTLS proves device identity, not user identity. A stolen-but-unlocked device with valid hardware-backed mTLS credentials still needs user-level authentication (password, biometric-gated key access) layered on top; mTLS alone answers "is this the enrolled device," not "is this the account owner using it."
Describe XML External Entity (XXE) attacks: how attackers craft them, what parser configurations make an application vulnerable, and typical impacts such as local file disclosure, SSRF, and port scanning. Explain concrete mitigations including parser hardening (disabling DTDs and external entity resolution), preferring safer data formats where possible, and egress restrictions as defense in depth.
Sample Answer
Direct answer: XML External Entity (XXE) attacks exploit XML parsers that, by default, resolve external and internal entity declarations in a document's DOCTYPE, letting an attacker read local files, perform SSRF, or exhaust resources through entity expansion - all before the application's own logic ever processes the "data" the XML was supposed to carry.
How attackers craft them. The XML spec allows a document to declare custom entities in its DOCTYPE, including ones that reference external resources:
<?xml version="1.0"?>
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<root>&xxe;</root>
A parser configured to resolve external entities substitutes &xxe; with the actual file contents before returning the parsed document to the application - the file-read happens silently, inside the parsing step, with no application code involved at all. This is the DEFAULT configuration for some widely-used XML libraries, most notably Java's DocumentBuilderFactory, which resolves external entities by default unless it is explicitly hardened. It is worth being precise about which libraries, though: the common assumption that all XML parsers are vulnerable out of the box is also wrong. Python's standard-library xml.etree.ElementTree and modern lxml are both safe against this exact payload by default: ElementTree.fromstring raises xml.etree.ElementTree.ParseError: undefined entity, and lxml.etree.fromstring with its default parser raises lxml.etree.XMLSyntaxError: Entity 'xxe' not defined; lxml only becomes vulnerable if a caller explicitly opts in with XMLParser(resolve_entities=True). The practical lesson is to verify the specific library and its actual default configuration rather than assuming based on the language.
Vulnerable parser configurations. The vulnerability isn't in the XML format itself; it's in a parser left at its permissive default. Any XML-consuming code path is at risk if the parser hasn't been explicitly hardened: SOAP endpoints, file-upload handlers that accept XML/SVG/DOCX (which are XML internally), and RSS/Atom feed parsers are all common real-world XXE entry points that don't look like "an XML parser" at first glance.
Typical impacts:
- Local file disclosure: reading
/etc/passwd, application config files containing secrets, or source code. - SSRF: an entity referencing
http://internal-service/makes the vulnerable server issue that request, inheriting all the same internal-network-reach risk as classic SSRF. - Port scanning: timing differences on entity resolution against different internal hosts/ports can map internal network topology.
- Billion-laughs / entity expansion denial of service: nested entity definitions that each reference several copies of the previous one can expand a tiny XML document into gigabytes in memory, exhausting the server.
Mitigations, verified. I confirmed by execution that disabling DOCTYPE processing entirely (disallow-doctype-decl on the parser factory, or an equivalent hardened configuration/library) rejects the exact payload above outright with a parse error, before any entity is ever resolved - a stronger fix than trying to selectively allow "safe" entities, since it removes the whole feature class rather than trying to filter it correctly. Additional layers: prefer data formats with no entity mechanism at all where the use case allows it (JSON has no equivalent construct), and apply egress restrictions as defense in depth against the SSRF variant specifically.
Trade-offs and pitfalls: disabling DTD processing entirely breaks any LEGITIMATE use of custom entities (rare, but it exists in some document-generation pipelines); the practical response when that's a genuine requirement is to use a different, safer mechanism for that specific need rather than leaving entity resolution enabled application-wide.
Propose a comprehensive mitigation strategy for insecure deserialization across Java, Python, and Node services. Cover code-level patterns (type whitelisting, safe serializers, schema validation), runtime protections (serialization filters, sandboxing, capability restrictions), how you would detect both in source code and at runtime, recommended libraries and formats, and a pragmatic, incremental migration plan for legacy services that currently rely on native serialization.
Sample Answer
Direct answer
A cross-language deserialization strategy needs three independent layers, because no single layer covers every service and every failure mode: code-level patterns (type whitelisting, safe serializers, schema validation) close the vulnerability at the point of deserialization; runtime protections (serialization filters, sandboxing, capability restrictions) catch what the code-level layer misses or has not yet been applied to; and detection at both the source-code and runtime level tells you which services still need attention and flags exploitation attempts against the ones that do. None of Java, Python, or Node.js has this problem solved by simply "using a safer library," because the underlying risk (a format that reconstructs live, method-bearing objects from untrusted bytes) exists in some form in all three ecosystems; the fix has to be applied per-language with language-appropriate tooling, coordinated under one cross-cutting policy, and rolled out on a migration timeline that recognizes some services cannot be rewritten quickly.
Structured elaboration
Code-level patterns, per language.
| Language | Native risk | Type whitelisting | Safe serializer / format | Schema validation |
|---|---|---|---|---|
| Java | ObjectInputStream.readObject() on untrusted bytes | java.io.ObjectInputFilter (JDK 9+, JEP 290): allow-list classes, cap graph depth/size, before construction | Prefer JSON (via a concretely-typed, non-polymorphic Jackson mapper) or Protocol Buffers over native serialization | JSON Schema or Protobuf's own schema, validated before field access |
| Python | pickle.loads() on untrusted bytes (arbitrary code via __reduce__) | No built-in filter equivalent; must avoid pickle for untrusted input entirely, or use a restricted unpickler that overrides find_class() to allow-list module/class names | json for interoperable data; msgpack for compact binary with no code-execution surface | pydantic/jsonschema validating structure and types before use |
| Node.js | JSON.parse with a reviver that reconstructs class instances; third-party libraries (node-serialize and similar) that eval-reconstruct objects | No native concept of type-restricted deserialization for JS objects; the fix is almost always "do not use a library that reconstructs arbitrary prototypes from a string," rather than restricting one | Plain JSON.parse (no reviver, no prototype reconstruction) is inherently safe against this specific class, since it only ever produces plain objects/arrays/scalars | zod/ajv validating the parsed structure against a declared schema before use |
The pattern that repeats across all three: the safest fix is not a smarter deserializer, it is removing the deserializer's ability to construct arbitrary types at all, either by restricting it (Java's filter, a Python restricted unpickler) or by choosing a format that structurally cannot express "construct this class" in the first place (JSON without a reviver/polymorphic-type extension, Protocol Buffers, MessagePack).
Runtime protections, beyond the code-level fix.
- Serialization filters (Java's
ObjectInputFilter, applied per-call-site or JVM-wide via thejdk.serialFiltersystem property) are the one runtime protection with first-class platform support; there is no exact Python or Node.js equivalent, which is precisely why avoiding native, unrestricted deserialization of untrusted data is the stronger recommendation in those ecosystems rather than "add a runtime filter." - Sandboxing the deserializing process itself (a dedicated, minimally-privileged container or process with no filesystem write access beyond a scratch directory, no outbound network access beyond what is strictly required, and a restrictive seccomp/AppArmor profile on Linux) limits what a successful gadget chain can actually do even if the code-level and filter layers both fail. This is language-agnostic and is the right investment specifically for legacy services where the code-level fix cannot be applied quickly.
- Capability restrictions at the process or service-account level (no ambient cloud credentials beyond what the specific service needs, network policy denying egress to sensitive internal services) bound the blast radius of a successful exploit, independent of language; this is the same "least privilege" principle that limits SSRF (Server-Side Request Forgery) impact by bounding what a compromised process can reach, applied here to the process that would run attacker-controlled code after a successful gadget chain.
Detecting the pattern in source code. A static, repository-wide sweep for the risky call shapes is cheap and should run in Continuous Integration (CI) as a blocking check going forward, not just a one-time audit:
- Java: grep/AST-search for
new ObjectInputStream(not immediately followed by asetObjectInputFiltercall in the same method or a shared wrapper; flag anyreadObject()override introduced in application code, since that is where gadget-chain terminal steps or unexpected side effects tend to live. - Python: grep for
pickle.loads,pickle.load,yaml.load(withoutLoader=SafeLoader), and any use ofeval/execreachable from deserialized data. - Node.js: grep for
node-serialize,serialize-javascriptused for deserialization (not just serialization), or any customJSON.parsereviver that calls a constructor based on a field value in the parsed data. - Cross-language: a dependency audit flagging known-vulnerable versions of common gadget-adjacent libraries (Commons Collections below the patched line in Java; specific PyYAML versions; specific
node-serializeversions) even where the application does not call the dangerous function directly, since a transitively-included vulnerable class is still a usable gadget if reachable.
Detecting exploitation attempts at runtime. This complements the source-code sweep by catching what static analysis cannot: attempts against services that have not yet been fixed, and attempts using gadget chains not yet catalogued.
- Structured logging of every deserialization call site's outcome (success, filter-rejection, exception type), correlated centrally rather than per-service, so a spike in rejections or
ClassNotFoundException/InvalidClassException-shaped errors across multiple services in a short window reads as a probing campaign, not isolated noise. - Runtime instrumentation (application performance monitoring (APM) traces, or for Java specifically, a Java Agent hooking
ObjectInputStream.resolveClass) flagging any class resolution during deserialization that does not match the service's expected, allow-listed type set, as a defense-in-depth detection layer even where the filter itself should already be blocking it. - Signing serialized data as an additional mitigation. Where a service must accept serialized data across a trust boundary it does not fully control (a partner integration, a long-lived cache written by an older version of the same service), attaching a message authentication code (MAC) or signature computed at write time, and verifying it before deserialization is attempted at all, adds a check that runs before any deserialization logic, catching tampering even against a payload that would otherwise pass the type allow-list (a legitimate-typed but attacker-modified field value). This is a narrower, complementary control, not a replacement for type restriction: it protects data integrity between trusted writers and readers, not against a malicious writer who has valid signing credentials.
Recommended libraries and formats, by priority. Preferring interoperable, non-executable formats over language-native serialization is the single highest-leverage decision available across all three languages, because it removes the vulnerability class structurally rather than mitigating it after the fact: Protocol Buffers or JSON Schema-validated JSON for structured, cross-service data; MessagePack where compactness matters and the data has no cross-service schema evolution need; and language-native serialization reserved only for genuinely same-process, same-version, trusted-boundary use cases (in-memory caching within a single service, for example) where the security boundary the vulnerability depends on does not actually exist.
Pragmatic, incremental migration plan for legacy services on native serialization. A "rewrite everything to Protocol Buffers" plan is usually not fundable or fast enough, so sequence the work by risk, not by convenience:
- Inventory every deserialization call site across every service and language, using the source-code detection sweep above, and rank services by two independent factors: network exposure (internet-facing or reachable from a low-trust zone scores highest) and the privilege/data sensitivity of the service if compromised.
- Apply the cheapest, lowest-risk mitigation first, everywhere it applies, before attempting any format migration: Java services get
ObjectInputFilter(a config-level change, no data-format migration required) or the JVM-widejdk.serialFilteras a stopgap; Python services get an immediate audit forpickle/unsafeyaml.loadon untrusted input with a restricted unpickler as an interim fix; this step alone typically closes the highest-severity gap across most of the fleet within weeks, not the months a format migration takes. - Migrate the highest-risk services (internet-facing, high-privilege) to a neutral format first, on a service-by-service basis, since a format migration is a breaking wire-protocol change and needs coordinated versioning with every consumer of that service's serialized data, not a fleet-wide simultaneous cutover.
- Use a dual-read/dual-write transition period per service: accept both the old and new format for a defined window, write only the new format, and monitor for consumers still sending the old format before removing support for it, which avoids a hard cutover that breaks any consumer the migration inventory missed.
- Track remaining native-serialization services as a standing, visible risk register item, not a closed finding, until the migration completes; the interim mitigations from step 2 reduce risk but do not eliminate the underlying exposure, and treating step 2 as "done" is how legacy risk quietly becomes permanent.
For Site Reliability Engineering (SRE) specifically, this maps most directly to steps 2 and 5: rolling out ObjectInputFilter/jdk.serialFilter as a low-risk, broadly-applicable configuration change is an operational deployment concern (staged rollout, monitoring for unexpected rejections breaking legitimate traffic) more than a code-review concern, and maintaining the standing risk register and the detection telemetry from the runtime-protections section above is squarely an SRE ownership area even where the actual code migration is owned by each service team.
Worked example
A concrete inventory result: Service A (Java, internet-facing payment webhook receiver, uses ObjectInputStream on the raw webhook body) ranks highest risk on both axes and gets ObjectInputFilter applied within the first sprint (step 2) and scheduled for a Protocol Buffers migration in the current quarter (step 3). Service B (Python, internal batch job reading pickle-serialized intermediate results written by a trusted upstream step in the same pipeline, no external network exposure) ranks low on network exposure; it still gets the restricted-unpickler stopgap (cheap, step 2) but is deprioritized for a full format migration, since the actual trust boundary the vulnerability depends on (an untrusted writer) does not exist in this specific data flow. This is the point of ranking by risk rather than migrating uniformly: the same underlying pattern (native serialization) gets a different, proportionate response depending on where the trust boundary actually sits.
Trade-offs and pitfalls
- Treating the filter/stopgap layer as the finished state. As the migration plan's step 5 stresses,
ObjectInputFilterand a restricted Python unpickler both reduce risk substantially and cheaply, but they are still gating a fundamentally dangerous primitive rather than removing it; a maintenance lapse (someone widens the allow-list "temporarily" to unblock a deploy, and it is never narrowed back) silently reopens the hole. - Migrating format without migrating trust. Switching to Protocol Buffers or JSON Schema validation closes the code-execution risk but does not itself add integrity or authenticity checking; a service that genuinely needs to know its data was not tampered with in transit still needs the signing/MAC layer described above, independent of format choice.
- Applying uniform urgency across every service. Not every native-serialization use case sits at a real trust boundary (Service B above); spending migration budget uniformly instead of risk-proportionately means the highest-risk service (Service A) waits in the same queue as a genuinely low-risk internal batch job, which is the opposite of what a security-driven migration plan should optimize for.
- Forgetting that Node.js's risk lives in library choice, not language primitive. Unlike Java and Python, plain JavaScript has no built-in "deserialize arbitrary object graph" primitive; the risk is entirely a function of which third-party library a team reached for. An audit that only checks "does this service call something named
deserialize" can miss libraries that use different naming, so the dependency-level check (what is actually inpackage.json, not just what the application code calls directly) matters more here than in the other two languages.
Define insecure deserialization, describe how it leads to remote code execution or a logic-bypass, and list the common language-specific risks (Java native serialization, Python pickle, PHP unserialize()). Explain where in an application deserialization typically happens (cookies, RPC calls, message queues), recommend secure design patterns and runtime mitigations, and note the detection signals you would look for in application logs and crash traces.
Sample Answer
Direct answer: Insecure deserialization happens when an application reconstructs an object from untrusted byte data using a mechanism that can be tricked into instantiating arbitrary classes or invoking arbitrary methods as a side effect, letting an attacker achieve remote code execution or bypass application logic without the application's own code ever intentionally calling anything malicious.
How it leads to RCE or a logic bypass. Deserialization mechanisms like Java's native serialization, Python's pickle, and PHP's unserialize() are designed to reconstruct arbitrary object graphs, which means they can call constructors, setters, and "magic methods" (__reduce__ in Python, readObject() in Java, __wakeup() in PHP) automatically during the reconstruction process - a data format that CAN execute code by definition can be steered into executing the WRONG code by an attacker who controls the byte stream. Even without full RCE, tampering with a serialized object's fields (an admin flag, a price, a permission level) before it's deserialized back can bypass application logic that assumed the object was only ever produced by the application's own trusted serialization step.
Language-specific risks: Java native serialization is exploited via "gadget chains" - sequences of otherwise-legitimate classes already present on the classpath (from common libraries) whose methods, when chained together during deserialization, achieve code execution the developer never intended. Python's pickle explicitly supports arbitrary callable invocation via __reduce__ by design, which is why the Python documentation itself warns never to unpickle untrusted data. PHP's unserialize() similarly invokes magic methods on class reconstruction, and PHP-specific "POP chain" (property-oriented programming) techniques chain together classes already loaded by the application to the same effect.
Where deserialization typically occurs, often less obviously than a dedicated "deserialize" API call: session storage (a serialized session object read back on every request), inter-service message queues (one service serializes an object, another deserializes it), cookies used to persist client state, and RPC/remote object protocols.
Secure design patterns and mitigations:
- Prefer data-only formats (JSON, Protocol Buffers) with no code-execution surface at all, wherever the use case allows - this is the strongest fix, since it removes the vulnerability class structurally rather than trying to use a code-capable format safely.
- If a code-capable format must be used, apply strict type allowlisting so only explicitly-trusted classes can be instantiated during deserialization, never accepting "whatever class the byte stream names."
- Runtime mitigations: sandboxing/isolating the deserialization step, and monitoring for deserialization exceptions or unexpected class-instantiation patterns as a detection signal.
A small, concrete trace of the mechanism, executed. Real gadget chains are hard to show in full (they typically chain several existing classes together), but the core mechanism - that reconstructing an object can trigger an arbitrary call, not just populate fields - is easy to demonstrate directly and I ran this:
class CacheWarmer:
def __reduce__(self):
# pickle calls __reduce__ automatically while RECONSTRUCTING the
# object, and __reduce__ is free to name any callable with any
# arguments - a real gadget reuses a class already on the classpath
# for a legitimate reason, choosing which already-present callable
# to invoke rather than injecting new code.
return (print, ("[gadget fired] code ran during deserialization",))
malicious_bytes = pickle.dumps(CacheWarmer()) # 86 bytes on the current default pickle protocol, looks like ordinary data
pickle.loads(malicious_bytes) # the victim app just wants to load a cached object
Running this prints [gadget fired] code ran during deserialization at the pickle.loads() line itself, before the victim application's own code ever runs anything - confirming the call happened as a side effect of reconstruction, not because the application explicitly invoked print. A real Java gadget chain follows the identical shape with readObject() instead of __reduce__, and a PHP POP (property-oriented programming) chain follows it with __wakeup()/__destruct(): an attacker who cannot inject new code can still reach a dangerous outcome (a real attack typically ends at something like a file write, a command execution primitive, or a class constructor with a serious side effect) by choosing which already-loaded class's magic method fires next, then which method THAT one calls, walking through classes already present in the application rather than introducing any new code of its own - "chain" refers to that sequence of hops through existing code, each one legitimate in isolation.
Detection signals in logs/crash traces: unexpected ClassNotFoundException/InvalidClassException-style errors (an attacker probing with class names that don't exist on the classpath), unusually large serialized payloads, or a spike in deserialization exceptions correlated with requests from a single source.
Trade-offs and pitfalls: type allowlisting has to be maintained as the application's legitimate object model evolves, and a too-broad allowlist (allowing a class merely because it's "already used somewhere in the app") can still admit a usable gadget if that class happens to have a dangerous side effect in its constructor or setters - the allowlist needs review, not just existence.
You need to fuzz a JSON-based REST API to discover input-validation and parsing bugs. Create a fuzzing plan: what you would target (field types, boundary values, malformed JSON, encoding edge cases), what harness or tooling you would use, and how you would triage crashes or unexpected behavior into real findings versus noise.
Sample Answer
Direct answer
A fuzzing plan for a JSON REST API needs to deliberately cover four target categories, field types, boundary values, malformed JSON, and encoding edge cases, rather than throwing unstructured random bytes at the endpoint, since structure-aware mutation that respects the API's own schema finds far more real bugs per hour than blind fuzzing does. The harness should generate or mutate inputs against that schema and log every input alongside its result, not just the failures, so any finding is independently reproducible afterward. Triage means grouping raised exceptions by root cause, not raw count, and clearly separating "this is a real defect" from "the input was cleanly rejected as invalid," since counting every non-success response as a finding buries the bug that actually matters in expected validation noise.
Structured elaboration
What to target
- Field types: swap each field's expected type for a wrong one, a string where an integer is expected, an object where a string is expected, an array where a scalar is expected,
nullfor a required field. This tests whether the handler validates type before using the value, or trusts it and misbehaves when it does not match. - Boundary values: for numeric fields, zero, negative numbers, the documented minimum and maximum exactly at the boundary and one step past it, and very large magnitudes near the language's integer limits; for string fields, an empty string, the maximum allowed length exactly and one character past it, and very long strings to probe for resource exhaustion rather than only logic bugs. Boundary testing is where off-by-one validation bugs concentrate, exactly the class of bug that slips past code review because the code reads correct at a glance.
- Malformed JSON: truncated payloads, trailing commas, duplicate keys (different parsers resolve these inconsistently), deeply nested structures, and a wrong top-level type (an array where an object is expected). This tests the parsing layer itself, before any application-level validation runs at all, and whether a parse failure is handled as a clean client error or crashes the process.
- Encoding edge cases: non-ASCII and Unicode content in string fields, including characters outside the basic multilingual plane such as emoji, since encoding bugs concentrate exactly at those code-point boundaries; embedded null bytes inside strings; and differing Unicode normalization forms of visually identical strings. This tests whether every downstream system the value touches, a database, a search index, a log, handles the same logical string consistently regardless of its exact byte-level encoding.
Harness and tooling
Prefer structure-aware (schema-guided) fuzzing over blind byte-level fuzzing: generate or mutate inputs that respect the API's own schema, correct JSON shape, correct field names, while deliberately violating values, types, and boundaries within that shape. A target with input validation at the parsing layer rejects the overwhelming majority of purely random byte mutations before they ever reach the interesting application logic, which wastes fuzzing budget on the layer least likely to be the actual bug. Concrete tooling: a coverage-guided fuzzer paired with a JSON-aware mutator, built around the target's OpenAPI or JSON-schema definition, or a property-based testing library that can generate structured cases from that schema; or, when a full coverage-guided setup is more infrastructure than the target justifies, a purpose-built harness that directly enumerates the four categories above against a pinned seed, as demonstrated below. Log every input alongside its result, not only the failures, with a stable, recorded seed, so any finding reproduces from the log alone, and so the noise ratio (the fraction of trials cleanly rejected versus raising an exception versus succeeding) can actually be computed rather than guessed at.
Triage: separating crashes from noise
First pass: sort results into three buckets, handled cleanly (a structured validation error, expected and correct), succeeded (a legitimate accepted case), and unhandled exception (a genuine crash or server-error response). Only the third bucket needs further triage attention; a fuzzer configuration that treats every non-success response as a finding buries real bugs under expected, correct validation rejections. Second pass, within the unhandled-exception bucket: group by exception type and stack signature, not by raw occurrence count, since many distinct inputs frequently trigger the exact same underlying bug, a missing-field check produces the same error regardless of which specific malformed payload triggered it. The number of distinct signatures, not the raw crash count, is what tells you how many actual bugs were found. Third pass: for each distinct signature, assess severity by what the exception actually reveals or allows, whether the error response leaks a stack trace with internal paths or version information (an information-disclosure finding on its own, even if the underlying bug is otherwise minor), and whether the crash corresponds to a data-integrity issue such as a partial write before the failure, which is materially more severe than a request that simply fails cleanly with nothing persisted.
Worked example
A toy JSON order-handling endpoint with a real, subtle boundary bug (validated as -1 <= quantity, a typo for the intended 1 <= quantity, so quantity = 0 slips through and reaches a division), plus an ASCII-only downstream write that the encoding category trips. The harness below is the whole campaign: it generates all four target categories against a pinned seed, buckets every trial, and groups the failures by signature. It retains the offending input for every finding and counts the other two buckets; a real campaign persists all three to a log file.
import json
import random
SEED, TRIALS = 20260727, 500
def handle_order(raw_body, sku_fixed=False):
"""Target under test. Carries one deliberate boundary bug."""
payload = json.loads(raw_body) # raises on malformed JSON
quantity = payload["quantity"] # raises on a missing field
total_cents = payload["total_cents"]
sku = payload["sku"]
if sku_fixed: # round 2 only: shallow bug fixed
sku = sku.encode("ascii", "replace").decode()
sku.encode("ascii") # round 1: raises on non-ASCII
if not (-1 <= quantity <= 10_000): # BUG: should be 1 <= quantity
return {"error": "quantity out of range"} # the clean-rejection path
return {"unit_price_cents": total_cents // quantity}
# One list per target category. rng.random() is the only RNG call used, because it is
# the one call the standard library guarantees stable across Python versions.
TYPES = ["7", None, [7], {"n": 7}] # field types
BOUNDS = [-2, -1, 0, 1, 2, 9999, 10_000, 10_001, 2**63] # boundary values
MALFORMED = ['{"quantity": 1, "total_cents": 500', # truncated
'{"quantity": 1, "total_cents": 500,}', # trailing comma
'[{"quantity": 1}]', # wrong top-level type
'{"quantity": 1, "quantity": 2, "sku": "A"}'] # duplicate key
SKUS = ["SKU-1", "SKU-2", "SKU-3", "SKU-\x00", "SKU-\xe9", "SKU-\U0001f600"] # encoding
DROP = ["quantity", "total_cents", "sku"]
def campaign(sku_fixed=False, seed=SEED, trials=TRIALS):
rng = random.Random(seed)
pick = lambda seq: seq[int(rng.random() * len(seq))]
ok = rejected = 0
groups = {}
for _ in range(trials):
bucket = int(rng.random() * 5)
if bucket == 4:
body = pick(MALFORMED)
else:
case = {"quantity": pick(BOUNDS), "total_cents": pick(BOUNDS), "sku": pick(SKUS)}
if bucket == 1:
case["quantity"] = pick(TYPES) # field-type category
elif bucket == 2:
case.pop(pick(DROP)) # missing required field
body = json.dumps(case)
try:
result = handle_order(body, sku_fixed)
except Exception as exc: # broad by design: this is a fuzz harness
groups.setdefault(type(exc).__name__, []).append((str(exc), body))
else:
rejected += "error" in result
ok += "error" not in result
return ok, rejected, groups
def report(label, sku_fixed):
ok, rejected, groups = campaign(sku_fixed)
found = sum(len(v) for v in groups.values())
print(f"{label}: trials={TRIALS} seed={SEED}")
print(f" handled_ok={ok} noise(cleanly rejected)={rejected} "
f"findings(unhandled)={found} distinct signatures={len(groups)}")
for name, hits in sorted(groups.items(), key=lambda kv: -len(kv[1])):
print(f" {name}: {len(hits)}x error={hits[0][0][:44]!r}")
print(f" example input={hits[0][1][:56]!r}")
return groups
g1 = report("ROUND 1 (target as written)", sku_fixed=False)
print()
g2 = report("ROUND 2 (shallow encoding bug fixed, same seed)", sku_fixed=True)
print()
print("ZeroDivisionError round 1 ->", len(g1.get("ZeroDivisionError", [])),
"| round 2 ->", len(g2.get("ZeroDivisionError", [])))
Actual output from running the script above:
ROUND 1 (target as written): trials=500 seed=20260727
handled_ok=63 noise(cleanly rejected)=38 findings(unhandled)=399 distinct signatures=5
KeyError: 136x error="'quantity'"
example input='{"total_cents": 1, "sku": "SKU-\\u0000"}'
UnicodeEncodeError: 103x error="'ascii' codec can't encode character '\\xe9' "
example input='{"quantity": 10001, "total_cents": 1, "sku": "SKU-\\u00e9'
TypeError: 93x error="'<=' not supported between instances of 'int"
example input='{"quantity": [7], "total_cents": 10001, "sku": "SKU-2"}'
JSONDecodeError: 53x error='Illegal trailing comma before end of object:'
example input='{"quantity": 1, "total_cents": 500,}'
ZeroDivisionError: 14x error='division by zero'
example input='{"quantity": 0, "total_cents": 1, "sku": "SKU-3"}'
ROUND 2 (shallow encoding bug fixed, same seed): trials=500 seed=20260727
handled_ok=98 noise(cleanly rejected)=66 findings(unhandled)=336 distinct signatures=4
KeyError: 136x error="'quantity'"
example input='{"total_cents": 1, "sku": "SKU-\\u0000"}'
TypeError: 126x error="'<=' not supported between instances of 'int"
example input='{"quantity": [7], "total_cents": 10001, "sku": "SKU-2"}'
JSONDecodeError: 53x error='Illegal trailing comma before end of object:'
example input='{"quantity": 1, "total_cents": 500,}'
ZeroDivisionError: 21x error='division by zero'
example input='{"quantity": 0, "total_cents": 1, "sku": "SKU-3"}'
ZeroDivisionError round 1 -> 14 | round 2 -> 21
Each of the four target categories produced its own distinct signature: boundary values found the ZeroDivisionError at quantity == 0, field types found the TypeError on the comparison, malformed JSON found the JSONDecodeError, and encoding edge cases found the UnicodeEncodeError on the non-ASCII SKU. A fifth signature, KeyError, came from dropping a required field.
Applying the triage passes above to round 1: 500 raw trials produce 399 unhandled exceptions, which collapse to exactly 5 distinct signatures. Raw occurrence count ranks KeyError (136) first and ZeroDivisionError (14) last, which is precisely why raw count is the wrong ranking. The KeyError group is one validation-hardening gap (a missing field should return a clean structured error, not an unhandled exception that likely surfaces as a 500), while the 14 ZeroDivisionError occurrences are the one genuine logic defect, and all 14 share a single root cause rather than being 14 separate bugs.
Round 2 re-runs the identical seed after fixing only the shallow encoding bug. The UnicodeEncodeError signature disappears and 63 trials leave the crash bucket, but 40 of them do not become passes: ZeroDivisionError rises from 14 to 21 and TypeError from 93 to 126, because those inputs had been dying on the SKU encode before they ever reached the quantity check. The remaining 63 split 35 into handled_ok and 28 into clean rejections. That is the masking effect made measurable: the shallow bug was costing depth of coverage, not just adding noise.
Trade-offs and pitfalls
- A fuzzer that keeps finding the same bug over and over can look unproductive by raw finding count but is telling you something useful: fix the root cause once, then re-run the same campaign, which is part of the process, not a one-shot exercise, since a deeper layer of bugs may only become reachable once the shallow, frequently-triggered one is gone. Round 2 above measures exactly that: removing one shallow encoding bug pushed 40 further trials deep enough to reach the two defects sitting behind it.
- Purely random, non-schema-aware fuzzing against a JSON API mostly stresses the parser, often the most hardened layer already in a mature JSON library, so a campaign that only does this under-invests relative to a schema-aware approach that reaches deeper into application logic.
- Treating "no crashes found" as "no bugs" is a common wrong turn. The absence of an unhandled exception on the inputs tried does not mean the boundary or business logic is correct, only that none of those specific inputs happened to throw. The same broken check proves it:
{"quantity": -1, "total_cents": 500, "sku": "SKU-1"}also slips past-1 <= quantity, and rather than raising it returns a perfectly successful{'unit_price_cents': -500}, a negative unit price that this exception-only harness counts underhandled_okand never surfaces at all. - 500 trials is a small campaign, chosen here for a readable worked example; a real engagement runs orders of magnitude more, and a coverage-guided fuzzer specifically prioritizes inputs that reach code paths not yet exercised rather than repeatedly re-testing already-covered ones, which matters far more at that scale than it does at the toy scale shown here.
Unlock Full Question Bank
Get access to all Secure Coding and Application Security interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.