Java代码审计之交易类模块审计

  • A+

常见业务场景

  常见的业务场景有:

  • 商品买卖
  • 打折抢购
  • 新人福利(优惠券、代金券、......)
  • 用户体验反馈(好评/差评)
  • ......

审计要点

  一般的业务流程都会跟金额、数量以及产品相关,也就是说基本上是围绕这个下述内容进行相关的审计。例如:

  • 相关交易数据是否可篡改(篡改订单价格、配送费,商品购买数量为负,无限期的保单等)
  • 防重放措施(是否可恶意刷单、刷好评/差评)
  • 相关的优惠产品使用是否合理(例如代金券可枚举、新人优惠可重复使用)
  • 线程安全问题(多线程并发下的读写操作是否合理,例如存在超额提现缺陷)
  • ......

  同时交易类模块也是分步业务模块的一种,也需要关注例如流程绕过等缺陷。

常见漏洞场景及审计方法

数据篡改

  数据篡改是比较直观的缺陷了,交易过程繁琐、参数多且复杂,即使大部分参数进行了防篡改设置,但若一不小心存在漏网之鱼,就会直接造成企业的经济损失。比如,一分钱买任何东西。少收款、企业收费产品被免费使用,或者负值对冲等。

  一般来说,常见的可能被篡改的变量有:

  • 交易金额
  • 单价
  • 商品数量
  • 分期数量(借贷、白条等)
  • 保险(航空险等)
  • 优惠券数量
  • 时间
  • ......

  例如下面的例子,在创建订单时候,仅仅校验了金额小数点的位数,并未对金额的正负以及金额的完整性进行检查:

java
public ReturnModel<CreateOrUpdateOrderRsp> createOrUpdate(ReuqestInfo req){
CreateOrUpdateOrderRsp rsp = new CreateOrUpdateOrderRsp();
//非空判断
......
String amount = req.getPayAmount();
int pos = amount.indexOf(".");
if(pos>=0&&pos<amount.length()){
int lenOfdecimal = amount.substring(pos).length()-1;
CurrencyDao currencyDao = currencyService.getById(req.getCurrency());
if(currencyDao !=null&&lenOfdecimal>currencyDao.getCurrDecimal()){
return new ReturnModel<>(rsp,"invalid input");
}
//订单创建逻辑
......
}
}

  可能在实际支付的时候,第三方的支付宝、银行卡等做了相关的校验,无法进行负值的交易,那么此时只需要修改对应的amount值为0或者0.01,即可低价创建订单进行低价购买,直接造成企业损失了。

  正确的做法应该在设计过程中,在后端检查订单的每一个值(例如校验价格、数量参数,比如产品数量只能为整数,并限制最大购买数量 ),同时与第三方支付平台检查,校验实际支付的金额是否与订单金额一致。当然也可以引入数字签名,保证敏感数据传输过程中的完整性。

条件竞争

  在进行设计时,未考虑线程的可见性以及有序性。导致多个线程竞争同一个资源(变量,对象,文件,数据库表等)时,由于每个线程执行的过程是不可控的,所以可能导致实际上运行的结果与我们理想状态下的结果不一致。例如如下场景:

  当前账户有100块代金券可提现,一般来说每个用户都会有一个bean,里面保存了当前用户拥有的用户以及相关代金券。这里可以理解为一个迷你钱包,这个钱包仅作用于当前业务平台,当发起提现操作时,直接就可以提现,并且是秒到账。正常提现时流程如下:

  • 获取当前账户余额,检查余额是否大于0
  • 若大于0,进行提现(减1),否则拒绝进行操作

  假设两个线程同时从这100代金券中进行提现操作,两个线程分别用thread-1和thread-2表示,某一时刻,thread-1和thread-2都读取到了同一个共享变量——当前账户余额(临界资源),那么可能会发生这种情况,thread-1与thread-2获取到的当前账户余额都是100,进行提现后当前账户余额均为99,但是实际上提现到银行卡的钱却是2,与实际情况不符。

image.png

  再比如下面的例子:

  通过对应的现金券code绑定用户Id进行提现操作,正常的流程应该是当现金券金额大于0时,若该券属于该用户,则正常进行提现操作:

```java
if(withdrawalsMoney.compareTo(new BigDecimal(0))>0){
//现金券提现
this.withdrawalService.consumeWithdrawalCode(userId,code,orderId);
msg = "成功提现"+withdrawalsMoney+"元";
......

}
```

  查看consumeWithdrawalCode的具体实现,这里应该是检验现金券已经提现的方法,可以看到检查了现金券的使用状态,用户所属等关键信息:

```java
if(withdrawalStatus.AVALIABLE!=withdrawalCodeStatus.fromValue(withdrawalDao.getStatus)){
throw new Exception("现金券已经使用");
}

//校验当前提现券是否是当前用户所属
......

//检查完后后更新现金券的状态
withdrawalDao.updateStatus(code,userId,new Date(),withdrawalStatus.UNAVALIABLE)
......
//生成提现订单
......
```

  因为没有对现金券是否使用成功进行处理,当现金券仍未使用时,同一个现金券在高并发条件下,可能会有多个线程进入到了这个事务中,在update执行之前查到的状态都是现金券的状态都可能是可使用状态withdrawalStatus.AVALIABLE,那么多个线程都会执行到updateStatus方法。此时便会生成多个提现订单从而超额提现。

  在进行接口实现时,应考虑共享资源的竞态条件,必须保证共享变量在处理过程中的可见性以及有序性。简单来说就是在访问临界资源的代码前面加上一个锁,当访问完临界资源后释放锁,让其他线程继续访问。除了使用synchronized和Lock两种方式来实现同步互斥访问还可以结合数据库事务进行并发处理,防止条件竞争缺陷。

恶意刷单

  除了商品购买以外,用户评价、商品点击量等也是交易模块的一部分。通过这种机制,相关的店家可以获得较好的搜索排名,比如,在平台搜索时“按销量”搜索,该店铺因为销量大(商品点击量)会更容易被买家找到。但是若没有相应的限制,会导致恶意刷单的问题,例如获取虚假的商品销量、店铺评分、信用积分等不当利益,妨害买家权益。

  例如下面的例子,防止批量刷用户点击量的防护手段是通过ipUtils获取用户IP,然后结合当前时间和商品id,生成redis的key,根据key进行对应value的联动,记录点击次数:

```java
private boolean checkHits(HttpServletRequest request,int productId){
String ip=ipUtils.getIpAddress(request);
String currentDate = DateUtils.getFormatDate().replaceAll("-","");
String key=ip+currentDate+productId;

    //获取点击数限制
    Setting setConfig = SystemUtils.getSetting();
    int hitLimit = Integer.parseInt(setConfig.getHitLimt());

    redisCacheManager = SpringUtils。getBean(RedisCacheManager.class);
    Cache cache= redisCacheManager.getCache(Product.HITS_CACHE);
    try{
            cache.ValueWrapper valueWrapper = cache.get(key);
            int hit=0;
            if(valueWrapper!=null){
            hit=Integer.parseInt(valueWrapper.get())+1;
            if(hit>hitLimi){
            //超过点击限制
                    return false;
            }else{
               hit=hit+1;
            }
            cache.put(key,hits);
            }

    }catch(Exception e){
            e.printStachTrace();
    }
    return true;

}
```

  查看具体的ipUtils,直接通过request请求中的x-forwarded-for参数来获取ip,可以直接通过修改header的方式进行绕过:

java
public static String getIpAddress(HttpServletRequest request) {
String ip = request.getHeader(“x-forwarded-for”);
if (ip == null || ip.length() == 0 || “unknown”.equalsIgnoreCase(ip)) {
ip = request.getHeader(“Proxy-Client-IP”);
}
if (ip == null || ip.length() == 0 || “unknown”.equalsIgnoreCase(ip)) {
ip = request.getHeader(“WL-Proxy-Client-IP”);
}
if (ip == null || ip.length() == 0 || “unknown”.equalsIgnoreCase(ip)) {
ip = request.getHeader(“HTTP_CLIENT_IP”);
}
if (ip == null || ip.length() == 0 || “unknown”.equalsIgnoreCase(ip)) {
ip = request.getHeader(“HTTP_X_FORWARDED_FOR”);
}
if (ip == null || ip.length() == 0 || “unknown”.equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}

  比较保险的做法是把ip替换成userid等用户不可控的凭证进行key的生成,绑定用户身份后就可以很好的限制当前用户的商品点击数了:

java
HttpSession session=request.getSession;
String userId = (String)session.getAttribute("userid");
String currentDate = DateUtils.getFormatDate().replaceAll("-","");
String key=userId+currentDate+productId;

优惠券活动

  除了正常的资金交易模式,一些商家为了营业推广,促进消费。一般会有对应的优惠券的活动,例如新人买书优惠,代金券购买特定商品可以享受对应的优待等。同样的如果相关的设计存在缺陷时,也会产生对应的业务逻辑问题,常见的有:

  • 优惠券可复用

  很多平台为了推广促销,都会伴随一些首单优惠、新人免单的活动。同样的,可能存在优惠复用的问题。例如如下场景:

  新用户有对应的首单优惠,用户在购买时可以享受对应的折扣。但是在设计支付接口时没有校验订单是不是首单,订单金额是多少就按多少结账。那么此时就可以考虑注册新用户,然后批量下订单不付款。此时购物车里的结账金额都是首单优惠过的(因为还没结账,都是首单),若能成功付款则可复用对应的首单优惠了。

  案例代码:

  用户拿到首单优惠券后进行支付,会调用对应的接口获取对应的折扣信息,首先优惠券id会与用户身份进行绑定,在使用前会进行校验,防止越权使用的问题。当优惠券检查通过后,会根据对应的折扣信息生成订单金额:

java
private String getFirstDiscount(HttpSession session,int discountId,Order order){
User user = session.getAttribute("user");
//检查优惠券,防止重复使用或者越权使用
boolean DiscountStatus = DiscountService.check(user.getId(),discountId);
if(checkFistOrder(user.getStatus())&&DiscountStatus){
double discount =DiscountService.getDiscount(discountId);
//生成订单数据
......
}else{
return ResponseData.fail("用户已经享受过首单优惠");
}
......
}

  可以看到这里实际是跟支付接口完全是独立的,那么如果在支付接口没有校验订单是不是首单,就可以按照上面提到的思路,通过如下流程,达到优惠复用的效果:

  选购商品->提交订单->不付款->重新选购(重复多次)->合并支付享受多单优惠

  • 暴力枚举

  例如一些代金券一般是通过充值的方式进行使用(例如流量卡等),相应的会有一个兑换码,当用户输入对应的兑换码后若后台校验成功,那么会转换为账户上对应的可用金额。也有的模式是通过扫描二维码的方式,实际上也是通过携带对应的兑换码token进行访问,然后完成充值的。若相关兑换码使用时间戳或者较短有规律的数字字符等组合生成的话,那么可以通过该缺陷暴力枚举对应的充值凭证进行充值操作,造成损失。

  例如如下兑换码的生成方式,首先是生产6位随机数,然后拼接一个商标产品的字符(这里应该是产品名字),MD5加密后便是对应的兑换码了:

java
String sources = "0123456789";
Random rand = new Random();
StringBuffer RedeemCode = new StringBuffer();
for (int j = 0; j < 6; j++) {
RedeemCode.append(sources.charAt(rand.nextInt(9)) + "");
}
RedeemCode = MD5Utils.decrypt(RedeemCode+trademark);

  因为兑换码不同于短信验证码,短信验证码的生命周期比较短,一般5分钟左右就失效了,兑换码的生命周期可能是覆盖整个优惠活动过程的,长则好几个月甚至一年。若相关的兑换业务接口没有类似图形验证码等防重放措施,就可以通过上述方式构造对应的兑换码进行暴力枚举操作了。

其他

  交易类模块也是分布业务流程的一种,其中常见的流程绕过,凭证复用的问题同样的也是审计过程中需要关注的点。

相关推荐: CVE-2020-6110 Zoom RCE

漏洞概述 近日,Cisco Talos安全研究人员在Zoom客户端中发现了1个部分路径遍历漏洞,利用该漏洞可以实现远程代码执行。漏洞CVE编号为CVE-2020-6110,CVSS 3.0评分8.0分,研究人员测试发现影响Zoom Client Applica…