如果插入用户输入而不修改 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。
你基本上有两个选项来实现这个目标:
使用PDO (适用于任何支持的数据库驱动程序):
$stmt = $pdo->prepare('SELECT * FROM employees WHERE name = :name');
$stmt->execute(array('name' => $name));
foreach ($stmt as $row) {
// do something with $row
}
使用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 之类的步骤来提升抽象层是有用的 / 更容易的。
请注意,您询问的案例非常简单,更复杂的案例可能需要更复杂的方法。尤其是:
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 语法关键字(例如AND
, DESC
等)的问题,但在这种情况下,白名单似乎是唯一的方法。
虽然对 SQL 注入保护的最佳实践达成了一致意见,但仍有许多不良做法。其中一些根深蒂固的 PHP 用户心中。例如,在这个页面上(虽然大多数访问者看不到) 超过 80 个已删除的答案 - 由于质量差或促进不良和过时的做法而被社区删除。更糟糕的是,一些不好的答案不是删除而是繁荣。
例如, (1) 是(2) 仍然(3) 许多(4) 答案(5) ,包括第二个最受欢迎的答案,建议你手动字符串转义 - 一种被证明是不安全的过时方法。
或者有一个稍微好一点的答案,它表明了另一种字符串格式化方法,甚至将它作为最终灵丹妙药。当然,事实并非如此。这种方法并不比常规字符串格式更好,但它保留了所有缺点:它仅适用于字符串,并且与任何其他手动格式一样,它基本上是可选的,非强制性的度量,容易出现任何类型的人为错误。
我认为这一切都是因为一个非常古老的迷信,得到了诸如OWASP或PHP 手册等权威机构的支持,它宣称了 “逃避” 和防止 SQL 注入之间的平等。
无论 PHP 手册*_escape_string
所说的是什么, *_escape_string
决不会使数据安全,而且从来没有打算过。除了对字符串以外的任何 SQL 部分无用之外,手动转义是错误的,因为它是手动的,与自动化相反。
并且 OWASP 使情况变得更糟,强调逃避用户输入这完全是胡说八道:在注入保护的背景下应该没有这样的词。每个变量都有潜在危险 - 无论来源如何!或者,换句话说 - 每个变量都必须正确格式化以便放入查询中 - 无论来源是什么。这是重要的目的地。在开发人员开始将绵羊与山羊分开的那一刻(想想某个特定变量是否 “安全”),他迈出了迈向灾难的第一步。更不用说即使是措辞也表明在入口点大量逃避,类似于非常神奇的引用功能 - 已经被鄙视,弃用和删除。
因此,与无论 “逃离”,准备好的语句是 ,确实是从 SQL 注入(如适用)保护措施。
如果您仍然不相信,这里是我写的“Hitchhiker 的 SQL 注入预防指南”的逐步解释,我详细解释了所有这些问题,甚至编写了一个完全致力于不良做法及其披露的部分。