协慌网

登录 贡献 社区

PDO 准备好的语句是否足以防止 SQL 注入?

假设我有这样的代码:

$dbh = new PDO("blahblah");

$stmt = $dbh->prepare('SELECT * FROM users where username = :username');
$stmt->execute( array(':username' => $_REQUEST['username']) );

PDO 文档说:

准备好的语句的参数不需要用引号引起来;司机为您处理。

那真的是我避免 SQL 注入所需要做的一切吗?真的那么容易吗?

您可以假设 MySQL 会有所作为。另外,我真的只是对针对 SQL 注入使用准备好的语句感到好奇。在这种情况下,我不在乎 XSS 或其他可能的漏洞。

答案

简短的回答是 “否” ,PDO 准备将不会为您防御所有可能的 SQL-Injection 攻击。对于某些晦涩的边缘情况。

我正在修改此答案以谈论 PDO ...

长答案不是那么容易。它基于此处演示的攻击。

攻击

因此,让我们开始展示攻击...

$pdo->query('SET NAMES gbk');
$var = "\xbf\x27 OR 1=1 /*";
$query = 'SELECT * FROM test WHERE name = ? LIMIT 1';
$stmt = $pdo->prepare($query);
$stmt->execute(array($var));

在某些情况下,这将返回 1 行以上。让我们剖析这里发生的事情:

  1. 选择字符集

    $pdo->query('SET NAMES gbk');

    为了使这种攻击起作用,我们需要服务器在连接上期望的编码既编码为'如 ASCII 即0x27 也要具有某些字符的最终字节为 ASCII \0x5c 。事实证明,默认情况下,MySQL 5.6 默认支持 5 种此类编码: big5cp932gb2312gbksjis 。我们将在此处gbk

    现在,在此处SET NAMES的使用非常重要。这将在服务器上设置字符集。还有另一种方法,但是我们会尽快到达那里。

  2. 有效载荷

    我们将用于此注入的有效负载从字节序列0xbf27 。在gbk ,这是一个无效的多字节字符;在latin1 ,它是字符串¿' 。请注意,在latin1gbk0x27自身是一个文本'字符。

    之所以选择此有效负载,是因为如果在其上addslashes() ,则会在'字符\0x5c因此,我们将获得0xbf5c27 ,它在gbk是两个字符序列: 0xbf5c后跟0x27 。换句话说,就是一个有效字符,后跟一个未转义的' 。但是我们没有使用addslashes() 。因此,继续下一步...

  3. $ stmt-> execute()

    这里要意识到的重要一点是,默认情况下,PDO不会执行真正的预准备语句。它模拟它们(对于 MySQL)。因此,PDO 在内部构建查询字符串, mysql_real_escape_string() (MySQL C API 函数)。

    的 C API 调用mysql_real_escape_string()不同于addslashes()在它知道的连接字符集。因此,它可以为服务器期望的字符集正确执行转义。但是,到目前为止,客户端认为我们仍在使用latin1进行连接,因为我们从未告诉过它。我们确实告诉服务器我们正在使用gbk ,但是客户端仍然认为它是latin1

    因此,对mysql_real_escape_string()的调用将插入反斜杠,并且我们在 “转义” 内容中'实际上,如果我们要在gbk字符集中$var

    縗' OR 1=1 /*

    这正是攻击所需要的。

  4. 查询

    这部分只是一个形式,但这是呈现的查询:

    SELECT * FROM test WHERE name = '縗' OR 1=1 /*' LIMIT 1

恭喜,您刚刚使用 PDO Prepared Statements 成功攻击了一个程序...

简单修复

现在,值得注意的是,可以通过禁用模拟的准备好的语句来防止这种情况:

$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

通常,这将导致一个真正的准备好的语句(即,数据在与查询分开的数据包中发送)。但是,要知道,PDO 会悄悄地退回到仿真陈述,MySQL 不能原生准备:那些可被在手册中,但要注意选择合适的服务器版本)。

正确的解决方法

这里的问题是我们没有调用 C API 的mysql_set_charset()而不是SET NAMES 。如果这样做的话,如果我们从 2006 年开始使用 MySQL 版本,我们会很好的。

如果您使用的是较早的 MySQL 版本,则mysql_real_escape_string()的错误意味着出于转义目的,无效的多字节字符(例如我们的有效负载中的字符)被视为单个字节,即使已正确告知客户端连接编码等,也是如此。这次攻击仍然会成功。该错误是固定在 MySQL 4.1.205.0.225.1.11

但是最糟糕的是PDO mysql_set_charset()的 C API,因此在以前的版本中,它无法针对所有可能的命令阻止这种攻击!它现在作为DSN 参数公开,应该代替SET NAMES

拯救的恩典

正如我们在一开始所说的那样,要使此攻击起作用,必须使用易受攻击的字符集对数据库连接进行编码。 utf8mb4并不容易受到攻击,但它可以支持每个Unicode 字符:因此您可以选择使用它,但是它仅自 MySQL 5.5.3 起可用。 utf8是一种替代方法,它也不易受攻击,可以支持整个 Unicode Basic Multilingual Plane

另外,您可以启用NO_BACKSLASH_ESCAPES SQL 模式,该模式(除其他外)会更改mysql_real_escape_string()的操作。启用此模式后, 0x27将替换为0x2727而不是0x5c27 ,因此转义过程无法使用以前不存在的任何易受攻击的编码创建有效字符(即0xbf27仍为0xbf27等),因此服务器仍将拒绝该字符串为无效。但是,请参阅@eggyal 的答案,以了解使用此 SQL 模式可能会引起的其他漏洞(尽管不是 PDO)。

安全的例子

以下示例是安全的:

mysql_query('SET NAMES utf8');
$var = mysql_real_escape_string("\xbf\x27 OR 1=1 /*");
mysql_query("SELECT * FROM test WHERE name = '$var' LIMIT 1");

因为服务器期望utf8 ...

mysql_set_charset('gbk');
$var = mysql_real_escape_string("\xbf\x27 OR 1=1 /*");
mysql_query("SELECT * FROM test WHERE name = '$var' LIMIT 1");

因为我们已经正确设置了字符集,所以客户端和服务器匹配。

$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$pdo->query('SET NAMES gbk');
$stmt = $pdo->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$stmt->execute(array("\xbf\x27 OR 1=1 /*"));

因为我们已经关闭了模拟的准备好的语句。

$pdo = new PDO('mysql:host=localhost;dbname=testdb;charset=gbk', $user, $password);
$stmt = $pdo->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$stmt->execute(array("\xbf\x27 OR 1=1 /*"));

因为我们已经正确设置了字符集。

$mysqli->query('SET NAMES gbk');
$stmt = $mysqli->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$param = "\xbf\x27 OR 1=1 /*";
$stmt->bind_param('s', $param);
$stmt->execute();

因为 MySQLi 始终会执行真正的预备语句。

包起来

如果你:

  • 使用 MySQL 的现代版本(5.1 版,所有 5.5 版,5.6 版等)PDO 的 DSN 字符集参数(在 PHP≥5.3.6 中)

或者

  • 不要使用易受攻击的字符集进行连接编码(您只能使用utf8 / latin1 / ascii等)

或者

  • 启用NO_BACKSLASH_ESCAPES SQL 模式

您是 100%安全的。

否则,即使您使用的是 PDO 预准备语句,也很容易受到攻击。

附录

我一直在缓慢地开发一个补丁程序,以更改默认值,以不模仿将来的 PHP 版本。我遇到的问题是,当我这样做时,很多测试都失败了。一个问题是,模拟的 Prepare 只会在执行时抛出语法错误,而真正的 Prepare 则会在 Prepare 上抛出错误。因此,这可能会导致问题(这是测试很乏味的部分原因)。

准备好的语句 / 参数化查询通常足以防止对该语句* 进行一阶注入。如果您在应用程序中的其他任何地方使用未经检查的动态 sql,则仍然容易受到二阶注入的攻击。

2 阶注入意味着数据在包含在查询中之前已经在数据库中循环了一次,并且很难提取。据我所知,你几乎从来没有看到真正的工程二阶攻击,因为它是攻击者的社会工程师他们的方式通常更容易,但你有时有 2 次错误裁剪,因为额外的良性达'的字符或相似。

当您可以使一个值存储在数据库中,该数据库以后用作查询中的文字时,就可以完成二阶注入攻击。例如,假设您在网站上创建帐户时输入以下信息作为新的用户名(假设使用 MySQL DB 解决此问题):

' + (SELECT UserName + '_' + Password FROM Users LIMIT 1) + '

如果对用户名没有其他限制,则一条准备好的语句仍将确保上述嵌入式查询在插入时不会执行,并将值正确存储在数据库中。但是,请想象一下,稍后应用程序将从数据库中检索您的用户名,并使用字符串串联将值包括在新查询中。您可能会看到别人的密码。由于用户表中的前几个名称通常是管理员,因此您可能也刚刚放弃了服务器场。 (还请注意:这是不将密码存储为纯文本的另一个原因!)

我们看到,那么,准备语句是足以让一个单一的查询,但它们本身并不足以防止 SQL 注入攻击贯穿整个应用程序,因为他们缺乏一种机制来执行应用程序中的所有对数据库的访问使用安全代码。但是,用作良好应用程序设计的一部分(其中可能包括诸如代码审查或静态分析之类的实践,或者使用限制动态 sql 的 ORM,数据层或服务层),准备好的语句解决 Sql Injection 的主要工具问题。如果您遵循良好的应用程序设计原则,从而使数据访问与程序的其余部分分开,则可以轻松实施或审核每个查询正确使用参数化的过程。在这种情况下,完全防止了 sql 注入(一阶和二阶)。


*事实证明,当涉及到宽字符时,MySql / PHP 只是(愚蠢的)对于处理参数是愚蠢的,并且在这里另一个极受好评的答案中仍然概述了一种罕见的情况,该情况可以允许注入通过参数化来进行滑动询问。

不,并非总是如此。

这取决于您是否允许将用户输入放置在查询本身中。例如:

$dbh = new PDO("blahblah");

$tableToUse = $_GET['userTable'];

$stmt = $dbh->prepare('SELECT * FROM ' . $tableToUse . ' where username = :username');
$stmt->execute( array(':username' => $_REQUEST['username']) );

将容易受到 SQL 注入的攻击,并且在此示例中无法使用预处理语句,因为用户输入用作标识符而不是数据。正确的答案是使用某种过滤 / 验证,例如:

$dbh = new PDO("blahblah");

$tableToUse = $_GET['userTable'];
$allowedTables = array('users','admins','moderators');
if (!in_array($tableToUse,$allowedTables))    
 $tableToUse = 'users';

$stmt = $dbh->prepare('SELECT * FROM ' . $tableToUse . ' where username = :username');
$stmt->execute( array(':username' => $_REQUEST['username']) );

注意:您不能使用 PDO 绑定 DDL(数据定义语言)之外的数据,即此方法不起作用:

$stmt = $dbh->prepare('SELECT * FROM foo ORDER BY :userSuppliedData');

上面的方法不起作用的原因是因为DESCASC不是数据。 PDO 只能逸出数据。其次,你甚至不能把'引号了。允许用户选择排序的唯一方法是手动过滤并检查它是DESC还是ASC