SQL 注入是一种控制数据库查询的技术,通常会导致机密性受损。在某些情况下(例如,如果SELECT 'evil code here' INTO OUTFILE '/var/www/reverse_shell.php'
成功),这可能会导致服务器完全接管。
由于代码注入(包括 SQL、LDAP、OS 命令和 XPath 注入技术)一直位居OWASP 十大漏洞之首,因此对于试图涉足应用程序安全领域的博主来说,这是一个热门话题。
虽然更多人分享有关应用程序安全的知识是一件好事,但不幸的是,在 Internet 上流传的许多建议(尤其是在搜索引擎上排名靠前的古老博客文章中)已经过时、无意中误导,而且往往很危险。
如何保证每次都(几乎)防止 SQL 注入*
使用准备好的语句,也称为参数化查询。例如:
/**
* Note: This code is provided for demonstration purposes.
* In general, you want to add some application logic to validate
* the incoming parameters. You do not need to escape anything.
*/
$stmt = $pdo->prepare('SELECT * FROM blog_posts WHERE YEAR(created) = ? AND MONTH(created) = ?');
if ($stmt->execute([$_GET['year'], $_GET['month']])) {
$posts = $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
准备好的语句消除了 Web 应用程序中 SQL 注入的任何可能性。无论在$_GET
这里传递什么变量,SQL 查询的结构都不能被攻击者改变(当然,除非你已经PDO::ATTR_EMULATE_PREPARES
启用,这意味着你没有真正使用准备好的语句)。
注意:如果您尝试PDO::ATTR_EMULATE_PREPARES
关闭,某些数据库驱动程序的某些版本可能会忽略您。要格外小心,请将 DSN 中的字符集显式设置为您的应用程序和数据库都使用的字符集(例如UTF-8
,如果您使用的是 MySQL,则会混淆地称为utf8mb4
)。
Prepared Statements 解决了应用程序安全的一个基本问题:它们通过将要处理的数据与对所述数据进行操作的指令分开,将它们发送到完全独立的数据包中。这与使堆栈/堆溢出成为可能的基本问题相同。
只要您从不将用户提供的变量或环境变量与 SQL 语句连接(并确保您没有使用模拟准备),您就可以出于所有实际目的将 SQL 注入从您的清单中永久删除。
重要的警告和说明*
准备好的语句保护您的 Web 应用程序和数据库服务器之间的交互(如果它们在不同的机器上,它们也应该通过 TLS 进行通信)。攻击者仍有可能将有效负载存储在可能是危险的字段中,例如存储过程。我们将此称为高阶 SQL 注入(链接的 Stack Overflow 答案将它们称为“二阶”,但执行初始查询之后的任何内容都应该是分析的目标)。
在这种情况下,我们的建议是不要编写存储过程,以便它们创建高阶 SQL 注入点。
清理输入呢?
很多人都看过XKCD 2007 年关于 SQL 注入漏洞的漫画。它经常被引用或包含在安全会议演讲中,尤其是针对新人的演讲。漫画在提高人们对数据库查询中用户输入危险的认识方面做了很多好事,但根据 2015 年对所涉及问题的理解,它对清理数据库输入的建议只是成功的一半。
忘记清理输入可能会更好(在大多数情况下)
虽然可以通过在将传入数据流发送到数据库驱动程序之前重写传入的数据流来防止攻击,但它充满了危险的细微差别和模糊的边缘情况。(强烈推荐上一句中的两个链接。)
除非您想花时间研究并完全掌握您的应用程序使用或接受的每种 Unicode 格式,否则最好不要尝试清理您的输入。准备好的语句在防止 SQL 注入方面比转义字符串更有效。
此外,更改传入的数据流可能会导致数据损坏,尤其是在处理原始二进制 blob(例如图像或加密消息)时。
准备好的语句更容易,并且可以保证防止 SQL 注入。
如果用户输入永远没有机会改变查询字符串,它永远不会导致代码执行。准备好的语句将代码与数据完全分开。
XKCD 的作者 Randall Munroe 是一个聪明的 cookie。如果今天写这部漫画,黑客妈妈角色可能会这样说:
输入仍应验证
验证与卫生不同。准备好的语句可以防止 SQL 注入,但它们不能使您免受不良数据的影响。对于大多数情况,filter_var()
这里很有用。
$email = filter_var($_POST['email'], FILTER_VALIDATE_EMAIL);
if (empty($email)) {
throw new \InvalidArgumentException('Invalid email address');
}
注意: filter_var()
验证给定的电子邮件地址字符串是否符合 RFC 规范。它不保证该地址有一个打开的收件箱,也不检查域名是否已注册。有效的电子邮件地址仍然不能安全地用于原始查询,也不能在未经过滤以防止 XSS 攻击的情况下显示在网页上。
列和表标识符呢?
由于列和表标识符是查询结构的一部分,因此您不能对它们进行参数化。因此,如果您正在开发的应用程序需要用户选择表或列的动态查询结构,您应该选择白名单。
白名单是一种应用程序逻辑策略,它明确地只允许少数接受的值,而拒绝其余的值或使用合理的默认值。将其与仅禁止已知不良输入的黑名单进行对比。在大多数情况下,白名单比黑名单更安全。
$qs = 'SELECT * FROM photos WHERE album = ?';
// Use switch-case for an explicit whitelist
switch ($_POST['orderby']) {
case 'name':
case 'exifdate':
case 'uploaded':
// These strings are trusted and expected
$qs .= ' ORDER BY ' . $_POST['orderby'];
if (!empty($_POST['asc'])) {
$qs .= ' ASC';
} else {
$qs .= ' DESC';
}
break;
default:
// Some other value was passed. Let's just order by photo ID in descending order.
$qs .= ' ORDER BY photoid DESC';
}
$stmt = $db->prepare($qs);
if ($stmt->execute([$_POST['album_id']])) {
$photos = $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
如果您允许最终用户提供表和/或列名,因为标识符无法参数化,您仍然必须求助于转义。在这些情况下,我们建议如下:
- 不要:只是转义 SQL 元字符(例如
'
) - Do:过滤掉所有不允许的字符。
以下代码片段只允许以大写或小写字母开头的表名,后跟任意数量的字母数字字符和下划线。
if (!preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $table)) {
throw new AppSpecificSecurityException("Possible SQL injection attempt.");
}
// And now you can safely use it in a query:
$stmt = $pdo->prepare("SELECT * FROM {$table}");
if ($stmt->execute()) {
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
如果使用准备好的语句看起来太麻烦怎么办?
开发人员第一次遇到准备好的语句时,他们可能会对被迫编写大量冗余代码(准备、执行、获取;准备、执行、获取;令人作呕)的前景感到沮丧。
因此,Paragon Initiative Enterprises 的团队编写了一个名为EasyDB的 PHP 库。
如何使用 EasyDB
有两种方法可以EasyDB
在您的代码中开始使用:
- 您可以使用
EasyDB
包装现有PDO
实例。 - 如果您熟悉 PDO 构造函数,则可以将相同的参数传递给
\ParagonIE\EasyDB\Factory::create()
。
// First method:
$pdo = new \PDO(
'mysql;host=localhost;dbname=something',
getenv('MYSQL_USERNAME'),
getenv('MYSQL_PASSWORD')
);
$db = \ParagonIE\EasyDB\EasyDB($pdo);
// Second method:
$db = \ParagonIE\EasyDB\Factory::create(
'mysql;host=localhost;dbname=something',
getenv('MYSQL_USERNAME'),
getenv('MYSQL_PASSWORD')
);
(getenv()
最好用phpdotenv 之类的库作为补充。)
一旦有了EasyDB
对象,您就可以开始利用其简化的界面来快速开发安全的数据库感知应用程序。一些例子包括:
使用准备好的语句进行安全数据库查询
/**
* As mentioned previously, you should perform validation on all input.
* Not necessarily for security reasons, but because well-designed software
* validates all user-supplied input and informs them how to correct it.
*
* For the sake of easy auditing, you probably don't want to pass $_GET,
* $_POST, other superglobals. Instead, validate and store the results
* in a local variable.
*/
$data = $db->safeQuery(
'SELECT * FROM transactions WHERE type = ? AND amount >= ? AND date >= ?',
[
$_POST['ttype'],
$_POST['minimum'],
$_POST['since']
]
);
从数据库表中选择多行
/**
* Although safe from SQL injection, this example snippet does not
* validate its input. In real applications, please check that any data
* your script is given is valid.
*/
$rows = $db->run(
'SELECT * FROM comments WHERE blogpostid = ? ORDER BY created ASC',
$_GET['blogpostid']
);
foreach ($rows as $row) {
$template_engine->render('comment', $row);
}
从数据库表中选择一行
/**
* Although safe from SQL injection, this example snippet does not
* validate its input. In real applications, please check that any data
* your script is given is valid.
*/
$userData = $db->row(
"SELECT * FROM users WHERE userid = ?",
$_GET['userid']
);
在数据库表中插入新行
/**
* Although safe from SQL injection, this example snippet does not
* validate its input. In real applications, please check that any data
* your script is given is valid.
*/
$db->insert('comments', [
'blogpostid' => $_POST['blogpost'],
'userid' => $_SESSION['user'],
'comment' => $_POST['body'],
'parent' => isset($_POST['replyTo']) ? $_POST['replyTo'] : null
]);
新:动态查询EasyStatement
如果您使用的是 EasyDB 1.2.0 或 2.2.0(或更高版本),您可以使用新的EasyStatement
API(由Woody Gilk提供)生成动态查询。
$statement = EasyStatement::open()
->with('last_login IS NOT NULL');
if (strpos($_POST['search'], '@') !== false) {
// Perform a username search
$statement->orWith('username LIKE ?', '%' . $db->escapeLikeValue($_POST['search']) . '%');
} else {
// Perform an email search
$statement->orWith('email = ?', $_POST['search']);
}
// The statement can compile itself to a string with placeholders:
echo $statement; /* last_login IS NOT NULL OR username LIKE ? */
// All the values passed to the statement are captured and can be used for querying:
$user = $db->single("SELECT * FROM users WHERE $statement", $statement->values());
EasyStatement
API 支持可变参数 ( ) x IN (1, 2, 3)
:
// Statements also handle translation for IN conditions with variable arguments,
// using a special ?* placeholder:
$roles = [1];
if ($_GET['with_managers']) {
$roles[] = 2;
}
$statement = EasyStatement::open()->in('role IN (?*)', $roles);
// The ?* placeholder is replaced by the correct number of ? placeholders:
echo $statement; /* role IN (?, ?) */
// And the values will be unpacked accordingly:
print_r($statement->values()); /* [1, 2] */
最后,使用EasyStatement
,您还可以将条件组合在一起:
// Statements can also be grouped when necessary:
$statement = EasyStatement::open()
->group()
->with('subtotal > ?')
->andWith('taxes > ?')
->end()
->orGroup()
->with('cost > ?')
->andWith('cancelled = 1')
->end();
echo $statement; /* (subtotal > ? AND taxes > ?) OR (cost > ? AND cancelled = 1) */
尽管上述查询具有动态特性,但准备好的语句一直在使用。
为动态查询转义标识符(列/表/视图名称)
EasyStatement
与手动执行此操作相比,应该首选新API。
$whiteListOfColumnNames = ['username', 'email', 'last_name', 'first_name'];
$qs = 'SELECT * FROM some_table';
$and = false;
if (!empty($where)) {
$qs .= ' WHERE ';
foreach (\array_keys($where) as $column) {
if (!\in_array($column, $whiteListOfColumnNames)) {
continue;
}
if ($and) {
$qs .= ' AND ';
}
$qs .= $db->escapeIdentifier($column).' = ?';
$and = true;
}
}
$qs .= ' ORDER BY rowid DESC';
// And then to fetch some data
$data = $db->run($qs, \array_values($where));
注意:该escapeIdentifier()
方法适用于转义字段和表名的这种非常特殊的用例,不应用于转义用户输入。
能否使用 EasyDB 满足业务需求?
是的。我们选择EasyDB
在非常宽松的许可证 (MIT)下发布,因为我们希望在整个社区中促进采用更好的安全实践。随意EasyDB
在您的任何项目中使用,甚至是商业项目。你不欠我们任何东西。
我应该在我的框架的 ORM 或组件上使用 EasyDB 吗?
如果您已经在使用您熟悉的提供安全默认值的工具(例如大多数现代 PHP 框架),请不要放弃它们而转而使用 EasyDB。简单并不意味着“适合所有用例”。
如果您使用的CMS 没有遵循安全的最佳实践,您应该通过让 CMS 采用非模拟的准备好的语句来解决上游问题。
PHP 和 PDO 特定的建议
如果您是一名希望充分利用 PDO 的 PHP 开发人员,并且您不想将 EasyDB 添加到您的项目中,我们建议您更改两个默认设置:
- 关闭模拟准备。这可以确保您获得实际准备好的语句。
- 设置错误模式以抛出异常。这使您不必检查结果,
PDOStatement::execute()
并使您的代码减少冗余。
$pdo = new PDO(/* Fill in the blank */);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
因为PDO::ATTR_EMULATE_PREPARES
设置为false
,我们得到了真正准备好的语句,并且因为我们设置PDO::ATTR_ERRMORE
为PDO::ERRMODE_EXCEPTION
,而不是这个......
$stmt = $pdo->prepare("SELECT * FROM foo WHERE first_name = ? AND last_name = ?");
if ($stmt->execute([$_GET['first_name'], $_GET['last_name'])) {
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
} else {
// Handle error here.
}
$args = [
json_encode($_GET)
(new DateTime())->format('Y-m-d H:i:s')
];
$insert = $pdo->prepare("INSERT INTO foo_log (params, time) VALUES (?, ?);");
if (!$insert->execute($args)) {
// Handle error here.
}
...您可以像这样编写代码:
try {
$stmt = $pdo->prepare("SELECT * FROM foo WHERE first_name = ? AND last_name = ?");
$stmt->execute([$_GET['first_name'], $_GET['last_name']);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
$args = [
json_encode($_GET),
(new DateTime())->format('Y-m-d H:i:s')
];
$pdo->prepare("INSERT INTO foo_log (params, time) VALUES (?, ?);")
->execute($args);
} catch (PDOException $ex) {
// Handle error here.
}
更好的安全性、简洁性和更好的可读性。你还能要求什么呢?
让我们确保规范
我们的软件开发人员有责任确保我们开发的应用程序不受恶意行为者的攻击。我们是前线的人(与我们的系统管理员一起,当我们也没有履行该角色时)防御零日漏洞。
不是政客。不是反病毒供应商。不是法医调查员。
安全性始于开发人员。
Paragon Initiative Enterprises 开发的工具和平台设计为默认安全,以减少客户和同行的认知负担。我们通过我们的 Github 组织与社区分享我们的许多创新(无论大小)。我们为缺乏类似专业知识或担心其网络或平台安全性的公司提供技术咨询服务。
在接下来的几周内,我们将讨论其他常见的安全漏洞,这些漏洞因糟糕或无用的建议泛滥而受到影响,以及一些有效的项目。我们挑战其他库和框架开发人员花一些时间考虑他们自己的项目的设计策略,使以安全的方式做事比以不安全的方式做事更容易。如果您需要帮助,请给我们发送电子邮件。
更新
我们更新了这篇文章,以反映来自 PHP 开发者社区的三位成员@htimoh、@enygma和@suckup_de的建议,以解释标识符转义和输入验证。