前言
前面分享了两篇关于Hyperf框架中RPC 同语言框架跨语言框架的调用,今天来点有意思,具有实战意义的技术分享,hyperf框架和laravel非常相似的PHP框架,按理来说下面的思路可以应用于任何语言框架。
最近有一些薅羊毛的羊毛党盯上了我们公司的支付宝小程序的抽奖产品,主要的逻辑是在前端判断用户是否关注的该小程序,如果关注了就给用户一次抽奖机会,然后请求后端接口直接抽奖发放奖励,运营人员发现奖金消耗过快,然后后台分析,没有几个人关注小程序却有很多人抽奖,分析过后发现是某些别有用心的人抓到了我们的接口进行更换请求参数然后跳过了前端判断是否关注小程序进行直接抽奖请求(这里是直接更换了支付宝用户ID参数达到了直接给支付宝用户直接发放奖励的效果)。
粗略方案
经过前后端讨论,解决的办法就是header头增加几个参数,例如时间戳,随机字符串,然后把这些内容加上请求参数进行混淆生成一个签名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上实现了完美与前端一样的混淆: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){//根据时间戳求余奇偶数来进行混淆拼接,得出最后的signnew_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中设置环境变量:
在请求发送之前会执行上面那段代码,把所有的参数进行计算生成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对比验签]
后端中间件的代码,相对于前端较为简单,主要是框架做了一些封装,post脚本都是底层逻辑代码app/Middleware/AuthMiddleware.php:
<?php
declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contactgroup@hyperf.io
* @licensehttps://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
* @contactgroup@hyperf.io
* @licensehttps://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');}
...
这样访问接口的时候后端就会验证必须携带这几个header混淆参数,并且需要混淆计算准确。
总结
混淆方法可以根据自己的需求更改,可以加上AES 带秘钥的加解密,数组排序换个排序方式,md5和base64加密顺序互换等等,我这里只是提供一个思路。主要的流程是把前端请求参数和随机字符字符串加上时间戳(随机字符串和时间戳也要放在header头)进行混淆生成一个header混淆参数,后端以同样的方式把请求参数和从header头获取到的验签参数进行计算与前端传入的混淆参数进行对比验签,同时进行时间戳时间范围验证。
这样修改某个请求参数,不修改header头中的验签参数去请求,后端中间件验证一定不会通过。破解之法就是使用关键参数进行在前端代码中Debug,完全模拟生成关键参数吗,这样的人太少了,太难了,这样的混淆应该会过滤95%以上的薅羊毛技术党。
最后分享下所有的代码在GitHub:github.com/koala9527/h…
网络安全成长路线图
这个方向初期比较容易入门一些,掌握一些基本技术,拿起各种现成的工具就可以开黑了。不过,要想从脚本小子变成hei客大神,这个方向越往后,需要学习和掌握的东西就会越来越多,以下是学习网络安全需要走的方向:
# 网络安全学习方法
上面介绍了技术分类和学习路线,这里来谈一下学习方法:
## 视频学习
无论你是去B站或者是油管上面都有很多网络安全的相关视频可以学习,当然如果你还不知道选择那套学习,我这里也整理了一套和上述成长路线图挂钩的视频教程,完整版的视频已经上传至CSDN官方,朋友们如果需要可以点击这个链接免费领取。网络安全重磅福利:入门&进阶全套282G学习资源包免费分享!