绕过mysql_real_escape_string()的SQL注入攻击

778

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

考虑以下示例情况。在PHP中构建的SQL如下:

$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()函数,像这样的代码仍然是危险的,并且可能被黑客攻击。但我想不出任何可能的漏洞?

像这样的经典注入攻击:

aaa' OR 1=1 --

无法工作。

你知道有哪些可能透过上面的PHP代码进行注入的方法吗?


42
@ThiefMaster - 我更倾向于不提供冗长的错误信息,例如无效的用户名/密码...这会告诉那些尝试暴力破解的人,他们已经获得了一个有效的用户ID,只需要猜测密码即可。请您等一下,我需要为您翻译这段话的中文版。 - Mark Baker
24
从用户体验的角度来看,这真是太可怕了。有时候你可能无法使用你的主昵称/用户名/电子邮件地址,并在一段时间后忘记了这一点,或者网站因为长时间不活动而删除了你的账户。那么,即使只是你的用户名无效,如果你继续尝试密码,甚至可能会被封锁IP地址,这会非常恼人。 - ThiefMaster
66
请勿在新代码中使用mysql_*函数。它们已不再得到维护,并且已经开始弃用过程。看到红框了吗?学习 预处理语句,并使用PDOMySQLi这篇文章将帮助你决定选择哪种方式。如果你选择了PDO,这是一个好的教程 - tereško
18
@machineaddict,自从最近发布的5.5版本开始,mysql_*函数已经产生了E_DEPRECATED警告。ext/mysql扩展已经有超过10年没有维护了。你真的这么妄想吗? - tereško
20
他们在PHP 7.0上刚刚删除了那个扩展,而现在还没有到2050年。 - GGG
显示剩余12条评论
4个回答

745

简短的回答是是的,有一种方法可以绕过mysql_real_escape_string()

#只适用于非常特殊的情况!!!

详细的回答并不那么简单。它基于一个在这里演示的攻击。

攻击

所以,让我们开始展示攻击...

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行。让我们分析一下这里发生了什么:
  1. Selecting a Character Set

    mysql_query('SET NAMES gbk');
    

    For this attack to work, we need the encoding that the server's expecting on the connection both to encode ' as in ASCII i.e. 0x27 and to have some character whose final byte is an ASCII \ i.e. 0x5c. As it turns out, there are 5 such encodings supported in MySQL 5.6 by default: big5, cp932, gb2312, gbk and sjis. We'll select gbk here.

    Now, it's very important to note the use of SET NAMES here. This sets the character set ON THE SERVER. If we used the call to the C API function mysql_set_charset(), we'd be fine (on MySQL releases since 2006). But more on why in a minute...

  2. The Payload

    The payload we're going to use for this injection starts with the byte sequence 0xbf27. In gbk, that's an invalid multibyte character; in latin1, it's the string ¿'. Note that in latin1 and gbk, 0x27 on its own is a literal ' character.

    We have chosen this payload because, if we called addslashes() on it, we'd insert an ASCII \ i.e. 0x5c, before the ' character. So we'd wind up with 0xbf5c27, which in gbk is a two character sequence: 0xbf5c followed by 0x27. Or in other words, a valid character followed by an unescaped '. But we're not using addslashes(). So on to the next step...

  3. mysql_real_escape_string()

    The C API call to mysql_real_escape_string() differs from addslashes() in that it knows the connection character set. So it can perform the escaping properly for the character set that the server is expecting. However, up to this point, the client thinks that we're still using latin1 for the connection, because we never told it otherwise. We did tell the server we're using gbk, but the client still thinks it's latin1.

    Therefore the call to mysql_real_escape_string() inserts the backslash, and we have a free hanging ' character in our "escaped" content! In fact, if we were to look at $var in the gbk character set, we'd see:

    縗' OR 1=1 /*

    Which is exactly what the attack requires.

  4. The Query

    This part is just a formality, but here's the rendered query:

    SELECT * FROM test WHERE name = '縗' OR 1=1 /*' LIMIT 1
    
恭喜,您刚刚成功地使用mysql_real_escape_string()攻击了一个程序...

问题

更糟糕的是,PDO默认使用MySQL模拟预处理语句。这意味着在客户端上,它基本上通过C库中的mysql_real_escape_string()进行sprintf操作,这意味着以下内容将导致成功注入:

$pdo->query('SET NAMES gbk');
$stmt = $pdo->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$stmt->execute(array("\xbf\x27 OR 1=1 /*"));
现在值得注意的是,您可以通过禁用模拟预处理语句来防止这种情况:
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

这通常会导致真正的预处理语句(即数据与查询分开发送)。但是,请注意PDO将默默地fallback到模拟MySQL无法本地准备的语句:可以在手册中找到它可以准备的语句listed,但请注意选择适当的服务器版本)。

丑陋的事实

一开始我说过,如果我们使用mysql_set_charset('gbk')而不是SET NAMES gbk,我们本可以防止所有这些问题。如果您使用的是自2006年以来的MySQL版本,则是正确的。

如果您正在使用较早版本的MySQL,则mysql_real_escape_string()中的bug意味着无效的多字节字符(例如我们负载中的字符)将被视为单个字节进行转义,即使客户端已正确地接收到连接编码信息,因此攻击仍将成功。该漏洞已在MySQL 4.1.20, 5.0.225.1.11中修复。

但最糟糕的是,PDO直到5.3.6版本才公开了mysql_set_charset()的C API,因此在早期版本中,它无法防止每个可能的命令受到攻击! 现在它作为DSN参数暴露出来。

救世之恩

对于这种攻击方式来说,数据库连接必须使用易受攻击的字符集进行编码。utf8mb4 不易受攻击,同时可以支持每个 Unicode 字符:所以您可以选择使用它——但它只能在 MySQL 5.5.3 及以上版本中使用。另一种选择是 utf8,它也不容易受到攻击,并且可以支持整个 Unicode 基本多文种平面。 或者,您可以启用NO_BACKSLASH_ESCAPES SQL模式,该模式(除其他外)会更改mysql_real_escape_string()的操作。启用此模式后,0x27将被替换为0x2727而不是0x5c27,因此转义过程不能在任何原本不存在的易受攻击编码中创建有效字符(即0xbf27仍然是0xbf27等)- 因此服务器仍将拒绝无效字符串。但是,请参见@eggyal's answer以了解使用此SQL模式可能出现的不同漏洞。

安全示例

以下示例是安全的:

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等)并且mysql_set_charset() / $mysqli->set_charset() / PDO的DSN字符集参数(在PHP≥5.3.6中)

或者

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

那么您就是100%安全的。

否则,即使您使用mysql_real_escape_string(),您也很容易受到攻击...


4
PDO 模拟准备语句用于 MySQL,真的吗?我不明白为什么会这样做,因为该驱动程序本来就原生支持。不是吗? - netcoder
18
可以的。文档中说它不会,但在源代码中很明显可以看到,而且易于修复。我认为这是开发人员的无能。 - Theodore R. Smith
7
@TheodoreR.Smith:这不是那么容易修复的。我一直在努力更改默认设置,但转换后会失败大量测试。因此,这比看起来要更大的变化。我仍然希望在5.5之前完成它。 - ircmaxell
15
@shadyyx说:不,文章所描述的漏洞是关于addslashes的。我基于那个漏洞开发了这个漏洞利用程序。你可以自己试一下。下载MySQL 5.0,运行这个漏洞利用程序,亲眼看看结果。至于如何将其放入PUT/GET/POST请求中,这很简单。输入数据只是字节流。char(0xBF)只是生成一个字节的可读方式而已。我已经在多个会议上现场演示了这个漏洞。相信我吧...但如果你不相信,可以自己试一下。它能够成功运行... - ircmaxell
5
关于在URL中传递?var=%BF%27+OR+1=1+%2F%2A这样的怪异字符,只需要在代码中使用$var = $_GET['var']即可。 - cHao
显示剩余10条评论

421

请考虑以下查询:

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

mysql_real_escape_string() 无法保护您免受此类攻击。事实上,您在查询内使用单引号(' ')来包围变量才能保护自己免受攻击。以下也是一种选项:

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

10
这不会是一个真正的问题,因为mysql_query()不会执行多个语句,对吗? - Pekka
13
@Pekka,虽然通常的例子是“DROP TABLE”,但在实际操作中,攻击者更有可能使用“SELECT passwd FROM users”。在后一种情况下,第二个查询通常通过使用“UNION”子句执行。 - Jacco
62
(int)mysql_real_escape_string - 这没有意义。它与(int)没有任何区别,对于每个输入都会产生相同的结果。 - zerkms
30
这更多是对函数的误用,而不是别的什么。毕竟,它的名字是 mysql_real_escape_string,而不是 mysql_real_escape_integer。它不适合用于整数字段。 - NullUserException
12
@ircmaxell的回答完全是误导性的。显然,问题是在询问引号中的内容。"Quotes are not there"不是这个问题的答案。 - Pacerier
显示剩余8条评论

208

简短概括

mysql_real_escape_string()完全无法提供保护(并且可能进一步损坏您的数据),如果:

  • 启用了MySQL的NO_BACKSLASH_ESCAPES SQL模式(除非您每次连接都明确地选择另一个SQL模式,否则它可能被启用);和

  • 您的SQL字符串文字使用双引号"字符引用。

这被视为bug #72458,在MySQL v5.7.6中已得到修复(请参见下面标题为“The Saving Grace”的部分)。

这是另一个(也许不那么?)晦涩的边缘情况!!!

为了向@ircmaxell的优秀回答致敬(真的,这是恭维而不是剽窃!),我将采用他的格式:

攻击

先从演示开始...

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表中的所有记录。解析如下:
  1. Selecting an SQL Mode

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

    As documented under String Literals:

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

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

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

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

    • 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.

    If the server's SQL mode includes NO_BACKSLASH_ESCAPES, then the third of these options—which is the usual approach adopted by mysql_real_escape_string()—is not available: one of the first two options must be used instead. Note that the effect of the fourth bullet is that one must necessarily know the character that will be used to quote the literal in order to avoid munging one's data.

  2. The Payload

    " OR 1=1 -- 
    

    The payload initiates this injection quite literally with the " character. No particular encoding. No special characters. No weird bytes.

  3. mysql_real_escape_string()

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

    Fortunately, mysql_real_escape_string() does check the SQL mode and adjust its behaviour accordingly. See libmysql.c:

    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);
    }
    

    Thus a different underlying function, escape_quotes_for_mysql(), is invoked if the NO_BACKSLASH_ESCAPES SQL mode is in use. As mentioned above, such a function needs to know which character will be used to quote the literal in order to repeat it without causing the other quotation character from being repeated literally.

    However, this function arbitrarily assumes that the string will be quoted using the single-quote ' character. See charset.c:

    /*
      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++= '\'';
        }
    

    So, it leaves double-quote " characters untouched (and doubles all single-quote ' characters) irrespective of the actual character that is used to quote the literal! In our case $var remains exactly the same as the argument that was provided to mysql_real_escape_string()—it's as though no escaping has taken place at all.

  4. The Query

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

    Something of a formality, the rendered query is:

    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模式下,这个函数无法安全地转义每个输入以便与任意引用一起使用(至少不会翻倍需要翻倍的字符,从而破坏您的数据)。

丑陋的事实

情况变得更糟。由于需要与标准SQL兼容(例如,请参见SQL-92规范的第5.3节,即<quote symbol> ::= <quote><quote>语法产生和未给反斜杠赋予任何特殊含义),因此NO_BACKSLASH_ESCAPES在野外可能并不那么罕见。此外,它的使用明确被推荐作为解决方法来修复ircmaxell帖子所描述的(早已修复的)bug。谁知道,一些数据库管理员甚至可能将其配置为默认开启,以防止使用不正确的转义方法,如addslashes()

此外,新连接的SQL模式由服务器根据其配置设置(可以由SUPER用户随时更改)。因此,为确保服务器行为的确定性,在连接后您必须始终明确指定所需的模式。

救世之恩

只要您始终明确设置SQL模式不包括NO_BACKSLASH_ESCAPES,或者使用单引号字符引用MySQL字符串文字,就不会出现此错误:分别使用escape_quotes_for_mysql()将不被使用,或者它对于需要重复的引号字符的假设将是正确的。 因此,我建议任何使用 NO_BACKSLASH_ESCAPES 的人也启用 ANSI_QUOTES 模式,因为它将强制使用单引号字符串字面量。请注意,这并不能防止在使用双引号文字时发生 SQL 注入,它只是减少了这种情况发生的可能性(因为正常的、非恶意的查询将失败)。 在 PDO 中,其等效函数 PDO::quote() 和其准备语句模拟器调用 mysql_handle_quoter() —— 它确保转义的文字用单引号引起来,因此,您可以确定 PDO 总是免疫于这个错误。 截至MySQL v5.7.6,该错误已被修复。请参见变更日志

功能新增或更改

安全示例

结合ircmaxell所解释的漏洞,以下示例完全安全(假设使用的MySQL版本高于4.1.20、5.0.22、5.1.11,或未使用GBK/Big5连接编码):

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模式。

mysql_set_charset($charset);
$var = mysql_real_escape_string("' OR 1=1 /*");
mysql_query("SELECT * FROM test WHERE name = '$var' LIMIT 1");
因为我们使用单引号引用字符串字面量。
$stmt = $pdo->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$stmt->execute(["' OR 1=1 /*"]);
因为PDO预处理语句免疫此漏洞(以及ircmaxell的预处理语句也是如此,前提是您使用的是PHP≥5.3.6并且字符集已在DSN中正确设置;或者已禁用预处理语句仿真)。
$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中的字符集。
$stmt = $mysqli->prepare('SELECT * FROM test WHERE name = ? LIMIT 1');
$param = "' OR 1=1 /*";
$stmt->bind_param('s', $param);
$stmt->execute();

...因为MySQLi预处理语句是安全的。

总结

因此,如果您:

  • 使用本机准备好的语句

或者

  • 使用MySQL v5.7.6或更高版本

或者

  • 除了使用ircmaxell摘要中的解决方案之一外,还至少使用以下解决方案之一:

    • PDO;
    • 单引号字符串字面量;或者
    • 明确设置不包括NO_BACKSLASH_ESCAPES的SQL模式

...那么您应该完全安全(除了字符串转义范围之外的漏洞)。


11
TL;DR是这样的:"如果你没有使用单引号,就会有一个名为NO_BACKSLASH_ESCAPES的MySQL服务器模式可能导致注入问题。" - Your Common Sense
1
人们一开始就不应该使用 " 作为字符串。SQL 表示这是用于标识符的。但是,MySQL 又说“规范可以去死,我想干嘛就干嘛”,这只是另一个例子而已。(幸运的是,您可以在模式中包含 ANSI_QUOTES 来修复引用的问题。然而,对标准的漠视是一个更大的问题,可能需要更严厉的措施。) - cHao
2
@DanAllen:我的回答更广泛,因为你可以通过PDO的quote()函数避免这个特定的bug,但是准备好的语句通常是避免注入更安全和更合适的方法。当然,如果您直接将未转义的变量连接到SQL中,则无论之后使用什么方法,您都肯定容易受到注入攻击。 - eggyal
1
@eggyall:我们的系统依赖于上面第二个安全示例。有些错误没有使用mysql_real_escape_string。在紧急模式下修复这些错误似乎是明智的选择,希望在纠正之前我们不会受到攻击。我的理解是转换为预处理语句将是一个更长的过程,必须在此之后进行。准备语句更加安全的原因是错误不会创建漏洞吗?换句话说,正确实现的第二个示例与准备好的语句一样安全吗? - DanAllen
1
@kittygirl - 你搞错了。这个答案说“NO_BACKSLASH_ESCAPES”是危险的(除非你采取其他建议来避免危险)。你展示了一个包含“NO_BACKSLASH_ESCAPES”的sql_mode。你刚刚冒了一个不必要的风险 - 把它移除掉。 - ToolmakerSteve
显示剩余5条评论

22

嗯,除了通配符%,没有什么可以通过那个的。如果你使用LIKE语句而攻击者把登录名设置为只有%,那么这可能很危险,如果您不过滤掉它,攻击者会尝试破解您的任何用户密码。 人们经常建议使用预处理语句来使其100%安全,因为这样数据就无法干扰查询本身。 但对于这种简单的查询,可能更有效的方法是使用$login = preg_replace('/[^a-zA-Z0-9_]/', '', $login);


2
+1,但通配符用于LIKE子句,而不是简单的相等性。 - Dor
8
您认为使用简单替换比使用预处理语句更有效的度量标准是什么?(预处理语句始终可用,库可以在攻击情况下快速纠正,不会暴露人为错误[例如错误输入完整的替换字符串],如果语句被重复使用,则具有显着的性能优势。) - MatBailie
8
@Slava: 你实际上把用户名和密码限制为仅使用字母字符。对于了解安全的大多数人来说,这被认为是个坏主意,因为它会显著缩小搜索范围。当然,他们也认为在数据库中存储明文密码是一个坏主意,但我们不需要使问题更加复杂。:) - cHao
2
@Slava:我以前见过这个想法;请参见http://xkcd.com/936/。问题是,数学并不完全支持它。你的例子中,一个17个字符的密码可能有96^17种可能性,而且这还是假设你忘记了umlauts并限制了自己使用可打印ASCII码。这大约是4.5x10^33。我们说的是比暴力破解多*十亿万亿倍*的工作量。即使是一个8个字符的ASCII密码也会有7.2x10^15种可能性——多3千倍。 - cHao
2
用户只是想把事情做好。在大多数情况下,安全性直接与此相反,您可以安全地假设它将被回避或以任何可能的方式被破坏。人们不会选择半打随意的虚构词语;他们会从自己喜欢的书籍、电影或其他作品中选择一个简短的句子/语句/口号,使该短语变得更加可预测。为了防止他们这样做,您基本上必须让服务器理解英语和/或搜索最知名艺术作品的数据库以查找所使用的短语。 - cHao
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,