☰
PHP代码审计:in_array宽松比较绕过Piwigo权限校验
2026/10/17 9:46:00 网站建设 项目流程

1. 起点:为什么审计 Piwigo 时会盯上 in_array()

Piwigo 是 PHP 生态里比较有名的开源图库系统,功能上虽然没法和那些重型 CMS 比,但它有自己的插件机制、皮肤系统、批量处理、多用户权限,代码量又相对精简,用来做 PHP 代码审计练习非常顺手。这篇文章要聊的是我在审计一个基于 Piwigo 二次开发的项目时,反复撞到同一个点:in_array()函数在没有传第三个参数的情况下,默认使用宽松比较,导致出现了一类典型的逻辑绕过问题。放在真实生产环境里,这种问题不直接等于 SQL 注入或远程执行,但它能把本不该放行的请求放过去,常见位置基本都集中在权限校验、状态判断、参数白名单里。

我之前也见过不少项目,开发者习惯用in_array()做“这个值允不允许”的判断,看起来代码挺规整,安全意识也不算差,但就是少了一个参数。这个函数如果少写了true,它就只是“宽松匹配”,不是“严格匹配”。在 PHP 7 及更早版本里,这种宽松匹配会先做类型转换再比较,字符串和数字一旦混在一起,就会出现非常反直觉的结果。比如一个字符串“1abc”在比较时会被当成数字 1,一个以字母开头的字符串可能被当成数字 0,你以为是白名单挡住了非法输入,实际上攻击者只要换个字符,就能让自己的参数变成另一个完全不同的值。

这篇文章面向的读者主要是两类:一类是做应用安全、刚开始接触 PHP 代码审计的测试人员,需要知道在哪里找问题、怎么证明危害;另一类是 PHP 开发人员,尤其是维护类似图库、内容管理这类老项目的团队,需要理解为什么代码评审里要反复强调in_array的第三个参数。我会从 PHP 弱类型原理讲起,然后以 Piwigo 里的权限管理、相册访问控制为例,把代码定位、数据流分析、请求复现这一套流程走一遍,最后给出可落地的修复和排查建议。

这不算一个多么高深的漏洞,但它非常典型。弱类型比较带来的问题在 PHP 老项目里几乎是一抓一大把,Piwigo 只是其中一个很好的样本。把这个点吃透,你再回头去审其他 PHP CMS,思路会通很多。

2. in_array() 弱类型比较原理:真不只是少写一个参数

2.1 宽松比较背后的类型转换规则

要理解这个漏洞,先要理解 PHP 的比较机制。in_array($needle, $haystack)的默认逻辑是遍历数组中的每个元素,逐个和$needle做“宽松比较”。宽松比较在 PHP 里的运算符是==,它跟严格比较===最大的区别是:比较前会尝试把两边的值转换成同一种类型。

具体到字符串和数字比较,情况是这样的。在老版本 PHP 里,比如 5.x 和 7.x,如果拿字符串和整数比较,字符串会被转成整数,转换规则是看字符串开头有没有数字。有数字就截取数字部分,没有数字就直接变成 0。所以'123abc' == 123是true,'abc' == 0也是true。这看起来好像很好理解,但在白名单校验里就乱套了,你以为放行的是一串固定字符,实际上它可能匹配到数字 0,也可能匹配到某个数字前缀。

到了 PHP 8,官方调整了比较行为。非数字字符串不再自动转成数字,所以'abc' == 0在 PHP 8 里会返回false,'1abc' == 1也会返回false。但是注意,数字字符串的数值比较仍然存在,'2.0' == 2依然是true,'0e123' == 0也依然成立。这意味着即便你升级到 PHP 8,只要代码里还用宽松比较,依然可能被数字字符串绕过,绝对不能把 PHP 8 当成免死金牌。

in_array()内部用的就是==这套规则,所以我们前面说的字符串转数字、数字字符串数值比较,都会原封不动地发生在in_array()的匹配过程中。如果你传了第三个参数true,它才会切到===严格比较,类型不同就直接判定不相等,不会做任何转换。

2.2 常见误区:觉得“反正值差不多,影响不大”

很多人第一次接触这个知识点时会有个困惑:就算in_array('12abc', [12, 34])匹配到了 12,攻击者直接传12不也一样能达到目的吗?这里有个误解,真实危害不在于“能不能匹配到 12”,而在于“通过非预期路径匹配到了本来不该匹配的值”。

举个常见的场景。开发者写了一个权限判断,把用户提交的相册编号放进in_array()里判断是否有权限。攻击者传'12abc',在 PHP 7 下匹配到了编号 12,于是被判定为“有权限”。后续代码如果没有再做严格类型处理,拿着这个'12abc'直接拼到 SQL 里,数据库执行查询时又会把字符串转成数字 12 来用。前端逻辑认为你访问的是字符串'12abc',后端数据库认为你访问的是相册 12,这两层认知一旦出现偏差,就可能发生越权访问或者其他逻辑混乱。

更麻烦的还有 0 的情况。PHP 7 下,随便一个非数字开头的字符串,比如'guest'、'undefined',和整数 0 宽松比较都是相等的。如果某个功能里用in_array($level, [0, 1, 2, 5])判断用户等级,而$level是用户可控的字符串,那么攻击者传'guest'可能会被当成 0 级用户,传'5abc'可能被当成 5 级用户。具体会造成什么后果,取决于 0 级和 5 级在白名单里代表什么含义、后续逻辑怎么使用这个值。

所以这个问题的本质不是“多传了几个字符”,而是“宽松比较把两个类型不同的值硬凑到了一起”,打破了开发者在写代码时默认的“这个值一定在那个列表里”的前提假设。

2.3 最容易踩雷的三种参数组合

根据我在实际项目里总结的经验,真正容易出问题的in_array()用法通常长这样。

第一种是代码从数据库、请求参数、Cookie 里取出一个值,这个值本身是字符串,但白名单数组里的元素是整数。比如权限级别、状态码、类型 ID,这类数字型枚举值经常被存成 VARCHAR 字段。两者类型不一致,宽松比较就开始了。

第二种是白名单数组里有 0 这个元素。0 在宽松比较里特别容易“误伤”,因为非数字字符串在 PHP 7 下都会转成 0,null也比较出来跟 0 相等,空数组、布尔值false跟 0 也有各种奇怪的兼容关系。只要白名单里躺着 0,整个判断基本上就是形同虚设。

第三种是用户输入可以被构造成“合法数字前缀 + 任意后缀”,比如'2.0'、'0x...'这类。数字字符串在 PHP 8 下依然会做数值比较,所以'2.0'可以匹配到 2,'1e3'可以匹配到 1000。如果白名单里的值是 1000,你传'1e3'就绕过去了。

这三种组合并非孤立出现,经常是叠加的。比如一个相册 ID 列表,里面既有 0 又有正常的 ID,用户输入又来自查询参数,那宽松比较的危险程度就直接拉满了。下面我们就到 Piwigo 的实际代码环境里把场景搭起来看。

3. 实战审计:在 Piwigo 代码里挖出 in_array() 风险点

3.1 第一步:全局搜索 in_array 调用点

拿到一套 Piwigo 源码后,我不会急着看功能逻辑,先做一个全局搜索,把in_array()的调用点全部列出来。这一步很快:

grep -rn "in_array(" --include="*.php" .

搜索结果通常会有几十甚至上百条,别指望全部看一遍,要会挑重点。我的筛选顺序是:先看有没有传第三个参数true,没传的标记出来;再看参数内容是不是由外部输入带进来的;最后看in_array()的判断结果会走哪些分支。

Piwigo 这种老牌 CMS,代码里in_array()出现频率不低,但真正值得深挖的通常集中在几个模块里:用户和权限相关、相册分类相关、批量编辑相关、插件功能开关相关。尤其是权限模块,一旦判断被绕过,后面就是越权,这是我每次审计的重心。

3.2 第二步:从权限判断点反推数据流

我挑一个很典型的示意代码来说明,这种写法在真实项目里反复出现,我在审计时也见过差不多的结构:

function can_manage_album($user, $album_id) { // 获取当前用户有管理权限的相册 ID 列表 $allowed_albums = get_manageable_albums($user['id']); // 判断传入的相册 ID 是否在允许列表里 if (in_array($album_id, $allowed_albums)) { return true; } return false; }

只看这段代码,好像没什么问题。但关键在$album_id的来源。如果外部传入的是$_GET['album_id']或$_POST['album_id'],那它天然就是字符串。而get_manageable_albums()返回的列表大概率是数据库里查出来的整数 ID 数组。

在 PHP 7 环境下,in_array('12abc', [12, 34, 56])返回true,因为'12abc'会被转成数字 12。这就意味着,攻击者只要给相册 ID 后面追加几个字母,就能让一个原本没有权限的相册 ID 通过校验。后续业务逻辑如果继续用这个带字母的字符串去操作数据库,数据库再自己转一次类型,整个判断就像被偷换了一样。

我看代码有一个习惯:看到in_array()之后,紧接着会去追它关联的后续操作。如果后续是更新数据库、删除数据、读取私有内容,那问题就从“逻辑瑕疵”升级成“安全漏洞”。如果后续只是纯展示、不涉及敏感数据,危害就会降低很多,这也是审计时不能一视同仁的原因。

3.3 第三步:本地环境复现一次绕过

为了把问题讲清楚,我在本地搭了一套 Piwigo 测试环境,PHP 版本用的 7.4,模拟旧环境下的真实表现。然后构造了一个相册管理请求,目标是绕过权限判断访问到所属权限之外的相册。

请求大致长这样:

GET /admin.php?page=album&album_id=12abc HTTP/1.1 Host: piwigo-test.local Cookie: [管理员登录后的 Cookie]

正常情况下,我当前用户能管理的相册 ID 是 12,但传进去的是字符串'12abc'。如果in_array()没开严格模式,这个请求会顺利通过权限检查,并进入后续的相册编辑逻辑。我在代码里加了日志,确认in_array('12abc', [12, 34])确实返回了true,而后面的 SQL 语句执行时,MySQL 把'12abc'转成数字 12 来匹配记录,最终把一个不在预期范围内的相册内容查了出来。

换成 PHP 8 环境再跑一遍,'12abc'会被直接拒绝,因为 PHP 8 不再做非数字字符串的自动转换。但是如果我传'12.0',在某些比较上下文里依然可能匹配到 12,所以不能说升级 PHP 8 就万事大吉。

复现过程中还有一个值得注意的细节:很多开发者会在in_array()判断前先做一次intval()转换,比如$album_id = intval($_GET['album_id']),这种情况其实相对安全,因为数值已经被强制归一化了。但很多人没有这步,直接把参数值扔进in_array(),宽松比较才有发挥空间。

3.4 扩展:所有用户可控参数都要过一遍

相册管理只是一个入口。在 Piwigo 里,类似的判断还出现在用户等级设置、批量操作指令、插件开关状态等地方。审计时要把所有用户可控参数列出来,逐一追踪它们有没有进过in_array()。

我整理了一个风险清单,每次审计新项目都会先过一遍:

参数来源常见用途风险等级
GET/POST 参数相册 ID、用户 ID、动作类型、状态值高
Cookie 值用户标识、语言标识、站点偏好高
上传文件属性扩展名、MIME 类型、尺寸参数中
数据库字段用户等级、配置状态、权限位中
第三方回调数据webhook 参数、API 返回值中

这张表的价值在于提醒我:不一定只有 HTTP 参数才是攻击面,数据库里被别的漏洞写入的脏数据,同样可能成为触发in_array()松比较的源头。比如某个字段原本应该存整数,结果因为前面另一个逻辑漏洞存入了字符串,后面所有依赖这个字段做权限判断的地方都会连环出问题。

4. 修复方案:从一行改动到一套规范

4.1 最直接的修复:给 in_array() 加上 true

针对具体的in_array()调用点,修复其实只需要一行改动:

// 修复前 if (in_array($album_id, $allowed_albums)) { // ... } // 修复后 if (in_array($album_id, $allowed_albums, true)) { // ... }

第三个参数true会把比较从==切换成===。===先判断类型,再判断值,字符串'12abc'不会等于整数 12,类型都不一样,直接返回false。这样就把最核心的类型混淆问题堵住了。

但这里要提醒一句:直接加严格模式,有可能会导致原本“能用”的功能突然不能用。因为在宽松模式下,in_array('12', [12, 34])以前是true,现在开了严格模式,字符串'12'和整数 12 类型不同,结果变成false。如果上游传入的一直是字符串,下游又依赖这个判断放行,你就要连带上游做类型修正,否则会误伤正常业务。

所以我的建议是:不能只改这一行,要顺藤摸瓜把数据源头一起改了。比较之前先统一类型,要么在入口处强制转成整数,要么在存储层保证字段类型和取值一致。

4.2 从源头消除类型混淆:入口强制转换

相比在判断时“打补丁”,我更推荐在入口阶段就把类型定死。比如相册 ID 这种数字型参数,入口处直接转整数:

$album_id = (int)$_GET['album_id'];

这样做的好处是,后续所有逻辑拿到的都是纯整数,不会再出现'12abc'这种混合字符串。哪怕后面某个in_array()写漏了第三个参数,由于比较双方都是整数,宽松比较的行为也趋近于严格比较,危险面小很多。当然,最佳实践还是两者一起做:入口转类型,判断开严格模式。

类型转换也有坑,尤其是把用户输入直接(int)转换时,要警惕 0 值的误判。比如攻击者传一个非数字字符串,转换结果就是 0。如果后续逻辑里 0 代表某种特殊身份或资源,就可能产生新的逻辑绕过。所以入口转换仅仅适用于明确的数字型字段,而且转换后还要继续参与后续的合法性校验,不能转完就认为安全了。

4.3 推荐白名单结构:关联数组加 isset

除了in_array(),其实还有一种更稳的写法,就是用关联数组做白名单:

$allowed_map = array_fill_keys($allowed_albums, true); if (isset($allowed_map[$album_id])) { // 通过校验 }

这个写法的底层逻辑是:键的命中要求完全一致,isset($allowed_map['12abc'])不会命中键 12,只有传'12'和传 12 这两种情况需要小心处理。如果用array_key_exists(),在 PHP 8 下数字字符串键和整数键之间可能还是会有键转换,所以更建议在放入白名单前就把类型统一成字符串,或者统一成整数,保持键的类型一致。

这种写法的另一个好处是性能更好。in_array()本质上是一个循环比对,数组一大,效率就下来了。而isset()是基于哈希查找的,常数级时间复杂度,在批量操作、大批量数据判断的场景下优势非常明显。代码审计规范里,我一般建议团队把“白名单判断”统一写成这种形式。

4.4 团队层面的规范落地

修复完具体代码还不算完,如果不从习惯层面改变,下次还会有人写新的in_array()调用点。我在做代码安全评审时,会要求团队遵守下面几条:

  • in_array()一律必须带第三个参数true,除非有非常明确的理由并留下注释。
  • 外部输入进入判断前必须先做类型转换,数字参数转数字,字符串参数说明可接受字符集。
  • 优先使用关联数组白名单写法,替代in_array()做合法值校验。
  • 每次评审重点检查权限、状态、动作三类的判断代码,这些都是绕过的高发区。

规范不是靠嘴上说,最好把它固化到代码规范文档里,并配合静态扫描工具做自动提醒。像 PHPStan、Psalm 这类静态分析工具,虽然不一定能直接找出所有宽松比较问题,但配合人工 review,效果会好很多。很多问题之所以反复出现,就是因为每次都是修完一个点就完事,没有把规律沉淀成规则。

5. 审计中的常见误判与补漏心得

5.1 见到 in_array() 就报“漏洞”是最大的误判

做审计时间长了,很容易走向另一个极端:看到in_array()没带第三个参数,就顺手标记一个“高危”。这其实不对。in_array()没有开严格模式只是触发漏洞的一个前提条件,真正决定危害大小的,是它的参数是否来自外部输入、白名单数组里是否存在可利用的值、判断结果会被哪些敏感逻辑消费。

正确的判断顺序应该是:先确认外部可控性,再确认类型差异,再追后续逻辑。一个只在服务端内部使用、数据源永远是整数的in_array(),没开严格模式造成的风险几乎可以忽略。这时候强行报高危,只会让开发团队对审计报告失去信任。漏洞描述里最好写清楚“触发条件”和“影响路径”,而不只是一个函数名。

我一般会用一个三问法:这个函数的第一个参数是不是外部可控?第二个数组里存的是不是和第一个参数类型不一致的值?判断通过后有没有进入权限、写入、删除等敏感操作?三个问题里至少满足两个,才值得继续深入。满足不了,就先搁置。

5.2 容易被漏掉的“宽松比较亲戚”

in_array()只是宽松比较的冰山一角。在做完整代码审计时,我还会顺手排查它的几个亲戚函数,因为很多修复只堵了in_array()一个点,其他地方却还开着门。

第一个是array_search()。它和in_array()行为几乎一样,默认也是宽松比较,只不过返回的不是布尔值,而是命中的键名。如果开发者用array_search($input, $whitelist) !== false做判断,问题的性质完全相同。

第二个是in_array()处理数组参数时的情况。如果第一个参数本身就是数组,in_array()会比较整个数组结构,宽松比较下两个值相同的数组可能因为键顺序、类型不同而产生奇怪的结果,这个要比单个值复杂得多,需要逐个字段分析。

第三个是switch-case和一堆杂七杂八的==直接比较。Piwigo 老代码里也有不少地方直接拿==做进制比较,比如用户状态和字符串常量比较,这些本质上和in_array()是一个病根。我会在搜索in_array()的同时,顺手搜搜==加变量名的组合,挑选和权限相关的部分重点看。

补充一句,array_flip()和in_array()配合使用时也要注意。如果先把一个字符串数组array_flip()变成键,再用isset()判断,键的类型通常会变成整数键和字符串键混合,在 PHP 数组键转换机制下,"1"和1会被当成同一个键,这可能引入新的误判。白名单结构虽然推荐,但前提是键类型必须统一。

5.3 修改后的回归测试:不能只测正常路径

修复完成后,还有一个我很多年前踩过坑的环节:回归测试。大部分人测试逻辑漏洞,只跑一条正常路径和一条明显非法路径,比如传'abc'看有没有被拦,然后就宣布修好了。但针对弱类型问题,测试用例必须覆盖类型边界。

我建议至少测以下几类输入:

  • 正常合法值,比如12
  • 带前缀数字的字符串,比如12abc
  • 纯非数字字符串,比如guest
  • 数字字符串变体,比如12.0、1e2
  • 特殊的 0 相关值,比如空字符串、null、布尔值false

这些用例里,有些可能在 PHP 8 下表现正常,但不代表未来升级后仍然安全。测试完in_array()的判断结果,还得继续测后续业务逻辑有没有把原始参数值带到数据库或文件操作里。曾经有个项目,等我把in_array()修好之后,才发现下游有一个 SQL 查询仍然在使用未转换的原始字符串做拼接,等于前面的校验白修了。

把回归测试的用例清单沉淀到测试用例库里,每次遇到类似漏洞都能直接用。这也是为什么我强调,单点修复只是临时止血,真正的价值是把整套“弱类型检查用例”固化下来,以后再做代码改动,直接套用这些用例跑一遍,能少踩很多坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询