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.
Design an end-to-end runtime detection and response architecture to detect OWASP Top Ten exploitation events for a multi-tenant SaaS with 5 million monthly users. Cover instrumentation choices (RASP, WAF, application logs), telemetry pipelines, correlation rules, alerting thresholds and false-positive management, and the performance/cost trade-offs. Specify concrete components and how alerts flow to the operations team, including the SOC triage workflow and the feedback loop that gets tuning changes back to engineering.
Sample Answer
Direct answer
At 5 million monthly active users (MAU) across many tenants, runtime detection has to be layered by cost: cheap, coarse filtering at the edge (web application firewall, WAF), precise but heavier instrumentation inside the app (Runtime Application Self-Protection, RASP), and structured application logs everywhere else, all feeding one telemetry pipeline that correlates events, scores severity, and routes to the right response channel automatically. The design goal is not "detect everything with equal fidelity"; it is "spend the expensive, low-latency-budget instrumentation only on the highest-value signals," while making sure every alert that reaches a human carries enough context to act on immediately.
Structured elaboration
Instrumentation choices
- Edge WAF: deployed at the content delivery network (CDN) or load-balancer layer, running managed rule sets aligned to the Open Worldwide Application Security Project (OWASP) Top Ten (current 2025 edition) plus custom rules for tenant-specific abuse patterns. Cheapest per-request cost, coarsest signal; catches obvious injection and cross-site scripting (XSS) payloads before they reach the application.
- RASP: an in-process agent on application instances that has runtime context a WAF cannot see (the actual SQL statement about to execute, the authenticated user and tenant, the call stack). Because it adds per-request CPU (central processing unit) overhead, deploy it selectively: full instrumentation on high-value flows (authentication, payments, admin actions), sampled instrumentation elsewhere.
- Application logs: structured, tenant-tagged events for authentication outcomes, authorization decisions, and unusual query shapes, forming the baseline signal for anything neither the WAF nor RASP is positioned to see (business-logic abuse, for example).
Telemetry pipeline
- Ingest: WAF logs, RASP events, and structured app logs are shipped to a durable, ordered ingest layer (a log/event streaming system) so a burst of traffic cannot drop events.
- Enrichment: a stream processor attaches
tenant_id, geo, and a running per-request risk score before anything reaches storage, so downstream correlation and per-tenant filtering do not need to re-derive context from raw log lines. - Correlation and storage: enriched events land in a Security Information and Event Management (SIEM) system with both indexed hot storage (for active investigation) and cheaper cold storage (for the retention window required for forensic readiness, typically twelve months or more for authentication and access-denial events, and a much shorter window, days to a few weeks, for full raw request bodies).
flowchart LR
U[Client traffic] --> WAF[Edge WAF]
WAF --> APP[App instances with RASP agent]
APP --> LOGS[Structured app logs]
WAF --> STREAM[Ingest stream]
APP --> STREAM
LOGS --> STREAM
STREAM --> ENRICH[Enrichment: tenant id, geo, risk score]
ENRICH --> SIEM[SIEM correlation rules]
SIEM -->|high severity| PAGE[Auto-block plus page on-call]
SIEM -->|medium severity| SOC[SOC triage queue]
SIEM -->|low severity| DASH[Daily dashboard]
SOC -->|confirmed true positive| TUNE[Tuning change proposal]
TUNE --> ENG[Engineering: rule and code fix]
ENG -->|deployed fix| APP
Correlation rules and example alert types
Rules should chain signals rather than fire on a single event, since a single blocked WAF request is usually noise:
- SQL injection escalation: a WAF signature match on a request, followed by a RASP exception trace showing an unparameterized query on the same request, escalates to high severity (two independent layers agreeing raises confidence sharply).
- Account-takeover chain: several failed authentications against the same account from diverging geographies, followed by one success and then an immediate high-privilege action (password change, payout destination change) from that session, escalates even though no individual event looks severe alone. This is deliberately modeled alongside the SQL-injection chain above, not as an afterthought, because account takeover is typically the highest-volume real exploitation category in a consumer SaaS and rarely trips a WAF rule at all.
- Example SIEM alert types produced by these rules:
injection.confirmed(WAF and RASP agree),injection.suspected(WAF only, RASP silent because the flow was not instrumented),ato.chain_detected(the sequence above),logging.gap_detected(a tenant's expected authentication events stop arriving, itself a signal something upstream broke).
Alerting thresholds and severity routing
- High: auto-block at the WAF, page the on-call security engineer, open a tracked incident with the full event chain attached.
- Medium: route to a Security Operations Center (SOC) triage queue rather than paging immediately; a human confirms true/false positive within a defined service-level objective (SLO) before any auto-action.
- Low: aggregate into a daily dashboard; no individual alert.
False-positive management
- Alert-only mode for new rules for an initial baseline period before any rule is allowed to auto-block, so a bad rule degrades to noise instead of an outage.
- Feedback loop: every SOC triage decision (true positive, false positive, needs more context) writes back to the rule's tuning record. A rule with a sustained high false-positive rate is automatically demoted from auto-block to alert-only pending review, rather than staying a silent source of alert fatigue.
- Per-tenant thresholds: a single noisy tenant (a legitimate high-volume integration partner, for example) should not be able to drown out signal for every other tenant; thresholds and suppression windows are scoped per tenant, not global.
Performance and cost trade-offs
- WAF filtering is cheap per request and belongs on the highest-volume, coarsest-grained rules; RASP is precise but adds real per-request latency and CPU cost, so it is reserved for flows where a missed detection is expensive (auth, payments, admin) rather than applied uniformly across all traffic.
- Full-fidelity indexed SIEM storage is expensive at 5 million MAU scale; the standard trade-off is indexing a smaller set of high-value fields for fast search while keeping raw request/response bodies in cheap cold storage, pulled on demand during an actual investigation rather than kept hot by default.
- Chaining correlation rules (as in the account-takeover example) trades detection latency (a few extra seconds to observe the full sequence) for a large reduction in false positives compared to alerting on any single weak signal in isolation; at this scale that trade almost always favors correlation, since paging on every single weak signal would overwhelm the SOC.
Alert flow to the operations team and SOC triage workflow
A high-severity alert reaches the on-call engineer with the full correlated event chain, the tenant identifier, and a suggested action already attached, not just a raw log line; a medium-severity alert lands in a SOC queue where a triage analyst has a runbook per alert type (for injection.confirmed: confirm the payload against the flagged endpoint, escalate if the endpoint has no compensating control; for ato.chain_detected: force session revocation and notify the account owner). Multi-finding bursts (several distinct vulnerability classes triggering around the same time window, which usually signals either a coordinated attack or a broken deployment rather than unrelated coincidences) are triaged together under one incident rather than as separate tickets, so the SOC is reasoning about the likely single root cause instead of chasing five symptoms independently.
Feedback loop back to engineering
Every confirmed true positive that traces back to a specific code path (not just a rule tuning issue) generates a tracked engineering ticket with the exact request that triggered it, not a paraphrase, so the fix can be verified against the original trigger. Every rule that produces a sustained high false-positive rate generates a tuning ticket for the security engineering team, not the product team, keeping ownership clear.
Worked example
Concretely size one piece of this: at 5 million MAU, assume a conservative average of 5 authenticated requests/user/day, giving roughly 25 million authenticated requests/day (5,000,000 x 5 = 25,000,000) to log at minimum for authentication and authorization events alone. If each such event is a compact structured record of a few hundred bytes, that is on the order of a few gigabytes/day just for this one event category before enrichment or retention multiplication, which is why indexed hot storage is scoped to specific high-value event types (authentication, access-denial, WAF/RASP hits) rather than every request an application serves; this arithmetic is the concrete reason performance/cost trade-offs push toward selective instrumentation and tiered storage rather than "log and index everything."
Trade-offs and pitfalls
- Alerting on every single WAF block. At this scale that produces thousands of daily alerts with a poor signal-to-noise ratio; correlation across layers (as in the SQL-injection escalation example) is what makes the volume tractable for a human SOC team.
- Treating this architecture as the incident-response process itself. Detection, correlation, and initial triage routing are in scope here; deep breach containment, forensics, and executive communication during a confirmed active incident are a distinct discipline with its own playbooks and are intentionally out of scope for this design.
- Under-instrumenting RASP "to save cost" on the flows that matter most. The temptation is to sample RASP uniformly across all traffic to control overhead; the better trade is uneven sampling, near-100% on auth/payment/admin flows and light sampling elsewhere, since that is where a missed detection is most expensive.
- Letting the feedback loop stop at "rule tuned." A rule that keeps needing retuning against the same code path is usually pointing at a code-level fix (parameterize that query, add that authorization check) that would remove the need for the detection rule entirely; the feedback loop should route to engineering, not just to the rule configuration.
You review a Java service that accepts raw bytes over HTTP and deserializes them using ObjectInputStream, casting the result to an internal type. Describe the security risks this pattern introduces and how gadget chains enable remote code execution here. Propose concrete code-level mitigations and safer serialization alternatives, with code showing type allowlisting, an input-size limit, and avoiding polymorphic type resolution (for example with a library like Jackson). List the tests you would require in the pull request to verify the fix.
Sample Answer
Direct answer
ObjectInputStream.readObject() reconstructs whatever type the byte stream claims to contain and runs that type's readObject() method as part of reconstruction, before the caller's cast to the expected internal type ever executes, so the cast in this code provides no protection at all: by the time (InternalType) result runs (and potentially throws ClassCastException), the attacker's chosen class has already been fully constructed and any side effect in its readObject() has already happened. The security risk is that any Serializable class reachable on the classpath, not just the ones the application's own code references, is a candidate for a gadget chain (a sequence of such classes' methods chained together to reach something dangerous, most famously Runtime.exec()), and the fix is to restrict which classes the stream is even allowed to construct before it constructs anything, using either a type-allowlisting filter or a safer serialization format entirely.
Structured elaboration
Why the risk is exactly "the cast happens too late," proven, not asserted. The core misconception this code embodies is that type-checking the result of deserialization is a safety net; it is not, because the dangerous work (arbitrary code running inside readObject()) is already complete by the time any type check can run. This was verified directly rather than reasoned about in the abstract: a stand-in gadget class (Payload, whose readObject() prints a marker representing what a real gadget chain's terminal step would do) was serialized and fed through the exact pattern in the original code.
static InternalType handle(byte[] rawBytes) throws IOException, ClassNotFoundException {
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(rawBytes))) {
Object result = ois.readObject();
return (InternalType) result; // cast happens AFTER readObject already ran
}
}
$ java VulnerableServer
--- legitimate request ---
result: InternalType[orderId=ORD-1234]
--- malicious request ---
[PAYLOAD SIDE EFFECT] readObject() executed attacker logic for cmd="whoami": this ran BEFORE any cast/type check in the caller.
caught ClassCastException (class Payload cannot be cast to class InternalType (Payload and InternalType are in unnamed module of loader 'app')) -- but note the payload's side effect already printed above, BEFORE this exception. The cast check was too late to prevent it.
The [PAYLOAD SIDE EFFECT] line prints before the ClassCastException is even thrown, which is the concrete, observed proof that catching or checking the cast result buys you nothing: whatever the attacker's class does inside readObject() has already run. A real gadget chain does not print a marker; it typically walks through several intermediate, individually-innocuous library classes (this is what "gadget chain" refers to) until it reaches a class with an attacker-steerable reflective call, but the timing property demonstrated here (side effect before any caller-side check) is identical regardless of which specific classes make up the chain.
Code-level mitigation: type allow-listing via the JDK's native serialization filter. Since Java 9 (standardized via JEP 290, java.io.ObjectInputFilter), the platform ships a mechanism to restrict which classes, and how much data, an ObjectInputStream will accept, evaluated before any class is resolved or constructed, which is exactly the "before, not after" placement this bug needs:
static InternalType handle(byte[] rawBytes) throws IOException, ClassNotFoundException {
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(rawBytes))) {
// FIX: allow-list the one expected type, and cap graph depth,
// reference count, and stream size, all enforced BEFORE any class
// is resolved or readObject() is invoked.
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"InternalType;maxdepth=5;maxrefs=50;maxbytes=4096;!*"
);
ois.setObjectInputFilter(filter);
Object result = ois.readObject();
return (InternalType) result;
}
}
$ java FixedServer
--- legitimate request ---
result: InternalType[orderId=ORD-1234]
--- malicious request ---
REJECTED before readObject() ran: filter status: REJECTED -- no [PAYLOAD SIDE EFFECT] line above, because the filter blocked class resolution before any attacker code executed.
The [PAYLOAD SIDE EFFECT] line is entirely absent from the fixed run: the filter rejects the Payload class by name before readObject() is ever called on it, which is the observable difference between "checked too late" and "checked before construction." This also demonstrates the input-size limit in the same mechanism (maxbytes=4096, maxdepth=5, maxrefs=50), closing the denial-of-service angle (a maliciously deep or wide object graph) with the identical filter, not a separate control.
Safer serialization alternative: avoiding polymorphic type resolution, demonstrated with Jackson. The question names Jackson specifically as an example library; the mechanism worth demonstrating is not "use Jackson instead of native serialization" in the abstract, but the specific footgun that makes Jackson-based deserialization just as dangerous if misused: a field typed as Object (or a broad interface) combined with polymorphic type resolution that trusts a type hint embedded in the payload itself.
// UNSAFE shape: a field typed as Object, annotated so Jackson trusts an
// attacker-supplied "@class" property to pick the runtime type. This is the
// real-world pattern behind the jackson-databind polymorphic-deserialization
// CVE class (2017 onward): the field's declared type is too broad, and
// Id.CLASS lets the payload name any class on the classpath.
static class WrapperUnsafe {
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = "@class")
public Object payload;
}
// SAFE shape: same field, concretely typed to the actually-expected type.
// There is no type choice left for attacker input to make.
static class WrapperSafe {
public SafePayload payload;
}
$ java JacksonPolyDemo
--- UNSAFE: Object-typed field with Id.CLASS trusting the payload's @class ---
[DANGEROUS SIDE EFFECT] Dangerous() constructed by the deserializer because the attacker-supplied @class value named it.
payload resolved to attacker-chosen runtime type: Dangerous
--- SAFE: concretely-typed field, no type id trusted ---
SAFE mapper rejected the smuggled @class field (UnrecognizedPropertyException); Dangerous was never constructed.
With the attacker-controlled JSON {"payload":{"@class":"Dangerous","value":"x"}}, the unsafe mapper constructs the attacker-named Dangerous class purely because the field's declared type (Object) left the door open for a type hint to decide; the safe mapper, with the field concretely typed to SafePayload, never even considers Dangerous: Jackson rejects the unexpected @class property outright, and the class is never constructed. This is the JSON-native equivalent of "type allow-listing": the allow-list is implicit in a concrete field type, and "avoiding polymorphic type resolution" means never annotating an attacker-influenced field with Id.CLASS/enableDefaultTyping against a broad target type in the first place.
Input-size limit, restated for clarity. Demonstrated above as part of the ObjectInputFilter configuration (maxbytes, maxdepth, maxrefs), this closes a distinct risk from the type-confusion issue: even a payload of an allow-listed type can be crafted with a pathologically deep or wide object graph to exhaust memory or stack space during reconstruction, so the limit needs to be enforced regardless of whether the type itself is trusted.
Tests required in the pull request to verify the fix. A code review approving this change should require, at minimum:
- A regression test asserting the legitimate
InternalTypepayload still deserializes correctly (the fix must not silently break the happy path). - A test asserting that a serialized instance of any other class (not just a crafted gadget; even an innocuous but non-allow-listed class) is rejected by the filter before construction, ideally asserting on the absence of any side effect a test double's constructor or
readObject()would otherwise produce, exactly like the[PAYLOAD SIDE EFFECT]marker used above. - A test asserting the size/depth/reference limits actually reject an oversized or deeply-nested payload of the allow-listed type itself, not just wrong-typed payloads, since the DoS vector is orthogonal to the type-confusion vector.
- If migrating a field to Jackson with a broad declared type anywhere else in the codebase, a static-analysis or code-review checklist item flagging any
@JsonTypeInfo(use = Id.CLASS)oractivateDefaultTyping(...)call against a type that can be influenced by external input, since this specific pattern is the recurring root cause across the published jackson-databind CVEs.
Worked example
The three executed comparisons above are the worked example, each isolating one part of the fix: the VulnerableServer/FixedServer pair proves the timing claim (cast-after-construction is not a safety check, and a pre-construction filter is) and the size-limit claim in one mechanism; the JacksonPolyDemo pair proves the same "concrete type beats a trust-the-payload type hint" principle in the JSON/Jackson world named explicitly by the question. All four program runs shown are real, captured output from compiling and executing the code exactly as shown, not narrated expected behavior.
Trade-offs and pitfalls
- Believing a
catch (ClassCastException)is a mitigation. This is the exact bug in the original code, made concrete by the demonstration: the exception fires only after the dangerous work is already done. Any "defense" implemented afterreadObject()returns is defending against nothing. - Writing an allow-list filter as
"*"(allow everything) with limits only. Size and depth limits alone do not stop a correctly-sized, correctly-shaped payload of a genuinely dangerous class; the type allow-list and the resource limits are two independent controls addressing two independent risks (arbitrary code execution versus resource exhaustion), and both are required. - Enabling Jackson's default/polymorphic typing globally "to make deserialization easier" and forgetting where attacker input reaches an
Object-typed field. The unsafe pattern demonstrated here is rarely written deliberately; it usually arrives throughobjectMapper.activateDefaultTyping(...)being set once, globally, on a sharedObjectMapperinstance for an unrelated convenience use case, and then silently applying to every field across the codebase that happens to be typed broadly, including ones that later receive external input. - Migrating away from native serialization to JSON without re-checking for the same class of bug. Teams sometimes treat "we moved off
ObjectInputStream" as the fix in itself; as demonstrated, Jackson (or any polymorphic-capable serialization library) can reintroduce an equivalent vulnerability if the migration keeps a broadly-typed field and adds type-hint trust to preserve the old code's flexibility.
You discover an insecure-deserialization vulnerability in a microservice that consumes messages from a queue and forwards deserialized objects to other services written in different languages. Explain an exploitation plan for how an attacker could abuse the serialization formats involved to achieve remote code execution or privilege escalation across services, and propose architectural mitigations to prevent this cross-service gadget abuse.
Sample Answer
Direct answer
A microservice consuming queue messages and forwarding deserialized objects to other services written in different languages has a specific failure mode single-service gadget-chain analysis misses entirely: the exploitability of the payload depends on which service in the chain actually deserializes it, not on the service where the attacker's message was injected, so the attack surface is the union of every deserializer any hop in the pipeline might invoke, not just the first one. The exploitation plan is to identify the weakest deserializer anywhere downstream (not necessarily the entry point), craft a payload that survives whatever intermediate transformation the message undergoes en route, and land a gadget chain valid for that specific hop's language and library; the architectural fix has to break the chain at the message-format layer itself, since per-service serialization hardening alone leaves every other consumer of the same queue independently exposed to the same class of bug.
Structured elaboration
Why cross-service message-queue deserialization is a genuinely different threat model, not just "the same bug in more places." In a single-service scenario, the attacker controls the bytes and the same process that receives them also deserializes them, so the analysis is contained: one language, one classpath, one gadget catalogue to check. In a queue-mediated, multi-language pipeline, the attacker typically does not get to choose which consumer processes a given message; multiple services may subscribe to the same topic, each running a different language runtime with a different deserialization library, and each independently vulnerable or not vulnerable to a completely different gadget chain. A payload engineered against a Java consumer's ObjectInputStream will simply fail to parse in a Python or Node.js consumer using a different format, which means the practical attack has to either target the specific consumer known to be weakest, or (more dangerously) exploit a message format that multiple consumers all deserialize permissively, such as a shared JSON schema where one consumer's library has polymorphic-type deserialization enabled.
Exploitation plan.
- Map the message's full consumer graph, not just the producer. Identify every service subscribed to the topic or queue the attacker can reach, and for each, determine the serialization format and library actually used to deserialize incoming messages; this is reconnaissance work, not exploitation, but it determines which of the next steps is even worth attempting.
- Identify the weakest link across languages, not the entry point. A message injected via one service's API might be re-serialized and forwarded to a downstream service in a different format entirely (the ingress service parses JSON, validates it, then forwards a Java-native serialized object to a legacy downstream consumer). The attacker's actual target is whichever hop has the weakest deserialization posture, which the message has to survive the journey to reach; understanding the transformation pipeline (what gets re-encoded, what passes through unchanged) is necessary before a payload can be crafted.
- Craft a payload valid at the injection point but armed at the target hop. If the message format changes between injection and the vulnerable consumer, the payload has to be shaped so it survives that transformation intact, for instance smuggling a base64-encoded, language-specific serialized gadget chain inside a JSON field that the intermediate hop passes through unmodified (because it is treated as opaque application data, not something the intermediate service itself parses) but the final consumer decodes and deserializes natively.
- Select the gadget chain matching the target consumer's actual runtime and library versions. This is identical in kind to the single-service case (Java Commons Collections chains, PHP POP chains, or a Python
picklepayload using__reduce__, each covered by their own well-known techniques), just aimed at whichever specific downstream service was identified in step 2. - Achieve remote code execution or privilege escalation at that specific hop. The outcome (RCE via a reflective method-invocation chain, or privilege escalation if the vulnerable consumer runs with broader permissions or service-account scope than the ingress service the attacker directly touched) depends entirely on what that specific downstream service can do; a common and particularly severe variant is when the vulnerable consumer has more trust or broader network/data access than the entry point, meaning the attack is not just remote code execution but a privilege-escalation path from a lower-trust ingress service to a higher-trust internal one, purely by routing a malicious payload through the queue.
Architectural mitigations for cross-service gadget abuse. Per-consumer serialization filtering (type allow-listing or a language-level serialization filter applied at each service's own deserialization call sites) is necessary but not architecturally sufficient here, because it has to be applied correctly and independently at every single consumer, and a queue-based architecture makes it easy to add a new consumer later without anyone revisiting the security posture of the message format itself:
- Standardize on a schema-validated, non-executable message format at the queue boundary. Require every message to conform to a published schema (JSON Schema, Protocol Buffers, Avro) validated at the point of production, before it ever enters the queue, and enforce that no field is permitted to carry an opaque, language-native serialized blob "for convenience." This closes the smuggling path in step 3 above at the source, rather than relying on every downstream consumer to independently detect and reject it.
- Never forward payload data across a language boundary using a language-native serialization format. If a Java service needs to hand data to a Python service, that handoff should go through a neutral, schema-typed format (Protocol Buffers, well-defined JSON with a versioned schema), never native Java serialization decoded by a library on the Python side that happens to support it; a neutral format has no magic-method or gadget-chain concept to exploit in the first place.
- Apply schema validation and type allow-listing at every consumer, not just the first hop. Each service that reads from the queue should independently validate the message against the expected schema before doing anything with individual fields, treating "this message came from our own internal queue" as no more trustworthy than "this message came from the public internet," since the queue itself does not authenticate the semantic correctness of a message's content, only (at best) that it came from an authorized producer.
- Segment queues and topics by trust boundary, so a message that is valid input for a low-trust ingress-facing service is never structurally routable to a high-trust internal service without passing through an explicit, re-validating transformation step; this specifically closes the privilege-escalation variant of this attack, where the danger is not just RCE but RCE at a more trusted hop than the attacker directly touched.
- Instrument deserialization failures and unexpected type errors across every consumer as a security signal, not just an application bug. A downstream consumer receiving a message it cannot deserialize into its expected schema (or that throws a
ClassCastException/InvalidClassException-shaped error) is exactly the observable signature of a probing or failed gadget-chain attempt, and centralizing this telemetry across all consumers (not each service's own isolated logs) is what makes a cross-service attack pattern visible at all.
Worked example
Concretely: an order-processing pipeline has an ingress API (Node.js, validates and publishes to a queue), a fraud-scoring consumer (Python, subscribes to the same topic), and a legacy fulfillment consumer (Java, also subscribes, was never migrated off native serialization for historical reasons). The ingress service validates the JSON request body strictly and forwards it unchanged as JSON, but the legacy fulfillment consumer's message-handling code, written years earlier, base64-decodes a specific field (legacyPayload, originally intended for a one-time migration and never removed) and passes it to ObjectInputStream if present. An attacker who can reach the ingress API's public schema (which permits an optional, loosely-typed legacyPayload string field, because nobody re-audited the schema after the migration was declared complete) can smuggle a Commons-Collections-style gadget chain through the ingress service's validation (which only checks that the field is a string, not what it decodes to) and the fraud-scoring consumer (which never reads that field at all and is unaffected), landing exclusively on the legacy fulfillment consumer, which executes it. The architectural fix here is not "harden the Java consumer's deserialization," although that helps; it is "the ingress schema should never have permitted an opaque, purpose-unclear field to exist three services downstream of its intended one-time use," which is exactly the schema-discipline mitigation above.
Trade-offs and pitfalls
- Fixing only the consumer that was actually exploited. As the worked example shows, the vulnerable field often exists because of historical, half-removed functionality rather than a deliberate design choice; finding and fixing the one exploited consumer without auditing every other consumer of the same topic for similar legacy fields leaves the same class of bug reachable by a slightly different payload.
- Assuming schema validation at the ingress point is sufficient on its own. Validating that a field is "a string" or "well-formed base64" does not validate what that string decodes to; schema validation needs to extend to rejecting opaque, unstructured payload fields entirely, not just checking the outer envelope's shape.
- Treating this as purely a Java/legacy-consumer problem. Any language's deserialization primitive (Python
pickle, RubyMarshal, Node.js'snode-serializeand similar libraries) is equally exploitable if a message format allows opaque, language-native serialized data to flow between services; the specific gadget mechanics differ, but the architectural exposure (an unvalidated opaque field routable to whichever consumer happens to deserialize it unsafely) is identical regardless of which language ends up being the vulnerable hop. - Underestimating the privilege-escalation variant. Teams often triage this bug class purely as "remote code execution somewhere in our fleet," which understates the risk when the vulnerable consumer has meaningfully more trust or access than the entry point the attacker actually touched; the severity assessment should explicitly account for what the specific vulnerable hop can reach, not just that code execution is possible somewhere.
Create unit test scenarios for a function that encrypts user data using AES-GCM with a provided 256-bit key and returns a base64-encoded ciphertext. Provide at least five test scenarios with rationale: correct decryption round-trip, tampering detection (a modified ciphertext must fail to decrypt), nonce-reuse detection or handling, invalid-key handling, and boundary inputs (empty plaintext). Describe what each test asserts.
Sample Answer
Direct answer
Testing a security-critical primitive like AES-GCM (Advanced Encryption Standard in Galois/Counter Mode) is not just "does encrypt-then-decrypt return the original bytes." The five scenarios below cover the happy path, the two failure modes an attacker would specifically target (tampering and nonce reuse), and the two boundary conditions (bad key, empty input) that a real implementation has to handle deliberately rather than by accident.
Structured elaboration
1. Correct decryption round-trip. Encrypt a representative plaintext and decrypt the result, asserting the output equals the original input exactly. This is the baseline functional contract: everything else in the suite assumes this works, and it is the test most likely to catch a wiring bug (wrong key passed, nonce and ciphertext concatenated in the wrong order, encoding mismatch) before touching any security property at all.
2. Tampering detection. Encrypt a plaintext, then flip a single bit anywhere in the resulting ciphertext, including inside the authentication tag GCM appends, and assert that decryption raises an authentication error rather than returning corrupted plaintext. This is the property that distinguishes authenticated encryption from plain AES-CBC (Cipher Block Chaining) with no integrity check: an unauthenticated cipher will happily decrypt tampered ciphertext into garbage (or, worse, attacker-influenced) plaintext and hand it back to the caller as if nothing were wrong. Asserting the exception is what proves the "authenticated" half of AEAD is actually wired in, not merely documented.
3. Nonce-reuse detection or handling. Encrypt two different plaintexts under the same key and the same nonce (deliberately, for the test), and assert that the resulting ciphertexts leak the exclusive-or (XOR) of the two plaintexts, which is what actually happens when a nonce repeats under GCM. This test is not verifying that the library gracefully handles nonce reuse, because it does not and cannot: it verifies that the test author (and, by extension, the codebase) understands and has proven to themselves exactly why nonce reuse is catastrophic, so that the wrapper function's own logic (drawing a fresh random nonce on every call) is a deliberate, justified design decision rather than an assumption nobody actually checked.
4. Invalid-key handling. Call the encryption function with a key of the wrong length (for example, 16 bytes, an AES-128-sized key, when the function is documented to require 256-bit keys) and assert it raises a clear error rather than either crashing unpredictably or, worse, silently succeeding at a weaker security level than the caller intended. This test exists because a caller who accidentally passes a truncated or wrong-length key deserves a loud failure at the call site, not a working-but-weaker system discovered later.
5. Boundary inputs: empty plaintext. Encrypt and decrypt a zero-length plaintext and assert the round-trip still succeeds, and separately assert the output still has the expected fixed overhead (nonce length plus authentication tag length, with zero ciphertext bytes). This confirms the implementation treats "nothing to encrypt" as a valid, well-defined case rather than an edge case that happens to work by accident; a stream-cipher-based construction like GCM has no block-padding reason to reject it, but an implementation with an off-by-one length check could still fail it if this case were never exercised.
Worked example
import base64
import os
import unittest
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def encrypt(key: bytes, plaintext: bytes, aad: bytes = b"") -> str:
"""Encrypt with AES-256-GCM. Returns base64(nonce || ciphertext_with_tag).
A fresh random 96-bit nonce is generated per call and prepended to the
output, the standard on-the-wire layout for AEAD (authenticated
encryption with associated data)."""
if len(key) != 32:
raise ValueError("key must be 32 bytes (256 bits)")
aesgcm = AESGCM(key)
nonce = os.urandom(12) # 96-bit nonce, the size AES-GCM is designed for
ct = aesgcm.encrypt(nonce, plaintext, aad)
return base64.b64encode(nonce + ct).decode("ascii")
def decrypt(key: bytes, token: str, aad: bytes = b"") -> bytes:
raw = base64.b64decode(token)
nonce, ct = raw[:12], raw[12:]
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ct, aad) # raises InvalidTag on any tampering
class AesGcmTests(unittest.TestCase):
def setUp(self):
self.key = os.urandom(32)
def test_round_trip(self):
# Asserts: decrypt() returns byte-for-byte the original plaintext,
# the primary contract of the function.
pt = b"correct horse battery staple"
token = encrypt(self.key, pt)
self.assertEqual(decrypt(self.key, token), pt)
def test_tampering_detected(self):
# Asserts: flipping a single bit anywhere in the ciphertext (which
# includes the 16-byte GCM authentication tag) makes decrypt() raise
# InvalidTag rather than silently returning corrupted plaintext.
token = encrypt(self.key, b"transfer $10 to alice")
raw = bytearray(base64.b64decode(token))
raw[-1] ^= 0x01 # flip the last bit of the auth tag
tampered = base64.b64encode(bytes(raw)).decode("ascii")
with self.assertRaises(InvalidTag):
decrypt(self.key, tampered)
def test_nonce_reuse_breaks_confidentiality_and_must_never_happen(self):
# Asserts: this does not "handle" nonce reuse gracefully, because
# AES-GCM has no graceful handling for it. It demonstrates WHY the
# nonce must never repeat under the same key: two ciphertexts made
# with the same (key, nonce) pair XOR down to the XOR of their
# plaintexts, leaking structure without the attacker ever seeing the
# key. This is why encrypt() always draws a fresh os.urandom(12).
nonce = os.urandom(12)
aesgcm = AESGCM(self.key)
pt1 = b"AAAAAAAAAAAAAAAA"
pt2 = b"BBBBBBBBBBBBBBBB"
ct1 = aesgcm.encrypt(nonce, pt1, b"")
ct2 = aesgcm.encrypt(nonce, pt2, b"")
ciphertext_xor = bytes(a ^ b for a, b in zip(ct1[:16], ct2[:16]))
plaintext_xor = bytes(a ^ b for a, b in zip(pt1, pt2))
self.assertEqual(ciphertext_xor, plaintext_xor) # the keystream cancels
def test_invalid_key_length_rejected(self):
# Asserts: a 16-byte (AES-128-length) key is rejected up front by our
# wrapper's own validation, not silently accepted at a different
# security level than intended.
with self.assertRaises(ValueError):
encrypt(b"short_key_only16", b"data")
def test_empty_plaintext_boundary(self):
# Asserts: AES-GCM is a stream-cipher-based construction, so a
# zero-length plaintext is valid input; it must still round-trip and
# still produce/check a 16-byte authentication tag.
token = encrypt(self.key, b"")
self.assertEqual(decrypt(self.key, token), b"")
raw = base64.b64decode(token)
self.assertEqual(len(raw), 12 + 16) # nonce + auth tag, no ciphertext bytes
if __name__ == "__main__":
unittest.main(verbosity=2)
Running this file (python3 -m unittest -v or directly, since it calls unittest.main()) executes all five tests and reports:
test_empty_plaintext_boundary (__main__.AesGcmTests.test_empty_plaintext_boundary) ... ok
test_invalid_key_length_rejected (__main__.AesGcmTests.test_invalid_key_length_rejected) ... ok
test_nonce_reuse_breaks_confidentiality_and_must_never_happen (__main__.AesGcmTests.test_nonce_reuse_breaks_confidentiality_and_must_never_happen) ... ok
test_round_trip (__main__.AesGcmTests.test_round_trip) ... ok
test_tampering_detected (__main__.AesGcmTests.test_tampering_detected) ... ok
----------------------------------------------------------------------
Ran 5 tests in 0.004s
OK
(the parenthesised test path in each line is the unittest format from Python 3.11 onward, and older runners print (__main__.AesGcmTests) instead; the fractional-second timing varies run to run and machine to machine, so neither is a claim worth pinning. What matters and does reproduce is that all 5 tests report ok and the suite reports OK). All five pass, confirming the wrapper's round-trip, tamper-detection, invalid-key-rejection, and empty-input handling all behave as claimed, and confirming (via the deliberately-reused-nonce test) that nonce reuse under this construction really does leak the plaintext XOR exactly as described, not just as a cited fact.
Trade-offs and pitfalls
The nonce-reuse test is easy to misread as "the library handles this case," when the point is closer to the opposite: it demonstrates the failure mode exists so the codebase's actual defense (always drawing a fresh random nonce, never accepting a caller-supplied one for this code path) is understood as load-bearing rather than incidental. A test suite that omits this scenario can still pass every other test while shipping code that would be silently catastrophic if a nonce source were ever changed to something less rigorously random, for example a counter that resets on process restart without a persisted high-water mark.
Five scenarios is a solid floor, not a ceiling, for a security-critical function: a production suite for this exact function would typically add associated-data (AAD) mismatch handling (decrypting with different AAD than was used to encrypt must fail the same way tampering does), and a check that ciphertext truncated below the minimum expected length (nonce plus tag) fails cleanly rather than raising an unrelated exception like an index error.
Random 96-bit nonces are safe only up to a bounded number of encryptions under a single key before the birthday-bound collision probability becomes non-negligible (NIST SP 800-38D's guidance caps this at roughly 232 encryptions per key); a system that will exceed that volume under one key needs either a counter-based nonce with a durably persisted, guaranteed-unique counter, or a key-rotation policy, neither of which this five-scenario suite tests, since it is scoped to the function's correctness, not to a specific deployment's encryption volume.
Testing tampering by flipping the last bit of the output is a convenient, easy-to-write case, but a genuinely rigorous suite would also flip a bit in the middle of the ciphertext body (not just the tag) to confirm both the confidentiality-affecting bytes and the tag itself are independently covered by the authentication check, since a bug that only validates the tag's own bytes but not the ciphertext body would still pass a tag-only tampering test.
You discover a critical SQL injection in a decade-old legacy application. Management offers several alternatives: an immediate WAF rule as a stopgap, patching the query-string building directly, migrating to an ORM in the medium term, or isolating the app with network controls. Analyze each option's pros, cons, verification steps, and rollback risk, and recommend a phased remediation plan.
Sample Answer
Direct answer: For a critical SQL injection in a decade-old legacy app, the right call is almost never a single option in isolation - deploy the WAF rule immediately as a stopgap while you patch the actual query, because the four options operate on completely different timescales and risk profiles, not as mutually exclusive choices.
Structured elaboration, option by option:
1. Immediate WAF rule. Pros: deployable in minutes, no code change, no regression risk to the application itself. Cons: a signature-based rule can be evaded (encoding tricks, comment injection, alternate syntax) and gives false confidence if treated as "fixed." Verification: confirm the specific payload that triggered the finding is now blocked, and test a couple of known evasion variants against the rule. Rollback risk: near zero - disabling a WAF rule is instant and doesn't touch application state.
2. Patch the query-string building. Pros: fixes the actual root cause; this is the only option on the list that structurally closes the vulnerability rather than reducing its likelihood of exploitation. Cons: requires a code change, a deploy, and regression testing on a decade-old codebase that may have thin test coverage around this code path. Verification: the exact reproduction steps from the vulnerability report should return the expected safe result after the fix (as demonstrated for the classic pattern: a parameterized version of a vulnerable query returns zero rows for an injection payload that previously leaked every row). Rollback risk: moderate - a badly-tested change to old, brittle code can introduce a functional regression, so this needs real test coverage or careful manual verification before it ships to production.
3. Migrate to an ORM, medium-term. Pros: prevents this whole CLASS of bug going forward across the codebase, not just this one query. Cons: a large, slow, high-risk undertaking on a decade-old app; doing this under incident pressure invites new bugs from a rushed migration. This is a program of work, not an incident response action. Verification: this needs its own testing program, not a quick check. Rollback risk: high if rushed - this is exactly the kind of change that should happen on a normal engineering cadence, not as part of the immediate incident response.
4. Isolate the app with network controls. Pros: reduces exposure (fewer things can reach the vulnerable endpoint) without touching the vulnerable code at all. Cons: doesn't fix anything if the attack surface is still reachable by legitimate users who need it; only genuinely useful if the app can be taken off the public internet or restricted to a smaller trusted network without breaking its actual purpose. Verification: confirm the network change doesn't also break legitimate traffic. Rollback risk: low, but "isolating" a production app that customers need to reach isn't always a real option.
Recommended phased plan: (1) WAF rule live within the hour as a stopgap, verified against the specific reported payload; (2) patched query shipped within days, with the specific exploit payload from the report added as a permanent regression test; (3) network isolation considered in parallel only if it doesn't disrupt legitimate use, as extra defense in depth while (2) is in flight; (4) ORM migration scheduled as its own project, informed by this incident but not rushed because of it.
Trade-offs and pitfalls: the single biggest mistake here is treating the WAF rule as the fix and closing the incident - it buys time, nothing more, and a determined attacker will eventually find the encoding variant it doesn't cover. The second biggest mistake is rushing the ORM migration under incident pressure; a decade-old codebase's untested corners are exactly where a rushed migration introduces a NEW, unrelated bug.
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.