如何防止 PHP 中进行 SQL 注入?

如果将用户输入未经修改地插入到 SQL 查询中,则应用程序容易受到SQL 注入的攻击,如以下示例所示:

$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;--')

如何防止这种情况的发生?

答案

使用准备好的语句和参数化查询。这些是独立于任何参数发送到数据库服务器并由数据库服务器解析的 SQL 语句。这样,攻击者就不可能注入恶意 SQL。

您基本上有两种选择可以实现此目的:

  1. 使用PDO (对于任何受支持的数据库驱动程序):

    $stmt = $pdo->prepare('SELECT * FROM employees WHERE name = :name');
    
    $stmt->execute(array('name' => $name));
    
    foreach ($stmt as $row) {
        // Do something with $row
    }
  2. 使用MySQLi (对于 MySQL):

    $stmt = $dbConnection->prepare('SELECT * FROM employees WHERE name = ?');
    $stmt->bind_param('s', $name); // 's' specifies the variable type => 'string'
    
    $stmt->execute();
    
    $result = $stmt->get_result();
    while ($row = $result->fetch_assoc()) {
        // Do something with $row
    }

如果要连接到 MySQL 以外的数据库,则可以引用特定于驱动程序的第二个选项(例如,PostgreSQL 的pg_prepare()pg_execute() )。 PDO 是通用选项。

正确设置连接

请注意,使用PDO访问 MySQL 数据库时,默认情况下不使用 真实的预处理语句。要解决此问题,您必须禁用对准备好的语句的仿真。使用 PDO 创建连接的示例如下:

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

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

在上面的示例中,错误模式不是严格必需的, 但建议添加它 。这样,当出现问题时,脚本不会因Fatal Error而停止。并且它为开发人员提供了catch作为PDOException throw n 的任何错误的机会。

但是, 强制性的是第一行setAttribute()行,它告诉 PDO 禁用模拟的准备好的语句并使用实际的准备好的语句。这可以确保在将语句和值发送到 MySQL 服务器之前,不会对 PHP 进行解析(这样可能会使攻击者没有机会注入恶意 SQL)。

尽管您可以在构造函数的选项中设置charset ,但必须注意,PHP 的 “较旧” 版本(5.3.6 之前的版本)在 DSN 中默默地忽略了字符集参数

说明

您传递来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(array('column' => $unsafeValue));

准备好的语句可以用于动态查询吗?

尽管您仍可以对查询参数使用准备好的语句,但是无法对动态查询本身的结构进行参数化,并且无法对某些查询功能进行参数化。

对于这些特定方案,最好的办法是使用白名单过滤器来限制可能的值。

// Value whitelist
// $dir can only be 'DESC', otherwise it will be 'ASC'
if (empty($dir) || $dir !== 'DESC') {
   $dir = 'ASC';
}

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

安全警告 :此答案与安全最佳做法不符。 转义不足以防止 SQL 注入 ,请改用准备好的语句 。使用以下概述的策略需要您自担风险。 (此外,PHP 7 中删除了mysql_real_escape_string() 。)

如果您使用的是最新版本的 PHP,则下面概述的mysql_real_escape_string选项将不再可用(尽管mysqli::escape_string是现代的等效项)。 mysql_real_escape_string选项仅对旧版本 PHP 上的旧代码有意义。


您有两种选择 - 在unsafe_variable转义特殊字符,或使用参数化查询。两者都可以保护您免受 SQL 注入的侵害。参数化查询被认为是更好的做法,但是在使用它之前,需要先在 PHP 中更改为更新的 MySQL 扩展。

我们将介绍先转义的较低影响字符串。

//Connect

$unsafe_variable = $_POST["user-input"];
$safe_variable = mysql_real_escape_string($unsafe_variable);

mysql_query("INSERT INTO table (column) VALUES ('" . $safe_variable . "')");

//Disconnect

另请参见mysql_real_escape_string函数的详细信息。

要使用参数化查询,您需要使用MySQLi而不是MySQL函数。要重写您的示例,我们将需要以下内容。

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

    // TODO - Check that connection was successful.

    $unsafe_variable = $_POST["user-input"];

    $stmt = $mysqli->prepare("INSERT INTO table (column) VALUES (?)");

    // TODO check that $stmt creation succeeded

    // "s" means the database expects a string
    $stmt->bind_param("s", $unsafe_variable);

    $stmt->execute();

    $stmt->close();

    $mysqli->close();
?>

您将要阅读的关键功能是mysqli::prepare

另外,正如其他人所建议的那样,您可能会发现有用 / 更容易地使用PDO 之类的东西来增强抽象层。

请注意,您所询问的案例是一个相当简单的案例,更复杂的案例可能需要更复杂的方法。尤其是:

  • 如果要基于用户输入来更改 SQL 的结构,则参数化查询将无济于事,并且mysql_real_escape_string没有涵盖所需的转义。在这种情况下,最好将用户的输入通过白名单,以确保仅允许 “安全” 值通过。
  • 如果您在某种情况下使用来自用户输入的整数并采取mysql_real_escape_string方法,则将遭受以下注释中的多项式描述的问题。这种情况比较棘手,因为整数不会被引号引起来,因此您可以通过验证用户输入仅包含数字来进行处理。
  • 可能还有其他我不知道的情况。您可能会发现是一些有用的资源,可以解决您遇到的一些更细微的问题。

这里的每个答案仅涵盖部分问题。实际上,我们可以动态地将四个查询部分添加到 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,您必须将引号字符加倍以对其进行转义 。对于其他其他 DBMS,转义规则将有所不同。

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

因此,一般性建议可以表述为

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

更新资料

尽管就 SQL 注入保护的最佳做法达成了普遍共识,但仍然存在许多不良做法。而且其中有些根深蒂固于 PHP 用户的思想中。例如,在此页面上,(尽管对大多数访问者不可见) 有 80 多个已删除的答案 - 由于质量低劣或推广不良和过时的做法而被社区删除。更糟糕的是,一些错误的答案并没有被删除,反而会蒸蒸日上。

例如, There (1) 有(2) still(3) many(4)个 答案(5) ,其中第二个最重要的答案是建议您手动转义字符串 - 一种已被证明不安全的过时方法。

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

我认为所有这些都是由于一种非常古老的迷信,并得到诸如OWASPPHP 手册之类的权威的支持,该权威宣称在 “转义” 和防止 SQL 注入之间应保持平等。

不管 PHP 手册使用了多*_escape_string*_escape_string绝对不会保证数据的安全 ,也从未打算这样做。除了对于字符串以外的任何 SQL 部分都没有用之外,手动转义是错误的,因为它是手动的,与自动的相反。

OWASP 更加糟糕,它强调逃避用户输入 ,这完全是胡说八道:在注入保护的上下文中不应有这样的措辞。每个变量都有潜在的危险 - 无论来源如何!或者,换句话说 - 每个变量都必须正确设置格式才能放入查询中 - 不管源是什么。重要的是目的地。当开发人员开始将绵羊与山羊分开时(考虑某个特定变量是否 “安全”),他 / 她迈出了走向灾难的第一步。更不用说即使是措辞也建议在入口点进行大量转义,类似于非常讨厌的引号功能 - 已被轻视,不推荐使用和删除。

因此,与无论 “逃离”,准备好的语句 ,确实是从 SQL 注入保护措施(如适用)。

我建议使用PDO (PHP 数据对象)运行参数化的 SQL 查询。

这不仅可以防止 SQL 注入,还可以加快查询速度。

而且,通过使用 PDO 而不是mysql_mysqli_pgsql_函数,可以使应用程序从数据库中抽象一些,这种情况很少发生,您必须切换数据库提供程序。

使用PDO和准备好的查询。

$conn是一个PDO对象)

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

如您所见,人们建议您最多使用准备好的语句。没错,但是当每个进程执行一次查询时,将会有轻微的性能损失。

我当时面对这个问题,但我想我以非常复杂的方式解决了该问题 - 黑客用来避免使用引号的方式。我将其与模拟的准备好的语句结合使用。我用它来防止各种可能的 SQL 注入攻击。

我的方法:

  • 如果您希望输入是整数,请确保它确实是整数。在像 PHP 这样的变量类型语言中,这是非常重要的。例如,您可以使用这个非常简单但功能强大的解决方案: sprintf("SELECT 1,2,3 FROM table WHERE 4 = %u", $input);

  • 如果您希望从整数十六进制中得到任何其他结果, 则为 。如果您将其十六进制化,则可以完全避免所有输入。在 C / C ++ 中,有一个名为mysql_hex_string()的函数,在 PHP 中,您可以使用bin2hex()

    不必担心转义的字符串将是其原始长度的((2*input_length)+1) ,因为即使您使用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')

十六进制是完美的转义。无法注入。

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 并全部从 information_schema.tables 中选择 table_name

现在只提取表结构:

SELECT ... WHERE id = -1 并都从 information_schema.column 中选择 column_name,其中 table_name = 0x61727469636c65

然后,只需选择所需的任何数据即可。是不是很酷?

但是,如果可注入站点的编码器将其十六进制化,则无法进行注入,因为查询将如下所示: SELECT ... WHERE id = UNHEX('2d312075...3635')

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

安全警告 :此答案与安全最佳做法不符。 转义不足以防止 SQL 注入 ,请改用准备好的语句 。使用以下概述的策略需要您自担风险。 (此外,PHP 7 中删除了mysql_real_escape_string() 。)

重要

公认的答案所示,防止 SQL 注入的最佳方法是使用Prepared Statements 而不是 escaping

有一些库,例如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 注入预防中找到更多详细信息。

您可以执行以下基本操作:

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

这不会解决所有问题,但这是一个很好的垫脚石。我忽略了一些明显的项目,例如检查变量的存在,格式(数字,字母等)。

无论您最终使用什么,请确保您尚未检查输入内容是否已被magic_quotes或其他一些magic_quotes垃圾弄乱了,如果有必要,请通过stripslashes或其他方式对其进行清理。

不建议使用的警告:此答案的示例代码(如问题的示例代码)使用 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`");

在两种情况下,都不能使用'保护封装。

意外的 SQL 注入(当转义不够时)