越权与逻辑漏洞审计
前面几篇学的漏洞(SQL 注入、命令执行、文件包含)都有一个共同点:代码里存在一个「危险函数」(Sink),用户输入一路流进去就出事。审计方法也很机械——搜危险函数,往回倒推输入来源。
但业务逻辑漏洞不吃这一套。它没有统一的危险函数可搜:selectById() 是普通得不能再普通的数据库查询,DELETE FROM users 也是正常业务语句——代码每一行都「合法」,组合起来却能让 A 用户删掉 B 用户的账号。这类漏洞是「敏感函数回溯法」的盲区,只能靠逐接口问三个问题来挖。
好消息是:正因为自动化工具扫不出来,逻辑漏洞是 SRC 众测里出洞率最高的一类,性价比极高。
本篇按三条主线组织,难度递增:
- 越权(水平/垂直)——跨语言通用,Java/PHP/Python 都有,是最重要的一章;
- 变量覆盖——PHP 独有的历史漏洞形态;
- 弱类型与比较缺陷——PHP 为主的富矿,CTF 和面试高频。
先修知识
读这篇之前,先把下面几个词搞明白,后面不会再停下来解释。
| 术语 | 白话解释 |
|---|---|
| Source(输入源) | 攻击者能控制的数据入口,比如 URL 里的 ?id=123、表单提交的内容、HTTP 头、Cookie。凡是「用户能改」的东西都是 Source。 |
| Sink(危险落点) | 输入最终到达的「出事地点」,比如数据库查询、文件读写、system() 命令执行。本篇的特点就是:逻辑漏洞没有固定 Sink,正常函数也能当 Sink。 |
| 数据流 | 数据从 Source 到 Sink 走的路径。审计就是沿着这条路检查「中间有没有人把门」。 |
| Session(会话) | 服务器给每个登录用户发的「身份档案」,存在服务端。你登录后,服务器记住「这个连接是 3 号用户」,靠的就是 Session。 |
| Cookie | 服务器写在你浏览器里的小卡片,每次请求自动带上。Session 的钥匙(session id)通常就放在 Cookie 里。 |
| GET / POST | HTTP 请求的两种方式。GET 把参数放在 URL 里(?id=1),POST 把参数放在请求体里(表单提交)。对攻击者来说没有本质区别,都能改。 |
| 拦截器 / 过滤器 / 装饰器 | 同一个东西的三种语言叫法:请求到达业务代码之前先经过的一道「安检门」,常用来做登录校验。Java 叫拦截器/注解,PHP 老式项目叫「入口 include 的校验文件」,Python 叫装饰器。 |
| 水平越权 / 垂直越权 | 水平 = 平级之间互相串门(你看我的订单);垂直 = 下克上(普通用户用管理员功能)。下文详讲。 |
== 与 ===(PHP) | == 是「松散比较」:两边类型不同会先偷偷转型再比;=== 是「严格比较」:类型不同直接判不等。第三章整章都在讲这个区别怎么被利用。 |
| md5 / 哈希 | 把任意字符串算成一个固定长度的「指纹」。理论上不同输入指纹不同,但 PHP 的松散比较会让某些特殊指纹「被当成相等」。 |
| IDOR | Insecure Direct Object Reference,不安全直接对象引用——水平越权的一种典型形态:改个 id 就能看别人的东西。 |
第一章 越权漏洞审计
通用原理(跨语言)
是什么
越权 = 服务端对「当前身份能不能操作这个对象 / 执行这个功能」的校验缺失或写错了。
生活类比:酒店房卡系统。
- 身份认证 = 前台确认你是住客,给你一张房卡(你登录了)。
- 功能权限校验 = 保洁员的卡能开所有房门做清洁,普通住客的卡不行(你能不能干这事)。
- 对象归属校验 = 你的卡只能开 305,开不了隔壁 306(这东西是不是你的)。
三个环节任何一环没做好,就是越权。分两类:
| 类型 | 定义 | 酒店类比 | 典型场景 |
|---|---|---|---|
| 水平越权(IDOR 属于此类) | 同级别用户互操作:A 看/改 B 的数据 | 你的卡能开 306——你和隔壁都是普通住客 | 改 userId、orderId 看他人订单、资料、私信 |
| 垂直越权 | 低权限用户执行高权限功能 | 你的卡能开总控室 | 普通用户调后台接口、未登录访问管理页 |
为什么会出现这个漏洞
不是开发者傻,是三个非常自然的心理:
- 「前端藏起来就安全了」。管理员菜单在前端 JS 里做了
display:none,普通用户看不到按钮——开发者就觉得没事了。但接口还在那儿,攻击者直接发包就行,根本不经过页面。这是垂直越权的第一大成因。 - 「查了 id 就是查了」。开发者写
selectById(orderId)时想的是「用户要看自己的订单」,顺手就把 URL 里的 id 拿去查了,压根没想起来还要验证「这个订单是不是他的」。功能测试全通过(因为测试时用的都是自己的数据),漏洞就带上线了。 - 「登录了就是自己人」。很多项目的鉴权只做到「有没有登录」这一层,登录之后所有接口随便调。登录 ≠ 有权限,这是两个检查。
审计时怎么找
代码层的成因归结为一个公式:
鉴权 = 身份认证(你是谁)+ 功能权限校验(你能不能干这事)+ 对象归属校验(这东西是不是你的)
越权 = 三者任一缺失。
审计动作就是把全量路由列出来,逐接口问这三个问题。具体操作:
- 先找到「门」在哪里:Java 看注解/拦截器配置,PHP 看文件头 include 了什么,Python 看装饰器。
- 找到门之后,看哪些接口没经过门(漏配路径、漏加注解、漏 include)。
- 对所有带 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,能拿到数据就是实锤。
防御修复
- 垂直越权:在拦截器/网关统一鉴权兜底(deny by default——默认全部拒绝,明确放行的才放行),敏感注解加到方法级并 Code Review 查漏。
- 水平越权:所有按 id 取数的 SQL 强制带
user_id = #{当前登录人}条件;查询不到时返回 404 而非 403——403 等于告诉攻击者「这个 id 存在但不是你的」,反而帮他确认目标存在。 - 对象引用间接化:对外暴露不可遍历的随机 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 的风险被收敛到一个文件里。
防御修复
- 鉴权公共文件统一
require(不是include——include失败只是警告,require失败直接致命错误终止脚本,更适合做「门」),校验失败必须die。 - 水平越权:所有取数 SQL 拼接
$_SESSION中的身份维度,禁止信任请求中的 uid/role 字段。 - 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)。
越权审计清单(落地动作)
拿到一个项目,按这个顺序做:
- 列全量路由表,逐个标注三列:是否需登录、是否需角色、是否操作具体对象。这张表是后面所有工作的基础。
- 垂直越权排查:拦截器/注解/装饰器漏配;前端隐藏但后端未鉴权;同 Controller 部分方法漏注解;鉴权后不 die(PHP 专项)。
- 水平越权排查:URL/Body/JSON 中的
id、userId、orderId、fileId、docId等参数,凡后端没用「当前登录人」维度校验的,全部逐个手工遍历测试。 - 黑盒验证:注册 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 规则 |
被覆盖的高价值目标(审计时优先想这三个):
- 鉴权标志:
$auth、$is_admin、$login——覆盖了直接进后台; - 配置变量:文件路径(覆盖后包含任意文件)、数据库名、表名前缀;
- 过滤白名单数组:覆盖了等于过滤规则形同虚设。
案例带读(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;(双美元符,最直白的任意变量写)。
审计时怎么找:
- 全文搜
extract(、parse_str(、$$、import_request_variables、mb_parse_str; - 命中后回答两个问题:传进去的数组是不是外部输入?当前作用域里有没有已定义的重要变量(鉴权标志、路径、配置)?
- 两个答案都是「是」,漏洞成立。
防御修复
- ❌ 禁止对外部数组直接
extract($_GET/$_POST/$_REQUEST);业务确需时至少extract($arr, EXTR_SKIP)(遇同名跳过不覆盖)或加前缀EXTR_PREFIX_ALL。 - ✅ 鉴权/配置变量显式赋值(
$auth = false;)且放在函数内部作用域,赋值语句之后不再调用任何变量注册函数。 - ✅
parse_str($str, $result)永远传第二参数,只用返回的数组,不让它注册变量。 - ✅ 杜绝用
$$处理外部输入;需要动态键就用数组$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 为 true | if (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; }先分析题目在问什么:两个条件用 && 连着,都要满足——
$_GET['a'] != $_GET['b']:a 和 b 的明文必须不同;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 同理也有第三参数。
防御修复(统一收口)
- 所有比较用
===/!==:禁止==出现在鉴权、token 校验、哈希比较、白名单判断中。这一条能解决本章 80% 的问题。 - 哈希/口令比较用
hash_equals():恒定时间,同时防类型杂耍和时序侧信道。 - 白名单校验必带第三参数:
in_array($x, $allow, true);array_search同理。 - 先判类型再比较:外部输入进比较前用
is_string()/is_int()卡死类型,是数组直接拒绝——这一条专杀 strcmp/md5 数组类绕过。 - 数字校验用白名单正则(
ctype_digit()或/^\d+$/)替代is_numeric(),拒绝科学计数法与十六进制变体。 - 审计动作:全文搜
==、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 独有形态。 |