View on GitHub

yii2-cookbook-chinese

Yii Application Development Cookbook(Third Edition)中文翻译

防止SQL注入

SQL注入是一种代码注入,它利用了数据库层的易损性,允许你执行任意SQL,允许恶意用户删除数据或者提升自己的权限。

在本小节中,我们将会看到易损代码的例子,以及如何修复他们。

准备

  1. 按照官方指南http://www.yiiframework.com/doc-2.0/guide-start-installation.html的描述,使用Composer包管理器创建一个新的应用。
  2. 执行如下SQL:
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `username` varchar(100) NOT NULL,
  `password` varchar(32) NOT NULL,
  PRIMARY KEY (`id`)
);
INSERT INTO `user`(`id`,`username`,`password`) VALUES ('1','Alex','202cb962ac59075b964b07152d234b70');
INSERT INTO `user`(`id`,`username`,`password`) VALUES ('2','Qiang','202cb962ac59075b964b07152d234b70');
  1. 使用Gii生成User模型。

如何做…

  1. 首先,我们将会实现一个简单的动作,检查通过URL传递过来的用户名和密码是否是正确的。创建app/controllers/SqlController.php
<?php
namespace app\controllers;
use app\models\User;
use Yii;
use yii\base\Controller;
use yii\base\Exception;
use yii\helpers\ArrayHelper;
use yii\helpers\Html;
/**
 * Class SqlController.
 * @package app\controllers
 */
class SqlController extends Controller
{
    protected function renderContentByResult($result)
    {
        if ($result) {
            $content = "Success";
        } else {
            $content = "Failure";
        }
        return $this->renderContent($content);
    }
    public function actionSimple()
    {
        $userName = Yii::$app->request->get('username');
        $password = Yii::$app->request->get('password');
        $passwordHash = md5($password);
        $sql = "SELECT * FROM `user`"
            ." WHERE `username` = '".$userName."'"
            ." AND password = '".$passwordHash."' LIMIT |1";
        $result = Yii::$app->db->createCommand($sql)->queryOne();
        return $this->renderContentByResult($result);
    }
}
  1. 访问/sql/simple?username=test&password=test。因为我们不知道两组用户名和密码,正如所预料的,会打印失败。
  2. 现在尝试访问/sql/simple?username=%27+or+%271%27%3D%271%27%3B+--&password=whatever。这一次,它让我们通过了,尽管实际上我们并不知道真正的身份。解压的部分username的值如下所示:
' or '1'='1'; --
  1. 关闭quote,从而语法是正确的。添加OR '1'='1',它使得条件永远是正确的。使用; --来结束查询并注释剩余的部分。
  2. 因为没有做转义,整个查询语句是:
SELECT * FROM user WHERE username = '' or '1'='1'; --' AND password = '008c5926ca861023c1d2a36653fd88e2' LIMIT 1;
  1. 修复这个问题最好的方法是使用prepared statement,如下所示:
public function actionPrepared()
{
    $userName = Yii::$app->request->get('username');
    $password = Yii::$app->request->get('password');
    $passwordHash = md5($password);
    $sql = "SELECT * FROM `user`"
        ." WHERE `username` = :username"
        ." AND password = :password LIMIT 1";
    $command = Yii::$app->db->createCommand($sql);
    $command->bindValue(':username', $userName);
    $command->bindValue(':password', $passwordHash);
    $result = $command->queryOne();
    return $this->renderContentByResult($result);
}
  1. 现在使用相同的恶意参数检查/sql/prepared。这一次是正常的并且收到了失败的消息。相同的准则被应用到了ActiveRecord上。唯一的不同是AR使用了其它语法:
public function actionAr()
{
    $userName = Yii::$app->request->get('username');
    $password = Yii::$app->request->get('password');
    $passwordHash = md5($password);
    $result = User::findOne([
        'username' => $userName,
        'password' => $passwordHash
    ]);
    return $this->renderContentByResult($result);
}
  1. 在先前的代码中,我们以键值对的样式使用了usernamepassword参数。先前的代码我们只使用了第一个参数,这会很容易受到攻击:
public function actionWrongAr()
{
    $userName = Yii::$app->request->get('username');
    $password = Yii::$app->request->get('password');
    $passwordHash = md5($password);
    $condition = "`username` = '".$userName." AND `password` ='".$passwordHash."'";
    $result = User::find()->where($condition)->one();
    return $this->renderContentByResult($result);
}
  1. 如果正确使用,prepared statement可以防止所有类型的SQL注入。但是,这里还会有一些常见的问题:
  1. 当使用ActiveRecord时,通过添加where可以解决第一个问题:
public function actionIn()
{
    $names = ['Alex', 'Qiang'];
    $users = User::find()->where(['username' => $names])->all();
    return $this->renderContent(Html::ul(
        ArrayHelper::getColumn($users, 'username')
    ));
}
  1. 第二个问题有多种解决方法。第一种方法是依赖active record和PDO quoting:
public function actionColumn()
{
    $attr = Yii::$app->request->get('attr');
    $value = Yii::$app->request->get('value');
    $users = User::find()->where([$attr => $value])->all();
    return $this->renderContent(Html::ul(
        ArrayHelper::getColumn($users, 'username')
    ));
}
  1. 但是最安全的方法是使用白名单:
public function actionWhiteList()
{
    $attr = Yii::$app->request->get('attr');
    $value = Yii::$app->request->get('value');
    $allowedAttr = ['username', 'id'];
    if (!in_array($attr, $allowedAttr)) {
        throw new Exception("Attribute specified is not allowed.");
    }
    $users = User::find()->where([$attr => $value])->all();
    return $this->renderContent(Html::ul(
        ArrayHelper::getColumn($users, 'username')
    ));
}

工作原理…

防止SQL注入时,主要的目标是正确过滤输入。在所有的情况下,除了表名,我们使用了prepared statements——大多数关系数据库都支持的特性。他们允许你创建一次statement,然后多次使用,他们提供了安全的方法来绑定参数。

在Yii中,你可以为Active Record和DAO使用prepared statement。当使用DAO时,可以使用bindValuebindParam来达到目的。当我们希望执行多个同类型但值不同的查询时,非常有用。

public function actionBind()
{
    $userName = 'Alex';
    $passwordHash = md5('password1');
    $sql = "INSERT INTO `user` (`username`, `password`) VALUES (:username, :password);";
    // insert first user
    $command = Yii::$app->db->createCommand($sql);
    $command->bindParam('username', $userName);
    $command->bindParam('password', $passwordHash);
    $command->execute();
    // insert second user
    $userName = 'Qiang';
    $passwordHash = md5('password2');
    $command->execute();
    return $this->renderContent(Html::ul(
        ArrayHelper::getColumn(User::find()->all(), 'username')
    ));
}

大部分Active Record方法接受参数。安全起见,你应该使用他们,而不是将原始数据传进去。

至于quoting表名,列和其它关键词,你可以依赖Active Record,或者使用白名单方法。

参考

欲了解更多关于SQL注入,以及使用Yii配合数据库工作,参考如下地址: