Patterns from this week's pentests

Every engagement produces a punch list; the more valuable output is the pattern connecting those lists across dozens of environments. This week's rotation surfaced a familiar shape: legacy input handling stitched into modern deployment pipelines, authenticated-but-privileged features never treated as high-blast-radius, and a stubborn reappearance of null-byte parsing quirks in filesystem code. None of these are novel classes. All of them are still landing.

Below are five patterns we saw repeatedly, the mechanics behind each, and the detection-and-fix guidance we're passing to the engineering teams who own the affected code.

1. XSS in admin-only "internal" pages

The most common finding was reflected or stored cross-site scripting in pages engineers had mentally tagged as internal — devtools, message forms, report renderers, image-upload screens. The reasoning is always the same: "only admins reach that page." The reality: admin sessions are the exact ones an attacker wants to hijack, and phishing an operator into loading /admin/reports?msg=… is the whole game.

Two historical bugs are worth calling out because we still see their exact shape in current code: a message-parameter XSS in a diagnostic form (CVE-2013-5911) and an image-filename XSS in a reports feature (CVE-2018-1155). This week we found the same pattern in a homegrown ticketing system where the uploader stored the raw filename and echoed it into the reports index without contextual encoding.

The fix isn't "sanitize the input." The fix is contextual output encoding at the sink, plus a Content Security Policy that refuses to execute inline script. A minimum viable CSP for an internal console:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m';
  style-src 'self';
  img-src 'self' data:;
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';
  require-trusted-types-for 'script';

Pair the nonce with a templating layer that HTML-encodes by default and requires an explicit raw() call to opt out. Grep your codebase for those opt-outs during code review; that's where the next XSS lives.

2. SQL injection behind an authorization wall

Three engagements this week produced SQL injection in features gated behind "sufficient privileges" — scheduling scans, editing report templates, running diagnostic queries. This is the same architectural blind spot as CVE-2017-11508, where an authenticated user with scan-diagnostics rights could reach a SQLi sink.

Two things make this pattern persistent. Teams assume that if a user is trusted enough to trigger a feature, the feature can trust the user's input. And string-built SQL for admin-only paths rarely gets the same review that customer-facing endpoints do. In one codebase, the SQL builder for a report template used prepared statements everywhere except the ORDER BY clause — concatenated because parameters can't bind identifiers — and the parser accepted subqueries in that position.

The remediation pattern:

ALLOWED_SORT = {"created", "updated", "severity", "asset"}

def build_query(sort_key: str, direction: str) -> str:
    if sort_key not in ALLOWED_SORT:
        raise ValueError("invalid sort")
    if direction.lower() not in {"asc", "desc"}:
        raise ValueError("invalid direction")
    return f"SELECT id, title FROM findings ORDER BY {sort_key} {direction}"

Allow-list identifiers; parameterize values; write a linter rule that flags any f-string containing SELECT, INSERT, UPDATE, or DELETE. That last rule catches more issues than any WAF.

3. Username enumeration in login and reset flows

Login and password-reset endpoints keep leaking account existence via response body, header set, or timing. The reference bug is CVE-2018-1154, where response output differed enough that an unauthenticated attacker could brute-force valid usernames. Modern frameworks make this worse by returning structured JSON error objects that diverge by a single field.

A login response should return one constant error envelope for the entire "credential rejected" class, backed by a constant-time comparison that runs the password hash function even when the user doesn't exist — otherwise the response-time histogram gives you away.

def authenticate(username: str, password: str) -> Session | None:
    user = repo.find(username)
    stored_hash = user.hash if user else DUMMY_HASH
    ok = argon2.verify(stored_hash, password)
    if user and ok:
        return start_session(user)
    return None  # caller returns a single generic 401

Rate-limit per source IP and per username, log the pairing, and alert on any account accumulating failures across many source IPs — that's the credential-stuffing signature.

4. Filesystem paths that still forget the null byte

Three findings this week involved filesystem functions that treated a supplied path as C-terminated. The PHP link(), DirectoryIterator, and bcmath families all had variants of this problem — see CVE-2019-11044, CVE-2019-11045, and CVE-2019-11046 — and although those runtimes are patched, the pattern reappears anywhere application code hands a user-controlled string to a syscall wrapper.

The exploit shape is foo.png\x00../../etc/passwd: the extension check reads the whole string and passes .png; the underlying C call stops at the null byte. Two parsers, two views, one vulnerability.

Two defenses applied together: reject any user-supplied path containing a byte below 0x20, and canonicalize before the extension check rather than after. Canonicalize with the same syscall the runtime will invoke — otherwise you're back to the two-parsers-disagree problem.

5. Header injection in mail and notification sinks

The last recurring pattern was header injection in outbound mail. The nearest reference is CVE-2019-11049, where lowercase custom headers to mail() produced malformed output on Windows; the modern analog is any code that concatenates a user-controlled display name or subject into the header block without stripping CR/LF. We found this in two notification pipelines — one for password-reset emails, one for a webhook emitting arbitrary X- headers to downstream systems.

Normalize every header value against \r, \n, and their percent-encoded forms (%0a/%0d) before it enters the serializer. Prefer a mail library that constructs headers structurally rather than by string concatenation.

What ties them together

Four of the five patterns are trust-boundary bugs: the code assumed a boundary — admin only, privileged only, path-extension only, header-only — that the runtime didn't enforce. The fifth, enumeration, is a leakage bug where a boundary that does exist is announced to anyone who asks. Both classes are cheap to test and cheap to fix, but they only stay fixed when the test lives in CI. Every remediation above should ship with a regression case that would have caught it. Otherwise we'll see it again next week.

Verifiable security.