[Bug]: Stored XSS via LoRA safetensors sshs_model_hash metadata, escalated to RCE through the extension installer
Checklist
- The issue exists after disabling all extensions
- The issue exists on a clean installation of webui
- The issue is caused by an extension, but I believe it is caused by a bug in the webui
- The issue exists in the current version of the webui
- The issue has not been reported before recently
- The issue has been reported before but has not been fixed yet
What happened?
A LoRA's safetensors metadata.sshs_model_hash string is adopted as the model's hash with no validation of length, character set or format (extensions-builtin/Lora/network.py:50-57, NetworkOnDisk.set_hash: metadata.get('sshs_model_hash') or computed-hash), appended to the card's search_terms (extensions-builtin/Lora/ui_extra_networks_lora.py:27-29), and then interpolated into the extra-networks card HTML as the one field on the card that is NOT html.escape()d (modules/ui_extra_networks.py:312-320, search_term_template = "{search_term}"; the sibling fields sort_keys at :306 and description at :328 are both escaped). The safetensors metadata itself is copied verbatim by modules/sd_models.py:285-309.
Because card HTML for every registered page is produced by interface.load (modules/ui_extra_networks.py:788), the payload is in the DOM the moment the page opens — no click, no model load. A model package whose evil.safetensors carries HTML in sshs_model_hash therefore runs JavaScript in the WebUI origin as soon as the victim opens http://127.0.0.1:7860/.
From there the injected script drives the product's own hidden extension-installer controls (#extension_to_install / #install_extension_button, modules/ui_extensions.py:603-604 — the same pair javascript/extensions.js uses for index installs): it fills in the local package path models/Lora/mbe2e_lora and clicks install. install_extension_from_url git-clones that path into extensions/ and run_extension_installer (modules/launch_utils.py:228-237) executes the package's install.py — stage 1 code execution. The script then presses "Apply and restart UI" (modules/ui_extensions.py:26-56 → modules/restart.py restart_program() → os._exit(0); webui.sh re-execs because it exports SD_WEBUI_RESTART). In the restarted process, modules/scripts.py load_scripts() imports extensions/mbe2e_lora/scripts/mbe2e_stage2.py — stage 2 code execution across the restart boundary, i.e. persistence. No human action is involved after the page load.
I verified this end-to-end on a clean checkout of commit 82a973c04367123ae98bd9abdf80d9eda9b910e2 (the v1.10.1 release commit), CPU-only, no checkpoint, launched via webui.sh: the driven headless Chromium records the DOM milestones XSS_RAN → INSTALL_CLICKED → INSTALL_UNCONFIRMED → RESTART_CLICKED, the two canary files are written (stage 1 by install.py during extension install, stage 2 by the imported script after the self-triggered restart), the product log contains two "Running on local URL" lines from one launch (it restarted itself), and extensions/ contains the attacker package afterwards. A negative-control package that differs only in sshs_model_hash (64 'b' characters instead of HTML) produces no marker, no extension install and no canary in its own product lifetime.
Steps to reproduce the problem
Unpack the attacker's package into models/Lora/ (the exact layout a Hugging Face
git cloneproduces):mbe2e_lora/ evil.safetensors valid safetensors, official writer; __metadata__.sshs_model_hash = <img src=x onerror="..."> payload install.py writes a canary file when A1111's extension installer runs it scripts/mbe2e_stage2.py writes a second canary when imported after a restart .git/ makes the package clonable by install_extension_from_urlThe attached a1111-sshs-hash-poc.zip contains this package (mbe2e_lora/), a negative control (mbe2e_lora_benign/, identical install.py and scripts/, sshs_model_hash = 64 'b' chars), make_poc.py (official safetensors writer API) and SHA256SUMS.txt. Note the two packages use different .safetensors stems because A1111 keys its metadata cache on the basename.
Start the webui the ordinary local way (my runs: bash webui.sh -f --api --skip-prepare-environment --skip-torch-cuda-test --skip-version-check --no-half --use-cpu all --no-download-sd-model --skip-load-model-at-start --port 7860; webui.sh rather than launch.py so that "Apply and restart UI" is a genuine self-restart). The verification webui's config.json contains {"lora_show_all": true} (the stock "Always show all networks on the Lora page" setting of extensions-builtin/Lora) so cards list without a loaded checkpoint; the setting is not on the taint path.
Negative control first: put mbe2e_lora_benign/ into a separate lora dir, start with --lora-dir pointing there, open the page with a driven browser. Expected: no DOM marker, extensions/ untouched, no canary files. Observed: exactly that.
Positive: put mbe2e_lora/ into models/Lora/, start normally, open http://127.0.0.1:7860/ in a browser. Nothing else.
Observe (headless Chromium reading document.documentElement data-mbe2e attribute, which the payload sets at each step):
- dom_milestones: ["XSS_RAN", "INSTALL_CLICKED", "INSTALL_UNCONFIRMED", "RESTART_CLICKED"] ("INSTALL_UNCONFIRMED" is itself a small finding: install_extension_from_index clones and runs install.py BEFORE refresh_available_extensions_from_data raises on the never-loaded index, so the Gradio call aborts, the UI shows nothing — and the clone plus the code execution are not rolled back.)
- canary stage 1 (written by the package's install.py during the extension install): present, content "MBE2E-CANARY-a1111-sshs-stage1-v1"
- canary stage 2 (written by extensions/mbe2e_lora/scripts/mbe2e_stage2.py imported after the restart): present, content "MBE2E-CANARY-a1111-sshs-stage2-v1"
- product log: two "Running on local URL" lines from one launch (the product restarted itself)
- extensions/: ["mbe2e_lora", "put extensions here.txt"]
What should have happened?
A metadata string is data, not markup. Two guards close the chain:
- modules/ui_extra_networks.py:312-320 should html.escape() the search_term value exactly like the sibling fields on the same card (sort_keys at :306 and description at :328 already do).
- extensions-builtin/Lora/network.py:50-57 should not adopt an arbitrary string as an identity: a value used as "the model hash" should be validated (e.g. ^[0-9a-fA-F]{8,64}$) and anything else treated as "no recorded hash". Legitimate producers already emit hex, so the guard is cheap and also protects future consumers of the field.
With either guard the package is inert: no script runs, no extension is installed, no restart happens.
What browsers do you use to access the UI ?
Google Chrome
Sysinfo
Console logs
Full product console logs are attached (not pasted inline, to keep the report readable):
- webui-neg.log — negative-control phase (benign package, --lora-dir pointing at it). Nothing fires: no install activity, no restart, one "Running on local URL" line.
- webui-pos.log — positive phase (attacker package in the default models/Lora). Key lines to look for:
* "Running on local URL" appears TWICE from one launch — the page-driven script pressed "Apply and restart UI" and the product restarted itself;
* the extension install output for mbe2e_lora (cloned from models/Lora/mbe2e_lora, install.py executed by run_extension_installer).
The two canary files written by the package's install.py (stage 1) and by extensions/mbe2e_lora/scripts/mbe2e_stage2.py after the restart (stage 2) are on disk afterwards; their contents are quoted in the steps above.Additional information
- Verified on the v1.10.1 release commit 82a973c04367123ae98bd9abdf80d9eda9b910e2; the full affected range was not enumerated.
- Reachability of stages 1-2 depends on launch flags: modules/shared_cmd_options.py disables extension access when --share/--listen/--server-name is used without --enable-insecure-extension-access. The default localhost launch (the overwhelmingly common one) leaves it enabled. Under --listen the XSS still executes; only the escalation is blocked.
- The package must be a git checkout for install_extension_from_url to clone it — which is the normal state of a model directory obtained with
git clone https://huggingface.co/.... A plain zip without .git would need a different second stage. - No claim is made against the extension installer itself: installing an extension executes code by design and is documented as such. The defect is that model metadata acquires same-origin browser authority without consent; stages 1-2 only show what that authority is worth in a default install.
- Both canaries are inert file writes inside the test machine. No network calls, no persistence beyond the two marker files, no credential access.
- An independent containerized end-to-end validation of the same defect (offline --network none, two runs, byte-identical result.json) is on file; happy to share it.
Source: AUTOMATIC1111/stable-diffusion-webui