Close the hole before the CVE
On 21 October, PHP shipped emergency releases for PHP2Shell: CVE-2026-109618 and CVE-2026-109619. Unauthenticated remote code execution, every branch from 5.4 to 8.5.11, fixed in 8.5.12, 8.4.27, 8.3.36 and 8.2.35. CISA added both to its exploited list on 26 October. AWS saw attempts four days before disclosure.
Look at where it breaks:
CVE-2026-109618is in the engine's hash table. A crafted POST pushes a key allocation overmemory_limit, the fatal error leaves a half-built entry in$_POST, and shutdown cleanup trusts it.CVE-2026-109619is a use-after-free: state that survives one request is read by the next one on the same worker.
The first one fires while PHP is still parsing the request. Your first line of code has not run yet. Your input validation, your framework, your dependencies, your careful code review: none of it is on the path. The second needs little more from you than PHP's default upload-progress setting.
Datadog's write-up puts the remedy plainly: patching is the only complete fix. That is what an engine bug looks like. There is no line in your code to fix, so you wait for the patch, and then you apply it fast.
It is tempting to take the lesson "you can only ever wait for the patch." For this kind of bug, that is right. For many of the CVEs you actually patch, it is not.
A different PHP bug, from April
Six months earlier, Composer shipped fixes for two command injections in its Perforce driver: CVE-2026-40176 and CVE-2026-40261.
CVE-2026-40176.Perforce::generateP4Command()dropped the port, user and client from a repository URL into a shell command without escaping. Opening a maliciouscomposer.jsonwas enough.CVE-2026-40261.Perforce::syncCodeBase()appended a package's source reference the same way. Any Composer repository can serve that metadata, and installing from source is the default for dev versions.
Affected versions: 1.0 through 2.9.5. Composer 1.0 came out in 2016.
There is no memory corruption here and no engine internals. A string from outside goes in, a shell command comes out, and there is nothing in between. That line sat in every Composer release for ten years. It was not hidden. It was just not noticed.
Two kinds of bug
Put the two side by side.
| PHP2Shell | Composer Perforce | |
|---|---|---|
| Where it breaks | the engine, before your code runs | library code, on data passed to it |
| What it looks like | a counter bumped before an allocation | a string glued into a shell command |
| Typically found by | fuzzing, deep engine review | following data from input to sink |
| What you can do before the patch | almost nothing | close the path at the boundary |
Memory bugs hide in arithmetic. You find them with fuzzers, sanitizers and very clever people, and you defend against them by patching.
Dataflow bugs do not hide. Untrusted text flows through some library and lands in a shell, a deserializer, a file path or an outgoing request. That is a path in the code's graph. If you know where input enters and which operations are dangerous, the path is either there or it is not, whether or not anyone has filed an advisory.
yaml.load, pickle.loads, SSRF in URL fetchers, archive path traversal. Many of the dependency CVEs you have patched were this second kind.
For the second kind, the CVE is a notification, not a discovery. The edge was there on day one. So you can close it before the CVE.
The same shape in Python
Composer is PHP, so take the Python twin of its VCS driver: GitPython, which builds git commands from what you pass it. GitPython has been here too. CVE-2022-24439 was a crafted URL to clone_from that reached command execution, fixed in 3.1.30. That fix was incomplete, and CVE-2023-40267 finished the job in 3.1.32.
Here is a feature plenty of services have. A user pastes a repository URL and you import it:
import git
from flask import Flask, request
app = Flask(__name__)
@app.post("/import")
def import_repo():
url = request.form["repo_url"]
git.Repo.clone_from(url, "/srv/imports/latest")
return {"ok": True}
Nothing in this code looks wrong. clone_from is the documented API. The package exists, it is popular, and in 2021 no scanner had an advisory against it.
We pinned GitPython to 3.1.29, the last release before the first fix, and pointed vdb harden at it:
<<HARDEN_OUTPUT>>
The point is what the analysis did not need. It did not look up an advisory. It followed url from the request, into clone_from, through GitPython's own code, to the place where a process gets started. That route is the same in 3.1.29 as it was the day the API shipped.
Close it before anyone files it
You can't patch GitPython from your service. You shouldn't have to wait for its maintainers either. What you own is the boundary: the one line where your input goes in. The fix harden suggests for command execution is an allowlist at that line:
def vdb_safe_arg(value, allowed):
"""Accept only an explicitly enumerated command argument."""
v = str(value)
if not allowed:
raise ValueError("a non-empty command allowlist is required")
if v not in allowed or "\0" in v:
raise ValueError("command argument not in allowlist")
return v
APPROVED = {"https://github.com/our-org/templates.git", "https://github.com/our-org/examples.git"}
git.Repo.clone_from(vdb_safe_arg(url, APPROVED), "/srv/imports/latest")
Yes, it is blunt. That is the point. Escaping is what Composer had to get right, and it took GitPython two releases. An allowlist does not need to understand shell quoting, git's option parser, or the ext:: transport. A value is on the list or it does not get through.
Now look at the timeline:
| Without the wrapper | With the wrapper | |
|---|---|---|
| Before the advisory | exposed, and nobody knows | closed |
| Advisory published | scramble to upgrade | nothing to do today |
| The fix turns out incomplete | exposed again | still closed |
The wrapper works the same on the day before the CVE as on the day after. That is what "catching it before the CVE" actually means. Nobody predicts the advisory. You close the path it would have been about.
If your feature really has to accept arbitrary URLs, the list becomes a rule (https only, an approved host, no leading -), and you are writing a parser again. Make that trade knowingly.
Then re-run with --verify <path_id>. The path is reported as closed only when the required defense is found at the boundary. It checks that the defense is there. It does not grade your list.
Where this stops working
"Find vulnerabilities before they have CVEs" is a sentence that can very easily become a lie. So here is the line.
What harden covers: outside input reaching a dangerous operation through a dependency's code, from a call site in your Python.
What it does not cover:
- Composer itself. harden reads Python and PyPI packages, not PHP. And Composer is a tool you run, not a library you call, so there is no call site of yours for the input to pass through. It would not have caught these two CVEs.
- Engine and memory bugs. PHP2Shell is the example, and its Python equivalents would be bugs in CPython, OpenSSL or the kernel. They are not a path between calls in your code, and a call-site wrapper does not stop a use-after-free. Patch those fast.
- C extensions. No Python source, nothing to follow.
- Install-time code. A malicious
setup.pyruns before any call site exists. - SQL injection. It is not one of the sink classes yet.
- Every edge. Reach comes from static summaries of each package. Where a summary can't be built, the analysis assumes the worst and says so. Where the summarizer misses a pass-through, it misses it silently.
- Exploitability. A flagged path is where to read first. When we ran harden on our own service, one lead in eleven turned into a real problem.
Comments
No comments yet. Start the discussion.