先看结论与判断条件

  • VMP 保护的是客户端实现和执行路径,服务端授权决定特定用户在特定时刻能否对特定资源执行特定操作,两者问题不同。
  • 隐藏按钮、修改本地布尔值、验证客户端缓存或只检查令牌是否存在,都不能构成高价值业务操作的最终授权。
  • 服务端裁决要绑定用户、资源、操作、权益版本、请求新鲜度和重放状态,不能只相信客户端上报的“已验证”结论。
  • 完整性信号、VMP 状态和设备信息是风险输入,不是绝对可信根;允许、拒绝或降级仍由后端结合业务上下文决定。
  • 离线能力必须预先限定有效期、操作范围、数据范围与恢复策略,不能把无法联网变成无限期客户端授权。
  • 授权边界应通过状态机、拒绝路径、重放用例、跨账号用例和服务端审计回执验证,而不是只看 App 未崩溃。

先区分代码保护和授权裁决回答的两个问题

VMP 关注“客户端核心实现被观察、理解或修改的成本”,服务端授权关注“某个主体能否在当前上下文执行某项操作”。前者作用在代码与运行路径,后者作用在身份、资源、权益和策略。即使客户端判断被严密保护,它仍运行在用户控制的设备上,无法独立掌握服务器账户状态、撤销记录、并发限制和最新风险策略。

高价值操作包括激活付费功能、下载商业资源、提交交易、导出敏感数据或调用计费接口。按钮是否可见只是体验状态,本地缓存是否为 true 只是输入,真正的业务动作应在服务端重新检查主体、对象、操作和上下文。若后端只接收客户端的 allow 结果,攻击面会集中到客户端状态和请求重放。

工程判断上,VMP 与服务端授权应形成两层责任,而不是互相替代。客户端保护专有算法、输入规范化和本地体验分支,服务器保存可撤销的权威权益并签发受限结果。本文没有具体应用协议与候选包,因此只给出边界设计和审计方法,不声称任何项目已经达到防护或授权目标。

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 或幂等策略、完整性信号用途、候选包摘要和拒绝路径回执,再从御盾中央平台提交申请。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: VMP 加固是什么,如何选择保护范围