防止SQL注入

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

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

准备

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

如何做…

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

工作原理…

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

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

  1. public function actionBind()
  2. {
  3. $userName = 'Alex';
  4. $passwordHash = md5('password1');
  5. $sql = "INSERT INTO `user` (`username`, `password`) VALUES (:username, :password);";
  6. // insert first user
  7. $command = Yii::$app->db->createCommand($sql);
  8. $command->bindParam('username', $userName);
  9. $command->bindParam('password', $passwordHash);
  10. $command->execute();
  11. // insert second user
  12. $userName = 'Qiang';
  13. $passwordHash = md5('password2');
  14. $command->execute();
  15. return $this->renderContent(Html::ul(
  16. ArrayHelper::getColumn(User::find()->all(), 'username')
  17. ));
  18. }

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

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

参考

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