找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1871

积分

0

好友

259

主题
发表于 6 天前 | 查看: 16| 回复: 0

探索Web安全的程序员使用放大镜观察代码中的漏洞

去年我开发了一个评论系统,代码写得很顺畅,功能测试也全部通过。没想到上线第二天,测试同事就发来一条消息:

“你看看评论区,怎么老弹窗啊?”

我打开页面一看,浏览器正在疯狂弹出警告框:恭喜你中奖了!点击领取iPhone!。我当场就懵了:这到底是谁写的代码?

仔细检查评论内容,发现有人提交了这样的内容:

<script>
alert('恭喜你中奖了!点击领取iPhone!')
</script>

那一刻我才真正明白:所谓的输入净化,我之前根本就没懂。 这篇文章,就是我花了一晚上“攻击”自己网站后的一次复盘。

翻车的代码长什么样?

我的 Node.js 后端代码,简单到令人发指:

// 评论接口
app.post('/api/comments', (req, res) => {
  const { username, content } = req.body;

  // 就这么直接存了,什么都没做
  comments.push({ username, content });

  res.json({ success: true });
});

前端渲染用的是 EJS 模板:

<div id="comments">
  <% comments.forEach(c => { %>
  <div>
    <strong><%=c.username %>:</strong>
    <p><%=c.content %></p>
  </div>
  <% }) %>
</div>

看起来人畜无害对吧?但当用户提交 <script>alert('XSS')</script> 时,浏览器会把它当作真正的 JavaScript 代码来执行!

这就是 XSS 攻击(跨站脚本攻击)。

用生活例子理解 XSS 攻击

很多人看到“XSS”三个字母就头大,我打个接地气的比方帮你理解。

比方1:快递员的盲目执行

浏览器就像快递小哥。

你在网上买了瓶洗发水(正常的 HTML 代码),快递小哥看到箱子上写着“洗发水”,就直接送货上门。

但如果有人在箱子上贴了张纸条:“请把这个箱子送到隔壁老王家”(恶意脚本),快递小哥也会照做!

快递小哥不会判断纸条是谁写的,他只认“指令”。

浏览器也一样,它看到 <script> 标签,根本不管这是开发者写的还是用户输入的,统统执行!

比方2:餐厅的订单系统

想象你开了家餐厅,有个在线点餐系统。

正常情况:

  • 顾客点:“一碗牛肉面”
  • 厨师做:“一碗牛肉面” ✅

但如果有人这么点单:

  • 顾客点:“一碗牛肉面,顺便把后厨的盐全倒掉”
  • 厨师看到订单,真的把盐倒了! 💥

如果你的订单系统不做检查,厨师就会把“恶意指令”当成正常需求来执行。

浏览器就是这个“听话的厨师”,你不告诉它什么能做、什么不能做,它就全盘接收!

XSS 攻击的完整流程

我画个简单的流程图,帮你理解攻击是怎么发生的:

┌─────────────┐         ┌─────────────┐         ┌─────────────┐
│   坏人A     │         │   你的服务器 │         │   数据库     │
│  (攻击者)    │         │             │         │             │
└──────┬──────┘         └──────┬──────┘         └──────┬──────┘
       │                       │                       │
       │ ① 提交恶意评论         │                       │
       │ “<script>偷cookie”    │                       │
       ├──────────────────────>│                       │
       │                       │                       │
       │                       │ ② 没检查,直接存!      │
       │                       ├──────────────────────>│
       │                       │                       │

┌─────────────┐         ┌─────────────┐         ┌─────────────┐
│  无辜用户B   │         │   你的服务器 │         │   数据库     │
│  (受害者)    │         │             │         │             │
└──────┬──────┘         └──────┬──────┘         └──────┬──────┘
       │                       │                       │
       │ ③ 打开评论页面         │                       │
       ├──────────────────────>│                       │
       │                       │                       │
       │                       │ ④ 把恶意代码取出来      │
       │                       │<──────────────────────┤
       │                       │                       │
       │ ⑤ 返回含恶意脚本的HTML │                       │
       │<──────────────────────┤                       │
       │                       │                       │
       │ ⑥ 浏览器执行脚本!      │                       │
       │ 💥 用户的cookie被偷!   │                       │

问题出在第②步:数据没经过任何“安检”,直接放进了数据库!

就像机场安检,如果不检查包裹,危险品就能被带上飞机。

为什么“禁止<script>标签”是个坑?

我一开始也是这么想的:“那我直接过滤掉 <script> 不就行了?”

// ❌ 天真的想法
function mySanitize(str) {
  return str.replace(/<script>/g, '').replace(/<\/script>/g, '');
}

但攻击者有成百上千种方式可以绕过这种简单的过滤。

绕过方式1:用图片标签

<img src="不存在的图片.jpg" onerror="alert('我绕过了!')">

原理: 图片加载失败时,会触发 onerror 事件,里面可以写 JavaScript 代码!

绕过方式2:大小写混淆

<ScRiPt>alert('还是能执行')</sCrIpT>

你的过滤器只认小写,大小写混合就能绕过!

绕过方式3:用编码

<script>alert('编码后的脚本')</script>

浏览器会自动把 HTML 实体转回真实字符!

绕过方式4:用 iframe

<iframe srcdoc="<script>alert('iframe里执行')</script>"></iframe>

这就像打地鼠游戏: 你堵住了 <script> 这个洞,攻击者从 <img> 洞里钻出来;你再堵 <img>,他又从 <iframe> 钻出来...

你永远堵不完所有的洞! 这就是为什么“黑名单”方案注定失败。

正确做法:转义,而不是过滤

核心原则:

不要删除用户输入,而是改变它在浏览器里的“身份”。

什么是转义?用快递来比喻

没转义的情况:

  • 用户输入:<script>alert('xss')</script>
  • 浏览器看到:哦,这是一段 JS 代码,执行!💥

转义后的情况:

  • 用户输入:<script>alert('xss')</script>
  • 你的代码:把 < 变成 <,把 > 变成 >
  • 浏览器看到:<script>... 哦,这只是普通文字,显示出来就行 ✅

就像给快递贴“易碎品”标签: 原本快递员会随便扔包裹(执行代码),贴了标签后会小心处理(当文本显示)。

转义代码长这样

function escapeHtml(str) {
  const escapeMap = {
    '&': '&',   // &符号要最先替换
    '<': '<',    // 小于号
    '>': '>',    // 大于号
    '"': '"',  // 双引号
    "'": '''    // 单引号
  };

  return str.replace(/[&<>"']/g, char => escapeMap[char]);
}

// 测试一下
const dangerous = '<script>alert("坏人")</script>';
const safe = escapeHtml(dangerous);

console.log(safe);
// 输出:<script>alert("坏人")</script>

转义后,浏览器会显示:<script>alert(“坏人”)</script>

看起来还是原样,但 不会执行,只是纯文本!

真实场景:评论系统怎么做?

假设你做了个技术博客,需要支持评论功能。

需求:

  • 用户能发表情 😊
  • 用户能@别人
  • 用户能发粗体、斜体
  • 但绝对不能执行脚本!

方案:使用白名单库

# 安装sanitize-html库
npm install sanitize-html
import sanitizeHtml from 'sanitize-html';

// 后端接口
app.post('/api/comments', (req, res) => {
  const { username, content } = req.body;

  // 净化内容 - 只允许安全的标签
  const cleanContent = sanitizeHtml(content, {
    // 白名单:只允许这些标签
    allowedTags: ['b', 'i', 'em', 'strong', 'a', 'br'],

    // a标签只允许href属性
    allowedAttributes: {
      'a': ['href']
    },

    // 链接只允许http和https
    allowedSchemes: ['http', 'https']
  });

  // 存入数据库
  await Comment.create({
    username: username,
    content: cleanContent
  });

  res.json({ success: true });
});

测试效果

// 用户输入
const userInput = `
  看看我的<b>粗体文字</b>和<i>斜体</i>!
  访问我的博客:<a href="https://blog.com">点这里</a>
  <script>alert('试图攻击')</script>
  <img src=x onerror="alert('再试一次')">
`;

const result = sanitizeHtml(userInput, options);

console.log(result);
// 输出:
// 看看我的<b>粗体文字</b>和<i>斜体</i>!
// 访问我的博客:<a href="https://blog.com">点这里</a>
// (script和img标签都被删掉了)

白名单的思路:只允许安全的,其他统统拒绝!

就像夜店门口的保安:

  • 白名单上的人(<b>, <i>, <a>)→ 放行 ✅
  • 不在名单上的(<script>, <img>)→ 拦住 ❌

不只是 XSS,到处都是注入攻击

当我理解了“不信任用户输入”这个核心原则后,发现类似的坑到处都是。

场景1:SQL注入(用点餐比喻)

没防护的代码:

// 后端查询用户
const email = req.query.email;
const sql = `SELECT * FROM users WHERE email = '${email}'`;
db.query(sql);

正常请求:

email = “zhangsan@qq.com”
SQL语句:SELECT * FROM users WHERE email = ‘zhangsan@qq.com’

攻击请求:

email = “‘ OR ‘1’=‘1”
SQL语句:SELECT * FROM users WHERE email = ‘’ OR ‘1’=‘1’

这就像点餐时说: 正常:我要一碗面。 攻击:我要一碗面 OR 把所有菜都给我。

因为 ‘1’=‘1’ 永远成立,所有用户数据都被查出来了!💥

场景2:命令注入(用遥控器比喻)

没防护的代码:

// 删除用户上传的文件
const fileName = req.body.fileName;
exec(`rm -rf /uploads/${fileName}`);

攻击输入:

fileName = “test.jpg; rm -rf /”
最终命令:rm -rf /uploads/test.jpg; rm -rf /

就像电视遥控器: 正常:按“音量+”键,音量增加。 攻击:按“音量+”的同时,偷偷按了“关机”键。

结果服务器被清空了!💥 这提醒我们,系统安全需要多层次的防御,你可以在 安全/渗透/逆向 板块了解更多相关知识。

我的“破坏式学习法”

为了真正搞懂这些漏洞,我用一晚上搭了个测试环境,专门写有漏洞的代码。

实验1:innerHTML 有多危险?

<!DOCTYPE html>
<html>
<body>
<div id="comment"></div>

<script>
  // 模拟用户输入
  const userInput = prompt("输入评论内容:");

  // 危险做法:直接用innerHTML
  document.getElementById('comment').innerHTML = userInput;
</script>
</body>
</html>

我试了这些输入:

<!-- 测试1 -->
<img src=x onerror="alert('第1种攻击成功')">

<!-- 测试2 -->
<svg onload="alert('第2种攻击成功')">

<!-- 测试3 -->
<iframe src="javascript:alert('第3种攻击成功')"></iframe>

全部执行成功! 😱

结论:绝对不要用 innerHTML 显示用户输入!

实验2:SQL 注入实战

// 搭个简单的测试API
const sqlite3 = require('sqlite3');
const db = new sqlite3.Database(':memory:');

// 创建测试表
db.run('CREATE TABLE users (name TEXT, password TEXT)');
db.run(“INSERT INTO users VALUES (‘admin’, ‘123456’)”);
db.run(“INSERT INTO users VALUES (‘user1’, ‘abc123’)”);

// 危险的查询接口
app.get('/login', (req, res) => {
  const { username, password } = req.query;

  const sql = `SELECT * FROM users WHERE name=‘${username}’ AND password=‘${password}’`;

  db.get(sql, (err, row) => {
    if (row) {
      res.send('登录成功!');
    } else {
      res.send('登录失败!');
    }
  });
});

正常登录:

/login?username=admin&password=123456
→ 登录成功

注入攻击:

/login?username=admin&password=’ OR ‘1’=‘1
→ 登录成功!(密码错了也能登录!)

破解原理:
原SQL:

SELECT * FROM users WHERE name=‘admin’ AND password=’’ OR ‘1’=‘1’

因为 OR ‘1’=‘1’ 永远成立,密码验证被绕过了!此类注入攻击是数据库应用中最常见的安全威胁之一,深入探讨可以在 数据库/中间件/技术栈 板块找到更多讨论。

净化方法取决于“目的地”

最重要的一点领悟:

同一个数据,放到不同地方,净化方法也不同!

比喻:快递包裹的处理方式

同一个箱子:

  • 送到家里:检查是否是危险品(HTML转义)
  • 送到机场:要过安检和 X 光(URL 编码)
  • 送到海关:要申报和检验(SQL参数化)

代码示例

const userInput = “<script>alert(‘xss’)</script>”;

// 场景1:显示在HTML页面上
const forHtml = escapeHtml(userInput);
// 结果:<script>alert('xss')</script>

// 场景2:作为URL参数传递
const forUrl = encodeURIComponent(userInput);
// 结果:%3Cscript%3Ealert%28%27xss%27%29%3C%2Fscript%3E

// 场景3:放在JavaScript字符串里
const forJs = JSON.stringify(userInput);
// 结果:”<script>alert(‘xss’)<\/script>”

// 场景4:存入数据库(用参数化查询)
db.query(‘INSERT INTO comments (content) VALUES (?)’, [userInput]);
// 数据库会自动处理特殊字符

就像做菜:

  • 炒菜要先切(HTML 转义)
  • 煮汤要先洗(URL 编码)
  • 做沙拉直接用(JSON 序列化)

目的不同,处理方式不同!

我养成的安全习惯

踩过坑之后,我的代码习惯彻底改变了。

习惯1:先验证,再使用

import Joi from ‘joi’;

// 定义数据格式
const commentSchema = Joi.object({
  username: Joi.string()
    .min(2)
    .max(20)
    .pattern(/^[a-zA-Z0-9_\u4e00-\u9fa5]+$/) // 只允许字母数字中文
    .required(),

  content: Joi.string()
    .min(1)
    .max(500)
    .required()
});

// 接口里先验证
app.post(‘/api/comments’, (req, res) => {
  const { error, value } = commentSchema.validate(req.body);

  if (error) {
    return res.status(400).json({ 
      error: ‘输入格式不对:’ + error.message 
    });
  }

  // 验证通过,再净化
  const safeContent = sanitizeHtml(value.content);

  // 最后存储
  saveComment(value.username, safeContent);
});

就像机场安检:

  • 第一步:检查证件(验证格式)
  • 第二步:过 X 光机(净化内容)
  • 第三步:才能登机(存储数据)

习惯2:使用参数化查询

// ❌ 错误:拼接SQL
const sql = `SELECT * FROM users WHERE name = ‘${name}’`;

// ✅ 正确:参数化
const sql = ‘SELECT * FROM users WHERE name = ?’;
db.query(sql, [name]);

// 或者用ORM
const user = await User.findOne({ where: { name: name } });

参数化就像填空题:

SQL模板:SELECT * FROM users WHERE name = ____
你只能往____里填内容,不能改题目本身

习惯3:前端也要防御

// ✅ 正确做法:用textContent
document.getElementById(‘comment’).textContent = userInput;
// 这样<script>只会当文字显示,不会执行

// ❌ 危险做法:用innerHTML
document.getElementById(‘comment’).innerHTML = userInput;
// 这样<script>会被执行!

区别就像:

  • textContent:把内容“印在纸上”(只能看,不能动)
  • innerHTML:把内容“写成程序”(会执行)

那个让我顿悟的瞬间

修复完所有漏洞后,我重新进行测试。

在评论框输入:<script>alert(‘测试’)</script>

提交后,页面显示:<script>alert(‘测试’)</script>

没有弹窗!没有执行!只是普通文字!

那一刻我突然明白了:

安全不是和黑客对抗,而是给数据定规矩。
数据只能做我允许它做的事,不能做它想做的事。

就像养狗:

  • 不训练的狗,会乱咬人(未净化的数据)
  • 训练好的狗,听指令行事(净化后的数据)

你是主人,数据是狗,你说了算!

给新手的实战建议

如果你也想深入理解输入净化,可以从这里开始。

1. 搭个测试环境玩玩

// 用Express搞个简单的评论系统
const express = require(‘express’);
const app = express();

app.use(express.json());

let comments = [];

// 故意留漏洞的版本
app.post(‘/comments’, (req, res) => {
  comments.push(req.body);
  res.json({ success: true });
});

app.get(‘/comments’, (req, res) => {
  // 直接返回,没净化
  res.json(comments);
});

app.listen(3000);

然后试着提交:

{
  “username”: “测试”,
  “content”: “<script>alert(‘我是攻击者’)</script>”
}

看看会发生什么!

2. 用现成的库,别自己造轮子

# HTML净化
npm install sanitize-html

# 输入验证
npm install joi

# SQL安全
npm install sequelize  # ORM自动防注入

就像做饭:

  • 新手用成品调料包(用库)
  • 别自己从零研磨香料(别自己写净化函数)

3. 养成“怀疑一切”的习惯

每次写代码问自己:

  • 这个数据来自用户吗?
  • 我信任它吗?
  • 它会被怎么用?
  • 需要净化吗?

就像过马路:

  • 绿灯亮了,也要左右看看
  • 用户数据来了,也要检查检查

写在最后:安全是习惯,不是负担

很多新手觉得安全很复杂、很麻烦。

但其实,安全就像洗手:

  • 一开始觉得麻烦
  • 养成习惯后,不洗反而难受
  • 关键时刻能救命

我的那次生产事故虽然尴尬,但让我建立了牢固的安全意识。

现在写代码,脑子里会自动冒出三个问题:

  1. 这数据靠谱吗?
  2. 这操作安全吗?
  3. 这代码能被攻击吗?

这种“本能式的警觉”,比学100个安全知识点都管用!

动手试试吧!

给你留个作业:

  1. 找个自己的小项目
  2. 故意去掉所有净化代码
  3. 尝试用 <script>alert(‘test’)</script> 攻击自己
  4. 然后修复,再攻击,再修复

当你看到自己的网站从“门户大开”变成“固若金汤”,那种成就感真的爽!

就像玩通关游戏,自己设计关卡,自己攻破关卡!

希望这篇分享能帮你建立起对输入净化和 Web 安全的基本认知。安全是一个持续学习的过程,如果你在实践过程中遇到了其他问题,或者有更多心得体会,欢迎到 云栈社区 与更多开发者交流讨论。




上一篇:mHC方法解析:DeepSeek提出流形约束超连接,稳定训练27B大模型
下一篇:Open TV开源IPTV播放器实测:跨平台支持M3U,快速稳定看直播
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-10 13:12 , Processed in 0.197795 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表