Safeguard
Vulnerability Analysis•

CVE-2026-49950: Xoscar's Actor Pool Unpickles Before It Checks, and There Is No Patch Yet

CVE-2026-49950 is a critical remote code execution bug in Xoscar, the Python actor framework that the Xinference model-serving platform is built on. Anyone who can reach an Xoscar actor pool over TCP can run code as the process that owns it. No authentication is needed.

Safeguard Security Team
Security Research
10 min read

CVE-2026-49950 is a critical remote code execution bug in Xoscar, the Python actor framework that the Xinference model-serving platform is built on. Anyone who can reach an Xoscar actor pool over TCP can run code as the process that owns it. No authentication is needed. As of 14 October 2026 there is no fixed release, and the most common Python dependency scanners don't flag it.

This post is for teams running Xinference, or anything else built on Xoscar, on GPU servers or in Kubernetes. It covers what the bug is, why the default network settings matter more than usual, a script that finds exposed actor-pool listeners on a host, and what to do until a patch ships.

The short answer

CVE-2026-49950 affects Xoscar 0.1.0 through 0.11.2, which is the latest release. It has no fix yet. The actor pool's TCP channel passes incoming bytes to cloudpickle.loads() before checking what kind of message they are. Unpickling attacker-controlled data runs attacker-chosen code. VulnCheck published the CVE on 13 October 2026, with CVSS 9.8 (v3.1) and 9.3 (v4.0). The CVE record credits Valentin Lobstein (Chocapikk) with the finding. Until a patched release exists, the only defence is network isolation: make sure nothing outside the cluster can connect to any actor-pool port.

CVECVE-2026-49950 (GHSA-5rfg-55mw-4h28)
Componentxoscar on PyPI, used by xinference (which requires xoscar>=0.10.0)
Affected0.1.0 through 0.11.2 (released 7 October 2026)
Fixed inNone as of 14 October 2026
WeaknessCWE-502, deserialization of untrusted data
PreconditionNetwork access to an actor-pool port

What goes wrong in the actor pool

Xoscar lets Python processes on one or many machines call each other's "actors" as if they were local objects. Each process runs an actor pool that listens on a TCP port, and the pools exchange messages over that socket.

Messages are serialized with cloudpickle. In the 0.11.2 source, SocketChannel.recv() reads a header and buffers from the socket and hands them straight to deserialize(). For pickled payloads that ends in unpickle_buffers(), which calls cloudpickle.loads(buffers[0], ...). Nothing before that point checks who is sending or what they are allowed to send.

Pickle is not a data format in the safe sense. A pickle stream can instruct the loader to call any importable function with any arguments, which is how objects define their own reconstruction (__reduce__). So pickle.loads() on untrusted input is equivalent to eval() on untrusted input. Python's own documentation says never to unpickle data from an untrusted source, and our guide to pickle deserialization risk covers the mechanics.

The advisory's key detail is the ordering. The gadget runs during deserialization, before Xoscar inspects the message type. Validating the message afterwards can't help, because by then the attacker's code has already run. The CVE record adds that the server process "commonly runs as root on GPU nodes", which makes the impact a full host compromise on many deployments.

We are not publishing a proof of concept. The description above is the level of detail already in the public advisory, and there is no patch to point people to yet.

Why the defaults matter here

Whether you are exposed depends almost entirely on where the actor pools listen. We read Xinference's command-line code and ran Xoscar 0.11.2 locally to check.

Distributed mode listens on every interface by default. xinference-supervisor and xinference-worker default --host to 0.0.0.0. The supervisor's actor pool binds to host:<supervisor-port>, and if you don't pass --supervisor-port, it picks a free port at random. Workers do the same with --worker-port.

Local mode is safer, until you change it. xinference-local defaults to 127.0.0.1, and its actor pool binds to that host plus a random port. But Xinference's Docker instructions run xinference-local -H 0.0.0.0 and say the flag can't be omitted, because otherwise the API inside the container is unreachable. That flag also moves the unauthenticated actor pool onto every interface. With plain docker run -p 9997:9997, only the API port is published on the host. The actor pool is still reachable from other containers on the same Docker network, from anything that can reach the pod IP in Kubernetes, and from the network itself if you use host networking.

There is more than one port. When we created a pool with two sub-processes on 0.0.0.0, the main process and each sub-process opened their own listener:

$ lsof -nP -iTCP -sTCP:LISTEN | grep -i python
Python  87460 ...  TCP *:35741 (LISTEN)
Python  87527 ...  TCP *:54313 (LISTEN)
Python  87528 ...  TCP *:54314 (LISTEN)

Random ports on all interfaces are a bad combination for firewalling. A rule that blocks "the Xinference port" doesn't cover them, and a port scan finds them anyway.

Your scanner probably says you're fine

The GitHub advisory for CVE-2026-49950 is unreviewed and lists no affected package at all. The CVE record identifies the product only by its GitHub repository (github.com/xorbitsai/xoscar), not as a PyPI package. OSV, which feeds pip-audit, returned no result for xoscar 0.11.2 when we queried it on 14 October. We ran pip-audit against a requirements file pinning the vulnerable version:

$ echo "xoscar==0.11.2" > req.txt
$ pip-audit -r req.txt --no-deps --disable-pip
No known vulnerabilities found

This will probably change once the advisory is reviewed and mapped to the PyPI package. Until then, a clean scan says nothing about this bug. If you run Xinference, assume you're affected and check by hand.

Find exposed actor pools on a host

This script finds processes that have Xoscar loaded (by its native extension in the process's memory map, or xinference/xoscar on the command line), lists their TCP listeners, and flags any that aren't on loopback. It needs psutil. Run it as root to see other users' processes.

#!/usr/bin/env python3
"""List TCP listeners opened by processes that have Xoscar loaded (Xinference and
other Xoscar apps), and flag any that are not bound to loopback.
Needs psutil (pip install psutil). Run as root to see other users' processes."""
import ipaddress, subprocess, psutil

def mapped_files(p):
    try:
        return [m.path for m in p.memory_maps()]            # Linux, Windows
    except AttributeError:                                  # macOS: ask lsof
        out = subprocess.run(["lsof", "-Fn", "-p", str(p.pid)], capture_output=True, text=True).stdout
        return [l[1:] for l in out.splitlines() if l.startswith("n")]

def has_xoscar(p):
    try:
        if any("xoscar" in f for f in mapped_files(p)):
            return True
    except (psutil.AccessDenied, psutil.NoSuchProcess):
        pass
    try:
        return any("xinference" in a or "xoscar" in a for a in p.cmdline())
    except (psutil.AccessDenied, psutil.NoSuchProcess):
        return False

exposed = 0
for p in psutil.process_iter(["pid", "name", "username"]):
    if not has_xoscar(p):
        continue
    try:
        listens = [c for c in p.net_connections(kind="tcp") if c.status == psutil.CONN_LISTEN]
    except (psutil.AccessDenied, psutil.NoSuchProcess):
        print(f"pid {p.pid}: access denied, rerun as root"); continue
    for c in listens:
        ip = c.laddr.ip
        local = ip not in ("0.0.0.0", "::") and ipaddress.ip_address(ip.split("%")[0]).is_loopback
        exposed += not local
        print(f"{'ok      ' if local else 'EXPOSED '} pid {p.pid:<7} user {p.info['username']:<12} {ip}:{c.laddr.port}")
print(f"\n{exposed} listener(s) reachable from other hosts")
raise SystemExit(1 if exposed else 0)

We tested it with two Xoscar 0.11.2 pools running side by side, one on 0.0.0.0 with two sub-processes and one on 127.0.0.1 with one:

EXPOSED  pid 90173   user dev          0.0.0.0:10218
ok       pid 90175   user dev          127.0.0.1:16744
ok       pid 90181   user dev          127.0.0.1:54361
EXPOSED  pid 90182   user dev          0.0.0.0:54359
EXPOSED  pid 90183   user dev          0.0.0.0:54360

3 listener(s) reachable from other hosts

"EXPOSED" means bound to all interfaces, not necessarily reachable from the internet. A cloud security group or host firewall may still block it, so check that next. In a distributed cluster, the supervisor and workers have to reach each other, so some exposure on the private network is expected. The question is whether anything else can connect.

What to do until there is a patch

  1. Single-node Xinference: keep the default 127.0.0.1 on bare metal. If the API needs to be reachable remotely, put a reverse proxy on the same host in front of port 9997 instead of passing --host 0.0.0.0, and the actor pool stays on loopback. In Docker, where -H 0.0.0.0 is required, publish only port 9997, avoid host networking, and keep the container off shared networks with untrusted workloads.
  2. Distributed Xinference: bind to the private interface, not 0.0.0.0. Pass --host <private-ip> to the supervisor and workers, and fix their ports with --supervisor-port and --worker-port so they are predictable.
  3. Firewall by source, not by port. Sub-process ports are random, so allow inbound traffic to cluster nodes only from the other cluster nodes, and allow everyone else only to the API port. In Kubernetes, a NetworkPolicy expresses exactly that:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: xinference-isolate
  namespace: inference
spec:
  podSelector:
    matchLabels:
      app: xinference
  policyTypes: ["Ingress"]
  ingress:
    - from:                       # supervisor and workers: any port, peers only
        - podSelector:
            matchLabels:
              app: xinference
    - from:                       # everyone else: the REST API only
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
      ports:
        - protocol: TCP
          port: 9997

Adjust the labels and the ingress namespace to your setup, and check that your CNI actually enforces NetworkPolicy, because some don't. We validated the manifest's structure but haven't run it against a live cluster. 4. Don't run the server as root. Container images that run model servers as root turn this bug into host compromise. A non-root user doesn't fix the bug, but it limits what the injected code can do. 5. Remember shared GPU boxes. Binding to 127.0.0.1 stops other machines, not other users on the same machine. On a shared research server, any local account can connect to a loopback actor pool. 6. Watch for the fix. Track xorbitsai/xoscar releases. Because your scanner won't warn you yet, add a manual check to CI for anything that installs Xinference, for example python -c "import importlib.metadata as m; print(m.version('xoscar'))", and treat 0.11.2 or older as vulnerable.

The pattern behind it

Distributed Python frameworks keep making the same trade. Inside a "trusted" cluster, pickling arbitrary objects between processes is convenient, and authentication feels like overhead. Ray took the same position for its job API: CVE-2023-48022 is marked disputed in NVD because the vendor says Ray is not meant to be used outside a strictly controlled network. That is a legitimate design choice. But it means the network boundary is the only control, and GPU clusters are often set up quickly by people optimising for throughput, with 0.0.0.0 in the quick-start command.

It's also worth being fair to the project. Xoscar is an internal transport, not a public API, and nothing in its design suggested it should face untrusted networks. The bug is real because deployments put it there. Xinference has already fixed one critical code execution bug this year (CVE-2026-61539, an eval() in tool-call parsing, fixed in 2.7.0). Expect an Xoscar fix to follow, and plan for the next one by assuming the actor pools are a root shell for anyone who can reach them.

For a dependency like this, the useful question isn't just "which version do we run?" but "which of our services pull it in, and where are they reachable?" Answering that across every repository at once is what Safeguard SCA does. Keep in mind that every tool, ours included, depends on advisories that can lag the disclosure, as this one does.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.