漏洞思维补完:从客户端声明到可信业务事实——一次 HTTP 请求到底是怎样被服务器“理解”的
漏洞思维补完:从客户端声明到可信业务事实——一次 HTTP 请求到底是怎样被服务器“理解”的
前言:我以前把“前端不可信”理解得太简单了
前面写《漏洞思维》的时候,我已经慢慢接受了一个观点:
参数只是业务变量在网络请求里的技术表现形式。
比如:
user_id
order_id
status
price
quantity
它们真正重要的地方,从来不是:
这是一个参数
而是:
它在业务里代表什么?
所以以前我看到:
id=888
会慢慢从:
“这是一个可以修改的数字”
转变成:
“这是一个业务对象定位符”
看到:
status=submitted
也不会只理解成:
“这是一个字符串”
而会继续想:
“它可能在表达一个业务对象当前所处的阶段”
但直到最近真正开始分析靶场数据包,我才发现:
仅仅知道“参数背后是业务变量”还不够。
因为一个请求包里面,并不是所有字段都在做同一件事情。
它可能同时混合着:
身份凭证
用户意图
目标对象
客户端声明
业务事实
状态
关系
范围
派生结果
而服务器也不会用同一种方式处理它们。
于是我开始重新理解:
一条 HTTP 请求,本质上不是客户端把一堆“事实”交给服务器,而是客户端向服务器提交了一组身份凭证、目标选择、行为意图和事实声明。服务器真正要做的,是把这些声明重新验证、解析、计算,最后建立一个自己可以信任的业务世界。
这篇文章,就是把这件事情彻底想清楚。
一、为什么前端明明不可信,还要做校验
以前学安全时,很容易听到一句:
不要相信前端
于是我很自然会产生一个问题:
既然前端不可信,那为什么开发还要在前端做各种校验?为什么不全部交给后端?
后来我发现,这其实混淆了两个完全不同的目标。
例如用户注册时:
密码至少 8 位
前端可以在用户只输入 3 位的时候直接提示:
密码长度不足
这样做的价值是:
用户体验更好
减少无意义请求
减少服务器压力
更快发现输入错误
但攻击者完全可以绕开页面,直接构造:
POST /register
password=123
所以后端仍然必须重新检查:
密码长度是否合法?
于是:
前端校验
=
帮助正常用户遵守规则
后端校验
=
真正维持安全和业务规则
所以真正准确的说法不是:
前端没有意义
而是:
前端可以提前帮助用户遵守规则,但不能成为“用户已经遵守规则”的最终证明。
二、为什么“客户端不可信,服务器可信”也不够准确
继续往下以后,我发现:
客户端不可信
服务器可信
这种说法虽然适合入门,但仍然太粗。
因为真正的“信任”不是:
某个机器整体可信不可信
而是:
对于某一个具体事实,这个来源有没有资格证明它?
例如用户提交:
{
"quantity": 2
}
客户端是在表达:
“我想买 2 件”
这件事情本来就应该由用户决定。
所以:
quantity=2
作为:
用户意图
客户端就是合理来源。
但如果客户端提交:
{
"price": 1
}
并且服务器把它理解成:
“商品真实价格就是 1”
问题就完全不同。
因为:
用户有资格决定
“我想买几件”
但用户没有资格决定
“商品真实价格是多少”
所以客户端不是:
什么都不能相信
而是:
对于“我想做什么”
客户端可以是合法来源
对于“世界真实是什么样”
客户端通常不能成为最终权威来源
这让我重新理解了“信任边界”:
信任不是组件属性,而是“某个来源对于某个具体事实拥有多大的证明能力”。
三、Token / Session 也不是“整包可信”的印章
另一个以前很容易混淆的问题是:
用户已经登录了,为什么后续访问订单、文件、项目的时候还需要 Session 或 Token?
原因很简单。
登录成功只能证明:
在登录这个时刻
服务器确认:
这个人是用户 A
但下一条:
GET /orders/888
是一个新的 HTTP 请求。
如果什么身份信息都不带,服务器只知道:
“有人请求订单 888”
却不知道:
这个“有人”是谁
所以 Session / Token 的作用是:
登录成功
↓
建立身份
Session / Token
↓
让后续请求重新绑定到这个身份
例如:
session=ABC123
↓
服务器验证
↓
CurrentUser = A
但这里还有一个非常关键的区别:
Session / Token
只能回答:
“谁在说这些话?”
它不能回答:
“他说的所有内容都是真的。”
例如用户 A 带着完全合法的 Session 提交:
{
"price": 1,
"role": "admin",
"balance": 999999
}
Session 最多证明:
这些声明确实来自用户 A
但它不能证明:
A 真的是管理员
A 的余额真的有 999999
商品真实价格真的为 1
所以我现在特别喜欢一句:
Authenticated Claim ≠ True Claim
也就是:
“我知道是谁说的”,不等于“他说的就是事实”。
四、一条请求里的字段,其实不是同一种东西
假设客户端创建订单:
POST /api/order/create
Cookie: session=ABC123
{
"product_id": 1001,
"quantity": 2,
"price": 1,
"address_id": 888,
"coupon_id": 20,
"total_price": 2,
"remark": "下午送"
}
以前我可能会看成:
7 个参数
现在我会重新分类:
session
→ 身份凭证 Credential
product_id
→ 目标选择 Target
quantity
→ 用户意图 Intent
price
→ 业务事实 Business Fact
address_id
→ 目标对象 + 对象关系
coupon_id
→ 目标对象 + 状态 + 关系
total_price
→ 派生事实 Derived Fact
remark
→ 用户内容
这里最重要的是:
同一个请求里的不同字段,服务器必须用不同的方式处理。
不能统一理解成:
“客户端传来的值,检查一下对不对”
五、服务器真正做的,不是“逐字段对答案”
这里也是我这次对话里想通的一个关键点。
以前我会想象:
客户端:
price=1
服务器:
数据库里是99
1 != 99
→ 拒绝
这种理解不算错,但还是太机械。
更准确地说:
服务器不是为了证明客户端说的话“对不对”,而是根据客户端表达的意图,重新建立一套可信的业务事实。
例如用户提交:
{
"product_id": 1001,
"quantity": 2,
"price": 1
}
一个正常后端真正应该理解成:
用户 A
想购买
商品 1001
数量 2
然后服务器自己开始重建真实世界:
商品1001存在吗?
↓
查商品数据库
商品现在可售吗?
↓
查商品状态
真实价格是多少?
↓
查定价系统 / 数据库
库存是多少?
↓
查库存
用户A能购买吗?
↓
查资格 / 权限 / 限购规则
买2件合法吗?
↓
检查数量、库存、限购
最终多少钱?
↓
服务端重新计算
于是最终形成:
CurrentUser = A
Product = 1001
Quantity = 2
RealPrice = 99
Stock = 10
Total = 198
这时候,这些才是:
服务器认可的业务事实
六、为什么 quantity=2 可以由用户决定,而 price=1 不行
这个问题一开始让我很困惑。
既然:
客户端不可信
那为什么:
quantity=2
服务器可以接受?
答案是:
“客户端可以提供”不等于“客户端说什么服务器都相信”。
quantity 的业务含义是:
用户想买几件
这本来就是用户意图。
所以客户端有:
表达权
但是服务器仍然要做:
quantity > 0?
库存够不够?
有没有超过限购?
所以:
quantity=2
↓
客户端表达意图
↓
服务器验证意图是否合法
↓
合法以后采用
而 price 的业务含义通常是:
商品真实价格
这不是用户意图,而是业务事实。
所以:
price=1
即使客户端传了,服务器也不应该因此认为:
真实价格 = 1
它应该重新:
product_id=1001
↓
查询定价系统
↓
RealPrice=99
甚至完全忽略客户端提供的 price。
七、客户端其实拥有不同程度的“权力”
想到这里,我觉得可以进一步把一个字段拆成三个问题:
表达权
证明权
裁决权
表达权
谁可以提出这个值?
例如:
quantity=2
用户当然可以说:
“我要买2件”
证明权
谁有资格证明这个事实是真的?
例如:
price=99
真正能够证明它的可能是:
商品数据库
定价系统
促销规则
而不是买家。
裁决权
最后谁决定这个动作能不能发生?
例如:
用户想买2件
最终仍然由服务器判断:
库存够不够?
限购是否允许?
当前状态是否允许购买?
所以服务器保留的是:
最终裁决权
这比简单说:
客户端不可信
要精确得多。
八、一个正常后端大概会怎样处理请求
现在我会把正常服务器处理请求理解成这样:
Client Request
↓
原始声明
HTTP / Schema Parsing
↓
请求能不能正常解析?
Authentication
↓
这是谁?
Target Resolution
↓
你想操作的对象到底是什么?
Authorization
↓
你有没有资格操作这个对象?
Authoritative Lookup
↓
关键业务事实真实是什么?
Business Validation
↓
你的意图是否满足业务规则?
State Validation
↓
当前阶段还允许做这件事吗?
Server-side Calculation
↓
价格、金额、折扣、结果重新计算
Trusted Business State
↓
建立服务器认可的业务事实
Execute / Persist
↓
真正执行业务
实际系统不一定严格按照这个代码顺序实现。
有的逻辑可能在:
Gateway
Controller
Middleware
Service
Repository
不同位置。
但对于安全研究来说,比代码顺序更重要的问题是:
为了正确允许这次业务,服务器最终必须确认哪些事实?
九、目标字段和权限之间是什么关系
还有一类字段特别容易产生误解:
order_id
user_id
address_id
file_id
project_id
这些通常不是:
“事实”
而是在表达:
“我想操作哪个对象”
也就是:
Target
例如:
address_id=888
客户端有资格说:
“我想使用地址888”
但这不能证明:
“地址888属于我”
所以服务器还要:
Session
↓
CurrentUser = A
address_id=888
↓
TargetAddress = 888
查询对象关系
↓
Address888.owner = B
A != B
↓
Deny
所以:
客户端能够选择目标,不等于客户端能够决定自己有权访问目标。
这其实和前面订单 user_id 靶场完全一致。
十、我现在怎么看一个数据包
以前:
看到 id
→ 改
看到 status
→ 改
看到 price
→ 改
看到 user_id
→ 改
现在我希望自己先做翻译。
第一步:这是什么业务动作?
看:
Method
Path
Referer
页面上下文
先回答:
用户到底在做什么?
第二步:谁在做?
看:
Cookie
Session
Authorization
JWT
API Key
问:
服务器靠什么确定当前主体?
第三步:在操作谁 / 什么对象?
看:
id
order_id
user_id
file_id
project_id
tenant_id
问:
这些字段到底定位了哪个业务对象?
然后继续:
当前主体和这个对象是什么关系?
第四步:客户端在表达什么意图?
例如:
quantity
keyword
remark
sort
问:
这个意图允许吗?
有什么业务限制?
第五步:客户端有没有在宣布关键业务事实?
例如:
price
role
owner
balance
paid
status
tenant
问:
谁真正有资格证明这个事实?
服务器有没有重新确认?
第六步:有没有状态、关系和前置条件?
即使请求里没有:
status
服务器也可能必须知道:
订单是否已经付款
申请是否已经提交
验证码是否已经使用
所以还要问:
当前状态允许这个动作吗?
前面的必要步骤真的发生了吗?
第七步:有没有应该由服务器计算的结果
例如:
total_price
discount
tax
final_amount
问:
为什么这个值由客户端提供?
服务器有没有重新计算?
十一、参数只是业务变量的表现形式,还可以继续往下拆
现在回头看前面的那句话:
参数只是业务变量在网络请求里的表现形式。
我觉得还可以继续升级。
应该变成:
HTTP 参数
↓
对应业务变量
业务变量
↓
承担不同角色:
身份
目标
意图
关系
状态
业务事实
范围
派生结果
角色不同
↓
正确处理方式不同
↓
错误方式也不同
↓
产生不同漏洞
例如同样一个:
user_id
在不同接口里可能完全不同。
一种情况下:
user_id
→ Target User
那么我要重点考虑:
Authorization
另一种情况下:
user_id
→ Current Identity
如果服务器直接相信客户端声明:
CurrentUser = user_id
那重点就变成:
Authentication / Trust
所以:
不能靠参数名字判断漏洞,要靠参数在当前业务决策中承担的角色判断。
十二、很多漏洞为什么会出现
经过今天这些讨论,我觉得“服务器过于相信客户端”这句话是对的,但还不够完整。
因为有些漏洞确实是:
客户端声明
↓
直接被当成
服务端事实
例如:
price=1
role=admin
owner_id=A
但也有很多漏洞是:
服务器确实做了校验
↓
但检查条件不完整
例如:
已经登录
✓
但订单是不是他的
没有检查
又或者:
对象属于当前用户
✓
但当前状态是否允许修改
没有检查
再或者并发场景:
两个请求看到的事实都是真的
↓
但同时消费同一个即将失效的旧事实
↓
业务不变量仍然被破坏
所以更完整的理解应该是:
漏洞往往发生在服务器没有完整维护一次业务决策所依赖的身份、对象、关系、状态、事实、范围和时间约束。
“信错客户端”只是其中很重要的一类。
十三、我现在对前端和后端的理解
今天讨论完以后,我觉得可以用一句非常简单的话区分:
客户端负责告诉服务器:
“我想做什么”
“我想操作谁”
服务器负责确认:
“你是谁”
“你有没有资格”
“世界真实是什么样”
“现在允不允许”
“最终结果应该是多少”
再进一步:
Session / Token
→ 你是谁?
客户端参数
→ 你想做什么 / 想操作谁?
Authorization
→ 你有没有资格?
Authoritative Data
→ 世界真实是什么样?
Business Rules
→ 当前条件能不能这么做?
Server-side Calculation
→ 最终结果应该是多少?
这样一来:
前端
后端
Session
Token
权限
信任
业务校验
参数篡改
这些以前分散的概念,终于开始连到了一起。
十四、最终形成的一套数据包分析思维
以后再看到一个数据包,我不希望自己第一时间问:
哪个参数能改?
而应该按下面的思维走:
这是什么业务动作?
↓
谁在做?
↓
身份靠什么证明?
↓
在操作什么对象?
↓
客户端在表达什么意图?
↓
客户端有没有声明关键业务事实?
↓
这些事实真正应该由谁证明?
↓
主体和对象之间是什么关系?
↓
当前状态允许吗?
↓
哪些结果应该由服务器重新计算?
↓
服务器有没有少做一步?
真正值得寻找的不是:
“可修改参数”
而是:
“客户端获得了它本来不应该拥有的证明权或决定权”
或者:
“服务器没有重新确认做出业务决策所需的关键事实”
结语:服务器不是在验证客户端,而是在重建可信世界
今天最大的收获,不是终于知道:
price为什么不能改
quantity为什么能改
Session有什么用
而是慢慢看见了它们背后的同一个模型。
客户端发来的请求只是:
一组声明
这些声明里有:
身份凭证
目标
意图
事实声明
状态声明
派生结果
服务器真正应该做的是:
验证身份
解析目标
检查权限
确认关系
读取权威事实
验证状态
执行规则
重新计算
最后建立:
Trusted Business State
然后才决定:
Allow
or
Deny
所以我现在更愿意这样理解一个安全的后端:
服务器不是“相信客户端,然后检查有没有明显错误”,而是把客户端当成提出请求的一方,再根据服务端掌握的身份、对象关系、业务规则、权威数据和状态,重新构造一个足够可信的业务世界。
而漏洞,往往就发生在:
这个重建过程
少了一步
信错了一步
漏了一条关系
漏了一个状态
或者把客户端声明
错误升级成了服务器事实
这也让我重新理解了最开始那句话:
参数只是业务变量在网络请求里的表现形式。
真正的漏洞思维,不是研究:
参数怎么改
而是研究:
这个业务变量到底代表什么,谁有资格决定它,服务器为了相信它,本来应该做什么,而它实际上有没有做到。

浙公网安备 33010602011771号