This is an automated email from the ASF dual-hosted git repository.
git-hulk pushed a commit to branch unstable
in repository https://gitbox.apache.org/repos/asf/kvrocks.git
The following commit(s) were added to refs/heads/unstable by this push:
new 567fcf6c2 fix(scripting): guard against stack overflow on deeply
nested Lua reply (#3547)
567fcf6c2 is described below
commit 567fcf6c26fc38367c1bda81ca0541c6ab861086
Author: hulk <[email protected]>
AuthorDate: Thu Jul 9 11:11:23 2026 +0800
fix(scripting): guard against stack overflow on deeply nested Lua reply
(#3547)
ReplyToRedisReply() converts a Lua return value into a RESP reply with
native C++ recursion that runs after lua_pcall() returns,
so Lua's own recursion guard does not apply. A deeply self-nested table
could recurse until the worker thread stack overflowed and crashed
the whole server (the Lua VM is shared per worker).
Add a lua_checkstack() guard so such a reply fails with "reached lua
stack limit"
instead of crashing. Includes a Go regression test in the scripting
suite.
Assistant-By Claude Fable 5
---
src/storage/scripting.cc | 64 ++++++++++++++++++++++-----
tests/gocase/unit/scripting/scripting_test.go | 39 ++++++++++++++++
2 files changed, 93 insertions(+), 10 deletions(-)
diff --git a/src/storage/scripting.cc b/src/storage/scripting.cc
index 49727b112..aa31c09d0 100644
--- a/src/storage/scripting.cc
+++ b/src/storage/scripting.cc
@@ -1271,13 +1271,27 @@ void PushError(lua_State *lua, const char *err) {
lua_settable(lua, -3);
}
-// this function does not pop any element on the stack
-std::string ReplyToRedisReply(redis::Connection *conn, lua_State *lua) {
+// Max nesting depth of a Lua reply table. The conversion below recurses on the
+// native C stack (Lua's own recursion guard doesn't apply after lua_pcall
+// returns), and worker threads may have small stacks (512KiB by default on
+// macOS), so the cap must be low enough to keep ~100 frames affordable.
+constexpr int kMaxLuaReplyNestingDepth = 100;
+
+// Convert the Lua value at the top of the stack (and everything nested in it)
+// into RESP protocol bytes. This function does not pop any element on the
stack.
+StatusOr<std::string> LuaTypeToRedisReply(redis::Connection *conn, lua_State
*lua, int depth) {
std::string output;
const char *obj_s = nullptr;
size_t obj_len = 0;
int j = 0, mbulklen = 0;
+ // Also reserve Lua stack room before converting this level (a map reply
+ // pushes up to 4 slots). On failure the whole reply is discarded by the
+ // top-level caller and turned into a single error.
+ if (depth > kMaxLuaReplyNestingDepth || !lua_checkstack(lua, 4)) {
+ return {Status::NotOK, "reached lua stack limit"};
+ }
+
int t = lua_type(lua, -1);
switch (t) {
case LUA_TSTRING:
@@ -1384,11 +1398,21 @@ std::string ReplyToRedisReply(redis::Connection *conn,
lua_State *lua) {
while (lua_next(lua, -2)) {
lua_pushvalue(lua, -2);
// return key
- map_output += ReplyToRedisReply(conn, lua);
- lua_pop(lua, 1);
+ auto key_output = LuaTypeToRedisReply(conn, lua, depth + 1);
+ if (!key_output) {
+ lua_pop(lua, 4); // pop the key copy, value, iteration key and
'map' table
+ return key_output;
+ }
+ map_output += *key_output;
+ lua_pop(lua, 1); // pop the converted key copy
// return value
- map_output += ReplyToRedisReply(conn, lua);
- lua_pop(lua, 1);
+ auto value_output = LuaTypeToRedisReply(conn, lua, depth + 1);
+ if (!value_output) {
+ lua_pop(lua, 3); // pop the value, iteration key and 'map' table
+ return value_output;
+ }
+ map_output += *value_output;
+ lua_pop(lua, 1); // pop the converted value
map_len++;
}
output = conn->HeaderOfMap(map_len) + std::move(map_output);
@@ -1408,8 +1432,13 @@ std::string ReplyToRedisReply(redis::Connection *conn,
lua_State *lua) {
while (lua_next(lua, -2)) {
lua_pop(lua, 1);
lua_pushvalue(lua, -1);
- set_output += ReplyToRedisReply(conn, lua);
- lua_pop(lua, 1);
+ auto entry_output = LuaTypeToRedisReply(conn, lua, depth + 1);
+ if (!entry_output) {
+ lua_pop(lua, 3); // pop the entry copy, iteration key and 'set'
table
+ return entry_output;
+ }
+ set_output += *entry_output;
+ lua_pop(lua, 1); // pop the converted entry copy
set_len++;
}
output = conn->HeaderOfSet(set_len) + std::move(set_output);
@@ -1428,8 +1457,13 @@ std::string ReplyToRedisReply(redis::Connection *conn,
lua_State *lua) {
break;
}
mbulklen++;
- output += ReplyToRedisReply(conn, lua);
- lua_pop(lua, 1);
+ auto element_output = LuaTypeToRedisReply(conn, lua, depth + 1);
+ if (!element_output) {
+ lua_pop(lua, 1); // pop the element
+ return element_output;
+ }
+ output += *element_output;
+ lua_pop(lua, 1); // pop the converted element
}
output = redis::MultiLen(mbulklen) + output;
break;
@@ -1439,6 +1473,16 @@ std::string ReplyToRedisReply(redis::Connection *conn,
lua_State *lua) {
return output;
}
+std::string ReplyToRedisReply(redis::Connection *conn, lua_State *lua) {
+ auto reply = LuaTypeToRedisReply(conn, lua, 1);
+ if (!reply) {
+ // Discard the partially converted reply and report a single top-level
+ // error instead of embedding it deep inside nested array headers.
+ return redis::Error({Status::RedisErrorNoPrefix, reply.Msg()});
+ }
+ return std::move(*reply);
+}
+
/* In case the error set into the Lua stack by pushError() was generated
* by the non-error-trapping version of redis.pcall(), which is redis.call(),
* this function will raise the Lua error so that the execution of the
diff --git a/tests/gocase/unit/scripting/scripting_test.go
b/tests/gocase/unit/scripting/scripting_test.go
index e1318977b..47bd5bbd8 100644
--- a/tests/gocase/unit/scripting/scripting_test.go
+++ b/tests/gocase/unit/scripting/scripting_test.go
@@ -958,3 +958,42 @@ func TestLuaJITBytecodeDoS(t *testing.T) {
require.Equal(t, []interface{}{"load_failed:attempt to load chunk with
wrong mode"}, r.Val())
require.NoError(t, rdb.Ping(ctx).Err())
}
+
+// TestScriptEvalDeeplyNestedTableReply is a regression test for a deeply
+// self-nested Lua reply that used to overflow the stack and crash the server.
+func TestScriptEvalDeeplyNestedTableReply(t *testing.T) {
+ srv := util.StartServer(t, map[string]string{})
+ defer srv.Close()
+
+ ctx := context.Background()
+ rdb := srv.NewClient()
+ defer func() { require.NoError(t, rdb.Close()) }()
+
+ require.Equal(t, "PONG", rdb.Ping(ctx).Val())
+
+ require.NoError(t, rdb.Eval(ctx, `local t={} for i=1,50 do t={t} end
return t`, nil).Err())
+
+ // One script per recursive conversion site in ReplyToRedisReply
+ const depth = 200000
+ for name, format := range map[string]string{
+ "array element": `local t={} for i=1,%d do t={t}
end return t`,
+ "array element after sibling": `local t={} for i=1,%d do t={t}
end return {1, t, 3}`,
+ "map key": `local t={} for i=1,%d do t={t}
end return {map={[t]=1}}`,
+ "map value": `local t={} for i=1,%d do t={t}
end return {map={key=t}}`,
+ "set entry": `local t={} for i=1,%d do t={t}
end return {set={[t]=true}}`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ err := rdb.Eval(ctx, fmt.Sprintf(format, depth),
nil).Err()
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "reached lua stack
limit")
+
+ // confirm the error path left the Lua stack top in a
good state
+ require.Equal(t, []interface{}{int64(1), int64(2),
int64(3)},
+ rdb.Eval(ctx, `return {1, 2, 3}`, nil).Val())
+ })
+ }
+
+ require.Eventually(t, func() bool {
+ return rdb.Ping(ctx).Val() == "PONG"
+ }, 5*time.Second, 100*time.Millisecond)
+}