用户无法登录

发现问题

2016年的某一天,我收到了一封用户的求助邮件,一般比较严重的问题才会提到我们『程序员』这里。用户在邮件中描述自己的帐号在登录的时候一直提示『未知错误』。

当时公司的帐号体系有专门的部门在维护,我们Web端与App端都是通过『帐号中心』提供的接口来完成登录等相关操作。

因为登录功能是最常用的功能,所以我们加到了监控系统实时监控可用性。如果监控系统没报警,说明是个例。我们首先考虑会不会是用户的帐号被封了,但是查看帐号并无异常。然后我们查看curl的请求日志,发现接口请求确实发送了,但是『帐号中心』没有查到我们发出的请求日志,所以基本定位到了问题”我们的请求未发送出去”。

请求为什么没发送出去呢?我查了各种日志也没找到原因,『未知错误』对应的错误码是9999属于比较紧急的问题。一直定位不到问题气氛也越来越紧张,这时候我有点慌了就开始没方向的乱排除。首先想到是不是某台机器的CURL挂了,或者网络抖动?全机房都验证了一遍,排除了这个可能。各种脑洞大开都定位不到问题,最后我又静下心来过一遍整个登录流程。

问题追踪

因为『帐号中心』对用户的密码加密方式比较复杂,我们在前端通过js无法实现,所以必须通过PHP『中转』一次,登录验证流程如下:

  1. 用户输入帐号密码;
  2. 前端通过js对密码进行加密,并将帐号和加密后的密码传到PHP端;
  3. PHP端拿到帐号密码后将密码解密,再用『帐号中心』给的加密方法加密;
  4. PHP将帐号和加密后的密码传给『帐号中心』,并打上日志,因为密码信息很敏感所以打日志的时候又做了一次加密处理;
  5. 『帐号中心』验证帐号密码并返回校验结果;

问题就发生在第4步,因为我一直在关注第4步中打的日志,并主观的认为日志就是标准的线索,所以忽略了打日志的时候密码是加过密的这一点。这个时候我突然想到可不可能是CURL将第一个字符是@符号会解析成上传的命令?因为对密码加密过一次所以应该是加密前的数据有这个问题,最后经过测试果然是这个问题。

当时我们生产环境的PHP版本为5.5.*,而PHP在5.6.0 才将CURLOPT_SAFE_UPLOAD设置为TRUE(默认关闭@符号的解析)。所以在5.6.0之前的所有PHP版本都可能会触发将@符解析成上传命令的问题。比如@123,因为找不到123资源文件就会执行失败,并且不会抛出任何异常。比如我把自己的昵称修改为 @123 如果服务端处理不好可能就会报错,这也为攻击者提供了一扇门。

我们在代码中通过CURL去请求接口的情况非常多,如何去避免上文的问题呢?通过CURL去发送POST请求时,如果对POST参数做http_build_query($data)处理,CURL会默认将CONTENT_TYPE设置为application/x-www-form-urlencoded,这样就不会将@符号解析成上传命令。如果我们直接将POST参数当数组传递过去CONTENT_TYPE默认设置为multipart/form-data,@符号会被解析成上传命令。

解决方案

如果你的PHP版本大于5.6.0,已经不存在这个问题了,可以直接使用CURLFile对象。如果你的PHP版本在5.5.0-5.6.0之间,建议强制将CURLOPT_SAFE_UPLOAD 设置为TRUE,并使用CURLFile对象。如果你的PHP版本小于5.5.0,那就只能通过http_build_query()函数来处理了。我推荐的通用解决办法就是无论是GET还是POST请求都务必使用http_build_query()函数处理参数。对于需要上传文件的接口单独做差异化处理。现在就去认真检查一遍自己的CURL请求吧,免得跟我踩一样的坑。

影响:因为我们将用户的密码加过密,加密之后第一个字符是@符号的概率极低,因为用户量大所以命中了。但是用户在修改信息的时候也会有这个问题,比如在个人介绍里面写上 @…… 就会中招。