巧用Chrome-CDP远程调用Debug突破JS逆向 前言 我在测试一个网站的时候,大多数的网站不会对接口的请求数据做加密处理,但有的时候在测试一些重点行业的网络资产时,常常会碰到一些功能点存在加密,例如登录,个人信息查询,搜索等等。在以往我遇到这些存在接口加密站点的时候,一般就是通过一些调试手段,找到加解密函数,从F12的控制台对变量手动加密,解密,但这一方法的弊病就是我们每次都必须断好点,同时必须提前预知要加解密的内容,还要拼接函数执行,因此如果遇到需要FUZZ或者爆破的接口,只能放弃。再者就是通过开发者工具断点跟栈,一点点找函数调用关系,还原加密算法,但是一旦遇到加密复杂,存在js混淆的网站,这个方法的失败率就会成指数级上升。
于是,为了解决这个需求,在网上冲浪的时候,便发现了Chrome DevTools Protocol 这个好东西,这是一套开放协议,简称CDP,允许外部程序通过Chrome浏览器提供的接口与其进行交互,CDP提供了丰富的接口,使得我们可以通过WebSocket协议来对接口传输数据,进而操作浏览器的各种功能,例如打开标签页,断点调试,执行js函数等等。这里有兴趣的小伙伴可以看一下官方文档:https://chromedevtools.github.io/devtools-protocol/
我在网上搜索资料的时候发现这方面教程极少,应用于安全领域方面的文章也是不超过五篇,在文章最后已经注明了地址,在按照文章学习使用的过程中,也是遇到了挫折重重,但是经过研究分析之后,本篇文章也都会尽可能详细地向大家介绍,CDP远程调用非常方便,他允许我们直接可以通过代码来操作浏览器完成一系列行为,希望通过我的这篇文章让师傅们对其有一定了解,学习并赋能与我们的渗透测试与安全研究工作之中,提升效率!
CDP,启动! 开启远程调试 首先我们要开启谷歌浏览器的远程调试,并且设置端口,同时允许所有的源访问调试界面:
进入Chrome的根目录,呼出cmd:
执行命令,打开Chrome浏览器,同时设置远程调试端口,允许所有源访问调试详情页面:
这里要注意,在运行下面命令的时候,我们要先关闭电脑上运行的所有浏览器,不然会由于冲突导致启动失败。
chrome –remote-debugging-port=9222 –remote-allow-origins=*
查看正在进行CDP调试的端点信息 执行命令我们会打开一个全新的Chrome,访问http://localhost:9222/json :
在上图可以看到,访问之后有一堆json格式的数据,实际这个通俗理解是Chrome为我们以API的方式展示的可供远程调用的端点,我们可以看到我现在有一个标签页,是百度,因此在第二个数据中也看到了它的WebSocke调试地址,webSocketDebuggerUrl,因此假如我们想对其进行远程断点debug,执行js函数,我们就可以对这个地址发命令即可。
这里也标注一下json数据中其他键的含义:
id
: 会话 ID,用于在 CDP 中与特定页面进行通信。
title
: 页面的标题。
url
: 当前页面的 URL。
webSocketDebuggerUrl
: WebSocket 地址,用于与 CDP 建立通信。
CDP 命令 CDP命令格式,即我们向WebSocket接口发送的数据格式,所有的命令实际上有一个统一的格式规范。
{ ‘method’: ‘方法(命令)’, ‘id’: (随便一个数字), ‘params’: {
该方法需要用到的参数都写在这里 } }
官方文档介绍了很多命令,这里我们主要用到一个命令:Debugger.evaluateOnCallFrame ,主要作用就是:在xx处断点,同时在这里执行xx函数。
{ ‘method’: ‘Debugger.evaluateOnCallFrame’, ‘id’: 123, ‘params’: { ‘callFrameId’: callFrameId, ‘expression’: expression, ‘objectGroup’: ‘console’, ‘includeCommandLineAPI’: True, } }
上面那句话可以看到我给了两个变量,而本方法参数callFrameId,实际就表示了在哪里断点,而expression则是我们要执行的js函数。callFrameId的获取我们在下面的实验部分将有所介绍
实验开始 生成一个示例登录站点 这部分使用GPT来完成,我选用的模型是ChatGPT 4o with canvas,提示词如下:
请你帮我写一个登录系统,输入账号密码登录即可。要求使用php语言,无需使用mysql,直接用硬编码判断账号admin和密码admin123即可。登陆成功后可以无需有任何页面,这个系统的关键就是在前端写入账号密码,发送到后端接口校验的时候,要利用js对账号和密码的参数进行加密,请你发挥想象,对AES加密算法进行魔改,把加密算法写道单独的额外的js文件中,尽可能地避免通过前端的js代码审计可以复刻出加密算法,一定要确保js加密的安全性,防止被逆向。
在这篇文章完成的时候利用这个提示词,我测试了多个大模型,发现这个提示词会使AI产生幻觉,包括我再次用ChatGPT 4o跑了一遍,代码就有问题了,要么是前端得加密是先A再B再C,结果后端解密时候顺序乱套了,要么就是前后端的密钥和偏移,以及一些函数都不一样,容易出现一些尴尬的小问题,因此我把这个实验用的示例系统源代码也贴在下面,各位朋友可以复制到本地,方便复现学习。当然代码中也有丰富的注释,大家也可以一目了然加密逻辑,方面理解。
示例系统构成:前端页面i代码ndex.html,二次加密混淆逻辑代码secureEncrypt.js,后端校验代码login.php。
index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > 史上最安全的登录系统</title > <script src ="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js" > </script > <script src ="./js/secureEncrypt.js" > </script > <style > body { font-family : Arial, sans-serif; background-color : #f4f4f9 ; margin : 0 ; padding : 0 ; display : flex; justify-content : center; align-items : center; height : 100vh ; } .login-container { background : #fff ; padding : 2rem ; border-radius : 8px ; box-shadow : 0 4px 8px rgba (0 , 0 , 0 , 0.1 ); max-width : 400px ; width : 100% ; } .login-container h1 { margin : 0 0 1.5rem ; font-size : 1.8rem ; color : #333 ; text-align : center; } form { display : flex; flex-direction : column; } label { margin-bottom : 0.5rem ; font-size : 0.9rem ; color : #555 ; } input { padding : 0.8rem ; border : 1px solid #ddd ; border-radius : 4px ; margin-bottom : 1rem ; font-size : 1rem ; transition : border-color 0.3s ease; } input :focus { border-color : #007bff ; outline : none; } button { padding : 0.8rem ; background-color : #007bff ; color : #fff ; border : none; border-radius : 4px ; font-size : 1rem ; cursor : pointer; transition : background-color 0.3s ease; } button :hover { background-color : #0056b3 ; } .message { margin-top : 1rem ; text-align : center; font-size : 0.9rem ; } .message .error { color : #ff0000 ; } .message .success { color : #28a745 ; } </style > </head > <body > <div class ="login-container" > <h1 > 史上最安全的登录系统</h1 > <form id ="loginForm" > <label for ="account" > 账号:</label > <input type ="text" id ="account" name ="account" placeholder ="请输入你的账号" required > <label for ="password" > 密码:</label > <input type ="password" id ="password" name ="password" placeholder ="请输入你的密码" required > <button type ="submit" > 登录</button > <div id ="message" class ="message" > </div > </form > </div > <script > document .getElementById ('loginForm' ).addEventListener ('submit' , async function (e ) { e.preventDefault (); const account = document .getElementById ('account' ).value .trim (); const password = document .getElementById ('password' ).value .trim (); const messageBox = document .getElementById ('message' ); if (!account || !password) { messageBox.textContent = "Please fill out all fields." ; messageBox.className = "message error" ; return ; } messageBox.textContent = "" ; const key = "MySuperSecretKey1234567890123456" ; const iv = "RandomInitVector" ; try { const encryptedAccount = secureEncrypt (account, key, iv); const encryptedPassword = secureEncrypt (password, key, iv); const response = await fetch ('login.php' , { method : 'POST' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' }, body : new URLSearchParams ({ account : encryptedAccount, password : encryptedPassword, }), }); const result = await response.json (); if (response.ok ) { messageBox.textContent = result.message ; messageBox.className = "message success" ; } else { messageBox.textContent = result.message || "Login failed." ; messageBox.className = "message error" ; } } catch (err) { console .error ('Error:' , err); messageBox.textContent = "An error occurred while logging in." ; messageBox.className = "message error" ; } }); </script > </body > </html >
secureEncrypt.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 function secureEncrypt (input, key, iv ) { function aesEncrypt (data, key, iv ) { return CryptoJS .AES .encrypt (data, CryptoJS .enc .Utf8 .parse (key), { iv : CryptoJS .enc .Utf8 .parse (iv), mode : CryptoJS .mode .CBC , padding : CryptoJS .pad .Pkcs7 , }).toString (); } function addNoise (data ) { const noise = Math .random ().toString (36 ).substring (2 , 8 ); return noise + '|' + data + '|' + noise.split ('' ).reverse ().join ('' ); } function obfuscate (data ) { const map = { 'a' : '@' , 'e' : '3' , '1' : 'l' , 'o' : '0' , 's' : '$' }; return data.replace (/[aeios]/g , char => map[char] || char); } const noisyInput = addNoise (input); const obfuscatedInput = obfuscate (noisyInput); return aesEncrypt (obfuscatedInput, key, iv); }
login.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 <?php header ('Content-Type: application/json' );$key = "MySuperSecretKey1234567890123456" ; $iv = "RandomInitVector" ; function customDecrypt ($data , $key , $iv ) { $method = "AES-256-CBC" ; $options = OPENSSL_RAW_DATA; $key = mb_convert_encoding ($key , 'UTF-8' ); $iv = mb_convert_encoding ($iv , 'UTF-8' ); $ciphertext = base64_decode ($data ); $plaintext = openssl_decrypt ($ciphertext , $method , $key , $options , $iv ); return $plaintext ; }function removeNoiseAndReverse ($data ) { $map = ['@' => 'a' , 'e' => '3' , 'l' => '1' , 'o' => '0' , '$' => 's' ]; $replacedData = strtr ($data , $map ); $parts = explode ('|' , $replacedData ); if (count ($parts ) !== 3 ) { throw new Exception ("Invalid data format." ); } return $parts [1 ]; }try { $encryptedAccount = $_POST ['account' ] ?? '' ; $encryptedPassword = $_POST ['password' ] ?? '' ; $decryptedAccountRaw = customDecrypt ($encryptedAccount , $key , $iv ); $decryptedPasswordRaw = customDecrypt ($encryptedPassword , $key , $iv ); if ($decryptedAccountRaw === false || $decryptedPasswordRaw === false ) { throw new Exception ("AES decryption failed." ); } $decryptedAccount = removeNoiseAndReverse ($decryptedAccountRaw ); $decryptedPassword = removeNoiseAndReverse ($decryptedPasswordRaw ); $validAccount = 'admin' ; $validPassword = 'admin123' ; if ($decryptedAccount === $validAccount && $decryptedPassword === $validPassword ) { echo json_encode (['success' => true , 'message' => '登录成功!' ]); } else { echo json_encode (['success' => false , 'message' => "账号或密码错误!" ]); } } catch (Exception $e ) { echo json_encode (['success' => false , 'message' => 'Error: ' . $e ]); }?>
搭建完成,运行之后,我们抓登陆包,目前已经达到了请求包加密效果:
开启协议监听 这一步骤用于监听我们执行debug的地方,得到callFrameId
打开网页,按F12进入开发者工具,进入设置,实验,开启Protocol Monitor
开启后紧接着我们去更多工具,开启协议监视器
开启后我们进行抓包,接下来便是常规的js逆向初始阶段,但是我们不用还原加密算法,直接找到加解密的函数即可,这个很简单,使用最常规的方法,点一下登录之后到抓包界面,点击js
进入后开启断点,再点击一下登录,发现程序已经断点,向上跟栈,得到加解密的最终步骤
这里由于是我们自己创建的实验环境,所以很简单的找到了加密函数地点,我们在这里再断点,重新运行后让网页在这里停下
紧接着我们到控制台执行任意一个函数,这里我们直接输入变量,请求控制台把加密后的值打印出来
接下来进入协议监视器,拉到最下方,突然发现一个熟悉的字眼!没错,这就是我们上面刚刚提到的一个CDP命令,在右边也是出现了命令执行的结果,再加上上面熟悉的请求/响应,整个过程很像抓包有木有,哈哈哈哈
从请求中,我们可以看到这次方法调用的参数,得到我们之后要用的callFrameId ,这里细心观察可以看到,我们上面的命令编写,实际就是重写了一下这里的参数,我们只需要再包装成相同的格式,替换callFrameId后再重发即可,替换expression的值,执行我们想要的js函数,即可实现远程调用
代码远程调试 从上面的步骤我们抓到了callFrameId为”9154534773061276043.7.0”,接下来我们使用Python的websocket-client模块尝试远程调用。
在开始之前我们别忘了要到 http://localhost:9222/json 里面看下这个标签页的webSocketDebuggerUrl
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import jsonimport websocket webSocketDebuggerUrl = "ws://localhost:9222/devtools/page/9F47572FDBFB63E8B1EF661D1714E3DC" command = { 'method' : 'Debugger.evaluateOnCallFrame' , 'id' : 123 , 'params' : { 'callFrameId' : "9154534773061276043.7.0" , 'expression' : "encryptedAccount" , 'objectGroup' : 'console' , 'includeCommandLineAPI' : True , } } connection = websocket.create_connection(webSocketDebuggerUrl) connection.send(json.dumps(command))print (json.loads(connection.recv()))
运行之后,发现返回一个json格式的数据,这个数据恰好也就是我们从浏览器看到响应模块的内容,里面包含了该函数运行的结果
更改expression的值,运行其他函数,测试一下,这里我改成明文的函数,可以看到已经返回了我输入的明文
至此,我们就成功实现了CDP远程调用,在代码层面执行了JS函数,实现逆向!
思路拓展 脚本化批量执行实现爆破 我们知道既然可以用Python远程调用CDP命令,执行JS函数,那么我们可以应用这一功能,批量执行目标网站的JS加密函数,把我们本地的明文字典,批量跑函数,得到密文字典,从而方便后续爆破测试,如下实战应用:
目标网站登录存在复杂加密,js逆向逆不出来
但是通过断点,我们可以得到加解密的函数,算法无法还原
使用Python进行CDP远程调用,批量跑加密函数,使明文字典变为密文字典
如下图代码,我把执行CDP命令部分封装成了一个方法,直接调用这个方法,即可打印批量函数运行的结果,得到密文字典
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 import jsonimport websocketdef execute_dp (connection, data ): command = { 'method' : 'Debugger.evaluateOnCallFrame' , 'id' : 123 , 'params' : { 'callFrameId' : "-2349054866915643001.1.0" , 'expression' : f'secureEncrypt("{data} ", key, iv)' , 'objectGroup' : 'console' , 'includeCommandLineAPI' : True , } } connection.send(json.dumps(command)) return json.loads(connection.recv())if __name__ == '__main__' : webSocketDebuggerUrl = "ws://localhost:9222/devtools/page/1EC8507DECF7CA1E2FB1F9A9E9FB0862" connection = websocket.create_connection(webSocketDebuggerUrl) f = open ("zhuan.txt" , "r" , encoding="utf-8" ) g = open ("result.txt" , "a" , encoding="utf-8" ) for i in f.read().split("\n" ): g.write(execute_dp(connection, i)['result' ]['result' ]['value' ] + "\n" )
使用Burp,Intruder模块,密码选用密文字典爆破
返回200,成功爆破带有登录参数加密的系统
配合mitmproxy实现全流量加解密 mitmproxy是一款非常好用的工具,可以截获请求,同时支持将请求体内容存储为变量,因此我们就可以使用mitmproxy截获请求,将请求体的某个参数,以变量形式作为执行CDP命令的参数,将他解密之后再拼回请求体,转发至burp,这样我们在burp侧看到的请求就是解密后的,同时我们在burp设置代理转发,将流量转回mitmproxy,运行加密脚本,将明文加密成密文再拼接请求体,发给服务端。
这样就可以达到burp明文测试的结果,具体流量发送步骤如下:
我们直接使用python写一个decrypt.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 from mitmproxy import http, ctximport urllib.parse import zhuanclass ModifyPassword : def request (self, flow: http.HTTPFlow ) -> None : if "xx.xx" in flow.request.headers.get("Host" , "" ) and flow.request.method == "POST" : try : req_data = flow.request.text ctx.log.info(f"原始请求体: {req_data} " ) parsed_data = urllib.parse.parse_qs(req_data) if "password" in parsed_data: old_password = parsed_data["password" ][0 ] new_password = execute_cdp(data) parsed_data["password" ] = [new_password] ctx.log.info(f"旧的 password 值: {old_password} " ) ctx.log.info(f"替换后的 password 值: {new_password} " ) else : ctx.log.info("请求体中未找到 password 参数" ) new_req_data = urllib.parse.urlencode(parsed_data, doseq=True ) flow.request.text = new_req_data ctx.log.info(f"修改后的请求体: {new_req_data} " ) except Exception as e: ctx.log.error(f"处理请求时出错: {str (e)} " ) addons = [ ModifyPassword() ]
启用cmd,运行mitmproxy
mitmproxy –mode upstream:http://127.0.0.1:8080 -s decrypt.py –listen-port 8989 –ssl-insecure
其中 –mode upstream:http://127.0.0.1:8080 指的是二次转发至burp
运行后可以看到mitmproxy已经抓到了,是密文
但是我们到burp,拦截,发现password字段已经变成了明文
同理我们使用相同的方法,也可达到相应包全流程加解密。
结语 Chrom的CDP命令是一个十分强大的功能,这个我们也许往往忘记深入挖掘,实际其中的很多接口,很多方法都能对我们渗透测试过程提供莫大帮助,本文只是介绍了冰山一角,展现其对于js逆向的遍历,我们不用再拘束于传统的算法逆向,而是直接找到关键函数即可,希望读完这篇文章的你可以继续探索,欢迎评论交流,我是小安,感谢你的阅读!
致谢