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")