无需每次调用 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 变化都要发送请求。可以根据业务风险采用不同方式:
- 在浏览器中收集事件,按数量或时间窗口批量上传。
- 只有影响最终结果的业务操作才调用后端,纯界面操作留在本地。
- 使用 WebSocket 或流式连接发送事件,减少重复建立连接的开销。
- 限制单个批次的大小,并通过
batchId实现幂等重试。 - 后端可以按用户或会话串行处理事件,也可以使用数据库锁和版本号处理并发。
- 只保存必要的业务状态,不必永久保留每条原始事件。
批处理只能减少网络开销,无法提高客户端数据的可信度。
后端无法验证时怎么处理
如果数值来自鼠标点击、停留时长或页面浏览次数等纯客户端行为,而且后端无法在行为发生时参与,那么严格防篡改就没有实现路径。此时只能降低作弊收益,或识别明显异常,例如:
- 限制服务端允许的最大增量;
- 限制每个账号或会话的提交频率;
- 检查时间、顺序和状态是否合理;
- 对异常结果进行二次验证;
- 将客户端数值作为参考信息,不直接用于资金、权限、排名或奖励计算。
这些措施只能用于风险控制,攻击者仍有可能模拟正常行为。如果最终结果涉及安全或经济利益,后端应使用自己能够验证的数据进行计算。
备注:内容仅供参考。
