🤖
AI审核中

文章解锁功能安全漏洞修复技术记录

  Java   28分钟   144浏览   1评论
AI
AI智能摘要
正在分析文章内容

一、问题背景

1.1 漏洞描述

博客系统的文章解锁功能存在一个严重的安全漏洞:用户可以通过浏览器开发者工具(F12)访问 Application -> Local Storage,手动将 blog_global_unlocked 键值设置为 true,从而绕过关注公众号输入验证码的限制机制,无需验证即可阅读完整文章内容。

1.2 漏洞影响

  • 绕过验证机制:用户无需关注公众号即可解锁文章
  • 影响内容付费/引流策略:破坏运营策略,降低公众号关注转化率
  • 安全隐患:客户端存储敏感状态信息,容易被篡改

1.3 原有实现分析

原有实现的核心逻辑:

// 客户端验证 - 可被轻易绕过
const CONFIG = {
    STORAGE_KEY: 'blog_global_unlocked'  // 本地存储键名
};

function isGlobalUnlocked() {
    return localStorage.getItem(CONFIG.STORAGE_KEY) === 'true';
}

function markGlobalUnlocked() {
    localStorage.setItem(CONFIG.STORAGE_KEY, 'true');
}

问题根源

  1. 验证逻辑完全依赖客户端存储
  2. 没有服务器端状态验证
  3. 缺乏防篡改机制

二、解决方案设计

2.1 设计目标

  1. 服务器端控制:将关键验证逻辑移至服务器端处理
  2. 不可伪造:确保客户端无法通过任何方式伪造验证状态
  3. 用户体验:保持流畅的用户体验,减少额外操作
  4. 可追踪性:能够识别和追踪用户的验证状态

2.2 技术架构

┌─────────────────┐     ┌──────────────────┐     ┌─────────────┐
│   客户端         │────▶│   服务器端API     │────▶│   Redis     │
│  (post.html)    │     │  (Controller)    │     │  (状态存储)  │
└─────────────────┘     └──────────────────┘     └─────────────┘
        │                        │
        │ 1. 请求验证码验证       │
        │───────────────────────▶│
        │                        │
        │ 2. 验证成功,返回令牌   │
        │◀───────────────────────│
        │                        │
        │ 3. 页面加载时查询状态   │
        │───────────────────────▶│
        │                        │
        │ 4. 返回解锁状态         │
        │◀───────────────────────│

2.3 核心组件

组件 职责 技术实现
ArticleUnlockService 管理用户解锁状态 Redis + 客户端ID
VerifyCodeController 提供验证API Spring Boot REST API
IndexInterceptor 设置客户端追踪Cookie HandlerInterceptor
前端脚本 与服务器通信验证 Fetch API + async/await

三、详细实现

3.1 服务器端状态管理服务

3.1.1 服务接口定义

public interface ArticleUnlockService {
    /**
     * 获取客户端唯一标识
     */
    String getClientId(HttpServletRequest request);

    /**
     * 标记用户为已解锁状态
     */
    void markAsUnlocked(String clientId);

    /**
     * 检查用户是否已解锁
     */
    boolean isUnlocked(String clientId);

    /**
     * 生成解锁令牌
     */
    String generateUnlockToken(String clientId);

    /**
     * 验证解锁令牌
     */
    boolean verifyUnlockToken(String clientId, String token);
}

3.1.2 Redis 存储设计

# 解锁状态存储
Key: article:unlock:status:{clientId}
Value: "true"
TTL: 24小时

# 解锁令牌存储
Key: article:unlock:token:{clientId}
Value: {md5_hash_token}
TTL: 30分钟(滑动过期)

3.1.3 客户端ID生成策略

采用多层识别机制,确保用户追踪的准确性:

public String getClientId(HttpServletRequest request) {
    // 1. 优先从拦截器设置的request属性获取
    Object attrClientId = request.getAttribute("clientId");
    if (attrClientId != null) {
        return attrClientId.toString();
    }

    // 2. 从Cookie获取
    Cookie[] cookies = request.getCookies();
    // ... 解析 blog_client_id

    // 3. 从请求头获取(前端传递)
    String headerClientId = request.getHeader("X-Client-Id");

    // 4. 基于IP和User-Agent生成指纹
    String clientId = generateClientId(request);
    return clientId;
}

3.2 验证API设计

3.2.1 验证码验证接口

@PostMapping("/code")
public JsonResult verifyCode(@RequestParam("code") String code, 
                             HttpServletRequest request) {
    // 1. 验证验证码
    boolean success = verifyCodeService.verifyAndConsumeCode(code, SCENE);

    if (success) {
        // 2. 获取客户端ID
        String clientId = articleUnlockService.getClientId(request);

        // 3. 在服务器端标记解锁状态
        articleUnlockService.markAsUnlocked(clientId);

        // 4. 生成解锁令牌返回给客户端
        String token = articleUnlockService.generateUnlockToken(clientId);

        return JsonResult.success("验证成功", Map.of(
            "unlocked", true,
            "token", token
        ));
    }
    return JsonResult.fail("验证码错误或已过期");
}

3.2.2 解锁状态查询接口

@GetMapping("/status")
public JsonResult checkUnlockStatus(HttpServletRequest request) {
    String clientId = articleUnlockService.getClientId(request);
    boolean isUnlocked = articleUnlockService.isUnlocked(clientId);

    Map<String, Object> result = new HashMap<>();
    result.put("unlocked", isUnlocked);

    if (isUnlocked) {
        // 重新生成令牌
        result.put("token", articleUnlockService.generateUnlockToken(clientId));
    }

    return JsonResult.success("查询成功", result);
}

3.2.3 令牌验证接口(防篡改)

@PostMapping("/token")
public JsonResult verifyUnlockToken(@RequestParam("token") String token, 
                                    HttpServletRequest request) {
    String clientId = articleUnlockService.getClientId(request);
    boolean isValid = articleUnlockService.verifyUnlockToken(clientId, token);

    if (isValid) {
        return JsonResult.success("令牌有效");
    }
    return JsonResult.fail("令牌无效或已过期");
}

3.3 客户端实现

3.3.1 核心验证逻辑

// 配置项
const CONFIG = {
    TOKEN_STORAGE_KEY: 'blog_unlock_token',
    CLIENT_ID_KEY: 'blog_client_id'
};

// 获取或生成客户端ID
function getClientId() {
    let clientId = localStorage.getItem(CONFIG.CLIENT_ID_KEY);
    if (!clientId) {
        clientId = 'cid_' + Math.random().toString(36).substring(2) + 
                   Date.now().toString(36);
        localStorage.setItem(CONFIG.CLIENT_ID_KEY, clientId);
    }
    return clientId;
}

// 从服务器检查解锁状态(唯一可信来源)
async function checkServerUnlockStatus() {
    const clientId = getClientId();
    const response = await fetch('/api/verify/status', {
        method: 'GET',
        headers: {
            'X-Client-Id': clientId
        },
        credentials: 'same-origin'
    });

    const data = await response.json();

    if (data.flag && data.data.unlocked) {
        // 保存服务器返回的令牌
        currentUnlockToken = data.data.token;
        sessionStorage.setItem(CONFIG.TOKEN_STORAGE_KEY, currentUnlockToken);
        return { unlocked: true };
    }
    return { unlocked: false };
}

3.3.2 页面初始化流程

async function initArticleLock() {
    // 向服务器查询解锁状态(唯一可信来源)
    const status = await checkServerUnlockStatus();

    if (status.unlocked) {
        // 服务器确认已解锁
        unlockArticle();
    } else {
        // 显示锁定状态
        lockArticle();
    }
}

3.4 拦截器实现

@Component
public class IndexInterceptor implements HandlerInterceptor {
    private static final String CLIENT_ID_COOKIE = "blog_client_id";
    private static final int COOKIE_MAX_AGE = 365; // 天

    @Override
    public boolean preHandle(HttpServletRequest request, 
                            HttpServletResponse response, 
                            Object handler) {
        ensureClientId(request, response);
        return true;
    }

    private void ensureClientId(HttpServletRequest request, 
                               HttpServletResponse response) {
        String clientId = null;

        // 1. 从Cookie获取
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (CLIENT_ID_COOKIE.equals(cookie.getName())) {
                    clientId = cookie.getValue();
                    break;
                }
            }
        }

        // 2. 生成新的客户端ID
        if (!StringUtils.hasText(clientId)) {
            clientId = generateClientId();
        }

        // 3. 设置到request属性
        request.setAttribute("clientId", clientId);

        // 4. 设置Cookie
        Cookie clientCookie = new Cookie(CLIENT_ID_COOKIE, clientId);
        clientCookie.setMaxAge(COOKIE_MAX_AGE * 24 * 60 * 60);
        clientCookie.setPath("/");
        clientCookie.setHttpOnly(false); // 允许JS读取
        response.addCookie(clientCookie);
    }

    private String generateClientId() {
        return "cid_" + UUID.randomUUID().toString().replace("-", "");
    }
}

四、安全机制详解

4.1 多层身份识别

系统采用多层身份识别机制,确保用户追踪的准确性:

  1. Cookie 追踪:设置长期有效的 blog_client_id Cookie
  2. 请求头传递:前端通过 X-Client-Id 请求头传递客户端ID
  3. 指纹生成:基于 IP 地址 + User-Agent 生成备用指纹

4.2 令牌机制

令牌生成公式:
token = MD5(clientId + timestamp + UUID)

验证流程:
1. 从Redis获取存储的令牌
2. 对比客户端提交的令牌
3. 验证成功后刷新过期时间(滑动过期)

4.3 防篡改设计

攻击方式 防御措施
修改 LocalStorage 服务器端不读取 LocalStorage 的权限状态
修改 Cookie Cookie仅用于识别,权限状态存储在Redis
伪造令牌 令牌存储在服务器端Redis,无法伪造
重放攻击 令牌有过期时间,且与clientId绑定
IP更换 结合Cookie和指纹多重识别

五、测试验证

5.1 正常流程测试

1. 访问文章页面
   └─▶ 显示文章前1/3内容,显示解锁提示

2. 点击解锁按钮,输入正确验证码
   └─▶ 服务器验证成功,Redis存储解锁状态
   └─▶ 返回解锁令牌
   └─▶ 客户端解锁文章显示

3. 刷新页面
   └─▶ 客户端向服务器查询状态
   └─▶ 服务器返回已解锁状态
   └─▶ 文章保持解锁状态

5.2 安全测试

测试1:LocalStorage 篡改测试

// 攻击尝试
localStorage.setItem('blog_global_unlocked', 'true');
location.reload();

// 预期结果:文章仍显示锁定状态
// 实际结果:✅ 文章保持锁定,因为服务器端没有对应记录
// 攻击尝试
document.cookie = "blog_client_id=fake_id; path=/";
location.reload();

// 预期结果:系统生成新的客户端ID或识别为未解锁
// 实际结果:✅ 系统识别为新用户,显示锁定状态

测试3:令牌伪造测试

// 攻击尝试
sessionStorage.setItem('blog_unlock_token', 'fake_token');
location.reload();

// 预期结果:令牌验证失败,显示锁定状态
// 实际结果:✅ 服务器拒绝无效令牌

六、性能优化

6.1 Redis 连接优化

  • 使用连接池管理 Redis 连接
  • 设置合理的超时时间
  • 启用 Redis 管道(Pipeline)批量操作

6.2 缓存策略

// 解锁状态缓存 24 小时
redisTemplate.opsForValue().set(
    key, 
    "true", 
    24, 
    TimeUnit.HOURS
);

// 令牌缓存 30 分钟,滑动过期
redisTemplate.opsForValue().set(
    tokenKey, 
    token, 
    30, 
    TimeUnit.MINUTES
);

6.3 前端优化

  • 使用 sessionStorage 而非 localStorage 存储令牌(更安全)
  • 异步加载验证状态,不阻塞页面渲染
  • 添加防抖机制,避免频繁请求

七、部署注意事项

7.1 Redis 配置

确保 Redis 服务可用,并配置合适的内存策略:

# redis.conf
maxmemory 256mb
maxmemory-policy allkeys-lru

7.2 Nginx 配置(如使用)

确保请求头能够正确传递到后端:

location / {
    proxy_pass http://backend;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Client-Id $http_x_client_id;
}

7.3 HTTPS 建议

生产环境建议启用 HTTPS,并设置 Cookie 的 Secure 属性:

clientCookie.setSecure(true); // HTTPS 环境下启用

八、总结

8.1 修复成果

  1. 彻底修复安全漏洞:用户无法通过修改客户端存储绕过验证
  2. 服务器端控制:所有验证逻辑由服务器端控制,确保安全性
  3. 用户体验保持:验证流程对用户透明,无需额外操作
  4. 可扩展性:架构支持后续添加更多验证方式

8.2 关键改进点

改进项 修复前 修复后
验证存储位置 LocalStorage Redis(服务器端)
验证逻辑 客户端判断 服务器端判断
防篡改能力 多层防护
用户追踪 单一Cookie Cookie + 指纹
令牌机制 MD5 + 滑动过期

8.3 后续建议

  1. 添加限流机制:防止验证码接口被暴力破解
  2. 日志审计:记录验证成功/失败日志,便于分析
  3. 异常监控:监控Redis连接状态和API响应时间
  4. 定期轮换:定期清理过期的解锁状态数据
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  1 条评论
伴我   湖南省衡阳市

博主你这样一改我还怎么通过手动设置blog_global_unlocked的值来跳过公众号验证?wugan

AI助手
召田最帅boy的小助手
🤖
我是召田最帅boy的小助手
我已经阅读了这篇文章,可以帮您:
理解文章内容 · 解答细节问题 · 分析核心观点