1. 项目概述:为什么若依框架需要一份专属的安全自查清单?
若依(RuoYi)作为国内Java开发者圈子里普及度极高的开源后台管理系统,以其“开箱即用”的特性赢得了大量青睐。无论是单体版还是微服务版,很多中小型项目甚至一些快速迭代的内部系统,都直接基于若依进行二次开发。但“快”的另一面,往往是安全细节的疏忽。我见过太多团队,拿到若依后,只关心业务功能是否跑通,UI是否美观,却很少系统地审视其默认配置下的安全隐患。直到某天被安全扫描工具打出一堆高危漏洞,或者更糟——真的出了安全事件,才手忙脚乱地开始“打补丁”。
这份清单,就是针对若依4.7.x版本(一个非常经典且仍有大量项目在使用的版本)的一次深度“体检”和“加固手术”。它聚焦于三个最典型、也最危险的漏洞类型:Shiro默认密钥导致的身份绕过与反序列化、SQL注入、以及任意文件读取。这些漏洞并非若依独有,但由于框架的默认配置和某些历史版本的代码逻辑,它们在若依项目中的存在具有相当的普遍性。我们的目标不是制造恐慌,而是提供一份清晰、可操作的自查与修复指南,让你在享受若依开发便利的同时,也能筑起一道可靠的安全防线。
2. 核心漏洞原理与风险深度解析
在动手修复之前,我们必须理解这些漏洞究竟是如何产生的,以及它们可能带来的实际危害。知其然,更要知其所以然,这样才能在后续的开发中举一反三,避免引入同类问题。
2.1 Shiro默认密钥:一把人尽皆知的“家门钥匙”
Apache Shiro是一个强大的Java安全框架,若依用它来处理登录认证、权限控制和会话管理。Shiro有一个“记住我”(RememberMe)功能,其核心是将用户的身份信息序列化后,用AES算法加密,存储在客户端的Cookie中。加解密需要一个密钥(CipherKey)。
问题的根源在于:若依在早期版本中,使用了Shiro官方示例中硬编码的默认密钥kPH+bIxk5D2deZiIxcaaaA==。这个密钥在互联网上几乎是公开的“秘密”。攻击者一旦知道你的系统使用此密钥,就可以:
- 伪造任意用户的“记住我”Cookie:直接绕过登录,以任何用户身份(包括管理员)进入系统。
- 触发反序列化攻击:如果项目中存在可利用的反序列化链依赖库(如 Commons-Collections 的老版本),攻击者可以构造恶意的序列化数据,加密后作为Cookie发送,服务器在解密反序列化时,就可能执行任意代码,完全控制服务器。
这就像你家门锁的钥匙模具被公开挂在网上,任何人都能轻易复制一把打开你的门。风险极高。
2.2 SQL注入:数据层的“万能钥匙”
SQL注入是老生常谈,但在若依的动态SQL拼接场景下依然常见。若依的MyBatis XML映射文件中,大量使用了${}进行参数拼接,而非安全的#{}预编译。
${}与#{}的本质区别:
#{}:MyBatis会将其处理为预编译语句的参数占位符?,传入的参数会被视为一个整体(字符串或数字),从根本上杜绝了SQL注入。${}:MyBatis会将其直接替换为字符串,拼接进SQL语句。如果用户输入' or '1'='1,拼接后的SQL就可能改变原意。
例如,一个模糊查询:
<!-- 危险写法 --> <select id="selectUser" parameterType="String" resultMap="UserResult"> select * from sys_user where user_name like '%${userName}%' </select>如果userName传入' or '1'='1,最终SQL变为select * from sys_user where user_name like '%' or '1'='1%',导致查询出所有用户。
风险场景:若依后台的管理模块,如用户管理、日志查询等,凡涉及动态表头、排序字段、过滤条件且使用了${}的地方,都是潜在的风险点。攻击者可以利用此漏洞窃取、篡改或删除数据库中的所有敏感数据。
2.3 任意文件读取:服务器的“后门”
任意文件读取漏洞允许攻击者通过构造特定的请求路径,读取服务器上的任意文件。在若依4.7.x的某些版本中,此漏洞主要出现在两个地方:
- 定时任务(Quartz)的调用目标字符串(invokeTarget)校验不严:在动态调用类方法时,如果传入的字符串参数未做严格过滤,攻击者可能通过
../../../这样的目录遍历(Path Traversal)手法,拼接到文件路径中,从而读取系统敏感文件,如/etc/passwd、C:\Windows\win.ini或应用的配置文件(含数据库密码)。 - 文件下载/预览接口的路径参数未校验:一些文件下载或图片预览接口,直接从请求参数中获取文件路径,如果没有校验该路径是否在允许的目录范围内,攻击者同样可以跨目录读取文件。
危害:攻击者可以读取服务器上的配置文件(获取数据库连接信息、加密密钥)、源代码、日志文件(可能包含敏感信息),甚至系统关键文件,为后续更深入的攻击(如SSH密钥窃取)铺平道路。
3. 漏洞修复与系统加固实操指南
理解了原理,我们现在开始“动手术”。以下操作均基于若依4.7.x版本,请根据你的实际代码情况进行调整。
3.1 Shiro安全加固:更换密钥与关闭风险功能
第一步:生成并替换一个全新的、强壮的Shiro密钥。绝对不要使用任何已知的默认密钥。我们可以在项目启动时,动态生成一个密钥。
- 找到Shiro配置类:通常是
ShiroConfig.java或RuoYiConfig.java中与Shiro相关的部分。 - 修改RememberMe管理器配置:定位到
cookieRememberMeManager()或rememberMeManager()方法。 - 使用随机生成的密钥:将原有的固定密钥替换为如下代码:
import org.apache.shiro.crypto.AesCipherService; import java.util.Base64; @Bean public CookieRememberMeManager rememberMeManager(){ CookieRememberMeManager cookieRememberMeManager = new CookieRememberMeManager(); // 生成一个随机的128位(16字节)AES密钥 AesCipherService cipherService = new AesCipherService(); byte[] key = cipherService.generateNewKey().getEncoded(); String base64Key = Base64.getEncoder().encodeToString(key); // 打印密钥,首次启动时记录并妥善保存!后续部署需使用同一密钥。 System.out.println("Generated Shiro RememberMe Key: " + base64Key); cookieRememberMeManager.setCipherKey(Base64.getDecoder().decode(base64Key)); // ... 其他配置 return cookieRememberMeManager; }重要提示:首次启动后,控制台会打印出新密钥。你必须将此密钥记录下来,并作为配置文件(如
application.yml)中的一个项,后续部署从配置读取,而不是每次启动都重新生成。否则所有已登录用户的“记住我”功能都会失效。
第二步:检查并关闭不必要的Shiro过滤器。在ShiroConfig的shiroFilter()方法中,检查URL过滤规则。确保像/actuator/**、/druid/**、/swagger-ui.html这类监控或调试接口,在生产环境中已被正确拦截或禁用,避免信息泄露。
第三步:升级Shiro及相关依赖。检查pom.xml,确保使用的Shiro版本至少是1.7.0以上,以规避已知的旧版本反序列化漏洞。同时,检查项目中是否存在已知的危险反序列化链依赖,如commons-collections:commons-collections的3.x老版本,考虑升级或排除。
3.2 SQL注入全面防御:MyBatis规范与全局过滤
修复SQL注入需要代码层面的细致审查和习惯养成。
1. 将${}替换为#{}(治本之策)这是最根本的解决方案。对所有MyBatis XML文件进行全局搜索$,逐一审查。
- 安全改造示例:
使用<!-- 修复后 --> <select id="selectUser" parameterType="String" resultMap="UserResult"> select * from sys_user where user_name like concat('%', #{userName}, '%') </select>concat函数或bind标签来实现模糊查询。<select id="selectUser" parameterType="String" resultMap="UserResult"> <bind name="userNamePattern" value="'%' + userName + '%'" /> select * from sys_user where user_name like #{userNamePattern} </select>
2. 必须使用${}的场景:严格的白名单校验对于动态表名、排序字段(order by)等无法使用预编译的场景,必须进行严格的输入校验。
- 创建工具类进行白名单校验:
public class SqlInjectionUtil { private static final Set<String> SAFE_SORT_FIELDS = new HashSet<>(Arrays.asList("create_time", "user_id", "user_name")); private static final Pattern VALID_FIELD_PATTERN = Pattern.compile("^[a-zA-Z0-9_]+$"); public static String checkSortField(String fieldName) { // 方案一:白名单校验(推荐) if (!SAFE_SORT_FIELDS.contains(fieldName)) { throw new IllegalArgumentException("非法的排序字段: " + fieldName); } // 方案二:正则校验(灵活性高,但需确保正则严密) if (!VALID_FIELD_PATTERN.matcher(fieldName).matches()) { throw new IllegalArgumentException("非法的排序字段格式: " + fieldName); } return fieldName; } } - 在Service层调用:在拼接SQL前,先对传入的参数进行校验。
String orderByField = SqlInjectionUtil.checkSortField(params.get("orderByColumn")); // 然后再使用 ${orderByField}
3. 启用MyBatis SQL拦截插件进行监控即使修复后,也建议添加一个拦截器,用于在开发测试阶段监控所有执行的SQL,及时发现潜在的拼接风险。
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})}) public class SqlInjectionInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler = (StatementHandler) invocation.getTarget(); BoundSql boundSql = handler.getBoundSql(); String sql = boundSql.getSql(); // 简单的正则检测,报警告日志(生产环境可关闭或只记录错误) if (sql.matches(".*\\$\\{.*\\}.*")) { log.warn("⚠️ 警告:执行的SQL中包含${}拼接,请确认是否安全。SQL: {}", sql); } // 检测可疑的SQL片段 if (sql.toLowerCase().contains(" or ") || sql.toLowerCase().contains("union select")) { log.error("🚨 高危:SQL语句中包含疑似注入关键词,请立即审查!SQL: {}", sql); } return invocation.proceed(); } } // 在MyBatis配置中注入此拦截器3.3 任意文件读取漏洞封堵:路径校验与权限控制
1. 修复定时任务(Quartz)文件读取漏洞此漏洞通常出现在JobInvokeUtil.java类中,在通过反射调用方法时,对传入的字符串参数(特别是文件路径相关参数)处理不当。
- 修复关键:在对
invokeTarget(调用目标字符串)进行解析和执行前,加入参数校验逻辑。特别是当参数表示文件路径时,必须将其规范化为绝对路径,并判断是否在允许的目录内。 - 示例加固代码:
public static void invokeMethod(...) throws Exception { // ... 原有的反射调用准备代码 // 假设 methodParams 是调用方法的参数列表 for (Object param : methodParams) { if (param instanceof String) { String strParam = (String) param; // 如果参数看起来像一个文件路径(简单判断) if (strParam.contains("..") || strParam.startsWith("/") || strParam.matches("^[A-Za-z]:\\\\.*")) { // 进行规范化并校验 Path normalizedPath = Paths.get(strParam).normalize().toAbsolutePath(); Path safeBasePath = Paths.get("/app/safe/directory").toAbsolutePath(); // 你的安全基础目录 if (!normalizedPath.startsWith(safeBasePath)) { throw new SecurityException("禁止访问安全目录之外的文件路径: " + strParam); } // 可以用校验后的安全路径替换原参数 } } } // ... 执行反射调用 }
2. 加固文件下载/预览接口所有从请求参数中接收文件路径的接口都必须加固。
- 方案一:白名单映射。不直接传递文件路径,而是传递一个文件ID或经过编码的令牌,后端通过ID从数据库查找到安全的存储路径。
- 方案二:强制校验与规范化。如果必须传路径,则:
@GetMapping("/common/download") public void download(String fileName, HttpServletResponse response) { // 1. 解码文件名(如果前端编码了) String decodedFileName = FileUtils.filenameFilter(fileName); // 自定义过滤函数,移除../等 // 2. 拼接基础目录,并规范化路径 Path baseDir = Paths.get("D:/ruoyi/uploadPath").toAbsolutePath(); // 你的上传目录 Path filePath = baseDir.resolve(decodedFileName).normalize(); // 3. 关键校验:确保最终路径仍在基础目录内 if (!filePath.startsWith(baseDir)) { throw new RuntimeException("文件路径非法,禁止目录遍历攻击。"); } // 4. 检查文件是否存在,然后下载... if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) { throw new RuntimeException("文件不存在。"); } // ... 后续下载逻辑 }注意:
FileUtils.filenameFilter需要自己实现,用于过滤../、..\、%2e%2e%2f(URL编码)等各种形式的目录遍历符。
4. 进阶加固与安全开发规范
完成上述紧急修复后,我们需要建立长期的安全防线,将安全思维融入日常开发。
4.1 依赖组件安全管理
- 定期升级依赖:使用Maven的
versions:display-dependency-updates插件或GitHub Dependabot、Snyk等工具,定期扫描并升级项目依赖,特别是Spring Boot、MyBatis、Shiro、Fastjson等核心组件。 - 移除无用依赖:清理
pom.xml中与业务无关的依赖,减少攻击面。比如一些旧的工具包可能包含已知漏洞。 - 使用安全版本数据库:参考国家信息安全漏洞库(CNNVD)或NVD,了解所用组件的安全公告。
4.2 应用层安全增强
- 输入验证与输出编码:
- Controller层验证:使用JSR-303注解(如
@NotBlank,@Size)或Spring Validator进行强类型校验。 - 输出编码:在Thymeleaf或FreeMarker模板中,默认启用HTML转义。向前端返回JSON数据时,确保敏感信息(如手机号、邮箱)已脱敏。
- Controller层验证:使用JSR-303注解(如
- 会话与权限细化:
- 会话超时:在
application.yml中配置合理的会话超时时间(如server.servlet.session.timeout: 30m)。 - 权限注解:除了Shiro自带的
@RequiresPermissions,对于关键操作(如删除、导出、资金操作)可以增加自定义注解,进行更细粒度的日志记录和二次确认。
- 会话超时:在
- 安全HTTP头:通过配置
WebSecurityConfigurerAdapter或使用spring-boot-starter-security(即使不用它的认证功能)来添加安全头部,如:Strict-Transport-Security(HSTS):强制HTTPS。X-Content-Type-Options: nosniff:防止MIME类型嗅探。X-Frame-Options: DENY:防止点击劫持。Content-Security-Policy(CSP):有效缓解XSS。
4.3 部署与运维安全
- 最小权限原则:运行若依应用的系统账户,不应具有root或管理员权限。只授予其必要的文件读写和网络访问权限。
- 配置文件隔离:将
application-prod.yml中的数据库密码、加密密钥等敏感信息,从代码仓库中分离。使用环境变量、配置中心或密钥管理服务来注入。 - 定期安全扫描:在CI/CD流水线中集成静态应用安全测试(SAST)工具,如SonarQube、Fortify SCA或开源工具FindSecBugs,对代码进行自动化漏洞扫描。同时,使用动态应用安全测试(DAST)工具,如OWASP ZAP,对运行中的应用进行黑盒测试。
- 日志与监控:确保应用日志完整记录关键操作(登录、增删改敏感数据)和异常信息。对接监控告警平台,对频繁的登录失败、异常的路径访问等行为设置告警。
5. 常见问题排查与修复验证实录
在实际加固过程中,你可能会遇到以下问题:
Q1:更换Shiro密钥后,已登录用户的“记住我”功能全部失效,怎么办?A1:这是预期行为。因为旧Cookie是用旧密钥加密的,新密钥无法解密。解决方案有两种:
- 方案一(推荐,适用于用户量不大或可接受重新登录):直接让所有用户重新登录。在升级公告中说明。
- 方案二(平滑过渡):在代码中暂时保留解密逻辑,先同时支持新旧两个密钥进行解密,但加密只用新密钥。实现一个支持多密钥的
RememberMeManager,运行一段时间(如一个月)后,再移除旧密钥支持。此方案较复杂,需自定义Shiro组件。
Q2:将${}改为#{}后,模糊查询like语句报错或查不出数据。A2:这是最常见的语法错误。#{}会被当作字符串参数处理,所以like '%#{name}%'实际会变成like '%'张三'%',语法错误。必须使用concat函数或bind标签,具体写法见3.2节示例。
Q3:修复文件读取漏洞时,路径规范化后,在Windows和Linux系统下表现不一致。A3:使用java.nio.file.Paths和Path.normalize()方法是平台无关的,它会正确处理不同系统的路径分隔符。关键在于在路径校验前,先将基础目录和用户输入路径都转换为绝对路径,再进行startsWith比较,这样可以避免因相对路径导致的校验绕过。
Q4:如何验证我的修复是否真的有效?A4:必须进行验证测试,而非“我以为修好了”。
- Shiro密钥:使用旧密钥生成的Cookie尝试访问,应被拒绝(跳转到登录页或报错)。使用新密钥(可通过登录后获取)生成的Cookie可以正常访问。
- SQL注入:
- 手动测试:在所有搜索、排序接口,尝试输入
'、\"、or 1=1--、;sleep(5)--等Payload,观察响应。正常情况应返回空结果或明确的参数错误,而不是执行了Payload或导致页面异常。 - 工具扫描:使用sqlmap等工具(仅在授权测试环境下使用!)对修复后的接口进行扫描,确认高危漏洞已消失。
- 手动测试:在所有搜索、排序接口,尝试输入
- 文件读取:
- 尝试访问
../、..\、....//等变形Payload,如/common/download?fileName=../../../etc/passwd。 - 尝试读取已知的、但不应被访问的服务器文件,如应用的
application.yml。 - 所有此类请求都应返回“文件不存在”、“路径非法”等统一错误信息,避免泄露服务器真实路径。
- 尝试访问
Q5:若依框架本身发布了新版本,修复了这些漏洞,我该直接升级吗?A5:升级是彻底解决问题的好方法,但需谨慎评估。
- 优点:官方修复最权威,通常能一次性解决多个问题。
- 挑战:若依版本间可能存在数据库结构变更、API变更或依赖库大版本升级,直接覆盖升级可能导致你的二次开发代码不兼容。
- 建议流程:
- 详细阅读目标版本的官方更新日志,重点关注Breaking Changes(破坏性变更)。
- 在本地或测试环境,基于你的当前代码,创建一个新分支,尝试合并官方的新版本代码。这是一个复杂的代码合并过程,可能需要手动解决大量冲突。
- 进行全面的功能回归测试和性能测试。
- 如果合并升级成本过高,退而求其次,可以只反向移植(Backport)官方针对特定漏洞的修复补丁到你的当前版本。这需要你具备一定的代码阅读和修改能力。
安全加固不是一次性的任务,而是一个持续的过程。这份清单为你修复若依4.7.x的高危漏洞提供了明确的路径,但更重要的是,它希望你建立起“默认不安全,处处需验证”的安全开发意识。在后续的每一次功能开发、每一次第三方库引入、每一次接口暴露时,都多问一句:这里会不会有注入?这个配置会不会太宽松?这个权限是不是给大了?唯有将安全作为开发流程中不可分割的一环,你的系统才能真正稳固。