【Web安全】一次性搞懂XSS漏洞原理/检测/防御

往期文章:

【Web安全】一次性搞懂ReDOS漏洞原理/检测/防护

1 XSS 漏洞的本质

原理:当网站将用户输入的数据当作代码执行而非普通文本时,攻击者就能注入恶意脚本。

Vue 官方说明

“Vue 会自动转义 HTML 内容,防止你在模板中意外注入可执行代码。但当你使用 v-html 时,需要特别小心。”

—— Vue 安全指南

React 官方警告

“React DOM 在渲染前会转义所有字符串,防止 XSS 攻击。但使用 dangerouslySetInnerHTML 相当于直接操作 DOM,需要自行确保内容安全。”

—— React DOM 元素文档

XSS本质
数据被当作代码执行
攻击入口
防御核心
用户输入点
输出渲染点
输入验证
输出编码
框架安全特性
CSP策略

关键点

  • 浏览器无法区分开发者编写的代码和用户输入的恶意代码。
  • 漏洞发生在数据输出到 HTML 的位置,而非输入时。

示例代码

<!-- 漏洞代码:用户输入直接拼接到 HTML -->
<div>
  欢迎您,<%= request.getParameter("username") %>
</div>

<!-- 攻击者输入: -->
<script>alert('XSS!')</script>

2 XSS 漏洞类型介绍

类型存储型 XSS反射型 XSSDOM 型 XSS
特征恶意脚本存储在服务器,所有访问者触发恶意脚本在 URL 中,需诱导用户点击完全在浏览器端执行,不经过服务器
攻击步骤1. 提交恶意脚本到评论区等存储位置
2. 其他用户访问时执行
1. 构造含恶意脚本的 URL
2. 用户点击后在结果页执行
1. 恶意脚本嵌入 URL 片段
2. 浏览器解析 DOM 时执行
典型 Payload<img src=x onerror=alert(1)>';alert(1);//http://example.com#<img src=x onerror=alert(1)>

3 XSS 漏洞代码示例

3.1 存储型场景

// 留言板代码(Node.js示例)
app.post('/comment', (req, res) => {
  db.saveComment(req.body.text); // 直接存储未过滤
});

// 显示留言
app.get('/comments', (req, res) => {
  const comments = db.getComments();
  res.send(comments.map(c => `<div>${c.text}</div>`)); // 直接输出
});

框架中的触发点

<!-- Vue 危险示例 -->
<template>
  <div>
    <!-- 用户评论直接渲染 -->
    {{ unescapedComment }} 
    
    <!-- 更危险的渲染方式 -->
    <div v-html="userContent"></div>
  </div>
</template>
// React 危险示例
function CommentSection({ comments }) {
  return (
    <div>
      {comments.map(comment => (
        // 直接渲染未过滤内容
        <div key={comment.id}>{comment.text}</div>
      ))}
      
      {/* 危险操作 */}
      <div dangerouslySetInnerHTML={{ __html: userContent }} />
    </div>
  );
}

3.2 反射型场景

<!-- 搜索页面(PHP示例) -->
<p>您搜索的关键词: <?php echo $_GET['q']; ?></p>

框架中的触发点

<script>
export default {
  mounted() {
    // 从 URL 获取参数直接使用
    const searchTerm = this.$route.query.q;
    this.searchResults = fetchResults(searchTerm);
  }
}
</script>
// React 危险示例
function SearchPage() {
  const [search] = useSearchParams();
  const term = search.get('q');
  
  // 直接渲染 URL 参数
  return <h2>搜索: {term}</h2>;
}

3.3 DOM 型场景

// 动态加载URL片段
window.onload = () => {
  const userToken = location.hash.substring(1);
  document.write(`您的token: ${userToken}`); // 危险操作!
};

框架中的触发点

<script>
export default {
  methods: {
    loadContent() {
      // 直接操作 DOM
      document.getElementById('preview').innerHTML = this.userContent;
    }
  }
}
</script>
// React 危险示例
function UserProfile() {
  useEffect(() => {
    // 直接设置 innerHTML
    document.querySelector('.bio').innerHTML = userBio;
  }, []);
  
  return <div className="bio"></div>;
}

4 框架中的 XSS 表现与防御

4.1 Vue 中的安全实践

不安全的动态属性绑定

<template>
  <!-- 危险:用户控制属性名 -->
  <div :[userKey]="userValue"></div>
  
  <!-- 危险:用户控制事件名 -->
  <button @[userEvent]="handleClick">点击</button>
</template>

<script>
export default {
  data() {
    return {
      userKey: 'onmouseover',  // 攻击者可注入事件
      userValue: 'alert(1)',   // 可执行代码
      userEvent: 'onclick'     // 可被替换为恶意事件
    }
  }
}
</script>

修复方案

<template>
  <!-- 安全:使用静态属性名 -->
  <div :data-custom="sanitizedValue"></div>
</template>

<script>
import DOMPurify from 'dompurify';

export default {
  computed: {
    sanitizedValue() {
      // 严格限制允许的键名
      const allowedKeys = ['data-id', 'data-role'];
      return allowedKeys.includes(this.userKey) 
        ? DOMPurify.sanitize(this.userValue)
        : '';
    }
  }
}
</script>

不安全的模板插槽

<template>
  <!-- 危险:用户控制插槽内容 -->
  <component :is="userComponent">
    <template v-slot:default>
      {{ userContent }}
    </template>
  </component>
</template>

<script>
export default {
  data() {
    return {
      userComponent: 'div',
      userContent: '<img src=x onerror=alert(1)>'
    }
  }
}
</script>

修复方案

<template>
  <!-- 安全:强制使用作用域插槽 -->
  <component :is="safeComponent">
    <template v-slot:default="{ sanitize }">
      {{ sanitize(userContent) }}
    </template>
  </component>
</template>

<script>
import { markRaw } from 'vue';
import SafeWrapper from './SafeWrapper.vue';

export default {
  computed: {
    safeComponent() {
      // 仅允许安全组件
      const allowed = ['div', 'span', SafeWrapper];
      return allowed.includes(this.userComponent) 
        ? markRaw(this.userComponent)
        : markRaw(SafeWrapper);
    }
  }
}
</script>

Vue 官方推荐方案

安全模式

<template>
  <!-- 安全:自动转义 -->
  <p>{{ userContent }}</p>
  
  <!-- 安全:属性绑定自动转义 -->
  <a :href="safeUrl">链接</a>
</template>

危险场景处理

<template>
  <!-- 必须使用 v-html 时 -->
  <div v-html="sanitizedHtml"></div>
</template>

<script>
import DOMPurify from 'dompurify';

export default {
  computed: {
    sanitizedHtml() {
      return DOMPurify.sanitize(this.rawHtml, {
        ALLOWED_TAGS: ['b', 'i', 'em', 'strong'],
        FORBID_ATTR: ['style', 'on*']
      });
    }
  }
}
</script>

动态组件安全

<script>
const safeComponents = ['BaseButton', 'BaseIcon'];

export default {
  computed: {
    safeComponent() {
      return safeComponents.includes(this.userComponent) 
        ? this.userComponent 
        : 'FallbackComponent';
    }
  }
}
</script>

Vue 官方建议

  1. 避免使用 v-html,优先使用插值表达式
  2. 警惕 SSR 中的 Hydration 不匹配风险
  3. 对非受信内容使用 DOMPurify 清理

4.2 React 中的安全实践

不安全的动态标签名

// 危险:用户控制标签名
function DynamicTag({ tagName, content }) {
  const Tag = tagName || 'div';
  return <Tag>{content}</Tag>;
}

// 攻击者使用:<DynamicTag tagName="img" content="x onerror=alert(1)" />

修复方案

// 安全:白名单限制标签
import { isValidElementType } from 'react-is';

function SafeDynamicTag({ tagName, content }) {
  const validTags = ['div', 'span', 'p', 'section'];
  const Tag = validTags.includes(tagName) ? tagName : 'div';
  
  return <Tag>{content}</Tag>;
}

// 或者使用安全包装
function SanitizedTag({ tagName, ...props }) {
  const Element = tagName;
  
  // 防止非标准标签
  if (!isValidElementType(Element)) {
    return <div {...props} />;
  }
  
  // 过滤危险属性
  const safeProps = Object.keys(props).reduce((acc, key) => {
    if (!key.startsWith('on') && key !== 'dangerouslySetInnerHTML') {
      acc[key] = props[key];
    }
    return acc;
  }, {});
  
  return <Element {...safeProps} />;
}

不安全的 ref 使用

// 危险:通过 ref 直接操作 DOM
function UnsafeComponent() {
  const divRef = useRef(null);
  
  useEffect(() => {
    // 攻击者可控制外部输入
    divRef.current.innerHTML = externalInput; 
  }, []);
  
  return <div ref={divRef} />;
}

修复方案

// 安全:使用受控组件
function SafeComponent() {
  const [safeContent, setSafeContent] = useState('');
  
  useEffect(() => {
    // 使用 DOMPurify 清理
    import('dompurify').then(DOMPurify => {
      setSafeContent(DOMPurify.sanitize(externalInput));
    });
  }, []);
  
  return <div>{safeContent}</div>;
}

// 或使用 React 受控方式
function RefSanitizer() {
  const divRef = useRef(null);
  
  useEffect(() => {
    if (divRef.current) {
      // 使用 textContent 替代 innerHTML
      divRef.current.textContent = externalInput;
    }
  }, []);
  
  return <div ref={divRef} />;
}

不安全的第三方库集成

// 危险:直接渲染 Markdown 不处理
import ReactMarkdown from 'react-markdown';

function MarkdownViewer({ content }) {
  return <ReactMarkdown>{content}</ReactMarkdown>;
}

// 攻击者输入:![x](javascript:alert(1))

修复方案

// 安全:配置渲染规则
import ReactMarkdown from 'react-markdown';
import rehypeSanitize from 'rehype-sanitize';

function SafeMarkdown({ content }) {
  return (
    <ReactMarkdown
      rehypePlugins={[rehypeSanitize]}
      components={{
        a: ({ node, ...props }) => (
          <a {...props} rel="noopener noreferrer" target="_blank" />
        ),
        img: ({ node, ...props }) => (
          <img {...props} onError={(e) => e.target.style.display='none'} />
        )
      }}
    >
      {content}
    </ReactMarkdown>
  );
}

React 官方推荐方案

安全渲染

// 安全:自动转义
function SafeComponent({ text }) {
  return <div>{text}</div>;
}

// 安全:URL 验证
function SafeLink({ url, children }) {
  const isValid = url.startsWith('https://') || url.startsWith('/');
  
  return isValid ? (
    <a href={url} rel="noopener noreferrer">{children}</a>
  ) : (
    <span>{children}</span>
  );
}

危险内容处理

import DOMPurify from 'dompurify';

function SanitizedHTML({ html }) {
  const clean = DOMPurify.sanitize(html, {
    FORBID_TAGS: ['style', 'script'],
    FORBID_ATTR: ['onclick', 'onerror']
  });
  
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

安全数据流

// 使用受控组件防止注入
function ContactForm() {
  const [message, setMessage] = useState('');
  
  return (
    <textarea 
      value={message}
      onChange={(e) => setMessage(e.target.value)}
    />
  );
}

React 官方建议

  1. 所有渲染内容默认转义,除非使用 dangerouslySetInnerHTML
  2. 使用 JSX 语法防止注入攻击
  3. 对富文本内容使用 sanitization 库

4.3 JQuery 安全实践

// 危险操作
$('#output').html(userInput);

// 安全方案1:使用text()
$('#output').text(userInput);

// 安全方案2:过滤后使用
const clean = DOMPurify.sanitize(userInput);
$('#output').html(clean);

5 XSS 检测方法

5.1 人工检测指南

// 常用测试 Payload 清单
const payloads = [
  '<script>alert(1)</script>',      // 基础检测
  '<img src=x onerror=alert(1)>',   // 绕过基础过滤
  'javascript:alert(1)',            // 测试URL上下文
  '\'";alert(1)//',                 // 测试JS字符串中断
  '%26%23x3c;script%26%23x3e;'      // HTML实体编码绕过
];

// 检测步骤:
1. 在输入框依次提交payloads
2. 检查页面是否弹窗/执行脚本
3. 查看HTML源码确认特殊字符是否转义

5.2 自动化工具使用

BurpSuite (专业版)检测流程

  1. 配置浏览器代理
  2. 正常浏览网站抓包HTTP请求
  3. 右键点击请求 → “Do active scan”
  4. 查看报告中的"Cross-site Scripting"条目

其他工具推荐

  • XSS Hunter(自动捕获漏洞)
  • Retire.js(检测JS依赖库漏洞)

6 XSS 防御代码

6.1 内容安全策略 (CSP)

Vue 、React 均适用

<!-- 在 index.html 中添加 CSP-->
<meta http-equiv="Content-Security-Policy" 
      content="default-src 'self'; 
               script-src 'self' 'unsafe-eval' https://trusted.cdn.com; 
               style-src 'self' 'unsafe-inline'; 
               img-src * data:; 
               connect-src 'self'; 
               object-src 'none';">

6.2 服务端防御

Node.js 示例

// HTML实体编码函数
function escapeHTML(str) {
  return str.replace(/[&<>"']/g, char => 
    ({
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&#39;'
    }[char])
  );
}

// Express中间件
app.use((req, res, next) => {
  // 对所有输出数据编码
  res.locals.escape = escapeHTML;
  next();
});

// 模板中使用
<p><%= escape(userContent) %></p>

6.3 中间件配置

Nginx 配置

add_header Content-Security-Policy "default-src 'self'; 
  script-src 'self'; 
  style-src 'self' 'unsafe-inline'; 
  img-src * 'self':; 
  font-src 'self'; 
  connect-src 'self'; 
  object-src 'none'; 
  frame-ancestors 'none';";

6.4 服务端 Cookie 安全设置

// Java Servlet 示例
@Component
public class CookieSecurityFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
        HttpServletResponseWrapper wrapper = new HttpServletResponseWrapper((HttpServletResponse) res) {
            @Override
            public void addCookie(Cookie cookie) {
                cookie.setSecure(true);      // 仅 HTTPS 传输
                cookie.setHttpOnly(true);    // 防止 XSS
                cookie.setSameSite("Strict"); // 禁止跨站发送
                cookie.setPath("/");         // 路径范围
                cookie.setMaxAge(3600);      // 有效期 1 小时
                super.addCookie(cookie);
            }
        };
        chain.doFilter(req, wrapper);
    }
}

// Spring Security 示例
@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .sessionManagement(session -> session
                .sessionCookie(cookie -> cookie
                    .name("MY_SESSION")     // 自定义 Session Cookie 名称
                    .secure(true)           // 仅 HTTPS
                    .httpOnly(true)         // 防止 XSS
                    .sameSite(SameSite.CookieSameSite.LAX)  // 跨站限制
                )
            )
            ... // 其他配置
            );
        return http.build();
    }
}

// Spring Session 示例
// 1. 配置文件
# application.yml
spring:
  session:
    cookie:
      name: SPRING_SESSION     # 自定义 Session Cookie 名称
      secure: true             # 仅 HTTPS
      http-only: true          # 防止 XSS
      same-site: lax           # 跨站限制 (Spring Boot 3 支持)
      max-age: 3600            # 有效期 1 小时
    store-type: redis          # 存储方式(可选)
        
 // 2. 代码配置
@Configuration
public class SessionConfig {
    @Bean
    public CookieSerializer cookieSerializer() {
        DefaultCookieSerializer serializer = new DefaultCookieSerializer();
        serializer.setCookieName("XXX_SESSION");
        serializer.setUseSecureCookie(true);
        serializer.setUseHttpOnlyCookie(true);
        serializer.setSameSite("LAX");
        return serializer;
    }
}

7 安全测试速查

  1. 输入测试:在所有输入点提交 <img src=x onerror=alert(1)>

  2. 输出检查

    • 查看页面是否弹窗
    • 按 F12 检查元素,看尖括号是否变成&lt;
  3. DOM 检测:在URL后添加 #<script>alert(1)</script> 测试前端渲染

  4. 工具扫描

    # 使用命令行工具
    xsser -u "http://example.com/search?q=test"
    
  5. 防御验证

    • 检查 HTTP 响应头是否有 Content-Security-Policy
    • 查看 Cookie 是否含 HttpOnly 属性

8 总结

核心原则:所有来自外部的数据在输出时,必须根据上下文进行转义或过滤!

一句话口诀:数据渲染前必转义,动态内容必过滤,CSP 与 Cookie 安全必配置。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

介一安全

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值