先看结论与判断条件
- VMP 保护的是客户端实现和执行路径,服务端授权决定特定用户在特定时刻能否对特定资源执行特定操作,两者问题不同。
- 隐藏按钮、修改本地布尔值、验证客户端缓存或只检查令牌是否存在,都不能构成高价值业务操作的最终授权。
- 服务端裁决要绑定用户、资源、操作、权益版本、请求新鲜度和重放状态,不能只相信客户端上报的“已验证”结论。
- 完整性信号、VMP 状态和设备信息是风险输入,不是绝对可信根;允许、拒绝或降级仍由后端结合业务上下文决定。
- 离线能力必须预先限定有效期、操作范围、数据范围与恢复策略,不能把无法联网变成无限期客户端授权。
- 授权边界应通过状态机、拒绝路径、重放用例、跨账号用例和服务端审计回执验证,而不是只看 App 未崩溃。
先区分代码保护和授权裁决回答的两个问题
VMP 关注“客户端核心实现被观察、理解或修改的成本”,服务端授权关注“某个主体能否在当前上下文执行某项操作”。前者作用在代码与运行路径,后者作用在身份、资源、权益和策略。即使客户端判断被严密保护,它仍运行在用户控制的设备上,无法独立掌握服务器账户状态、撤销记录、并发限制和最新风险策略。
高价值操作包括激活付费功能、下载商业资源、提交交易、导出敏感数据或调用计费接口。按钮是否可见只是体验状态,本地缓存是否为 true 只是输入,真正的业务动作应在服务端重新检查主体、对象、操作和上下文。若后端只接收客户端的 allow 结果,攻击面会集中到客户端状态和请求重放。
工程判断上,VMP 与服务端授权应形成两层责任,而不是互相替代。客户端保护专有算法、输入规范化和本地体验分支,服务器保存可撤销的权威权益并签发受限结果。本文没有具体应用协议与候选包,因此只给出边界设计和审计方法,不声称任何项目已经达到防护或授权目标。
| 问题 | VMP 主要作用 | 服务端授权主要作用 | 错误替代方式 |
|---|---|---|---|
| 代码实现 | 提高核心逻辑分析和修改成本 | 不负责隐藏客户端实现 | 把加固当成账号权限 |
| 用户身份 | 保护本地处理路径 | 验证会话、主体与绑定关系 | 只看本地登录标记 |
| 权益状态 | 保护展示和离线计算 | 查询最新购买、撤销和范围 | 信任缓存布尔值 |
| 请求重放 | 可保护请求构造代码 | 校验新鲜度、nonce 与幂等 | 认为代码难改就不会重放 |
| 最终操作 | 执行受保护客户端步骤 | 对资源和动作作最终裁决 | 客户端 allow 直接生效 |
从主体、资源、操作、环境和证据建立授权请求
服务端授权请求至少要明确主体、资源、操作、环境和证据。主体是已验证用户或服务身份,资源是订单、文件、功能或模型,操作是读取、执行、导出或修改,环境包含时间、会话与风险上下文,证据包含服务端签发令牌、购买记录、请求新鲜度和可选的完整性信号。缺少任一关键维度都会让策略过于宽泛。
客户端可以收集界面意图、规范化参数并附带当前令牌,但不能自行声明权益仍有效、nonce 从未使用或账号没有被撤销。服务器应从可信存储读取权威状态,检查令牌的受众、作用域、期限和主体绑定,再把决定限制到具体资源和操作。返回结果也应避免成为可长期复用的万能通行证。
授权拒绝必须是正常业务结果。过期会话需要重新认证,权益不足需要明确升级入口,请求重复应返回幂等结果或拒绝,风险信号不足可以要求额外验证。把所有拒绝都压成网络错误会迫使客户端采用危险的“失败时放行”,也让测试无法区分策略、身份与可用性问题。
| 维度 | 示例对象 | 权威来源 | 客户端责任 |
|---|---|---|---|
| 主体 | 用户、组织或设备会话 | 认证服务与会话存储 | 携带受限凭据 |
| 资源 | 订单、文件或付费能力 | 业务数据库 | 提交稳定资源标识 |
| 操作 | 读取、执行、导出或修改 | 服务端策略 | 表达用户意图 |
| 环境 | 时间、风险和请求上下文 | 服务端时钟与风控 | 提供必要上下文 |
| 证据 | 权益、nonce 和完整性结果 | 可信服务与后端校验 | 传递而不自行裁决 |
许可响应必须绑定用户、新鲜度和服务端记录
Google Play Licensing overview 说明许可判断涉及签名响应、客户端策略、缓存和离线可用性。这个模型揭示了一个关键边界:客户端可以执行缓存与体验策略,但许可事实来自外部服务,且响应需要验证。它只适用于相应分发场景,不能直接当作所有商业 App 的通用授权协议。
Client-side license verification 明确提醒客户端许可逻辑更容易被修改或移除,敏感判断应尽量移到服务端。VMP 可以增加修改这些代码的难度,却不会改变客户端由终端用户控制这一事实。若高价值资源已经完整下发,并且唯一门槛是本地分支,服务端无法撤销、限流或区分跨账号滥用。
Server-side license verification 强调服务端校验要关联用户、nonce、签名响应并防止重放。实际业务还要保存请求状态和消费结果,让同一凭据不能反复兑换不同资源。客户端只收到当前操作所需的最小结果,服务器保留撤销、审计和策略变更能力,才形成真正的最终裁决。
| 步骤 | 客户端可做 | 服务端必须做 | 主要失败 |
|---|---|---|---|
| 发起许可请求 | 附带用户和应用上下文 | 生成或登记新鲜度材料 | 主体或应用错配 |
| 接收签名响应 | 传递响应并限制缓存 | 验证签名与关联字段 | 伪造或旧响应 |
| 计算权益 | 展示受限体验状态 | 查询购买、撤销和范围 | 跨账号或过期权益 |
| 消费高价值操作 | 提交具体资源和动作 | 原子判断并记录结果 | 重放和重复消费 |
| 离线处理 | 按既定窗口有限降级 | 恢复后重新同步权威状态 | 无限期本地放行 |
令牌存在不等于令牌允许当前业务操作
RFC 9700 OAuth Security BCP 讨论重定向、授权码注入、令牌重放和不安全授权方式等部署风险。对移动 App 来说,拿到 access token 只是开始,资源服务器仍需验证发行者、受众、作用域、期限和主体,并把令牌权限映射到当前资源与动作。客户端显示“登录成功”不能代替这些检查。
令牌被截获后的价值取决于它能否被重放、权限是否过宽、有效期是否过长以及服务端是否要求额外绑定。VMP 可保护令牌使用代码和部分本地存储路径,但不能让服务器知道请求是否第一次出现。高价值动作需要服务端新鲜度、幂等键、交易状态或一次性挑战等机制,具体选择由业务协议决定。
授权码和令牌错误也必须分层处理。无效受众应拒绝,权限不足不应静默升级,过期凭据应走重新认证,重复业务请求要返回稳定结果。若客户端把任何失败改写成 allow,或服务端为“兼容”接受缺少关键声明的令牌,VMP 无法修复这个授权边界。
| 层次 | 检查对象 | 通过后仍需什么 | 典型错误 |
|---|---|---|---|
| 令牌格式 | 结构和签名 | 发行者与受众 | 只解析不验签 |
| 令牌语义 | 主体、期限和作用域 | 资源级业务策略 | 作用域过宽 |
| 请求新鲜度 | nonce、时间或幂等键 | 消费状态和重放记录 | 重复请求再次生效 |
| 权益状态 | 购买、撤销和组织关系 | 当前操作限制 | 缓存替代权威记录 |
| 业务执行 | 资源、动作和参数 | 原子提交与审计 | 检查和执行分离 |
完整性信号是风险证据,不是授权结果
Play Integrity overview 建议在接近受保护操作的时点请求完整性信号,并由后端解密、验证和决策。这样可以减少信号与业务请求脱节,但它仍只是后端决策的一个输入。服务器还要检查用户权益、资源范围、请求新鲜度和业务状态,不能因为设备信号满足某个等级就自动授予付费能力。
完整性响应不能证明 VMP 配置是否正确,也不能直接等同于 Root、Hook 或攻击已被阻断。平台可观察范围、设备生态、服务可用性和业务误伤都需要项目策略处理。工程上应为信号缺失、暂时失败和风险升高分别设计拒绝、降级或额外验证,而不是把所有情况压成单一布尔值。
VMP 与完整性信号可以互补。前者增加客户端核心逻辑分析与修改成本,后者给后端提供与应用或设备环境相关的风险材料,服务端将这些材料和账户、权益、nonce、资源状态一起裁决。每一层都要保留边界,才能在某一信号不可用时仍维持可解释的安全与业务行为。
| 信号状态 | 后端可选动作 | 仍需校验 | 不应声称 |
|---|---|---|---|
| 满足预期 | 继续执行业务策略 | 主体、权益和新鲜度 | 设备绝对可信 |
| 风险升高 | 拒绝或要求额外验证 | 资源价值和误伤成本 | 已确认具体攻击 |
| 暂时不可用 | 按业务等级降级或重试 | 账户与操作风险 | 客户端可无限放行 |
| 请求错配 | 拒绝当前操作 | 请求绑定和重放记录 | VMP 自动修复协议 |
| 历史信号 | 仅作趋势与风险参考 | 当前请求的新证据 | 旧结果长期有效 |
离线能力要有事先限定的最小权限
离线场景并不意味着客户端可以永久成为授权中心。业务应预先定义哪些能力必须在线,哪些能力可在短暂失联时继续,离线结果能访问哪些本地数据,以及恢复网络后如何同步和撤销。VMP 可以保护离线规则实现,但规则的期限和权限上限仍应来自可验证的服务端材料。
缓存内容应比在线权益更窄。缓存可包含主体绑定、功能集合、签发时间、到期条件和版本,但不应允许客户端自行延长或扩大。时钟回拨、应用数据恢复、跨账号切换和设备迁移都会影响本地判断。若业务无法可靠处理这些边界,高价值操作应保持在线裁决,而不是默认失败时放行。
恢复联网后要重新获取权威状态,并处理离线期间生成的操作。只读体验可以重新同步,计费、导出或消耗型动作需要幂等与冲突规则。本文只说明在线授权责任,不展开完整离线许可状态机;具体窗口、数据范围和冲突策略必须由项目证据与商业规则确认。
| 能力类型 | 建议权威方 | 离线处理 | 恢复要求 |
|---|---|---|---|
| 账户登录 | 认证服务 | 仅保留受限会话体验 | 重新验证主体 |
| 权益查询 | 业务服务端 | 使用有界缓存展示 | 获取最新撤销与范围 |
| 本地只读内容 | 服务端签发范围 | 按最小数据集开放 | 同步版本与权限 |
| 计费或消耗 | 服务端原子裁决 | 通常排队或拒绝 | 幂等提交与冲突处理 |
| 敏感导出 | 服务端逐次授权 | 不默认离线放行 | 重新获取操作许可 |
用拒绝路径和跨账号用例验证真正的授权边界
授权测试不能只覆盖合法用户成功操作。至少要验证无令牌、过期令牌、错误受众、作用域不足、权益撤销、资源不属于当前用户、nonce 重放、重复提交、账号切换、完整性信号缺失和服务端不可达。每个用例都要断言后端结果、客户端提示、状态变化和审计记录。
OWASP MASVS-RESILIENCE 将抗逆向与抗篡改放在纵深防御中。测试因此要分别证明客户端保护路径仍可用,以及服务端在绕过界面或伪造客户端 allow 时仍拒绝未授权操作。前者属于应用韧性与兼容,后者属于授权边界;任何一边通过都不能替代另一边。
设备测试和服务端测试需要绑定同一候选、协议版本和测试账户状态。报告应记录 APK 摘要、请求标识、服务端策略版本和明确结果,避免用一次 UI 冒烟代表后端拒绝能力。若关键拒绝路径没有真实回执,结论应标为阻塞或待补证,不用静态代码检查替代。
- 绕过界面直接调用服务端仍会重新授权
- 跨账号资源访问被服务端稳定拒绝
- 过期、错受众和权限不足令牌分别处理
- nonce 或幂等键重放不会重复产生价值
- 完整性信号异常不会变成客户端自行放行
- 候选摘要、账户状态和策略版本进入回执
用状态机审计脚本找出客户端单独放行的操作
下面的 Python 示例读取一份公开安全的授权状态机 JSON。状态包含 id、decisionOwner、grants、requires 和 terminal,迁移包含 from、to、event 和 evidenceSource。脚本从 initialState 计算可达状态,找出会授予高价值操作却仍由 client 单独决定、缺少 server-entitlement 或没有新鲜度材料的状态。
检查器不会调用真实接口、处理令牌或修改状态机。它要求每个 grant 状态列出主体、服务端权益和 freshness 证据,并要求到达该状态的迁移存在 server evidenceSource;否则以非零退出。输出只能证明声明文件存在明显边界缺口,不能证明运行中的服务端策略与文件一致。
准备客户端核心代码保护与服务端授权评估时,可整理业务操作表、状态机、令牌字段、权益来源、nonce 与幂等策略、完整性信号用途、候选包和拒绝路径回执,再通过御盾中央平台提交申请。本站关于核心函数清单的技术文章可用于定位需要 VMP 的客户端方法,但最终授权仍由后端完成。
- 高价值 grant 状态必须列出主体、权益和新鲜度证据
- 客户端单独决定的 grant 直接标记为缺口
- 到达 grant 的迁移必须具有服务端证据来源
- 不可达状态单独输出以便人工复核
- 检查通过不代表线上实现和声明文件一致
- 真实放行仍依赖服务端和设备测试回执
#!/usr/bin/env python3
import json
import sys
from collections import defaultdict, deque
from pathlib import Path
REQUIRED_GRANT_EVIDENCE = {"authenticated-user", "server-entitlement", "freshness"}
ALLOWED_OWNERS = {"client", "server", "joint"}
ALLOWED_SOURCES = {"client", "server", "trusted-service"}
def fail(message, code):
print(message, file=sys.stderr)
raise SystemExit(code)
def load_state_machine(path):
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
fail("cannot read state machine: " + str(exc), 3)
if not isinstance(value, dict):
fail("state machine root must be an object", 4)
return value
def strings(record, field, allow_empty=False):
value = record.get(field)
if not isinstance(value, list):
fail("invalid list: " + field, 5)
result = []
for item in value:
if not isinstance(item, str) or not item.strip():
fail("invalid item in: " + field, 6)
result.append(item.strip())
if not allow_empty and not result:
fail("empty required list: " + field, 7)
if len(result) != len(set(result)):
fail("duplicate item in: " + field, 8)
return sorted(result)
def index_states(document):
states = document.get("states")
if not isinstance(states, list) or not states:
fail("states are missing", 9)
index = {}
for state in states:
if not isinstance(state, dict):
fail("state must be an object", 10)
state_id = state.get("id")
owner = state.get("decisionOwner")
if not isinstance(state_id, str) or not state_id.strip():
fail("state id is missing", 11)
if owner not in ALLOWED_OWNERS:
fail("invalid decision owner: " + str(state_id), 12)
if state_id in index:
fail("duplicate state: " + state_id, 13)
index[state_id] = {
"decisionOwner": owner,
"grants": strings(state, "grants", True),
"requires": strings(state, "requires", True),
"terminal": bool(state.get("terminal", False)),
}
return index
def index_transitions(document, states):
transitions = document.get("transitions")
if not isinstance(transitions, list) or not transitions:
fail("transitions are missing", 14)
graph = defaultdict(list)
incoming_sources = defaultdict(set)
for transition in transitions:
if not isinstance(transition, dict):
fail("transition must be an object", 15)
source = transition.get("from")
target = transition.get("to")
event = transition.get("event")
evidence_source = transition.get("evidenceSource")
if source not in states or target not in states:
fail("transition references unknown state", 16)
if not isinstance(event, str) or not event.strip():
fail("transition event is missing", 17)
if evidence_source not in ALLOWED_SOURCES:
fail("invalid evidenceSource", 18)
graph[source].append(target)
incoming_sources[target].add(evidence_source)
return graph, incoming_sources
def reachable(initial, graph):
seen = {initial}
queue = deque([initial])
while queue:
current = queue.popleft()
for target in graph.get(current, []):
if target not in seen:
seen.add(target)
queue.append(target)
return seen
def audit(states, incoming_sources, reachable_states):
findings = []
for state_id in sorted(reachable_states):
state = states[state_id]
if not state["grants"]:
continue
missing = sorted(REQUIRED_GRANT_EVIDENCE - set(state["requires"]))
reasons = []
if state["decisionOwner"] == "client":
reasons.append("client-only-decision")
if missing:
reasons.append("missing-evidence:" + ",".join(missing))
sources = incoming_sources.get(state_id, set())
if "server" not in sources and "trusted-service" not in sources:
reasons.append("no-server-derived-transition")
if reasons:
findings.append({
"state": state_id,
"grants": state["grants"],
"reasons": reasons,
})
return findings
def main():
if len(sys.argv) != 2:
print("Usage: auth_boundary_audit.py STATE_MACHINE_JSON", file=sys.stderr)
raise SystemExit(2)
source_path = Path(sys.argv[1])
if not source_path.is_file():
print("state machine file is missing", file=sys.stderr)
raise SystemExit(2)
document = load_state_machine(source_path)
states = index_states(document)
initial = document.get("initialState")
if initial not in states:
fail("initialState is invalid", 19)
graph, incoming_sources = index_transitions(document, states)
reachable_states = reachable(initial, graph)
unreachable = sorted(set(states) - reachable_states)
findings = audit(states, incoming_sources, reachable_states)
result = {
"status": "blocked" if findings else "manual-review-required",
"clientAuthorizationFindings": findings,
"unreachableStates": unreachable,
"checkedStates": len(reachable_states),
}
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
if findings:
raise SystemExit(20)
if __name__ == "__main__":
main()事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 移动端抗逆向与抗篡改属于纵深防御,不能替代服务端授权。 | OWASP MASVS-RESILIENCE 将应用韧性控制放在多层防御语境中。 | 控制目录不证明某个候选包或 VMP 配置已经达到任何防护强度。 |
| 许可判断涉及签名响应、客户端策略、缓存和离线可用性。 | Google Play Licensing overview 描述相应分发场景中的许可流程。 | 该机制适用于相应 Google Play 场景,不是任意商业 App 的通用授权协议。 |
| 敏感许可判断不应只依赖可被修改或移除的客户端逻辑。 | Client-side license verification 说明客户端校验的风险与服务端验证方向。 | 迁移到服务端仍需设计身份、离线、失败体验和业务状态一致性。 |
| 服务端许可校验要绑定用户、nonce、签名响应并处理重放。 | Server-side license verification 描述服务端验证许可响应的关键关联。 | 该流程不等于任意离线许可协议,也不能替代具体业务授权策略。 |
| OAuth 部署需要防范授权码注入、令牌重放与不安全授权方式。 | RFC 9700 OAuth Security BCP 总结当前 OAuth 安全部署建议。 | 该 BCP 不决定具体 App 的权益模型、资源粒度或商业操作权限。 |
| 完整性信号应靠近受保护操作,并由后端验证和参与决策。 | Play Integrity overview 描述完整性请求与后端处理的职责。 | 完整性信号不证明 VMP 配置、设备绝对可信或具体攻击已经被阻断。 |
| 服务端必须对主体、资源、操作、权益与请求新鲜度作最终裁决。 | 工程判断:客户端无法独立掌握可撤销账户状态、全局消费记录和最新服务端策略。 | 具体字段、时效和拒绝策略需要项目协议与真实服务端回执确认。 |
| 客户端 VMP、完整性信号和本地缓存只能作为分层输入,不能直接等于 allow。 | 工程判断:不同控制解决不同风险,合并为单一布尔值会丢失边界与失败语义。 | 分层设计本身不是测试结果,仍需候选包、状态机与拒绝路径证据。 |
工程常见问题
客户端判断已经做了 VMP,服务端还要重复校验权益吗?
要。VMP 提高客户端逻辑被分析和修改的成本,但服务器仍需查询最新购买、撤销、账号和资源状态,并把决定限制到当前操作。否则客户端 allow 或旧缓存可能直接产生价值。
隐藏付费按钮能否算作授权控制?
不能。隐藏按钮是体验控制,调用者仍可能绕过界面直接请求接口。高价值资源与操作必须由后端按主体、权益、资源和请求上下文重新判断。
Play Integrity 返回预期结果后能否直接允许操作?
不建议。完整性结果是风险证据,服务端仍要验证用户、权益、作用域、资源、新鲜度和重放状态。信号异常或不可用时也要有可解释的拒绝、降级或额外验证策略。
离线用户如何获得有限的付费体验?
使用服务端预先签发且范围受限的缓存材料,明确主体、功能、数据、期限和恢复规则。客户端不能自行延长或扩大;计费、消耗和敏感导出通常仍需要在线最终裁决。
令牌签名验证通过是否代表业务请求已授权?
不代表。还要检查发行者、受众、期限、主体、作用域、资源归属、权益状态和请求新鲜度,并在服务端原子执行。签名只证明令牌来源和完整性的一部分。
申请客户端保护与服务端授权边界评估要准备什么?
准备业务操作表、授权状态机、令牌与权益字段、nonce 或幂等策略、完整性信号用途、候选包摘要和拒绝路径回执,再从御盾中央平台提交申请。