The Fork Kept eval() After Upstream Quit It: YOLOv9 Runs ONNX Metadata as Python

At 15:16:52Z on 18 October 2026, NVD published CVE-2026-111991, and the vulnerable line is exactly one function call long. In DetectMultiBackend.__init__ in models/common.py, the ONNX Runtime branch of YOLOv9’s model loader reads the names entry of the weight file’s own metadata map and passes it to eval(). ONNX metadata is attacker-controlled free text embedded in the .onnx file itself: onnxruntime parses it as data and returns a plain dict, and one line later it is executed as a Python expression. A victim who runs the repository’s headline documented command — python detect.py --weights evil.onnx — on a crafted weights file executes attacker Python at model-load time, in the victim’s process, with the victim’s privileges. We read the NVD record and the code on current main first-hand.

The line, verbatim from models/common.py on current main:

meta = session.get_modelmeta().custom_metadata_map  # metadata
if 'stride' in meta:
    stride, names = int(meta['stride']), eval(meta['names'])

Note the asymmetry inside the expression: stride is coerced with int(), which can only ever produce a number, while names — sitting one comma away — is handed to the interpreter. The reporter, 3em0, filed it as issue #670 on 30 September, verified against master HEAD 5b1ea9a8 (10 June 2024). The issue is still open with zero comments, nineteen days later. The repository itself — 9,565 stars, 1,620 forks, created February 2024 — was last pushed on 9 August 2024 and is not archived, so nothing warns a downloader that the code is unmaintained. There is no patched version and no fix commit.

Upstream already fixed this exact line

The issue notes the code was inherited from the YOLOv5 lineage’s DetectMultiBackend, and that the same eval() on the custom metadata map exists throughout the forks. We checked upstream directly: on current ultralytics/yolov5 master, the identical statement at models/common.py:516 reads stride, names = int(meta["stride"]), ast.literal_eval(meta["names"]). Somebody, at some point, replaced the arbitrary-code-execution primitive with the safe literal parser — in the parent — and the fix never propagated to the child. The YOLOv9 file even uses ast.literal_eval itself elsewhere in the same module, so the safe pattern was one import away from the vulnerable line the entire time.

This is the sharpest possible framing of the fork-lag problem this site keeps documenting: the remediation exists, in public, in the lineage’s own upstream, on the same line number family, and the downstream still ships eval() because nobody carried a one-token change across a repository boundary. Scanners keyed on yolov9 will now flag CVE-2026-111991; scanners keyed on behavior — eval( applied to weight-file metadata — would have found it in every fork two years ago.

The severity scores understate it, again

The NVD record carries vulnDB-sourced scores of 2.1 LOW (CVSS 4.0) and 6.3 MEDIUM (CVSS 3.1, AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:L), with CWE-74 (improper neutralization of data within code). Partial confidentiality, partial integrity, partial availability is not a defensible reading of arbitrary Python executing in the victim’s process at model-load time — the reporter’s assessment, that a crafted weights file executes attacker code with the victim’s privileges, is the correct one, and we side with the reporter. Readers of yesterday’s coverage will recognize the pattern: the same scoring gap applied to the AUTOMATIC1111 sidecar-YAML finding. When the primitive is code execution, average the vectors downward and you mislead every prioritization queue that consumes them.

The other two WebUI bugs get their numbers

The same NVD morning completed a set this site has been tracking. At 12:17:11.210Z and 12:17:11.440Z on 18 October — 230 milliseconds apart — NVD published CVE-2026-111984 and CVE-2026-111985, both in AUTOMATIC1111 stable-diffusion-webui through 1.10.1, both sourced to cna@vuldb.com, both vulnStatus: Deferred. These are the two September issues by the same reporter, 3em0, that yesterday’s briefing explicitly noted had no identifiers yet. All four issues, #17497 through #17500, are now four CVEs (vuldb 421650 through 421653), all still open, all still at zero comments.

CVE-2026-111984 (CWE-79) is the model-filename HTML injection: create_item_html in modules/ui_extra_networks.py interpolates the filename into the extra-networks card HTML unescaped. The line at the release commit, verbatim:

btn_copy_path = self.btn_copy_path_tpl.format(**{"filename": item["filename"]})

The defect is visible as an asymmetry inside the same function: two dozen lines above, the preview image goes through html.escape(preview), and the click handler is passed through html.escape(onclick), while the filename — attacker-chosen, being simply the name of a file inside a downloaded model package whose contents are a perfectly valid safetensors file — is formatted raw into a data-clipboard-text attribute. Card HTML is built during interface load, so the payload sits in the WebUI origin on page open.

CVE-2026-111985 (CWE-400) is the unchecked safetensors header length: read_metadata_from_safetensors() in modules/sd_models.py re-implements the header parse and feeds the file’s own 8-byte little-endian length field straight into file.read():

metadata_len = int.from_bytes(metadata_len, "little")
json_start = file.read(2)
assert metadata_len > 2 and json_start in (b'{"', b"{'"), ...
json_data = json_start + file.read(metadata_len-2)

The assert checks the length is greater than two and the magic bytes look right; nothing compares the claimed length to the real file size. The reporter’s measurement: a 40-byte file whose first 8 bytes encode 2^46 turns every model enumeration — automatic at startup and on every checkpoint refresh — into a ~64 TiB read attempt inside the WebUI process, with the resulting MemoryError swallowed by the product’s own except Exception and logged as a metadata error. Both NVD references include the reporter’s zipped PoCs as GitHub user attachments.

What to do

  • Treat every downloaded weight file as executable, regardless of extension. .onnx, .safetensors, and sidecar .yaml files have all now been demonstrated as code-execution or XSS vectors in widely starred repositories. Inspect ONNX metadata (meta = session.get_modelmeta().custom_metadata_map via onnxruntime, or onnx.load() and read metadata_props) before running inference on weights you did not train.
  • If you ship a YOLOv5-lineage fork, carry the upstream one-token fix. Replace eval(meta['names']) with ast.literal_eval(meta['names']) in the ONNX Runtime branch, then grep the lineage for every remaining eval( applied to file-derived data. The parent repository already shows what the line should look like.
  • For WebUI operators, yesterday’s mitigations stand and widen. Delete same-basename sidecar YAMLs you did not write, do not unpack model archives with untrusted filenames directly into the models tree (rename on extraction), and confirm the effective config via GET /sdapi/v1/sd-models. There is still no patched release; the branch has not moved since July 2024.
  • Do not trust the CVSS on model-loading primitives. Three of the five CVEs in this article score below 7.0 while describing arbitrary code execution or origin-context script execution. Prioritize by primitive — does attacker-controlled file content reach an interpreter or an HTML sink — not by the number.

None of the three CVEs appears in the CISA KEV catalog (1,748 entries, 16 October 2026 release), and there is no published evidence of in-the-wild exploitation. That is the expected state for findings whose disclosure channel is an unanswered GitHub issue, and it is not reassuring: the PoCs are public, the issues are indexed, and the code has not moved. This site’s recent model-trust coverage — the WebUI sidecar pair and the Unsloth/Espnet trust gates — keeps converging on one sentence: a model package is a directory, and every file in the directory is input to something.

Sources: