协慌网

登录 贡献 社区

如何在 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 以外的数据库,你可以参考一个特定于驱动程序的第二个选项(例如pg_prepare()pg_execute()用于 PostgreSQL)。 PDO 是通用选项。

正确设置连接

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

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

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

在上面的例子中,错误模式并不是绝对必要的, 但建议添加它 。这样,当出现问题时,脚本不会因Fatal Error而停止。它使开发人员有机会catch任何作为PDOException throw错误。

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

虽然您可以在构造函数的选项中设置charset ,但重要的是要注意 PHP 的旧版本(<5.3.6) 默认忽略 DSN 中的 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(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 中完全删除。

如果您使用的是最新版本的 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方法,则您将在下面的注释中遇到Polynomial描述的问题。这种情况比较棘手,因为整数不会被引号括起来,所以你可以通过验证用户输入只包含数字来处理。
  • 可能还有其他我不知道的情况。您可能会发现是一个有用的资源,可以解决您可能遇到的一些更微妙的问题。

这里的每个答案仅涵盖部分问题。
实际上,我们可以动态添加四个不同的查询部分:

  • 一个字符串
  • 一个号码
  • 标识符
  • 语法关键字。

准备好的报表只涵盖其中的两个

但有时我们必须使查询更加动态,同时添加运算符或标识符。
因此,我们需要不同的保护技术。

通常,这种保护方法基于白名单 。在这种情况下,每个动态参数都应该在脚本中进行硬编码,并从该集合中进行选择。
例如,要进行动态排序:

$orders  = array("name","price","qty"); //field names
$key     = array_search($_GET['sort'],$orders)); // see if we have such a name
$orderby = $orders[$key]; //if not, first one will be set automatically. smart enuf :)
$query   = "SELECT * FROM `table` ORDER BY $orderby"; //value is safe

但是,还有另一种方法来保护标识符 - 转义。只要你有一个引用的标识符,你可以通过加倍来逃避内部的反引号。

作为进一步的步骤,我们可以从准备好的语句中借用一些占位符(代表查询中的实际值的代理),并发明另一种类型的占位符 - 标识符占位符。

因此,长话短说:它是一个占位符 ,没有准备好的声明可以被视为银弹。

因此,一般性建议可以表述为
只要您使用占位符向查询添加动态部分(当然这些占位符已正确处理),您就可以确保查询是安全的

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

更新

虽然对 SQL 注入保护的最佳实践达成了一致意见,但仍有许多不良做法。其中一些根深蒂固的 PHP 用户心中。例如,在这个页面上(虽然大多数访问者看不到) 超过 80 个已删除的答案 - 由于质量差或促进不良和过时的做法而被社区删除。更糟糕的是,一些不好的答案不是删除而是繁荣。

例如, (1) 是(2) 仍然(3) 许多(4) 答案(5) ,包括第二个最受欢迎的答案,建议你手动字符串转义 - 一种被证明是不安全的过时方法。

或者有一个稍微好一点的答案,它表明了另一种字符串格式化方法,甚至将它作为最终灵丹妙药。当然,事实并非如此。这种方法并不比常规字符串格式更好,但它保留了所有缺点:它仅适用于字符串,并且与任何其他手动格式一样,它基本上是可选的,非强制性的度量,容易出现任何类型的人为错误。

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

无论 PHP 手册*_escape_string所说的是什么, *_escape_string决不会使数据安全,而且从来没有打算过。除了对字符串以外的任何 SQL 部分无用之外,手动转义是错误的,因为它是手动的,与自动化相反。

并且 OWASP 使情况变得更糟,强调逃避用户输入这完全是胡说八道:在注入保护的背景下应该没有这样的词。每个变量都有潜在危险 - 无论来源如何!或者,换句话说 - 每个变量都必须正确格式化以便放入查询中 - 无论来源是什么。这是重要的目的地。在开发人员开始将绵羊与山羊分开的那一刻(想想某个特定变量是否 “安全”),他迈出了迈向灾难的第一步。更不用说即使是措辞也表明在入口点大量逃避,类似于非常神奇的引用功能 - 已经被鄙视,弃用和删除。

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

如果您仍然不相信,这里是我写的“Hitchhiker 的 SQL 注入预防指南”的逐步解释,我详细解释了所有这些问题,甚至编写了一个完全致力于不良做法及其披露的部分。