在Java Web开发中,SQL注入是最常见、最危险的安全漏洞之一。它利用开发者编写SQL语句时的疏忽,通过恶意输入篡改SQL逻辑,从而实现越权登录、数据泄露、甚至数据库被篡改等严重后果。本文将结合实战代码,详细拆解SQL注入漏洞的产生前提、攻击效果、底层原因,以及最有效的解决方案,全程贴合实际开发场景,新手也能轻松理解。
一. 什么是SQL注入?核心定义
SQL注入(SQL Injection),简单来说,就是攻击者通过向应用程序输入恶意的SQL语句片段,利用程序对用户输入的未过滤、未验证,将恶意片段拼接进后台执行的SQL语句中,篡改原有SQL的执行逻辑,达到非法操作数据库的目的。
结合我们的实战场景:在已知用户名的情况下,攻击者无需输入正确密码,只需输入特定的恶意字符串,就能绕过登录验证,成功登录系统——这就是最典型的SQL注入攻击。
二. SQL注入漏洞的产生前提(必看)
SQL注入并非随时随地都能发生,它需要满足两个核心前提,缺一不可,这也是我们后续防范漏洞的关键切入点:
已知关键信息:攻击者需要知道系统中的某个有效用户名(比如本文案例中的“aaa”),这是攻击的基础(如果连用户名都未知,注入攻击的难度会大幅提升)。
后台存在SQL语句拼接:这是漏洞产生的核心前提。后台程序没有对用户输入的用户名、密码进行过滤,直接将用户输入的内容拼接进SQL语句中,导致恶意输入被当作SQL代码执行。
三. SQL注入的攻击效果(实战演示)
我们以登录功能为例,结合提供的代码,演示两种最常见的SQL注入攻击方式,看看攻击者如何在“密码任意输入”的情况下,成功登录系统。
先看后台存在漏洞的登录代码(对应代码中login方法):
/**
* 存在SQL注入漏洞的登录方法
* @param username 用户输入的用户名
* @param password 用户输入的密码
* @return 登录结果
*/
public String login(String username,String password){
Connection conn = null;
Statement stmt = null;
ResultSet rs = null;
try {
// 获取数据库连接
conn = JDBCUtils.getConnection();
// 核心问题:直接拼接用户输入和SQL语句
String sql = "select * from t_user where username = '"+username+"' and password = '"+password+"'";
// 创建Statement对象执行SQL
stmt = conn.createStatement();
rs = stmt.executeQuery(sql);
// 有查询结果则登录成功
if(rs.next()){
return "登录成功...";
}else{
return "登录失败了...";
}
} catch (SQLException e) {
e.printStackTrace();
}finally {
JDBCUtils.close(conn,stmt,rs);
}
return null;
}
攻击方式1:输入恶意字符串 “aaa'or'1=1”(用户名)+ 任意密码
攻击者已知用户名是“aaa”,在登录界面输入:
用户名:aaa'or'1=1
密码:任意字符(比如123456、sfsdfsds等,无需正确)
我们来分析拼接后的SQL语句,就能明白为什么能登录成功:
原SQL模板:select * from t_user where username = '"+username+"' and password = '"+password+"'
拼接后实际执行的SQL:
select * from t_user where username = 'aaa'or'1=1' and password = 'sfsdfsds'
核心分析:
SQL中 '1=1' 是一个永远为“真”的条件(恒成立);
原SQL的逻辑是“用户名等于输入值 并且密码等于输入值”,拼接后变成“用户名等于aaa 或者 1=1(恒真) 并且 密码等于任意值”;
根据SQL的逻辑优先级,“and”优先级高于“or”,1=1(恒真) 并且 密码等于任意值这两个条件结合结果为“假”,'aaa'或者假的结果为真(因为用户名正确),这样就会导致整个WHERE条件最终为“真”;
SQL会查询出t_user表中的所有数据,后台判断有查询结果,就返回“登录成功”——攻击者无需正确密码,成功登录。
攻击方式2:输入恶意字符串 “aaa'-- ”(用户名)+ 任意密码
这是另一种更简洁的注入方式,攻击者输入:
用户名:aaa'-- (注意:-- 后面有一个空格,这是SQL的注释符号)
密码:任意字符
拼接后实际执行的SQL:
select * from t_user where username = 'aaa'-- '' and password = 'sfsdfsdfs'
核心分析:
SQL中 -- 是注释符号,注释符号后面的所有内容都会被数据库忽略;
拼接后,-- 后面的 “'' and password = 'sfsdfsdfs'” 被全部注释,相当于SQL变成:select * from t_user where username = 'aaa';
只要用户名“aaa”存在,SQL就会查询到对应数据,后台判断登录成功——同样无需正确密码,实现越权登录。
四. SQL注入漏洞的产生原因(底层剖析)
通过上面的演示,我们能明确:SQL注入漏洞的根本原因,就是后台程序对用户输入的内容未做任何过滤和验证,直接将用户输入拼接进SQL语句中。
具体拆解为两点,结合代码更易理解:
SQL语句拼接的弊端:代码中使用 String sql = "select ... where username = '"+username+"' and password = '"+password+"'" 这种拼接方式,用户输入的内容会直接成为SQL语句的一部分。如果用户输入的是恶意SQL片段(比如'or'1=1、-- 等),就会篡改原有SQL的逻辑。
未过滤特殊字符:对于SQL中的特殊字符(比如单引号'、注释符号--、逻辑运算符or/and等),后台程序没有进行转义或过滤,导致这些特殊字符被当作SQL代码的一部分执行,而不是当作普通的用户输入数据。
简单来说:程序把“用户输入的数据”当成了“SQL代码”来执行,这就是SQL注入的本质。
五. SQL注入漏洞的解决方案(终极方案)
解决SQL注入的核心思路是:禁止SQL语句拼接,将用户输入的内容与SQL语句的逻辑分离,让用户输入的内容永远只作为“数据”,而不是“SQL代码”执行。
在Java中,最标准、最有效的解决方案是:使用PreparedStatement接口,它是Statement接口的子接口,核心优势是“预编译SQL语句”,能从根源上杜绝SQL注入。
1. PreparedStatement的核心原理
预编译功能:先将SQL语句的“骨架”(固定逻辑)发送到MySQL服务器端进行编译,编译后的SQL语句格式被固定,无法再被篡改。
占位符替代参数:SQL语句中需要传入用户输入的部分,用 ? 作为占位符(比如 select * from t_user where username = ? and password = ?),而不是直接拼接用户输入。
参数单独传入:编译完成后,再将用户输入的内容作为“参数”,通过专门的方法传入占位符,数据库会自动将参数当作普通数据处理,不会解析成SQL代码。
2. 核心方法(必掌握)
创建PreparedStatement对象:通过Connection接口的 prepareStatement(String sql) 方法创建,参数是带占位符的SQL语句(预编译SQL)。
给占位符赋值:通过PreparedStatement的setXxx()方法给 ? 赋值,核心方法如下:
setString(int parameterIndex, String x):给指定位置的占位符赋值(字符串类型),parameterIndex是占位符的位置(从1开始,不是从0开始)。
setInt(int parameterIndex, int x):给占位符赋值(int类型)。
setObject(int parameterIndex, Object x):通用赋值方法,可适配任意数据类型。
执行SQL语句:
executeQuery():执行查询类SQL(select),无需传入SQL语句(因为已经预编译完成)。
executeUpdate():执行增删改类SQL(insert、update、delete),同样无需传入SQL语句。
实战代码(解决SQL注入,对应login2方法)
/**
* 采用预编译方式,解决SQL注入漏洞的登录方法
* @param username 用户输入的用户名
* @param password 用户输入的密码
* @return 登录结果
*/
public String login2(String username,String password){
Connection conn = null;
PreparedStatement stmt = null; // 预编译SQL执行对象
ResultSet rs = null;
try {
// 1. 获取数据库连接
conn = JDBCUtils.getConnection();
// 2. 编写带占位符的SQL语句(骨架固定)
String sql = "select * from t_user where username = ? and password = ?";
// 3. 预编译SQL语句,创建PreparedStatement对象
stmt = conn.prepareStatement(sql);
// 4. 给占位符赋值(用户输入的内容仅作为数据传入)
stmt.setString(1, username); // 第1个? 赋值为username
stmt.setString(2, password); // 第2个? 赋值为password
// 5. 执行SQL(无需传入SQL语句,已预编译)
rs = stmt.executeQuery();
// 6. 判断登录结果
if(rs.next()){
return "登录成功...";
}else{
return "登录失败了...";
}
} catch (SQLException e) {
e.printStackTrace();
}finally {
// 关闭资源
JDBCUtils.close(conn,stmt,rs);
}
return null;
}
4. 效果验证(彻底杜绝注入)
当我们使用login2方法,再输入之前的恶意字符串时,注入攻击会彻底失效:
输入用户名:aaa'or'1=1,密码:任意字符;
预编译后,SQL的骨架 select * from t_user where username = ? and password = ? 被固定;
用户输入的“aaa'or'1=1”会被当作普通字符串,赋值给第一个占位符,不会被解析成SQL逻辑;
数据库会查询“username等于'aaa'or'1=1'”的用户,而这种用户名并不存在,因此返回“登录失败”,注入攻击失效。
六、核心总结(必背)
SQL注入是什么:攻击者通过恶意输入,篡改后台拼接的SQL语句逻辑,实现越权操作(如无密码登录)。
产生原因:后台程序直接拼接用户输入和SQL语句,未过滤特殊字符,导致用户输入被当作SQL代码执行。
攻击前提:已知有效用户名 + 后台存在SQL拼接。
解决方案:使用PreparedStatement预编译SQL,用?占位符替代参数拼接,将用户输入作为数据传入,从根源杜绝注入。
关键区别:Statement(拼接SQL,有注入漏洞) vs PreparedStatement(预编译+占位符,无注入漏洞),实际开发中必须使用PreparedStatement。
完整代码
import cn.qcby.utils.JdbcUtils;
import java.sql.*;
/**
* 演示SQL注入的问题,漏洞
* 在已知用户名的情况下,通过sql语言关键字,登录系统。
* SQL注入产生原因是SQL语句的拼接,利用SQL关键字(or和and的优先级问题)产生效果。
* 需要解决SQL注入的问题
*
* 解决SQL注入问题,采用SQL语句预编译的方式,把SQL语句中的参数使用 ? 占位符来表示,先把SQL语句编译,格式固定的。
* 再给 ? 传入值,传入任何内容都表示值。数据库会判断SQL执行的结果。
*/
public class JdbcTest4 {
public static void main(String[] args) {
/*
// 模拟登录的功能,有SQL注入的问题*/
// String result = new JdbcTest4().login("aaa'or'1=1", "123");
// System.out.println(result);
String result = new JdbcTest4().login2("aaa'or'1=1", "12346546464");
System.out.println(result);
}
/**
* 采用预编译的方式,解决SQL注入的问题
* @param username
* @param password
* @return
*/
public String login2(String username,String password){
Connection conn = null;
// 预编译执行SQL语句对象
PreparedStatement stmt = null;
ResultSet rs = null;
try {
// 获取到连接
conn = JDBCUtils.getConnection();
// 使用?占位符
String sql = "select * from t_user where username = ? and password = ?";
// 预编译SQL语句,把SQL语句固定
//Statement statement = conn.createStatement();
//statement不能防止sql注入问题 prepareStatement (有占位符)能够防止sql注入问题
stmt = conn.prepareStatement(sql);
// 需要给 ? 设置值
stmt.setString(1,username);
stmt.setString(2,password);
// 执行SQL语句
rs = stmt.executeQuery();
// 遍历数据
if(rs.next()){
// 表示存在数据,如果存在,说明用户名和密码编写正确
return "登录成功...";
}else{
return "登录失败了...";
}
} catch (SQLException e) {
e.printStackTrace();
}finally {
JDBCUtils.close(conn,stmt,rs);
}
return null;
}
/**
* 模拟登录的功能,通过用户名和密码从数据库中查询
* @param username
* @param password
* @return
*/
public String login(String username,String password){
Connection conn = null;
Statement stmt = null;
ResultSet rs = null;
try {
// 获取到连接
conn = JDBCUtils.getConnection();
// 编写SQL语句的拼接 '1=1' true password = '1234sdfsce' false true and false 整体上false,前面的用户名'aaa'or false,用户名正确,所以即使密码错误也能登录
// String sql = "select * from t_user where username = 'aaa' or '1=1' and password = '1234sdfsce'";
// String sql = "select * from t_user where username = 'aaa' or false";
String sql = "select * from t_user where username = '"+username+"' and password = '"+password+"'";
// 执行sql
stmt = conn.createStatement();
// 执行
rs = stmt.executeQuery(sql);
// 遍历数据
if(rs.next()){
// 表示存在数据,如果存在,说明用户名和密码编写正确
return "登录成功...";
}else{
return "登录失败了...";
}
} catch (SQLException e) {
e.printStackTrace();
}finally {
JDBCUtils.close(conn,stmt,rs);
}
return null;
}
}