关于WEB金融系统中的提现安全问题很多人没有深入思想,导致有漏洞,常常会遇到有些人遇到被攻击到导资金损失的麻烦, 其实要彻底解决重复并发请求 导致重复提现问题,是需要花点心思的,并没有看起来的那么 简单,即使是最直观简单的语句都是有漏洞的比如:
-----------------------------------------场景1--------------------
发现很多朋友的项目一个漏洞:先为一账户充值100元,然后瞬间发送10次提现请求(都是提现100,提现接口是有做余额不足校验的),其中大约有四五次都是成功的,剩下的会报余额不足。期望是,只有一次可以成功完成提现,分析到能部分请求能通过余额不足校验原因是,由于是瞬间发出的提现请求,这些请求中拿到的余额数据都是余额扣减之前的数据。
以上场景可以提炼出两个关键步骤:
- 查询余额并校验,select * from account where user_id = 123;
- 扣减余额并支付,update account set balance...
根据以上步骤,可知:1.在两条SQL语句执行的中间这段时间,由于重复请求攻击,可能会出现多次请求的第一步操作成功,并继续执行第二步,最后导致资金损失。2.由于第一步操作是查询操作,没有数据库会限制重复读取数据
-----------------------------------------场景2----------------------------------
重复提交,表面上是重复提交,威力不大,但实际。。。我们来分析分析:
假设一个用户,余额100,平台恰好有个提现的地方,理所当然用户最多只能提取100元。
我们来分析下程序在生成提现数据的过程:
开启事务;
用户发起一次提现请求,到达应用后,程序判断用户余额是否够用,如果不够就跳出事务了;
然后扣除100元,
然后再提现数据表中插入一条数据,
到这里还没结束,因为事务还没提交,当上面进行顺利时,到达这里就应该commit提交了,如果上面操作任何一步异常,就rollback回滚了。
看起来挺完美的过程,其实!弱暴了!
为啥?
假如用户发起两个请求,而且同一时间(1/1000秒级)请求到服务器,
再走一次上面的逻辑:
请求一达到服务器 请求二达到服务器
开启事务 开启事务
余额检查->通过 余额检查->通过
扣除余额->done 扣除余额->done
插入提现记录->done 插入提现记录->done
提交->commit(); 提交->commit();
两边几乎同时进行一样的操作,为什么没被拦截掉只处理一个请求呢?因为余额检查时,别的请求的事务未提交,在此请求内select的数据还未生效,所以两个请求处理都通过了检查。
那怎么防御呢?
token?
扯J8蛋!token用来防御这原子级别的攻击?别说session了,即使你重写php底层,让session动态调用php的内存也无济于事。原因自己脑补;
队列是终极解决方案。
然后有一个临时方案,提现的表中肯定会有time/datetime之类的字段,在建表时将这个表中的time/datetime + userId 设置为联合主键,然后事务在插入提现数据时,因为时间同一秒且同一用户所以数据冲突,只会成功一条,然后事务报错启动回滚,近乎完美。唯一的瑕疵就是假如前后误差1ms, 然后恰好前一个时间是xxxx1,后一个时间是xxxx2,这样就扯痛蛋了。。。千分之一的概率。
-----------------------------------------原因-----------------------------------
有人人甚至认为无解 ,其实是对数据库理解不够深,如事务级别(脏读,读提交,不可重复读,序列化级,快照级,)、并发机制、锁(共享锁,更新锁,X独占锁,行级锁,页级锁、意向锁),这么多底层知识有足够的理解才能解决这个问题,因为这些方便资料很少,愿意花精力去研究的人更不多,更郁闷的是微软数据库对查询作了优化,文档和实际执行效果是不一样的,比如微软文档明文写着,共享锁 与更新锁是可以相关排斥的,select语句默认是发布hold共享锁,如果你真信就完了,你实际执行结果是共享锁和updlock不会排斥,除非你显示指定,select * from account with(holdlock) 文档和实际不一致,只有遇到坑后请求微软技术支持才他技术人员才知道你,微软对select做了特别优势默认不是被排斥很多锁的,瞬间被坑,当年还记有个携程的主程序员不懂锁乱用,给一个查询加了with(nolock), 订票资金出现重大事故教训。 所以我提供以下几个常用的解决方法。不是不可能其实也很简单。
数据在数据库层面解决这个问题很简单,反相用了ORM EntityFramework之类的才不好解决数据库解决方案
解决方案1:使用显示事务
- begin tran
- select * from account with(rowlock updlock) where user_id = 123; --发布行级更新锁,第二并发请求到这里严格排序,不管有多快,这里有个技巧,因为第二个并发撞进来第一句也是updlock所以两个updlock之间会排斥
- update account set balance...
- commit
解决方案2:在代码层使用分布事务
- using (TransactionScope ts = new TransactionScope()) //用这个需要本地单独开MSDTC (Distributed Transaction Coordinator)服务,并不一定通用 有门槛
- {
- exesql("update account set balance=balance where user_id =123"); //这一句很重要,事务中开头一句update让数据库先发布一个x锁,后面的并发将被严格排队
- exesql("select * from account where user_id = 123;");
- exesql("update account set balance..");
- ts.Complete();
- }
方案3 ,在代码入口使用线程锁
- public static object lockObj =new object();
- public void Withdraw(int user_id,int amount){
- lock (lockObj){ //让提现操作在线程线严格排序,不管并发有多快,缺点是不同的用户 提现也得按顺序排序,但一般提现操作是小概率操作,不会很密集,正常提现的没阻塞感知,但是攻击者可以反复发起请求,导致正确用户提现变慢或阻塞
- exesql("select * from account where user_id = 123;");
- exesql("update account set balance..");
-
- }
- }
一个很重要的技巧是在一个事务内,第一句先 写一个无意义的update Account with(rowlock) set balance=balance where userid=123 ; 这个技巧在任何时候都适用,强制让数据库在事务期内发布x级独占行级锁锁,后面的操作被严格排队,就算攻击者重复请求也只会阻塞他自己的用户查询,不会阻塞别人的
总结:最可行的是存储过程方案1,缺点是不灵活,在如今ORM满天飞的情况下新一代人很少有会写存储过程SQL了,
方案2,是一个折中方案,一般可控性还好
方案3,使用最简单,基本是零成本,零难度,但是会有潜被拒绝服务攻击的功能,但保证最重要的数据安全