从 Android 9(API 级别 28)开始,Android 平台对应用能使用的非 SDK 接口实施了限制。
本文聚焦Android P(9),即第一个引入隐藏API限制的版本,通过分析其源码,揭示系统在调用隐藏API时的拦截过程。
在解析拦截机制的过程中,我们会梳理其关键点,尝试找出可能绕过限制的方法。这些发现将为后续研究如何突破隐藏API的封锁提供依据。
本文是隐藏API绕过系列文章的第一篇,旨在介绍Android系统中对调用隐藏API进行拦截的实现机制。在此基础上,后续文章将逐步介绍多种隐藏API绕过的实现方式,同时结合更高版本的系统源码,分析一些绕过方式失效的原因。
最后,对于不熟悉Android Hook技术的读者,隐藏API绕过则是一个很好的学习案例,后续文章将结合代码实践,介绍Hook相关的技巧和工具。
一、背景
1、介绍隐藏API
从 Android 9(API 级别 28)开始,Android 平台对应用能使用的非 SDK 接口实施了限制。这些接口在源码的注释中使用**@hide**来说明,例如:
java 代码解读复制代码/**
* Returns the object that represents the current runtime.
* @return the runtime object
*
* @hide
*/
@UnsupportedAppUsage
@SystemApi(client = MODULE_LIBRARIES)
@libcore.api.IntraCoreApi
public static VMRuntime getRuntime() {
return THE_ONE;
}
详细介绍可以见官方文档针对非 SDK 接口的限制,总的来说有以下要点:
-
隐藏API的名单。参考非 SDK API 名单,对隐藏API的限制记录在系统名单中,而名单则分为
whitelist
、greylist
、blacklist
等。访问不同级别名单的API,系统的处理方式不同,在blacklist
中的API通常会抛出异常,在greylist
则可能运行访问并在Logcat打印警告。在源码中,我们也可以找到这些名单,例如hiddenapi-force-blacklist.txt:
shell代码解读复制代码Ldalvik/system/VMRuntime;->setHiddenApiExemptions([Ljava/lang/String;)V ...
-
隐藏API的名单会随着Android版本的升级而变更。参考确定接口属于哪个名单。
-
可以通过adb更改 API强制执行策略。参考如何允许访问非 SDK 接口,通过:
shell代码解读复制代码adb shell settings put global hidden_api_policy 1
可以临时修改系统的强制执行策略,从而允许APP对隐藏API的访问。
2、调用时的表现
当应用尝试调用隐藏API,由于所在限制名单的不同而表现不同,具体参考访问受限的非 SDK 接口时可能会出现的预期行为,但总的来说:
- 反射获取指定方法/属性,会抛出
NoSuchMethodException
/NoSuchFieldException
异常。 - 反射获取方法/数量列表,结果中不会获取到非 SDK 成员。
- 使用JNI获取MethodID/FieldID,返回
NULL
,并抛出NoSuchMethodError
。
您可以使用 adb logcat
来查看这些日志消息,这些消息显示在所运行应用的 PID 下。举例而言,日志中可能包含如下条目:
shell代码解读复制代码Accessing hidden field Landroid/os/Message;->flags:I (light greylist, JNI)
这里透露的重点是,隐藏API机制限制的是通过反射等手段获取目标Method对象的这个过程,而一旦我们获取到目标Method对象后,对它的调用则不经过检查。
3、运行时豁免名单
java 代码解读复制代码package dalvik.system;
public final class VMRuntime {
...
/**
* Sets the list of exemptions from hidden API access enforcement.
*
* @param signaturePrefixes
* A list of signature prefixes. Each item in the list is a prefix match on the type
* signature of a blacklisted API. All matching APIs are treated as if they were on
* the whitelist: access permitted, and no logging..
*
* @hide
*/
public native void setHiddenApiExemptions(String[] signaturePrefixes);
...
}
在进行具体源码分析前,需要介绍一个重要方法,即VMRuntime.setHiddenApiExemptions()
。
根据注释,这个方法可以在运行时给虚拟机设置隐藏API的豁免名单,在此名单中的方法可以不受限制的调用隐藏API,并且这个名单是根据方法签名进行前缀匹配来检验。
集合源码hiddenapi-force-blacklist.txt,所谓的方法签名规则和JNI方法签名类似,即:
shell 代码解读复制代码Ldalvik/system/VMRuntime;->setHiddenApiExemptions([Ljava/lang/String;)V
Ljava/lang/invoke/MethodHandles$Lookup;->IMPL_LOOKUP:Ljava/lang/invoke/MethodHandles$Lookup;
...
因此,如果我们把**L
**作为前缀添加到豁免名单中,就相当于给所有方法都添加了豁免。
遗憾的是,setHiddenApiExemptions()
本身就是隐藏API,这是一个鸡生蛋蛋生鸡的问题。
但是我们最终的目标就可以转换为绕过隐藏API机制,进而调用setHiddenApiExemptions()
方法。
二、Android P源码分析
如图所示,隐藏API的拦截机制主要有四个检查点:
-
检查目标方法的accessflag。accessflag低29,30位记录着ApiList类型,分别为白名单,灰名单等,如果是白名单,则返回允许。方法的accessflag在系统dex文件编译时就已经指定。
-
检查系统的隐藏API拦截策略。这个策略为
kNoChecks
则说明不拦截,这对应前文使用adb指令修改的hidden_api_policy
标志。 -
检查调用栈是否可信。这里指的可信,即指首个调用反射方法(getMethod)的类/方法是否可信,例如在Android P中,如果调用者是系统类,那么不需要拦截,否则连系统自身也调用不了隐藏API了。
在Android不同版本中,对"可信"这个判断条件不同,是造成部分针对旧版本的绕过机制失效的重要原因。
-
检查豁免名单。即检查目标方法是否在通过
VMRuntime.setHiddenApiExemptions()
设置的豁免名单内,是则不拦截。
接下来将参考Android P源码,以反射/JNI获取Method对象的过程为例,详细介绍隐藏API的拦截机制。
1、反射/JNI调用入口
1.1、反射调用
java 代码解读复制代码public Method getDeclaredMethod(String name, Class>... parameterTypes)
throws NoSuchMethodException, SecurityException {
// Android-changed: ART has a different JNI layer.
return getMethod(name, parameterTypes, false);
}
// BEGIN Android-added: Internal methods to implement getMethod(...).
private Method getMethod(String name, Class>[] parameterTypes, boolean recursivePublicMethods)
throws NoSuchMethodException {
...
//1、实际调用getDeclaredMethodInternal()这个jni方法
Method result = recursivePublicMethods ? getPublicMethodRecursive(name, parameterTypes)
: getDeclaredMethodInternal(name, parameterTypes);
..
return result;
}
@FastNative
private native Method getDeclaredMethodInternal(String name, Class>[] args);
- JAVA方法最终调用会调用
Class_getDeclaredMethodInternal()
方法。
c++ 代码解读复制代码static jobject Class_getDeclaredMethodInternal(JNIEnv* env, jobject javaThis,
jstring name, jobjectArray args) {
ScopedFastNativeObjectAccess soa(env);
...
//1、传入方法名和参数类型,获取目标方法对象
Handle result = hs.NewHandle(
mirror::Class::GetDeclaredMethodInternalfalse >(
soa.Self(),
DecodeClass(soa, javaThis),
soa.Decode(name),
soa.Decode>(args)));
//2、进入隐藏拦截进制判断,这里传入的方法对应的ArtMethod指针和当前线程
if (result == nullptr || ShouldBlockAccessToMember(result->GetArtMethod(), soa.Self())) {
return nullptr;
}
//3、将前面得到Method对象返回
return soa.AddLocalReference(result.Get());
}
- 首先传入方法名和参数类型,获取目标方法,即Method对象。可以看到,这里是先找到Method对象,再检查它是否应该被拦截的。
- 调用
ShouldBlockAccessToMember
检查是否应该拦截。
c++ 代码解读复制代码// Returns true if the first non-ClassClass caller up the stack should not be
// allowed access to `member`.
template<typename T>
ALWAYS_INLINE static bool ShouldBlockAccessToMember(T* member, Thread* self)
REQUIRES_SHARED(Locks::mutator_lock_) {
//1、传入参数为:访问的成员(属性、方法)、当前线程、IsCallerTrusted回调、方法类型,返回值为检查结果
hiddenapi::Action action = hiddenapi::GetMemberAction(
member, self, IsCallerTrusted, hiddenapi::kReflection);
if (action != hiddenapi::kAllow) {
hiddenapi::NotifyHiddenApiListener(member);
}
//2、判断检查结果是否为拒绝
return action == hiddenapi::kDeny;
}
enum Action {
kAllow, //允许访问
kAllowButWarn, //允许但有warn日志
kAllowButWarnAndToast, //允许但有warn日志、Toast提示
kDeny //不允许
};
hiddenapi::GetMemberAction()
方法进行调用拦截判断,其中参数hiddenapi::kReflection
表示是由反射调用的。hiddenapi::GetMemberAction()
方法值为action,从代码可以看出,只要返回Action.kDeny
才表示不允许访问。
1.2、JNI调用
c++ 代码解读复制代码enum AccessMethod {
kNone, // 测试模式,不会出现在实际场景访问权限
kReflection, //反射
kJNI, //JNI调用
kLinking, //动态链接过程
};
static jmethodID GetMethodID(JNIEnv* env, jclass java_class, const char* name, const char* sig) {
...
//1、调用FindMethodID
return ShouldBlockAccessToMember(soa, java_class, name, sig, false);
}
static jmethodID FindMethodID(ScopedObjectAccess& soa, jclass jni_class,
const char* name, const char* sig, bool is_static)
..
ArtMethod* method = nullptr;
auto pointer_size = Runtime::Current()->GetClassLinker()->GetImagePointerSize();
//2、根据名称和参数类型,找到目标ArtMethod指针
if (c->IsInterface()) {
method = c->FindInterfaceMethod(name, sig, pointer_size);
} else {
method = c->FindClassMethod(name, sig, pointer_size);
}
//3、检查是否应该被拦截
if (method != nullptr && ShouldBlockAccessToMember(method, soa.Self())) {
method = nullptr;
}
...
//4、将ArtMethod转成jmethodID
return jni::EncodeArtMethod(method);
}
template<typename T>
ALWAYS_INLINE static bool ShouldBlockAccessToMember(T* member, Thread* self)
REQUIRES_SHARED(Locks::mutator_lock_) {
//5、通用调用GetMemberAction方法,
hiddenapi::Action action = hiddenapi::GetMemberAction(
member, self, IsCallerTrusted, hiddenapi::kJNI);
if (action != hiddenapi::kAllow) {
hiddenapi::NotifyHiddenApiListener(member);
}
return action == hiddenapi::kDeny;
}
JNI调用和反射调用的区别:
- 根据名称和参数类型,找到目标ArtMethod指针,最终返回
jmethodID
。而反射则返回Method对象。 - 调用同名的
ShouldBlockAccessToMember()
方法,都是传入ArtMethod指针。 - 最终调用
hiddenapi::GetMemberAction()
方法,区别是IsCallerTrusted
和hiddenapi::kJNI
两个参数。
💡无论是反射还是JNI,都是使用ArtMethod指针表示目标方法,如果我们把某个公开方法的ArtMethod指针替换成隐藏方法的ArtMethod指针,不就可以直接使用了吗?
1.3、核心方法GetMemberAction
c++ 代码解读复制代码template<typename T>
inline Action GetMemberAction(T* member,
Thread* self,
std::function<bool(Thread*)> fn_caller_is_trusted,
AccessMethod access_method)
...
//1、获取成员(属性、方法)的accessFlags
HiddenApiAccessFlags::ApiList api_list = member->GetHiddenApiAccessFlags();
//2、将accessFlags转成对应的Action
Action action = GetActionFromAccessFlags(member->GetHiddenApiAccessFlags());
if (action == kAllow) {
// Nothing to do.
return action;
}
//3、继续检查调用栈是否可信
if (fn_caller_is_trusted(self)) {
// Caller is trusted. Exit.
return kAllow;
}
// Member is hidden and caller is not in the platform.
//4、最后,检查是否在豁免名单内。
return detail::GetMemberActionImpl(member, api_list, action, access_method);
}
无论是反射还是JNI,最终都会调用到GetMemberAction()
这个核心方法,注释中说明了隐藏API机制拦截的四个过程,前面已经介绍过了,不再赘述。
💡如果我们可以使用Hook将
GetMemberAction()
替换掉,使得它总是返回kAllow
,就可以绕过隐藏API机制。
接下来看看每个过程的细节。
2、目标方法AccessFlags
2.1、AccessFlags记录方法所属的隐藏名单
c++ 代码解读复制代码class ArtField FINAL {
HiddenApiAccessFlags::ApiList GetHiddenApiAccessFlags() REQUIRES_SHARED(Locks::mutator_lock_) {
return HiddenApiAccessFlags::DecodeFromRuntime(GetAccessFlags());
}
...
}
//0b110000000000000000000000000000
static constexpr uint32_t kAccHiddenApiBits = 0x30000000; // field, method
c++ 代码解读复制代码class HiddenApiAccessFlags {
//1、统计后缀0的个数,因此这里是28
static const int kAccFlagsShift = CTZ(kAccHiddenApiBits);
static ALWAYS_INLINE ApiList DecodeFromRuntime(uint32_t runtime_access_flags) {
// This is used in the fast path, only DCHECK here.
DCHECK_EQ(runtime_access_flags & kAccIntrinsic, 0u);
//2、只获取access_flags的高29、30位,右移转成ApiList
uint32_t int_value = (runtime_access_flags & kAccHiddenApiBits) >> kAccFlagsShift;
return static_cast(int_value);
}
..
}
c++ 代码解读复制代码class ArtMethod FINAL {
// Note: GetAccessFlags acquires the mutator lock in debug mode to check that it is not called for
// a proxy method.
template
uint32_t GetAccessFlags() {
if (kCheckDeclaringClassState) {
GetAccessFlagsDCheck();
}
return access_flags_.load(std::memory_order_relaxed);
}
protected:
// Field order required by test "ValidateFieldOrderOfJavaCppUnionClasses".
// The class we are a part of.
GcRoot declaring_class_;
// Access flags; low 16 bits are defined by spec.
// Getting and setting this flag needs to be atomic when concurrency is
// possible, e.g. after this method's class is linked. Such as when setting
// verifier flags and single-implementation flag.
std::atomicuint32_t > access_flags_;
}
- ArtMethod的成员属性
access_flags_
中,然后读取access_flags_
的高两位(29, 30),转成ApiList。access_flags_
是在类编译过程中写入到dex文件的,我们常见的Public、Private等属性也记录在这个值中。 - ApiList就是隐藏名单的类型,前面介绍背景时中提到过,在hidden_api_access_flags.h中定义。
c++ 代码解读复制代码class HiddenApiAccessFlags {
public:
enum ApiList {
kWhitelist = 0, //白名单, 0x00
kLightGreylist, //浅灰名单, 0x01
kDarkGreylist, //深灰名单, 0x10
kBlacklist, //黑名单, 0x11
};
...
}
💡如果我们可以修改ArtMethod的access_flags_,使得高两位为0,就等价于把目标方法放入到白名单中。
2.2、检查系统隐藏API策略
c++ 代码解读复制代码enum class EnforcementPolicy {
kNoChecks = 0,
kJustWarn = 1, // 保持检查,一切都允许(仅仅记录日志)
kDarkGreyAndBlackList = 2, // 禁止深灰色和黑名单
kBlacklistOnly = 3, // 只禁止黑名单
kMax = kBlacklistOnly,
};
inline Action GetActionFromAccessFlags(HiddenApiAccessFlags::ApiList api_list) {
//1、如果是白名单内,直接允许调用
if (api_list == HiddenApiAccessFlags::kWhitelist) {
return kAllow;
}
//2、获取隐藏名单处理策略,如果是不检查,那么全部返回允许
EnforcementPolicy policy = Runtime::Current()->GetHiddenApiEnforcementPolicy();
if (policy == EnforcementPolicy::kNoChecks) {
// Exit early. Nothing to enforce.
return kAllow;
}
//3、其他情况都需要继续检查,不具体看
...
}
c++ 代码解读复制代码class Runtime {
...
// Whether access checks on hidden API should be performed.
hiddenapi::EnforcementPolicy hidden_api_policy_;
hiddenapi::EnforcementPolicy GetHiddenApiEnforcementPolicy() const {
return hidden_api_policy_;
}
void SetHiddenApiEnforcementPolicy(hiddenapi::EnforcementPolicy policy) {
hidden_api_policy_ = policy;
}
...
}
- 如果
api_list
是kWhitelist
,那么不拦截,后续也不需要检查了。 - 如果隐藏名单处理策略
hidden_api_policy_
为kNoChecks
,那么不拦截,后续也不需要检查了。 hidden_api_policy_
是Runtime对象的一个成员变量。
💡如果我们可以修改
Runtime.hidden_api_policy_
为kNoChecks
,不就可以关闭检查策略了。
3、调用栈是否可信
3.1、查找反射调用者
c++ 代码解读复制代码//1、如果外部第一次调用Class.class或反射方法的地方,是来源于platform DEX file,那么认为是系统调用的
static bool IsCallerTrusted(Thread* self) REQUIRES_SHARED(Locks::mutator_lock_) {
//2、回溯JAVA堆栈,找到第一个不是来自java.lang.Class和java.lang.invoke的栈
struct FirstExternalCallerVisitor : public StackVisitor {
explicit FirstExternalCallerVisitor(Thread* thread)
: StackVisitor(thread, nullptr, StackVisitor::StackWalkKind::kIncludeInlinedFrames),
caller(nullptr) {
}
//3、访问当前栈帧,返回true说明要继续向上查找,caller指针用于记录查找结果
bool VisitFrame() REQUIRES_SHARED(Locks::mutator_lock_) {
ArtMethod *m = GetMethod();
if (m == nullptr) {
//4、native线程调用,那么判断为非系统的调用,不继续查找
caller = nullptr;
return false;
} else if (m->IsRuntimeMethod()) {
// 5、判断是否虚拟机内部方法,是则继续查找
return true;
}
ObjPtr declaring_class = m->GetDeclaringClass();
//6、如果是classloader是BootStrapClassLoad,也就是classLoader为空
if (declaring_class->IsBootStrapClassLoaded()) {
//6.1、如果是Class类,那么向上再找
if (declaring_class->IsClassClass()) {
return true;
}
//6.2、检查 java.lang.invoke 包中的类。在撰写本文时,感兴趣的类是 MethodHandles 和 MethodHandles.Lookup,但这有可能发生变化,因此保守地覆盖整个包。注意 java.lang.invoke 中的静态初始化器是允许的,不需要进一步的堆栈检查。
//也就是说,如果包名为java.lang.invoke,那么继续向上查找
ObjPtr lookup_class = mirror::MethodHandlesLookup::StaticClass();
if ((declaring_class == lookup_class || declaring_class->IsInSamePackage(lookup_class))
//并且不是构造方法或静态方法
&& !m->IsClassInitializer()) {
return true;
}
}
//7、如果classloader不是BootStrapClassLoad,那么此时caller就为第一个调用反射的类
//如果classloader是BootStrapClassLoad,但又不是Class或者在java.lang.invoke包内,那么也找到了
caller = m;
return false;
}
ArtMethod* caller;
};
FirstExternalCallerVisitor visitor(self);
//根据调用栈向上查找反射入口
visitor.WalkStack();
//8、如果找到调用反射的方法,那么进一步检查它是否可信,找不到说明来自native,直接不可信
return visitor.caller != nullptr &&
hiddenapi::IsCallerTrusted(visitor.caller->GetDeclaringClass());
}
这一步骤大家可以结合代码和注释查看,实际并不复杂。目标是找到第一个调用反射相关方法的类,通常也就是我们应用代码中的类。
这里是以反射调用过程中传入ShouldBlockAccessToMember()
中的IsCallerTrusted()
为例的,JNI调用过程也会传入同名的IsCallerTrusted()
方法,但是实现稍有不同,但目标相同。
大家看自行对比两者的实现。
代码中有一个重点是如果碰到Class.java
或者java.lang.invoke
包名下的类会继续网上查找,目的是拦截MethodHandles机制的调用。
在常见的应用代码中,例如:
java 代码解读复制代码public class Test{
public void test(){
Class clazz = Class.forName("xxx");
clazz.getDeclaredMethod(...);
}
}
按照向上查找的逻辑,首次调用的方法即为Test.test()
。
💡如果首个调用
getDeclaredMethod()
的方法是某个系统包下的方法,不就可以正常调用了。
3.2、系统类的判断
c++ 代码解读复制代码inline bool IsCallerTrusted(ObjPtr caller) REQUIRES_SHARED(Locks::mutator_lock_) {
//1、传入参数判断
return !caller.IsNull() &&
detail::IsCallerTrusted(caller, caller->GetClassLoader(), caller->GetDexCache());
}
ALWAYS_INLINE
inline bool IsCallerTrusted(ObjPtr caller,
ObjPtr caller_class_loader,
ObjPtr caller_dex_cache)
REQUIRES_SHARED(Locks::mutator_lock_) {
//1、如果classloader为null,则认为是boot class loader,因此是来自系统的调用
if (caller_class_loader.IsNull()) {
return true;
}
if (!caller_dex_cache.IsNull()) {
const DexFile* caller_dex_file = caller_dex_cache->GetDexFile();
//2、根据dex文件判断是platform,这个标志在dex文件加载时会设置,主要是通过dex文件路径是否在/framework路径下判断的
if (caller_dex_file != nullptr && caller_dex_file->IsPlatformDexFile()) {
// Caller is in a platform dex file.
return true;
}
}
...
return false;
}
找到caller后,需要进一步判断caller是否可信:
- classloader为null。如果classloader为null则被认为是系统类,因此放过拦截。
- 类所在的dex文件是否位于
/system/framework/
目录。IsPlatformDexFile()
方法返回变量is_platform_dex_
,根据dex_file.h代码中的注释,表示Dex文件是否位于/system/framework/
目录。
💡dex文件所在的目录无法改变,但是如果我们把某个类的classloader设置为空,不就会被认为是系统类了吗。
4、豁免名单
4.1、GetMemberActionImpl
c++ 代码解读复制代码template<typename T>
Action GetMemberActionImpl(T* member,
HiddenApiAccessFlags::ApiList api_list,
Action action,
AccessMethod access_method) {
...
// Get the signature, we need it later.
//1、获取方法签名
MemberSignature member_signature(member);
Runtime* runtime = Runtime::Current();
const bool shouldWarn = kLogAllAccesses || runtime->IsJavaDebuggable();
if (shouldWarn || action == kDeny) {
//2、判断方法是否在豁免名单内
if (member_signature.IsExempted(runtime->GetHiddenApiExemptions())) {
action = kAllow;
//2.1、为了避免下次再检查豁免名单,MaybeWhitelistMember()可以把member的access_flag修改为白名单。
MaybeWhitelistMember(runtime, member);
return kAllow;
}
...
}
...
return action;
}
template<typename T>
static ALWAYS_INLINE void MaybeWhitelistMember(Runtime* runtime, T* member)
REQUIRES_SHARED(Locks::mutator_lock_) {
//3、CanUpdateMemberAccessFlags()默认为true, ShouldDedupeHiddenApiWarnings()默认为true,
if (CanUpdateMemberAccessFlags(member) && runtime->ShouldDedupeHiddenApiWarnings()) {
//4、修改access_flags
member->SetAccessFlags(HiddenApiAccessFlags::EncodeForRuntime(
member->GetAccessFlags(), HiddenApiAccessFlags::kWhitelist));
}
}
static ALWAYS_INLINE bool CanUpdateMemberAccessFlags(ArtMethod* method) {
return !method->IsIntrinsic();
}
最后检查方法是否在豁免名单中,正如前文所述,豁免名单可以通过VMRuntime.setHiddenApiExemptions()
方法设置。
并且当某个方法通过了豁免名单的检查,就会修改它accessflag
的高两位为HiddenApiAccessFlags::kWhitelist
,从而避免下次调用还需要经过那么复杂的检查。
💡能否直接调用VMRuntime.setHiddenApiExemptions()方法?
三、总结
经过对于Android P源码的仔细分析,我们明白了隐藏API拦截机制的关键步骤,并且发现最终要实现拦截,实际上有很多前提条件,包括方法的accessflags、系统当前的拦截机制开关、classloader不为null等等。
随着Android系统版本的迭代,官方不断优化代码从而使得前提条件更加严格,但是无论如何,我们Hook的思路仍然是阅读源码,然后见招拆招。
这些前提条件就是绕过拦截的突破口,甚至可以说这样的突破口显得有点多,这使得隐藏API绕过是一个学习常见Hook技巧和工具的一个很好的实践例子。
对这些工具不熟悉的朋友可以关注下一篇文章,我们将会在那里了解详细的实践过程。
四、写在最后
1、源码下载
2、免责声明
本文涉及的代码,旨在展示和描述方案的可行性,可能存bug或者性能问题。
不建议未经修改验证,直接使用于生产环境。
3、转载声明
本文欢迎转载,转载请注明出处。
4、留言讨论
你是否也在现实开发中遇到类似的场景或者应用,是否有更多的意见和想法,欢迎留言一起学习讨论。
5、欢迎关注
如果你对更多的Android Hook开发技巧、思路感兴趣的,欢迎关注我的栏目。
后续将提供更多优质内容,硬核干货。
评论记录:
回复评论: