越权与逻辑漏洞审计

前面几篇学的漏洞(SQL 注入、命令执行、文件包含)都有一个共同点:代码里存在一个「危险函数」(Sink),用户输入一路流进去就出事。审计方法也很机械——搜危险函数,往回倒推输入来源。

但业务逻辑漏洞不吃这一套。它没有统一的危险函数可搜:selectById() 是普通得不能再普通的数据库查询,DELETE FROM users 也是正常业务语句——代码每一行都「合法」,组合起来却能让 A 用户删掉 B 用户的账号。这类漏洞是「敏感函数回溯法」的盲区,只能靠逐接口问三个问题来挖。

好消息是:正因为自动化工具扫不出来,逻辑漏洞是 SRC 众测里出洞率最高的一类,性价比极高。

本篇按三条主线组织,难度递增:

  1. 越权(水平/垂直)——跨语言通用,Java/PHP/Python 都有,是最重要的一章;
  2. 变量覆盖——PHP 独有的历史漏洞形态;
  3. 弱类型与比较缺陷——PHP 为主的富矿,CTF 和面试高频。

先修知识

读这篇之前,先把下面几个词搞明白,后面不会再停下来解释。

术语白话解释
Source(输入源)攻击者能控制的数据入口,比如 URL 里的 ?id=123、表单提交的内容、HTTP 头、Cookie。凡是「用户能改」的东西都是 Source。
Sink(危险落点)输入最终到达的「出事地点」,比如数据库查询、文件读写、system() 命令执行。本篇的特点就是:逻辑漏洞没有固定 Sink,正常函数也能当 Sink。
数据流数据从 Source 到 Sink 走的路径。审计就是沿着这条路检查「中间有没有人把门」。
Session(会话)服务器给每个登录用户发的「身份档案」,存在服务端。你登录后,服务器记住「这个连接是 3 号用户」,靠的就是 Session。
Cookie服务器写在你浏览器里的小卡片,每次请求自动带上。Session 的钥匙(session id)通常就放在 Cookie 里。
GET / POSTHTTP 请求的两种方式。GET 把参数放在 URL 里(?id=1),POST 把参数放在请求体里(表单提交)。对攻击者来说没有本质区别,都能改。
拦截器 / 过滤器 / 装饰器同一个东西的三种语言叫法:请求到达业务代码之前先经过的一道「安检门」,常用来做登录校验。Java 叫拦截器/注解,PHP 老式项目叫「入口 include 的校验文件」,Python 叫装饰器。
水平越权 / 垂直越权水平 = 平级之间互相串门(你看我的订单);垂直 = 下克上(普通用户用管理员功能)。下文详讲。
== 与 ===(PHP)== 是「松散比较」:两边类型不同会先偷偷转型再比;=== 是「严格比较」:类型不同直接判不等。第三章整章都在讲这个区别怎么被利用。
md5 / 哈希把任意字符串算成一个固定长度的「指纹」。理论上不同输入指纹不同,但 PHP 的松散比较会让某些特殊指纹「被当成相等」。
IDORInsecure Direct Object Reference,不安全直接对象引用——水平越权的一种典型形态:改个 id 就能看别人的东西。

第一章 越权漏洞审计

通用原理(跨语言)

是什么

越权 = 服务端对「当前身份能不能操作这个对象 / 执行这个功能」的校验缺失或写错了。

生活类比:酒店房卡系统。

  • 身份认证 = 前台确认你是住客,给你一张房卡(你登录了)。
  • 功能权限校验 = 保洁员的卡能开所有房门做清洁,普通住客的卡不行(你能不能干这事)。
  • 对象归属校验 = 你的卡只能开 305,开不了隔壁 306(这东西是不是你的)。

三个环节任何一环没做好,就是越权。分两类:

类型定义酒店类比典型场景
水平越权(IDOR 属于此类)同级别用户互操作:A 看/改 B 的数据你的卡能开 306——你和隔壁都是普通住客改 userId、orderId 看他人订单、资料、私信
垂直越权低权限用户执行高权限功能你的卡能开总控室普通用户调后台接口、未登录访问管理页

为什么会出现这个漏洞

不是开发者傻,是三个非常自然的心理:

  1. 「前端藏起来就安全了」。管理员菜单在前端 JS 里做了 display:none,普通用户看不到按钮——开发者就觉得没事了。但接口还在那儿,攻击者直接发包就行,根本不经过页面。这是垂直越权的第一大成因。
  2. 「查了 id 就是查了」。开发者写 selectById(orderId) 时想的是「用户要看自己的订单」,顺手就把 URL 里的 id 拿去查了,压根没想起来还要验证「这个订单是不是他的」。功能测试全通过(因为测试时用的都是自己的数据),漏洞就带上线了。
  3. 「登录了就是自己人」。很多项目的鉴权只做到「有没有登录」这一层,登录之后所有接口随便调。登录 ≠ 有权限,这是两个检查。

审计时怎么找

代码层的成因归结为一个公式:

鉴权 = 身份认证(你是谁)+ 功能权限校验(你能不能干这事)+ 对象归属校验(这东西是不是你的)

越权 = 三者任一缺失。

审计动作就是把全量路由列出来,逐接口问这三个问题。具体操作:

  1. 先找到「门」在哪里:Java 看注解/拦截器配置,PHP 看文件头 include 了什么,Python 看装饰器。
  2. 找到门之后,看哪些接口没经过门(漏配路径、漏加注解、漏 include)。
  3. 对所有带 id 类参数的接口(/order/123、?userId=5),看 SQL 里有没有「当前登录人」这个维度。

下面分语言看具体形态。注意:原理完全一样,只是「门」的实现方式不同。

Java

Sink/成因(注解与拦截器视角)

Java(Spring)越权的审计对象是权限声明的位置,而不是某个危险函数——这是它和 SQL 注入审计最大的区别。没有「搜一个函数名」的捷径,要逐个接口核对。

排查清单:

  • @PreAuthorize("hasRole('ADMIN')") / @Secured 是否遗漏在某个敏感接口上。这两个注解就是写在方法头上的「门牌」:标了才查权限,没标就不查。
  • 拦截器 addPathPatterns 配置是否漏路径。比如配置了 /admin/** 需要管理员,但 /admin/internal/health 单独注册在另一个 Controller 里没被覆盖到。另外 Filter 链对 URI 的解析差异(;;、..;/、大小写、尾部 /)也可能让请求绕过路径匹配——应用觉得路径是 /admin/user,Tomcat 解析后却是另一个。
  • 普通用户和管理员接口写在同一个 Controller,但只给部分方法加了权限注解——漏加的方法就是垂直越权。Code Review 时这种情况最常见:一个类里十个方法,九个有注解,第十个是后加的,忘了。
  • 角色判断写在前端 JS / 前端只隐藏菜单,后端接口未鉴权(上文说的第一大成因)。
  • 水平越权:Service/Mapper 层查询只按对象 id 查,不带当前登录人维度(selectById(id) 而非 selectByIdAndUserId(id, userId))。

案例带读(水平越权 / IDOR)

这是一段真实的典型写法,逐行看:

// ❌ 危险:只校验登录,没校验订单归属
@GetMapping("/order/{id}")                          // ① 路由:GET /order/123 这样的请求会进这个方法
public Order getOrder(@PathVariable Long id) {      // ② 把 URL 里的 123 取出来放进变量 id —— 这是 Source,用户想填几填几
    return orderMapper.selectById(id);              // ③ 拿这个 id 直接查数据库,查到什么返回什么 —— 这是 Sink
}
 
// ✅ 安全:校验对象属于当前用户
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable Long id, @AuthenticationPrincipal User user) {
    //                                              ④ 多拿一个参数 user:这是 Spring 从登录凭证里解析出的「当前登录人」,用户改不了
    return orderMapper.selectByIdAndUserId(id, user.getId());
    //                                              ⑤ SQL 变成 WHERE id=123 AND user_id=5 —— 别人的订单根本查不出来
}

数据流走一遍:

① 用户输入从哪进来:攻击者把请求改成 GET /order/456(他自己的订单是 123,456 是别人的),URL 里的 456 被 @PathVariable 绑到 id 上。

② 经过什么处理:什么处理都没有——方法里没有任何一行检查「456 号订单是不是当前登录人的」。

③ 到达哪个危险函数:selectById(id),直接执行 SELECT * FROM orders WHERE id = 456。

④ 为什么防线失效:这道接口只有「登录」一道防线(框架层面),攻击者注册个免费账号就能过。真正的防线应该是「订单归属校验」,但代码里压根没写。

审计结论:存在水平越权(IDOR)。写个脚本从 1 遍历到 N,就能拖出全站订单。审计同类接口时的硬标准:凡是 Mapper 方法签名里没有 userId 维度的查询,全部逐个标记出来人工确认。

黑盒验证方法(白盒看完顺手验证):注册 A、B 两个账号,A 创建一个订单记下 id,用 B 的登录态请求这个 id,能拿到数据就是实锤。

防御修复

  1. 垂直越权:在拦截器/网关统一鉴权兜底(deny by default——默认全部拒绝,明确放行的才放行),敏感注解加到方法级并 Code Review 查漏。
  2. 水平越权:所有按 id 取数的 SQL 强制带 user_id = #{当前登录人} 条件;查询不到时返回 404 而非 403——403 等于告诉攻击者「这个 id 存在但不是你的」,反而帮他确认目标存在。
  3. 对象引用间接化:对外暴露不可遍历的随机 UUID/加密 token 代替自增 id。注意这只是缓解(攻击者拿不到别人的 UUID),不是根治,归属校验才是根治。

PHP

Sink/成因(会话归属判断视角)

PHP 没有注解和拦截器这种框架级设施(老式项目),「门」就是每个文件开头自己 include 的校验代码。所以 PHP 越权审计对象是每个入口文件的鉴权代码与 SQL 归属条件:

  • 后台/敏感文件头部是否 include 了权限校验文件,有没有文件漏 include。非 MVC 平铺项目(一个 PHP 文件就是一个页面)的重灾区——一百个文件九十九个 include 了 check.php,漏的那个就是洞。
  • 鉴权代码用 die()/exit 终止,还是只 header('Location: login.php') 跳转——只跳转不 die,代码会继续往下执行,等于没鉴权。这是 PHP 垂直越权最经典的写法,下面案例详讲。
  • 水平越权:SQL 只按 $_GET['id'] 查,WHERE 里不拼 $_SESSION['uid']。
  • 角色判断取自 $_SESSION 还是取自请求参数——如果代码里出现 if ($_GET['role'] == 'admin') 或者信任 Cookie 里的 is_admin=1,攻击者改一下请求就直接垂直越权。Session 存在服务端改不了,请求参数在客户端随便改,这就是区别。

案例带读(垂直越权:未登录直达后台功能)

<?php
// admin/deluser.php —— 漏洞代码
session_start();                                // ① 开启会话,准备读当前登录人的身份信息
if ($_SESSION['role'] !== 'admin') {            // ② 检查:Session 里记的角色不是管理员?
    header('Location: /login.php');             // ③ ⚠️ 只是往响应里写了一个「请跳转到登录页」的头,PHP 脚本本身不会停!
}                                               //    if 块结束,程序继续往下走
$id = intval($_GET['id']);                      // ④ 取 URL 里的 id(intval 防了 SQL 注入,但跟越权无关)
$db->query("DELETE FROM users WHERE id = $id"); // ⑤ Sink:删除用户的 SQL 照样被执行
echo 'deleted';                                 // ⑥ 攻击者收到 deleted,用户已被删

先解释第 ③ 行为什么是错的,这是新手最容易卡住的点:

header('Location: ...') 的作用是在 HTTP 响应里写一行跳转指令,仅此而已。它不是 return,不会让 PHP 停下来。浏览器收到这个响应后会跳去登录页——但那是浏览器的事,PHP 脚本已经把后面的代码全执行完了。攻击者用 Burp/curl 发包,根本不「跟随跳转」,他只关心服务器到底执没执行那条 DELETE。

生活类比:保安在门口贴了张「闲人免进请去登记处」的纸条就下班了——纸条拦得住守规矩的人,拦不住直接把纸条无视了往里闯的人。真正的做法是把门锁上(exit)。

数据流走一遍:

① 用户输入从哪进来:攻击者不登录,直接发 GET /admin/deluser.php?id=5,并在 URL 里带上要删的用户 id。

② 经过什么处理:$_SESSION['role'] 是空的(没登录),!== 'admin' 成立,进入 if,发出 302 跳转头——然后没有 exit,脚本继续。

③ 到达哪个危险函数:$db->query("DELETE FROM users WHERE id = 5") 原样执行。

④ 为什么防线失效:防线(登录检查)逻辑上存在、语法上也执行了,但缺了最关键的「拦住之后要停下」这一步。

审计结论:存在垂直越权(未授权任意用户删除)。审计动作:全文搜 header('Location',命中后逐个检查下一行是不是 exit/die。这个搜索五分钟能跑完一个项目,命中率出奇地高。

修复:跳转后立刻 exit;。更好的做法是写一个公共鉴权文件 check_admin.php(校验失败即 die),每个后台文件第一行 require 它——漏 die 的风险被收敛到一个文件里。

防御修复

  1. 鉴权公共文件统一 require(不是 include——include 失败只是警告,require 失败直接致命错误终止脚本,更适合做「门」),校验失败必须 die。
  2. 水平越权:所有取数 SQL 拼接 $_SESSION 中的身份维度,禁止信任请求中的 uid/role 字段。
  3. MVC 框架(ThinkPHP/Laravel)检查未继承鉴权基类(CommonController/BaseController)的 Controller——它们是无鉴权高危路由重灾区;WordPress 插件重点查 add_action('wp_ajax_nopriv_*')(nopriv 意味着未登录用户也能触发)是否做了 current_user_can() 校验。

Python

Python(Flask/Django/FastAPI)越权形态与 Java 注解模式同构——「门」就是装饰器和依赖注入,审计思路直接平移:

  • Flask:@login_required / 自定义 @admin_required 装饰器是否遗漏在某个视图函数上。注意装饰器顺序也有讲究——鉴权装饰器必须紧贴视图函数(放在最外层路由装饰器之下、其他装饰器之上),写错顺序可能先执行业务逻辑再鉴权。
  • Django:@permission_required / LoginRequiredMixin;用 DRF 的话看 permission_classes 是否被某个 ViewSet 覆盖成了 AllowAny(一个属性直接把所有门拆掉)。
  • FastAPI:路由级 Depends(get_current_admin) 是否漏挂。
  • 水平越权同 Java:ORM 查询 Model.objects.get(id=id) 不带 owner=request.user 条件。

防御:框架级默认鉴权(Django 全局 LoginRequiredMiddleware、FastAPI 在 router 级统一挂依赖)+ ORM 查询强制带归属条件 + 单元测试覆盖越权用例(用 B 用户的 token 请求 A 用户的资源,断言返回 404)。

越权审计清单(落地动作)

拿到一个项目,按这个顺序做:

  1. 列全量路由表,逐个标注三列:是否需登录、是否需角色、是否操作具体对象。这张表是后面所有工作的基础。
  2. 垂直越权排查:拦截器/注解/装饰器漏配;前端隐藏但后端未鉴权;同 Controller 部分方法漏注解;鉴权后不 die(PHP 专项)。
  3. 水平越权排查:URL/Body/JSON 中的 id、userId、orderId、fileId、docId 等参数,凡后端没用「当前登录人」维度校验的,全部逐个手工遍历测试。
  4. 黑盒验证:注册 A、B 两个同级账号互测对象访问;普通账号抓管理员请求包直接重放。

第二章 变量覆盖(PHP 特性)

语言形态说明:变量覆盖是 PHP 独有 的漏洞形态,根源在于 PHP 允许「把数组键名动态注册为变量」的函数(extract/parse_str)和 $$ 可变变量语法。Java 不存在该形态——强类型语言在编译期就把变量名绑死了,不存在运行时用外部数据声明/覆盖局部变量的机制;Python 也不存在——虽然理论上可以 globals()[k]=v 动态写全局命名空间,但正常 Web 代码不会这样写,不构成像 PHP 这样成体系的历史漏洞类型。本章只写 PHP。

是什么 + 为什么会出现

是什么:变量覆盖 = 攻击者通过请求参数,把代码里已经定义好的变量改写成自己的值。

生活类比:你填了一张入职表,「职位」一栏你写的是「实习生」。人事图省事,直接把一沓外部来信里的字段抄进系统——结果某封信里有个「职位:CEO」字段,系统不管三七二十一覆盖了原来的值。你第二天就是 CEO 了。extract($_GET) 干的就是「把外部字段直接抄进系统」这件事。

为什么开发者会写出这种代码:一个字,懒。$_GET 里有 10 个参数,正常写法是 $a = $_GET['a']; $b = $_GET['b']; ... 写十行。extract($_GET) 一行全搞定,键名自动变变量名。PHP 官方文档对 extract 的安全警告用红字标着,但架不住「方便」两个字的诱惑——尤其是 2010 年前后的大量老 CMS,这种写法遍地都是。

原理与 Sink

先记住一个前置知识:PHP 里 $a = 1 定义变量,$$ 是「可变变量」——$name = 'a'; $$name = 1; 等价于 $a = 1(变量名本身可以来自另一个变量的值)。这个语法配合外部输入就是灾难。

Sink风险点版本分界
extract($arr)默认 EXTR_OVERWRITE 模式:把数组的键名注册为变量,并覆盖同名已有变量全版本
parse_str($str)把 a=1&b=2 这样的查询串解析成变量;PHP<8 可以不传第二参数,变量直接注册到当前作用域PHP 8.0 起第二参数必填,风险收敛
$$ 可变变量foreach($_GET as $k=>$v) $$k=$v;——键名完全由攻击者控制,等价于「任意变量任意写」全版本
import_request_variables()直接把 GET/POST/Cookie 全体导入全局变量PHP 5.4 已移除,只会在古董代码里见到
mb_parse_str()同 parse_str同 parse_str 规则

被覆盖的高价值目标(审计时优先想这三个):

  1. 鉴权标志:$auth、$is_admin、$login——覆盖了直接进后台;
  2. 配置变量:文件路径(覆盖后包含任意文件)、数据库名、表名前缀;
  3. 过滤白名单数组:覆盖了等于过滤规则形同虚设。

案例带读(extract 覆盖鉴权变量)

<?php
// admin.php —— 漏洞代码
include 'config.php';           // ① 加载配置,里面有一行 $auth = false;(默认未授权)
extract($_GET);                 // ② ❌ 把 URL 参数全体注册为变量,默认 EXTR_OVERWRITE —— 同名变量直接被盖掉
if ($auth) {                    // ③ 检查鉴权标志 —— 但它此刻的值已经是攻击者给的了
    system($_GET['cmd']);       // ④ Sink:后台「特权功能」,执行任意系统命令
} else {
    echo 'forbidden';
}

(说明:原笔记此案例用的是 $auth === true 强比较,那种写法下 GET 传进来的字符串永远不等于布尔 true,实际不可利用。这里改成更典型、真实漏洞里更常见的 if ($auth) 松散判断,强比较的应对思路见下文。)

数据流走一遍:

① 用户输入从哪进来:攻击者请求 ?auth=1&cmd=id,两个参数都进 $_GET 数组。

② 经过什么处理:extract($_GET) 循环数组,执行了 $auth = '1'; $cmd = 'id';——第 ① 步 config.php 里设的 $auth = false 被覆盖成 '1'。

③ 到达哪个危险函数:if ($auth) 判断时 '1' 为真,进入分支,system('id') 执行。

④ 为什么防线失效:防线($auth = false 的默认值)确实存在,但 extract 在防线之后执行,把设好的值又改掉了。顺序即命运。

payload 逐段拆解——?auth=1&cmd=id:

  • auth=1:extract 后变成 $auth = '1'。if ('1') 中任何非空非 '0' 的字符串都是真,所以顺利通过第 ③ 步。
  • &:URL 参数分隔符,无特殊含义。
  • cmd=id:变成 $cmd = 'id',第 ④ 步被 system() 当系统命令执行。id 是 Linux 下查看当前用户身份的命令,是漏洞验证的惯例首选(无害、输出明确)。实战中换成 cat /etc/passwd 之类。

一个重要的变体(面试常追问):如果第 ③ 步是 if ($auth === true) 强比较呢?$auth 经过 extract 只能得到字符串,'1' === true 恒为假,auth=1 失效。这时的思路是:放弃覆盖 $auth,转而寻找代码里其他松散判断的变量(比如某个 if ($debug == 1)),或者看能不能同时覆盖 config.php 里的路径变量走文件包含。审计时要逐个判断覆盖目标后面的比较是 == 还是 ===,两者利用方式完全不同。

审计结论:extract($_GET) 未指定 EXTR_SKIP 且未加前缀,可覆盖任意已定义变量。同类考点一起记:parse_str($_SERVER['QUERY_STRING'])(PHP<8 省略第二参数,效果等同 extract)、foreach($_GET as $k=>$v) $$k=$v;(双美元符,最直白的任意变量写)。

审计时怎么找:

  1. 全文搜 extract(、parse_str(、$$、import_request_variables、mb_parse_str;
  2. 命中后回答两个问题:传进去的数组是不是外部输入?当前作用域里有没有已定义的重要变量(鉴权标志、路径、配置)?
  3. 两个答案都是「是」,漏洞成立。

防御修复

  1. ❌ 禁止对外部数组直接 extract($_GET/$_POST/$_REQUEST);业务确需时至少 extract($arr, EXTR_SKIP)(遇同名跳过不覆盖)或加前缀 EXTR_PREFIX_ALL。
  2. ✅ 鉴权/配置变量显式赋值($auth = false;)且放在函数内部作用域,赋值语句之后不再调用任何变量注册函数。
  3. ✅ parse_str($str, $result) 永远传第二参数,只用返回的数组,不让它注册变量。
  4. ✅ 杜绝用 $$ 处理外部输入;需要动态键就用数组 $data[$k]——数组键是数据,变量名是代码,两者不能混。

第三章 弱类型与比较缺陷(PHP 为主)

语言形态说明:本章是 PHP 审计独有的富矿,根源是 PHP 的 == 为松散比较——两边类型不同时先转型再比。Java 不存在该形态:== 两侧类型不匹配直接编译报错,编译都过不了;Java 对应的坑是 String 用 == 比引用而非 equals(),那属于编码错误而非类型杂耍漏洞。Python 也不存在:'1' == 1 恒为 False,Python 不做隐式转型比较。本章以 PHP 为主体,Java/Python 只需了解「不存在该形态及原因」即可。

是什么 + 为什么会出现

是什么:PHP 用 == 比较两个不同类型的值时,不会报错,而是先把其中一边(或两边)偷偷转换成同一类型再比。这个转换规则有很多反直觉的角落,攻击者专门利用这些角落让两个「明明不相等」的值被判为相等。

生活类比:考试对答案,老师图省事,不管学生写的是「1」「一」「壹」还是「one」,一律先翻译成阿拉伯数字再判分。结果有人写「1个苹果」,老师的翻译规则是「取开头的数字」,翻出来也是 1——判对了。'1abc' == 1 为 true,就是这个道理。

为什么会出现:PHP 诞生于 1995 年,设计目标是「让不懂编程的人也能快速写网页」。表单提交过来的数据全是字符串($_GET['age'] 是 '18' 不是 18),如果每次比较都要手动转型,新手根本没法玩。于是 PHP 选择了「自动转型」这条方便的路——方便了三十年,也挖了三十年的坑。PHP 8 收紧了一部分规则(见下表版本分界),但互联网上跑着的海量老代码不会自己升级。

必须先背下来的核心转型规则(PHP < 8):

  • 字符串转数字:取开头的数字部分。'1abc' → 1,'123' → 123,没有数字开头(如 'abc')→ 0。
  • 所以 '1abc' == 1 为 true,0 == 'a' 为 true('a' 转成 0)。
  • 0e 开头后面全数字的字符串(如 '0e12345')会被当成科学计数法:0 × 10^12345 = 0。所以任意两个这种字符串 == 都相等——这就是 Magic Hash 的原理。
  • PHP 8.0 起:数字与非数字字符串比较时,改成把数字转成字符串做字符串比较,0 == 'a' 变为 false。但 magic hash 的 0e 比较在 PHP 8 下依然成立(两边都是数字格式字符串,按数字比,都是 0)。

原理与缺陷速查表

缺陷原理经典案例
== 松散比较'1abc' == 1(PHP<8 字符串转数字取前缀)为 true;0 == 'a'(PHP<8)为 true传 ?id=1abc 绕过 if ($id == 1) 型校验
md5 相等(Magic Hash)md5 结果形如 0e\d+(0e 开头纯数字)时,两串都被当成科学计数法数字 0,== 判相等md5($_GET['a']) == md5($_GET['b']) 用 QNKCDZO(md5=0e830400...)与 240610708(md5=0e462097...)绕过;「自身比较」型(md5($pass) == $pass 这类)用 ffifdyop,其 md5 的原始二进制(md5($x, true))里含 'or'6,若被拼进 SQL 可形成永真注入
strcmp() 数组PHP<8 中 strcmp(数组, 字符串) 无法处理,返回 NULL 并抛一条警告;而 NULL == 0 为 trueif (strcmp($_POST['pass'], $pass) == 0) 传 pass[]=x 绕过登录(PHP 8 起抛 TypeError 不可利用)
is_numeric()接受科学计数法(1e5)、正负号、前导空白;PHP<7 还接受 0x 十六进制传 1e5 或 %0a1 绕过「是否为数字」的校验后进入 SQL
in_array() 第三参数缺省 $strict=false 时用松散比较,in_array('1abc', [1,2,3]) 为 true(PHP<8 行为)白名单校验传 1abc 绕过
switch 松散比较case 匹配用的是 == 语义同上,case 值可被 '1abc' 型输入匹配
md5($a) === md5($b) 强比较=== 连类型都比,magic hash 失效;改用数组:a[]=1&b[]=2,md5(数组) 处理不了返回 NULL,NULL === NULL 为 true强哈希比较绕过的标准答案

案例带读

案例 1:双绕过考题(不同明文 + md5 相等)

CTF 入门必考题,也是理解 magic hash 的最佳素材:

<?php
if ($_GET['a'] != $_GET['b'] && md5($_GET['a']) == md5($_GET['b'])) { echo $flag; }

先分析题目在问什么:两个条件用 && 连着,都要满足——

  1. $_GET['a'] != $_GET['b']:a 和 b 的明文必须不同;
  2. md5(...) == md5(...):但它们的 md5 又要(松散)相等。

看似矛盾:md5 是哈希,不同输入就该不同输出。突破口在第 2 条用的是 == 而不是 ===——只要找到两个明文,它们的 md5 都是 0e 开头纯数字,松散比较下就都是科学计数法的 0,判为相等。

答案:?a=QNKCDZO&b=240610708

payload 逐段拆解:

  • a=QNKCDZO:一个特殊字符串,md5('QNKCDZO') = 0e830400451993494058024219903391。注意开头 0e 且后面全是数字。
  • &b=240610708:另一个特殊字符串,md5('240610708') = 0e462097431906509019562988736854。同样 0e + 纯数字。
  • 比较时:'0e830...' == '0e462...' → 两边都被识别为科学计数法 → 0×10^830... 对 0×10^462... → 0 == 0 → true。
  • 同时 QNKCDZO != 240610708 明文不同,第 1 条也满足。flag 到手。

这类字符串不用自己算,背几个现成的(或存进字典):QNKCDZO、240610708、s878926199a、s155964671a。

变体:若比较改成 === 强比较,magic hash 全灭(字符串 !== 数字语义下指纹不相等)。标准解法改用数组:?a[]=1&b[]=2——

  • a[]=1 中的 [] 是 PHP 的表单约定:参数名带方括号,PHP 就把它解析成数组,$_GET['a'] = [1]。
  • md5(数组) 类型不对,函数返回 NULL(并抛警告)。
  • 于是比较变成 NULL === NULL → true;同时 [1] != [2](数组内容不同)也成立。两个条件再次同时满足。

案例 2:strcmp 数组绕过登录

<?php
// login.php —— 漏洞代码(PHP<8)
$pass = getPasswordFromDb($_POST['user']);      // ① 按用户名从数据库取出正确密码(假设没注入)
if (strcmp($_POST['pass'], $pass) == 0) {       // ② ❌ strcmp 比较提交密码和正确密码,相等返回 0
    $_SESSION['uid'] = $uid;                    // ③ Sink:写 Session = 登录成功
}

先补背景:strcmp($a, $b) 是字符串比较函数,相等返回 0,不等返回非 0。所以「相等」的判断写法是 == 0。注意这里又是个 ==——给后面埋了雷。

数据流走一遍:

① 用户输入从哪进来:攻击者提交登录表单,但把密码字段构造成 pass[]=x——字段名带方括号,PHP 解析后 $_POST['pass'] 不是字符串,而是数组 ['x']。

② 经过什么处理:strcmp(['x'], '正确密码')——第一个参数是数组,strcmp 处理不了,PHP<8 下返回 NULL 并抛一条警告(警告不影响执行)。

③ 到达哪个判断:if (NULL == 0)——NULL 松散比较数字时转为 0,0 == 0 为 true。

④ 为什么防线失效:防线的设计是「密码相等才放行」,实现却用了两个脆弱点的组合——strcmp 不校验参数类型 + == 松散比较把 NULL 当 0。攻击者不需要知道正确密码,只需要让比较函数「出故障」,故障的返回值恰好被判为「相等」。

审计结论:strcmp 数组注入绕过认证,PHP<8 可利用;PHP 8.0 起 strcmp 遇数组直接抛 TypeError 终止执行,不可利用。但审计老 CMS(PHP 5/7 时代的存量项目)时必查。

修复:strcmp((string)$_POST['pass'], $pass) === 0(强转类型 + 严格比较);更彻底的做法用 hash_equals($pass, $_POST['pass'])——恒定时间比较,同时防类型杂耍和时序侧信道。

案例 3:in_array 白名单失效

<?php
// ❌ 危险:缺第三参数,松散比较
$allow = [1, 2, 3];                                  // ① 白名单:只允许访问 1/2/3 号页面
if (in_array($_GET['page_id'], $allow)) {            // ② 检查参数是否在白名单里 —— 但没传第三参数!
    include "page{$_GET['page_id']}.php";            // ③ Sink:包含对应页面文件
}

分析:in_array 的完整签名是 in_array($needle, $haystack, $strict = false)——第三个参数默认 false,意思是「用 == 松散比较」。攻击者传 ?page_id=1abc:'1abc' 依次和 1, 2, 3 松散比较,'1abc' 转数字为 1,命中白名单(PHP<8 行为)。校验通过,include "page1abc.php"——page_id 原样拼进了文件路径,白名单形同虚设。

为什么开发者会犯:in_array 查白名单看起来天经地义,第三参数的存在感极低——不看文档根本不知道还有严格模式这回事。

修复:in_array($x, $allow, true)——第三参数强制「类型 + 值」双校验,'1abc' 严格不等于 1,白名单语义才真正成立。array_search 同理也有第三参数。

防御修复(统一收口)

  1. 所有比较用 === / !==:禁止 == 出现在鉴权、token 校验、哈希比较、白名单判断中。这一条能解决本章 80% 的问题。
  2. 哈希/口令比较用 hash_equals():恒定时间,同时防类型杂耍和时序侧信道。
  3. 白名单校验必带第三参数:in_array($x, $allow, true);array_search 同理。
  4. 先判类型再比较:外部输入进比较前用 is_string()/is_int() 卡死类型,是数组直接拒绝——这一条专杀 strcmp/md5 数组类绕过。
  5. 数字校验用白名单正则(ctype_digit() 或 /^\d+$/)替代 is_numeric(),拒绝科学计数法与十六进制变体。
  6. 审计动作:全文搜 ==、strcmp、in_array、is_numeric、switch,命中后判断两侧类型是否均可控——两侧都可控就是可疑点,逐个分析转型规则。

面试题眼速答

题眼一句话标准答案展开两三句
越权漏洞在代码层怎么找?三问逐接口核对:是否认证、是否校验角色、是否校验对象归属。认证看拦截器/注解/装饰器/include 鉴权文件有没有漏配;角色看同 Controller 是否有方法漏加权限注解、前端隐藏但后端未鉴权;归属看 SQL 是否带当前登录人维度。另查两个经典写法:PHP 鉴权后只 header 跳转不 die、角色取自请求参数。
水平越权和垂直越权区别?水平是同级互操作数据,垂直是低权限调高权限功能。水平的典型是 IDOR:改 orderId 看别人订单,修复是 SQL 强制带归属条件(查不到返回 404 而非 403)。垂直的典型是普通用户直放管理员请求包,修复是服务端强制角色校验,不能只靠前端藏菜单。
IDOR 参数有哪些?URL/Body/JSON 里的 id、userId、orderId、fileId、docId 等。凡是后端没用「当前登录人」维度校验的对象 id 参数,全部逐个手工遍历测试。黑盒验证用 A、B 两个同级账号互访对象;查询结果不存在时返回 404 而非 403,避免暴露 id 是否存在。
变量覆盖函数?extract()、parse_str()、$$ 可变变量、import_request_variables、mb_parse_str。extract 默认 EXTR_OVERWRITE 覆盖同名变量;parse_str 在 PHP<8 可不传第二参数直接注册变量;foreach($_GET as $k=>$v) $$k=$v 是任意变量写。修复:禁止对外部数组 extract,配置/鉴权变量显式赋值且之后不再调用注册函数。
extract 案例怎么讲?extract($_GET) 默认覆盖 $auth 等鉴权变量,进入后台分支。讲的时候先点明执行顺序:config.php 先设 $auth=false,extract 后执行把它覆盖成 '1'。再补一句深度:利用前先看覆盖目标的比较是 == 还是 ===——强比较下字符串无法等于 true,要换目标变量或改走文件包含路线。
== 松散比较怎么绕过?利用字符串转数字规则:'1abc'==1、0=='a'(均 PHP<8)。核心规则是字符串转数字取开头数字前缀,无数字开头则转 0。数组场景下 []==false 也成立。PHP 8 收紧了数字与非数字字符串的比较,但 0e magic hash 比较在 PHP 8 下依然成立。
md5 相等绕过?0e 开头 magic hash 在 == 下都当数字 0;=== 时传数组使 md5 返回 NULL。== 用 QNKCDZO / 240610708(md5 均为 0e 开头纯数字串,按科学计数法都等于 0);=== 强比较传 a[]=1&b[]=2,md5(数组) 返回 NULL,NULL===NULL 为真。另记 ffifdyop:其 md5 原始二进制含 'or'6,用于 md5 被拼进 SQL 的「自身比较」型注入。
strcmp 怎么绕过?PHP<8 传数组,strcmp(数组,字符串) 返回 NULL,NULL==0 为真。把密码字段构造成 pass[]=x 即可,无需知道正确密码——思路是让比较函数「出故障」,故障返回值恰好被判为相等。PHP 8 起抛 TypeError 不可利用,但老 CMS 必查。修复用 (string) 强转 + === 0,或直接用 hash_equals()。
in_array 缺陷?第三参数 $strict 未设 true 时用松散比较,白名单可被绕过。如 in_array('1abc', [1,2,3]) 在 PHP<8 下为 true,'1abc' 转数字命中 1。修复必须显式传 in_array($x, $allow, true);array_search 有同样的问题和同样的修法。
弱类型比较如何统一防御?比较一律 ===,敏感比较用 hash_equals()。加上三条:白名单 in_array 带第三参数 true;外部输入进比较前先 is_string()/is_int() 卡类型、拒绝数组;数字校验用 ctype_digit() 或 /^\d+$/ 正则替代 is_numeric()。审计时全文搜 ==、strcmp、in_array、is_numeric、switch 逐个分析。
Java/Python 为什么没有弱类型绕过?Java 编译期就卡死类型不匹配的 ==,Python 不做隐式转型。Java 是强类型编译语言,String == int 编译直接报错,根本没有运行期转型的机会(它的坑是 String 用 == 比引用而非 equals(),属编码错误);Python '1' == 1 恒为 False。松散比较是 PHP「方便优先」设计哲学的历史产物,属 PHP 独有形态。