真正可查询的可执行文件
文章摘要
这是 Farid Zakaria 对他此前那篇《你的可执行文件是一个 SQLite 数据库》的续作。前作提出了一种叫 SELF 的格式:程序本身就是一个 SQLite 数据库,通过 Linux 的 binfmt_misc 机制注册一个自定义解释器,由解释器把 segments 表里的行映射进内存并跳转到入口点执行。这样一来,一整类二进制工具链(读段表、查符号、看重定位)就全部塌缩成了 SQL 查询。
这篇续作追问的是一个在评论区被反复提出的问题:既然可执行文件是数据库,而数据库是可写的,那运行中的程序能不能把自己的状态也写回去?作者的回答是能,而且他做了一个概念验证——self-httpd,一个单文件的 Web 服务器。这个文件同时是程序、是网站内容、是路由表、是访客日志。用 file 命令看它,报告的是「SQLite 3.x database」;直接执行它,它就开始在 8080 端口提供服务;用 sqlite3 打开它,就能查出刚才有多少人访问了首页、按了多少次按钮。所有应用状态都以事务方式更新在同一个文件里,作者由此设想:可以把整个发行版和所有应用的状态收进单个文件,/var、/tmp、/home 这些目录都不再必要。
技术实现上有几个关键点。程序如何拿到自身的句柄?目前还不能用 /proc/self/exe——因为 binfmt_misc 匹配时内核根本没有 execve 你的文件,它执行的是解释器,只是把路径交了过去(作者顺带提到,VFS 维护者最近刚在内核里落地了透明 binfmt_misc 支持,将来这条路会通)。解释器把 argv 往后挪一位传给程序,于是程序的 argv[0] 就是自己的路径;解释器还会在跳转前释放自己的 SQLite 连接,程序拿 argv[0] 直接 sqlite3_open 就能读写自己。
由此衍生出的能力很有意思。构建过程平淡得反常:先用 cc 编译成普通 ELF,再用 elf2self 转换成数据库,然后用 DDL 建应用表、用 INSERT 加上 readfile() 把网站塞进去。修改线上站点变成一条 UPDATE,而且是事务性的——ROLLBACK 就能撤销,不用重启、不用重载、不用部署。因为格式是 SQLite,整个生态的工具都能白嫖:sqldiff --summary 可以精确告诉你「这次部署到底改了什么」,能审计出 routes 表改了一行而 segments、symbols、relocations 都没动;FTS5 只要一条 CREATE VIRTUAL TABLE,Web 服务器就能给自己的页面建全文索引,索引也存在自己体内,而它仍然是个能跑的 Web 服务器。部署退化成 scp 单个文件;而升级迁移就是两条 INSERT ... SELECT,因为程序和数据本来就是同一个文件——ATTACH 旧文件、把 visits 和 presses 搬进新版本、换文件、重启,访客日志就保住了。作者还促狭地指出:segments 表也不过是普通的表,所以你也可以反过来迁移程序本身。
作者坦承灵感来自 Justine Tunney 的 redbean(用自解压 ZIP 做的单文件 Web 服务器),并做了对比:redbean 需要额外引入归档格式 ZIP,而 SELF 里数据库本身就是容器;redbean 用 Lua 钩子操作响应,SELF 的等价物是往 handlers 表里插一行 SQL。如果 redbean 是「真正可移植的可执行文件」(APE),这个就是「真正可查询的可执行文件」——一个到处都能跑,另一个你可以对它做 SELECT。代码开源在 fzakaria/selfdb,作者大方承认「有点半成品,而且确实是 AI 辅助写的,但我无所谓」,结尾引用 Randy Pausch 的话:永远不要低估「玩得开心」的重要性。
HN 评论精华
这条帖子拿到 338 分,讨论氛围罕见地愉快——大量评论是「这玩意儿邪门但我爱死了」,另一半则在认真挖掘它的安全隐患和历史先例。
- rao-v 的评价被广泛认同:「这东西病态,而且危险地接近愚蠢,这让它成为我今年在 HN 上看到的最好的东西之一。」hypendev 补充说它「危险地同时接近才华横溢和愚蠢」。hasley 借机感叹这类古怪有趣的话题在 HN 上越来越少、被 AI 话题挤占了,brabel 却回敬说:这个项目恰恰是因为 AI 帮作者干掉了苦力活才做得成,作者自己在文档里写了。
- 安全隐患是最集中的批评。Retr0id 一句话点破:「现在所有 SQL 注入漏洞都成 RCE 了。」stephenlf 称之为「SQL 注入到任意代码执行的流水线」。jdub 写了长评,一边设想更花哨的升级路线(把新的 SELF 数据写进旧二进制、发 SIGHUP、让服务 fork+exec 自己并做 haproxy 式零停机 FD 交接,甚至用多套 segments 实现瘦多架构二进制),一边强调「话说回来——非常强烈地话说回来——我绝不会让一个面向互联网的服务二进制文件可以自我写入」。dgl 给出了技术解法:写一个自定义 SQLite VFS,确保特定的 SQLite 页面映射到被 mprotect 保护的 OS 页(W^X),改可执行页就直接崩溃;再配合
mseal()应该可行,SQLite 官方的 appendvfs.c 就是相关先例。vlovich123 则希望有某种「封印」机制,让 SQLite 支持 ACL,把 segments 和描述符表隔离成只读、只开放运行时表给程序写。 - 要不要把状态存进二进制这一点分歧明显。yjftsjthsd-h 的态度很有代表性:一方面「我不想要这个,静态内容跟二进制打包没问题,但可写的运行时数据存进去感觉很脏,我更喜欢只读二进制加一个可写状态目录」(他有大量 Nix 和不可变发行版经验);另一方面「这是我很久以来见过最酷最好玩的东西,我完全支持把它推进 1000%,当黑客精神在场时谁还管什么完美运维的不可变部署」。他还提出 fat binary 的玩法:程序正文既然是行,多一行何妨——按
uname -m从 executable 表里 SELECT 对应架构的正文就行。kleiba2 追问「那我同时跑多个实例呢」,引出一串讨论:有人说 SQLite 本来就支持并发和 ACID 事务,处理共享状态说不定比现状更好;layer8 反驳说像 Excel 这样的应用,你不会希望编辑的文件全存进二进制里、还得靠导入导出才能分享,「应用只是查看和修改数据的工具,数据应该独立存在」。 - 「太阳底下无新事」是另一条主线。多人指出 IBM 的 AS/400 / OS/400 早就把「一切皆对象、库就像数据库表且是一等 OS 对象、命令行直接写 SQL」做到了极致,至今仍是 i 系列的一部分。quink 提到 MUMPS 的 globals,特别是
^rOBJ。JaumeGreen 说这就是 Lisp / APL / Smalltalk 的 image,只不过驱动力换成了 SQL——「老想法又回来了,而且我不是在贬低:很多老想法本身就是好想法,只是当年没赢」。kqr 顺着往下推演:如果存进数据库的不是编译后的代码而是 s-表达式,就能用 INSERT 实时更新定义——「然后我意识到我重新发明了 Lisp」。thesz 补充了 Tcl 社区 2000 年前后的 starkit/tclkit(基于 Metakit,可直接查询的 ZIP+VFS 单文件应用),teyc 提到 Windows MSI 安装包格式本身就是围绕 SQL 建的。ramses0 列了一长串谱系:SQLar、SQLite 官方的「作为应用文件格式」文档、CouchDB/CouchApp(把 html+js 直接从数据库里 serve 出来,「领先了两个十年」),以及 PHP 默认带上 SQLite 驱动的那个美好时刻。 - 实用价值的讨论里,garganzol 认为这套思路眼下最能发光的地方是 .o/.obj 目标文件:与其用 COFF/ELF 这类二进制巫术,关系型数据库容器能让以前很多难事变简单,包括可选的厂商扩展,「对现代编译器套件来说这是极其务实的做法,也是整个想法里真正才华横溢的部分」。aureate 则说他更感兴趣的是作者的 sqlelf 工具(对真实 ELF 做 SQL 查询),至于把二进制本身做成可运行数据库,性能开销恐怕不实用。tesnorindian 花了几分钟才想明白原理,评价 binfmt_misc 这个「脚本 shebang 的自定义解释器版」用得很妙,但提醒说误删二进制会同时丢掉代码和数据。
- 有人问「能不能 ELI5」,ccapitalK 给了一个被点赞的解释:原生应用打包成可执行文件,本质是机器码 + 待加载数据 + 元数据的组合;第一篇文章指出这个格式其实可以看作一种极其专门化、老掉牙的数据库格式,于是作者把它转成 SQLite 并教会操作系统怎么运行;第二篇则让这个「以数据库形式发行的程序」通过 SQLite 读写自己来存应用状态。