项目讲解之常见安全漏洞

项目,讲解,常见,安全漏洞 · 浏览次数 : 284

小编点评

**代码案例分析** * **分页查询排序参数RuoYi 项目中代码案例** * 攻击者可以通过构造分页查询请求传入 orderByColumn 参数为 1; DROP TABLE users; * 在处理文件上传下载等操作时,对路径参数要进行校验,防止目录遍历漏洞。 * **SQL注入** * SQL 参数要进行转义。 * 在拼接 SQL 语句时,对用户传入的不可控参数要进行转义,防止 SQL 注入。 * **代码安全** * 不要盲目相信用户传入的参数。 * 对 SQL 语句要谨慎拼接。 * 对路径要严谨校验。 * 在处理文件上传下载等操作时,对路径参数要进行校验,防止目录遍历漏洞。 * 定期代码审计可以尽早发现并修复项目漏洞。 **总结** * 在代码编写过程中,我们应该关注用户输入的安全性和 SQL 注入的预防。 * 对用户传入的参数要保持警惕,对 SQL 语句要谨慎拼接,对路径要严谨校验。 * 定期代码审计可以尽早发现并修复项目漏洞。

正文

本文是从开源项目 RuoYi 的提交记录文字描述中根据关键字漏洞|安全|阻止筛选而来。旨在为大家介绍日常项目开发中需要注意的一些安全问题以及如何解决。

项目安全是每个开发人员都需要重点关注的问题。如果项目漏洞太多,很容易遭受黑客攻击与用户信息泄露的风险。本文将结合3个典型案例,解释常见的安全漏洞及修复方案,帮助大家在项目开发中进一步提高安全意识。

一、重置用户密码

RuoYi 项目中有一个重置用户密码的接口,在提交记录 dd37524b 之前的代码如下:

@Log(title = "重置密码", businessType = BusinessType.UPDATE)
@PostMapping("/resetPwd")
@ResponseBody
public AjaxResult resetPwd(SysUser user)
{
    user.setSalt(ShiroUtils.randomSalt());
    user.setPassword(passwordService.encryptPassword(user.getLoginName(), 
                          user.getPassword(), user.getSalt()));
    int rows = userService.resetUserPwd(user);
    if (rows > 0)
    {
        setSysUser(userService.selectUserById(user.getUserId()));
        return success();
    }
    return error();
}

可以看出该接口会读取传入的用户信息,重置完用户密码后,会根据传入的 userId 更新数据库以及缓存。

这里有一个非常严重的安全问题就是盲目相信传入的用户信息,如果攻击人员通过接口构造请求,并且在传入的 user 参数中设置 userId 为其他用户的 userId,那么这个接口就会导致某些用户的密码被重置因而被攻击人员掌握。

1.1 攻击流程

假如攻击人员掌握了其他用户的 userId 以及登录账号名

  1. 构造重置密码请求
  2. 将 userId 设置未其他用户的 userId
  3. 服务端根据传入的 userId 修改用户密码
  4. 使用新的用户账号以及重置后的密码进行登录
  5. 攻击成功

1.2 如何解决

在记录 dd37524b 提交之后,代码更新如下:

@Log(title = "重置密码", businessType = BusinessType.UPDATE)
@PostMapping("/resetPwd")
@ResponseBody
public AjaxResult resetPwd(String oldPassword, String newPassword)
{
    SysUser user = getSysUser();
    if (StringUtils.isNotEmpty(newPassword)
                    && passwordService.matches(user, oldPassword))
    {
        user.setSalt(ShiroUtils.randomSalt());
        user.setPassword(passwordService.encryptPassword(
                    user.getLoginName(), newPassword, user.getSalt()));
        if (userService.resetUserPwd(user) > 0)
        {
            setSysUser(userService.selectUserById(user.getUserId()));
            return success();
        }
        return error();
    }
    else
    {
        return error("修改密码失败,旧密码错误");
    }
}

解决方法其实很简单,不要盲目相信用户传入的参数,通过登录状态获取当前登录用户的userId。如上代码通过 getSysUser() 方法获取当前登录用户的 userId 后,再根据 userId 重置密码。

二、文件下载

文件下载作为 web 开发中,每个项目都会遇到的功能,相信对大家而言都不陌生。RuoYi 在提交记录 18f6366f 之前的下载文件逻辑如下:

@GetMapping("common/download")
public void fileDownload(String fileName, Boolean delete, HttpServletResponse response, HttpServletRequest request)
{
    try
    {
        if (!FileUtils.isValidFilename(fileName))
        {
            throw new Exception(StringUtils.format(
                      "文件名称({})非法,不允许下载。 ", fileName));
        }
        String realFileName = System.currentTimeMillis() + fileName.substring(fileName.indexOf("_") + 1);
        String filePath = Global.getDownloadPath() + fileName;

        response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
        FileUtils.setAttachmentResponseHeader(response, realFileName);

        FileUtils.writeBytes(filePath, response.getOutputStream());
        if (delete)
        {
            FileUtils.deleteFile(filePath);
        }
    }
    catch (Exception e)
    {
        log.error("下载文件失败", e);
    }
}

public class FileUtils
{
    public static String FILENAME_PATTERN = 
                  "[a-zA-Z0-9_\\-\\|\\.\\u4e00-\\u9fa5]+";
    public static boolean isValidFilename(String filename)
    {
        return filename.matches(FILENAME_PATTERN);
    }
}

可以看到代码中在下载文件时,会判断文件名称是否合法,如果不合法会提示 文件名称({})非法,不允许下载。 的字样。咋一看,好像没什么问题,博主公司项目中下载文件也有这种类似代码。传入下载文件名称,然后再指定目录中找到要下载的文件后,通过流回写给客户端。

既然如此,那我们再看一下提交记录 18f6366f 的描述信息,

不看不知道,一看吓一跳,原来再这个提交之前,项目中存在任意文件下载漏洞,这里博主给大家讲解一下为什么会存在任意文件下载漏洞。

2.1 攻击流程

假如下载目录为 /data/upload/

  1. 构造下载文件请求
  2. 设置下载文件名称为:../../home/重要文件.txt
  3. 服务端将文件名与下载目录进行拼接,获取实际下载文件的完整路径为 /data/upload/../../home/重要文件.txt
  4. 由于下载文件包含 .. 字符,会执行上跳目录的逻辑
  5. 上跳目录逻辑执行完毕,实际下载文件为 /home/重要文件.txt
  6. 攻击成功

2.2 如何解决

我们看一下提交记录 18f6366f 主要干了什么,代码如下:

@GetMapping("common/download")
public void fileDownload(String fileName, Boolean delete, HttpServletResponse response, HttpServletRequest request)
{
    try
    {
        if (!FileUtils.checkAllowDownload(fileName))
        {
            throw new Exception(StringUtils.format(
                      "文件名称({})非法,不允许下载。 ", fileName));
        }
        String realFileName = System.currentTimeMillis() + fileName.substring(fileName.indexOf("_") + 1);
        String filePath = Global.getDownloadPath() + fileName;

        response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
        FileUtils.setAttachmentResponseHeader(response, realFileName);
        FileUtils.writeBytes(filePath, response.getOutputStream());
        if (delete)
        {
            FileUtils.deleteFile(filePath);
        }
    }
    catch (Exception e)
    {
        log.error("下载文件失败", e);
    }
}

public class FileUtils
{
    /**
     * 检查文件是否可下载
     * 
     * @param resource 需要下载的文件
     * @return true 正常 false 非法
     */
    public static boolean checkAllowDownload(String resource)
    {
        // 禁止目录上跳级别
        if (StringUtils.contains(resource, ".."))
        {
            return false;
        }

        // 检查允许下载的文件规则
        if (ArrayUtils.contains(MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION,
                            FileTypeUtils.getFileType(resource)))
        {
            return true;
        }

        // 不在允许下载的文件规则
        return false;
    }
}
...
public static final String[] DEFAULT_ALLOWED_EXTENSION = {
        // 图片
        "bmp", "gif", "jpg", "jpeg", "png",
        // word excel powerpoint
        "doc", "docx", "xls", "xlsx", "ppt", "pptx", "html", "htm", "txt",
        // 压缩文件
        "rar", "zip", "gz", "bz2",
        // 视频格式
        "mp4", "avi", "rmvb",
        // pdf
        "pdf" };
...
public class FileTypeUtils
{
    /**
     * 获取文件类型
     * <p>
     * 例如: ruoyi.txt, 返回: txt
     *
     * @param fileName 文件名
     * @return 后缀(不含".")
     */
    public static String getFileType(String fileName)
    {
        int separatorIndex = fileName.lastIndexOf(".");
        if (separatorIndex < 0)
        {
            return "";
        }
        return fileName.substring(separatorIndex + 1).toLowerCase();
    }
}

可以看到,提交记录 18f6366f 中,将下载文件时的 FileUtils.isValidFilename(fileName) 方法换成了 FileUtils.checkAllowDownload(fileName) 方法。这个方法会检查文件名称参数中是否包含 .. ,以防止目录上跳,然后再检查文件名称是否再白名单中。这样就可以避免任意文件下载漏洞。

路径遍历允许攻击者通过操纵路径的可变部分访问目录和文件的内容。在处理文件上传、下载等操作时,我们需要对路径参数进行严格校验,防止目录遍历漏洞。

三、分页查询排序参数

RuoYi 项目作为一个后台管理项目,几乎每个菜单都会用到分页查询,因此项目中封装了分页查询类 PageDomain,其他会读取客户端传入的 orderByColumn 参数。再提交记录 807b7231 之前,分页查询代码如下:

public class PageDomain
{
    ...
    public void setOrderByColumn(String orderByColumn)
    {
        this.orderByColumn = orderByColumn;
    }
    ...
}

/**
 * 设置请求分页数据
 */
public static void startPage()
{
    PageDomain pageDomain = TableSupport.buildPageRequest();
    Integer pageNum = pageDomain.getPageNum();
    Integer pageSize = pageDomain.getPageSize();
    String orderBy = pageDomain.getOrderBy();
    Boolean reasonable = pageDomain.getReasonable();
    PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
}

/**
 * 分页查询
 */
@RequiresPermissions("system:post:list")
@PostMapping("/list")
@ResponseBody
public TableDataInfo list(SysPost post)
{
    startPage();
    List<SysPost> list = postService.selectPostList(post);
    return getDataTable(list);
}

可以看到,分页查询一般会直接条用封装好的 startPage() 方法,会将 PageDomainorderByColumn 属性直接放进 PageHelper 中,最后也就会拼接在实际的 SQL 查询语句中。

3.1 攻击流程

假如攻击人员知道用户表名称为 users,

  1. 构造分页查询请求
  2. 传入 orderByColumn 参数为 1; DROP TABLE users;
  3. 实际执行的 SQL 可能为:SELECT * FROM users WHERE username = 'admin' ORDER BY 1; DROP TABLE users;
  4. 执行 SQL,DROP TABLE users; 完毕,users 表被删除
  5. 攻击成功

3.2 如何解决

再提交记录 807b7231 之后,针对排序参数做了转义处理,最新代码如下,

public class PageDomain
{
    ...
    public void setOrderByColumn(String orderByColumn)
    {
        this.orderByColumn = SqlUtil.escapeSql(orderByColumn);
    }
}

/**
 * sql操作工具类
 * 
 * @author ruoyi
 */
public class SqlUtil
{
    /**
     * 仅支持字母、数字、下划线、空格、逗号、小数点(支持多个字段排序)
     */
    public static String SQL_PATTERN = "[a-zA-Z0-9_\\ \\,\\.]+";

    /**
     * 检查字符,防止注入绕过
     */
    public static String escapeOrderBySql(String value)
    {
        if (StringUtils.isNotEmpty(value) && !isValidOrderBySql(value))
        {
            throw new UtilException("参数不符合规范,不能进行查询");
        }
        return value;
    }

    /**
     * 验证 order by 语法是否符合规范
     */
    public static boolean isValidOrderBySql(String value)
    {
        return value.matches(SQL_PATTERN);
    }
    ...
}

可以看到对于 order by 语句后可以拼接的字符串做了正则匹配,仅支持字母、数字、下划线、空格、逗号、小数点(支持多个字段排序)。以此可以避免 order by 后面拼接其他非法字符,例如 drop|if()|union 等等,因而可以避免 order by 注入问题。

SQL 注入是 Web 应用中最常见也是最严重的漏洞之一。它允许攻击者通过将SQL命令插入到 Web 表单提交中实现,数据库中执行非法 SQL 命令。
永远不要信任用户的输入,特别是在拼接SQL语句时。我们应该对用户传入的不可控参数进行过滤。

四、总结

通过这三个 RuoYi 项目中的代码案例,我们可以总结出项目开发中需要注意的几点:

  1. 不要盲目相信用户传入的参数。无论是修改密码还是文件下载,都不应该直接使用用户传入的参数构造 SQL 语句或拼接路径,这会导致 SQL 注入及路径遍历等安全漏洞。我们应该根据实际业务获取真实的用户 ID 或其他参数,然后再进行操作。
  2. SQL 参数要进行转义。在拼接 SQL 语句时,对用户传入的不可控参数一定要进行转义,防止 SQL 注入。
  3. 路径要进行校验。在处理文件上传下载等操作时,对路径参数要进行校验,防止目录遍历漏洞。例如判断路径中是否包含 .. 字符。
  4. 接口要设置权限。对一些敏感接口,例如重置密码,我们需要设置对应的权限,避免用户越权访问。
  5. 记录提交信息。在记录提交信息时,最好详细描述本次提交的内容,例如修复的漏洞或新增的功能。这在后续代码审计或回顾项目提交历史时会很有帮助。
  6. 定期代码审计。作为项目维护人员,我们需要定期进行代码审计,找出项目中可能存在的漏洞,并及时修复。这可以最大限度地保证项目代码的安全性与健壮性。

综上,写代码不仅仅是完成需求这么简单。我们还需要在各个细节上多加注意,对用户传入的参数要保持警惕,对 SQL 语句要谨慎拼接,对路径要严谨校验。定期代码审计可以尽早发现并修复项目漏洞,给用户更安全可靠的产品。希望通过这几个案例,可以提醒大家在代码编写过程中进一步加强安全意识。

到此本文讲解完毕,感谢大家阅读,感兴趣的朋友可以点赞加关注,你的支持将是我的更新动力😘。

公众号【waynblog】每周更新博主最新技术文章,欢迎大家关注

与项目讲解之常见安全漏洞相似的内容:

项目讲解之常见安全漏洞

本文是从开源项目 RuoYi 的提交记录文字描述中根据关键字漏洞|安全|阻止筛选而来。旨在为大家介绍日常项目开发中需要注意的一些安全问题以及如何解决。 项目安全是每个开发人员都需要重点关注的问题。如果项目漏洞太多,很容易遭受黑客攻击与用户信息泄露的风险。本文将结合3个典型案例,解释常见的安全漏洞及修

如何有效的解决代码的圈复杂度

不管小型公司还是大型互联网公司,很多项目债台高筑,新功能开发困难。其中一个很大的原因就是代码复杂,可读性差。那复杂度有没有一个明确的衡量标准,我们又如何去解决代码的圈复杂度呢?本篇文章将详细讲解圈复杂度的计算方式以及常用的解决方法。

项目讲解之火爆全网的开源后台管理系统RuoYi

博主是在2018年中就接触了 RuoYi 项目 这个项目,对于当时国内的开源后台管理系统来说,RuoYi 算是一个完成度较高,易读易懂、界面简洁美观的前后端不分离项目。 对于当时刚入行还在写 jsp 模板的博主来说,RuoYi 项目在后台基础功能、模块划分、易用性和页面美观度上,对比同期用 Java

MongoDB从入门到实战之.NET Core使用MongoDB开发ToDoList系统(1)-后端项目框架搭建

前言: 前面的四个章节我们主要讲解了MongoDB的相关基础知识,接下来我们就开始进入使用.NET7操作MongoDB开发一个ToDoList系统实战教程。本章节主要介绍的是如何快熟搭建一个简单明了的后端项目框架。 MongoDB从入门到实战的相关教程 MongoDB从入门到实战之MongoDB简介

面向状态机编程:复杂业务逻辑应对之道

在研发项目中,经常能遇到复杂的状态流转类的业务场景,比如游戏编程中NPC的跳跃、前进、转向等状态变化,电商领域订单的状态变化等。这类情况其实可以有一种优雅的实现方法:状态机。本文重点介绍有限状态机,并结合具体项目,通过状态机的应用将状态和业务逻辑解耦,便于简化复杂业务逻辑,降低理解成本。另外,重点讲解如何优雅的解决更广泛的复杂业务问题。

quarkus实战之七:使用配置

通过各种方式设置的配置项,在代码中如何获取呢?本文会详细讲解

产品代码都给你看了,可别再说不会DDD(七):实体与值对象

这是一个讲解DDD落地的文章系列,作者是《实现领域驱动设计》的译者滕云。本文章系列以一个真实的并已成功上线的软件项目——码如云(https://www.mryqr.com)为例,系统性地讲解DDD在落地实施过程中的各种典型实践,以及在面临实际业务场景时的诸多取舍。 本系列包含以下文章: DDD入门

如何在现有的Vue项目中嵌入 Blazor项目?

目前官方只提供了angular和react俩种示例,所以本教程将来讲解如何在Vue的现有项目中使用,上期已经做好了react的教材! 准备流程 Vue 项目创建流程 使用Vue创建一个Demo项目 全部选择默认No即可 然后项目名称就用demo了 npm init vue@latest cd dem

Falcon-7B大型语言模型在心理健康对话数据集上使用QLoRA进行微调

文本是参考文献[1]的中文翻译,主要讲解了Falcon-7B大型语言模型在心理健康对话数据集上使用QLoRA进行微调的过程。项目GitHub链接为https://github.com/iamarunbrahma/finetuned-qlora-falcon7b-medical,如下所示: 使用领域适

[转帖]一文读懂Redis6的--bigkeys选项源码以及redis-bigkey-online项目介绍

https://www.jianshu.com/p/9e150d72ffc9 本文分为两个部分,第一是详细讲解Redis6的--bigkeys选项相关源码是怎样实现的,第二部分为自己对--bigkeys源码的优化项目redis-bigkey-online的介绍。redis-bigkey-online