bigywater.tech
Redis · 130

Redis Lua 安全边界

Redis 允许客户端提交一段 Lua 脚本在服务端运行。那它能碰到什么?能不能读文件、执行系统命令、改全局环境?ACL 对脚本内部有没有效?这篇只做一件事:在本机的 8.10.2 上把沙箱的边界逐条试出来,并给出限制脚本时容易漏掉的地方。

os 只有 clock
脚本里没有 io / package / require / dofile / print,os 只剩 clock
逐条检查
ACL 对脚本里的每一次 redis.call 都生效;+eval 不等于什么都能做
漏一半
只禁 eval / evalsha / eval_ro / evalsha_ro 四条时,SCRIPT LOAD / FUNCTION LOAD / FCALL 仍被允许;用 -@scripting 才是全拒

脚本的环境

实测 · 本机执行输出
  全局变量: __redis__err__handler _G _VERSION ARGV assert bit cjson cmsgpack collectgarbage coroutine error gcinfo getmetatable ipairs KEYS load loadstring math next os pairs pcall rawequal rawget rawset redis select setmetatable string struct table tonumber tostring type unpack xpcall
  库(type):
    os         table
    io         不存在(读取报错:nonexistent global 'io')
    package    不存在(读取报错:nonexistent global 'package')
    debug      不存在(读取报错:nonexistent global 'debug')
    string     table
    table      table
    math       table
    cjson      table
    cmsgpack   table
    struct     table
    bit        table
    redis      table
  函数(type):
    require        不存在(读取报错:nonexistent global 'require')
    dofile         不存在(读取报错:nonexistent global 'dofile')
    loadfile       不存在(读取报错:nonexistent global 'loadfile')
    loadstring     function
    load           function
    print          不存在(读取报错:nonexistent global 'print')
    setmetatable   function
    getmetatable   function
    rawset         function
    collectgarbage function
    pcall          function
    newproxy       不存在(读取报错:nonexistent global 'newproxy')
  loadstring("return 1+1")() → 2  | loadstring("x = 1")() → ERR [string "x = 1"]:1: Attempt to modify a readonly table script: afbff2ee5a9bb020f9918fdbbf2a281dc3fec921, on x = 1:1.
  os 里有: clock
  os.execute 的类型: nil
  redis 表里有: acl_check_cmd breakpoint call debug error_reply log LOG_DEBUG LOG_NOTICE LOG_VERBOSE LOG_WARNING pcall REDIS_VERSION REDIS_VERSION_NUM REPL_ALL REPL_AOF REPL_NONE REPL_REPLICA REPL_SLAVE replicate_commands set_repl setresp sha1hex status_reply

沙箱里没有文件、进程、模块加载的入口:io、package、require、dofile、loadfile、print 读取时直接报 nonexistent global;os 只剩 clock。load / loadstring 还在,但它们跑在同一个沙箱里(loadstring("x = 1")() 同样被拒)。cjson、cmsgpack、struct、bit 可用。

全局环境只读

实测 · 本机执行输出
  x = 1(创建全局变量)                → ERR Attempt to modify a readonly table
  _G.y = 1                     → ERR Attempt to modify a readonly table
  string.foo = 1(改标准库表)        → ERR Attempt to modify a readonly table
  redis.foo = 1(改 redis 表)     → ERR Attempt to modify a readonly table
  local z = 1(局部变量)            → 1
  setmetatable(_G, {})         → ERR Attempt to modify a readonly table
  os = nil                     → ERR Attempt to modify a readonly table

创建全局变量、改标准库表、改 redis 表、给 _G 设元表,全部报 Attempt to modify a readonly table。脚本之间不能通过全局变量互相传东西——要传就走 Redis 里的 key。

脚本里能调用哪些命令

实测 · 本机执行输出
  SET k v                → OK
  KEYS *                 → OK
  FLUSHDB                → OK
  CONFIG GET dir         → ERR: ERR This Redis command is not allowed from script
  CONFIG SET maxmemory 0 → ERR: ERR This Redis command is not allowed from script
  ACL WHOAMI             → ERR: ERR This Redis command is not allowed from script
  SUBSCRIBE ch           → ERR: ERR This Redis command is not allowed from script
  MONITOR                → ERR: ERR This Redis command is not allowed from script
  WATCH k                → ERR: ERR This Redis command is not allowed from script
  MULTI                  → ERR: ERR This Redis command is not allowed from script
  EVAL return 1 0        → ERR: ERR This Redis command is not allowed from script
  SAVE                   → ERR: ERR This Redis command is not allowed from script
  BGSAVE                 → ERR: ERR This Redis command is not allowed from script
  DEBUG SLEEP 0          → ERR: ERR This Redis command is not allowed from script
  BLPOP q 1              → OK
  SCRIPT FLUSH           → ERR: ERR This Redis command is not allowed from script
  CLIENT LIST            → ERR: ERR This Redis command is not allowed from script
  SLAVEOF NO ONE         → ERR: ERR This Redis command is not allowed from script

CONFIG、ACL、SUBSCRIBE、MONITOR、WATCH、MULTI、EVAL、SAVE、DEBUG、SCRIPT、CLIENT、SLAVEOF 被拒;SET、KEYS、FLUSHDB、BLPOP 可以。这是「不许改服务器状态、不许阻塞连接」的一条线,不是「不许破坏数据」的线——所以能执行 EVAL 的人,能做的数据破坏跟他自己直接执行命令一样多,取决于他的 ACL。(没有测 SHUTDOWN 等其余命令。)

ACL 在脚本内部

实测 · 本机执行输出
  luser 的脚本 GET app:1                        → None
  luser 的脚本 GET other:1                      → ERR ACL failure in script: No permissions to access a key
  luser 的脚本 DEL app:1                        → ERR ACL failure in script: User luser has no permissions to run the 'del' command
  luser 的脚本 KEYS *                           → ERR ACL failure in script: User luser has no permissions to run the 'keys' command
  luser 的脚本 SET app:2(KEYS 里没声明该 key)        → OK
  luser 的脚本 pcall(DEL app:1)                 → ERR: ERR ACL failure in script: User luser has no permissions to run the 'del' command
  EVAL_RO 里 SET → ERR Write commands are not allowed from read-only scripts.

脚本里每次 redis.call 都按调用者的 ACL 检查(命令和 key 都查)。redis.pcall 把 ACL 失败作为错误值返回而不是终止脚本。没在 KEYS 里声明的 key,只要符合 ACL 的 key 模式仍然可以访问(单机下的实测)。EVAL_RO 里写命令被拒。

限制脚本:别只禁 EVAL

实测 · 本机执行输出
  EVAL return 1 0            -@scripting:拒绝   只禁 4 条 EVAL 系列:拒绝
  EVALSHA aaaaaaaaaaaaaaaa   -@scripting:拒绝   只禁 4 条 EVAL 系列:拒绝
  SCRIPT LOAD return 1       -@scripting:拒绝   只禁 4 条 EVAL 系列:允许
  FUNCTION LOAD #!lua name   -@scripting:拒绝   只禁 4 条 EVAL 系列:允许
  FCALL f 0                  -@scripting:拒绝   只禁 4 条 EVAL 系列:允许
  FUNCTION FLUSH             -@scripting:拒绝   只禁 4 条 EVAL 系列:允许
  @scripting 分类里的命令: fcall eval evalsha eval_ro fcall_ro evalsha_ro function|flush function|list function|stats function|help function|dump function|kill function|restore function|load function|delete script|kill script|debug script|help script|flush script|exists script|load

脚本相关的命令有 21 条(见上面 @scripting 的完整列表)。只禁 eval 系列四条,FUNCTION LOAD 后 FCALL 仍然能跑 Lua。要禁就用 -@scripting,然后 ACL DRYRUN 验一遍。

死循环与 SCRIPT KILL

实测 · 本机执行输出
  busy-reply-threshold 默认值: 5000 ms
  纯死循环:另一个连接的 PING 等了 390 ms 才得到 → BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
  SCRIPT KILL → OK;死循环那条连接得到 → ERR Script killed by user with SCRIPT KILL...
  先 SET 再死循环:PING → BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
  SCRIPT KILL → UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in a ha
  SHUTDOWN NOSAVE → 连接断开(进程退出) EOFError
  进程还在运行吗: False

脚本运行时服务器是单线程忙碌的,其它连接的命令要等。超过 busy-reply-threshold(默认 5000 ms;别名 lua-time-limit)后,其它连接得到 BUSY,只能 SCRIPT KILL 或 SHUTDOWN NOSAVE。脚本一旦写过数据,SCRIPT KILL 返回 UNKILLABLE,只剩 SHUTDOWN NOSAVE。所以没有写权限的用户跑死循环,伤害是可恢复的;有写权限的,只能停服务。

分步演示:沙箱的边界

Lua 安全边界
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 / Redis 8.10.2 独立实例(本机回环端口,save 关闭),redis-cli 风格输出由自写的 RESP 客户端打印;计时用 time.perf_counter,数字每次运行会有小幅抖动。busy-reply-threshold 在测试里调成 500 ms;SHUTDOWN NOSAVE 只在我自己的临时实例上执行
    • 没有验证:沙箱逃逸类漏洞(这里只测了设计边界,不代表没有漏洞;跟进版本公告与升级)、脚本内存/递归上限、cjson / cmsgpack 自身的输入限制、集群模式下未声明 key 的行为、SCRIPT DEBUG、脚本在从库与 AOF 中的复制语义(见第 131 篇之外)
    京ICP备2026067502号-1京公网安备11010502063487号