IDOR(Insecure Direct Object Reference,不安全的直接对象引用)在国内讨论得不多,但在自建站里它几乎是最常见、也最容易上线的漏洞。原因很朴素:一个功能从「自己能用」变成「别人也能用」的时候,开发者常常只加了登录校验,没有加归属校验。
举个最常见的样子:后台的「编辑文章」页地址是 edit.php?id=1203。你登录自己账号,把 1203 改成 1204,页面照样打开了,里面是别人的稿子。整个过程不需要任何特殊技术,改一个数字就行。这就是 IDOR。
更麻烦的是它的变体极多:不只是 id,还有订单号、手机号、邮箱、文章 slug、导出文件名、分享码、时间戳……凡是「能被用户控制、且能唯一定位一条数据」的东西,都可能成为入口。而这类漏洞的检测工具很难自动发现(它需要理解业务语义),通常只能靠自查。
一、把问题说清楚:认证与授权是两件事
绝大多数 IDOR 的根因,是把「登录了」当成了「有权限」。这两件事在代码里必须分开:
认证(Authentication)解决的是「你是谁」。用 session、JWT、Cookie 都行。
授权(Authorization)解决的是「你能对这条数据做什么」。
典型的有缺陷写法是这样的:
// 错的:只校验登录
if (empty($_SESSION['uid'])) {
header('Location: /login.php');
exit;
}
$row = $db->fetchRow("SELECT * FROM posts WHERE id = " . intval($_GET['id']));这段代码里,intval 挡住了 SQL 注入,登录校验挡住了匿名用户,看起来已经很安全。但任何注册用户都能读任意一篇别人的文章,包括草稿。
正确的写法必须带上归属条件,或者显式判断:
// 对的:把归属写进 WHERE
$row = $db->fetchRow("SELECT * FROM posts WHERE id = ? AND uid = ?",
array(intval($_GET['id']), $_SESSION['uid']));
if (!$row) {
// 统一返回 404,不要返回「无权访问」
header('HTTP/1.1 404 Not Found');
exit;
}这里有两个细节值得强调。一是把归属条件写进 SQL 的 WHERE,比「先查出来再在 PHP 里 if 判断」更不容易漏——因为一旦写成 if,未来某次重构把那个 if 删了,漏洞就复现了,而 WHERE 条件会跟着查询一起走。二是失败时返回 404 而不是 403:403 等于告诉攻击者「这条数据存在,只是你没权限」,这本身就泄露了数据规模;404 什么都不透露。
二、先给自己的站点做一次清点
修 IDOR 不能靠想起哪修哪,得先把入口清点出来。我的做法是打开 Web 服务器的访问日志,把所有带参数、且参数值像「标识符」的请求捞出来:
awk '{print $7}' /home/wwwlogs/www.zz1984.com.log \
| grep -oE '[?&][a-z_]*(id|uid|no|sn|key|token|file|name|code)=[^&]+' \
| sed 's/=[^&]*/=X/' | sort | uniq -c | sort -rn | head -40输出形如 2 id=X、18 aid=X、7 filename=X,按频次排序就是一张待查清单。频次高的说明是核心功能,优先检查;filename、file、path 这类参数单独标红,它们还可能带来路径穿越(../../etc/passwd)。
扫完之后逐个确认三件事:这个参数能不能换成别人的值、换掉之后服务器返回什么、返回的内容里有没有别人的数据。三项都确认了才算查完,只看代码容易漏。
三、五种最容易被忽略的 IDOR 变形
变形一:列表接口只限制了条数,没限制范围。 很多人只测「详情接口」,其实列表接口更容易出问题。比如 /api/comments?post_id=1203,如果后端只按 post_id 过滤,没检查这篇文章是否是私有的,那么私有文章下的评论全都能被拉走。
变形二:写操作比读操作更危险。 读接口漏了只是数据泄露,写接口漏了就是数据篡改。删除、编辑、置顶、审核这些操作,很多人只在前端做了按钮隐藏,后端没校验。前端隐藏按钮不是安全措施,抓包照样能发请求。
变形三:附件与导出文件。 「下载导出文件」这个功能常见实现是 download.php?file=export_2026.zip,文件全部放在同一个目录里。只要改文件名就能下别人的。修法两种:文件名用随机串(不可猜),或者下载前查库确认这个文件属于当前用户。
变形四:重置密码、邮箱验证这类「一次性凭证」。 凭证本身如果就是用户的 id 或者可预测的 md5(uid + 固定盐),那它等于没有。这类 token 必须随机生成、存库、有有效期、用后作废。
变形五:后台的「切换用户」「代登录」功能。 这种功能本身就是为了越权而生的,很多站只校验「是管理员」,但没校验「操作者是谁、操作被记录到哪」。至少要留审计日志,并且不允许切换成比自己权限高的账号。
四、把归属校验做成一个统一的入口
逐个接口改,改完三个月又会漏。更可持续的做法是「所有取数据的操作都经过同一个函数」,把归属条件强制带上:
function fetch_own($table, $id, $uidField = 'uid') {
global $db;
$uid = isset($_SESSION['uid']) ? intval($_SESSION['uid']) : 0;
if (!$uid) {
throw new Exception('not logged in');
}
$sql = "SELECT * FROM `$table` WHERE id = ? AND `$uidField` = ?";
$row = $db->fetchRow($sql, array(intval($id), $uid));
if (!$row) {
throw new NotFoundException();
}
return $row;
}强制约定:业务代码里除了后台管理模块,禁止直接 SELECT * FROM posts WHERE id = ?,一律走 fetch_own。发布前用 grep 检查一遍:
grep -rn "WHERE *id *= *?" app/ | grep -v "AND *uid" | grep -v "fetch_own"这条命令会把所有「只按 id 查、没带 uid、也没走统一函数」的地方列出来,每一行都值得看一眼。它不能保证一定没问题(有些表的主键不叫 id,有些表用 team_id 做归属),但它能抓住九成的漏网。
五、标识符本身也可以设计成不可猜的
除了校验归属,还可以让标识符本身不可枚举。edit.php?id=1203 的攻击成本是「改个数字」,而 edit.php?k=Hq7dK2mX9a 的攻击成本是「猜 11 位随机串」,实际上不可能。
常见的两种做法:
UUID v4 或者 16 位随机串作为对外的公开 ID,数据库内部仍然用自增 id 做主键。好处是内部逻辑不变,只是对外多一列 public_id,所有路由都按这一列查。
自增 ID 加密(如 Hashids、Sqids)。它对现有代码改动最小:数据库不动,只在输出和接收时各做一次编解码。缺点是有密钥——如果密钥泄露,所有 ID 都能解出来。所以它只能算「降低被扫描的概率」,不能替代归属校验。
两种做法都要明确一点:不可猜的 ID 是纵深防御的一层,不是归属校验的替代品。链接分享、日志泄露、浏览器历史都可能让随机 ID 外流,最终兜底的仍然是那句 AND uid = ?。
六、几个具体的场景判据
判断一个功能是不是漏了归属校验,可以用一张很短的问答表:
这个请求里,服务器怎么知道「这条数据是谁的」? 如果答案是从请求参数里读出来的(比如 $_GET['uid']、$_POST['user_id']),那基本可以判定有漏洞——用户 id 必须从 session 里取,绝不能从请求体里取。
如果我传一个不存在的 id,服务器返回什么? 返回「数据不存在」是对的;返回「无权访问」就泄露了存在性;返回 500 或者一堆 SQL 报错就更糟。
我把自己的 id 换成别人的,页面有什么变化? 如果页面正常打开、内容变了,就是标准 IDOR。如果提示登录、或者跳回列表页,那可能是被挡住了(也可能是假的挡住,要进一步看数据是否已经在响应体里)。
批量接口呢? 比如「批量删除选中的文章」,请求体里传一个 id 数组。这类接口最容易只校验「数组非空」,不校验每个 id 的归属。必须逐条过归属检查,或者干脆在 SQL 里加 AND uid = ?,然后比对影响行数是否等于提交数量。
七、线上排查时容易误判的两个点
误判一:JSON 接口返回了别人的数据,但页面没显示。 前端可能做了二次过滤,看起来「点进去是空的」,但抓包就能看到完整数据。所以验证 IDOR 一定要看响应体,不能只看页面渲染结果。
误判二:接口返回 403,但其实数据已经查出来了。 有些代码是先查、后判断、再输出,若输出逻辑分散在模板里,可能出现「PHP 里已经 die 了,但模板早被渲染了一次」的情况。判断标准是响应体里有没有目标数据,而不是状态码。
还有一个更隐蔽的:缓存。某些页面缓存是按 URL 做 key 的,/post/1203 和 /post/1204 各自一份。如果缓存生成时用的是「当前登录用户」的视角,而读取时不校验身份,就会出现「A 打开过的私有页面被 B 命中」。这类问题在加了 CDN 之后尤其容易发生,要确认缓存 key 里包含用户身份,或者干脆不对私有页面做缓存。
八、收尾:把它变成发布前的固定动作
IDOR 不会自己消失,因为每个新功能都可能带来新的入口。我的做法是在发布检查单里加三条,成本很低但很有效:
第一条:凡新增「带标识符参数的接口」,必须同时提交一句测试描述——「用 A 账号请求 B 账号的 id,预期返回 404」。做不到就别上线。
第二条:所有操作类接口(改、删、审),在日志里记录「操作者 uid + 目标 id + 结果」。一旦出现越权尝试,这条日志就是唯一的证据。
第三条:每月跑一次上面那条 grep,看看有没有新的「只按 id 查」出现。新增行数应该稳定在「后台模块」的数量上,如果某个月突然多了几行,那就是新功能引入的入口。
最后说个心态问题。IDOR 之所以高频,不是因为它难修——它修起来往往就是加一个 AND uid = ?——而是因为它「看起来已经安全了」。登录校验、参数化查询、输入过滤,这三样一做,开发很容易觉得「该做的都做了」。但授权是第四件事,它不在任何框架的默认行为里,必须有人主动想一遍:这条数据,凭什么是他能看的。想清楚这一句,比装十个扫描器有用。