一、 需求
在渗透测试web/app/小程序/公众号的时候,通过各种途径解决了burp抓包的问题。还经常碰到Request/Response的body加密的情况。这使得我们很难在burp中修改和分析它的流量。
但众所周知,加密过程是在客户端完成的,防君子不防小人。因此前端加密仅仅只是增加了改包的时间成本,只要时间够经验足,总能较快的分析出来。
整个过程的难度,基本取决于如何获取和读懂前端代码。
web,前端就是js,一目了然。最多只会打包和开源混淆,较少商业混淆。
公众号,前端也是js,只是微信浏览器没有F12。
小程序,前端是打包在.wxapkg中的js,不一定能完整还原出来。
app,前端是apk,保护一般比较严格,可能会加壳和商业混淆。
二、 动态调试
对于web,我们天然有着动态调试的优势,因此可以很轻松靠下断点获取加密前的数据。
而公众号和小程序就没那么容易了,可以试用旧版和一些工具配合达到想要的效果。
公众号(微信内置浏览器)F12
https://www.cnblogs.com/conne/p/15884968.html
小程序+微信内置浏览器F12
https://github.com/JaveleyQAQ/WeChatOpenDevTools-Python
小程序解包
https://blog.csdn.net/Linlietao0587/article/details/127255806
app,就更难了,但可以靠一些工具直接hook密钥
https://github.com/monkeylord/XServer
三、 常碰到的加密算法
hash非对称加密。md5,sha1,sha256,通常用来验签或者传输密码。
AES,单密钥对称加密。常见有CBC模式(带iv)和ECB模式,通常使用pkcs7padding填充和128位密钥。
DES,单密钥对称加密。64位密钥(实际用到了56位),已经基本淘汰。
3DES,单密钥对称加密。DES向AES过渡的产物,和AES类似。
RC4,单密钥对称加密。密钥长度可变。
RSA,公私钥非对称加密。常用1024和2048位算法,客户端保存公钥或者N(模数)和E(公钥指数,一般为65537)。然后加密数据给服务端,服务端用私钥解密。公私钥机制会导致我们无法解出加密后的数据,除非动态调试或者篡改公钥。
SM2,公私钥非对称加密。和RSA类似。
SM4,单密钥对称加密。和AES类似。
jdk的javax.crypto.Cipher自带了很多算法,但要实现更多的比如国密系列,可以用hutool。
四、 静态分析
如果动态调试有困难,能否依靠静态分析来实现呢。当然可以,一般来说,只要不是js混淆的太厉害,你总能看懂加密后的body是从哪儿来的。
以前面的图为例,json发送的是dataPackage.business和dataPackage.key,定位一下。
可以看出来,business由des加密且hex化,key为RSA加密且hex化。
deskey=s.a.getters["handshake/getHandshakeInfo"].datakey
rsa用的n/e,为a.modulus/a.exponent
那么继续在js中找。
datakey是随机生成的24字节密钥
n/e是s.MODULUS/ s.PUBLICEXPONENT
然后就可以追踪到原来是首次访问服务器,服务器传过来的模数。
这下整个body的加密流程就明白了。
1,服务器下发n/e。
2,客户端生成随机datakey。
3,n/e加密datakey,生成dataPackage.key。
4,datakey加密body,生成dataPackage.business。
5,客户端发送dataPackage给服务端。
6,服务端用私钥解密dataPackage.key得到datakey。
7,服务端用datakey解密dataPackage.business得到body。
这就是整个Request body的加解密流程,如果Response body或者header中也有加密,也是一样的道理。
五、 解决办法
可以看到,我们想要解密的核心在于获取datakey。datakey是js首次运行一次后在内存中固定下来,所以如果能够动态调试,直接在随机生成24字节密钥的地方下断点,就能获得datakey。然后就能解密body,进而修改body。
当然,datakey如果是每次发送body都随机生成一次,每次都要断点就过于麻烦。此时可以直接修改
dataKey: h.a.CBPlugin.encrypt.random.getRandomKey(24)
暴力修改成
dataKey: ‘111111111111111111111111’
甚至都不需要懂代码,而是直接在burp中完成。不过要记得要删除缓存,重新加载js。。
但是如果是在app或者小程序上,我们既没法动态调试(小程序还是可以的),前端又是写死的,没法篡改(实际上还可以用别的办法hook)。
纯静态还有别的办法吗?当然有,还记得第一步服务端传过来的n/e吗?我们当然可以将其篡改成我们自己的n/e,然后客户端会使用我们自己的公钥加密datakey,再用datakey加密body。接着我们用自己的私钥解密dataPackage.key得到datakey,一切就水到渠成了。
六、 burp插件
burp插件的sdk在这儿导出。
新版sdk是这样的,比较复杂,网上的教程都是旧版的,burp做了兼容,推荐大家还是用旧版的。
旧版是这样的,入口为burp.BurpExtender,实现burp.IBurpExtender接口。新版burp可以写成别的类,但旧版burp入口只能为burp.BurpExtender。打包时随便弄个main方法打包即可。
因为我们只需要劫持流量,不用写GUI,因此只需要实现注册功能和HttpListener就行了。
public void registerExtenderCallbacks(IBurpExtenderCallbacks callbacks) {
this.callbacks = callbacks;
this.helpers = callbacks.getHelpers();
this.writer = new PrintWriter(callbacks.getStdout(), true);
callbacks.registerHttpListener(this);
callbacks.printOutput("[+]插件加载成功");
String currentPath = System.getProperty("user.dir");
callbacks.printOutput("[+]user.dir: "+currentPath);
}
将request/response/url/header/body等取出来。
public void processHttpMessage(int toolFlag, boolean messageIsRequest, IHttpRequestResponse messageInfo) {
byte[] request = messageInfo.getRequest();
byte[] response = messageInfo.getResponse();
IRequestInfo analyzeRequestInfo = this.helpers.analyzeRequest(request);
IResponseInfo analyzeResponseInfo = this.helpers.analyzeResponse(response);
this.url = analyzeRequestInfo.getUrl();
List requestHeaders = analyzeRequestInfo.getHeaders();
List parameters = analyzeRequestInfo.getParameters();
int requestBodyOffset = analyzeRequestInfo.getBodyOffset();
String requestBody = helpers.bytesToString(request).substring(requestBodyOffset).trim();
List responseHeaders = analyzeResponseInfo.getHeaders();
int responseBodyOffset = analyzeResponseInfo.getBodyOffset();
String responseBody = helpers.bytesToString(response).substring(responseBodyOffset).trim();
}
parameters只适合简单的传参,复杂一点的最好自己截取用json处理。
HttpListener实际上进行了两次监听,一次是Request发出去之前,此时没有Response,一次是收到Response之前。也就是说整个流程是这样的。
1,浏览器发送:/index.php?id=1
2,Burp Proxy Request: /index.php?id=1
【可劫持】
3,http流量 Request: /index.php?id=2
4,http流量 Response: admin
【可劫持】
5,Burp Proxy Response: system
6,浏览器收到:system
其中劫持后的http流量 可以在Logger中看到。
所以在处理的时候要用messageIsRequest来区分Request/Response。比如这个劫持Response的代码。
if (url.toString().contains("140.x.x.x")) {
if (!messageIsRequest) {
IResponseInfo analyzeResponseInfo = helpers.analyzeResponse(messageInfo.getResponse());
headers = analyzeResponseInfo.getHeaders();
int bodyOffset = analyzeResponseInfo.getBodyOffset();
String body = helpers.bytesToString(new_response).substring(bodyOffset).trim();
writer.println(body);
body = "admin";
new_response = helpers.buildHttpMessage(headers, body.getBytes());
messageInfo.setResponse(new_response);
}
那么整个burp解密插件的方案就出来了。
1,服务端下发真n/e。
2,Burp插件劫持,setResponse,修改成假n/e,并将真n/e保存。
3,客户端随机生成datakey,datakey加密body得到business,假n/e加密datakey得到key。
4,客户端发送business和key到Burp。
5,Burp插件劫持,setRequest,用私钥解密key得到datakey,用datakey解密business得到body。
6,Burp将body,key,business重新打包个json输出在日志中。
7,Burp用真n/e加密datakey,datakey加密body,发送给服务端。
8,Repeater改包时手动从日志中复制json。
9,Burp插件劫持,setRequest,检测到多了个body键,用私钥解密key得到datakey。
10,Burp用真n/e加密datakey, datakey加密body,发送给服务端。
至此,除了手动从日志中复制json的那一步,基本实现了全自动化。而之所以那一步不能自动化,主要还是因为浏览器到Burp Request这一步从Burp插件层无法劫持。
全程我们都使用的是客户端生成的随机datakey,但其实可以直接使用固定的24个1,服务端也不会拒绝。
但有的服务端会校验随机datakey是不是仅使用了一次,防止包重放。
还有的服务端,在header中增加经过加密的时间戳,来防止包重放。
以及Response body进行了加密。
这些都可以通过同样的方案解决。
七、 nodejs
有的时候还要对面一个问题,那就是客户端使用的js加密是自己实现的魔改算法,表面上也叫RSA/AES/SM2/SM4,实际上你写出来的java版和js版就不一样。又或者是个古早冷门的加密算法,将其移植到java短时间不现实。
这个时候就需要java调js,直接跑js代码去实现。但不管是java自带的Nashorn,还是Rhino,甚至java调python的一些js实现,去运行那些几百kb的js sdk,都是比较慢的。什么最快呢?经过我的测试,直接起命令行调nodejs最快。
提前在本地安装好nodejs,然后在注册插件的过程中在temp目录写入所需的js脚本。
boolean isWin = java.lang.System.getProperty("os.name").toLowerCase().contains("win");
if (isWin) {
SM.isWin = true;
SM.tempdir = "C:\windows\temp\";
callbacks.printOutput("[+]windows 设置C:\windows\temp\");
} else {
SM.isWin = false;
SM.tempdir = "/tmp/";
callbacks.printOutput("[+]linux 设置/tmp/");
}
if (SM.isNode()) {
callbacks.printOutput("[+]存在node");
} else {
callbacks.printOutput("[-]不存在node, 请安装node, 如果已安装, 可能存在沙箱");
}
try {
SM.writeJS();
callbacks.printOutput("[+]temp目录写入js成功");
} catch (Exception e) {
callbacks.printOutput("[-]temp目录写入js失败");
e.printStackTrace();
}
最后加解密的时候调一下就好。
public static String node_exec(String cmd) {
cmd = "node " + cmd;
return exec(cmd);
}
public static String exec(String cmd) {
String[] cmds = new String[]{"cmd.exe", "/c", cmd} ;
if (!isWin) {
cmds = new String[]{"/bin/sh", "-c", cmd};
}
InputStream in = null;
try {
in = Runtime.getRuntime().exec(cmds).getInputStream();
} catch (IOException e) {
return "runtime error";
}
Scanner s = new Scanner(in).useDelimiter("\a");
String output = s.hasNext() ? s.next() : "";
output = output.replace("r", "").replace("n", "");
return output;
}
此时又衍生了新的问题,既然要用node xxxx.js去加解密数据,必然涉及传参的问题。如果将data传到cmd中执行
node xxxx.js data
data如果超过了cmd的最大限度8191,就会直接报错。因此data最好写死在xxxx.js中,那么可以实现每运行一次,就生成一个缓存xxxx_temp.js。
以及安全问题,由于我们使用了客户端/服务端的数据,拼接在cmd命令和xxxx_temp.js中,就有着被反制的风险。不过这个很好解决,因为我们传输的数据要么是base64,要么是hex,字符是固定的,只要入参时检测一下是否只有那些字符就行了。
原文始发于微信公众号(珂技知识分享):实战小程序公众号解密burp插件