从自增ID到Keyed混淆:如何防止订单号被枚举扫描
最近做一个小产品到了联调阶段分享链接里的订单号还是当年图省事直接透出的自增主键/share/1024、/share/1025。同事提了一句“你这个数字是不是能数出我们一天有多少单”我盯着 URL 想了很久发现这确实不是危言耸听。我并不是说自增主键是错的。数据库里用单调整数做主键索引、关联、分页都非常省心。问题出在把同一个 ID 直接暴露到对外链接上。一个可预测、可遍历的 ID不只让订单量可以被轻松推算还会给那些没有做好越权校验的接口提供一个天然扫把。搜了一圈常见做法有人建议直接换 UUID有人建议用 Hashids有人建议把 ID 加密后再放出去。这些方案各自有道理但经常被混在一起推荐很少有人把“加密、哈希、混淆”之间的差异讲清楚。直到我注意到一个在 Hacker News 上展示的项目标题Show HN: Arxid – keyed, non-enumerable ID obfuscationkeyed、non-enumerable、obfuscation这三个词几乎就把这类方案的本质写完了。这篇文章不是替某个库写 README而是展开聊聊这个标题背后的工程问题keyed ID 混淆解决的是什么它和 UUID、哈希、加密有什么边界以及把它接进生产环境前你应该想清楚哪些事。先给一个贯穿全文的判断keyed ID 混淆的真正价值不是“把 ID 藏起来”而是在不改变内部主键、不让 ID 失去可逆性的前提下把“可猜测、可枚举、可推断规模”这三个暴露在系统边界上的问题拆掉。它是一层很薄的磨砂玻璃挡住的是顺手扫描和好奇心挡不住下定决心攻击你的人。任何把混淆当作安全兜底的方案都还没真正理解这层玻璃的厚度。1. 为什么自增 ID 暴露在链接里是一个真实问题1.1 一个数字 ID 透露的信息往往超出你的预期先还原一个常见场景。你的数据库里有一张订单表主键是自增整数第 10001 条订单的详情页是https://example.com/order/10001把数字换一换就能访问第 10000 条、第 10002 条。如果接口没有校验“当前用户是否属于这条订单”这就是一个标准的越权访问问题通常被叫做 IDORInsecure Direct Object Reference。但更隐蔽的是另一个问题就算权限校验做得滴水不漏攻击者访问不到别人的订单内容他仍然可以沿着数字区间做大量请求通过返回码、响应时间、页面状态来探测一个事实——你的平台到底有多少条订单。这个信息有多敏感取决于业务。对一个小社区来说知道你有 5000 个用户和知道你有 5000 个用户、且每周还在以 3 位数增长完全是两个量级的信息。后者已经可以作为商业决策输入了。订单量、用户量、发票号段、草稿数量这些业务指标会通过一串自增 ID 悄悄写在 URL 上。1.2 枚举扫描和越权访问不是一回事但常常连在一起很多团队会把“ID 暴露”和“越权访问”混为一谈然后得出一个结论只要把接口的权限校验做好ID 露出来也没关系。这个结论只对了一半。权限校验解决的是“你能不能访问别人的资源”枚举扫描解决的是“你的平台存在多少资源以及资源以什么顺序产生”。前者是访问控制问题后者是信息泄露问题。两者可以同时存在也可以单独存在。举个例子一个接口对每个订单都校验了归属权攻击者拿不到别人的订单但他可以通过连续请求/order/1到/order/100000统计出哪些 ID 存在、哪些不存在从而恢复出你的业务数据规模。这种情况下权限校验是好的可信息仍然漏了。所以如果你对外的资源引用用的是自增 ID就算你自认权限已经写得很完整也应该在“对外可见的 ID 形态”这件事上多想想。枚举本身就是一种攻击面。1.3 关键变化从“隐藏”到“不可枚举”有人会说那我把 ID 从 URL 里去掉不就行了比如通过 POST 参数、通过短链服务跳转。这在某些场景可行但大多数情况下 ID 就是资源定位信息的一部分你需要给用户、给合作方、给前端一个稳定引用。既然不能隐藏那就必须让这个引用不可被预测。“不可枚举”这个词换成大白话是给你一个合法 ID你推不出下一个给你一段合法序列你无法在合理时间内遍历出完整集合。它不要求别人“看不见”只要求别人“看见了也没法扩展”。要做到这件事最简单的两种路径一种是让 ID 变得足够随机且足够长例如 UUID v4另一种是让 ID 的生成依赖一个别人不知道的秘密也就是 keyed 方案。Arxid 走的是后一条路这也是它和“直接换 UUID”最大的分水岭。2. 拆解 Arxid 标题里的三个关键词2.1 keyed机密性来自密钥而不是隐藏算法先看keyed。这个词的意思是混淆过程依赖一个密钥而不是依赖某个“别人不知道的算法”。很多初学的人会下意识觉得只要我的置换逻辑足够复杂别人看不出规律就行。但工程世界里有一条铁律算法永远会泄露。你的代码可能被开源可能被反编译可能从一次版本泄露里流出去。如果混淆的安全完全建立在“算法保密”上一旦代码曝光整个方案就归零了。keyed 方案则不同。哪怕把全部源码拿给你只要密钥不泄露你仍然无法从一个合法 token 推算出另一个。密钥给人的感觉像是一把钥匙锁的结构公开没关系没有钥匙就是打不开。在落地层面“keyed”通常意味着几个具体设计约束密钥必须独立管理不能硬编码在前端代码里。编码和解码必须使用同一个密钥体系并且要考虑多环境开发、测试、生产的 key 隔离。密钥要能轮换。设计 token 结构时最好预留版本位让解码方能根据版本选择对应的 key。2.2 non-enumerable不是“看不见”是“推不出下一个”再看non-enumerable。这个词是这个方案的核心卖点。很多团队的直觉是“我只要把 ID 搞复杂一点别人就猜不到了。”于是有人把 10001 转成十六进制得到2711或者直接 base64 一下得到MTAwMDE以为这样就安全了