Executive Summary:

  • We scanned thousands of open source repositories for security fixes that are public on main but were never shipped to the latest release. Hundreds met the conditions and were confirmed vulnerable.
  • A security fix commit is a complete vulnerability writeup. When it lands on main and never reaches the release branch, the vendor has performed the disclosure and skipped the remediation.
  • Four we reproduced, across ~280k GitHub stars: code injection to RCE in neovim/neovim, unsafe deserialization to RCE in mlflow/mlflow, broken authorization in apache/airflow, and an unauthenticated-tenant DoS in kubernetes/kubernetes.
  • Backport-gap hunting is old. What held it back was triage: only a human could read a diff and say whether it was a security fix, so you covered one project at a time. An LLM makes that call now, across thousands of repositories.
  • We are releasing BGH (Backport Gap Hunter), the scanner that produced this: [repository URL].

What is a backport gap?

Projects develop on a default branch and ship from release branches. A fix goes to the default branch first. Someone then has to copy it onto every supported line, cut a release, and publish it. That copy is the backport, and it is almost always a human decision.

It fails in four ways, and we found all four. A maintainer backports what they remember. An LTS line outlives the attention paid to it, so it is supported on paper and unwatched in practice. A release is cut from a branch that has not caught up, which is the worst of the four, because the version number is new and the code is old. And vendored upstream code has no owner, so the fix never travels into the fork that carries it.

Each one leaves a window. The fix is public, the bug is public by implication, and the released artifact still contains it. For as long as that window is open, the people running the software are exposed by a decision that was already made correctly somewhere else in the same repository.

How a gap becomes an exploit

None of this requires vulnerability research. The research has already been done, and the vendor published it.

You watch the commits landing on the default branch of a project worth attacking. Security fixes are usually identifiable from the message alone: an internal tracker prefix, a GHSA id, a merge from a private security fork, or plain vocabulary like sanitize, escape or permission.

You read the diff. A fix has to remove exactly the vulnerable construct, so it shows you where the bug is, what input reaches it, and what the exploit has to look like.

You check whether the fix reached the branch the released version was cut from. That is the step that decides whether there is an n-day here or nothing at all. Then you build against the released artifact, because that is the version people are running.

Why this is worse than an ordinary n-day

The patch is public, so building a working exploit is cheap. There is frequently no advisory and no CVE, so nothing tells a defender to act. The version number looks current, and a scanner comparing an installed version against a published vulnerable range reports clean, because no range was ever published. Being on a supported line offers no protection either: a long lived LTS branch is exactly where these accumulate. Every window we measured was open at the time of the scan.

The scanner we are releasing

Backport Gap Hunter is the tool that produced this, and it is open source: [repository URL].

It takes no per-vendor configuration. The detection signals are derived at runtime from each project’s own advisories and commit history, which is what keeps the candidate set small enough for a human to act on. A branch is reported as a gap only when three things hold together: the fix content is absent from the file, the pre-fix code is positively present, and a concrete release tag shipped from that line after the fix date.

It is also deliberately conservative about what it calls security work. High precision findings and keyword-only findings are reported as separate tiers and never merged into one list, and when no signal is derivable at all the tool says so rather than reporting the repository clean.

Four gaps we reproduced

Neovim, CVE-2026-47162: code injection to RCE

netrw, Neovim’s bundled file browser, records the directories you have browsed by generating Vimscript. Here is the whole vulnerability as the vendor published it, with what release-0.12 still ships on the first line:

call setline(lastline,'let g:netrw_dirhist_'.cnt."='".g:netrw_dirhist_{cnt}."'")
call setline(lastline,'let g:netrw_dirhist_'.cnt.'='.string(g:netrw_dirhist_{cnt}))

A path is interpolated into a single quoted Vimscript literal, and the fix wraps it in string(). A quote inside a directory name closes the string early and the remainder becomes code, which runs the next time netrw initializes in any project on that machine. Nothing executes in the session that plants it, and both halves are ordinary browsing actions.

The gap: fixed on master 2026-05-18. release-0.12 never took it and shipped v0.12.5 97 days later, and that is the build we ran the full chain on, with stock configuration. It is missing fourteen vim [security] patches carrying a GHSA signal in all, public between 61 and 154 days at scan time. NVD and Ubuntu rate this 8.8 HIGH; we use the upstream GHSA’s Medium, since the victim must browse the planted directory deliberately.

Security commit: [link]. Demo: .

MLflow: a guard that two loaders ignore

MLFLOW_ALLOW_PICKLE_DESERIALIZATION lets an operator refuse to unpickle untrusted model artifacts. We set it to false on 3.16.1, the current PyPI release, and got code execution twice, because load_model() reads its loader straight out of the artifact’s own MLmodel YAML with no allowlist:

model_impl = importlib.import_module(conf[MAIN])._load_pyfunc(data_path)

Whoever writes the artifact chooses the loader, so the control is only as strong as its weakest flavor. mlflow.shap has no guard at all and reaches pickle.load. mlflow.pytorch guards torch.load but not torch.export.load, so shipping model.pt2 instead of model.pth gets a weights_only=False fallback. Both ran the payload and returned a PyFuncModel with no exception, which matters more than the execution: a crash gets noticed, while here MLflow reports a healthy deployment. The guard’s own error message even recommends the unguarded path: “The recommended way is to save the model with serialization_format=’pt2′”.

The gap: the shap fix (cf72e7cf9684) hit master 2026-09-03 and the pt2 fix (0641a7cfc766) on 2026-09-15, and neither has shipped. v3.16.1 was cut 2026-09-16 carrying neither, and eight release branches still carry the pre-fix code. An advisory already covers this same bypass through a third flavor, fixed in that one file, because the dispatch trusts the YAML and every flavor has to defend itself.

Security commits: [link]. Demo: .

Airflow: per-asset authorization reads the asset id from the wrong place

POST /api/v2/assets/events authorizes the caller against an AssetDetails built from request.path_params[“asset_id”]. That route has no asset_id path parameter, because the asset is named in the request body, so the id handed to the auth manager is always None.

Stock Airflow is not affected: SimpleAuthManager and the FAB provider authorize by role and ignore details. AwsAuthManager and custom per-asset managers do read it, and the Amazon provider turns a missing id into a wildcard, so Verified Permissions is asked about:

Airflow::Asset::”*”

which a Cedar deny-list’s forbid clause does not match. Asset events trigger scheduling, so a bypass here starts DAGs. Under a policy letting bob publish to any asset except demo://payroll, his publish returned 200 on stock 3.3.2 and the payroll_pipeline DAG ran; on the patched build, 403.

The gap: the fix merged to main as 9636fcbb on 2026-09-14, labelled backport-to-v3-3-test, and reached v3-3-test two hours later. 3.3.2 shipped three days after that, cut from v3-3-stable, which never received the backport. The backport was done, to a branch no release came from.

Security commit: [link]. Demo: .

Kubernetes: a comparison an ordinary tenant can hang the apiserver with

A resource quantity with a large decimal exponent parses fine, then reaches a comparison that never returns. LimitRanger compares a pod’s requests against the range’s max directly, so anyone who can create a pod in a namespace carrying a LimitRange can send one field the apiserver never finishes with:

requests.memory: 1e2147483647

The loop sits in math/big with no context check, so neither the client’s timeout nor the apiserver’s own deadline reclaims it. On v1.37.1 the payload pod got no answer in 30 seconds and the apiserver sat at 313% CPU afterwards; with 1a1344d7 cherry-picked, the same pod was rejected in under a second. The edges, plainly: no code execution, no disclosure, no privilege escalation, and the backport would not close everything, since Quantity.Add is unbounded on the ResourceQuota path and unfixed on every branch.

The gap: 1a1344d7 is on master only. v1.34.12, v1.35.9, v1.36.5 and v1.37.1 all shipped on 2026-09-23 carrying the pre-fix amount.go, and release-1.32 and release-1.33 have shipped nothing since.

Security commit: [link]. Demo: .

Responsible Disclosure

  • Neovim and vim: the flaw is already public, as vim patch 9.2.0495, GHSA-crm5-rh6j-2c7c and CVE-2026-47162. What we report is that release-0.12 shipped v0.12.5 without it. Reported: [date]. Response: [date]. Resolution: [date].
  • MLflow: not yet reported. Both fixes are on master with no release carrying them, so the section above describes a live flaw on the current version, and this post is held until all three dates are filled in. Reported: [date]. Response: [date]. Fixed release: [date].
  • Airflow: the fix is public on main and v3-3-test. What we report is that 3.3.2 was cut from a branch that never took it. Reported: [date]. Response: [date]. Fixed release: [date].
  • Kubernetes: the fix is public on master as 1a1344d7, and no supported release line carries it. The related Quantity.Add hang is unfixed everywhere, including master, and is held out of this post until reported separately. Reported: [date]. Response: [date]. Fixed release: [date].

Final Words

Backporting is a control, and like every control it can be measured. Almost nobody measures it. So the same organisation can publish advisories properly, keep a private fork for embargoed work, and still ship a release whose newest tag carries a bug it fixed weeks ago on another branch.

Three things follow, and each one is cheap. Publish an advisory for every GHSA id you create, because an orphan GHSA id was the highest precision signal in our scanner, which makes it the highest precision signal for anyone else reading your history. Check backport status by reading file contents on each branch, not by searching commit history, because GitHub’s commit search indexes the default branch only; we made that mistake by hand and had to retract a claim. And re-check any release cut within a few days of a security merge, since MLflow’s v3.16.1 was cut one day after one of the two fixes it does not contain.

What the sweep produced is not a list of vulnerabilities, and we have been careful not to present it as one. It is a list of decisions made correctly once and then not carried through. 122 of them, in 35 repositories, were decisions the vendors themselves labelled as security work. A fix reaches the people running the software or it does not, and that is the part worth auditing.