//nefariousplan

Sentinel Conflates Failure Modes

A function's return type is too narrow to carry its multiple distinct failure meanings. Two or more semantically opposite outcomes collapse to the same sentinel value. The caller discriminates between them from context, or does not discriminate at all. The fix widens the return type and names each failure with a value the caller can read; the diff that does so is a security-shaped diff whose commit message rarely says "security."

A function returns one value. The caller reads that value as a decision: branch, trust, allow, forward, strcmp, parse. Between the function's internal bookkeeping and the caller's decision are several reasons the function could have arrived at that value, and the function's declared return type can encode only one of them. Two outcomes the author would describe to a reviewer as semantically opposite share a single wire shape. The caller discriminates between them from surrounding context. Or does not discriminate at all.

The fix is almost always to widen the return type, or to route the colliding branch around the value the caller will read. The diff that does so is a security-shaped diff. The commit message rarely says security.

Mechanism

A caller has one job at the moment of the return: read the value and act on it. Everything the function learned in the process of producing that value, every branch it took, every precondition that failed, every lower-level library it delegated to, is compressed through the declared return type on the way out. When the type is narrower than the behavior, the compression loses signal. The signal the compression loses is the signal the caller needed to make the right decision.

The failure is structural, not careless. Four exhibits, four languages, four interpreters, and the same shape in every one.

musl's __crypt_sha256 and __crypt_sha512 wrappers have return type char *. They need to express at least three outcomes: the SHA computation produced a hash, the input key exceeded 256 bytes, the library's internal self-test failed. The type cannot carry three values without additional grammar, so three outcomes collapse into the string "*". OpenSSH reads the result through /etc/shadow's convention where "*" means "no password hash lives here; a password login must never succeed." The caller reads one two-byte string; the function wrote three different failures into it. musl's own DES implementation, written a decade earlier, carries a one-character defense against exactly this collision (return (setting[0]=='*') ? "x" : "*";). The SHA paths added later dropped it. The 2016 timing-side-channel patch introduced the unauthenticated reachability; the 2026 bypass used it (CVE-2026-123158).

Brotli's DecodeBlockTypeAndLength has return type BROTLI_BOOL. The function expresses three outcomes: success, need-more-input (retry the frame after more bytes arrive), format-error (reject the stream permanently). Two map to BROTLI_FALSE. The hot-path macro at the call sites throws the return value on the floor and discriminates based on how many input bytes the decoder consumed on the failing call; the format-error branch consumed none, so the macro treats the rejection as a retry and loops. The patch widens the signature to BrotliDecoderErrorCode, mints a new negative value (-17 BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH), and converts the three call sites to a macro that actually reads the return. The commit title is "Detect and report invalid stream earlier." Nothing in that title tells a dependency-bump bot the change is a fix for a security bug, and eight months pass between the upstream patch and the downstream advisory (CVE-2026-111497).

Barracuda's Index.pm password-change handler returns a Perl scalar named $userrole. The success path returns $userrole. The ERROR_PASSWD_CHANGE branch also returns $userrole. There is no third shape for "the password change I attempted on your behalf failed; do not consult my return value for authorization." The dispatcher reads the value as authorization in both cases. The patch does not widen the return type; the patch routes the failure branch through a different exit function (_bailout_with_json_login_response) whose side effect is terminating response processing before the dispatcher runs. The sentinel is unchanged; the exit is routed around it. The variant is instructive: when the surrounding code cannot be refactored to widen the type, the equivalent fix is to make sure the colliding branch never reaches the reader (CVE-2026-116606).

Hippoo's get_user_permissions is a PHP method returning mixed, intended as a role array or null. Three branches return null: no current user, administrator, authenticated user with no configured role policy. Two of those returns carry the inline comment // Full access. The downstream gate has_role_access reads $perms === null as return true; // admin or unrestricted, and the unauthenticated visitor inherits administrator trust through the shared sentinel. The fix separates the sentinel: null for administrator, false for "no user." No authentication check is added anywhere (CVE-2026-49060).

The pattern lives in the gap between the function's internal behavior-space and its declared return type. The compression is where the sentinel acquires its multiple meanings; the caller is where the ambiguity pays out. When one of the colliding meanings is "I succeeded," another is "I failed," and the caller's branch is a security decision, the collapse is a bypass. When the colliding meanings are "retry" and "reject," and the caller loops on retry, the collapse is a denial-of-service. When the colliding meanings are "administrator" and "anonymous," and the caller reads either as allow, the collapse is unauthenticated privilege escalation.

What makes the fix a security-shaped diff is not any added permission check. The patches in all four exhibits add no caller-side check. They widen the return type (Brotli), redirect the exit path to avoid the collision (Barracuda), raise the caller's minimum-length precondition to exclude one of the colliding values (OpenSSH), or split the sentinel across two of the colliding states (Hippoo). None of these look like security to a dependency bumper reading commit titles. The signature change, the extra return line, the new enum case: those are the receipts. A reviewer who learns to read return-type widenings as security fixes has a cheap cross-project signal that neither the CVE record nor the commit message will give them.

The class repeats because every language ships a cheap narrow return type as its default: bool, int zero-means-success, None-or-value, a scalar, a char * whose convention is set by the surrounding system. The ergonomic default of each language is exactly the shape that cannot carry more than two outcomes. The function's internal behavior-space grows past the type as the code matures; the type does not grow with it. The comments at each return statement grow instead. The compiler, the linter, and the caller read the value through the type; only a human reader reaches the comments. Each comment at a return line that disambiguates meaning is a candidate exhibit of this class waiting for a reachable caller.

Exhibits

CVE-2026-123158 (SSHole): The 2016 Timing-Side-Channel Fix Is The Reason The 2026 Bypass Reaches. CVE-2026-123158 is the archetype at a trust boundary between two codebases. musl's __crypt_sha256 and __crypt_sha512 wrappers take an internal 0 return (meaning "key exceeds 256 bytes") from sha256crypt/sha512crypt and collapse it into the string "*", the same two-byte sentinel they emit for "self-test failed" and for every other internal error path. The return type is char *; three distinct failures share one value. OpenSSH's sys_auth_passwd reads /etc/shadow, where "*" is the Unix convention for "no password hash lives here; a password login must never succeed." The two conventions meet at strcmp(encrypted_password, pw_password) == 0, and the caller has no way to discriminate between them. musl's own DES implementation (crypt_des.c:1015) carries a one-character defense against exactly this collision: return (setting[0]=='*') ? "x" : "*";. The SHA paths added a decade later dropped it. The fix is MIN_PASSWORD_HASH_LEN = 4 on the OpenSSH side; the collision in the shared string space is untouched.

CVE-2026-116606: Barracuda's Failed Password Change Returns $userrole. The Patch Comment Names the Bug.. The Perl specimen of sentinel-conflates-failure-modes. Index.pm's password-change handler returns $userrole on success and returns $userrole on ERROR_PASSWD_CHANGE. The return type is a Perl scalar that names the caller's role; it has no third shape for "the password change I attempted on your behalf failed; do not consult my return value for authorization." Two semantically opposite outcomes collapse to the same value, and the dispatcher reads the value as authorization in both cases. Where the Brotli specimen widens a BROTLI_BOOL to a four-value BrotliDecoderErrorCode, and the SSHole specimen traces "*" conflating "key too long" with "no password hash lives here," Barracuda's fix widens the branch's exit without widening the return type: the failure branch now returns a different function call (_bailout_with_json_login_response) whose side effect is terminating response processing before the dispatcher reads any return value at all. The sentinel is unchanged; the exit is routed around it.

Boundaries

Not every narrow return type is this pattern. A boolean-returning predicate whose only semantic axis is true-or-false is correctly typed, and a char *-returning helper with a single sentinel for the single failure mode it can have is correctly typed. The pattern names the case where the function's actual behavior-space exceeds the type's carrying capacity and the collapse is observable at a security boundary. If the function's three return statements all mean the same thing, the type fits. If two of the three mean opposite things, the type is the bug.

Not Idle Indistinguishable From Broken. That pattern names the same return-type-impoverishment shape in the wire format of a periodic process's telemetry: "nothing to do" and "could not determine" collapse into the same dashboard tile, and the observer who reads the signal is a human on-call rotation reading a graph. Sentinel Conflates Failure Modes names the mistake in a function's hot-path return value where the observer is the next line of code, and the decision the observer makes is a security branch running at machine speed. Same structural impoverishment, two different audiences: one is the dashboard, one is the dispatcher.

Not Null Equals Null. That pattern names a caller-side equality check where two unset sentinels compare equal and the comparison waves the caller through: request.user.gym != target.gym on two None values. The collision is in the comparison operator's semantics; both sides may be correctly typed at the function layer. Sentinel Conflates Failure Modes lives one level up, in the producing function's choice of return value. If the bug is a == b on two legitimate unset values, the Null Equals Null shape applies. If the bug is func() returning the same value for two legitimate different reasons, this pattern applies.

Not Naming Was The Check. That pattern names a function whose name asserts verification its body does not perform: verified_signed_node that never calls cryptographically_verify, validateAccessToken that returns true after a base64 decode. The function's body is wrong; the return value accurately reflects the body's behavior. Sentinel Conflates names a function whose body does the right work but whose return type cannot carry the full result the body already computed. The fix for Naming Was The Check is to make the body match the name; the fix for Sentinel Conflates is to widen the type so the caller can see what the body already knows.

Defender playbook

Enumerate the behavior-space of every security-decision function in the codebase, then count its return values. For each authentication helper, parser error reporter, cryptographic verifier, authorization gate, and session decoder, list the semantically distinct outcomes the body can produce. Compare that count to the cardinality of the declared return type (bool is 2; int with "zero means success" is 2; None-or-value is 2; char * with a project-wide sentinel string is 2). When the behavior count exceeds the type's cardinality, at least two distinct outcomes are sharing a wire shape. The collision site is where the function's name, its inline comments, or its error-string table disambiguate what the type cannot. Find the collision before an attacker does.

Grep the comments at the return statements. When a function returns the same literal value on multiple branches and each branch carries an inline comment disambiguating the meaning (// admin, // no user, // self-test failed, // need more input, // key too long), the grammar the comments encode is not in the type. The compiler, the linter, and the caller all read the value through the type; only a human reaches the comments. For each such function, promote the comments to distinct return values: a new enum case per meaning, a tagged union, a struct with an explicit kind field, a Result-shaped type. The diff is small; the review signal is that every call site now compiles against a type the author already documented in prose.

Treat a return-type-widening commit on any dependency as a security-shaped diff regardless of its commit message. A patch that changes bool to a multi-valued enum, adds a third case to a two-case union, splits a sentinel across two meanings, or introduces a new error variant alongside an existing one is almost always closing a conflation bug. Audit the bumped version against every caller: callers that consume the return by discarding into a branch may be reading the old sentinel as a decision the new type invalidates. Brotli's "Detect and report invalid stream earlier," OpenSSH's "Add minimum length check on password hash," Hippoo's "separate null and false in get_user_permissions" are the shapes to watch for.

Test every failure branch for value collision, not just for value correctness. Unit tests for the happy path commonly assert returns $expected on success. Tests for failure paths commonly assert raises or returns non-$expected on failure. Neither asserts that two distinct failures return distinct values. Write the regression tests that fail when two failure branches return the same value: a test that distinguishes ERROR_FORMAT from NEEDS_MORE_INPUT by inspecting the returned value, not the consumed-byte count; a test that asserts get_user_permissions returns distinct sentinels for anonymous visitor and administrator; a test that feeds crypt a key over 256 bytes and asserts the result is distinguishable from the "*" already in /etc/shadow. The ones that pass describe the type's actual carrying capacity.

Kinship

Idle Indistinguishable From Broken. Sibling in the general class of return-type impoverishment. Idle Indistinguishable From Broken names the shape in a polling job's telemetry: a wire format with three structural outcomes (did work, no work, could not determine) compressed into two, and the observer is a human on a dashboard who cannot tell "I checked and there was nothing to do" from "I checked and could not determine what to do." Sentinel Conflates Failure Modes names the same impoverishment in a hot-path function return, where the observer is the next line of code running a security branch. The two patterns often share a codebase: a service with an idle-indistinguishable health check will usually also have a sentinel-conflates authentication helper, because the ergonomic default of the language teaches both lessons.

Name Is The Only Type. Adjacent class in the family of type-contract impoverishment. Name Is The Only Type is the container-side instance: a generic map (map[string]interface{}, dict, mixed array) stores a named slot whose per-field type lives only in the variable's label. Sentinel Conflates Failure Modes is the function-return instance: the storage is a single scalar return value whose per-outcome meaning lives only in the inline comments at each return statement. Both patterns describe a contract that lives in human-readable prose next to the code rather than in the type the code compiles against; both are closed by promoting the prose to an actual type (a schema check for Name Is The Only Type, a widened return shape for Sentinel Conflates). They often co-occur in the same codebase because the same language-level ergonomic defaults encourage both.

What the type cannot carry, the caller will decide, and the decision will be wrong in exactly the direction the comments warned about.