DuckDB 没有权限系统?我用 Quack 钩子 + Lua 策略引擎造了一个(含 4 条绕过实测)
2026 年 5 月 12 日,DuckDB 悄悄发布了 Quack,把一直"只在你进程里"的 DuckDB 变成了可以网络访问、多客户端并发读写的客户端-服务端数据库。但服务端化之后,一个绕不开的问题浮出水面:谁能读哪张表? 今天这篇,是真机实测:我用手头的 duckdb-luajit 扩展 + Quack 官方文档里一个不起眼的钩子,把表级 RBAC 真跑了起来——包括把它打穿,再把它修好。配套一个可直接用的 Lua 库(rbac.lua)和 20 项实测证据。● ● ●
一、前提:DuckDB 核心根本没有权限系统
先看事实。DuckDB 的设计哲学是"嵌入式、单用户",安全边界就是操作系统文件权限——谁能打开这个 .duckdb 文件,谁就能读里面的所有数据。官方一直没有 GRANT/REVOKE。实测(DuckDB 1.5.5):
CREATE ROLE r1;
-- Parser Error: syntax error at or near "ROLE"
GRANT SELECT ON TABLE x TO r1;
-- Parser Error: syntax error at or near "GRANT"
单机单用户无所谓;可一旦用 Quack 把 DuckDB 变成多客户端共享的服务,SELECT * FROM secret_data 和 DROP TABLE 在同一个 token 面前是等权的。没有权限系统,就没有"给数据分析师开只读、不给删表"这回事。
● ● ●
二、官方文档里被忽略的宝藏:authn / authz 双钩子
Quack 的安全文档(duckdb.org/docs/current/quack/security)里藏着两段大部分人都没细看的机制:
- 认证钩子
:客户端连接时,服务器执行 SELECT quack_authentication_function(session_id, client_token, server_token)——三个参数:连接 ID、客户端交上来的 token、服务器配置的 token。 - 授权钩子
:客户端每一条查询在执行前,服务器执行 SELECT quack_authorization_function(connection_id, query)——连接 ID + 完整 SQL 文本,返回 BOOLEAN,false就拒绝,报Authorization failed。
关键点:
- 01钩子可用任意"函数"
:官方原话——"Anything resolvable as a function with the matching arity and returning a BOOLEAN... built-in scalar functions, scalar UDFs registered by another extension, or SQL macros"。也就是说,钩子可以是宏、内置函数、也可以是扩展注册的标量 UDF。 - 02Python UDF 明确被排除
: con.create_function注册的 Python 函数是连接级的,而钩子跑在服务器每次新建的临时连接上,所以不可见、不可用。官方给复杂策略开的药方是"register a scalar function via a DuckDB extension"(C++/Rust/C/Go)。 - 03官方自带 per-user ACL 示例
:文档里有一节 "Example: Per-User Access Control List",用 quack_sessions(sid→user)表 +quack_user_acls(user→query_kind)表 + 一个授权宏实现逐用户 ACL。但它明说:"Because macros can't write, the recording side has to be a scalar UDF defined e.g. by a custom DuckDB extension"——宏没法写库,记录 sid→user 这件事必须靠扩展 UDF。
这就是整个方案的入口:Quack 提供强制点,我们需要一个不用编译 C++ 就能注册的进程级标量 UDF 来做策略引擎——duckdb-luajit 正好就是。
● ● ●
三、架构:Quack 是强制点,luajit 是策略引擎
我写了一个单文件 Lua 库 libs/security/rbac.lua(duckdb-luajit-libs 仓库),挂在两个钩子上:
连接时: SELECT rbac_authn(conn_id, client_token, server_token) ← Lua: 查 quack_tokens 表,
(返回 true → 写入 quack_sessions(conn_id→user)) token→用户,并记录会话
每条查询: SELECT rbac_authz(conn_id, query) ← Lua: conn_id→user→角色→表权限,
只读白名单加固
数据模型(普通表,管理员在服务器本地建):
quack_tokens(auth_token PK, user_name) | |
quack_sessions(sid PK, user_name) | |
user_roles(user_name, role) | |
role_perms(role, table_name, can_select, can_write) |
挂钩只要三条 SQL(服务器会话里,LOAD luajit + compile rbac.lua 之后):
CREATE MACRO rbac_authn(sid, ct, st) AS (luajit_s('rbac', {op:'authn', sid:sid, client_token:ct, server_token:st})::BOOLEAN);
CREATE MACRO rbac_authz(sid, query) AS (luajit_s('rbac', {op:'authz', sid:sid, q:query})::BOOLEAN);
SET GLOBAL quack_authentication_function = 'rbac_authn';
SET GLOBAL quack_authorization_function = 'rbac_authz';
Lua 策略函数内部用 _duckdb_query 回调查权限表——这正是官方文档说的"宏做不到、要写 C++ 扩展"的 imperative 能力,luajit 零编译就给了。
● ● ●
四、实测:表级 RBAC 真跑起来了
环境:WSL + DuckDB 1.5.5 + 本地编译的 luajit 扩展 + Quack。两个用户:alice 角色 analyst(可读全部),bob 角色 viewer(只读 public_data,禁读 secret_data)。
完整输出见 libs/security/PoC-rbac-output.txt(同目录下),核心结果:
| bob 读 secret_data | Authorization failed |
| 同样的权限,ATTACH 挂载路径 | |
Authentication failed |
注意最后一行:ATTACH 挂上来当本地 catalog 用的查询,也一样走授权钩子。这意味着强制点不在客户端手里,绕不开。
● ● ●
五、反转一:默认策略全是洞(全部实测可打穿)
漏洞不是钩子机制本身,而是默认策略太宽。我在宽松策略(只查表权限、不拦语句类型)下实测,四条路径全部走通:
- 01换钩子
: SET GLOBAL quack_authorization_function = 'quack_nop_authorization'→ 授权函数被换成官方自带的"全放行",随后 bob 读到了 secret_data; - 02接管宏
: CREATE OR REPLACE MACRO rbac_authz(sid, q) AS (true)→ 策略宏直接被覆盖,同理放行; - 03自授角色
: INSERT INTO user_roles VALUES ('bob','analyst')→ bob 给自己加上 analyst 角色,读到密表; - 04篡改权限表
: UPDATE role_perms SET can_select=true WHERE role='viewer'...→ 直接改规则。
根因:DuckDB 没有"受保护系统目录"的概念。PostgreSQL 的 pg_authid、pg_class 是内核目录,普通用户碰不到;而这里——钩子设置(SET GLOBAL)、策略宏、权限表,全和数据躺在同一个用户可写平面里。策略和数据之间没有特权边界。
● ● ●
六、反转二:白名单加固,全部封死(实测对比)
修法很简单:授权策略加一道语句类型白名单——只允许 SELECT / FROM / WITH / EXPLAIN / DESCRIBE / SHOW 开头的语句,SET / CREATE / INSERT / UPDATE / DELETE / DROP / ALTER / CALL 等一律拒绝。rbac.lua 默认就是这个模式(allow_write=false)。
同一套绕过测试,加固后:
20 项测试(功能 10 + 绕过 10)全部符合预期。 代价也如实说:白名单模式只能做"只读/不可读"粗粒度——一旦业务需要客户端写库(INSERT 业务数据),就得给写语句也做细粒度授权,而那是文本匹配的地狱(见下)。
● ● ●
七、边界与实话(比绕过更本质的限制)
- 01钩子只拿到 SQL 文本,没有解析后的语句结构
。官方文档自己举过反例: WITH x AS (SELECT 1) INSERT INTO t SELECT * FROM x以 WITH 开头却改数据——前缀判断不可靠。表名匹配同理:"secret_data"加引号、注释、secret_||'data'、视图封装都能绕。rbac.lua 里我用strict_write模式宁可错杀(WITH 开头一律拒绝)来缓解,但生产级判断需要 C 扩展拿 binder 的解析结果,或前面架代理。 - 02没有行级安全(RLS)、没有列级掩码
。钩子只能放行/拒绝整条语句,不能改写查询加 WHERE。行级过滤必须走代理层改写 SQL,luajit 做不到。 - 03权限元数据与数据同平面
:安全只能靠"禁写"维持。任何写权限的开放都会把 RBAC 表自身变成攻击面——这是这套方案的天花板。 - 04性能
:每条查询 ≈ 1 次 Lua 调用 + 2~3 次 _duckdb_query(毫秒级)。OLAP 共享库无感;高频小查询 OLTP 场景有压力。
● ● ●
八、结论:什么时候值得用
- 单机嵌入式
:不需要,文件权限即安全边界; - Quack 多用户、团队内可信、只读分析库
:官方 read_only宏就够,连 luajit 都不用; - 多角色、表级权限、连接级身份、审计
:这是 rbac.lua 的用武之地——它是目前唯一不用编译 C++ 就能实现的"能写库的钩子 UDF",官方文档钦定的路径; - 不可信租户隔离 / 行级安全 / OLTP 高频鉴权
:别用这套,上真数据库或代理。
最后提醒一句:这套方案的安全默认不成立,白名单是前提,文本匹配是短板。用它之前,先读库头注释里的"安全前提"。
配套材料:
库: github.com/alitrack/duckdb-luajit-libs→libs/security/rbac.lua(含完整用法注释与"安全前提"清单)实测证据:同目录 PoC-rbac-output.txt(20 项测试全绿)复现脚本: D:\wsl2\rbac_poc\(server.py / client.py / bypass2.py)Quack 安全文档:duckdb.org/docs/current/quack/security
本文基于 DuckDB 1.5.5 + Quack + duckdb-luajit 扩展的真机实测。全文测试均在 WSL 下复现,结论随时间/版本变化请以实测为准。