ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从抢票外挂到接口安全:Spring Boot实现轻量风控拦截器

从抢票外挂到接口安全:Spring Boot实现轻量风控拦截器 抢票外挂被刑拘的新闻出现过不止一次很多报道会把焦点放在“非法获取计算机信息系统数据罪”这几个字上。作为开发者看到这类事件更该关心的是另一面当脚本绕过平台限制、高频读取接口数据时平台侧是如何用风控手段识别和拦截的以及一个做自动化测试或数据采集的开发者为什么需要了解这条合法边界。这篇文章不教怎么写外挂而是站在服务端防护视角拆解抢票外挂造成的风险并且用 Spring Boot 实现一个轻量风控拦截器再说明如何扩展到 Redis、验证码和规则引擎等生产能力。读完可以形成一套可落地的接口防自动化攻击思路也能在自己的项目中正确加入限流和风险识别。1. 从抢票外挂被刑拘看接口数据安全的三层问题1.1 抢票外挂破坏了哪些平衡抢票外挂的核心是让程序代替人去完成高频率请求。正常用户买票时会先搜索车次、查看余票、选择座位、填写乘车人、提交订单每一步之间都有思考时间和操作间隔。外挂脚本则可以跳过大部分交互直接在短时间内批量查询余票甚至在放票瞬间自动提交订单。从平台角度看这不是简单的“手速快”而是破坏了三个层面的平衡第一是用户公平性。热门车次放票量有限外挂占用大量请求资源后普通用户能够抢到的概率被显著压缩。平台投入验证码、排队等机制就是为了让每一次出票机会更接近真实用户。第二是系统稳定性。外挂通常会用多账号、多代理、多设备同时发起请求单机 QPS 可以达到几十甚至上百远高于正常用户。如果平台没有限流数据库、缓存、网关都很容易被瞬时流量打满产生连锁故障。第三是数据安全。余票查询接口表面上只返回票数但接口背后可能还暴露车次信息、价格策略、库存分布甚至某些管理端字段。如果外挂脚本长期高频调用非公开接口就存在未经授权获取计算机信息系统数据的风险。司法实践中通过绕过技术措施获取后台数据情节严重的可能涉及非法获取计算机信息系统数据罪。这里不展开罪名认定细节更实际的意义在于普通开发者做一个自动化小工具时如果使用未授权接口、绕过验证码、高频抓取数据就已经站到了合规边界之外。与其事后承担责任不如从一开始就把技术和规则都放在合法范围内。1.2 为什么自动化请求会触碰刑事风险刑法意义上的“非法获取计算机信息系统数据罪”重点关注的是违反国家规定、采用技术手段、获取计算机系统中存储或传输的数据。对于抢票外挂来说风险点并不在“购票”这个动作而在获取数据的方式。如果脚本调用的是公开查询接口并且接口本身没有做任何访问控制访问频率也在合理范围内那么通常属于正常的接口调用。但如果脚本通过伪造请求头、绕过验证码、破解签名或者利用未授权接口来获取数据就属于“采用其他技术手段”并且很可能是在未经授权的情况下获取数据。当数据量、次数、违法所得达到一定标准时就可能构成刑事犯罪。对开发者最实用的结论是不要用未授权的自动化脚本去请求不属于自己的系统。学习自动化技术、压测自己的项目、参加平台官方开放的开发者计划都是安全的路径。反之把抓包工具分析出的接口直接拿去写“抢票脚本”风险并不只是封号那么简单。2. 识别自动化抢票请求的六个关键维度服务端要拦住外挂首先要知道自动化请求和正常用户请求的差异。下面六个维度是实际风控系统里最常见的判据。2.1 高频特征正常用户不会在几秒内反复查询余票查询是一个读操作服务端如果没有限流攻击者可以用很低成本发起大量请求。同一 IP 或同一账号在 1 秒内请求 10 次在 1 分钟内请求上百次这样的频率已经明显偏离正常用户。但频率不能只看总量还要看时间分布。正常用户可能在放票前频繁点击也会有短时高峰。因此做限流时需要用平滑的限流算法而不是简单的“固定窗口计数”。固定窗口容易在窗口边界产生突发流量令牌桶和滑动窗口更适合这种场景。2.2 客户端特征User-Agent、设备指纹和 IP 画像外挂脚本为了省事通常不会认真伪造客户端信息。于是服务端会看到固定的 User-Agent比如python-requests、curl、Go-http-client、Apache-HttpClient。这些特征可以直接拦截但攻击者也会伪造浏览器 UA所以不能只靠 UA 判断。设备指纹是更强的信号。同一个浏览器会有稳定的 Canvas 指纹、WebGL 指纹、字体指纹等。外挂往往使用同一个自动化环境指纹重复率很高。服务端如果发现一个设备指纹在短时间关联多个账号或者一个账号频繁切换设备就会提高风险分。IP 画像也很关键。家庭宽带 IP 的归属地和行为特征比较稳定IDC 机房 IP 则经常集中出现。如果发现大量请求来自同一个机房网段同时又符合高频特征基本可以判断为自动化脚本。2.3 行为轨迹点击路径和时间分布正常用户的操作路径是有逻辑的进入页面、选择车次、查看余票、填写信息、下单。即使操作很快中间也会存在页面渲染、滚动、点击等事件。外挂脚本直接向接口发请求不会产生这些前置事件。服务端可以通过前端埋点收集行为日志比如鼠标移动轨迹、按钮点击顺序、页面停留时间。如果服务端收到下单请求但前置行为事件缺失或者行为时间间隔全部小于 100 毫秒风险就会显著提高。这个维度在纯后端接口防护里很难做到通常需要接入前端 SDK 或采集页面日志。不过它对于识别“真人”和“脚本”非常有效。2.4 验证码指标通过率异常说明有自动识别验证码的作用不是拦住所有攻击而是提高自动化成本。正常用户多试几次也能通过通过率可能波动但整体不会太低。如果一个固定设备或 IP 在短时间内验证码通过率接近 100%同时请求频率又很高很大概率是接入了自动识别服务。反过来如果某个 IP 的验证码连续失败次数非常多也说明它在拿脚本盲试不是普通用户。验证码指标需要和频率、设备指纹一起使用单独看都有误判可能。下面的表格汇总了六个维度的正常与可疑特征检测维度正常特征可疑特征容易误判的情况请求频率分钟级操作符合业务节奏同一 IP/Token 每秒多次请求企业出口 IP 被多人共享User-Agent常见浏览器且版本分散为空、固定不变或包含脚本库名用户禁用 UA 或老系统客户端集中设备指纹设备标识稳定、数量有限短时间出现大量新设备 ID用户频繁更换设备或清理缓存行为轨迹有点击、停留、思考时间无前置事件直接请求下单接口屏幕阅读器等辅助工具验证码指标通过率正常失败间隔随机短时间高通过率或连续失败正常用户多次输错验证码IP 画像家庭宽带、运营商动态 IPIDC 机房 IP 集中且高频公司或校园统一出口 IP3. 用 Spring Boot 实现一个轻量风控拦截器理解了检测维度接下来用 Spring Boot 实现一个可运行的最小风控拦截器。它包含两类能力基于令牌桶的限流以及基于 IP 和 User-Agent 的简单拦截。3.1 环境准备和项目结构示例环境如下依赖版本建议JDK8 或 11Spring Boot2.7.x 或 3.xMaven3.6 以上IDEIntelliJ IDEA 或 Eclipse这里使用 Spring Boot 2.7 版本JDK 11。先创建一个 Maven 工程添加 Web 依赖。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies项目结构如下src/main/java/com/example/ticket/ ├── TicketApplication.java ├── controller/TicketController.java ├── interceptor/RiskControlInterceptor.java └── interceptor/TokenBucketRateLimiter.java src/main/resources/ └── application.yml3.2 基于内存令牌桶的限流器令牌桶是一种常用的限流算法。系统按固定速率向桶里放入令牌每个请求需要消耗一个令牌。如果桶里没有令牌请求就会被拒绝。相比固定窗口计数令牌桶允许短时突发流量同时在整体上限制平均速率。先写一个简单的令牌桶实现package com.example.ticket.interceptor; import java.util.concurrent.TimeUnit; public class TokenBucketRateLimiter { private final long capacity; private final long refillRate; private double tokens; private long lastRefillTime; public TokenBucketRateLimiter(long capacity, long refillRate) { this.capacity capacity; this.refillRate refillRate; this.tokens capacity; this.lastRefillTime System.currentTimeMillis(); } public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); double elapsedSeconds (now - lastRefillTime) / 1000.0; tokens Math.min(capacity, tokens elapsedSeconds * refillRate); lastRefillTime now; if (tokens 1) { tokens - 1; return true; } return false; } }关键点有三个capacity是桶容量代表瞬时最多能通过的请求数。refillRate是每秒补充的令牌数代表平均速率。synchronized保证并发下tokens的修改安全。如果给每个 IP 分配一个限流器桶容量设置为 5补充速率设置为 1那么同一个 IP 最多可以在短时间连续发 5 个请求后续每秒只能放行 1 个。这里要注意内存版本只能用于单实例学习和测试多实例部署时每个节点各自计数达不到全局限流效果。3.3 请求风险打分与拦截处理接下来写拦截器。拦截器会取出客户端 IP 和 User-Agent先用令牌桶判断频率再判断 UA 特征。package com.example.ticket.interceptor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; Component public class RiskControlInterceptor implements HandlerInterceptor { private final MapString, TokenBucketRateLimiter rateLimiterMap new ConcurrentHashMap(); private static final SetString BLOCKED_UA_PREFIXES Set.of( python-requests, curl, Go-http-client, Apache-HttpClient ); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String ip getClientIp(request); String ua request.getHeader(User-Agent); TokenBucketRateLimiter limiter rateLimiterMap.computeIfAbsent(ip, key - new TokenBucketRateLimiter(5, 1)); if (!limiter.tryAcquire()) { return writeBlocked(response, 429, request too frequent, please slow down); } if (ua null || BLOCKED_UA_PREFIXES.stream().anyMatch(ua::contains)) { return writeBlocked(response, 403, client not allowed); } return true; } private String getClientIp(HttpServletRequest request) { String xff request.getHeader(X-Forwarded-For); if (xff ! null !xff.isBlank()) { return xff.split(,)[0].trim(); } return request.getRemoteAddr(); } private boolean writeBlocked(HttpServletResponse response, int code, String message) throws Exception { response.setStatus(code); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\: code ,\message\:\ message \}); return false; } }这个实现的思路比较直接rateLimiterMap以 IP 为 key 保存各自的限流器。如果 IP 请求频率超过阈值直接返回 429。如果 UA 特征命中脚本库返回 403。computeIfAbsent可以避免高并发下重复创建对象但如果流量很大Map 会一直增加。实际项目中需要增加清理机制比如定时删除超过 10 分钟不活跃的 IP 记录否则可能造成内存泄漏。接着写控制器模拟一个余票查询接口package com.example.ticket.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.Map; RestController RequestMapping(/api/ticket) public class TicketController { GetMapping(/remaining) public MapString, Object remaining(RequestParam String trainNo) { // 实际项目中这里会查询缓存或数据库 return Map.of( trainNo, trainNo, remaining, 20, timestamp, System.currentTimeMillis() ); } }最后注册拦截器。Spring Boot 3.x 里WebMvcConfigurer的写法没有变化但注意addInterceptors中要限制路径不要把所有接口都挡住。package com.example.ticket.config; import com.example.ticket.interceptor.RiskControlInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class WebConfig implements WebMvcConfigurer { Autowired private RiskControlInterceptor riskControlInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(riskControlInterceptor) .addPathPatterns(/api/ticket/**); } }3.4 验证码降级的接入思路限流只能拦住高频请求如果攻击者把频率控制在阈值内同时伪造 UA就需要验证码介入。常见做法是当请求风险分超过一定阈值时后端返回一个verify_required标记前端弹出滑块验证码或点选验证码。验证通过后后端签发一个短期 token后续请求携带这个 token 才能访问接口。以拦截器为例可以把writeBlocked改造成返回不同的 code{ code: 4010, message: verify_required, data: { verifyType: slider } }前端拿到这个响应后调用验证码组件。验证码服务商一般会提供校验接口开发者需要在后端二次校验验证码结果不能只相信前端传递的successtrue。校验通过后再生成业务 token。验证码不能完全替代限流它的目的是提高自动化成本。两者配合才能在误杀和拦截之间找到平衡。4. 从单机拦截到生产级风控需要补哪些能力上面的实现可以在本地跑通但直接放到生产环境还不够。生产环境面临多实例部署、灰度发布、大规模流量和持续变化的攻击手法需要补上几类关键能力。4.1 用 Redis 和 Lua 实现滑动窗口限流单机内存限流在多实例部署下会失效。请求经过负载均衡后可能被分发到多个节点每个节点都有自己的令牌桶攻击者很容易通过切换节点绕开限流。更合理的方式是使用 Redis 存储计数让所有节点共享同一个窗口计数。滑动窗口比固定窗口更平滑。固定窗口在窗口切换瞬间可能出现两倍流量滑动窗口则按时间戳记录每次请求始终只统计最近一个时间窗口。下面是一个基于 Redis 的有序集合和 Lua 脚本的滑动窗口限流实现local key KEYS[1] local window tonumber(ARGV[1]) local limit tonumber(ARGV[2]) local now tonumber(ARGV[3]) redis.call(ZREMRANGEBYSCORE, key, 0, now - window) local count redis.call(ZCARD, key) if count limit then redis.call(ZADD, key, now, now .. : .. math.random(1000000)) redis.call(EXPIRE, key, window) return 1 else return 0 end这段脚本的逻辑是ZREMRANGEBYSCORE清理窗口外的旧记录。ZCARD统计当前窗口内的请求数。如果小于限制写入当前时间戳并返回 1否则返回 0。使用 Lua 脚本可以保证原子性避免并发请求同时读到旧计数后一起放行。在 Spring Boot 中调用时可以用StringRedisTemplate执行脚本。需要注意 Redis key 的设计最好带上接口路径或业务场景比如rate:limit:api:ticket:remaining:20240501这样后续排查问题时会更容易。4.2 风险数据异步上报与规则调整风控不是一次性规则而是持续调优的过程。每次拦截都应记录风险请求的原始信息包括 IP、UA、接口路径、请求参数、命中规则、拦截结果。这些数据异步写入日志系统或消息队列再由单独的规则引擎做离线分析。上报数据的结构可以类似{ timestamp: 2024-05-01T12:00:00.123Z, ip: 203.0.113.10, ua: python-requests/2.31.0, path: /api/ticket/remaining, deviceId: unknown, riskScore: 80, hitRules: [rate_limit, ua_block], action: block }异步上报的关键是不要阻塞主请求链路。可以用消息队列也可以先写入本地日志再由 Filebeat、Logstash 这类组件采集到 ClickHouse 或 Elasticsearch。规则需要支持动态发布。比如某个机房 IP 大量请求时运维希望在分钟级内把对应网段加入黑名单而不是修改代码后重新发布。生产环境通常会把规则放到配置中心或独立的规则管理后台配置变更后实时推送到网关。4.3 人机校验、鉴权、参数校验和敏感数据保护风控只是第一道防线接口本身也要有足够的安全设计。首先是接口鉴权。余票接口如果不需要登录那么任何用户都能无差别调用。实际项目中即使是查询接口也应该支持匿名访问和登录访问两套级别。登录用户加上频率限制匿名用户使用更严格的验证码策略。其次是参数校验。trainNo这类参数必须校验长度、格式和取值范围。否则攻击者可能通过构造超长参数、SQL 注入片段或 JSON 嵌套对象触发框架漏洞。GetMapping(/remaining) public MapString, Object remaining(RequestParam Pattern(regexp ^[A-Za-z0-9]$) String trainNo) { // ... }引入spring-boot-starter-validation后可以用Validated配合Pattern做基础校验避免脏参数进入后续逻辑。最后是敏感数据保护。接口返回余票数就够了不要把库存明细、用户手机号、内部 ID 一并返回。即使接口是登录用户才能访问也要遵循“最小返回”原则降低数据泄露造成的危害。5. 验证风控是否生效高频请求模拟与观察代码写完后需要验证规则是否真的生效。先启动 Spring Boot 应用然后用 curl 模拟请求。5.1 用 curl 模拟高频请求先测试正常请求使用浏览器 UAcurl -i -H User-Agent: Mozilla/5.0 http://localhost:8080/api/ticket/remaining?trainNoG1234预期会返回 200 和余票 JSON。再用循环模拟同一个 IP 的连续高频请求for i in {1..10}; do curl -s -o /dev/null -w %{http_code}\n \ -H User-Agent: Mozilla/5.0 \ http://localhost:8080/api/ticket/remaining?trainNoG1234 done当令牌桶容量为 5、补充速率为 1 时前 5 个请求应该返回 200之后会开始出现 429。再测试脚本 UAcurl -i -H User-Agent: python-requests/2.31.0 \ http://localhost:8080/api/ticket/remaining?trainNoG1234预期直接返回 403。5.2 观察拦截响应和日志被拦截时浏览器或命令行里会看到类似响应{ code: 429, message: request too frequent, please slow down }如果项目里配置了日志可以在RiskControlInterceptor中增加一行日志输出。这样方便在压测时确认规则匹配情况。if (!limiter.tryAcquire()) { System.out.println(risk blocked, ip ip , path request.getRequestURI()); return writeBlocked(response, 429, request too frequent, please slow down); }生产环境中不要使用System.out.println应该使用 SLF4J 或 Logback 记录结构化日志。日志关键字包括risk_blocked、rule_name、ip、ua方便后续检索。5.3 用 JMeter 做简单压测curl 适合功能验证要观察限流在并发下的效果可以用 JMeter 或 wrk。JMeter 中创建一个线程组设置 50 个线程、循环 10 次请求地址为http://localhost:8080/api/ticket/remaining?trainNoG1234加入 HTTP HeaderUser-Agent: Mozilla/5.0。压测结果中会看到大量 429 响应同时服务端日志中会出现限流记录。这说明拦截器在并发场景下是有效的。如果压测时发现 200 响应数量超过了限流阈值就要检查限流器是否被多个实例独立使用或者拦截器是否真的注册到了目标路径。常见问题如下表问题现象可能原因检查方式处理建议限流不生效拦截器未注册或路径没匹配查看启动日志和拦截器注册代码确认addPathPatterns覆盖目标接口429 响应过多阈值设置过小压测时观察正常用户请求量根据业务高峰期数据调整容量和速率多实例下拦截不准使用单机内存限流查看部署架构和限流实现改用 Redis 分布式限流内存持续增长rateLimiterMap未清理监控 JVM 内存和 Map 大小增加定时清理不活跃 IP 的逻辑6. 开发者的合规边界与自动化测试建议6.1 自动化脚本并不天然合法很多开发者写自动脚本只是觉得“技术能力强”但忽略了授权问题。自动化脚本是否合法核心看三点是否访问了未授权的系统或接口。是否绕过了平台设置的技术保护措施。是否获取了非公开数据并造成严重后果。如果你只是对自己开发的项目做自动化测试那完全合法。如果你对一个第三方平台做未授权自动抓取例如使用高频率请求获取余票数据、绕过验证码、伪造客户端就会超出正常接口调用范围。这类行为一旦造成平台损失或被认定为非法获取数据责任不会因为“我只是练手”而免除。因此安全边界应该是只测试自己拥有的系统或者在平台明确授权的范围内测试。不要因为接口没有登录就能访问就认为可以任意调用。6.2 合规自动化测试的操作清单学习自动化测试时建议遵守下面这份清单检查项合规做法不合规做法测试目标自己的项目或公司授权系统未经授权的第三方接口测试环境本地或测试环境使用脱敏数据直接在生产环境大量写入数据请求频率设置合理间隔降低对服务端压力无限循环重试造成服务不可用验证码使用测试环境验证码或测试开关编写程序自动识别生产验证码登录态使用测试账号按权限申请绕过登录接口直接访问受保护数据漏洞发现通过官方 SRC 或安全团队上报公开利用或数据下载压测提前申请错峰执行未经申请直接打满生产流量6.3 学习和生产环境的差异学习环境可以快速跑通功能生产环境则要考虑全面。以限流为例本地可以用ConcurrentHashMap和内存令牌桶生产环境则要换成 Redis 分布式限流并补充监控告警。能力学习环境生产环境限流存储内存 MapRedis 或网关限流风险规则写死在代码里配置中心动态调整日志控制台输出结构化日志并采集分析验证码关闭或测试模式商业验证码服务防护层次单接口拦截网关、应用、数据层多级防护数据保护忽略脱敏严格最小返回和脱敏6.4 面向生产的风控建设清单如果你需要在工作中建设中大型风控系统下面这些点可以作为建设清单接口层先做基础限流按用户维度、IP 维度、设备维度分别设置阈值。网关层做全局限流和黑白名单避免每个应用重复实现。前端埋点记录行为轨迹后端在做高风险操作时校验行为数据。验证码作为二次校验手段用于风险分较高的请求。所有风控决策都记录日志方便复盘和规则调优。规则不要全部写死要有配置平台和灰度发布能力。定期做压测确认风控规则在峰值流量下不会成为瓶颈。最后一个建议技术能力越强越要清楚边界在哪里。花时间研究限流算法、接口安全、防御体系对职业成长的价值远大于写一个抢票脚本。把同样的精力投入到合规技术实践上才能做出真正有长期价值的东西。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进