如何在PHP中防止SQL注入?

2773
如果用户输入被直接插入到 SQL 查询中,那么应用程序就容易受到 SQL 注入攻击,例如以下示例:

If user input is inserted without modification into an SQL query, then the application becomes vulnerable to SQL injection, like in the following example:

$unsafe_variable = $_POST['user_input']; 

mysql_query("INSERT INTO `table` (`column`) VALUES ('$unsafe_variable')");

这是因为用户可以输入类似于value'); DROP TABLE table;--这样的内容,然后查询就会变成:

INSERT INTO `table` (`column`) VALUES('value'); DROP TABLE table;--')

有什么方法可以防止这种情况发生吗?

27个回答

9626
无论使用哪种数据库,避免SQL注入攻击的正确方法是将数据与SQL分离,使数据保持为数据,并且不会被SQL解析器解释为命令。可以创建一个具有正确格式化数据部分的SQL语句,但如果你不完全了解细节,应始终使用预编译语句和参数化查询。这些是将SQL语句单独发送到数据库服务器并进行解析的语句,与任何参数分开。这样一来,攻击者就无法注入恶意SQL。

基本上,你有两个选择来实现这一点:

  1. 使用PDO(适用于任何支持的数据库驱动程序):
  2. $stmt = $pdo->prepare('SELECT * FROM employees WHERE name = :name');
    $stmt->execute([ 'name' => $name ]);
    
    foreach ($stmt as $row) {
        // 使用$row做一些操作
    }
    
  3. 使用MySQLi(适用于MySQL):
    自 PHP 8.2+ 版本开始,我们可以使用execute_query() 方法来同时准备、绑定参数和执行 SQL 语句:
  4. $result = $db->execute_query('SELECT * FROM employees WHERE name = ?', [$name]);
     while ($row = $result->fetch_assoc()) {
         // 使用$row做一些操作
     }
    

    在 PHP8.1 及更早版本中:

     $stmt = $db->prepare('SELECT * FROM employees WHERE name = ?');
     $stmt->bind_param('s', $name); // 's' 指定变量类型为 'string'
     $stmt->execute();
     $result = $stmt->get_result();
     while ($row = $result->fetch_assoc()) {
         // 使用$row做一些操作
     }
    
如果您连接的是除MySQL之外的数据库,还有一个特定于驱动程序的第二个选项可供参考(例如,对于PostgreSQL,可以使用pg_prepare()pg_execute())。PDO是通用选项。

正确设置连接

PDO

请注意,使用PDO访问MySQL数据库时,默认情况下不会使用真正的预处理语句。要解决此问题,您需要禁用模拟预处理语句。创建PDO连接的示例如下:

$dbConnection = new PDO('mysql:dbname=dbtest;host=127.0.0.1;charset=utf8mb4', 'user', 'password');

$dbConnection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$dbConnection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

在上面的例子中,错误模式并不是严格必需的,但建议添加它。这样PDO将通过抛出PDOException来通知您所有的MySQL错误。
然而,第一个setAttribute()行是强制性的,它告诉PDO禁用模拟准备语句并使用真正的准备语句。这确保语句和值在发送到MySQL服务器之前不会被PHP解析(给可能的攻击者注入恶意SQL的机会)。
尽管您可以在构造函数的选项中设置字符集,但重要的是要注意,'旧版'的PHP(5.3.6之前)在DSN中默默地忽略了字符集参数

Mysqli

对于mysqli,我们需要遵循相同的例程:
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); // error reporting
$dbConnection = new mysqli('127.0.0.1', 'username', 'password', 'test');
$dbConnection->set_charset('utf8mb4'); // charset

解释

你传递给 prepare 的 SQL 语句会被数据库服务器解析和编译。通过指定参数(可以是 ? 或者像上面例子中的 :name 这样的命名参数),你告诉数据库引擎要在哪里进行过滤。然后当你调用 execute 时,预编译语句会与你指定的参数值结合。

这里重要的是,参数值与编译后的语句结合,而不是一个 SQL 字符串。SQL 注入的工作原理是通过欺骗脚本,在创建要发送到数据库的 SQL 时包含恶意字符串。因此,通过将实际的 SQL 与参数分开发送,可以减少出现意外情况的风险。

使用预编译语句时,发送的任何参数都将被视为字符串(尽管数据库引擎可能会对参数进行一些优化,使其成为数字)。在上面的例子中,如果 $name 变量包含 'Sarah'; DELETE FROM employees,结果将仅仅是对字符串 "'Sarah'; DELETE FROM employees" 进行搜索,并且你不会得到一个空表

使用预编译语句的另一个好处是,如果在同一会话中多次执行相同的语句,它只会被解析和编译一次,从而提供一些速度上的提升。
哦,既然你问到如何在插入数据时使用它,这里有个示例(使用PDO):
$preparedStatement = $db->prepare('INSERT INTO table (column) VALUES (:column)');

$preparedStatement->execute([ 'column' => $unsafeValue ]);

可以使用预编译语句来处理动态查询吗?
虽然您仍然可以使用预编译语句来处理查询参数,但动态查询本身的结构无法进行参数化,并且某些查询功能也无法进行参数化。
对于这些特定情况,最好的做法是使用白名单过滤器来限制可能的值。
// Value whitelist
// $dir can only be 'DESC', otherwise it will be 'ASC'
if (empty($dir) || $dir !== 'DESC') {
   $dir = 'ASC';
}

59
mysql_query的官方文档只允许执行一条查询语句,因此除了分号之外的任何其他查询都将被忽略。即使这已经过时,仍然有很多运行在PHP 5.5.0以下系统上的系统可能会使用这个函数。http://php.net/manual/en/function.mysql-query.php - Randall Valenciano
20
这是一个不良习惯,但是它是一种事后解决方案:不仅适用于SQL注入,而且适用于任何类型的注入(例如,在F3框架v2中存在视图模板注入漏洞)。如果您有一个旧的网站或应用程序正在受到注入缺陷的影响,其中一种解决方案是在引导时重新分配您的超全局预定义变量(如$_POST),并将其值转义。使用PDO,仍然可以进行转义(对于今天的许多框架来说):substr($pdo->quote($str, \PDO::PARAM_STR), 1, -1)。 - AbbasAli Hashemian
26
这个答案没有解释什么是预处理语句,它是一个东西——如果你在请求期间使用了很多预处理语句,它会导致性能下降,有时会导致性能下降10倍。更好的情况是使用参数绑定关闭、语句准备关闭的PDO。 - donis
14
使用PDO更好,如果您正在使用直接查询,请确保使用mysqli :: escape_string。 - Kassem Itani
6
@Alix 这在理论上听起来是一个好主意,但有时候这些值需要不同类型的转义,比如针对 SQL 和 HTML。 - p0358
显示剩余6条评论

1740

要使用参数化查询,需要使用Mysqli或PDO。为了用mysqli重写您的示例,我们需要类似以下的代码:

<?php
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$mysqli = new mysqli("server", "username", "password", "database_name");

$variable = $_POST["user-input"];
$stmt = $mysqli->prepare("INSERT INTO table (column) VALUES (?)");
// "s" means the database expects a string
$stmt->bind_param("s", $variable);
$stmt->execute();

你需要了解的关键函数是mysqli::prepare

同时,如其他人建议的那样,你可能会发现通过使用PDO之类的抽象层可以更方便地进行操作。

请注意,你提出的这个情况相当简单,而更复杂的情况可能需要更复杂的方法。特别地:

  • 如果你想基于用户输入来改变SQL结构,参数化查询将不起作用,并且所需的转义并不包括在mysql_real_escape_string中。在这种情况下,你最好通过白名单处理用户的输入,以确保只允许“安全”的值。"

2
仅使用 mysql_real_escape_string 是否足够,还是我必须同时使用参数化查询? - peiman F.
10
@peimanF. 在本地项目中,保持使用参数化查询的良好惯例。使用参数化查询可以确保没有SQL注入攻击。但请记住,您应该对数据进行净化处理,以避免虚假检索(例如XSS注入,比如在文本中放置HTML代码),例如使用htmlentities函数。 - Goufalite
3
@peimanF。使用参数化查询和绑定值是很好的实践,但现在使用real escape string也不错。 - Richard
我理解包含 mysql_real_escape_string() 是为了完整性,但不喜欢将最容易出错的方法放在首位。读者可能只会快速地复制第一个例子。现在好在这个函数已经被弃用了 :) - Steen Schütt
5
жүҖжңүзҡ„mysql_*еҮҪж•°йғҪе·Іиў«ејғз”ЁпјҢе®ғ们被зұ»дјјзҡ„mysqli_*еҮҪж•°жүҖеҸ–д»ЈпјҢдҫӢеҰӮmysqli_real_escape_stringгҖӮиҜ·жіЁж„ҸпјҢж–°еҮҪж•°еҸӘжҳҜзұ»дјјдәҺж—§еҮҪж•°пјҢдҪҶ并дёҚе®Ңе…ЁзӣёеҗҢгҖӮ - Rick James
显示剩余2条评论

1172
每个答案只涉及问题的一部分。 实际上,我们可以动态地向SQL添加四个不同的查询部分:-
  • 字符串
  • 数字
  • 标识符
  • 语法关键字
准备好的语句仅涵盖其中两个。
但有时我们必须使查询更加动态,添加运算符或标识符。 因此,我们需要不同的保护技术。
通常,这种保护方法基于白名单。
在这种情况下,每个动态参数都应该在您的脚本中硬编码并从该集合中选择。 例如,要进行动态排序:
$orders  = array("name", "price", "qty"); // Field names
$key = array_search($_GET['sort'], $orders)); // if we have such a name
$orderby = $orders[$key]; // If not, first one will be set automatically. 
$query = "SELECT * FROM `table` ORDER BY $orderby"; // Value is safe

为了简化这个过程,我编写了一个白名单帮助函数,可以在一行代码中完成所有工作:
$orderby = white_list($_GET['orderby'], "name", ["name","price","qty"], "Invalid field name");
$query  = "SELECT * FROM `table` ORDER BY `$orderby`"; // sound and safe

还有一种保护标识符的方法 - 转义,但我更倾向于使用白名单作为更强大和明确的方法。然而,只要您引用了标识符,就可以转义引号字符以使其安全。例如,默认情况下,对于mysql,您必须double the quote character to escape it。对于其他DBMS,转义规则将不同。

但是,SQL语法关键字(例如ANDDESC等)存在问题,但在这种情况下,白名单似乎是唯一的方法。

因此,一个普遍的建议可能被措辞为

  • 表示SQL数据文字(或简单地说 - SQL字符串或数字)的任何变量都必须通过准备好的语句添加。没有例外。
  • 任何其他查询部分,例如SQL关键字、表或字段名称或运算符,都必须通过白名单进行过滤。

更新

虽然关于SQL注入保护的最佳实践已经有了普遍的共识,但仍然存在许多不良实践。其中一些实践深深植根于PHP用户的思想中。例如,在这个页面上(尽管对大多数访问者来说是不可见的),已经删除了超过80个回答,全部由社区因质量差或推广不良且过时的做法而删除。更糟糕的是,一些不良回答没有被删除,反而得到了发展。

例如,这里(1) 还有(2) 很多(3) 回答(4),包括第二个赞数最多的回答,建议您手动转义字符串-这是一种过时的方法,已被证明是不安全的。

或者有一个稍微好一点的答案,建议只使用另一种字符串格式化方法,甚至宣称它是终极良药。当然,它并不是。这种方法并不比常规字符串格式化更好,但它保留了所有缺点:它仅适用于字符串,并且像任何其他手动格式化一样,本质上是可选的、非强制性的措施,容易出现人为错误。

我认为这一切都是因为一个非常古老的迷信,得到了OWASPPHP手册等权威机构的支持,宣称“转义”和防止SQL注入之间的平等。

无论PHP手册多久以前说过什么,*_escape_string决不能使数据安全,也从未打算过如此。除了字符串之外,对于任何SQL部分来说,手动转义都是无用的,因为它是手动的而不是自动化的。

OWASP让情况更糟,强调转义用户输入是彻头彻尾的胡说八道:在注入保护的上下文中不应该有这样的词语。每个变量都潜在危险——无论来源如何!换句话说,每个变量都必须适当地格式化才能放入查询中——再次强调,重要的是目的地。一旦开发人员开始分辨哪些变量是“安全”的或不安全的,他/她就迈出了通向灾难的第一步。更不用说,即使措辞也暗示着在入口点进行大量转义,类似于已经被鄙视、废弃和删除的魔术引号功能。

因此,与任何“转义”不同,预处理语句确实是防止SQL注入的措施(如果适用)。


array_search() 可以返回一个整数(包括 0)或 false -- 这个回答的这个方面应该重新审视。 - undefined

897
我建议使用PDO(PHP数据对象)来运行参数化的SQL查询。
这不仅可以防止SQL注入,还可以加快查询速度。
而且,通过使用PDO而不是mysql_mysqli_pgsql_函数,您可以使应用程序与数据库更加抽象化,以防万一需要切换数据库提供商。

14
这个答案具有误导性。PDO 并不是一个魔术棒,仅仅存在并不能保护你的查询语句。为了从 PDO 获得保护,你需要将查询中的每个变量替换为一个占位符。 - Your Common Sense
你有任何资源或进一步解释你所说的“用占位符替换查询中的每个变量”吗?你是指bindValue吗? - Daniel L. VanDenBosch
@Daniel L. VanDenBosch,我们可以称之为主机变量吗?大多数嵌入式SQL系统都这样称呼它们。如果它们不是占位符,那么它们就是常量,即使该值到达可以容纳其他值的主机字段。最小化变量数量可提供可预测的访问路径,但显然会降低可重用性。 - mckenzm

665

使用PDO和预处理查询。

($conn是一个PDO对象)

$stmt = $conn->prepare("INSERT INTO tbl VALUES(:id, :name)");
$stmt->bindValue(':id', $id);
$stmt->bindValue(':name', $name);
$stmt->execute();

587
作为你所看到的,人们建议你最好使用准备好的语句。这并没有错,但是当你的查询每个进程只执行一次时,会有轻微的性能损失。
我曾经面临这个问题,但我认为我以非常精密的方式解决了它 - 黑客用来避免使用引号的方式。我将其与模拟的准备好的语句一起使用,以防止所有可能的SQL注入攻击。
我的方法:
  • 如果您想要输入为整数,请确保它真的是整数。在像PHP这样的变量类型语言中,这非常重要。您可以使用例如这个非常简单但功能强大的解决方案:sprintf("SELECT 1,2,3 FROM table WHERE 4 = %u", $input);

  • 如果您希望从整数以外的任何内容中获取输入,请将其转换为十六进制。如果您将其转换为十六进制,您将完全避免所有输入。在C/C++中有一个名为mysql_hex_string()的函数,在PHP中您可以使用bin2hex()

    不必担心转义后的字符串长度会比原始长度大两倍,因为即使您使用mysql_real_escape_string,PHP也必须分配相同容量((2*input_length)+1),这是一样的。

  • 这种十六进制方法通常用于传输二进制数据,但我认为为了防止SQL注入攻击,我们可以对所有数据使用它。请注意,您必须在数据前面加上0x或使用MySQL函数UNHEX

因此,例如,查询:

SELECT password FROM users WHERE name = 'root';

将变成:

SELECT password FROM users WHERE name = 0x726f6f74;

或者

SELECT password FROM users WHERE name = UNHEX('726f6f74');

Hex是完美的逃逸方式。无法注入。
UNHEX函数和0x前缀的区别
在评论中有些讨论,所以我想澄清一下。这两种方法非常相似,但在某些方面略有不同:
0x前缀只能用于数据列,如char、varchar、text、block、binary等。 此外,如果要插入空字符串,它的使用有点复杂。你必须完全替换它为'',否则会出错。
UNHEX()适用于任何列; 你不必担心空字符串。

十六进制方法常用于攻击

请注意,这种十六进制方法经常被用作SQL注入攻击,其中整数就像字符串一样并使用mysql_real_escape_string进行转义。然后您可以避免使用引号。

例如,如果您只是这样做:

"SELECT title FROM article WHERE id = " . mysql_real_escape_string($_GET["id"])

攻击者可以很容易地注入你的代码。请考虑以下从你的脚本返回的已注入代码:

SELECT ... WHERE id = -1 UNION ALL SELECT table_name FROM information_schema.tables;

现在只需提取表结构:

SELECT ... WHERE id = -1 UNION ALL SELECT column_name FROM information_schema.column WHERE table_name = __0x61727469636c65__;

我只需选择所需数据,是不是很酷?

但如果可注入网站的编码器将其转成十六进制,注入就将不可能,因为查询将变成这样:

SELECT ... WHERE id = UNHEX('2d312075...3635');

@Zaffy,我喜欢这个想法,但是关于性能怎么样呢?如果你有100万条记录和1000个用户在搜索,与准备好的解决方案相比,它会变慢吗? - Sumit Gupta
我刚刚测试了SELECT * FROM tblproducts WHERE product_code LIKE ( '%42%'),它可以找到记录,但是SELECT * FROM tblproducts WHERE product_code LIKE ('%' +0x3432 +'%')却不能,所以它根本不起作用,或者我做错了什么? - Sumit Gupta
9
@SumitGupta,是的,你说得对。MySQL不使用“+”进行串联,而是使用“CONCAT”。至于性能方面:我认为这并不会影响性能,因为MySQL必须解析数据,无论原始数据是字符串还是十六进制数都无关紧要。 - Zaffy
1
这种过于复杂的方法完全是徒劳无功的。一个人可以使用简单的引用函数 "'".$mysqli->escape_string($_GET["id"])."'",而不是这种十六进制/反十六进制的废话。但它同样有限,对于不适用的情况,仍会使您的应用程序容易受到 SQL 注入攻击。 - Your Common Sense
1
@Zaffy,谢谢,这很有帮助。我自己测试了一下,你的“公式”hex/unhex可以防止最常见的SQL注入攻击。这可能会破坏它,或者在过程中泄漏什么吗?至少以你所知道的方式。 - Edgaras

517

警告:已过时 这篇回答的示例代码(如问题的示例代码)使用了PHP的MySQL扩展,该扩展在PHP 5.5.0中被弃用,在PHP 7.0.0中完全移除。

安全警告:这篇回答不符合安全最佳实践。转义无法防止SQL注入,应使用预处理语句。使用下面概述的策略需自担风险。(此外,mysql_real_escape_string()在PHP 7中已删除。)

重要提示

避免SQL注入的最佳方法是使用预处理语句而非转义,正如接受的答案所演示的那样。

有一些库,如Aura.SqlEasyDB,可以让开发人员更轻松地使用预处理语句。要了解有关为什么预处理语句在防止SQL注入方面更好,请参见mysql_real_escape_string()绕过WordPress中最近修复的Unicode SQL注入漏洞

注入预防 - mysql_real_escape_string()

PHP有一个专门用于防范这些攻击的函数。你只需要使用这个函数,mysql_real_escape_string

mysql_real_escape_string接收一个将要用于MySQL查询的字符串,并返回同样的字符串,其中所有尝试进行SQL注入的代码都被安全地转义。基本上,它将替换用户可能输入的那些麻烦引号(')为MySQL安全的替代品,即转义引号\'。

注意:您必须连接到数据库才能使用此函数!

// 连接到MySQL

$name_bad = "' OR 1'"; 

$name_bad = mysql_real_escape_string($name_bad);

$query_bad = "SELECT * FROM customers WHERE username = '$name_bad'";
echo "Escaped Bad Injection: <br />" . $query_bad . "<br />";


$name_evil = "'; DELETE FROM customers WHERE 1 or username = '"; 

$name_evil = mysql_real_escape_string($name_evil);

$query_evil = "SELECT * FROM customers WHERE username = '$name_evil'";
echo "Escaped Evil Injection: <br />" . $query_evil;

您可以在MySQL - SQL注入预防中找到更多详情。


33
对于使用遗留的 MySQL 扩展,这是你能够做到的最好。对于新代码,建议切换到 mysqli 或 PDO。 - Álvaro González
7
我不同意“专门设计的函数来防止这些攻击”的说法。我认为 mysql_real_escape_string 的目的在于允许针对每个输入数据字符串构建正确的 SQL 查询语句,防止 SQL 注入只是该函数的副作用。 - sectus
5
不需要使用函数编写正确的输入数据字符串,只需编写不需要转义或已经转义过的正确字符串。mysql_real_escape_string() 可能是为了预防注入攻击而设计的,但它的唯一作用就是防止注入攻击。 - Nazca
22
警告!mysql_real_escape_string()并非绝对可靠,可能存在绕过的 SQL 注入漏洞。 - eggyal
10
mysql_real_escape_string现已被弃用,因此不再是可行的选择。它将从PHP中在未来被移除。最好转向PHP或MySQL开发人员推荐的方法。 - jww
显示剩余4条评论

487

安全警告:本答案不符合安全最佳实践。 转义不能有效防止SQL注入,请使用预处理语句。请自行承担以下策略的风险。

您可以尝试简单地使用以下代码:

$safe_variable = mysqli_real_escape_string($dbConnection, $_POST["user-input"]);
mysqli_query($dbConnection, "INSERT INTO table (column) VALUES ('" . $safe_variable . "')");

这并不能解决所有问题,但它是一个非常好的起点。我省略了一些显而易见的内容,例如检查变量的存在性和格式(数字、字母等)。


21
如果您不引用字符串,它仍然可以受到注入攻击。以 $q = "SELECT col FROM tbl WHERE x = $safe_var"; 为例,将 $safe_var 设置为 1 UNION SELECT password FROM users 在此情况下可行,因为缺少引号。还可以使用 CONCATCHR 将字符串注入查询中。 - Polynomial
31
警告!mysql_real_escape_string()不是万无一失的,详情请见:https://dev59.com/Ym025IYBdhLWcg3w4qEx。 - eggyal
13
mysql_real_escape_string现已被弃用,因此不再是可选项。它将在未来从PHP中删除。最好转向PHP或MySQL推荐的选项。 - jww
以上代码无法正常工作。mysqli_real_escape_string函数需要两个参数。请查看 - Abhijeet Kambli

396
无论你最终使用何种方式,请确保检查您的输入是否已被魔术引号或其他善意的垃圾代码破坏,并且必要时,通过去除反斜杠等方法来进行数据过滤。

13
确实,开启 magic_quotes 只会鼓励不良实践。然而,有时你无法完全控制环境 - 要么你无权管理服务器,要么你的应用程序必须与依赖于该配置的应用程序共存(令人发抖)。因此,编写可移植的应用程序是很好的选择,但显然如果你控制着部署环境,例如它是内部应用程序或仅在你特定的环境中使用,则这种努力将是浪费的。 - Rob
29
从PHP 5.4开始,“魔术引号”这种可怕的东西被废除了。庆幸脱离了这个麻烦。 - BryanH

377

弃用警告: 此答案的示例代码(如问题的示例代码)使用了 PHP 的 MySQL 扩展,该扩展在 PHP 5.5.0 中被弃用,并在 PHP 7.0.0 中完全删除。

安全警告:此答案未符合安全最佳实践。转义不足以防止 SQL 注入, 使用预处理语句代替。使用下面概述的策略存在风险。(此外,在 PHP 7 中,mysql_real_escape_string() 已被移除。)

参数化查询和输入验证是正确的方法。即使使用了mysql_real_escape_string(),仍可能发生 SQL 注入的许多情况。

以下示例容易受到 SQL 注入攻击:

$offset = isset($_GET['o']) ? $_GET['o'] : 0;
$offset = mysql_real_escape_string($offset);
RunQuery("SELECT userid, username FROM sql_injection_test LIMIT $offset, 10");

或者

$order = isset($_GET['o']) ? $_GET['o'] : 'userid';
$order = mysql_real_escape_string($order);
RunQuery("SELECT userid, username FROM sql_injection_test ORDER BY `$order`");

在这两种情况下,你都不能使用'来保护封装性。 Source意外的SQL注入(当转义不足时)

3
如果您采用一种输入验证技术,并根据长度、类型和语法以及业务规则将用户输入进行身份验证,那么您就可以防止 SQL 注入攻击。 - Josip Ivic

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接