在Hyperf框架中使用中间件实现接口参数混淆,防止重放攻击薅羊毛

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第4天,点击查看活动详情

前言

前面分享了两篇关于Hyperf框架中RPC 同语言框架跨语言框架的调用,今天来点有意思,具有实战意义的技术分享,hyperf框架和laravel非常相似的PHP框架,按理来说下面的思路可以应用于任何语言框架。

最近有一些薅羊毛的羊毛党盯上了我们公司的支付宝小程序的抽奖产品,主要的逻辑是在前端判断用户是否关注的该小程序,如果关注了就给用户一次抽奖机会,然后请求后端接口直接抽奖发放奖励,运营人员发现奖金消耗过快,然后后台分析,确实有很多用户没有几个人关注小程序却有很多人抽奖。一通分析过后发现是某些别有用心的人抓到了我们的接口进行更换参数跳转前端判断是否关注小程序进行直接抽奖。(这里是直接更换了支付宝用户ID参数达到了直接给支付宝用户直接发放奖励的效果)但的确是给我们的小程序带来了很多新用户,很奇怪。

粗略方案

经过前后端讨论,解决的办法就是header头增加几个参数,例如时间戳,随机字符串,混淆结果,其中混淆结果参数是用请求参数排序再加上时间戳再加上随机字符串混淆出来的。把这些参数,混淆方法同样给到后端进行签名验证,时间戳验证。

以上的方案就可防止一些薅羊毛的羊毛党通过简单的抓包更换参数来实现重放攻击(重放攻击就是拿着一样参数或者修改某些参数再次请求获取资源),减少了损失。

详细方案

参数简要说明

在header中增加3个签名参数:

  • sign 最终签名字符串
  • time-stamp 请求时的时间戳
  • nonce-str 随机字符串

混淆逻辑

前端流程

graph TD
        A[定义一个数组] --> B[Get请求参数加入数组]
 B[Post参数数组数组] --> C[Get参数加入数组]
 C -->D[获取时间戳time_stamp加入数组]
  D -->E[生成随机字符串nonce_str加入数组]
  E -->F[假如有鉴权参数TOKEN就加入数组]
  F -->G[依据数组的键进行ASCII码排序]
   G -->H[数组转字符串]
   H -->I[数组base64加密转大写]
   I -->J[MD5混淆生成sign参数放在请求header头]
复制代码

Postman Pre-request Script:

request_time_stamp =  Math.round(new Date() / 1000);  // 获取秒级时间戳
token = pm.environment.get("sign-token")  //  读取环境变量,这里的环境变量应该在登录接口的Tests里面设置
pm.environment.set('sign-time-stamp',request_time_stamp)   //  读取设置环境变量,共所有接口使用
nonce_str = randomString(32)  
pm.environment.set('sign-nonce-str',nonce_str)  // 设置随机字符串环境变量 
var params_args = pm.request.url.query.members;  // 获取当前请求所有Get参数及其值
var body_args = request.data; // 获取当前请求所有Post参数及其值
for(var i=0;i<params_args.length;i++){
    body_args[params_args[i].key] = params_args[i].value;  // 合并Get参数Post参数的键和值
}
body_args['time_stamp'] = request_time_stamp;
body_args['nonce_str'] = nonce_str
body_args['token'] = token
body_args = objectsort(body_args)  //所有参数合并排序
console.log(body_args);
body_args_base64 = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(body_args)).toUpperCase()  //  base64混淆字母转大写
// console.log(body_args_base64);
sign = CryptoJS.MD5(body_args_base64).toString()  // MD5混淆
// console.log(sign);
sign_type = (request_time_stamp % 5) % 2;  //当前时间进行取余操作,判断奇偶数

// console.log(sign_type);

if(sign_type==1){  //根据时间戳求余奇偶数来进行混淆拼接,得出最后的sign
    new_sign =  sign + token
}else{
    new_sign =  token + sign
}
console.log(new_sign);
pm.environment.set('sign-sign',new_sign)   //设置sign参数,供全局接口使用
function objectsort(obj){
    let arr = new Array();
    let num = 0;
    for (let i in obj) {
        arr[num] = i;
        num++;
    }
    const sortArr = arr.sort();
    //自定义排序字符串
    let str = "";
    for (let i in sortArr) {
        str += sortArr[i] + "=" + obj[sortArr[i]] + "&";
    }
    //去除两侧&符号
    const char = "&";
    str = str.replace(new RegExp("^\\" + char + "+|\\" + char + "+$", "g"), "");
    return str;
}

/* 生成随即字符串 */
function randomString(len) {
  len = len || 32;
  const $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz-';
  const maxPos = $chars.length;
  let res = '';
  for (let i = 0; i < len; i++) {
    res += $chars.charAt(Math.floor(Math.random() * maxPos));
  }
  return res;
}


Postman接口工具的代码语法跟Javascript一样的,增加一些环境变量读写的操作,直接在postman中设置环境变量:

image.png

在请求发送之前会执行上面那段代码,把所有的参数进行计算生成sign然给header头中的环境变量(sign-nonce-str)赋值。

后面找资料发现也可以不设置环境变量控制,使用下面的方法设置添加或更新header头:

pm.request.headers.upsert({
    key: 'sign-sign',
    value: sign-sign
})
pm.request.headers.upsert({
    key: 'sign-time-stamp',
    value: request_time_stamp
})
pm.request.headers.upsert({
    key: 'sign-nonce-str',
    value: nonce_str
})

后端逻辑

graph TD
        A[接收header头中time_stamp,sign,nonce-str] --> B[首先对time_stamp时间戳验证,与服务器时间对比验证]
 B --> C[时间戳求余分开token和sign]
 C -->D[接收请求中Post和Get参数和header头验签参数组成数组进行ASCII排序]
  D -->E[排序完成的数组进行base64加密转大写字符串]
  E -->F[生成的字符串进行MD5混淆与header头sign对比验签]
复制代码

后端代码 app/Middleware/AuthMiddleware.php:

<?php

declare(strict_types=1);
/**
 * This file is part of Hyperf.
 *
 * @link     https://www.hyperf.io
 * @document https://hyperf.wiki
 * @contact  [email protected]
 * @license  https://github.com/hyperf/hyperf/blob/master/LICENSE
 */
namespace App\Middleware;

use App\Exception\BusinessException;
use App\Tool\Token;
use Hyperf\Utils\context;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class AuthMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        if (! $request->hasHeader('sign') || ! $request->hasHeader('time-stamp') || ! $request->hasHeader('nonce-str')) {
            throw new BusinessException(3002, '登录失败');
        }
        $request_sign_str = $request->getHeader('sign')[0];
        $request_time_stamp = $request->getHeader('time-stamp')[0];
        $nonce_str = $request->getHeader('nonce-str')[0];
        $time_stamp = time();
        $difference = $time_stamp - $request_time_stamp;

        if ($difference && $difference > 180 || $difference && $difference < -180) {
            throw new BusinessException(3004, '密钥已过期');
        }
        $sign_type = ($request_time_stamp % 5) % 2;
        if ($sign_type) {
            $request_sign = substr($request_sign_str, 0, 32);
            $request_token = substr($request_sign_str, 32);
        } else {
            $request_sign = substr($request_sign_str, -32);
            $request_token = substr($request_sign_str, 0, -32);
        }
        $sign_info = [
            'time_stamp' => $request_time_stamp,
            'nonce_str' => $nonce_str,
            'token' => $request_token,
        ];
        $all_params = array_merge($sign_info, $request->getQueryParams(), $request->getParsedBody());
        $sort_string = $this->sort_ascii($all_params);
        $sign_string = strtoupper(base64_encode($sort_string));  // base64转后转大写
        $sign = md5($sign_string);
        if ($request_sign != $sign) {
            throw new BusinessException(3005, '签名错误');
        }
        $token = new Token();
        $token_info = $token->get($request_token);
        if (! $token_info) {
            throw new BusinessException(3003, '页面已过期,请重新操作');
        }
        $querys = $request->getQueryParams();
        if (isset($querys['openid'])) {
            if ($querys['openid'] != $token_info['openid']) {
                throw new BusinessException(3006, '非法请求!');
            }
        }
        $parsed = $request->getParsedBody('openid');
        if (isset($parsed['openid'])) {
            if ($parsed['openid'] != $token_info['openid']) {
                throw new BusinessException(3006, '非法请求!');
            }
        }
        Context::set(ServerRequestInterface::class, $request);
        return $handler->handle($request);
    }

    /*ascii码从小到大排序
     * @param array $params
     * @return bool|string
     */
    private function sort_ascii($params = [])
    {
        if (! empty($params)) {
            $p = ksort($params);
            if ($p) {
                $str = '';
                foreach ($params as $k => $val) {
                    $str .= $k . '=' . $val . '&';
                }
                return rtrim($str, '&');
            }
        }
        return false;
    }
}

config/autoload/listeners.php启用,设置为全局中间件

<?php

declare(strict_types=1);
/**
 * This file is part of Hyperf.
 *
 * @link     https://www.hyperf.io
 * @document https://hyperf.wiki
 * @contact  [email protected]
 * @license  https://github.com/hyperf/hyperf/blob/master/LICENSE
 */
 
 use App\Middleware\AuthMiddleware;
 
return [
    'http' => [
     AuthMiddleware::class
    ],
];

也可以在控制器类或者某个方法用注解使用单独使用这个中间件:

use App\Middleware\AuthMiddleware;
use App\Tool\Token;
use Hyperf\Di\Annotation\Inject;
...
  /**
     * @PostMapping(path="test")
     * @Middleware(AuthMiddleware::class)
     */
    public function test()
    {
        return $this->request->input('id');
    }
...

总结

混淆方法可以根据自己的需求更改,可以加上AES 带秘钥的加解密,数组排序换个排序方式,md5和base64加密顺序互换等等,我这里只是提供一个思路。主要的流程是把前端请求参数和随机字符字符串加上时间戳(随机字符串和时间戳也要放在header头)进行混淆生成一个header混淆参数,后端以同样的方式把请求参数和从header头获取到的验签参数进行计算与前端传入的混淆参数进行对比验签,同时进行时间戳时间范围验证。

这样修改某个请求参数,不修改header头中的验签参数去请求,后端中间件验证一定不会通过。破解之法就是使用关键参数进行在前端代码中Debug,完全模拟生成关键参数吗,这样的人太少了,太难了,这样的混淆应该会过滤95%以上有心之人。

最后分享下所有的代码在GitHub:github.com/koala9527/h…

猜你喜欢

转载自juejin.im/post/7126024604551741453