mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
fix(plugin): guard against wasm guests with no memory section (F2)
The guest's linear memory was taken from mod.Memory() and used unchecked, so an untrusted plugin wasm with no memory section nil-dereferenced on the unrecovered startup path and crashed the server. All guest-memory access now goes through one guestMemory() helper that detects wazero's non-nil interface wrapping a nil *MemoryInstance, binding no commands at activation and returning the existing missing-export diagnostic on dispatch. Verified by a panel of agents; the added regression test panics with the finding's exact stack against the unpatched tree. Note: TestRegistry_Activate_WithoutRuntime and TestRegistry_EnablePlugin_RollsBackWhenActivationFails fail under -tags wazero, confirmed here to fail identically on the base tree. They are pre-existing and unrelated; CI builds the wazero variant but does not test it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -49,6 +50,25 @@ import (
|
||||
// wazeroPageBytes is the size of a single WASM linear-memory page (64 KiB).
|
||||
const wazeroPageBytes = 65536
|
||||
|
||||
// guestMemory returns the module's linear memory, or nil when the guest
|
||||
// declared none. It exists because api.Module.Memory() is NOT safe to
|
||||
// dereference blindly: wazero returns its *wasm.MemoryInstance field as-is, and
|
||||
// that field is only populated for a module with a memory section — so a
|
||||
// memoryless guest yields a non-nil api.Memory interface wrapping a nil
|
||||
// pointer, and every method on it (Read, Write, Size) panics. A plain
|
||||
// `mem == nil` check does not catch that, hence the pointer-level check here.
|
||||
// Every host access to guest memory must go through this helper.
|
||||
func guestMemory(mod api.Module) api.Memory {
|
||||
mem := mod.Memory()
|
||||
if mem == nil {
|
||||
return nil
|
||||
}
|
||||
if v := reflect.ValueOf(mem); v.Kind() == reflect.Ptr && v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
return mem
|
||||
}
|
||||
|
||||
// platformInit stands up the shared wazero runtime for this Registry. The
|
||||
// runtime is the top-level handle that owns compiled modules, host modules,
|
||||
// and per-instance linear memory; every plugin in this registry shares it.
|
||||
@@ -237,6 +257,15 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch
|
||||
Reply: fmt.Sprintf("plugin %s: missing allocate export (required for command dispatch)", inst.Manifest.Name),
|
||||
}, true
|
||||
}
|
||||
// A guest that declares no linear memory has no usable JSON ABI. Checked
|
||||
// here, with the other ABI preconditions and before any guest call, rather
|
||||
// than dereferenced blindly further down.
|
||||
mem := guestMemory(mod)
|
||||
if mem == nil {
|
||||
return &CommandResult{
|
||||
Reply: fmt.Sprintf("plugin %s: no exported memory (required for command dispatch)", inst.Manifest.Name),
|
||||
}, true
|
||||
}
|
||||
|
||||
type dispatchPayload struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
@@ -287,7 +316,6 @@ func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, ch
|
||||
}
|
||||
ptr := ptrs[0]
|
||||
|
||||
mem := mod.Memory()
|
||||
if !mem.Write(uint32(ptr), payload) {
|
||||
return &CommandResult{Reply: fmt.Sprintf("plugin %s: memory write at %d failed", inst.Manifest.Name, ptr)}, true
|
||||
}
|
||||
@@ -350,8 +378,9 @@ func (r *Registry) releaseClosedModule(inst *Instance, mod api.Module) {
|
||||
|
||||
// listExportedCommands calls the plugin's optional `list_commands` export
|
||||
// which returns (ptr u32, len u32) pointing to a JSON array of command name
|
||||
// strings. If the export is absent or returns invalid JSON, an empty slice
|
||||
// is returned and no command bindings are created.
|
||||
// strings. If the export is absent, the module declares no linear memory, or
|
||||
// the result is invalid JSON, an empty slice is returned and no command
|
||||
// bindings are created.
|
||||
func listExportedCommands(ctx context.Context, mod api.Module) []string {
|
||||
fn := mod.ExportedFunction("list_commands")
|
||||
if fn == nil {
|
||||
@@ -361,8 +390,14 @@ func listExportedCommands(ctx context.Context, mod api.Module) []string {
|
||||
if err != nil || len(results) < 2 {
|
||||
return nil
|
||||
}
|
||||
// A guest with no memory section has no memory to read the name list from
|
||||
// (and its api.Memory must not be touched — see guestMemory).
|
||||
mem := guestMemory(mod)
|
||||
if mem == nil {
|
||||
return nil
|
||||
}
|
||||
ptr, length := uint32(results[0]), uint32(results[1])
|
||||
raw, ok := mod.Memory().Read(ptr, length)
|
||||
raw, ok := mem.Read(ptr, length)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -367,6 +367,84 @@ func TestWazeroConcurrentDispatchRace(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// noMemWASM exports the command ABI but declares NO memory section, so
|
||||
// api.Module.Memory() returns nil for it:
|
||||
//
|
||||
// (module
|
||||
// (func (export "list_commands") (result i32 i32) i32.const 0 i32.const 0)
|
||||
// (func (export "allocate") (param i32) (result i32) i32.const 0)
|
||||
// (func (export "command_dispatch") (param i32 i32) (result i32 i32)
|
||||
// i32.const 0 i32.const 0))
|
||||
var noMemWASM = []byte{
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // \0asm v1
|
||||
// type section: ()->(i32,i32), (i32)->i32, (i32,i32)->(i32,i32)
|
||||
0x01, 0x12, 0x03,
|
||||
0x60, 0x00, 0x02, 0x7f, 0x7f,
|
||||
0x60, 0x01, 0x7f, 0x01, 0x7f,
|
||||
0x60, 0x02, 0x7f, 0x7f, 0x02, 0x7f, 0x7f,
|
||||
// function section: 3 funcs using types 0,1,2
|
||||
0x03, 0x04, 0x03, 0x00, 0x01, 0x02,
|
||||
// (no memory section — this is the point of the fixture)
|
||||
// export section: list_commands, allocate, command_dispatch
|
||||
0x07, 0x2f, 0x03,
|
||||
0x0d, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x00, 0x00,
|
||||
0x08, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x65, 0x00, 0x01,
|
||||
0x10, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x00, 0x02,
|
||||
// code section
|
||||
0x0a, 0x14, 0x03,
|
||||
0x06, 0x00, 0x41, 0x00, 0x41, 0x00, 0x0b, // list_commands: (0,0)
|
||||
0x04, 0x00, 0x41, 0x00, 0x0b, // allocate: 0
|
||||
0x06, 0x00, 0x41, 0x00, 0x41, 0x00, 0x0b, // command_dispatch: (0,0)
|
||||
}
|
||||
|
||||
// TestWazeroMemorylessModuleDoesNotPanic locks the nil-memory guard: a guest
|
||||
// that exports the command ABI but declares no memory section must be handled
|
||||
// as a bad plugin, not dereferenced. Both host paths that touch guest memory
|
||||
// are covered — activation (list_commands) and dispatch (command_dispatch) —
|
||||
// because a panic on the activation path aborts server startup for every
|
||||
// subsequent restart while the plugin row stays enabled.
|
||||
func TestWazeroMemorylessModuleDoesNotPanic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
manifest := `{"name":"nomem","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"],"commands":[{"name":"noop"}]}`
|
||||
writeTestPlugin(t, dir, "nomem", manifest, noMemWASM)
|
||||
|
||||
reg, mem := newWazeroTestRegistry(t, dir)
|
||||
ctx := context.Background()
|
||||
if err := reg.LoadAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, _ := mem.ListPlugins(ctx)
|
||||
// Activation calls list_commands, which used to nil-deref the guest memory.
|
||||
if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil {
|
||||
t.Fatalf("EnablePlugin: %v", err)
|
||||
}
|
||||
reg.mu.RLock()
|
||||
inst := reg.plugins[rows[0].ID]
|
||||
reg.mu.RUnlock()
|
||||
if inst == nil || inst.module == nil {
|
||||
t.Fatal("expected the module to activate")
|
||||
}
|
||||
// No command may bind: the name list is unreadable without memory.
|
||||
reg.mu.RLock()
|
||||
_, bound := reg.commands["noop"]
|
||||
reg.mu.RUnlock()
|
||||
if bound {
|
||||
t.Fatal("memoryless module must not auto-bind commands")
|
||||
}
|
||||
|
||||
// The dispatch path must report a diagnostic instead of writing into nil.
|
||||
if err := reg.RegisterCommand("noop", inst); err != nil {
|
||||
t.Fatalf("RegisterCommand: %v", err)
|
||||
}
|
||||
result, ok := reg.DispatchCommand(ctx, 1, 2, "noop", nil)
|
||||
if !ok || result == nil {
|
||||
t.Fatalf("expected a dispatch result: ok=%v result=%+v", ok, result)
|
||||
}
|
||||
if !strings.Contains(result.Reply, "no exported memory") {
|
||||
t.Fatalf("expected a missing-memory diagnostic, got %q", result.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWazeroInvalidWASMFailsActivation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
manifest := `{"name":"brokey","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
|
||||
|
||||
Reference in New Issue
Block a user