标签 secure 下的文章

如何安全地保存用户的密码

最近数据库泄漏事件层出不穷,无数人的上网密码被人破解。本文来探讨一下如何安全地保存用户的密码。

为什么非要安全地保存密码?因为人类使用密码有三大偏好:

  1. 喜欢用简单好记的密码
  2. 喜欢到处用相同的密码
  3. 不喜欢经常地修改密码

为了你的客户不至于因为你的数据库泄漏事故和损失惨重,请保护好他们的密码!

最烂的方式:明文保存

许多早期制作的网站,还有众多的政府网站,都是这么保存密码的,包括著名的CSDN也是。有的时候这么保存密码是不得已的:我曾经接到过一个政府部门的项目,领导要求在他忘记密码的时候能让单位负责IT工作的小王帮助查一下密码是什么。不过还好一般并不难说服领导换另一种方式:如果您忘了密码,可以用手机重设密码。

明文保存密码的方法把密码安全完全交给了运维。任何安全漏洞,不论是操作系统漏洞,还是数据库漏洞,甚至应用程序中的漏洞,都会导致用户的密码大白于天下。

次烂的方式:MD5保存

相对明文密码好一点的方法是把用户的密码直接哈希保存。可惜大多数用这种方式保存密码的人并不是因为意识到明文保存密码有什么不妥,仅仅是因为学习编程的时候教材上是这么做的。利用单向哈希算法保存密码当然能比明文保存好一点点,但其最大的问题却在于会让开发者误以为用户的密码是非常安全的——即使泄漏了数据库,黑客也不可能知晓用户的密码是什么。

且不说MD5已经被证明是非常不安全的哈希算法,即使换成SHA-1或者复杂度更高的哈希算法,也不可能显著地提升用户密码的安全性,因为黑客攻击的方式往往并不是通过数学方法寻找哈希碰撞,而是直接在字典中查询。每个黑客手上都有上千万条记录的密码字典,包括常用的单词、拼音、19xx到20xx年的生日等等。他们只需要把MD5的结果输入,就能在字典库中找到对应的原文。一般一个MD5的密码库泄漏的时候,超过八成密码能在字典中反查得到。

比较好的方式:加盐哈希保存

如果定义一个长字符串,把它插入到用户密码中的某个地方,然后再哈希出结果,这样可以改变用户密码的哈希结果,使字典攻击失效。

比如用户的密码是abc123,直接MD5的结果是e99a18c428cb38d5f260853678922e03,大多数黑客的字典中都有这条记录。如果我们把用户的密码加上这个前缀ask3Kxsk777sA00bdsOo552,变成ask3Kxsk777sA00bdsOo552abc123,然后再进行MD5计算,得到的就是3ee795c4ceadf8b21a12f6e373cb1c56,一般黑客的字典里都不会有这条记录。这里用到的前缀就被称作“盐”

加盐哈希保存的结果是,黑客需要同时取得你的数据库和你的“盐”,并且加盐重新生成整个字典库才能破解你的用户密码。安全性比直接哈希保存要高多了。

更好的方式:随机加盐哈希保存

这是对于上一种方法的改进。在前面的方法中,盐是固定的,加盐的位置是固定的,以当今的计算机速度,黑客只需要多花点心思把你的盐搞到手,然后再花个一两天把密码库加盐跑一遍,还是能破解你大多数用户的密码。

如果在保存密码的时候盐随机生成,并插入到原始密码的随机位置,那么数据库里每条密码记录的盐和加盐位置都不同,黑客如果要破解密码,就需要为每一个密码生成一遍字典库,工作量要大得太多了。

最好的方法:没有密码

最安全的密码保存方法就是完全不保存用户密码。现在各大社交网站都支持账户接入非常发达,完全可以让用户用微博、微信、QQ、豆瓣、淘宝、人人、Google、Yahoo、Twitter、Facebook等等等等各种第三方账户来登录你的系统,再不济也可以让用户用随机短信密码来登录。没有保存密码,就不会丢失密码。

别人推荐的方式:慢哈希

慢哈希是一种特别的哈希算法,它比MD5、SHA1等常见的密码哈希算法要慢得多。它的安全原理是:在用户注册或者登录等正常行为时,哈希函数的运行时间由几毫秒变慢为几百毫秒,在用户感受上不会有太大的差别,而对于攻击者来说,因为需要大量计算哈希值试错,慢哈希函数就能有效延长破解密码所需的时间。

PDO prepare足以防止SQL注入吗?

这是一篇翻译的文章,可以点击查看原文


简单来说不能,PDO prepare无法防御全部的SQL注入攻击。比如一些潜在的边缘情况。

我调整了这个答案以适应PDO的场景……

完整的答案是不太容易,这篇文章证明了这种攻击的可能。

攻击实现

那么,接下来我来演示这种攻击……

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

某些情况下,上面的代码会返回多于一行数据。让我们剖析一下这里到底发生了什么:

1. 选择一个编码集

$pdo->query('SET NAMES gbk');

要让这种攻击生效,我们需要服务编码和连接编码两者都把'以ASCII形式编码,也就是0x27并且这种编码中要存在某个字符以ASCII的\也就是0x5c结尾。事实上,MySQL5.6默认支持的编码中有五种符合这一要求:big5cp932gb2312gbk还有sjis。我们这里用gbk举例。

现在,请格外注意这里的SET NAMES指令,它改变了服务器上的字符集。当然还有另一种方法修改字符集,我们后面会谈到。

2. 要嵌入的字符串

我们用于演示SQL注入的字符串以0xbf27开头。在gbk编码中,这是个不合法的多字节字符,而在latin1编码中,它是字符串¿'。注意,不论latin1还是gbk编码,0x27都是半角单引号'字符。

我们选择这个字串的原因在于,如果我们对它调用addslashes(),我们会在'字符前插入一个ASCII码的\字符,即0x5c。然后我们得到字符串编码是0xbf5c27,在gbk编码中,这是两个字符0xbf5c,后面接着0x27。换句话说,这是一个合法的字符后面跟着一个没转义的'。不过我们没调用addslashes(),那么会进行下一步。

3. $stmt->execute()

这里有一件重要的事情需要搞清楚的是,PDO在默认情况下并不会真的对语句做prepare,而是会模拟这一行为(对于MySQL)。也就是说,PDO会在内部构建查询语句,对每个绑定的字符串调用mysql_real_escape_string()(MySQL的C语言API中的函数)。

C语言API调用mysql_real_escape_string()addslashes()的不同点在于前者知道MySQL连接的字符集,所以它可以正确地根据服务器要使用的字符集进行转义。然而,正因为这一点,MySQL客户端会使用latin1进行转义——因为我们从来没有通知它切换字符集的事情。我们的确告诉服务器我们在用gbk,可是客户端仍以为是latin1

这样的情况下调用mysql_real_escape_string()时会插入反斜杠\,而那个挂单的'就会被转义出来!实际上,如果我们以gbk编码查看$var的值是:

縗' OR 1=1 /*

以上就是进行攻击的环境要求。

4. 生成的查询语句

这部分只是为了完整,下面是生成的SQL查询语句:

SELECT * FROM test WHERE name = '縗' OR 1=1 /*' LIMIT 1

恭喜,你刚刚完成了一次针对PDO prepare程序的攻击。

简单的修复方法

值得注意的是你可以简单地通过禁用prepare语句模拟功能来防御这种攻击:

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

这样一般导致PDO执行真正的prepare操作。(数据会独立于SQL语句传送给服务器。)不过还是得小心PDO会在本地的MySQL无法完成prepare时悄悄地恢复使用模拟prepare语句的状态。具体情况在MySQL手册中已然列出,请注意选择对于你服务器版本的手册。

正确的修复方法

这里真正的问题在于我们没有调用MySQL的C语言API中的mysql_set_charset(),而是使用了SET NAMES语句。如果正确调用了mysql_set_charset(),那么使用2006年之后发布的MySQL版本都将安全。

如果你仍在使用更早版本的MySQL,那么mysql_real_escape_string()中的一个bug会导致它把类似我们例子中的不合法的多字节字符串当作单字节字符串加以处理,这样即便客户端正确地指定了连接编码集和服务器编码集,仍会存在被攻击的风险。这个bug在MySQL 4.1.205.0.225.1.11中得以修复。

不过最糟的问题在于PHP 5.3.6之前版本的PDO压根就没暴露C语言API中的mysql_set_charset()方法,所以早于此版本的用户根本不可能阻止这种类型的攻击。现在PDO以一个DSN参数的形式暴露了这个接口,应该用这种方法取代SET NAMES……

其它好办法

话说回来,这种攻击能生效的前提条件是数据库连接使用了薄弱的字符编码。utf8mb4是一种不存在此类弱点的字符集编码方法,而且它能支持所有的Unicode字符——你应该考虑切换到这种编码上。可惜只有MySQL 5.5.3以上版本支持这一编码。另一个可以考虑的替代字符集编码是utf8,它也不存在前述弱点,而且能支持所有Unicode基本多文种平面中的字符

此外,你也可以启用NO_BACKSLASH_ESCAPES SQL模式,这会改变mysql_real_escape_string()的内部行为(也包括其它东西)。当这个模式启动时,0x27会被替换为0x2727而不是0x5c27,这样转义后输出的字符在所有的薄弱的编码字符集中都不会和前面的字符连成合法的存在(比如0xbf27转义出来还是0xbf27),最终服务器就会拒绝这个非法的字符串。不过请看看@eggyal的回答以了解这种SQL模式下的别的漏洞(尽管与PDO无关)。

安全的例子

以下例子是安全的:

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 /*"));

因为关掉了prepare模拟。

$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总是执行真正的prepare操作。

总结一下

如果你:

  • 使用了现代版本的MySQL(晚期版本的5.1,所有的5.5,所有的5.6等等)并且使用了PDO的DSN里的charset参数(PHP ≥ 5.3.6)

或者

  • 没用有弱点的连接字符集编码(比如只使用utf8latin1ascii等等)

或者

  • 启用了NO_BACKSLASH_ESCAPES SQL模式

那么你100%安全。

其它情况下,即使你用了PDO的prepare方法也依然存在SQL注入风险

补充

我正在慢慢编写一个补丁程序让未来版本的PHP默认不要使用模拟prepare方法。导致我进展缓慢的原因在于实在太多测试阻止我这么做。其中一个问题是模拟prepare功能只会在执行的时候返回SQL语法错误,然而真正的prepare可能会在进行prepare的时候就回报错误,这会带来许多麻烦(这也是导致那些测试出错的部分原因)。

某用户在被Rails开发组驳回其漏洞报告之后黑掉了Github网站来演示该漏洞

这是一篇翻译的文章(原文链接),已经是老掉牙的旧闻了,不过感觉这件事很有意思,所以翻译过来。

某用户在被Rails开发组驳回其漏洞报告之后黑掉了Github网站来演示该漏洞

作者:Lucian Constantin, IDG News Service 2012年3月5日

一名用户于上周日黑掉了Ruby on Rails托管在GitHub上代码仓库和Bug跟踪系统,以向Rails开发组展示问题有多么严重。

Ruby on Rails一般简称作Rails,是一种逐渐流行的Ruby语言的Web开发框架,它的设计目标是让开发人员专注于构建应用程序,而无需考虑底层的工作原理。

GitHub是基于Rails框架开发的最受欢迎的Web站点之一,它是一个大型代码托管和协作开发平台,Ruby on Rails项目的代码库和Bug跟踪系统也托管在GitHub上。

上周二,一个名叫Egor Homakov的俄国用户报告了一个Rails框架中存在的漏洞。利用该漏洞,黑客可以从Web表单直接向Rails程序的数据库中注入未经验证的数据,就像SQL注入一样。

这个问题与Rails中的mass assignment功能有关,滥用此功能会导致站点不安全。滥用这一功能的可能性早在几年前就已经知晓,但Rails开发组认为,定义哪些属性可以被这个功能修改的限制条件是应用开发人员的责任。

这个问题的实质是Rails开发人员究竟应该对这一功能应用黑名单策略还是白名单策略。到底是应该默认允许所有属性修改,然后让开发人员定义不许修改的黑名单(现在就是这样的策略);还是应该默认阻止所有属性的修改,再由程序员仔细考虑安全问题之后,定义允许修改的白名单。

很遗憾,历史一再证明把安全决策推给用户极不明智,这往往导致大量的不安全程序在线上运行,这也是Homakov声称问题持续存在了数年的原因。

在尝试说服Rails开发组该功能应该默认关闭未果之后,Homakov决定演示这个漏洞的存在是多么的普遍,即使最成功的Rails应用之一GitHub中也存在这个漏洞导致的安全问题。

星期天,Homakov利用这个漏洞向Ruby on Rails在GitHub上的Bug跟踪系统中加入了一条非法条目,该条目的创建时间居然是1001年后的未来。然后他又利用这个漏洞把他自己的公钥注入GitHub的数据库中,替换了一名Rails开发组成员的的公钥,从而取得了Rails官方代码库的提交权限。

“太平洋时间上午8:49,一名GitHub用户利用公钥更新表单中存在的安全漏洞把他的公钥加入了Rails组织,”——GitHub开发人员Tom Preston-Werner在一篇周日发表的博文中如是说——“后来他推送了一个新文件到该项目中以演示这个安全漏洞。”

GitHub在不到一小时后修复了这个漏洞,并暂时冻结了Homakov的帐号以调查他的行为。GitHub团队在不久后确认了他并无恶意,随即解冻了他的帐号。

“在对攻击行为进行调查的同时,我们对GitHub的代码库展开了彻底的审查,以确保没有其它地方存在相同问题,”Preston-Werner说道“审查工作仍在进行,而我则要确保我们有相应的流程制度避免此类问题再次发生。”

这一事件引起了大量关注,Rails开发组现在愿意更多地讨论这一问题,以期找到解决方案。然而,由于这个问题已经公开,不安全的Rails应用程序遭到攻击的风险也更高了。


我的感想

因为我不是Ruby程序员,所以我也是最近才知道这个发生在三年前的事件。在了解了mass assignment的功能之后,发现这和PHP早年的register global引发的安全问题简直一模一样。在GitHub的讨论中,网友DrPizza的留言很有道理: Insecure-by-default means insecure. (默认设置不安全就意味着不安全)。很难指望程序员在加班加点写完调通功能代码之后再去研究一下手册,发现“啊,我用的方法不安全”一般他们都是直接提交然后就回家睡觉了。只有等到某一天网站被攻击了造成了损失,他可能才会在求助别人之后得知当时犯下了多大的错误。

当年PHP的作法就是从某个版本开始,把register global默认关闭,然后在该项配置旁边写了一堆注释强调开启这个功能的潜在风险,以及不开这个功能的替代方案。