The Model Was Inert, the File Beside It Was Not: Two Stable Diffusion WebUI CVEs and the 94-Issue Sweep Behind Them

NVD published CVE-2026-111986 at 13:16:35.920Z and CVE-2026-111987 at 14:16:54.630Z on 18 October 2026. Both carry vulnStatus Received, both come from the VulDB CNA, and both are against AUTOMATIC1111/stable-diffusion-webui — at 165,073 stars and 33,618 forks, the most-starred Stable Diffusion interface on GitHub and the de facto reference implementation of “load a model someone else made.” Both CVE descriptions end with the same clause: “The project was informed of the problem early through an issue report but has not responded yet.”

That clause is doing more work than it appears to. We checked the repository directly. The master branch HEAD is commit 82a973c04367123ae98bd9abdf80d9eda9b910e2, committed 27 July 2024, and it is the same commit the v1.10.1 release tag points at. Both reports have been open since 30 September 2026 with zero comments, among 2,511 open issues. The project is not archived and not marked disabled, so no banner warns a downloader that nobody is home — and it ships no SECURITY.md, which means a public GitHub issue was the only disclosure channel the reporter had.

The more interesting finding is not either CVE. It is what these two are a sample of: the reporter, 3em0, has filed 94 issues across 77 distinct repositories since 1 September 2026 — 65 of them in October alone — and they are overwhelmingly one defect class, stated 94 different ways. The a1111 pair are simply the first two to be assigned numbers.

CVE-2026-111986: the YAML sitting next to the checkpoint is a constructor call

Stable Diffusion checkpoints are commonly distributed with a same-basename .yaml config beside the weights. WebUI adopts that file automatically. Read first-hand in modules/sd_models_config.py at the v1.10.1 release commit:

def find_checkpoint_config_near_filename(info):
    if info is None:
        return None

    config = f"{os.path.splitext(info.filename)[0]}.yaml"
    if os.path.exists(config):
        return config

    return None

There is no validation, no allow-list and no provenance check: if a file with the right name exists, it is the config. load_model then parses it with OmegaConf and hands sd_config.model straight to the instantiator (modules/sd_models.py, same commit):

sd_config = OmegaConf.load(checkpoint_config)
repair_config(sd_config, state_dict)
...
sd_model = instantiate_from_config(sd_config.model, state_dict)

And instantiate_from_config is a generic object factory over an attacker-supplied string:

def instantiate_from_config(config, state_dict=None):
    constructor = get_obj_from_str(config["target"])
    params = {**config.get("params", {})}
    ...
    return constructor(**params)

def get_obj_from_str(string, reload=False):
    module, cls = string.rsplit(".", 1)
    ...
    return getattr(importlib.import_module(module, package=None), cls)

The YAML names any importable Python object and the keyword arguments to call it with. That is the whole bug: importlib.import_module on a module name taken from a downloaded file. The reporter’s gadget is torch.hub.load with source: local pointed at the package’s own directory, because torch.hub imports the target directory’s hubconf.py and runs its top-level code. Three ordinary files — model.safetensors, model.yaml, hubconf.py — execute arbitrary Python the moment the victim selects the checkpoint in the dropdown.

One detail in the report is the mark of someone who tested rather than theorised. WebUI mutates the config before instantiating it, in repair_config:

def repair_config(sd_config, state_dict=None):
    if not hasattr(sd_config.model.params, "use_ema"):
        sd_config.model.params.use_ema = False

An injected use_ema=False would be a TypeError for most constructors and would break the chain. It does not break this one, because torch.hub.load forwards unknown keywords through **kwargs. The reporter says so explicitly — the gadget “survives the real product path” — and backs it with a negative control: an identical package differing only in model.target: torch.nn.Identity loads the same sidecar, prints the same console line, and does not run hubconf.py. That control is what separates a demonstration from a claim, and it is why the finding is credible without anyone re-running it.

The severity numbers are where we part company with the record. VulDB scored it 2.1 under CVSS 4.0 and 6.3 under CVSS 3.1 (AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:L) with CWE-74 and CWE-94. The reporter proposed 8.8 High (C:H/I:H/A:H, CWE-470). Partial confidentiality, integrity and availability impact is not a defensible reading of arbitrary Python executing in the WebUI process: that process holds the user’s outputs, their other models, their extension tree, and on the common single-user desktop install, their home directory. The 2.1 is the number a scanner will show you. It is not the number the demonstrated capability supports.

CVE-2026-111987: two fields on the card were escaped, the third was not

The second CVE is nominally stored XSS, and the nominal part is the problem. A LoRA’s safetensors metadata is copied verbatim out of the file header by read_metadata_from_safetensors. The Lora extension then adopts one of those strings as the model’s identity, in extensions-builtin/Lora/network.py:

self.set_hash(
    self.metadata.get('sshs_model_hash') or
    hashes.sha256_from_cache(self.filename, "lora/" + self.name, use_addnet_hash=self.is_safetensors) or
    ''
)

No length check, no character-set check, no format check — whatever the file says its hash is, that is the hash. The value is appended to the card’s search terms, and then rendered. The rendering code in modules/ui_extra_networks.py is worth reading as a single block, because the defect is visible as an asymmetry inside twenty lines:

sort_keys = " ".join(
    [
        f'data-sort-{k}="{html.escape(str(v))}"'
        for k, v in item.get("sort_keys", {}).items()
    ]
).strip()

search_terms_html = ""
search_term_template = "<span class='hidden {class}'>{search_term}</span>"
for search_term in item.get("search_terms", []):
    search_terms_html += search_term_template.format(...)

description = (item.get("description", "") or "" if shared.opts.extra_networks_card_show_desc else "")
if not shared.opts.extra_networks_card_description_is_html:
    description = html.escape(description)

sort_keys is escaped. description is escaped. search_term — sitting between them, interpolated into an HTML string literal — is not. Nobody decided that metadata should be markup; the field simply missed a convention its neighbours follow. And because card HTML for every registered page is built during interface.load, the payload is in the DOM when the page opens: no click, no model load, no checkpoint selected.

CVSS 3.1 4.3, CVE 4.0 2.1, CWE-79. Then read the title the reporter actually gave it: “Stored XSS via LoRA safetensors sshs_model_hash metadata, escalated to RCE through the extension installer.” The escalation chain is not speculation, and we verified every structural precondition at the same commit:

  • The extension installer is driven by two hidden Gradio controls with fixed element IDs — gr.Text(elem_id="extension_to_install", visible=False) and gr.Button(elem_id="install_extension_button", visible=False) — so same-origin JavaScript can fill and click them exactly as the product’s own front-end does.
  • install_extension_from_url calls git.Repo.clone_from(url, tmpdir), and a local directory is a valid git URL — so a model folder obtained by git clone from a model host is itself clonable as an “extension.”
  • run_extension_installer then executes the package’s install.py in a subprocess. Stage one.
  • The gate is check_access(), governed by cmd_opts.disable_extension_access = cmd_opts.webui_is_non_local and not cmd_opts.enable_insecure_extension_access. On a default localhost launch, webui_is_non_local is false, so extension access is enabled. The reporter states this precisely: under --listen the XSS still fires and only the escalation is blocked.
  • The script then presses “Apply and restart UI,” and the restarted process imports the installed extension’s scripts/. Stage two, across the restart boundary — persistence.

A sub-finding buried in the reproduction deserves its own line: the milestone INSTALL_UNCONFIRMED. The install path clones the repository and runs install.py before the step that raises on the never-loaded extension index — so the Gradio call aborts, the UI displays nothing at all, and neither the clone nor the code execution is rolled back. The victim’s interface shows no error, no install, no trace. A failed operation that has already executed attacker code and left it on disk is worse than a successful one, because the only observable signal is silence.

CWE-79 at 4.3 describes the first link of that chain. It does not describe a model download that ends in a self-restarting process with an attacker-authored extension loaded at boot. We have made this observation before about guardrail CVEs scored against the changed code rather than the reached capability; the asymmetry reappears here with a different sign.

Safe tensors, unsafe package

Here is what makes this pair worth a briefing rather than a line in a feed. In both proofs of concept, the .safetensors file is valid, inert, and written with the official safetensors writer API. There is no pickle, no torch.load, no deserialisation gadget. The format did its job perfectly.

safetensors exists because the ML ecosystem concluded that model weights should not be a code-execution vector, and the migration away from pickle was a genuine security win — one we have covered repeatedly, from LMCache’s cache speaking pickle to SGLang’s malicious GGUF files. What these two CVEs show is where the execution went instead. It did not disappear; it moved one file over, into the YAML, the config.json, the adapter_config.json, the model_index.json, the weight_map — the unglamorous metadata nobody thought of as a trust boundary because it is “just configuration.”

A safetensors file is a safe file format. A model package is a directory, and the directory was never in scope. “We use safetensors” is a statement about one file in it. The unit of distribution is what users actually download, and nothing validates the unit.

The sweep: 94 issues, 77 repositories, one defect

We enumerated the reporter’s issue history through the GitHub API rather than inferring it. Since 1 September 2026: 94 issues across 77 distinct repositories, of which 85 remain open and 84 have zero comments. Sixty-five were filed in October alone. Four are against a1111 (#17497–#17500, all open, all unanswered); two of those four became today’s CVEs, and the other two — HTML injection via model filename, and an unchecked safetensors header length used directly in file.read() — have no identifiers yet.

Read the October titles in sequence and the class is unmistakable. Arbitrary module import from model_index.json in huggingface/finetrainers. Path traversal via weight_map values in tjake/Jlama, lxtGH/OMG-Seg, alibaba/Pai-Megatron-Patch, UbiquitousLearning/mllm and dexmal/dexbotic. An adapter’s base_model_name_or_path deciding which base model gets downloaded and served in lm-sys/FastChat, myshell-ai/AIlice, HazyResearch/minions and SakanaAI/text-to-lora. Hydra _target_ dispatch in espnet/espnet and HazyResearch/ThunderKittens. Unsafe reflection in braindecode, prophesier/diff-svc and pytorch/pytorch. Checkpoint-controlled indices reaching unbounded array reads in turboderp-org/exllamav3 and kakao/khaiii.

Every one is the same sentence with the nouns changed: a value inside a model package is used to decide what code runs or which file is opened. Whether it surfaces as reflection, traversal, an out-of-bounds read or an unsafe download is a function of the host language, not of the defect. That is a genuine structural finding about the ML ecosystem, and it is more useful than either CVE number.

It also has a cost, and the honest version of this story includes it. Eighty-four of 94 reports have drawn no reply. Some of that is dormant projects. Some of it is that a maintainer receiving an unsolicited security issue has no way to triage at this rate, and queue volume is itself a maintainer resource — a tension we documented when SGLang’s maintainer closed an entire account’s submission queue, and with it the one PR that fixed a 9.2. We will also note plainly that this author’s earlier 2026 activity includes a handful of issues titled aa, aaa and [BUG] against NVIDIA, Apache and other projects, which is exactly the sort of noise that trains maintainers to stop reading. The September–October reports are a different standard of work — source lines, negative controls, reproducible artefacts, proposed fixes, self-imposed scope limits — but they arrive through the same door, at a volume no volunteer queue absorbs. Reports this good deserve a better channel than the one that exists, and a dormant 165,000-star project is proof that the channel is where the system breaks.

What to do

There is no patched version of stable-diffusion-webui, and on current evidence there will not be one. Both CVEs are “up to 1.10.1,” 1.10.1 is the latest release, and master has not moved in over two years. Treat remediation as your problem, not the vendor’s:

  • Treat a model package as an archive, not a file. Before loading anything downloaded, list the directory. A .yaml beside a checkpoint, a hubconf.py, an install.py, or a .git directory inside a model folder are not normal model contents, and any one of them is sufficient on its own here.
  • Delete same-basename sidecar YAMLs you did not write. WebUI adopts them silently; removing the file is the complete mitigation for CVE-2026-111986 and requires no code change. Confirm which config is in effect — the product reports it in GET /sdapi/v1/sd-models and prints Creating model from config: <path> at load time.
  • Do not git clone model repositories into models/. Download the weights. The .git directory is a precondition of the second-stage escalation, and a plain archive without it breaks that chain.
  • If you must run it, run it without extension access and without your home directory. Containerise, mount models read-only, and be aware that the usual advice to add --listen is a trade, not a fix: it disables the escalation and leaves the XSS.
  • Check your forks. The vulnerable code is in widely-forked files; downstream UIs built on this tree inherit both defects unless they diverged on these specific functions. Grep your own deployment for get_obj_from_str, find_checkpoint_config_near_filename and the unescaped search_term_template.
  • Generalise past a1111. The two-line audit that finds this class anywhere: does any string from a downloaded package reach importlib.import_module, getattr, eval, os.path.join against a trusted root, or an outbound model fetch? If yes, that string is code or a path, and it came from the internet.

Neither CVE is in the CISA KEV catalogue (checked directly, 18 October 2026, catalogue version 2026.10.16, 1,748 entries; zero hits for either identifier and no stable-diffusion-webui records of any kind), and no exploitation has been reported. Both exploits are publicly available as attachments on the open issues, which is the state the CVE descriptions record with “The exploit has been publicly disclosed and may be utilized.”

Our verification was read-only. Both CVE identifiers, publication timestamps (13:16:35.920Z and 14:16:54.630Z), vulnStatus (Received), source identifier (cna@vuldb.com), descriptions, CVSS 4.0/3.1/2.0 vectors and scores, CWE assignments and reference URLs were read first-hand from the NVD 2.0 API. All quoted Python — find_checkpoint_config_near_filename, instantiate_from_config, get_obj_from_str, repair_config, the OmegaConf.load call site, read_metadata_from_safetensors, NetworkOnDisk.set_hash, the extra-networks card builder, the hidden installer controls, run_extension_installer, install_extension_from_url and the disable_extension_access computation — was fetched at release commit 82a973c04367123ae98bd9abdf80d9eda9b910e2 and quoted verbatim. Repository metadata (165,073 stars, 33,618 forks, 2,511 open issues, AGPL-3.0, not archived, not disabled), the identity of master HEAD with the v1.10.1 release commit, its 27 July 2024 commit date, the release tag list, and the absence of SECURITY.md were read first-hand from the GitHub API. Issue numbers, titles, authors, creation timestamps, open states and zero comment counts for #17497–#17500, and the full issue bodies quoted and summarised above, were read first-hand from the GitHub API. The 94-issue / 77-repository / 85-open / 84-zero-comment tally was computed from a complete enumeration of the author’s issue history via the GitHub search API and classified by reading each title. KEV status was checked directly against CISA’s catalogue. Brave Search was used for discovery and returned no independent technical analysis of either CVE — only automated mirrors (VulDB 421652 and 421653, Threat Radar, strix.ai, TheHackerWire) reprinting the NVD text. All reproduction results, timings, canary files and DOM milestones are the reporter’s measurements, not ours. We installed nothing, downloaded no proof-of-concept archive, built no model package, authored no metadata value, started no WebUI instance, and executed no proof of concept.

Sources: