From 6a6339acfeceb2e77498896c81e1e0b4f4b826fe Mon Sep 17 00:00:00 2001 From: esrrhs Date: Sun, 11 Oct 2026 22:25:18 +0800 Subject: [PATCH 1/2] fix: reject redis pub/sub commands, auto-seed math.random, CLI exit code Fixes three issues from the FakeLua Phase 16 integration report (docs/fakelua-bugs-phase16.md, based on dafdac7). redis (high-value safety fix): SUBSCRIBE/PSUBSCRIBE/UNSUBSCRIBE/ PUNSUBSCRIBE issued via the one-shot conn:command() channel left hiredis holding a dangling CmdOp* as privdata after the first ack popped and destroyed it; every subsequent published message wrote through the freed pointer (use-after-free/heap corruption). Full pub/sub needs a dedicated long-lived subscription context, so for now the command verbs are rejected case-insensitively before the readiness check with a clear error (pcall-catchable, works without a live server), and both native READMEs document the limitation plus the polling workaround. math.random: std::rand() defaulted to seed 1, so without an explicit math.randomseed every process start produced the identical sequence. The math library now auto-seeds once per process on first state init (time + std::random_device + stack-address entropy), matching Lua 5.4; explicit math.randomseed(x) still overrides. flua CLI: an uncaught FakeluaException propagated to libc++abi and killed the process with SIGABRT (exit 134). main() now catches std::exception around compile/run, prints "flua: " to stderr and exits 1, per standard Lua interpreter convention. The report's Bug 1 (math-specialization entry guard aborting on non-numeric args) is addressed by the independent PR #30. Tests: new test_redis.subscribe_rejected (all backends, no server required); full suite passes except the 8 test_mysql integration tests that need a live MySQL server. --- cmd/main.cpp | 26 +++++++++++++++++--------- src/native/README.md | 2 ++ src/native/README.zh.md | 2 ++ src/native/math/native_math.cpp | 21 +++++++++++++++++++++ src/native/redis/native_redis.cpp | 15 ++++++++++++++- test/lua/redis/test_redis.lua | 22 ++++++++++++++++++++++ test/test_redis.cpp | 12 ++++++++++++ 7 files changed, 90 insertions(+), 10 deletions(-) diff --git a/cmd/main.cpp b/cmd/main.cpp index 2159bec..c089fcf 100644 --- a/cmd/main.cpp +++ b/cmd/main.cpp @@ -23,15 +23,23 @@ int main(int argc, char **argv) { const FakeluaStateGuard guard; const auto s = guard.GetState(); - CompileConfig cfg; - cfg.debug_mode = FLAGS_debug; - CompileFile(s, argv[1], cfg); - int code = 0; - for (int i = 0; i < FLAGS_repeat; i++) { - Call(s, static_cast(FLAGS_jit_type), FLAGS_entry, code); - } - std::cout << "code: " << code << std::endl; + // Lua 惯例:未捕获的运行时错误打印消息后以退出码 1 结束,而不是让 C++ + // 异常穿透到 libc++abi 触发 SIGABRT(退出码 134,FakeLua Phase16 Bug 4)。 + try { + CompileConfig cfg; + cfg.debug_mode = FLAGS_debug; + CompileFile(s, argv[1], cfg); + + int code = 0; + for (int i = 0; i < FLAGS_repeat; i++) { + Call(s, static_cast(FLAGS_jit_type), FLAGS_entry, code); + } + std::cout << "code: " << code << std::endl; - return code; + return code; + } catch (const std::exception &e) { + std::cerr << "flua: " << e.what() << std::endl; + return 1; + } } diff --git a/src/native/README.md b/src/native/README.md index eabbc43..1b685f0 100644 --- a/src/native/README.md +++ b/src/native/README.md @@ -667,6 +667,8 @@ Async client on Boost.Redis. Reconnect and health-checks are off so a failed con Connect does not send `HELLO 3`, so Redis 4+ works; AUTH/SELECT are issued when `password` / `db` are set. Arrays become Lua arrays; RESP3 maps become tables; bulk/simple strings stay strings. After an optional `HELLO 3`, `HGETALL` is a map. +> **Pub/Sub is not supported**: `command()` is a one-shot RPC channel (one callback per command) and cannot deliver the continuous pushes a subscription produces; `SUBSCRIBE`/`PSUBSCRIBE`/`UNSUBSCRIBE`/`PUNSUBSCRIBE` raise an error. For message delivery use polling (e.g. `LPUSH`+`LRANGE`, `ZADD`+`ZREVRANGE`). + --- ## SQLite diff --git a/src/native/README.zh.md b/src/native/README.zh.md index 3198bc8..ef7eb2a 100644 --- a/src/native/README.zh.md +++ b/src/native/README.zh.md @@ -722,6 +722,8 @@ end 连接时不发送 `HELLO 3`,因此 Redis 4+ 可用;配置了 `password` / `db` 时会发 AUTH/SELECT。数组变成 Lua 数组,RESP3 map 变成表,字符串保持字符串。可选执行 `HELLO 3` 后,`HGETALL` 为 map。 +> **不支持 Pub/Sub**:`command()` 是一次性 RPC 通道(每条命令对应一次回调),无法承接订阅后的持续推送;`SUBSCRIBE`/`PSUBSCRIBE`/`UNSUBSCRIBE`/`PUNSUBSCRIBE` 会直接报错。需要消息推送请用拉模式(如 `LPUSH`+`LRANGE`、`ZADD`+`ZREVRANGE`)。 + --- ## SQLite diff --git a/src/native/math/native_math.cpp b/src/native/math/native_math.cpp index d95f972..8954e8c 100644 --- a/src/native/math/native_math.cpp +++ b/src/native/math/native_math.cpp @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include namespace fakelua::math { @@ -67,11 +69,30 @@ static int64_t RandomInt64() { return std::bit_cast(value); } +// Lua 5.4 在状态初始化时自动播种 PRNG。底层 std::rand 全局唯一且默认种子恒为 1, +// 不显式 math.randomseed 时每次进程启动的随机序列完全相同(FakeLua Phase16 Bug 3)。 +// 进程内只播一次;显式 math.randomseed(x) 之后仍以用户种子为准。 +static void AutoSeedRandomOnce() { + static const bool seeded = [] { + uint64_t seed = static_cast(std::time(nullptr)); + std::random_device rd; + seed ^= (static_cast(rd()) << 32) | static_cast(rd()); + // 栈地址熵:同一秒内启动的进程也不易撞种子。 + int stack_anchor = 0; + seed ^= static_cast(reinterpret_cast(&stack_anchor)); + std::srand(static_cast(seed)); + return true; + }(); + (void) seeded; +} + // Use shared CheckNumberArg from native_common.h void RegisterMathLibraryApi(State *s) { if (!s) return; + AutoSeedRandomOnce(); + RegisterNativeFunction(s, "math.abs", 1, false, [](State *state, CVar *args, int n) -> CVar { CVar a0 = inter::GetNativeArg(state, args, n, 0); CheckMathNumberArg(a0, 1, "math.abs"); diff --git a/src/native/redis/native_redis.cpp b/src/native/redis/native_redis.cpp index 6b71411..41e540f 100644 --- a/src/native/redis/native_redis.cpp +++ b/src/native/redis/native_redis.cpp @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -174,10 +175,22 @@ class RedisConnection { } void Command(std::vector parts, std::string cb) { + if (parts.empty()) ThrowFakeluaException("redis command: empty argv"); + // Pub/Sub 订阅族命令不能走一次性 RPC 通道:订阅成功后 hiredis 会对每条推送 + // 都以同一个 privdata 回调 OnCommand,而该 CmdOp 在首条 ack 派发后即被弹出 + // 销毁,第二条推送起就是悬垂指针写(use-after-free)。完整支持需要独立的长 + // 生命周期订阅上下文;在此之前响亮拒绝,避免堆破坏(FakeLua Phase16 Bug 2)。 + // 参数校验先于连接状态检查,未就绪的连接上也能拿到明确报错。 + std::string verb = parts.front(); + std::transform(verb.begin(), verb.end(), verb.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + if (verb == "subscribe" || verb == "psubscribe" || verb == "unsubscribe" || verb == "punsubscribe") { + ThrowFakeluaException("redis: '" + verb + "' is not supported, command() is one-shot RPC and cannot deliver pub/sub pushes"); + } if (closed_ || close_pending_ || !ac_ || !ready_) { ThrowFakeluaException("redis: connection is closed"); } - if (parts.empty()) ThrowFakeluaException("redis command: empty argv"); auto op = std::make_unique(); op->cb = std::move(cb); auto *raw = op.get(); diff --git a/test/lua/redis/test_redis.lua b/test/lua/redis/test_redis.lua index 0bb9258..293ef18 100644 --- a/test/lua/redis/test_redis.lua +++ b/test/lua/redis/test_redis.lua @@ -71,3 +71,25 @@ function test_bad_db() if ok then return 0 end return 1 end + +-- SUBSCRIBE/PSUBSCRIBE/UNSUBSCRIBE/PUNSUBSCRIBE 必须被响亮拒绝:一次性 RPC 命令 +-- 通道无法承接 Pub/Sub 推送,放行会导致第二条推送写已释放的 CmdOp(UAF)。 +-- 无需真实服务器:参数校验先于连接就绪检查,错误可被 pcall 捕获。 +function test_subscribe_rejected() + local config = {} + config["host"] = "127.0.0.1" + config["port"] = 1 + config["timeout_ms"] = 500 + local conn = redis.connect(config, "RedisTest.on_connect") + local cmds = { "SUBSCRIBE", "PSUBSCRIBE", "UNSUBSCRIBE", "PUNSUBSCRIBE", "subscribe" } + for i = 1, #cmds do + local cmd = cmds[i] + local ok, err = pcall(function() + conn:command({ cmd, "ch" }, "RedisTest.on_connect") + end) + if ok then conn:close(); return 0 end + if not string.find(tostring(err), "not supported") then conn:close(); return 0 end + end + conn:close() + return 1 +end diff --git a/test/test_redis.cpp b/test/test_redis.cpp index 3e31d21..8aba3e5 100644 --- a/test/test_redis.cpp +++ b/test/test_redis.cpp @@ -122,3 +122,15 @@ TEST(test_redis, bad_db) { EXPECT_EQ(ret, 1); FakeluaDeleteState(s); } + +// Pub/Sub 订阅族命令被拒绝(防 use-after-free,无需真实服务器) +TEST(test_redis, subscribe_rejected) { + State *s = FakeluaNewState(); + ASSERT_NE(s, nullptr); + CompileConfig config; + CompileFile(s, "./redis/test_redis.lua", config); + int64_t ret = 0; + CallAll(s, "RedisTest.test_subscribe_rejected", ret); + EXPECT_EQ(ret, 1); + FakeluaDeleteState(s); +} From eecdcfd5d27cb6c4c811077a6443847f5e05c14a Mon Sep 17 00:00:00 2001 From: esrrhs Date: Mon, 12 Oct 2026 00:20:01 +0800 Subject: [PATCH 2/2] fix: reject sharded pub/sub commands (ssubscribe/sunsubscribe) --- src/native/README.md | 2 +- src/native/README.zh.md | 2 +- src/native/redis/native_redis.cpp | 3 ++- test/lua/redis/test_redis.lua | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/native/README.md b/src/native/README.md index 1b685f0..2ff5fe5 100644 --- a/src/native/README.md +++ b/src/native/README.md @@ -667,7 +667,7 @@ Async client on Boost.Redis. Reconnect and health-checks are off so a failed con Connect does not send `HELLO 3`, so Redis 4+ works; AUTH/SELECT are issued when `password` / `db` are set. Arrays become Lua arrays; RESP3 maps become tables; bulk/simple strings stay strings. After an optional `HELLO 3`, `HGETALL` is a map. -> **Pub/Sub is not supported**: `command()` is a one-shot RPC channel (one callback per command) and cannot deliver the continuous pushes a subscription produces; `SUBSCRIBE`/`PSUBSCRIBE`/`UNSUBSCRIBE`/`PUNSUBSCRIBE` raise an error. For message delivery use polling (e.g. `LPUSH`+`LRANGE`, `ZADD`+`ZREVRANGE`). +> **Pub/Sub is not supported**: `command()` is a one-shot RPC channel (one callback per command) and cannot deliver the continuous pushes a subscription produces; `SUBSCRIBE`/`PSUBSCRIBE`/`UNSUBSCRIBE`/`PUNSUBSCRIBE`/`SSUBSCRIBE`/`SUNSUBSCRIBE` raise an error. For message delivery use polling (e.g. `LPUSH`+`LRANGE`, `ZADD`+`ZREVRANGE`). --- diff --git a/src/native/README.zh.md b/src/native/README.zh.md index ef7eb2a..47d7410 100644 --- a/src/native/README.zh.md +++ b/src/native/README.zh.md @@ -722,7 +722,7 @@ end 连接时不发送 `HELLO 3`,因此 Redis 4+ 可用;配置了 `password` / `db` 时会发 AUTH/SELECT。数组变成 Lua 数组,RESP3 map 变成表,字符串保持字符串。可选执行 `HELLO 3` 后,`HGETALL` 为 map。 -> **不支持 Pub/Sub**:`command()` 是一次性 RPC 通道(每条命令对应一次回调),无法承接订阅后的持续推送;`SUBSCRIBE`/`PSUBSCRIBE`/`UNSUBSCRIBE`/`PUNSUBSCRIBE` 会直接报错。需要消息推送请用拉模式(如 `LPUSH`+`LRANGE`、`ZADD`+`ZREVRANGE`)。 +> **不支持 Pub/Sub**:`command()` 是一次性 RPC 通道(每条命令对应一次回调),无法承接订阅后的持续推送;`SUBSCRIBE`/`PSUBSCRIBE`/`UNSUBSCRIBE`/`PUNSUBSCRIBE`/`SSUBSCRIBE`/`SUNSUBSCRIBE` 会直接报错。需要消息推送请用拉模式(如 `LPUSH`+`LRANGE`、`ZADD`+`ZREVRANGE`)。 --- diff --git a/src/native/redis/native_redis.cpp b/src/native/redis/native_redis.cpp index 41e540f..c198c67 100644 --- a/src/native/redis/native_redis.cpp +++ b/src/native/redis/native_redis.cpp @@ -185,7 +185,8 @@ class RedisConnection { std::transform(verb.begin(), verb.end(), verb.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); - if (verb == "subscribe" || verb == "psubscribe" || verb == "unsubscribe" || verb == "punsubscribe") { + if (verb == "subscribe" || verb == "psubscribe" || verb == "unsubscribe" || verb == "punsubscribe" || + verb == "ssubscribe" || verb == "sunsubscribe") { ThrowFakeluaException("redis: '" + verb + "' is not supported, command() is one-shot RPC and cannot deliver pub/sub pushes"); } if (closed_ || close_pending_ || !ac_ || !ready_) { diff --git a/test/lua/redis/test_redis.lua b/test/lua/redis/test_redis.lua index 293ef18..2b7ea20 100644 --- a/test/lua/redis/test_redis.lua +++ b/test/lua/redis/test_redis.lua @@ -81,7 +81,7 @@ function test_subscribe_rejected() config["port"] = 1 config["timeout_ms"] = 500 local conn = redis.connect(config, "RedisTest.on_connect") - local cmds = { "SUBSCRIBE", "PSUBSCRIBE", "UNSUBSCRIBE", "PUNSUBSCRIBE", "subscribe" } + local cmds = { "SUBSCRIBE", "PSUBSCRIBE", "UNSUBSCRIBE", "PUNSUBSCRIBE", "subscribe", "SSUBSCRIBE", "SUNSUBSCRIBE", "ssubscribe" } for i = 1, #cmds do local cmd = cmds[i] local ok, err = pcall(function()