指南

SQL 注入

是时候深入了解 SQL 注入了。长期以来,它一直是 OWASP Top 10 榜单中无可争议的“霸主”,连续多年占据榜首。尽管这项技术已经存在了 20 多年,且排名略有下降,但它依然是一种极其流行且危险的漏洞。

作为一种 Web 安全漏洞,SQL 注入 (SQLi) 至今仍是攻击者最常用的“黑客”技术之一,因为它允许攻击者操纵数据库并从中提取关键信息。更令人担忧的是,攻击者甚至可以获取数据库服务器的管理员权限,进而执行破坏数据库、篡改交易记录、泄露数据以及为系统引入更多安全隐患等极具破坏性的操作。

让我们快速了解一下它的发生原理

SQL(结构化查询语言)是与关系型数据库进行通信的语言;它是开发人员、数据库管理员和应用程序用来管理每天产生的大量数据的查询语言。

在应用程序中,存在两种上下文:一种是数据,另一种是代码。代码上下文告诉计算机执行什么操作,并将其与待处理的数据分离开来。当攻击者输入的数据被 SQL 解释器错误地当作代码执行时,SQL 注入便发生了,这使得攻击者能够从应用程序中获取敏感信息。

SQL 注入攻击的影响

SQL 注入对任何 Web 应用程序都可能造成极大的危害。它之所以成为众多重大数据泄露事件的首选技术,是因为它能让攻击者未经授权访问关键数据。攻击者可以获取大量信息,从用户名和密码,到信用卡详情和个人身份识别码,无所不包。

在获取这些数据后,攻击者可以接管账户、重置密码、进行大规模在线购物,或实施其他(更严重的)欺诈行为。

但 SQL 注入最令人担忧的一点或许在于,如果未被发现,攻击者可以在系统中长期保留后门。正如你所想,只要后门保持开启,就会导致数据被反复窃取。这确实非常可怕。

让我们通过几个示例来更好地理解它的实际表现。

SQL 注入示例

SQL 注入包含多种可应对不同场景的漏洞技术。以下列举了一些最常见的 SQL 注入示例:

Technique Description
Retrieving Hidden Data With this technique, attackers can modify any SQL query to gather more information from the database.
Data Examination Attackers can extract information about the version and structure of a database which helps them exploit further information. This technique may differ across databases.
Union Attacks Attackers can extract information about the version and structure of a database which helps them exploit further information. This technique may differ across databases.
Blind SQLi With Blind SQLi, attackers can implement the query on a database. The catch is that the attackers control this query, and it does not return any results in the application’s response.
Subverting Application Logic Attackers interfere with or manipulate a query to disrupt the application’s logic. To manipulate the query, attackers can combine the SQL comment sequence “--” and a WHERE clause.

‍

SQL 注入类型

好了,现在让我们来看看三种不同的 SQL 注入类型。

带内 SQL 注入 (In-band SQLi)

这是最常见、最简单且最高效的 SQL 注入类型之一。在此类攻击中,攻击者使用相同的通信通道来发起攻击并获取结果。

以下是两种带内 SQL 注入攻击:

  • 联合查询注入 (Union-based SQLi) - 联合查询攻击利用 UNION 运算符将两个或多个 SQL 查询(如 SELECT 语句)合并,从而在 HTTP GET 响应中获取所需的信息和结果。
  • ‍报错注入 (Error-based SQLi) - 攻击者利用数据库的错误消息来了解其结构。在此类攻击中,攻击者可能会发送错误的请求或执行特定操作,诱使服务器显示错误消息,从而获取数据库信息。这就是为什么开发人员必须避免在生产环境中直接发送错误或日志消息的原因;相反,这些信息应存储在受限访问的区域中。

推理性 SQL 注入

推理性 SQL 注入(也称为盲注)攻击更为复杂,且利用过程耗时更长。此外,攻击者无法直接获取攻击结果,这也是它被称为“盲注”的原因。

攻击者通过 HTTP 请求将有效载荷发送至数据库服务器,试图重构用户的数据库,随后通过观察应用程序的响应和行为来判断攻击是否成功。

推理性 SQL 注入攻击主要分为以下两种类型:

  • 基于布尔的盲注 - 在此类攻击中,攻击者向数据库发送查询以获取布尔值(真或假)结果,并通过观察 HTTP 响应来推断该布尔值。
  • 基于时间的盲注 - 在此类攻击中,攻击者发送查询指令,使数据库在响应前强制等待几秒钟,随后根据 HTTP 请求的响应时间来判断查询结果。

带外 SQL 注入

这是一种较为罕见的 SQL 注入攻击,其可行性取决于数据库服务器所启用的功能。当攻击者无法使用其他攻击类型时,便会采用这种方式。

例如,如果攻击者无法使用与带内攻击相同的通信渠道,或者 HTTP 响应不足以让他们推断出查询结果时。
此外,这种攻击并不常见,因为它高度依赖数据库服务器发起 HTTP 或 DNS 请求的能力,以便将所需数据传回给攻击者。

如何防御 SQL 注入

值得庆幸的是,SQL 注入虽然历史悠久且十分常见,但我们已有多种方法可以预防。采用这些预防技术不仅是良好的编码习惯,还能显著增强组织抵御 SQL 注入的安全防线。

保护数据库服务器免受此类攻击的方法有很多,例如输入验证、使用 Web 应用防火墙 (WAF)、加固数据库、聘请第三方安全团队或系统,以及编写严密的 SQL 查询语句。

下面我们以 Python 为例,看看如何通过上述安全措施来预防 SQL 注入。

Python 示例

在此示例中,攻击者将使用基于布尔的盲注来窃取系统中的重要信息。

Python:存在漏洞的代码

假设数据库中有一个名为“sample_data”的表,该表存储了应用程序用户的用户名和密码。

现在,允许用户通过以下命令从该数据库表中查找数值:

import mysql.connector
db = mysql.connector.connect
#错误做法。请避免使用!仅供学习参考。
(host="localhost", user="newuser", passwd="pass", db="sample")
cur = db.cursor()
name = raw_input('Enter Name: ')
cur.execute("SELECT * FROM sample_data WHERE Name = '%s';" % name) for row in cur.fetchall(): print(row)
db.close()

SQL 注入

在此处,如果用户在搜索中输入名称(例如 Alicia),输出结果将一切正常。

然而,如果用户输入类似 Alicia’; DROP TABLE sample_data; 的内容,将会对数据库造成严重破坏。

Python:修复方案

应将 SQL 语句修改为以下形式,以防止此类攻击发生:

cur.execute("SELECT * FROM sample_data WHERE Name = %s;", (name,))

现在,系统会将用户输入视为字符串,即使攻击者尝试在其中注入任何 SQL 查询,系统也只会将其作为名称值进行处理。

这一简单的修改即可防止后续查询中的恶意活动,并保护系统免受用户输入攻击。

Java 示例

在此示例中,我们同样使用名为“sample_data”的数据库表来存储应用程序的用户数据。

一个基础的登录页面会接收用户名和密码,而 Java 文件(即 LoginServlet)则会根据数据库中的信息进行验证,从而允许登录操作。

Java:易受攻击示例

系统使用数据库中的“sample_data”表,通过获取用户输入的凭据来执行登录操作。

LoginServlet 文件中用于处理登录操作的查询语句如下:

//错误示例。请勿使用字符串拼接。
String query = "select * from sample_data where username='" + username + "' and password = '" + password + "'";
        Connection conn = null;
        Statement stmt = null;
        try {
            conn = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/user", "root", "root");
            stmt = conn.createStatement();
            ResultSet rs = stmt.executeQuery(query);
            if (rs.next()) {
                // 匹配成功则登录成功
                success = true;
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                stmt.close();
                conn.close();
            } catch (Exception e) {}
        }
        if (success) {
            response.sendRedirect("home.html");
        } else {
            response.sendRedirect("login.html?error=1");
        }
    }

以下是用于用户登录的查询语句:

select * from sample_data where username='username' and password ='password'

SQL 注入

如果输入有效,系统将完美运行。例如,我们再次假设用户名为 Alicia,密码为 secret。

系统将返回凭据匹配的用户数据。然而,攻击者可以使用 Postman 和 cURL 操纵用户请求,从而进行 SQL 注入。

例如,黑客可以发送一个虚拟用户名(Alicia)和密码 ‘or ‘1’=’1’。

在这种情况下,虽然用户名和密码不匹配,但条件 ‘1’=’1’ 始终为真,因此登录操作将会成功。

Java:预防措施

为了进行预防,我们需要修改 LoginValidation 代码,并使用 PreparedStatement 代替 Statement 来执行查询。此更改将避免在查询中拼接用户名和密码,而是将其作为设置数据处理,从而防止 SQL 注入。

以下是修改后的 LoginValidation 代码:

String query = "select * from sample_data where username=? and password = ?";
Connection conn = null;
PreparedStatement stmt = null;
try {
    conn = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/user", "root", "root");
    stmt = conn.prepareStatement(query);
    stmt.setString(1, username);
    stmt.setString(2, password);
    ResultSet rs = stmt.executeQuery();
    if (rs.next()) {
       success = true;
       }
      rs.close();
      } catch (Exception e) {
         e.printStackTrace();
         } finally {
             try {
                 stmt.close();
                 conn.close();
            } catch (Exception e) {
            }
         }

在这种情况下,PreparedStatement、setter 方法以及底层的 JDBC API 将处理用户输入,从而防止 SQL 注入。

示例

现在,我们来看一些不同语言的示例,以便更好地理解其实际应用。

C# - 不安全

此示例因使用了 `FromRawSql` 而不安全。该方法不会绑定参数,也不会尝试对参数进行转义。因此,应尽可能避免使用此方法。

var blogs = context.Posts
    .FromRawSql("SELECT * FROM Posts WHERE state = {0} AND author = {1}", state, author)
    .ToList();

C# - 安全

此示例是安全的,因为它使用了 `FromSqlInterpolated`,该方法会获取插值并对其进行参数化处理。

虽然这通常是安全的,但它存在与不安全的 `FromRawSql` 非常相似的风险。

var blogs = context.Posts
    .FromSqlInterpolated($"SELECT * FROM Posts WHERE state = {state} AND author = {author}")
.ToList();

Java - 安全:Hibernate - 命名查询 + 原生查询

Hibernate 通过其“原生查询”(Native Query)和“命名查询”(Named Query)提供了两种安全构建查询的方法。两者都允许指定参数位置。

@NamedNativeQuery(
        name = "find_post_by_state_and_author",
        query =
        "SELECT * " +
                "FROM Post " +
                "WHERE state = :state" + 
         " AND author = :author",
        resultClass = Post.class)

java
List<Post> posts = session.createNativeQuery(
        "SELECT * " +
        "FROM Post " +
        "WHERE state = :state" +
        " AND author = :author" )
        .addEntity(Post.class)
        .setParameter("state", state)
        .setParameter("author", author)
        .list();

Java - 安全:jplq

通过在 jplq 存储库接口上标注 `Query` 属性,查询可以采用多种形式,并且是参数化的。

@Query("SELECT p FROM Post p WHERE u.state = ?1 and u.author = ?2")
Post findPostByStateAndAuthor(String state, int author);
@Query("SELECT p FROM Post p WHERE u.state = :state and u.author = :author")
User findPostByStateAndAuthor(@Param("state") String state, @Param("author") int author);

Javascript - 安全实践:pg

使用 `pg` 库时,可以通过 `query` 方法的第二个参数传入参数值,从而实现参数化查询。

const { posts } = await db.query('SELECT * FROM Post WHERE state = $1 AND author = $2', [state, author])

Javascript - 安全实践:Sequelize

`sequelize` 库允许通过其第二个参数对查询进行参数化,该参数用于配置查询设置。你可以通过名称或索引,以绑定列表的形式传入查询参数。

await sequelize.query(
    'SELECT * FROM Post WHERE state = $state AND author = $author',
    {
        bind: { state: state, author: author},
        type: QueryTypes.SELECT
    }
);
await sequelize.query(
    'SELECT * FROM Post WHERE state = $1 AND author = $2',
    {
        bind: [state, author],
        type: QueryTypes.SELECT
    }
);
标语

实用安全编码

探索安全编码指南,了解并缓解 OWASP Top 10 等软件漏洞,并通过引导式训练任务在真实的应用程序模拟中进行实践。

更多指南
预约演示