gpu: identify a plugin before trusting it, and load only from a named place

A GPU plugin replaces a VM's state transitions wholesale. Three of the four
seams reached one by dlsym'ing launcher names out of a library nothing had
identified, and all four would fall back to a bare filename — which makes the
dynamic loader's default search path, and in bridgevm's case the working
directory of whoever started the node, the thing that decides which code
computes consensus state.

That is not theoretical. A library exporting the five bridgevm launcher names
and nothing else, compiled from twenty lines of C that return 0:

  before, LUX_GPU_PLUGIN_DIR=/tmp/rogue   AutoBackend = hip
  before, no env, cwd holds the .so       AutoBackend = hip
  after,  either                          AutoBackend = none

Two changes, neither of them new machinery.

The plugin header says a loader MUST check abi_version and vtbl_size, and
quantumvm already does, through lux_gpu_backend_init. aivm, bridgevm and mpcvm
now do the same before resolving a single launcher: a name is what was being
matched, and a name is not a contract.

Bare filenames and the CWD entry are gone from all four. What is left is
LUX_GPU_PLUGIN_DIR and LUXCPP_PREFIX — places an operator named. No new
environment variable: unset means no plugin, which is already the supported
configuration and the one every box without a GPU is in.

Verified with the real HIP plugin from lux-private/gpu-kernels: aivm, bridgevm,
mpcvm and quantumvm all build, vet and test green, aivm TestRoundTripAttestation
passes through the loaded plugin, and the rogue library is refused.
This commit is contained in:
2026-08-29 19:04:26 -07:00
parent 67d0853fc2
commit 634df5e5ae
6 changed files with 119 additions and 23 deletions
+32
View File
@@ -34,9 +34,12 @@ package aivm
#cgo darwin LDFLAGS: -ldl
#cgo linux LDFLAGS: -ldl
#cgo CFLAGS: -I${SRCDIR}/../internal/luxgpu/include
#include <dlfcn.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
// Six host-launcher trampolines — invoked by Go via cgo. The function
// pointer is opaque (void*); the cgo bridge casts it to the expected
@@ -136,6 +139,27 @@ static int call_aivm_proof_verify(void* fn, const void* ops, void* results,
return ((aivm_proof_verify_fn)fn)(ops, results, op_count, NULL);
}
#include "lux/gpu/backend_plugin.h"
// aivm_plugin_trusted resolves lux_gpu_backend_init in an already-dlopen'd
// library, runs it, and checks the two cookies the plugin header requires a
// loader to check: abi_version must be the one this build was compiled
// against, and vtbl_size must be our sizeof. Without it, dlsym'ing a launcher
// by name trusts a library nothing has identified — the name is the only thing
// being matched, and a name is not a contract. quantumvm already does this.
//
// 0 trusted | 1 no init symbol | 2 init false | 3 abi | 4 vtbl_size
static int aivm_plugin_trusted(void* h) {
void* sym = dlsym(h, LUX_GPU_BACKEND_INIT_SYMBOL);
if (sym == NULL) return 1;
lux_gpu_backend_desc desc;
memset(&desc, 0, sizeof desc);
if (!((lux_gpu_backend_init_fn)sym)(&desc)) return 2;
if (desc.abi_version != LUX_GPU_BACKEND_ABI_VERSION) return 3;
if (desc.vtbl_size != (uint32_t)sizeof(lux_gpu_backend_vtbl)) return 4;
return 0;
}
// dlopen / dlsym wrappers — kept here so backend.go can stay pure Go.
static void* lux_dlopen(const char* path) {
return dlopen(path, RTLD_NOW | RTLD_LOCAL);
@@ -508,6 +532,14 @@ func openGPUBackend(kind BackendKind, path string) (*GPUBackend, error) {
return nil, fmt.Errorf("aivm: dlopen(%s): %s", path, C.GoString(C.lux_dlerror()))
}
// Identify the library before trusting any symbol in it.
if rc := C.aivm_plugin_trusted(handle); rc != 0 {
C.lux_dlclose(handle)
return nil, fmt.Errorf(
"aivm: %s is not a lux GPU plugin this build can use (check %d)",
path, int(rc))
}
backendName := kind.String() // cuda / hip / metal / vulkan / webgpu
resolve := func(suffix string) (unsafe.Pointer, error) {
+4 -5
View File
@@ -82,9 +82,11 @@ func platformCandidates() []backendCandidate {
// - LUX_GPU_PLUGIN_DIR/build/metal-only/backends/<subdir>/<filename>
// - LUX_GPU_PLUGIN_DIR/build/vulkan-m1/backends/<subdir>/<filename>
// - LUXCPP_PREFIX/lib/<filename>
// - bare filename (DT_RUNPATH / DT_RPATH / system search)
//
// Bare last so an explicit env override always wins.
// Every entry is somewhere an operator named. There is deliberately no bare
// filename: with one the dynamic loader searches its own default path, and a
// GPU plugin replaces aivm's state transitions wholesale, so a library found
// rather than named would be deciding consensus state.
func candidatePaths(c backendCandidate) []string {
var paths []string
if dir := os.Getenv("LUX_GPU_PLUGIN_DIR"); dir != "" {
@@ -107,9 +109,6 @@ func candidatePaths(c backendCandidate) []string {
if dir := os.Getenv("LUXCPP_PREFIX"); dir != "" {
paths = append(paths, filepath.Join(dir, "lib", c.filename))
}
// Bare name — fall back to the loader's default search (DT_RPATH on Linux,
// @rpath on darwin, LD_LIBRARY_PATH / DYLD_LIBRARY_PATH set by user).
paths = append(paths, c.filename)
return paths
}
+42 -10
View File
@@ -38,10 +38,13 @@ package bridgevm
/*
#cgo LDFLAGS: -ldl
#cgo CFLAGS: -I${SRCDIR}/../internal/luxgpu/include
#include <dlfcn.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <string.h>
// Trampolines — we cannot call function pointers from Go directly through cgo.
// The trampoline takes the function pointer (resolved via dlsym from Go) and
@@ -122,6 +125,34 @@ static int call_transition(void* fn,
(void*)0);
}
#include "lux/gpu/backend_plugin.h"
// bvm_plugin_trusted resolves lux_gpu_backend_init in an already-dlopen'd
// library, runs it, and checks the two cookies the plugin header requires a
// loader to check: abi_version must be the one this build was compiled
// against, and vtbl_size must be our sizeof. Without this, dlsym'ing a
// launcher by name trusts a library nothing has identified — the name is the
// only thing being matched, and a name is not a contract.
//
// quantumvm already does exactly this; bridgevm, aivm and mpcvm dlsym'd
// blind. One way, and this is it.
//
// 0 — trusted
// 1 — no lux_gpu_backend_init (not one of our plugins)
// 2 — init returned false
// 3 — abi_version mismatch
// 4 — vtbl_size mismatch
static int bvm_plugin_trusted(void* h) {
void* sym = dlsym(h, LUX_GPU_BACKEND_INIT_SYMBOL);
if (sym == NULL) return 1;
lux_gpu_backend_desc desc;
memset(&desc, 0, sizeof desc);
if (!((lux_gpu_backend_init_fn)sym)(&desc)) return 2;
if (desc.abi_version != LUX_GPU_BACKEND_ABI_VERSION) return 3;
if (desc.vtbl_size != (uint32_t)sizeof(lux_gpu_backend_vtbl)) return 4;
return 0;
}
// dl helpers — wrap dlopen / dlsym / dlerror so the Go side doesn't have to
// deal with the C string lifetimes.
@@ -198,11 +229,6 @@ func probePlugin() {
setActiveBackend(bk)
return
}
// Bare-name fallback — let the dynamic loader use its default path.
if tryLoad(bk, dsoBareName(bk)) {
setActiveBackend(bk)
return
}
}
}
@@ -225,11 +251,12 @@ func candidatePluginPaths(bk Backend) []string {
paths = append(paths, filepath.Join(prefix, "lib", name))
}
// 4) CWD — last resort before falling back to the loader default.
if cwd, err := os.Getwd(); err == nil {
paths = append(paths, filepath.Join(cwd, name))
}
// Deliberately no CWD entry and no bare-name fallback. A GPU plugin
// replaces bridgevm's state transitions wholesale, so where it comes from
// has to be something an operator said, not something the loader found:
// with a bare name the dynamic loader searches its default path, and with
// CWD the working directory of whoever started the node decides which
// code computes consensus state. Both are named above or nothing loads.
return paths
}
@@ -274,6 +301,11 @@ func tryLoad(bk Backend, path string) bool {
return false
}
// Identify the library before trusting any symbol in it.
if rc := C.bvm_plugin_trusted(h); rc != 0 {
return false
}
prefix := "lux_" + bk.String() + "_bridgevm_"
syms := map[string]*unsafe.Pointer{
prefix + "signer_apply": &fnSignerApply,
+5 -3
View File
@@ -87,9 +87,11 @@ func candidatesFor(kind GPUBackendKind) []string {
}
}
// Bare leaf names — dynamic-linker default search picks them up if
// LD_LIBRARY_PATH / DYLD_LIBRARY_PATH or /usr/local/lib has them.
out = append(out, leaves...)
// Deliberately no bare leaf names. With one the dynamic linker searches
// its own default path, and a GPU plugin replaces mpcvm's ceremony and
// key-share transitions wholesale — a library found rather than named
// would be minting threshold-signing custody. Every entry above is
// somewhere an operator said.
return out
}
+28
View File
@@ -49,6 +49,8 @@ package mpcvm
#cgo darwin LDFLAGS: -ldl
#cgo linux LDFLAGS: -ldl
#cgo CFLAGS: -I${SRCDIR}/../internal/luxgpu/include
#include <dlfcn.h>
#include <stdint.h>
#include <stdlib.h>
@@ -190,6 +192,27 @@ static void* lux_dlsym(void* handle, const char* name) {
// if every candidate fails. The dlopen mode is RTLD_NOW|RTLD_LOCAL: NOW so
// any missing symbol fails fast at load time, LOCAL so the plugin's
// internal symbols don't pollute the global namespace.
#include "lux/gpu/backend_plugin.h"
// mpcvm_plugin_trusted resolves lux_gpu_backend_init in an already-dlopen'd
// library, runs it, and checks the two cookies the plugin header requires a
// loader to check: abi_version must be the one this build was compiled
// against, and vtbl_size must be our sizeof. Without it, dlsym'ing a launcher
// by name trusts a library nothing has identified — the name is the only thing
// being matched, and a name is not a contract. quantumvm already does this.
//
// 0 trusted | 1 no init symbol | 2 init false | 3 abi | 4 vtbl_size
static int mpcvm_plugin_trusted(void* h) {
void* sym = dlsym(h, LUX_GPU_BACKEND_INIT_SYMBOL);
if (sym == NULL) return 1;
lux_gpu_backend_desc desc;
memset(&desc, 0, sizeof desc);
if (!((lux_gpu_backend_init_fn)sym)(&desc)) return 2;
if (desc.abi_version != LUX_GPU_BACKEND_ABI_VERSION) return 3;
if (desc.vtbl_size != (uint32_t)sizeof(lux_gpu_backend_vtbl)) return 4;
return 0;
}
static void* lux_dlopen_first(const char* a, const char* b, const char* c) {
void* h = NULL;
if (a && *a) { h = dlopen(a, RTLD_NOW|RTLD_LOCAL); if (h) return h; }
@@ -790,6 +813,11 @@ func tryLoadPlugin(kind GPUBackendKind, candidates ...string) *GPUBackend {
return nil
}
// Identify the library before trusting any symbol in it.
if rc := C.mpcvm_plugin_trusted(h); rc != 0 {
return nil
}
resolve := func(name string) unsafe.Pointer {
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
+8 -5
View File
@@ -318,12 +318,15 @@ func init() {
func tryProbe() (*gpuBackend, bool) {
dir := os.Getenv(pluginEnv)
for _, p := range probeOrder {
if dir == "" {
// No bare-name attempt: with one the dynamic loader searches its
// own default path, so a library found rather than named would be
// running Q-Chain verification. The cookies below identify a
// plugin; they do not say where it should have come from.
continue
}
for _, bn := range pluginBasenames(p.name) {
path := bn
if dir != "" {
path = filepath.Join(dir, bn)
}
b, err := openPlugin(p.kind, path)
b, err := openPlugin(p.kind, filepath.Join(dir, bn))
if err == nil {
return b, true
}