PHP 防SQL注入技术


SQL注入是项目建设必须要考虑的问题,一般所用框架对接受或执行的SQL语句都进行了预处理,但如果要你自己处理呢?下面说一下。


正文

SQL注入是项目建设必须要考虑的问题,一般所用框架对接受或执行的SQL语句都进行了预处理,但如果要你自己处理呢?下面说一下。

注入攻击示例:

<?php    
// We didn't check $_POST['password'], it could be anything the user wanted! For example:
$_POST['username'] = 'aidan';
$_POST['password'] = "' OR ''='";

// Query database to check if there are any matching users
$query = "SELECT * FROM users WHERE user='{$_POST['username']}' AND password='{$_POST['password']}'";
mysql_query($query);

// This means the query sent to MySQL would be:
echo $query;
?>

输出:

SELECT * FROM users WHERE user='aidan' AND password='' OR ''=''

这意味着可以直接登录、或者获取到所有用户信息。类似的攻击还有可能删除数据库等。

如:

$username = $_GET['username'];
$sql = "SELECT * FROM user WHERE username = '$username'";

除了提供正确的用户名外,攻击者可以给你的应用程序输入类似 '; DROP TABLE user; -- 的语句。 这将会导致生成如下的 SQL:

SELECT * FROM user WHERE username = ''; DROP TABLE user; --'

我们要对用户提交的数据在与数据库进行交互前进行转义,让数据库安全平稳运行。 addslashes() 、 mysql_real_escape_string() 、 mysqli_real_escape_string()等函数, 可将特殊字符和可能引起数据库操作出错的字符转义。

魔术引号

为了防止SQL注入攻击,PHP自带一个功能可以对输入的字符串进行处理,可以在较底层对输入进行安全上的初步处理, 也即Magic Quotes。当php.ini里的magic_quotes_gpc=On时。 提交的变量中所有的单引号(')、双引号(")、 反斜线(\)与 NUL(NULL 字符)会自动转为含有反斜线的转义字符。 如果magic_quotes_gpc选项启用,那么输入的字符串中的单引号,双引号和其它一些字符前将会被自动加上反斜杠\。 但Magic Quotes并不是一个很通用的解决方案,没能屏蔽所有有潜在危险的字符, 并且在许多服务器上Magic Quotes并没有被启用。所以,我们还需要使用其它多种方法来防止SQL注入。

addslashes()

addslashes() 函数返回在预定义字符之前添加反斜杠的字符串。

addslashes ( string $str ) : string

预定义字符是:单引号(')、双引号(")、反斜杠(\)、 NUL(null 字符)

如:

$str = addslashes('Shanghai is the "biggest" city in China.');
echo($str);  // Shanghai is the \"biggest\" city in China. 

这个函数的作用和magic_quotes_gpc一样。所以一般用addslashes前会检查是否开了magic_quotes_gpc。

if (!get_magic_quotes_gpc()) {
    $lastname = addslashes($_POST[‘lastname’]);
} else {
    $lastname = $_POST[‘lastname’];
}

magic_quotes_gpc与addslashes的区别用法:

1)对于magic_quotes_gpc=on的情况
我们可以不对输入和输出数据库的字符串数据作addslashes()和stripslashes()的操作,数据也会正常显示。
如果此时你对输入的数据作了addslashes()处理,那么在输出的时候就必须使用stripslashes()去掉多余的反斜杠。

2)对于magic_quotes_gpc=off 的情况
必须使用addslashes()对输入数据进行处理,但并不需要使用stripslashes()格式化输出,
因为addslashes()并未将反斜杠一起写入数据库,只是帮助mysql完成了sql语句的执行。

stripslashes 反引用一个引用字符串

stripslashes ( string $str ) : string

需要特别提醒的是addslashes并不完美。addslashes的问题在于黑客可以用0xbf27来代替单引号, 而addslashes只是将0xbf27修改为0xbf5c27,成为一个有效的多字节字符, 其中的0xbf5c仍会被看作是单引号,所以addslashes无法成功拦截。

当然addslashes也不是毫无用处,它是用于单字节字符串的处理,多字节字符还是用mysql_real_escape_string吧。

mysql_real_escape_string

mysql_real_escape_string 转义 SQL 语句中使用的字符串中的特殊字符,并考虑到连接的当前字符集:

mysql_real_escape_string ( string $unescaped_string , resource $link_identifier = NULL ) : string

参数:

unescaped_string
    The string that is to be escaped.

link_identifier
    MySQL 连接。如不指定连接标识,则使用由 mysql_connect() 最近打开的连接。如果没有找到该连接,会尝试不带参数调用 mysql_connect() 来创建。如没有找到连接或无法建立连接,则会生成 E_WARNING 级别的错误。

返回值: Returns the escaped string, or false on error.

示例:

<?php
// Connect
$link = mysql_connect('mysql_host', 'mysql_user', 'mysql_password')
    OR die(mysql_error());

// Query
$query = sprintf("SELECT * FROM users WHERE user='%s' AND password='%s'",
            mysql_real_escape_string($user),
            mysql_real_escape_string($password));
            
mysql_query($query);
?>

mysql_real_escape_string 必须在(PHP 4 >= 4.3.0, PHP 5)的情况下才能使用,否则只能用 mysql_escape_string。 两者的区别是:mysql_real_escape_string 考虑到连接的当前字符集,而mysql_escape_string 不考虑。

mysql_real_escape_string() 函数转义 SQL 语句中使用的字符串中的特殊字符。 下列字符受影响:

    \x00
    \n
    \r
    \
    '
    "
    \x1a

如果成功,则该函数返回被转义的字符串。如果失败,则返回 false。

警告 MySQL扩展 自 PHP 5.5.0 起已废弃,并在自 PHP 7.0.0 开始被移除。 应使用 MySQLiPDO_MySQL 扩展来替换之。 参见 MySQL:选择 API 指南 以及相关 FAQ 来获取更多信息。 用以替代本函数的有: mysqli_real_escape_string()PDO::quote()

因为完全性问题,建议使用拥有 Prepared Statement机制的PDO 和 MYSQLi来代替mysql_query。

mysqli_real_escape_string

MYSQLi使用的是mysqli_real_escape_string: mysqli::real_escape_stringmysqli::escape_stringmysqli_real_escape_string , 根据当前连接的字符集,对于 SQL 语句中的特殊字符进行转义,得到一个编码后的合法的 SQL 语句。

面向对象风格:

mysqli::escape_string ( string $escapestr ) : string
mysqli::real_escape_string ( string $escapestr ) : string

过程化风格:

mysqli_real_escape_string ( mysqli $link , string $escapestr ) : string

参数:

link
    仅以过程化样式:由mysqli_connect() 或 mysqli_init() 返回的链接标识。
    
escapestr
    需要进行转义的字符串。
    会被进行转义的字符包括: NUL (ASCII 0),\n,\r,\,'," 和 Control-Z.

返回值:转义后的字符串。

面向对象风格示例:

<?php
$mysqli = new mysqli("localhost", "my_user", "my_password", "world");

/* 检查连接 */
if (mysqli_connect_errno()) {
    printf("Connect failed: %s\n", mysqli_connect_error());
    exit();
}

$mysqli->query("CREATE TEMPORARY TABLE myCity LIKE City");

$city = "'s Hertogenbosch";

/* 由于未对 $city 进行转义,此次查询会失败 */
if (!$mysqli->query("INSERT into myCity (Name) VALUES ('$city')")) {
    printf("Error: %s\n", $mysqli->sqlstate);
}

$city = $mysqli->real_escape_string($city);

/* 对 $city 进行转义之后,查询可以正常执行 */
if ($mysqli->query("INSERT into myCity (Name) VALUES ('$city')")) {
    printf("%d Row inserted.\n", $mysqli->affected_rows);
}

$mysqli->close();
?>

过程化风格示例:

<?php
$link = mysqli_connect("localhost", "my_user", "my_password", "world");

/* 检查连接 */
if (mysqli_connect_errno()) {
    printf("Connect failed: %s\n", mysqli_connect_error());
    exit();
}

mysqli_query($link, "CREATE TEMPORARY TABLE myCity LIKE City");

$city = "'s Hertogenbosch";

/* 由于未对 $city 进行转义,此次查询会失败 */
if (!mysqli_query($link, "INSERT into myCity (Name) VALUES ('$city')")) {
    printf("Error: %s\n", mysqli_sqlstate($link));
}

$city = mysqli_real_escape_string($link, $city);

/* 对 $city 进行转义之后,查询可以正常执行 */
if (mysqli_query($link, "INSERT into myCity (Name) VALUES ('$city')")) {
    printf("%d Row inserted.\n", mysqli_affected_rows($link));
}

mysqli_close($link);
?>

输出:

Error: 42000
1 Row inserted.

PDO::quote

PDO::quote() 为输入的字符串添加引号(如果有需要),并对特殊字符进行转义,且引号的风格和底层驱动适配。

说明:

public PDO::quote ( string $string , int $parameter_type = PDO::PARAM_STR ) : string

参数

string
    要添加引号的字符串。
parameter_type
    为驱动提示数据类型,以便选择引号风格。

返回值: 返回加引号的字符串,理论上可以安全用于 SQL 语句。 如果驱动不支持这种方式,将返回 false 。

示例:

<?php
$conn = new PDO('sqlite:/home/lynn/music.sql3');

/* 复杂字符串 */
$string = "Co'mpl''ex \"st'\"ring";
print "Unquoted string: $string\n";
print "Quoted string: " . $conn->quote($string) . "\n";
?>

输出:

Unquoted string: Co'mpl''ex "st'"ring
Quoted string: 'Co''mpl''''ex "st''"ring'

如果使用此函数构建 SQL 语句,强烈建议使用 PDO::prepare() 配合参数构建(下面讲的), 而不是用 PDO::quote() 把用户输入的数据拼接进 SQL 语句。 使用 prepare 语句处理参数,不仅仅可移植性更好,而且更方便、免疫 SQL 注入;相对于拼接 SQL 更快,客户端和服务器都能缓存编译后的 SQL 查询。

另外不是所有的 PDO 驱动都实现了此功能(例如 PDO_ODBC)。 考虑使用 prepare 代替。

预处理语句

prepare() 参数化绑定,防止 SQL 注入的又一道屏障。php MySQLi 和 PDO 均提供这样的功能。

PDO::prepare()PDOStatement::execute() 方法准备待执行的 SQL 语句。 SQL 语句可以包含零个或多个参数占位标记,格式是命名(:name)或问号(?)的形式,当它执行时将用真实数据取代。 在同一个 SQL 语句里,命名形式和问号形式不能同时使用;只能选择其中一种参数形式。 请用参数形式绑定用户输入的数据,不要直接字符串拼接到查询里。 调用 PDOStatement::execute() 时,每一个值的参数占位标记,名称必须唯一。 除非启用模拟(emulation)模式,同一个语句里无法使用重名的参数。

public PDO::prepare ( string $statement , array $driver_options = array() ) : PDOStatement

参数:

statement
    必须是对目标数据库服务器有效的 SQL 语句模板。
driver_options
    数组包含一个或多个 key=>value 键值对,为返回的 PDOStatement 对象设置属性。 常见用法是:设置 PDO::ATTR_CURSOR 为 PDO::CURSOR_SCROLL,将得到可滚动的光标。 某些驱动有驱动级的选项,在 prepare 时就设置。

比如 MySQLi 可以这样去查询:

$mysqli = new mysqli('localhost', 'my_user', 'my_password', 'world');
$stmt = $mysqli->prepare("INSERT INTO CountryLanguage VALUES (?, ?, ?, ?)");
$code = 'DEU';
$language = 'Bavarian';
$official = "F";
$percent = 11.2;
$stmt->bind_param('sssd', $code, $language, $official, $percent);

PDO 的更是方便,PDO::prepare(),比如:

/* Execute a prepared statement by passing an array of values */
$sql = 'SELECT name, colour, calories
    FROM fruit
    WHERE calories < :calories AND colour = :colour'; 
$sth = $dbh->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
$sth->execute(array(':calories' => 150, ':colour' => 'red'));
$red = $sth->fetchAll();
$sth->execute(array(':calories' => 175, ':colour' => 'yellow'));
$yellow = $sth->fetchAll();

绑定变量使用预编译语句是预防SQL注入的最佳方式,因为使用预编译的SQL语句语义不会发生改变, 在SQL语句中,变量用问号?表示,攻击者无法改变SQL语句的结构,从根本上杜绝了SQL注入攻击的发生。

正则匹配替换

我们也可以自己写敏感字符替换函数,用 preg_match()、 preg_match_all()、 preg_replace() 等来实现。

<?php 
function fixtags($text){
    $text = htmlspecialchars($text);
    $text = preg_replace("/=/", "=\"\"", $text);
    $text = preg_replace("/&quot;/", "&quot;\"", $text);
    $tags = "/&lt;(\/|)(\w*)(\ |)(\w*)([\\\=]*)(?|(\")\"&quot;\"|)(?|(.*)?&quot;(\")|)([\ ]?)(\/|)&gt;/i";
    $replacement = "<$1$2$3$4$5$6$7$8$9$10>";
    $text = preg_replace($tags, $replacement, $text);
    $text = preg_replace("/=\"\"/", "=", $text);
    return $text;
}
    
$string = "
this is smaller < than this<br />
this is greater > than this<br />
this is the same = as this<br />
<a href=\"http://www.example.com/example.php?test=test\">This is a link</a><br />
<b>Bold</b> <i>italic</i> etc...";
    
echo fixtags($string);
?> 

输出:

this is smaller &lt; than this<br />
this is greater &gt; than this<br />
this is the same = as this<br />
<a href="http://www.example.com/example.php?test=test">This is a link</a><br />
<b>Bold</b> <i>italic</i> etc...

htmlspecialchars

htmlspecialchars() 将特殊字符转换为 HTML 实体。

htmlspecialchars ( string $string , int $flags = ENT_COMPAT | ENT_HTML401 , string $encoding = ini_get("default_charset") , bool $double_encode = true ) : string

执行转换:

字符     替换后
& (& 符号)     &amp;
" (双引号)     &quot;,除非设置了 ENT_NOQUOTES
' (单引号)     设置了 ENT_QUOTES 后, &#039; (如果是 ENT_HTML401) ,或者 &apos; (如果是 ENT_XML1、 ENT_XHTML 或 ENT_HTML5)。
< (小于)     &lt;
> (大于)     &gt;

如:

$new = htmlspecialchars("<a href='test'>Test</a>", ENT_QUOTES);
echo $new; // 输出: &lt;a href=&#039;test&#039;&gt;Test&lt;/a&gt;
echo htmlspecialchars_decode($new, ENT_QUOTES); // 输出: <a href='test'>Test</a> 

利用这个特点,我们可以替换SQL语句中一些特殊字符,为执行SQL语句做好过滤准备。

$username = htmlspecialchars(trim("$_POST[username]"));
$uniqueuser = $realm_db->query('SELECT `login` FROM `accounts` WHERE `login` = "'.$username.'";');

补充

htmlspecialchars_decode() 函数的作用和 htmlspecialchars() 刚好相反,它将特殊的HTML实体转换回普通字符。

htmlspecialchars_decode ( string $string , int $flags = ENT_COMPAT | ENT_HTML401 ) : string

某类字符在 HTML 中有特殊用处,如需保持原意,需要用 HTML 实体来表达。 htmlspecialchars()函数会返回字符转义后的表达。 如需转换子字符串中所有关联的名称实体,使用 htmlentities() 代替htmlspecialchars()函数。

htmlentities ( string $string , int $flags = ENT_COMPAT | ENT_HTML401 , string $encoding = ini_get("default_charset") , bool $double_encode = true ) : string

该函数各方面都和 htmlspecialchars() 一样, 除了 htmlentities()的特殊字符, 还会转换所有具有 HTML 实体的字符。 如果要解码(反向操作),可以使用 html_entity_decode()。

html_entity_decode ( string $string , int $flags = ENT_COMPAT , string|null $encoding = null ) : string

如果传入字符的字符编码和最终的文档是一致的,则用htmlspecialchars()函数处理的输入适合绝大多数 HTML 文档环境。 然而,如果输入的字符编码和最终包含字符的文档是不一样的, 想要保留字符(以数字或名称实体的形式), htmlspecialchars()函数以及 htmlentities() (仅编码名称实体对应的子字符串)可能不够用。 这种情况可以使用 mb_encode_numericentity() 代替。

# 将字符转换为HTML 数字字符串
mb_encode_numericentity ( string $string , array $map , string|null $encoding = null , bool $hex = false ) : string

# 根据 HTML 数字字符串解码成字符
mb_decode_numericentity ( string $str , array $convmap , string $encoding = mb_internal_encoding() ) : string

get_html_translation_table() 返回使用 htmlspecialchars() 和 htmlentities() 后的转换表:

get_html_translation_table ( int $table = HTML_SPECIALCHARS , int $flags = ENT_COMPAT | ENT_HTML401 , string $encoding = 'UTF-8' ) : array

Yii2避免 SQL 注入

在 Yii 中,大部分的数据查询是通过 Active Record 进行的, 而其是完全使用 PDO 预处理语句执行 SQL 查询的。 在预处理语句中,开头示例中,构造 SQL 查询的场景是不可能发生的。

有时,你仍需要使用 raw queries 或者 query builder。 在这种情况下,你应该使用安全的方式传递参数。 如果数据是提供给表列的值,最好使用预处理语句:

// query builder
$userIDs = (new Query())
    ->select('id')
    ->from('user')
    ->where('status=:status', [':status' => $status])
    ->all();

// DAO
$userIDs = $connection
    ->createCommand('SELECT id FROM user where status=:status')
    ->bindValues([':status' => $status])
    ->queryColumn();

如果数据是用于指定列的名字,或者表的名字,最好的方式是只允许预定义的枚举值:

function actionList($orderBy = null)
{
    if (!in_array($orderBy, ['name', 'status'])) {
        throw new BadRequestHttpException('Only name and status are allowed to order by.')
    }

    // ...
}

如果上述方法不行,表名或者列名应该被转义。Yii 针对这种转义提供了一个特殊的语法, 这样可以在所有支持的数据库都使用一套方案。

$sql = "SELECT COUNT([[$column]]) FROM \{\{table\}\}";
$rowCount = $connection->createCommand($sql)->queryScalar();

引用表和列名称(Quoting Table and Column Names):

当写与数据库无关的代码时,正确地引用表和列名称总是一件头疼的事, 因为不同的数据库有不同的名称引用规则, 为了克服这个问题, 你可以使用下面由 Yii 提出的引用语法。

  • [[column name]]: 使用两对方括号来将列名括起来;
  • \{\{table name\}\}: 使用两对大括号来将表名括起来。

Yii DAO 将自动地根据数据库的具体语法来将这些结构转化为对应的 被引用的列或者表名称。 例如,

// 在 MySQL 中执行该 SQL : SELECT COUNT(`id`) FROM `employee`
$count = Yii::$app->db->createCommand("SELECT COUNT([[id]]) FROM {\{employee}\}")->queryScalar();

总结

第一种思路是字符替换,把危险字符替换掉。

第二种思路是用数据库预处理语句,把语意确定了。






参考资料

SQL注入防御与绕过的几种姿势 http://netsecurity.51cto.com/art/201705/538863.htm

PHP几个防SQL注入攻击自带函数区别 https://www.cnblogs.com/timelesszhuang/p/3744080.html

php SQL 防注入的一些经验 https://www.cnblogs.com/liuzhang/p/4753467.html

PHP 手册 函数参考 数据库扩展 数据库抽象层 PDO PDO::quote https://www.php.net/manual/zh/pdo.quote.php

mysql_real_escape_string https://www.php.net/manual/zh/function.mysql-real-escape-string.php

PHP 手册 函数参考 数据库扩展 针对各数据库系统对应的扩展 MySQL Mysqli mysqli_real_escape_string https://www.php.net/manual/zh/mysqli.real-escape-string.php

addslashes() http://www.php.net/manual/zh/function.addslashes.php

sprintf() http://www.w3school.com.cn/php/func_string_sprintf.asp

htmlspecialchars() http://php.net/manual/zh/function.htmlspecialchars.php

htmlentities() http://php.net/manual/zh/function.htmlentities.php

preg_replace() https://www.php.net/manual/zh/function.preg-replace.php

PHP正则替换preg_replace函数的使用 https://www.cnblogs.com/crxis/p/7714636.html

Yii2 最佳安全实践 https://www.yiichina.com/doc/guide/2.0/security-best-practices

Yii 2.0 权威指南 > 配合数据库工作(Working with Databases): 数据库访问(Data Access Objects) > 引用表和列名称 https://www.yiichina.com/doc/guide/2.0/db-dao#quoting-table-and-column-names

SQL Injection https://owasp.org/www-community/attacks/SQL_Injection


返回