Most people race to the newest CVE. I go the other way. I open the fix for a CVE that already shipped and ask one question: what did this patch not cover. A published fix is generous. It tells you where the dangerous sink is, what input class reaches it, and what the author believed was enough to stop it. When that belief is narrower than the real attack surface, a second bug is sitting right next to the first one, already half mapped for you.
Here are two recent advisories that came out of exactly that habit. Both are published and public. Both are the same story told twice: the fix stopped the reported vector and missed a sibling at the same sink. They land in the two most common shapes I see, so they make a clean pair to learn from.
Shape one: the guard was wired to some paths, not the siblings
When a fix adds an access check, the question that matters is where it was added. At the choke point that every request passes through, or bolted onto the specific handler named in the report. The second kind always leaves siblings behind.
goshs is a small Go HTTP file server. It supports per-directory access rules through a .goshs file, and a prior advisory, GHSA-wvhv-qcqf-f3cx, hardened that access control. The fix covered the write routes, and the notes said reads were protected. That last claim is the part worth testing, because "reads are protected" is a statement about every read path, not just the one the reporter used.
goshs also has a bulk download feature. Hand it the ?bulk query parameter with a set of files and it builds a zip on the fly and streams it back. That route never calls the access-control function. So a request like this pulls a file the .goshs rules were meant to deny, with no authentication at all:
curl -s "http://target:8000/?bulk&file=/.goshs-protected-file" -o loot.zip
unzip -p loot.zip The normal GET path correctly answers 401 when auth is required and 404 when a path is blocked. The bulk path answers with the bytes. Both protections are bypassed by one feature that was never wired into the check. I confirmed it against the current release before reporting. It is GHSA-rmxw-pq4x-3fvh, rated High, an unauthenticated read of files the server was configured to keep private. CVSS vector AV:N/AC:L/PR:N/UI:N/C:H, around 7.5.
The lesson is blunt. If you close an authorization gap by patching the handler that was reported, you treated a symptom. Put the check at the layer every caller shares, then list every entry point that reaches the protected resource and prove the check sits upstream of all of them. "Reads are protected" has to mean every read path, including the convenience feature you forgot you shipped.
Shape two: the denylist was placed one parser away from the sink
A denylist that lives one parser short of the sink loses one byte at a time. The fix blocks the tokens the reporter used. The attacker reaches for the token the author forgot, and the sink still runs it.
AVideo is a PHP video platform. CVE-2026-33482 was an OS command injection through the code that builds an FFmpeg command line. The fix added a sanitizer that rejected the obvious shell metacharacters: command substitution with the dollar-parenthesis form, brace expansion, the double ampersand, and newlines. That list looks thorough until you lay it next to the full grammar the shell actually honors.
It does not reject a single bare ampersand. In a shell, one ampersand is the background operator. It ends the current command and starts another. The sanitized string still gets handed to the same shell-invoking call that the original bug used, so a value shaped like this splits into two commands and the second one runs:
legitimate-arg & touch /tmp/incomplete_fix_poc I verified the byte actually executes at the real sink rather than only slipping past the filter, which is the step that separates a finding from a guess. The marker file appears. This is GHSA-wc3f-xc32-435f, rated High, an incomplete fix of CVE-2026-33482 that restores command execution. The attack complexity is higher than the parent because of how the value is reached, but the impact is full command execution, with confidentiality, integrity, and availability all affected.
The lesson is the same one denylists always teach. A blocklist keyed on specific bad tokens is only as complete as the author's memory of the grammar, and shells have a long grammar. Whenever you see a sanitizer in front of a shell, ask for the full separator set: the single ampersand, the semicolon, the pipe, the backtick, the redirections, every substitution form. The durable fix is not a longer blocklist. It is to stop building a shell string at all. Pass arguments as a list to an exec call that never invokes a shell, so there is no separator for an attacker to smuggle.
The habit, stated plainly
Two findings, two shapes, one method. Read the fix. Ask what it did not cover. Then prove the surviving path reaches the real sink before you assign a single point of severity.
A couple of principles carry across both:
- Put a guard where every caller passes, then enumerate the callers. A guard on the reported handler is a guard on one path out of many. The bulk route in goshs was simply a path nobody re-checked.
- Validate, or better yet construct, the exact value the sink will consume, not the string the user typed. For a shell that means never assembling a command line from user input in the first place. The single ampersand in AVideo lived in the gap between the string the filter inspected and the command the shell ran.
Both of these were reported privately, through coordinated disclosure, and both advisories are now public under the identifiers above. The fix is not a tombstone. It is a map. Read it, and check the doors the author latched against the doors they left open.
No comments yet