PAYATHON 2026

无需每次调用 API,如何防止客户端篡改发送到后端的数值?

排障赵师傅

结论

在普通 Web 浏览器中,如果数值和增量依据都由客户端控制,就无法保证用户没有篡改这个数值。

攻击者可以修改变量、替换执行逻辑、伪造请求或重放旧请求。HMAC、加密、Hash 和代码混淆都无法改变客户端不可信这一事实。后端收到的客户端数据只能算用户提交的声明,不能直接当作事实。

更可靠的方式是由后端根据可验证的操作计算数值。操作记录可以批量上传,不需要每次递增都调用 API。但批量上传只能减少请求次数。如果操作本身只能由浏览器证明,例如单纯的点击次数,后端仍然无法判断它是否伪造。

客户端签名和加密为什么无效

客户端必须具备生成请求所需的所有信息,包括代码、密钥或公钥。拥有完整 DevTools 权限的用户可以在签名或加密之前替换原始数值。

例如,下面的流程无法阻止当前用户作弊:

value = 100
signature = HMAC(clientSecret, value)
send(value, signature)

攻击者可以改成:

value = 999999
signature = HMAC(clientSecret, value)
send(value, signature)

非对称加密也面临同样的问题。公钥加密可以防止无关第三方读取传输内容,却不能证明明文来自可信代码。攻击者完全可以先修改明文,再调用原有的加密函数。

其他常见方案也有各自的限制:

  • HTTPS 可以防止第三方在传输途中修改数据,但无法约束发起请求的浏览器用户。
  • 身份认证可以确认请求属于哪个账号,却不能证明请求参数真实。
  • 如果客户端能针对任意数值向后端申请 Hash 或 token,也能为伪造值取得有效凭据。
  • 代码混淆只能增加分析成本,无法阻止用户修改请求。
  • 检查数值范围、增长速度或统计分布只能发现部分异常,无法提供完整性保证。

推荐方案:批量发送事件,由后端计算数值

不要提交由客户端计算的最终计数:

{
  "value": 120
}

改为提交一批业务事件:

{
  "sessionId": "9e562370-880d-47f5-84b4-90d651017809",
  "batchId": "f67212c3-cceb-4641-84d2-71097a3abf22",
  "events": [
    {
      "sequence": 101,
      "type": "TASK_COMPLETED",
      "taskId": "task-18"
    },
    {
      "sequence": 102,
      "type": "TASK_COMPLETED",
      "taskId": "task-21"
    }
  ]
}

后端逐条验证事件,再根据已经确认的事件更新计数。即使请求中包含客户端提交的 value,最终计算也不能使用它。

这种设计要求后端能根据可信状态判断事件是否有效,例如:

  • taskId 是否存在并属于当前用户;
  • 任务是否满足完成条件;
  • 同一任务是否已经计数;
  • 当前状态是否允许执行该操作;
  • 操作是否消耗了后端管理的资源;
  • sequence 是否连续;
  • batchId 是否已经处理。

如果事件只是“用户点了一次按钮”,而后端没有任何信息可用于验证这次点击,攻击者仍然可以伪造一万个点击事件。把数值改成事件提交,并不会让数据自动变得可信。

Angular 批量提交示例

客户端可以先暂存事件,到达检查点或批量上限后再发送:

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { randomUUID } from './uuid';

interface ClientEvent {
  sequence: number;
  type: 'TASK_COMPLETED';
  taskId: string;
}

interface EventBatch {
  sessionId: string;
  batchId: string;
  events: ClientEvent[];
}

@Injectable({ providedIn: 'root' })
export class EventBufferService {
  private readonly sessionId = randomUUID();
  private nextSequence = 1;
  private pending: ClientEvent[] = [];

  constructor(private readonly http: HttpClient) {}

  recordTaskCompletion(taskId: string): void {
    this.pending.push({
      sequence: this.nextSequence++,
      type: 'TASK_COMPLETED',
      taskId
    });
  }

  flush(): void {
    if (this.pending.length === 0) {
      return;
    }

    const events = [...this.pending];

    const batch: EventBatch = {
      sessionId: this.sessionId,
      batchId: randomUUID(),
      events
    };

    this.http.post('/api/event-batches', batch).subscribe({
      next: () => {
        this.pending.splice(0, events.length);
      },
      error: () => {
        // 保留原批次,稍后使用同一个 batchId 重试更合适。
        // 生产实现应单独保存待确认批次,避免生成新 batchId 导致重复处理。
      }
    });
  }
}

这段缓冲逻辑只用于减少请求次数。sequence 和 batchId 可以帮助后端处理乱序、重复提交和重试,但无法证明事件真的发生过。

生产环境通常会分别保存“尚未发送”和“已发送但未确认”的事件。重试时必须复用原来的 batchId,否则后端可能把同一批事件视为新的请求。

Spring Boot 后端示例

后端应忽略客户端提供的汇总值,只接受能够验证的业务事件:

public record ClientEvent(
    long sequence,
    String type,
    String taskId
) {}

public record EventBatchRequest(
    UUID sessionId,
    UUID batchId,
    List<ClientEvent> events
) {}

public record EventBatchResponse(
    long acceptedValue,
    long lastSequence
) {}

处理逻辑可以这样写:

@Service
public class EventBatchService {

    private final SessionRepository sessionRepository;
    private final ProcessedBatchRepository processedBatchRepository;
    private final TaskRepository taskRepository;

    public EventBatchService(
            SessionRepository sessionRepository,
            ProcessedBatchRepository processedBatchRepository,
            TaskRepository taskRepository) {
        this.sessionRepository = sessionRepository;
        this.processedBatchRepository = processedBatchRepository;
        this.taskRepository = taskRepository;
    }

    @Transactional
    public EventBatchResponse process(
            UUID userId,
            EventBatchRequest request) {

        SessionState session = sessionRepository
                .findForUpdate(request.sessionId(), userId)
                .orElseThrow(() -> new ResponseStatusException(
                        HttpStatus.NOT_FOUND, "Session not found"));

        var previousResult = processedBatchRepository
                .findResult(userId, request.batchId());

        if (previousResult.isPresent()) {
            return previousResult.get();
        }

        long expectedSequence = session.getLastSequence() + 1;
        long accepted = 0;

        for (ClientEvent event : request.events()) {
            if (event.sequence() != expectedSequence) {
                throw new ResponseStatusException(
                        HttpStatus.CONFLICT, "Unexpected sequence");
            }

            if (!"TASK_COMPLETED".equals(event.type())) {
                throw new ResponseStatusException(
                        HttpStatus.BAD_REQUEST, "Unsupported event type");
            }

            Task task = taskRepository
                    .findByIdAndUserId(event.taskId(), userId)
                    .orElseThrow(() -> new ResponseStatusException(
                            HttpStatus.BAD_REQUEST, "Invalid task"));

            if (!task.isEligibleForCompletion()) {
                throw new ResponseStatusException(
                        HttpStatus.CONFLICT, "Task cannot be completed");
            }

            task.markCompleted();
            accepted++;
            expectedSequence++;
        }

        session.incrementValue(accepted);
        session.setLastSequence(expectedSequence - 1);

        EventBatchResponse result = new EventBatchResponse(
                session.getValue(),
                session.getLastSequence()
        );

        processedBatchRepository.save(
                userId,
                request.batchId(),
                result
        );

        return result;
    }
}

Controller 应从已经认证的身份中获取 userId,不能信任请求体中的用户标识:

@RestController
@RequestMapping("/api/event-batches")
public class EventBatchController {

    private final EventBatchService service;

    public EventBatchController(EventBatchService service) {
        this.service = service;
    }

    @PostMapping
    public EventBatchResponse submit(
            @AuthenticationPrincipal AuthenticatedUser user,
            @RequestBody EventBatchRequest request) {
        return service.process(user.id(), request);
    }
}

findForUpdate、批次去重记录和状态更新需要放在同一个数据库事务中。数据库层还应为 (user_id, batch_id) 建立唯一约束,防止并发请求绕过去重检查。具体的 repository 实现取决于所用数据库和持久化框架。

这种方案能防住什么

服务端处理事件后,可以阻止以下操作:

  • 直接把计数从 10 改成 100000;
  • 重复提交同一个批次;
  • 重放已经接受的事件;
  • 跳过规定的状态转换;
  • 对同一个业务对象重复计数;
  • 使用不属于当前用户的对象增加数值。

这套方案无法证明纯客户端行为确实发生过。如果攻击者能够构造一组完全符合服务端规则的虚假事件,后端依然分不清真实操作和脚本模拟。

服务端的验证规则必须依赖攻击者无法随意制造的状态,比如数据库中的任务状态、服务端保存的额度、另一个可信系统返回的结果,或已经完成的服务端业务操作。

请求量过大时怎么处理

由服务端维护权威状态,并不意味着每次 UI 变化都要发送请求。可以根据业务风险采用不同方式:

  1. 在浏览器中收集事件,按数量或时间窗口批量上传。
  2. 只有影响最终结果的业务操作才调用后端,纯界面操作留在本地。
  3. 使用 WebSocket 或流式连接发送事件,减少重复建立连接的开销。
  4. 限制单个批次的大小,并通过 batchId 实现幂等重试。
  5. 后端可以按用户或会话串行处理事件,也可以使用数据库锁和版本号处理并发。
  6. 只保存必要的业务状态,不必永久保留每条原始事件。

批处理只能减少网络开销,无法提高客户端数据的可信度。

后端无法验证时怎么处理

如果数值来自鼠标点击、停留时长或页面浏览次数等纯客户端行为,而且后端无法在行为发生时参与,那么严格防篡改就没有实现路径。此时只能降低作弊收益,或识别明显异常,例如:

  • 限制服务端允许的最大增量;
  • 限制每个账号或会话的提交频率;
  • 检查时间、顺序和状态是否合理;
  • 对异常结果进行二次验证;
  • 将客户端数值作为参考信息,不直接用于资金、权限、排名或奖励计算。

这些措施只能用于风险控制,攻击者仍有可能模拟正常行为。如果最终结果涉及安全或经济利益,后端应使用自己能够验证的数据进行计算。

备注:内容仅供参考。