Apk加固原理解析
来源:互联网 发布:linux创建c文件 编辑:程序博客网 时间:2024/05/16 13:28
一、背景
为了更好的防止Apk被反编译,将Apk加密后打入宿主Apk包中,宿主启动时,解密Apk然后启动Apk。
二、工作原理
通过反射的方法替换ClassLoader及资源路径,使得启动的都是加固的Apk。
三、启动过程分析
加固的Apk大致的启动过程是这样的:
宿主Apk启动 -> 宿主Application中解密Apk -> 替换ClassLoader -> 替换资源路径 -> 替换Application对象(若APk中存在的话)
3.1 解密Apk
此步骤只是将asset下的加密的Apk文件拷贝到sd卡上,拷贝过程中同时解密。
3.2 替换ClassLoader
ActivityThread类里有个成员变量mPackages,mPackages存储的是LoadedApk对象,LoadedApk类的成员变量mClassLoader就是apk包类加载器,也就是要替换的ClassLoader。
3.2.1 LoadedApk创建过程
程序启动的时候,ActivitThread在handleBindApplication函数中创建Application对象。下面贴出关键代码
private void handleBindApplication(AppBindData data) { ... // 创建LoadedApk对象 data.info = getPackageInfoNoCheck(data.appInfo, data.compatInfo); ... //调用LoadedApk的makeApplication方法创建Application对象 Application app = data.info. makeApplication(data.restrictedBackupMode, null); mInitialApplication = app; ... // 调用Application的onCreate方法,至此Application对象创建完毕 try { mInstrumentation.callApplicationOnCreate(app); } catch (Exception e) { if (!mInstrumentation.onException(app, e)) { throw new RuntimeException( "Unable to create application " + app.getClass().getName() + ": " + e.toString(), e); } }}public final LoadedApk getPackageInfoNoCheck(ApplicationInfo ai, CompatibilityInfo compatInfo) { return getPackageInfo(ai, compatInfo, null, false, true, false);}private LoadedApk getPackageInfo(ApplicationInfo aInfo, CompatibilityInfo compatInfo, ClassLoader baseLoader, boolean securityViolation, boolean includeCode, boolean registerPackage) { WeakReference<LoadedApk> ref; if (differentUser) { // Caching not supported across users ref = null; } else if (includeCode) { ref = mPackages.get(aInfo.packageName); } else { ref = mResourcePackages.get(aInfo.packageName); } // 首先从mPackages从读取 LoadedApk packageInfo = ref != null ? ref.get() : null; // 首次启动,mPackages中是空的 if (packageInfo == null || (packageInfo.mResources != null && !packageInfo.mResources.getAssets().isUpToDate())){ //创建LoadedApk对象 packageInfo = new LoadedApk(this, aInfo, compatInfo, baseLoader,securityViolation, includeCode && (aInfo.flags&ApplicationInfo.FLAG_HAS_CODE) != 0, registerPackage); ... // 将LoadedApk对象保存到mPackages中 if (differentUser) { // Caching not supported across users } else if (includeCode) { mPackages.put(aInfo.packageName, new WeakReference<LoadedApk>(packageInfo)); } else { mResourcePackages.put(aInfo.packageName, new WeakReference<LoadedApk>(packageInfo)); } } return packageInfo;}
至此LoadedApk对象创建完毕。
3.2.2 ClassLoader创建过程
在LoadedApk对象创建的时候,并没有创建ClassLoader,看下LoadedApk构造函数。
public LoadedApk(ActivityThread activityThread, ApplicationInfo aInfo,CompatibilityInfo compatInfo, ClassLoader baseLoader,boolean securityViolation, boolean includeCode, boolean registerPackage) { final int myUid = Process.myUid(); aInfo = adjustNativeLibraryPaths(aInfo); mActivityThread = activityThread; mApplicationInfo = aInfo; mPackageName = aInfo.packageName; mAppDir = aInfo.sourceDir; mResDir = aInfo.uid == myUid ? aInfo.sourceDir : aInfo.publicSourceDir; mSplitAppDirs = aInfo.splitSourceDirs; mSplitResDirs = aInfo.uid == myUid ? aInfo.splitSourceDirs : aInfo.splitPublicSourceDirs; mOverlayDirs = aInfo.resourceDirs; mSharedLibraries = aInfo.sharedLibraryFiles; mDataDir = aInfo.dataDir; mDataDirFile = mDataDir != null ? new File(mDataDir) : null; mLibDir = aInfo.nativeLibraryDir; mBaseClassLoader = baseLoader; mSecurityViolation = securityViolation; mIncludeCode = includeCode; mRegisterPackage = registerPackage; mDisplayAdjustments.setCompatibilityInfo(compatInfo);}
那ClassLoader对象是什么时候创建的呢?从LoadedApk源码中,看到ClassLoader对象是在getClassLoader方法中创建的。
public ClassLoader getClassLoader() { if (mIncludeCode && !mPackageName.equals("android")) { // apk和lib路径 final List<String> zipPaths = new ArrayList<>(); final List<String> apkPaths = new ArrayList<>(); final List<String> libPaths = new ArrayList<>(); zipPaths.add(mAppDir); if (mSplitAppDirs != null) { Collections.addAll(zipPaths, mSplitAppDirs); } libPaths.add(mLibDir); //添加各种路径apk和lib路径 ... final String zip = TextUtils.join(File.pathSeparator, zipPaths); ... final String lib = TextUtils.join(File.pathSeparator, libPaths); mClassLoader = ApplicationLoaders.getDefault(). getClassLoader(zip, lib, mBaseClassLoader); } else { if (mBaseClassLoader == null) { mClassLoader = ClassLoader.getSystemClassLoader(); } else { mClassLoader = mBaseClassLoader; } } return mClassLoader;}
而getClassLoader是在什么时候被调用的呢?创建Application对象的时候,调用了LoadedApk的makeApplication方法,那在该方法中必然用到ClassLoader,看下该方法的代码。
public Application makeApplication(boolean forceDefaultAppClass, Instrumentation instrumentation) { // 若存在不重复创建 if (mApplication != null) { return mApplication; } Application app = null; String appClass = mApplicationInfo.className; if (forceDefaultAppClass || (appClass == null)) { appClass = "android.app.Application"; } try { // 获取ClassLoader对象,即在此创建了ClassLoader对象 java.lang.ClassLoader cl = getClassLoader(); if (!mPackageName.equals("android")) { initializeJavaContextClassLoader(); } ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, this); //创建Application对象 app = mActivityThread.mInstrumentation.newApplication( cl, appClass, appContext); appContext.setOuterContext(app); } catch (Exception e) { if (!mActivityThread.mInstrumentation. onException(app, e)) { throw new RuntimeException( "Unable to instantiate application " + appClass + ": " + e.toString(), e); } } mActivityThread.mAllApplications.add(app); mApplication = app; ... return app;}
至此已经清楚LoadedApk中的mClassLoader的创建和使用。
3.2.3 替换ClassLoader
宿主Apk包的ClassLoader已创建完毕,那在何时替换ClassLoader呢?选择在Application的attachBaseContext方法中进行执行替换动作。因为attachBaseContext是自定义的Application类中覆盖的父类方法中第一个被调用的。那具体何时调用的呢?来看下源码。
//Instrumentation类中创建Application对象public Application newApplication(ClassLoader cl, String className, Context context) throws InstantiationException, IllegalAccessException, ClassNotFoundException { return newApplication(cl.loadClass(className), context);}static public Application newApplication(Class<?> clazz, Context context) throws InstantiationException, IllegalAccessException, ClassNotFoundException { Application app = (Application)clazz.newInstance(); // attach Application context app.attach(context); return app;}
再来看下Application的attach方法。
final void attach(Context context) { //在此处调用了attachBaseContext attachBaseContext(context); mLoadedApk = ContextImpl.getImpl(context).mPackageInfo;}
至此已经清楚,在Application被创建的时候,就调用了attachBaseContext方法,完全可以在此方法中修改ClassLoader。
自定义Application类,覆盖attachBaseContext方法。
public class App extends Application { private Object obj; // 加固包的Application类对象 static { // 实现ClassLoader类,资源,Application替换的库 System.loadLibrary("native-lib"); } @Override protected void attachBaseContext(Context base) { super.attachBaseContext(base); String appName = getReinforceApkAppName(); // 实现ClassLoader类替换,资源加载路径替换,并创建加固包的Application对象 obj = onAppAttach(this, appName); } @Override public void onCreate() { super.onCreate(); //实现Application对象替换,将宿主的Application对象替换成加固包的Application对象 onAppCreate(this, obj, null); } // 以下是加载资源 protected static AssetManager mAssetManager;//资源管理器 protected Resources mResources;//资源 protected Resources.Theme mTheme;//主题 @Override public AssetManager getAssets() { AssetManager assetManager = mAssetManager == null ? super.getAssets() : mAssetManager; return assetManager; } @Override public Resources getResources() { if(mAssetManager != null && mResources == null) { Resources superRes = super.getResources(); mResources = new Resources(mAssetManager, superRes.getDisplayMetrics(), superRes.getConfiguration()); } return mResources == null ? super.getResources() : mResources; } @Override public Resources.Theme getTheme() { if(mResources != null && mTheme == null) { mTheme = mResources.newTheme(); mTheme.setTo(super.getTheme()); } return mTheme == null ? super.getTheme() : mTheme; } private native void onAppCreate(Application app, Object obj, String application); private native Object onAppAttach(Application app, String application);}jobject Java_com_jd_apploader_App_onAppAttach(JNIEnv *env, jobject thiz, jobject app, jstring appName) { InitPackageName(env, app); apkFilePath = GetApkFilePath(env, app); apkLibPath = GetApkLibPath(env, app); apkFileName = GetApkFileName(apkFilePath); // 解密Apk bool ret = CopyApkFile(env, apkFileName, apkFilePath, apkLibPath, app); if(!ret) { return NULL; } jstring strApkFileName = env->NewStringUTF(apkFileName); jstring strApkFilePath = env->NewStringUTF(apkFilePath); jstring strApkLibPath = env->NewStringUTF(apkLibPath); /** * 利用反射技术,找到ClassLoader对象,并替换 * 以及替换资源路径 */ // 获取ActivityThread的mPackages成员变量 jclass clsActThread = env->FindClass( "android/app/ActivityThread"); jmethodID currentActivityThread = env->GetStaticMethodID( clsActThread, "currentActivityThread", "()Landroid/app/ActivityThread;"); jobject actThread = env->CallStaticObjectMethod(clsActThread, currentActivityThread); jfieldID mPackagesId; if(GetOSVersion() >= 19) { mPackagesId = env->GetFieldID(clsActThread, "mPackages", "Landroid/util/ArrayMap;"); } else { mPackagesId = env->GetFieldID(clsActThread, "mPackages", "Ljava/util/HashMap;"); } jobject mPackages = env->GetObjectField(actThread, mPackagesId); // 获取LoadedApk对象 jclass mapCls = env->FindClass("java/util/Map"); jmethodID getId = env->GetMethodID(mapCls, "get", "(Ljava/lang/Object;)Ljava/lang/Object;"); jobject wr = env->CallObjectMethod(mPackages, getId, jPackageName); jclass wrclass = env->FindClass( "java/lang/ref/WeakReference"); jmethodID methodGet = env->GetMethodID(wrclass, "get", "()Ljava/lang/Object;"); jobject objApkLoader = env->CallObjectMethod(wr, methodGet); // 获取LoadedApk中的ClassLoader对象 jclass clsApkLoader = env->FindClass("android/app/LoadedApk"); jfieldID fieldClassLoader = env->GetFieldID(clsApkLoader, "mClassLoader", "Ljava/lang/ClassLoader;"); jobject classDexLoader = env->GetObjectField(objApkLoader, fieldClassLoader); // 创建Apk的ClassLoader,并以宿主的ClassLoader对象为父ClassLoader jclass dexClassLoader = env->FindClass( "dalvik/system/DexClassLoader"); jmethodID initDexLoaderMethod = env->GetMethodID( dexClassLoader, "<init>", "(Ljava/lang/String;Ljava/lang/String;" + "Ljava/lang/String;Ljava/lang/ClassLoader;)V"); jobject dexLoader = env->NewObject(dexClassLoader, initDexLoaderMethod, strApkFileName, strApkFilePath, strApkLibPath, classDexLoader); // 替换ClassLoader env->SetObjectField(objApkLoader, fieldClassLoader, dexLoader); // 设置App类中的mAssetManager,mResources,mTheme LoadResource(env, strApkFileName); // 替换LoadedApk中的mResDir,mResDir是资源加载路径,在此将该路径替换到解密后的Apk路径,这样应用就可以正常加载资源 jfieldID resDirId = env->GetFieldID(clsApkLoader, "mResDir", "Ljava/lang/String;"); env->SetObjectField(objApkLoader, resDirId, strApkFileName); // 若apk包中存在自定义的Application,则创建该Application jobject gameApp = NULL; if(appName != NULL) { gameApp = createGameApplication(env, appName, strApkFileName); } return gameApp;}void Java_com_jd_apploader_App_onAppCreate(JNIEnv *env, jobject obj, jobject app, jobject gameApp, jstring appName) { if(gameApp == NULL) { if(appName == NULL) { free(apkFilePath); free(apkLibPath); free(apkFileName); return; } else { gameApp = createGameApplication(env, appName, env->NewStringUTF(apkFileName)); } } jclass clsActThread = env->FindClass( "android/app/ActivityThread"); jmethodID curThreadMthId = env->GetStaticMethodID( clsActThread, "currentActivityThread", "()Landroid/app/ActivityThread;"); jobject actThread = env->CallStaticObjectMethod(clsActThread, curThreadMthId); // 替换ActivityThread中的mInitialApplication jfieldID initAppId = env->GetFieldID(clsActThread, "mInitialApplication", "Landroid/app/Application;"); jobject mInitialApplication = env->GetObjectField(actThread, initAppId); env->SetObjectField(actThread, initAppId, gameApp); // 替换mAllApplications中的宿主Application对象 jfieldID allAppId = env->GetFieldID(clsActThread, "mAllApplications", "Ljava/util/ArrayList;"); jobject mAllApplications = env->GetObjectField(actThread, allAppId); jclass clsArrList = env->FindClass("java/util/ArrayList"); jmethodID removeMth = env->GetMethodID(clsArrList, "remove", "(Ljava/lang/Object;)Z"); env->CallBooleanMethod(mAllApplications, removeMth, mInitialApplication); // 替换mBoundApplication中LoadedApk里的mApplication对象 jfieldID boundAppId = env->GetFieldID(clsActThread, "mBoundApplication", "Landroid/app/ActivityThread$AppBindData;"); jobject mBoundApplication = env->GetObjectField(actThread, boundAppId); jclass clsAppBindData = env->FindClass( "android/app/ActivityThread$AppBindData"); jfieldID infoId = env->GetFieldID(clsAppBindData, "info", "Landroid/app/LoadedApk;"); jobject info = env->GetObjectField(mBoundApplication, infoId); jclass clsLoadedApk = env->FindClass("android/app/LoadedApk"); jfieldID appId = env->GetFieldID(clsLoadedApk, "mApplication", "Landroid/app/Application;"); env->SetObjectField(info, appId, gameApp); jclass clsApplication = env->FindClass( "android/app/Application"); jmethodID onCreate = env->GetMethodID(clsApplication, "onCreate", "()V"); env->CallVoidMethod(gameApp, onCreate);}
至此所有需要替换的对象均已替换完成,应用可正常运行。
3.2.4 资源路径替换说明
在Java_com_jd_apploader_App_onAppAttach函数中,替换了LoadedApk里的mResDir资源路径,这个mResDir是干什么用的呢?从源码分析,mResDir是在LoadedApk的构造函数中被初始化的。
public LoadedApk(ActivityThread activityThread, ApplicationInfo aInfo, CompatibilityInfo compatInfo, ClassLoader baseLoader, boolean securityViolation, boolean includeCode, boolean registerPackage) { ... mResDir = aInfo.uid == myUid ? aInfo.sourceDir : aInfo.publicSourceDir; ...}
从源码上看,mResDir实际上就是Apk文件路径。
mResDir是在何时被使用的呢?看到LoadedApk类的getResources方法使用到mResDir。
public Resources getResources(ActivityThread mainThread) { if (mResources == null) { mResources = mainThread.getTopLevelResources(mResDir, mSplitResDirs, mOverlayDirs, mApplicationInfo.sharedLibraryFiles, Display.DEFAULT_DISPLAY, null, this); } return mResources;}
原来mResDir是用于创建Resources对象,而所有的资源都是通过Resource对象加载的。无论是Application、Activity还是View,加载资源的时候,都是调用Context的getResource方法获取Resource对象。Context类的getResource方法是抽象方法,具体实现是在ContextWrapper类中,Appliation和Activity实际上就是集成ContextWrapper类
public Resources getResources(){ return mBase.getResources();}
看到是通过mBase对象获取的Resource对象,mBase也是Context对象,这个mBase是怎么来的呢?从创建Application和Activity的源码出来看。LoadedApk类中makeApplication方法是创建Application对象的。
public Application makeApplication(boolean forceDefaultAppClass, Instrumentation instrumentation) { ... ContextImpl appContext = ContextImpl. createAppContext(mActivityThread, this); app = mActivityThread.mInstrumentation.newApplication(cl, appClass, appContext); ...}
在newApplication方法中传入了ContextImpl,前面已经分析过,最终这个对象是传到Application对象的attachBaseContext方法中,attachBaseContext方法是在ContextWrapper中实现的。
protected void attachBaseContext(Context base) { if (mBase != null) { throw new IllegalStateException("Base context already set"); } mBase = base;}
由此可见,Application的mBase实际上就是ContextImpl对象。那Activity是否也是如此呢?来看下Activity的创建,Activity对象是在ActivityThread的performLaunchActivity方法中创建的。
private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) { ... Activity activity = null; // 创建Activity实例 java.lang.ClassLoader cl = r.packageInfo.getClassLoader(); activity = mInstrumentation.newActivity( cl, component.getClassName(), r.intent); ... // 创建base Context实例 Context appContext = createBaseContextForActivity(r, activity); ... // attach base Context activity.attach(appContext, this, getInstrumentation(), r.token, r.ident, app, r.intent, r.activityInfo, title, r.parent, r.embeddedID, r.lastNonConfigurationInstances, config, r.referrer, r.voiceInteractor);}
再来看下createBaseContextForActivity方法,看看究竟创建的是什么对象。
private Context createBaseContextForActivity( ActivityClientRecord r, final Activity activity) { ... ContextImpl appContext = ContextImpl.createActivityContext( this, r.packageInfo, displayId, r.overrideConfig); appContext.setOuterContext(activity); Context baseContext = appContext; ... return baseContext;}
从代码中很清楚看到创建的也是ContextImpl对象。而Activity的attach方法最终调到的也是ContextWrap的attachBaseContext方法。因此,mBase实际上就是ContextImpl对象,那么再来看下ContextImpl对象getResources方法。
public Resources getResources() { return mResources;}
getResources方法返回的是mResources成员变量,再来看mResources是什么时候创建的。
private ContextImpl(ContextImpl container, ActivityThread mainThread, UserHandle user, boolean restricted,Display display, Configuration overrideConfiguration, int createDisplayWithId) { ... // 调用LoadedApk的getResource方法获取Resources对象 Resources resources = packageInfo.getResources(mainThread); if (resources != null) { if (displayId != Display.DEFAULT_DISPLAY || overrideConfiguration != null || (compatInfo != null && compatInfo.applicationScale != resources.getCompatibilityInfo().applicationScale)) { resources = mResourcesManager.getTopLevelResources( packageInfo.getResDir(), packageInfo.getSplitResDirs(), packageInfo.getOverlayDirs(), packageInfo.getApplicationInfo(). sharedLibraryFiles, displayId, overrideConfiguration, compatInfo); } } mResources = resources; ...}
mResources是在ContextImpl构造函数中创建的,正是通过LaodedApk对象的getResources方法获取。前面已经分析过LoadedApk的getResources方法就是用mResDir所指的路径创建Resources对象。因此在Application的attachBaseContext方法中替换了mResDir,后续启动的Activity都是从加固的apk中获取资源。
四、存在的问题及解决方案
4.1 加固Apk的Activity theme问题
加固Apk无法在Menifest中为Activity指定Theme,必须在代码中的onCreate方法手动设置。因为若在Menifest中设置Theme,必须在宿主的styles.xml中声明相关的style。此时使用的style id是宿主的id值,而Activity启动的时候查找的是加固Apk中的style id值,这两个是不一样的,因此无法找到对应的style,导致Theme设置失效。
4.2 宿主无法使用资源
由于进程的Resources路径被替换成加固的Apk路径,因此宿主的资源是无法被找到的,导致宿主无法使用资源。
4.3 解决方案
以上两个问题都是资源路径被替换,宿主无法查找到自己的资源引起的。如果不替换资源,而是将两个资源路径合并就可以宿主资源无法被查找到的问题。通过代理Instrumentation类的方法,在Activity启动的时候,将加固的Apk路径添加到资源查找列表中即可。
public class CustomInstrumentation extends Instrumentation { // 加固Apk路径 private static final String APK_PATH = "/data/data/files/dex/loader.apk"; Instrumentation mBase; public CustomInstrumentation(Instrumentation base) { mBase = base; } @Override public Activity newActivity(ClassLoader cl, String className, Intent intent) throws InstantiationException, IllegalAccessException, ClassNotFoundException { return mBase.newActivity(cl, className, intent); } @Override public Activity newActivity(Class<?> clazz, Context context, IBinder token, Application application, Intent intent, ActivityInfo info, CharSequence title, Activity parent, String id, Object lastNonConfigurationInstance) throws InstantiationException, IllegalAccessException { return mBase.newActivity(clazz, context, token, application, intent, info, title, parent, id, lastNonConfigurationInstance); } @Override public void callActivityOnCreate(Activity activity, Bundle icicle) { // Activity被启动的是调用onCreate方法之前,将加固的apk路径添加到Activity的资源路径列表中 addAssetPath(activity); mBase.callActivityOnCreate(activity, icicle); } @TargetApi(Build.VERSION_CODES.LOLLIPOP) @Override public void callActivityOnCreate(Activity activity, Bundle icicle, PersistableBundle persistentState) { addAssetPath(activity); mBase.callActivityOnCreate(activity, icicle, 、 persistentState); } // 用反射的方法,添加资源路径列表 private void addAssetPath(Activity activity) { AssetManager assetManager = activity.getAssets(); try { Method addAssetPath = AssetManager.class. getDeclaredMethod("addAssetPath", String.class); addAssetPath.setAccessible(true); addAssetPath.invoke(assetManager, APK_PATH); } catch (NoSuchMethodException e) { e.printStackTrace(); } catch (InvocationTargetException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } }}
至此加固的Apk路径已经成功添加到资源路径列表中,测试过程中发现资源id冲突的问题,及宿主的资源ID和加固Apk的资源ID相同,导致资源无法找到正确的资源。通过修改aapt源代码,在编译的时候修改宿主资源ID,这样就不会与加固的Apk资源ID冲突。
具体可参考:aapt修改资源ID
以上仅是自己研究记录,可能存在错误,尤其是C代码。
附上项目地址:Apk加固Github
- Apk加固原理解析
- APK 加固原理
- Apk源码的加固(加壳)原理解析和实现
- Android中的Apk的加固(加壳)原理解析和实现
- Android中的Apk的加固(加壳)原理解析和实现
- Android中的Apk的加固(加壳)原理解析和实现(转)
- Android中的Apk的加固(加壳)原理解析和实现(转)
- Android中的Apk的加固(加壳)原理解析和实现
- Android中的Apk的加固(加壳)原理解析和实现(转)
- Android中的Apk的加固(加壳)原理解析和实现
- Android中的Apk的加固(加壳)原理解析和实现
- Android中的Apk的加固(加壳)原理解析和实现
- Android中的Apk的加固(加壳)原理解析和实现
- Android中的Apk的加固(加壳)原理解析和实现
- Android中的Apk的加固(加壳)原理解析和实现
- Android中的Apk的加固(加壳)原理解析和实现
- Android中的Apk的加固(加壳)原理解析和实现
- Android中的Apk的加固(加壳)原理解析和实现
- ssm框架的基本搭建文件
- Unity Shader 学习笔记(7) 高光反射
- .net MVC随笔6
- Apache Kylin高级部分之JDBC访问方式
- GCD之辗转相除法的证明
- Apk加固原理解析
- 网络编程 学习笔记
- hive
- cloudera-manager安装
- 随感
- Android获取通话记录【名称,号码,日期,通话时间,类型】
- AFN请求问题,{ Status Code: 404, Headers { "Content-Language" = (en); "Content-Length" = (1175);
- mybatis 数据连接池(解决连接8小时自动失效问题)
- 442. Find All Duplicates in an Array