From c2a9b074f424154317b9daa7e56d7c0e58fcd3c0 Mon Sep 17 00:00:00 2001
From: Meo597 <197331664+Meo597@users.noreply.github.com>
Date: Thu, 15 Oct 2026 03:57:24 +0800
Subject: [PATCH] lua: redact memory addresses in sandbox errors
---
common/lua/program.go | 41 +++++++-
common/lua/program_test.go | 194 +++++++++++++++++++++++++++++++++++++
2 files changed, 233 insertions(+), 2 deletions(-)
diff --git a/common/lua/program.go b/common/lua/program.go
index d58700e5f..33b9a9a5f 100644
--- a/common/lua/program.go
+++ b/common/lua/program.go
@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"os"
+ "regexp"
"strings"
"time"
@@ -128,10 +129,10 @@ func newSandboxedState() *glua.LState {
result = value.String()
case glua.LTUserData:
switch native := value.(*glua.LUserData).Value.(type) {
+ case error: // Errors can also implement fmt.Stringer; redact them first.
+ result = redactMemoryAddresses(native.Error())
case fmt.Stringer:
result = native.String()
- case error:
- result = native.Error()
}
}
return glua.LString(result)
@@ -160,6 +161,36 @@ func newSandboxedState() *glua.LState {
return 1
}))
+ // Redact addresses before caught errors become readable by the script.
+ redactError := func(value glua.LValue) glua.LValue {
+ if message, ok := value.(glua.LString); ok {
+ return glua.LString(redactMemoryAddresses(string(message)))
+ }
+ return value
+ }
+ for _, name := range []string{"pcall", "xpcall"} {
+ call := L.GetGlobal(name)
+ L.SetGlobal(name, L.NewFunction(func(L *glua.LState) int {
+ nargs := L.GetTop()
+ if name == "xpcall" {
+ L.CheckFunction(1)
+ handler := L.CheckFunction(2)
+ L.Replace(2, L.NewFunction(func(L *glua.LState) int {
+ L.Replace(1, redactError(L.Get(1)))
+ L.Insert(handler, 1)
+ L.Call(1, 1)
+ return 1
+ }))
+ }
+ L.Insert(call, 1)
+ L.Call(nargs, glua.MultRet)
+ if L.Get(1) == glua.LFalse {
+ L.Replace(2, redactError(L.Get(2)))
+ }
+ return L.GetTop()
+ }))
+ }
+
// Prevent seed output from compromising cryptographic security.
L.GetGlobal(glua.MathLibName).(*glua.LTable).RawSetString("randomseed", glua.LNil)
@@ -185,6 +216,12 @@ func newSandboxedState() *glua.LState {
return L
}
+var memoryAddressPattern = regexp.MustCompile(`0[xX][0-9a-fA-F]+`)
+
+func redactMemoryAddresses(message string) string {
+ return memoryAddressPattern.ReplaceAllString(message, "
")
+}
+
// NewStateFactory returns a factory that gives each state an initialization timeout.
func (p *Program) NewStateFactory(initTimeout time.Duration, register func(*glua.LState), validate func(*glua.LState) error) LStateFactory {
return func(ctx context.Context) (*glua.LState, error) {
diff --git a/common/lua/program_test.go b/common/lua/program_test.go
index 0ffc4da82..38d9468bb 100644
--- a/common/lua/program_test.go
+++ b/common/lua/program_test.go
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/hex"
"errors"
+ "fmt"
"net"
"os"
"path/filepath"
@@ -18,6 +19,7 @@ import (
"time"
"weak"
+ xerrors "github.com/xtls/xray-core/common/errors"
glua "github.com/yuin/gopher-lua"
luar "layeh.com/gopher-luar"
)
@@ -631,6 +633,7 @@ for _, convert in ipairs({tostring, require("_G").tostring}) do
assert(convert(true) == "true" and convert(false) == "false")
assert(convert(0) == "0" and convert(-42) == "-42" and convert(1.25) == "1.25")
assert(convert("") == "" and convert("hello") == "hello")
+ assert(convert("0xdeadbeef") == "0xdeadbeef")
assert(convert(42, {}) == "42")
assert(not pcall(convert))
assert(convert(testIPv4) == "192.0.2.1")
@@ -695,6 +698,7 @@ assert(string.format == require("string").format and string.format == ("").forma
for _, format in ipairs({string.format, require("string").format, ("").format}) do
assert(format("%s %04d %.2f %t", "hello", 42, 1.25, true) == "hello 0042 1.25 true")
assert(format("%q", "hello") == '"hello"')
+ assert(format("%s", "0xdeadbeef") == "0xdeadbeef")
assert(format("%[2]s:%[1]d", 42, "hello") == "hello:42")
assert(format("%%p") == "%p")
assert(format("%%p", {}) == format("%%p", ""))
@@ -754,6 +758,189 @@ assert(("%s"):format({}) == "")
}
}
+func TestProgramSandboxProtectedCalls(t *testing.T) {
+ program := compileTestProgram(t, `
+assert(pcall == require("_G").pcall and xpcall == require("_G").xpcall)
+local object = {}
+local function succeed(a, b, c)
+ assert(a == 42 and b == nil and c == object)
+ return nil, object, "0xdeadbeef"
+end
+local ok, a, b, c = pcall(succeed, 42, nil, object)
+assert(ok and a == nil and b == object and c == "0xdeadbeef")
+assert(select("#", pcall(succeed, 42, nil, object)) == 4)
+local function noArguments(...)
+ assert(select("#", ...) == 0)
+ return nil, object, nil
+end
+local function unexpectedHandler() error("handler must not run") end
+ok, a, b, c = xpcall(noArguments, unexpectedHandler, "ignored")
+assert(ok and a == nil and b == object and c == nil)
+assert(select("#", xpcall(noArguments, unexpectedHandler)) == 4)
+ok, a = pcall(testCallable, 42)
+assert(ok and a == 42)
+ok, a = pcall(nil)
+assert(not ok and a == "attempt to call a nil value")
+ok, a = pcall(pcall)
+assert(not ok and string.find(a, "bad argument #1", 1, true))
+ok, a = pcall(xpcall, nil, nil)
+assert(not ok and string.find(a, "bad argument #1", 1, true))
+ok, a = pcall(xpcall, noArguments, nil)
+assert(not ok and string.find(a, "bad argument #2", 1, true))
+
+local businessError = "DNS lookup timed out (192.0.2.1)"
+ok, a = pcall(error, businessError, 0)
+assert(not ok and a == businessError)
+ok, a = xpcall(function() error(businessError, 0) end, function(message)
+ assert(message == businessError)
+ return "handled: " .. message, "ignored"
+end)
+assert(not ok and a == "handled: " .. businessError)
+local message = "lookup at 0x12Ab / 0XFE90; 2001:db8::1; 0x"
+local redacted = "lookup at / ; 2001:db8::1; 0x"
+ok, a = pcall(error, message, 0)
+assert(not ok and a == redacted)
+ok, a = pcall(error, "business id=0xdeadbeef", 0)
+assert(not ok and a == "business id=")
+for _, value in ipairs({object, testObject, true, 42}) do
+ ok, a = pcall(error, value, 0)
+ assert(not ok and a == value)
+ ok, a = xpcall(function() error(value, 0) end, function(caught)
+ assert(caught == value)
+ return caught
+ end)
+ assert(not ok and a == value)
+end
+ok, a = pcall(error, nil, 0)
+assert(not ok and a == nil)
+
+local conversion = "cannot use function: (type *lua.LFunction) as type string"
+ok, a = pcall(testTakeString, function() end)
+assert(not ok and string.find(a, conversion, 1, true))
+ok, a = xpcall(function() testTakeString(function() end) end, function(caught)
+ testSendError(caught)
+ return caught
+end)
+assert(not ok and string.find(a, conversion, 1, true))
+ok, a = pcall(function() testValues[1] = testObject end)
+assert(not ok and string.find(a, "", 1, true) and not string.find(a, "0x", 1, true))
+assert(string.find(a, "as type int", 1, true))
+testValues[1] = 7
+local extended = testValues + 9
+assert(testValues[1] == 7 and #extended == 2 and extended[2] == 9)
+ok, a = pcall(testPanic)
+assert(not ok and a == "{ }")
+ok, a = xpcall(testPanic, function(caught)
+ testSendError(caught)
+ return caught
+end)
+assert(not ok and a == "{ }")
+ok, a = xpcall(function() error(message, 0) end, function(caught)
+ assert(caught == redacted)
+ error("handler failed at 0xABCD", 0)
+end)
+assert(not ok and a == "handler failed at ")
+ok, a = xpcall(function() error(businessError, 0) end, testPanic)
+assert(not ok and a == "{ }")
+`)
+ var sent []string
+ L, err := program.NewState(context.Background(), func(L *glua.LState) {
+ callable := L.NewTable()
+ metatable := L.NewTable()
+ metatable.RawSetString("__call", L.NewFunction(func(L *glua.LState) int {
+ L.Push(L.Get(2))
+ return 1
+ }))
+ L.SetMetatable(callable, metatable)
+ L.SetGlobal("testCallable", callable)
+ L.SetGlobal("testTakeString", luar.New(L, func(string) {}))
+ target := 42
+ PushUserData(L, struct{ Pointer *int }{&target})
+ L.SetGlobal("testObject", L.Get(-1))
+ L.Pop(1)
+ NewSlicePusher[int](L)(L, []int{1})
+ L.SetGlobal("testValues", L.Get(-1))
+ L.Pop(1)
+ L.SetGlobal("testPanic", L.NewFunction(func(L *glua.LState) int {
+ panic(struct{ First, Second *int }{&target, &target})
+ }))
+ L.SetGlobal("testSendError", L.NewFunction(func(L *glua.LState) int {
+ message := L.CheckString(1)
+ if strings.Contains(message, "0x") || strings.Contains(message, "0X") {
+ t.Errorf("outbound error contains an address: %q", message)
+ }
+ sent = append(sent, message)
+ return 0
+ }))
+ }, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer L.Close()
+ if len(sent) != 2 || !strings.Contains(sent[0], "cannot use function: ") || sent[1] != "{ }" {
+ t.Fatalf("outbound errors = %q", sent)
+ }
+ if L.GetTop() != 0 {
+ t.Fatalf("stack top = %d, want 0", L.GetTop())
+ }
+}
+
+func TestProgramSandboxGoErrorStrings(t *testing.T) {
+ program := compileTestProgram(t, `
+for name, expected in pairs(testErrorMessages) do
+ local value = _G[name]
+ assert(tostring(value) == expected, name)
+ assert(string.format("%s", value) == expected, name)
+ assert(string.format("%p", value) == string.format("%p", expected), name)
+ assert(string.format("%#v", value) == string.format("%#v", expected), name)
+ local ok, caught = pcall(error, value, 0)
+ assert(not ok and caught == value and tostring(caught) == expected, name)
+ ok, caught = xpcall(function() error(value, 0) end, function(caught)
+ assert(caught == value and tostring(caught) == expected, name)
+ return caught
+ end)
+ assert(not ok and caught == value, name)
+end
+`)
+ nativeErrors := make(map[string]error)
+ originalMessages := make(map[string]string)
+ L, err := program.NewState(context.Background(), func(L *glua.LState) {
+ luaError := &glua.ApiError{Object: L.NewFunction(func(*glua.LState) int { return 0 })}
+ stringerError := xerrors.New("DNS query failed").Base(luaError)
+ messages := L.NewTable()
+ for _, tc := range []struct {
+ name string
+ err error
+ expected string
+ }{
+ {"nativeError", errors.New("lookup failed at 0x12Ab and 0XFe90"), "lookup failed at and "},
+ {"luaError", luaError, "function: "},
+ {"wrappedError", fmt.Errorf("DNS query failed: %w", luaError), "DNS query failed: function: "},
+ {"stringerError", stringerError, strings.ReplaceAll(stringerError.Error(), luaError.Object.String(), "function: ")},
+ } {
+ nativeErrors[tc.name] = tc.err
+ originalMessages[tc.name] = tc.err.Error()
+ PushError(L, tc.err)
+ L.SetGlobal(tc.name, L.Get(-1))
+ L.Pop(1)
+ messages.RawSetString(tc.name, glua.LString(tc.expected))
+ }
+ L.SetGlobal("testErrorMessages", messages)
+ }, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer L.Close()
+ for name, native := range nativeErrors {
+ if got := ReadError(L.GetGlobal(name), "invalid"); got != native {
+ t.Fatalf("%s error identity changed", name)
+ }
+ if got := native.Error(); got != originalMessages[name] {
+ t.Fatalf("%s host error = %q, want %q", name, got, originalMessages[name])
+ }
+ }
+}
+
func TestProgramSandboxModuleFailures(t *testing.T) {
program := compileTestProgram(t, `
assert(package == nil)
@@ -864,6 +1051,13 @@ assert(type(getmetatable) == "function" and type(setmetatable) == "function")
assert(type(rawget) == "function" and type(rawset) == "function")
assert(tostring(42) == "42" and type(tostring({})) == "string")
assert(tostring(setmetatable({}, {__tostring = function() return "custom" end})) == "custom")
+local ok, message = pcall(error, "lookup failed at 0x12Ab", 0)
+assert(not ok and message == "lookup failed at 0x12Ab")
+ok, message = xpcall(function() error("lookup failed at 0x12Ab", 0) end, function(caught)
+ assert(caught == "lookup failed at 0x12Ab")
+ return caught
+end)
+assert(not ok and message == "lookup failed at 0x12Ab")
assert(type(string.dump) == "function" and type(math.randomseed) == "function")
assert(type(package.loaders[2]) == "function" and type(package.path) == "string")
assert(type(debug.traceback()) == "string" and type(os.time()) == "number")