Scope and safety. This is the reporter’s own analysis of a vulnerability coordinated through CERT/CC (VU#944667). It was published on 2026-11-12, the same day CERT/CC published the vulnerability note and the CVE record. The maintainer had already committed a fix by then. This article explains the broken data flow, the fix, and how to detect and mitigate the issue. It deliberately leaves out the proof-of-concept script given to CERT/CC, any payload, and any automated exploitation procedure. At the time of writing, the fix is on main but not in a tagged release, so many installs are still exposed.

Executive summary

CVE-2026-123790 affects ComfyUI-Easy-Use. It is one of the most-installed ComfyUI custom-node packs, with about 2,800 GitHub stars. The vulnerable code is in all tagged releases up to and including v1.4.1.

The extension registers POST /easyuse/save/{name}. Its intended job is to copy a generated image next to a checkpoint or LoRA so the image becomes that model’s preview thumbnail. ComfyUI has no authentication, so any client that can reach the server can call this route. The handler, save_preview() in py/routes.py, has three defects that combine:

  1. The containment check covers the folder, not the file. The handler checks subfolder against the base directory. It then joins the request’s filename onto that folder and never checks the result again. os.path.join(base, "/abs/path") throws away base when the second part is absolute, so the attacker controls the copy source completely.
  2. The destination category is unrestricted. The text before the first / in {name} is passed straight to folder_paths.get_full_path() as a folder type. custom_nodes is a registered folder type in ComfyUI core. So the copy destination can be any existing file under an installed custom node, including its __init__.py.
  3. The destination takes the source file’s extension. The output path is splitext(destination)[0] + splitext(source)[1]. If the source is a .py file, the overwritten file is a .py file too.

Together these let a network client overwrite an installed node’s __init__.py with Python of their choosing. That code runs the next time ComfyUI imports custom nodes. In affected versions, the same extension also exposed an unauthenticated GET /easyuse/reboot that called os.execv on demand, so the attacker did not need to wait for a restart.

The CVE record, published by CERT/CC as CNA, classifies the bug as CWE-22 and gives no CVSS score. My own assessment, as submitted with the report, is CVSS 3.1 9.8 Critical, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H. The real-world condition is network reachability: ComfyUI binds to 127.0.0.1 by default. That covers every instance started with --listen and most hosted, Docker, RunPod, vast.ai and Colab images.

The point worth remembering is that this exact bug had already been fixed once. ComfyUI-Easy-Use’s save_preview() comes from the same handler in pythongosssss/ComfyUI-Custom-Scripts. On 2024-12-15, that project moved the commonpath check from the folder to the joined file path (5aa7f01). The fix never reached the copy.

Evidence labels used below:

  • Verified: shown by the published source at a named tag or commit, by the CERT/CC case, or by testing against a local ComfyUI instance I control.
  • Inferred: a deployment consequence derived from those facts. It is marked as inferred wherever it appears.

Key facts

Item Finding Confidence
Identifier CVE-2026-123790 Verified
CERT/CC case VU#944667 (report VRF#26-08-CPVQN), published 2026-11-12 Verified
CNA CERT/CC (certcc); CVE published 2026-11-12 Verified
Product ComfyUI-Easy-Use (yolain/ComfyUI-Easy-Use) Verified
Affected versions The CVE record lists 1.3.6, the version I reported. The same handler is unchanged in v1.4.0 and v1.4.1, so every tag through v1.4.1 is affected. The earliest affected version was not established Verified
Fix Commit 8a0f2fc412 on main, 2026-09-29 Verified
Fixed release None tagged at time of writing; v1.4.1 (2026-09-04) predates the fix Verified
Vendor status in VU#944667 “Unknown”; CERT/CC received no vendor statement Verified
Vulnerable component save_preview() in py/routes.py, route POST /easyuse/save/{name} Verified
Weaknesses CWE-22 in the CVE record; CWE-73 and CWE-306 also fit the defect (my classification) Verified / Inferred
Authentication None. ComfyUI core has no auth, and the route adds none Verified
Copy source Attacker-chosen absolute path via filename Verified
Copy destination Any existing file under any registered folder type, including custom_nodes Verified
Execution trigger Next custom-node import; on demand via GET /easyuse/reboot in affected versions Verified
Browser CSRF Blocked by ComfyUI core’s origin_only_middleware for Sec-Fetch-Site: cross-site Verified
Precondition Network reach to ComfyUI (--listen or a hosted image) Verified
CVSS 3.1 None in the CVE record; 9.8 Critical as assessed by the reporter Verified
Discovery method Static audit of the most-installed registry nodes, then local validation Verified

Attack surface and trust boundary

ComfyUI is a local-first application, and its defaults show that. There are no users, no sessions and no API keys. Every HTTP route, whether core or registered by a custom node, is reachable by anyone who can open a TCP connection to it. The default bind address is loopback. The only security boundary in the default setup is the network.

In practice, that boundary is often removed on purpose. ComfyUI needs a GPU, and many users rent one, so they run it in a container on a cloud host with --listen 0.0.0.0 and reach it through a public port, a tunnel or a notebook proxy. Most one-click templates for hosted GPU services do this by default. Inferred: a large share of ComfyUI instances on the internet are reachable with no credentials at all, and fingerprinting them is easy.

Every custom node loads into that same unauthenticated process and registers routes on the same PromptServer. So a custom node’s HTTP routes are part of the server’s unauthenticated attack surface, whether or not the author had that in mind. save_preview() was written as a helper for the extension’s own front end. Nothing in the code suggests it expected to receive requests from anyone else.

Root-cause analysis

The vulnerable handler at v1.4.1 (py/routes.py, lines 260–285), with numbered annotations added:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@PromptServer.instance.routes.post("/easyuse/save/{name}")
async def save_preview(request):
name = request.match_info["name"]
pos = name.index("/")
type = name[0:pos] # (A) folder type, from the URL
name = name[pos+1:]

body = await request.json()

dir = folder_paths.get_directory_by_type(body.get("type", "output"))
subfolder = body.get("subfolder", "")
full_output_folder = os.path.join(dir, os.path.normpath(subfolder))

if os.path.commonpath((dir, os.path.abspath(full_output_folder))) != dir: # (1)
return web.Response(status=400)

filepath = os.path.join(full_output_folder, body.get("filename", "")) # (2)
image_path = folder_paths.get_full_path(type, name) # (3)
image_path = os.path.splitext(
image_path)[0] + os.path.splitext(filepath)[1] # (4)

shutil.copyfile(filepath, image_path) # (5)

return web.json_response({
"image": type + "/" + os.path.basename(image_path)
})

The guard that exists

Line (1) is a real containment check, and it works for what it tests. A subfolder of ../../etc fails it. The problem is what it tests. It runs on full_output_folder, the directory, before the filename is added. Line (2) then joins body["filename"] onto that directory, and nothing checks the result.

os.path.join adds no protection here. Python’s documentation says that if a later part is absolute, all earlier parts are discarded. So a filename of /anything/at/all produces filepath == "/anything/at/all", and the check at (1) has already passed. A relative ../ sequence in filename escapes the same way.

This is the whole difference between Easy-Use and the upstream code it was copied from. The Custom-Scripts fix is three lines: compute filepath first, then run the same commonpath check on it.

1
2
3
4
5
6
7
-    if os.path.commonpath((dir, os.path.abspath(full_output_folder))) != dir:
+ filepath = os.path.join(full_output_folder, body.get("filename", ""))
+
+ if os.path.commonpath((dir, os.path.abspath(filepath))) != dir:
return web.Response(status=400)

- filepath = os.path.join(full_output_folder, body.get("filename", ""))

The destination nobody checked

Even with the upstream fix applied, line (3) is a separate problem, and the Custom-Scripts patch does not address it. type comes from the URL path, and get_full_path(type, name) resolves name inside whatever folder ComfyUI has registered under type. The handler expects checkpoints or loras, but it never enforces that.

ComfyUI’s folder_paths.py registers custom_nodes as a folder type, next to the model folders. get_full_path() returns a path only if the file already exists, which makes this an overwrite primitive rather than an arbitrary-create primitive. That limit does not help: every installed node has an __init__.py, and ComfyUI-Easy-Use itself is always installed when this route is present.

Getting a / into a single {name} path segment depends on aiohttp’s routing. The default {name} pattern does not match a literal /, but a percent-encoded %2F matches the segment and is decoded before it reaches match_info. I verified this on aiohttp 3.11.8, ComfyUI’s minimum, and on 3.14.3. In the handler, name then contains a real /, and index("/") splits it into a folder type and a relative file name.

The extension follows the source

Line (4) rebuilds the destination path from the destination’s stem and the source’s extension. For the intended use, this is sensible: a .webp preview next to model.safetensors becomes model.webp. For an attacker, it means the destination’s extension is also under their control. A .py source written over <node>/__init__ produces <node>/__init__.py, which is executable Python that ComfyUI imports by design.

Where the source comes from

The handler copies a file that already exists, so the attacker needs a file on disk containing their code. ComfyUI core supplies one: POST /upload/image is also unauthenticated and writes the uploaded bytes under the original filename into input/, without checking content or extension. I treat that as platform behaviour rather than part of this CVE, because an upload directory existing is not a vulnerability. The defects that turn it into code execution are (1)–(4) above, and they are in the extension.

The data flow, end to end

Step Request-controlled value What the handler does with it Result
URL {name} = custom_nodes%2F<node>%2F__init__.py split at first / into type, name type="custom_nodes"
Body type, subfolder choose base dir, check folder check passes
Body filename joined after the check source = attacker file
(3) type, name get_full_path("custom_nodes", "<node>/__init__.py") destination = node entry point
(4) source extension appended to destination stem .py
(5) — shutil.copyfile(source, destination) entry point overwritten

The server replies 200 with a JSON body naming the “image” it saved.

Turning a write into execution

An overwritten __init__.py does nothing until it is imported. In affected versions, the extension supplied that step too:

1
2
3
4
@PromptServer.instance.routes.get("/easyuse/reboot")
def reboot(request):
...
return os.execv(sys.executable, [sys.executable] + sys.argv)

os.execv replaces the process image with a new ComfyUI. The new process imports every custom node at startup, including the overwritten one. Without this route, the payload still runs on the next ordinary restart. Inferred: hosted instances are restarted often (preemption, crashes, model swaps), so that wait would not be long.

Impact

Verified

  • An unauthenticated network client can overwrite any existing file that ComfyUI’s folder registry can resolve, including any installed custom node’s __init__.py.
  • The overwritten file runs as Python inside the ComfyUI process, with that process’s privileges, on the next custom-node import, and immediately in affected versions via /easyuse/reboot.
  • No user interaction is needed. I confirmed the full chain against a local ComfyUI instance with a marker-file payload.

Deployment-dependent consequences (Inferred)

  • Data: read and write access to models, LoRAs, inputs, outputs and saved workflows, plus any API keys in the environment. Hosted images often carry tokens for Civitai, Hugging Face, OpenAI-compatible endpoints or cloud storage.
  • Host: many container images run ComfyUI as root, often with the GPU passed through. That makes cryptomining the obvious low-effort abuse, and the host a foothold for anything else.
  • Persistence: the payload sits in a custom node’s entry point. It survives restarts and looks like part of an installed extension.

What bounds the severity

  • Network reachability is the only real precondition. A ComfyUI instance on loopback with no other exposure is not reachable by this route.
  • A drive-by from a browser is blocked. ComfyUI core’s origin_only_middleware returns 403 for requests carrying Sec-Fetch-Site: cross-site, so a malicious web page cannot fire this at localhost. The vector is a direct, non-browser client.
  • The write is an overwrite. The target must already exist. Every install has suitable targets, so this limits the technique, not the outcome.

Safe validation and detection

Version and code check (the fast answer)

Look at custom_nodes/ComfyUI-Easy-Use/py/routes.py. If save_preview() contains pos = name.index("/") and runs its commonpath check before filepath is assigned, the install is vulnerable. If it contains model_type not in ("checkpoints", "loras"), it has the fix. A version = "1.4.1" in pyproject.toml does not mean you are fixed, because the fix landed after that tag without a version bump.

Exposure check

Check whether ComfyUI is listening on anything other than loopback (ss -ltnp | grep 8188, the container’s published ports, any tunnel or notebook proxy in front of it). If it is, and the code above is the vulnerable version, assume the instance has been reachable for as long as it has been running.

Telemetry and integrity worth reviewing

  • Access logs, at a reverse proxy if one exists, for POST /easyuse/save/ where the path contains custom_nodes, or any path segment other than checkpoints or loras.
  • GET /easyuse/reboot requests you did not trigger from the UI, especially shortly after a POST /easyuse/save/.
  • POST /upload/image requests uploading files with non-image extensions.
  • Integrity of every custom_nodes/*/__init__.py against the upstream repository (git -C custom_nodes/<node> status / git diff). Unexpected changes to an entry point are the clearest sign of compromise.
  • .py files in ComfyUI’s input/ directory.

I am deliberately not publishing a scanner or exploit request. The code check above answers the question without sending anything to a live instance.

Remediation and hardening

Required

Update ComfyUI-Easy-Use to a build that includes commit 8a0f2fc412. At the time of writing that means tracking main (for example git -C custom_nodes/ComfyUI-Easy-Use pull, or the “nightly” channel in ComfyUI-Manager). The latest tagged release, v1.4.1, does not contain the fix. Restart ComfyUI afterwards.

VU#944667 says the maintainer could not be reached and recommends removing the extension or limiting network access to trusted users. That guidance is correct for any tagged release. The maintainer did commit a fix to main on 2026-09-29 without responding to the case, so tracking main is a third option.

If you cannot update immediately

  • Do not expose ComfyUI to untrusted networks. Remove --listen, or bind to loopback and reach the instance over SSH port forwarding or an authenticated VPN.
  • If it must be reachable, put an authenticating reverse proxy in front of it. At minimum, deny /easyuse/save/ and /easyuse/reboot at the proxy.
  • If you do not use the extension, uninstall it.

If the instance was exposed

Treat it as potentially compromised, not just as needing a patch. Check custom-node integrity as described above. Rotate any credentials present in the environment or on disk. Rebuild from a clean image rather than cleaning in place: a modified __init__.py is easy to spot, but whatever it installed after it ran may not be.

What the patch does

Commit 8a0f2fc412 (“fix: harden model metadata and preview routes (#1045)”) rewrites save_preview() around what it is actually for:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
model_type, separator, model_name = name.partition("/")
if not separator or model_type not in ("checkpoints", "loras"):
return web.Response(status=400)
if model_name not in folder_paths.get_filename_list(model_type):
return web.Response(status=404)
...
if (body.get("type") != "temp" or body.get("subfolder", "") != ""
or not isinstance(filename, str) or not filename
or os.path.basename(filename) != filename or filename in (".", "..")):
return web.Response(status=400)

extension = os.path.splitext(filename)[1].lower()
if extension not in _PREVIEW_FORMATS:
return web.Response(status=400)

Each of the three defects is closed at its own point:

  • Destination: type must be checkpoints or loras, and the model name must appear in ComfyUI’s own file list for that type. custom_nodes is no longer reachable.
  • Source: it must come from temp, with no subfolder, as a bare filename (basename(filename) == filename). It is then checked again with realpath-based containment against the temp directory and must be a regular file. The check now runs on the final path, after the join.
  • Extension: it must be one of .png, .jpg, .jpeg, .webp, .gif. The file is copied to a temp file next to the target, opened with Pillow, checked to be the format its extension claims, and only then moved into place with os.replace. The handler also refuses symlinked targets and targets outside the model’s own directory.

This is a stronger fix than the upstream Custom-Scripts patch, which only moved the containment check. The commit adds tests/test_routes_security.py.

A note on the reboot route

The same commit changes /easyuse/reboot to POST and requires an X-EasyUse-Reboot-Token header. The token is served by GET /easyuse/reboot-token, and both are gated on a same-origin check. That closes the browser vector described in issue #1045. It does not stop a direct network client: a request without Sec-Fetch-Site and Origin headers passes _same_origin_request(), so a non-browser client can fetch the token and then use it. In this CVE’s threat model, the fix that matters is the save_preview() restriction. Without a write primitive, restarting the server achieves nothing. I don’t consider this a remaining vulnerability, because a client with direct network access to an unauthenticated ComfyUI already controls the server through core features like the queue. It is still worth being precise about which part of the patch does the work.

Timeline

Date (UTC) Event Source certainty
2024-12-15 Upstream sibling fix in ComfyUI-Custom-Scripts (5aa7f01, “Fix filepath validation”) Commit metadata
2026-08-28 Reported to CERT/CC via VINCE (VRF#26-08-CPVQN); the maintainer has no security contact and private vulnerability reporting is disabled VINCE case
2026-09-04 ComfyUI-Easy-Use v1.4.1 tagged, still vulnerable Tag metadata
2026-09-22 CERT/CC opens case VU#944667 VINCE case
2026-09-27 Public issue #1045 by 3em0 describes a stored-XSS chain that reaches the same save route and reboot route from the browser Issue metadata
2026-09-29 Fix commits 8a0f2fc412 and 67afdc8204 land on main Commit metadata
2026-10-06 CERT/CC notifies the maintainer VU#944667
2026-11-06 CVE-2026-123790 reserved CVE record metadata
2026-11-12 14:48 VU#944667 and CVE-2026-123790 published CERT/CC, CVE record
2026-11-12 This analysis published This site

On #1045. Issue #1045 was filed publicly a month after my CERT/CC report and two days before the fix. It reaches the same destination primitive (get_full_path() with an unrestricted custom_nodes type, and the source extension carried to the destination), triggered by script running in the victim’s own ComfyUI tab after a malicious model’s notes are rendered unescaped. As far as I can tell, the two findings were made independently. They are different entry points: #1045 needs a victim to open a model’s info dialog and is scored AV:L/UI:R, while this report is a direct, unauthenticated network request, and it also covers the copy-source ordering bug that #1045 does not depend on. The maintainer’s fix addresses both. Credit for the browser chain belongs to 3em0.

Lessons for builders

When you copy code, check whether its fixes followed

save_preview() was copied from ComfyUI-Custom-Scripts, and the bug was fixed there in December 2024. Easy-Use still shipped the pre-fix version through September 2026. If you vendor or fork a security-relevant function, record where it came from and watch the original for changes. One git log -p on the original file would have caught this. When I find a bug in derived code, I run the same check in reverse: diffing against the parent here took a few minutes and showed the bug was a fix that had never been ported.

Check the final path, after the last join

A containment check that runs before the last path component is added only tells you something about the directory. The rule is mechanical: compute the exact path you will pass to open, copyfile or replace, resolve it, and check that. Do nothing to the path afterwards. os.path.join with an absolute second argument, .., and symlinks are three different ways for anything later to undo the check.

Allowlist the category, not just the path

Even with correct containment, get_full_path(type, ...) with a caller-chosen type lets the caller pick which tree to stay contained in. “Stay inside custom_nodes“ is still a vulnerability. A helper that exists to save model previews should only accept the folder types it serves. The fix’s ("checkpoints", "loras") tuple is the most important line in it.

Don’t let the input decide the output’s type

Taking the destination extension from the source file meant the attacker chose both what was written and what kind of file it became. When an operation produces a specific file type, the code should fix that type and verify the content, as the patch does with Pillow, rather than inherit it from the input.

A plugin’s routes are the host’s attack surface

Every route a custom node registers is served by the same unauthenticated server as ComfyUI’s core API. That doesn’t make every custom node author responsible for ComfyUI’s lack of authentication. It does mean a route that writes files or restarts the process can’t assume the only caller is the extension’s own front end.

References

Reported by Nguyễn Huy Hoàng (hoanggxyuuki), PTIT, through CERT/CC. Thanks to my teacher, Nguyễn Huy Trung, for his guidance, and to Michael Bragg and the CERT/CC coordination team for handling a case with no reachable vendor contact. Public vulnerability records are often revised after publication, so recheck the primary sources above before relying on them for remediation.