本节摘要:本节讲一对"冒用"攻击:CSRF 冒用用户身份——诱导已登录用户的浏览器向目标站静默发请求;SSRF 冒用服务器位置——诱导目标服务器替攻击者访问内网资源。前者攻会话机制的隐性信任,后者攻网络位置的隐性信任,防御分别落在请求溯源凭据与出网管控上。
XSS 之后,红队发现还有更轻的打法——甚至不需要在页面里跑代码。浏览器的老规矩是:向某站发请求时,自动附上该站的 Cookie。这条为便利而设的机制,让"请求带着 Cookie"不再等价于"本人亲手操作"。CSRF(跨站请求伪造)利用的正是这条缝隙:在别处放一个自动提交的表单或一张图片,受害者浏览时,浏览器替攻击者向目标站发出了携带其会话的请求。改绑定邮箱、静默转账、添加管理员,凡是纯凭 Cookie 授权的状态变更接口,都在射程内。
SSRF 则把冒用对象换成服务器。许多功能需要服务端替用户取远程资源:头像抓取、网页预览、文档转码、回调通知。攻击者把目标地址换成内网地址——服务器的请求从内网发出,天然越过了防火墙,红队借服务器之手看到了本该看不到的东西:内网存活的服务、管理面板、云环境的元数据接口(那里常躺着临时凭证)。
靶场复现攻击与修复(最小化):
<!-- 反面教材:仅凭 Cookie 授权的状态变更接口 --> <form action="https://lab.local/api/email" method="POST"> <input name="email" value="new@mail.local"> </form> <!-- 攻击者页面(恶意站点)放一段自动提交,受害者浏览即触发: 浏览器向 lab.local 发 POST,自动带上受害者的会话 Cookie, 接口只认 Cookie → 绑定邮箱被改成攻击者的 → 重置密码通道失守(呼应上一章) -->
# 整改版一:CSRF 令牌——请求必须携带会话绑定的随机值 import secrets def form_page(session): token = secrets.token_urlsafe(16) session.set("csrf_token", token) # 服务端留存 return render("form.html", csrf_token=token) # 嵌入表单隐藏域 def submit(request): if not compare(request.form.get("csrf_token"), session.get("csrf_token")): return fail("请求来源校验失败") # 外站页面拿不到本站令牌 return do_change_email(request.form["email"])
令牌方案的关键在"外站不可知":攻击者的页面能诱使浏览器发请求,却读不到目标站的页面内容(同源策略在此反过来帮了防御者),没有令牌的请求一律拒绝。配合两层加固更稳:会话 Cookie 加 SameSite=Lax(跨站 POST 不再自动携带,上一章属性集的伏笔在此兑现);敏感操作前重新认证(改邮箱、改手机这类"改钥匙"动作,多问一次口令)。
# 整改版二:Cookie 层面的 SameSite(响应头) add_header Set-Cookie "sid=...; Secure; HttpOnly; SameSite=Lax; Path=/"; # 自定义请求头要求(如接口约定必须带 X-Requested-With)作为辅助信号
同样看靶场里的翻车与整改:
# 反面教材:服务端替用户抓取任意地址 import requests def fetch_avatar(url: str): return requests.get(url, timeout=5).content # 谁都能让它抓任何地方 # 攻击者提交: # http://10.0.0.5:8080/manager/html ← 内网管理面板 # http://169.254.169.254/latest/meta/ ← 云元数据:临时凭证所在地 # 服务器从内网发起请求,防火墙形同虚设
# 整改版:目标校验 + 网络层管控 from urllib.parse import urlparse import ipaddress, socket, requests ALLOWED_SCHEMES = {"http", "https"} def safe_fetch(url: str, allow_net: tuple = ("img-cdn.example",)) -> bytes: p = urlparse(url) if p.scheme not in ALLOWED_SCHEMES: raise ValueError("协议不允许") host = p.hostname or "" if host not in allow_net: # 域名白名单 raise ValueError("目标不在白名单") ip = ipaddress.ip_address(socket.gethostbyname(host)) if ip.is_private or ip.is_loopback or ip.is_link_local: raise ValueError("禁止访问内部地址") # 解析后再校验,防域名伪装 r = requests.get(url, timeout=5, allow_redirects=False) # 不跟随重定向 return r.content
要点拆开看:协议与域名白名单定死"能去哪";域名解析后校验 IP(否则攻击者把恶意地址解析到内网 IP 绕过字符串检查);禁跟随重定向(重定向是白名单绕过的经典后门);出网走独立代理与网段,让"服务端能出网"这件事本身受控。云环境的元数据接口要强制使用带会话要求的版本(令牌式访问),把"一个 GET 就拿凭证"的老路堵死。
两类冒用的防御哲学一致:让"身份/位置"无法被静态冒用。CSRF 侧四件套——CSRF 令牌、SameSite、自定义头要求、敏感操作重认证;SSRF 侧四件套——目标白名单、解析后校验、禁重定向、出网代理隔离。把它们写进对应功能的验收清单:任何状态变更接口上线前过 CSRF 四件套,任何服务端取数功能上线前过 SSRF 四件套。
CSRF 的检测信号:来自外部页面的跳转后紧跟状态变更请求(引用头缺失或异常)、同一用户在短时间内对同一接口的高频变更(攻击者在批量试探)。SSRF 的检测更直接:服务端出网请求的目的地出现内网段、链路本地地址、元数据地址——这些本不该出现在业务出网清单里,出现即高危。给出网流量做一张"合法目的地基线",偏离基线的请求直接告警,这条规则同时覆盖 SSRF 与后续回合的横向移动渗出。
冒用类攻击的统一解法一句话:让对方必须证明"来路",而不只是"带了钥匙"。Cookie 证明的是浏览器,令牌与白名单证明的才是意图。