关于php:围绕mysql_real_escape_string()的SQL注入

SQL injection that gets around mysql_real_escape_string()

即使在使用mysql_real_escape_string()函数时,是否有SQL注入的可能性?

考虑这个示例情况。SQL是用如下PHP构造的:

1
2
3
4
$login = mysql_real_escape_string(GetFromPost('login'));
$password = mysql_real_escape_string(GetFromPost('password'));

$sql ="SELECT * FROM table WHERE login='$login' AND password='$password'";

我听过很多人对我说,即使使用mysql_real_escape_string()函数,这样的代码仍然是危险的,并且可能被黑客攻击。但我想不出任何可能的利用?

像这样的经典注射剂:

1
aaa' OR 1=1 --

不要工作。

您知道有没有可能通过上面的PHP代码进行的注入?


简短的回答是肯定的,是的,有一种方法可以绕过mysql_real_escape_string()。好的。对于非常模糊的边缘情况!!!!

答案不那么简单。这是基于这里演示的攻击。好的。进攻

那么,让我们从展示攻击开始……好的。

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

在某些情况下,这将返回一行以上。让我们分析一下这里发生了什么:好的。

  • 选择字符集好的。

    1
    mysql_query('SET NAMES gbk');

    为了使这种攻击起作用,我们需要服务器在连接上期望的编码,既将'编码为ASCII格式,即0x27,也将最终字节为ASCII \,即0x5c。结果表明,MySQL5.6默认支持5种编码:big5cp932gb2312gbksjis。我们在这里选择gbk。好的。

    现在,注意这里使用SET NAMES非常重要。这将设置服务器上的字符集。如果我们使用对c-api函数mysql_set_charset()的调用,我们就可以了(2006年以来的mysql版本)。但更多关于为什么在一分钟内…好的。

  • 有效载荷好的。

    我们将用于此注入的有效负载从字节序列0xbf27开始。在gbk中,这是一个无效的多字节字符;在latin1中,它是字符串?'。注意,在latin1gbk中,0x27本身就是一个文字'字符。好的。

    我们选择这个有效载荷是因为,如果我们在它上面调用addslashes(),我们会在'字符之前插入一个ascii \,即0x5c。所以我们最终得到了0xbf5c27,在gbk中,它是一个两个字符的序列:0xbf5c,后面是0x27。或者换言之,一个有效字符,后跟一个未转义的'。但我们没有使用addslashes()。接下来…好的。

  • mysql_real_escape_string()好的。

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

    因此,对mysql_real_escape_string()的调用插入了反斜杠,我们的"转义"内容中有一个自由挂起的'字符!事实上,如果我们看gbk字符集中的$var,我们会看到:好的。

    1
    ' OR 1=1 /*

    这正是攻击所需要的。好的。

  • 查询好的。

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

    1
    SELECT * FROM test WHERE name = '縗' OR 1=1 /*' LIMIT 1
  • 恭喜你,你刚刚用mysql_real_escape_string()成功地攻击了一个程序…好的。坏的

    情况变得更糟了。PDO默认使用mysql模拟已准备好的语句。这意味着在客户端,它基本上通过mysql_real_escape_string()执行sprintf(在C库中),这意味着以下操作将导致成功注入:好的。

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

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

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

    这通常会导致一个真正准备好的语句(即,数据从查询中以单独的数据包发送)。但是,请注意,PDO会悄悄地回退到模仿MySQL本机无法准备的语句:那些可以在手册中列出的语句,但是要注意选择适当的服务器版本)。好的。丑陋的

    我一开始就说过,如果我们使用mysql_set_charset('gbk')而不是SET NAMES gbk,我们就可以避免所有这些。如果您从2006年开始使用MySQL版本,这是真的。好的。

    如果您使用的是早期的MySQL版本,那么mysql_real_escape_string()中的一个bug意味着,为了进行转义,将有效负载中的无效多字节字符(如那些)视为单字节,即使客户机已经正确地了解了连接编码,因此此攻击仍然会成功。在MySQL4.1.20、5.0.22和5.1.11中修复了这个bug。好的。

    但最糟糕的是,在5.3.6之前,PDO没有公开mysql_set_charset()的C API,所以在以前的版本中,它不能阻止对每个可能的命令进行攻击!它现在作为DSN参数公开。好的。拯救恩典

    正如我们在开始时所说,要使此攻击起作用,必须使用易受攻击的字符集对数据库连接进行编码。utf8mb4不易受攻击,但可以支持每个Unicode字符:因此您可以选择使用它来代替—但它只有在MySQL5.5.3之后才可用。另一种选择是utf8,它也不易受攻击,可以支持整个Unicode基本多语言平面。好的。

    或者,您可以启用NO_BACKSLASH_ESCAPESSQL模式,它(除其他外)会改变mysql_real_escape_string()的操作。启用此模式后,0x27将替换为0x2727,而不是0x5c27,因此转义过程无法在以前不存在的任何易受攻击的编码中创建有效字符(即0xbf27仍然是0xbf27等);mdash;因此服务器仍将拒绝将字符串视为无效。但是,请参阅@eggyal的答案,了解使用此SQL模式可能产生的不同漏洞。好的。安全实例

    以下示例是安全的:好的。

    1
    2
    3
    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…好的。

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

    因为我们已经正确地设置了字符集,以便客户机和服务器匹配。好的。

    1
    2
    3
    4
    $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 /*"));

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

    1
    2
    3
    $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 /*"));

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

    1
    2
    3
    4
    5
    $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等)和mysql_set_charset()/$mysqli->set_charset()/pdo的dsn charset参数(在php≥5.3.6中)

    或好的。

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

    你100%安全。好的。

    否则,即使你使用的是mysql_real_escape_string(),你也很脆弱…好的。好啊。


    考虑以下查询:

    1
    2
    $iId = mysql_real_escape_string("1 OR 1=1");    
    $sSql ="SELECT * FROM table WHERE id = $iId";

    mysql_real_escape_string()不会保护你不受伤害。在查询中的变量周围使用单引号(' ')是防止这种情况发生的原因。以下也是一个选项:

    1
    2
    $iId = (int)"1 OR 1=1";
    $sSql ="SELECT * FROM table WHERE id = $iId";


    TL;DR

    mysql_real_escape_string() will provide no protection whatsoever (and could furthermore munge your data) if:

    Ok.

    • MySQL's NO_BACKSLASH_ESCAPES SQL mode is enabled (which it might be, unless you explicitly select another SQL mode every time you connect); and

      Ok.

    • your SQL string literals are quoted using double-quote " characters.

      Ok.

    This was filed as bug #72458 and has been fixed in MySQL v5.7.6 (see the section headed"The Saving Grace", below).

    Ok.

    这是另一个(也许更少?)模糊边缘的情况!!!!

    为了向@ircmaxell出色的回答致敬(实际上,这应该是恭维而不是剽窃!),我将采用他的格式:好的。进攻

    从演示开始…好的。

    1
    2
    3
    mysql_query('SET SQL_MODE="NO_BACKSLASH_ESCAPES"'); // could already be set
    $var = mysql_real_escape_string('" OR 1=1 -- ');
    mysql_query('SELECT * FROM test WHERE name ="'.$var.'" LIMIT 1');

    这将返回test表中的所有记录。解剖:好的。

  • 选择SQL模式好的。

    1
    mysql_query('SET SQL_MODE="NO_BACKSLASH_ESCAPES"');

    如字符串文字所述:好的。

    There are several ways to include quote characters within a string:

    Ok.

    • A"'" inside a string quoted with"'" may be written as"''".

      Ok.

    • A""" inside a string quoted with""" may be written as"""".

      Ok.

    • Precede the quote character by an escape character ("\").

      Ok.

    • A"'" inside a string quoted with""" needs no special treatment and need not be doubled or escaped. In the same way,""" inside a string quoted with"'" needs no special treatment.

      Ok.

    如果服务器的SQL模式包括NO_BACKSLASH_ESCAPES,则这些选项中的第三个选项—是mysql_real_escape_string()通常采用的方法—不可用:必须使用前两个选项之一。请注意,第四个项目符号的效果是必须知道将用于引用文字的字符,以避免咀嚼数据。好的。

  • 有效载荷好的。

    1
    " OR 1=1 --

    有效载荷以"的特征启动了这一注入。没有特定的编码。没有特殊字符。没有奇怪的字节。好的。

  • mysql_real_escape_string()好的。

    1
    $var = mysql_real_escape_string('" OR 1=1 -- ');

    幸运的是,mysql_real_escape_string()确实检查了SQL模式并相应地调整了它的行为。见libmysql.c:好的。

    1
    2
    3
    4
    5
    6
    7
    8
    ulong STDCALL
    mysql_real_escape_string(MYSQL *mysql, char *to,const char *from,
                 ulong length)
    {
      if (mysql->server_status & SERVER_STATUS_NO_BACKSLASH_ESCAPES)
        return escape_quotes_for_mysql(mysql->charset, to, 0, from, length);
      return escape_string_for_mysql(mysql->charset, to, 0, from, length);
    }

    因此,如果使用NO_BACKSLASH_ESCAPESSQL模式,则调用不同的底层函数escape_quotes_for_mysql()。如上所述,这样的函数需要知道将使用哪个字符来引用文本,以便在不导致另一个引用字符从字面上重复的情况下重复它。好的。

    但是,此函数任意假定字符串将使用单引号'字符进行引用。见charset.c:好的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    /*
      Escape apostrophes by doubling them up

    // [ deletia 839-845 ]

      DESCRIPTION
        This escapes the contents of a string by doubling up any apostrophes that
        it contains. This is used when the NO_BACKSLASH_ESCAPES SQL_MODE is in
        effect on the server.

    // [ deletia 852-858 ]
    */


    size_t escape_quotes_for_mysql(CHARSET_INFO *charset_info,
                                   char *to, size_t to_length,
                                   const char *from, size_t length)
    {
    // [ deletia 865-892 ]

        if (*from == '\'')
        {
          if (to + 2 > to_end)
          {
            overflow= TRUE;
            break;
          }
          *to++= '\'';
          *to++= '\'';
        }

    因此,它使双引号"字符保持不变(并使所有单引号'字符加倍),而不考虑用于引用文字的实际字符!在我们的例子中,$var与提供给mysql_real_escape_string()的论点完全相同,就好像根本没有发生任何逃避。好的。

  • 查询好的。

    1
    mysql_query('SELECT * FROM test WHERE name ="'.$var.'" LIMIT 1');

    某种形式上,呈现的查询是:好的。

    1
    SELECT * FROM test WHERE name ="" OR 1=1 --" LIMIT 1
  • 正如我那位博学的朋友所说:恭喜你,你刚刚成功地攻击了一个使用mysql_real_escape_string()的程序…好的。坏的

    mysql_set_charset()无法帮助,因为这与字符集无关;mysqli::real_escape_string()也无法帮助,因为这只是围绕同一个函数的不同包装器。好的。

    问题是,如果还不明显的话,对mysql_real_escape_string()的调用无法知道该文本将用哪个字符引用,这将留给开发人员稍后决定。因此,在NO_BACKSLASH_ESCAPES模式下,从字面上看,此函数无法安全地转义每个输入以用于任意引用(至少,不需要将不需要加倍的字符加倍,从而咀嚼数据)。好的。丑陋的

    情况越来越糟。NO_BACKSLASH_ESCAPES在野外可能并不少见,因为它必须用于与标准SQL兼容(例如,见SQL-92规范第5.3节,即 ::= 语法生成,并且没有反斜杠的任何特殊含义)。此外,它的使用被明确推荐为解决ircmaxell的文章描述的(长期以来修复的)bug的一种方法。谁知道呢,有些DBA甚至可能将其配置为默认打开,以阻止使用不正确的转义方法,如addslashes()。好的。

    另外,新连接的SQL模式是由服务器根据其配置设置的(SUPER用户可以随时更改);因此,为了确定服务器的行为,必须始终在连接后明确指定所需的模式。好的。拯救恩典

    只要您总是明确设置SQL模式不包含NO_BACKSLASH_ESCAPES,或者使用单引号字符引用mysql字符串文字,这个bug就不能隐藏其丑陋的头部:分别不使用escape_quotes_for_mysql(),或者它对哪些引号字符需要重复的假设是正确的。好的。

    因此,我建议任何使用NO_BACKSLASH_ESCAPES的人也启用ANSI_QUOTES模式,因为它将强制习惯性使用单引号字符串。请注意,这不会防止在使用双引号文本的情况下插入SQL,它只会降低发生这种情况的可能性(因为正常的非恶意查询会失败)。好的。

    在pdo中,它的等效函数PDO::quote()和它的准备好的语句仿真器都调用mysql_handle_quoter()—这正好做到了:它确保转义的文本以单引号引用,因此您可以确定pdo始终不受此错误的影响。好的。

    从MySQLv5.7.6开始,这个bug已经修复。查看更改日志:好的。

    Functionality Added or Changed

    • Incompatible Change: A new C API function, mysql_real_escape_string_quote(), has been implemented as a replacement for mysql_real_escape_string() because the latter function can fail to properly encode characters when the NO_BACKSLASH_ESCAPES SQL mode is enabled. In this case, mysql_real_escape_string() cannot escape quote characters except by doubling them, and to do this properly, it must know more information about the quoting context than is available. mysql_real_escape_string_quote() takes an extra argument for specifying the quoting context. For usage details, see mysql_real_escape_string_quote().

      Ok.

       Note

      Applications should be modified to use mysql_real_escape_string_quote(), instead of mysql_real_escape_string(), which now fails and produces an CR_INSECURE_API_ERR error if NO_BACKSLASH_ESCAPES is enabled.

      Ok.

      参考文献:另见Bug 19211994。好的。

    < /块引用>安全实例

    结合ircmaxell解释的bug,以下示例是完全安全的(假设使用MySQL的时间晚于4.1.20、5.0.22、5.1.11;或者不使用gbk/big5连接编码):好的。

    1
    2
    3
    4
    mysql_set_charset($charset);
    mysql_query("SET SQL_MODE=''");
    $var = mysql_real_escape_string('" OR 1=1 /*');
    mysql_query('SELECT * FROM test WHERE name ="'.$var.'" LIMIT 1');

    …因为我们已经明确选择了一个不包括NO_BACKSLASH_ESCAPES的SQL模式。好的。

    1
    2
    3
    mysql_set_charset($charset);
    $var = mysql_real_escape_string("' OR 1=1 /*");
    mysql_query("SELECT * FROM test WHERE name = '$var' LIMIT 1");

    …因为我们用单引号引用字符串文字。好的。

    1
    2
    $stmt = $pdo->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
    $stmt->execute(["' OR 1=1 /*"]);

    …因为PDO准备好的语句不受此漏洞的影响(如果您使用的是php≥5.3.6并且在dsn中正确设置了字符集,或者准备好的语句仿真已被禁用,那么ircmaxell也不受此漏洞的影响)。好的。

    1
    2
    $var  = $pdo->quote("' OR 1=1 /*");
    $stmt = $pdo->query("SELECT * FROM test WHERE name = $var LIMIT 1");

    …因为pdo的quote()函数不仅转义了文字,而且还引用了文字(在单引号'字符中);注意,为了避免出现ircmaxell的错误,您必须使用php≥5.3.6并正确设置dsn中的字符集。好的。

    1
    2
    3
    4
    $stmt = $mysqli->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
    $param ="' OR 1=1 /*";
    $stmt->bind_param('s', $param);
    $stmt->execute();

    …因为mysqli准备的语句是安全的。好的。包扎

    因此,如果你:好的。

    • 使用本机准备的语句

    或好的。

    • 使用MySQLv5.7.6或更高版本

    或好的。

    • 除了采用ircmaxell总结中的一种解决方案外,还应至少使用以下其中一种:好的。

      • PDO;
      • 单引号字符串文本;或
      • 不包括NO_BACKSLASH_ESCAPES的显式设置的SQL模式。

    …那么您应该是完全安全的(字符串转义范围之外的漏洞)。好的。好啊。


    好吧,除了%通配符之外,没有什么可以通过它。如果您使用LIKE语句,因为攻击者可以将%作为登录名,如果您不过滤掉它,这可能是危险的,并且必须暴力地输入任何用户的密码。人们经常建议使用准备好的语句使其100%安全,因为数据不会以这种方式干扰查询本身。但是对于这样简单的查询,像$login = preg_replace('/[^a-zA-Z0-9_]/', '', $login);这样的操作可能更有效。