欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

关于web资金系统提现安全保护,防止极快的重复并发请求导致重复提现的解决思路

程序员文章站 2023-02-21 12:23:00
关于WEB金融系统中的提现安全问题很多人没有深入思想,导致有漏洞,常常会遇到有些人遇到被攻击到导资金损失的麻烦, 其实要彻底解决重复并发请求 导致重复提现问题,是需要花点心思的,并没有看起来的那么 简单,即使是最直观简单的语句都是有漏洞的比如: 场景1 发现很多朋友的项目一个漏洞:先为一账户充值10 ......

关于web金融系统中的提现安全问题很多人没有深入思想,导致有漏洞,常常会遇到有些人遇到被攻击到导资金损失的麻烦,     其实要彻底解决重复并发请求 导致重复提现问题,是需要花点心思的,并没有看起来的那么 简单,即使是最直观简单的语句都是有漏洞的比如:

-----------------------------------------场景1--------------------

发现很多朋友的项目一个漏洞:先为一账户充值100元,然后瞬间发送10次提现请求(都是提现100,提现接口是有做余额不足校验的),其中大约有四五次都是成功的,剩下的会报余额不足。期望是,只有一次可以成功完成提现,分析到能部分请求能通过余额不足校验原因是,由于是瞬间发出的提现请求,这些请求中拿到的余额数据都是余额扣减之前的数据。

以上场景可以提炼出两个关键步骤:

  1. 查询余额并校验,select * from account where user_id = 123;
  2. 扣减余额并支付,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,使用最简单,基本是零成本,零难度,但是会有潜被拒绝服务攻击的功能,但保证最重要的数据安全