一次 Android 无障碍风险对抗的实战复盘
下面分析来自一次授权测试环境中的真实样本分析。支付客户端、恶意样本、服务器地址、签名和目标包名均做了脱敏。保留检测与取证所需的关键 Smali 证据,不提供可直接用于控制支付应用或规避风控的完整实现。
开始:最初的判断其实错了一半
这次分析起因很直接:测试机上安装了某可疑 APK,开启它的无障碍服务以后,它可以读取和操作某支付客户端;但我们自己写的最小 Demo,使用相似的系统风格包名,并设置:![]()
android:isAccessibilityTool="true"
进入同一支付流程时,却出现了“检测到可疑活动、支付被阻止”的安全页。
如果只看表面,很容易得出两个结论:
- 木马用了系统风格包名,所以进入了白名单;
isAccessibilityTool=true 能绕过无障碍检测。
但对照实验已经否定了这个解释。相同的两个条件放到测试 Demo 上并不能通过检查。
后面把支付客户端 v332.1.1 的 Smali、Flutter AOT 字符串、运行时阻断现象,与恶意样本解密后的真实 DEX 放到一起看,才发现这里至少存在三套完全不同的机制:
- 本地 SDK 对 spoken-feedback 无障碍服务的检查;
- 敏感输入界面对无障碍节点的主动降暴露;
- Pay Flow 中的 Play Integrity App Access Risk 与服务端 Risk Engine。
而木马端做的事情,则包括:
isAccessibilityTool=true 和系统辅助功能风格的身份伪装;
- AB 包投递与双层动态 DEX;
- 目标应用前台识别;
- 运行时重建
AccessibilityServiceInfo;
- 进入目标应用时动态清除一个节点可见性 flag;
- 针对支付页面资源 ID 的专用控制逻辑。
这篇记录重点讲清楚两件事:支付客户端究竟怎么发现高风险无障碍应用,以及目前能不能证明木马真正“绕过”了这套检测。
一、先把检测端拆开:不是所有 Accessibility 调用都是风控
支付客户端使用 Flutter release AOT,Android 宿主经过 R8 混淆,另外还带有 native 安全组件。最初搜索 AccessibilityManager、AccessibilityServiceInfo、TalkBack、SwitchAccess 时会出现很多命中,但绝大部分属于 Flutter、AndroidX 和 Material 的正常无障碍适配。
真正有业务意义的代码需要结合调用场景判断。
1. GNP 促销展示前的本地检查
第一个明确的检测入口位于:
smali/hjz.smali
关键指令:
# hjz.smali:215
invoke-virtual {v5}, \
Landroid/view/accessibility/AccessibilityManager;->isEnabled()Z
# hjz.smali:227
invoke-virtual {v5, v2}, \
Landroid/view/accessibility/AccessibilityManager;->getEnabledAccessibilityServiceList(I)Ljava/util/List;
# hjz.smali:260
const-string v2, "Accessibility is enabled, not displaying."
结合寄存器值可以还原为:
AccessibilityManager manager =
context.getSystemService(AccessibilityManager.class);
if (manager != null
&& manager.isEnabled()
&& !manager.getEnabledAccessibilityServiceList(
AccessibilityServiceInfo.FEEDBACK_SPOKEN).isEmpty()) {
// 不显示当前 promotion
recordNotShown(PROMO_NOT_SHOWN_ACCESSIBILITY_ENABLED);
return;
}
事件枚举位于:
# oab.smali:275
const-string v15, "PROMO_NOT_SHOWN_ACCESSIBILITY_ENABLED"
这段代码确实检查了无障碍,但它不是支付风控入口。它只在 GNP/Growth Kit 准备展示 promotion 时运行,命中以后不展示促销 UI,并记录一个 not-shown reason。
还有一个容易误判的细节:
getEnabledAccessibilityServiceList(1)
参数 1 是 FEEDBACK_SPOKEN,并不是“列举所有无障碍服务”。TalkBack 一类读屏服务容易命中;只声明 generic feedback 的自动化服务未必进入这个列表。
所以,如果只找到 hjz.smali 就宣称“支付客户端通过本地 API 阻止所有无障碍转账”,结论是不成立的。
2. Switch Access 适配也不是黑名单
另一个类会调用:
getEnabledAccessibilityServiceList(0x10)
然后判断 getSettingsActivityName() 是否包含 SwitchAccess。继续追调用者可以看到,它最终影响 Material 下拉框的 showDropDown() 和 dismissDropDown() 行为。
这是兼容性适配,不是恶意工具检测。它没有进入风险 Proto,也没有触发交易阻断。
做 Android 逆向时,最容易犯的错误之一,就是看到安全敏感 API 便直接按“反作弊”分类。调用场景比 API 名字更重要。
二、支付输入页首先做的是“少暴露”,不是“识别谁在读”
支付 PIN 和银行卡页面没有依赖普通 EditText 的默认无障碍语义,而是在布局层主动降低敏感节点暴露。
PIN 容器:
<!-- fragment_pin_input.xml:2 -->
<RelativeLayout
android:importantForAccessibility="noHideDescendants"
... />
PIN 输入控件:
<!-- layout_pi_data.xml:6,属性已节选 -->
<FormItemEditText
android:id="@id/et_pi_input"
android:cursorVisible="false"
android:textIsSelectable="false"
android:importantForAccessibility="no"
... />
银行卡和身份字段也采用相同思路:
<EditText
android:id="@id/debit_card_input"
android:importantForAccessibility="no" />
<EditText
android:id="@id/expiry_input"
android:importantForAccessibility="no" />
<EditText
android:id="@id/identity_input"
android:importantForAccessibility="no"
android:importantForAutofill="no" />
凭据 Activity 还设置了:
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE);
这层防护解决的是数据暴露面:
- 不让敏感 View 出现在普通无障碍节点树中;
- 不让 Autofill 直接取得字段;
- 限制系统截图和非安全显示捕获;
- 使用自定义键盘和自定义输入组件。
但它不能彻底阻止无障碍控制。攻击者仍然可能使用坐标手势,或者根据页面布局操作自绘控件。也正因为这样,支付客户端还需要交易阶段的环境风险判断。
三、真正的交易级阻断:Play Integrity App Access Risk
1. 为什么 Java 层找不到服务包名黑名单
最初静态分析有一个疑问:支付被阻断了,为什么整个 Java/Smali 层却找不到:
enabled_accessibility_services
getInstalledAccessibilityServiceList()
ServiceInfo.packageName 黑名单
后续定位到 Play Integrity 插件以后,这个疑问就解释通了。支付客户端不需要自己枚举每个无障碍服务,它可以请求 Play Integrity Token,让平台侧生成 App Access Risk verdict,再由服务端决定是否继续交易。
Flutter Pigeon Channel 中存在:
dev.flutter.pigeon.PlayIntegrityApi.prepareIntegrityToken
dev.flutter.pigeon.PlayIntegrityApi.requestIntegrityToken
Android 插件实现在混淆类 nko 中。请求构造的核心片段:
# nko.smali:450
sget-object v0, Llrf;->a:Llrf;
# nko.smali:458-462
new-instance v6, Lkxf;
invoke-direct {v6, p1, v0}, \
Lkxf;-><init>(Ljava/lang/String;Ljava/util/Set;)V
这里 p1 是 requestHash,Llrf.a 是 verdictOptOut Set。继续检查集合实现可以确认它是空集合,也就是没有在该请求中 opt out App Access Risk。
如果集合为空对象异常,代码还会直接抛出:
# nko.smali:551
const-string p1, "Null verdictOptOut"
2. App Access Risk 返回什么
官方文档把风险分为:
KNOWN_CAPTURING / UNKNOWN_CAPTURING
KNOWN_CONTROLLING / UNKNOWN_CONTROLLING
KNOWN_OVERLAYS / UNKNOWN_OVERLAYS
其中 CONTROLLING 表示存在正在运行、权限已启用、能够控制设备输入或捕获输入输出的应用,典型来源之一就是被滥用的 AccessibilityService。
KNOWN_ 与 UNKNOWN_ 主要按安装来源区分:来自 Play 或系统分区的应用通常是 KNOWN_,其他来源通常是 UNKNOWN_。
官方说明还强调:通过增强无障碍审核的 verified accessibility service 会自动从 capturing、controlling 和 overlays 风险结果中排除。相关定义见 Play Integrity verdicts。
这里有一个非常重要的限制:
isAccessibilityTool=true 只是申请增强审核的必要条件之一,不等于应用已经通过审核,也不等于会自动被 App Access Risk 排除。
官方流程是发布应用并完成增强审核,或者通过指定渠道申请审核。测试 Demo 仅仅设置 Manifest 属性,自然不会因此变成 verified accessibility service。
3. 服务端为什么能拦截,客户端却没有 verdict 字符串
Integrity Token 是客户端获取后提交给服务端的。服务端解码并验证:
requestDetails
appIntegrity
deviceIntegrity
accountDetails
environmentDetails.appAccessRiskVerdict
客户端不必看到明文 UNKNOWN_CONTROLLING。这也解释了为什么 APK 中找不到这些字符串,却能在运行时看到交易阻断。
支付客户端 AOT 中同时存在:
integrity_challenge_handler.dart
risk_client_metadata_provider.dart
risk_client_metadata_challenge_handler.dart
risk_engine_response.pb.dart
RISK_DECLINE
RISK_PERMANENT_SUSPEND
failedDueToRiskEngineDenial
RISK_CLIENT_METADATA_CHALLENGE
完整链路更接近:
sequenceDiagram
participant App as 支付客户端
participant Play as Play Integrity
participant Server as 支付风控服务
Server->>App: Pay Flow Integrity/Risk challenge
App->>Play: requestIntegrityToken(requestHash)
Play-->>App: 加密 Integrity Token
App->>Server: Token + Risk Client Metadata
Server->>Server: 校验 requestHash 与环境 verdict
Server->>Server: 联合评估 controlling/capturing/overlay 等信号
Server-->>App: Allow 或 RISK_DECLINE
App->>App: 渲染安全修复/阻断页面
官方也建议在敏感操作附近请求 verdict,而不是应用启动时只检查一次。Play Integrity Overview
4. 它不是只看无障碍
风险插件还暴露了通话状态:
# nkp.smali:233
const-string p1, "phoneCallState"
# nkp.smali:325
const-string p0, "notInPhoneCallState"
# nkp.smali:339
const-string p0, "inPhoneCallState"
屏幕相关插件会通知 Flutter:
# nlt.smali:297
const-string v3, \
"dev.flutter.pigeon.SecurePageFlutterApi.secureDisplayCountChanged"
结合 AOT 中的 ScreenSharingDetector、SIM 状态、设备 ID 和 Risk Metadata 模块,可以判断交易风控是多信号联合决策,而不是一个 if (accessibilityEnabled) deny()。
运行时阻断页同时要求结束通话、关闭未知辅助应用、移除未知应用,与这条架构链是吻合的。具体页面文案没有出现在 APK 明文中,疑似由服务端或 Server-Driven UI 下发。
四、再看样本端:它做了哪些针对性动作
恶意样本采用 A/B 包结构。A 包负责投递和安装;B 包注册真正的 AccessibilityService,并在第二层加密 DEX 中实现远控。
这里只关注和无障碍风险相关的代码。
1. Manifest 身份伪装
B 包使用类似系统辅助功能组件的命名空间,并配置:
<accessibility-service
android:accessibilityEventTypes="..."
android:accessibilityFeedbackType="feedbackSpoken|feedbackHaptic|feedbackAudible|feedbackVisual|feedbackGeneric"
android:accessibilityFlags="flagDefault|flagRequestEnhancedWebAccessibility|flagReportViewIds|flagRequestFilterKeyEvents|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:canPerformGestures="true"
android:canTakeScreenshot="true"
android:isAccessibilityTool="true"
android:accessibilityDataSensitive="no" />
这会产生三种效果:
- 普通用户更容易把它当作正常辅助服务;
- 简单包名/描述规则更容易误判;
isAccessibilityTool=true 满足申请增强无障碍审核的一个前提。
但不能据此认定它已经通过增强审核。样本签名不是对应系统组件的官方签名,A/B 包也不是同一证书。根据当前证据,包名伪装无法自动获得 verified accessibility service 身份。
2. 服务连接时重建 ServiceInfo
真实 DEX 在 onServiceConnected() 中重新创建配置:
new-instance v1, Landroid/accessibilityservice/AccessibilityServiceInfo;
invoke-direct {v1}, Landroid/accessibilityservice/AccessibilityServiceInfo;-><init>()V
const/16 v2, 0x7d
iput v2, v1, Landroid/accessibilityservice/AccessibilityServiceInfo;->flags:I
const v2, 0x1ffffff
iput v2, v1, Landroid/accessibilityservice/AccessibilityServiceInfo;->eventTypes:I
const/4 v4, 0x0
iput-object v4, v1, Landroid/accessibilityservice/AccessibilityServiceInfo;->packageNames:[Ljava/lang/String;
invoke-virtual {v0, v1}, \
Landroid/accessibilityservice/AccessibilityService;->setServiceInfo(Landroid/accessibilityservice/AccessibilityServiceInfo;)V
packageNames=null 表示监听全部包。flags=0x7d 包含触摸探索、增强 Web、View ID、按键过滤和交互窗口等能力,但不包含:
AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS // 0x2
3. 如何判断目标支付应用在前台
样本维护了一个目标包名集合。无障碍事件到达后,它从当前事件中提取来源包名,并调用:
invoke-virtual {v8, v4}, \
L...ControlAccessibilityService$Companion;->onForegroundPkgForNotImportantViews(Ljava/lang/String;)V
在 Companion 中:
invoke-static {}, L...ControlAccessibilityService;->access$getTARGET_PACKAGES$cp()Ljava/util/Set;
invoke-interface {v0, p1}, Ljava/util/Set;->contains(Ljava/lang/Object;)Z
invoke-static {p1}, L...ControlAccessibilityService;->access$setTargetForeground$cp(Z)V
状态发生变化时,代码生成两个原因标签:
const-string p1, "target_enter"
const-string p1, "target_leave"
原样本中的字符串包含真实目标名称,本文做了替换。
所以它判断前台应用并不依赖 UsageStats,也没有看到注入支付进程。主要信号就是 AccessibilityEvent.getPackageName(),外加锁屏、覆盖层和内部自动化状态的条件保护。
4. 目标应用前台时清除 0x2
状态解析函数优先检查 forced 配置。如果没有 forced 值,发现目标应用在前台就直接返回 false:
invoke-static {}, L...ControlAccessibilityService;->access$getTargetForeground$cp()Z
move-result p0
if-eqz p0, :cond_continue
const/4 p0, 0x0
return p0
随后调用内部方法修改当前 ServiceInfo:
invoke-virtual {p0}, \
Landroid/accessibilityservice/AccessibilityService;->getServiceInfo()Landroid/accessibilityservice/AccessibilityServiceInfo;
iget v1, v0, Landroid/accessibilityservice/AccessibilityServiceInfo;->flags:I
# flags &= ~0x2
and-int/lit8 v1, v1, -0x3
iput v1, v0, Landroid/accessibilityservice/AccessibilityServiceInfo;->flags:I
invoke-virtual {p0, v0}, \
Landroid/accessibilityservice/AccessibilityService;->setServiceInfo(Landroid/accessibilityservice/AccessibilityServiceInfo;)V
这一点可以确定:样本会感知目标应用进入前台,并确保运行时不带 FLAG_INCLUDE_NOT_IMPORTANT_VIEWS。
清除这个 flag 后,服务仍能:
- 获取活动窗口和“重要”节点;
- 按 View ID 定位公开节点;
- 执行
ACTION_SET_TEXT 和 ACTION_CLICK;
- 使用
dispatchGesture() 做坐标控制;
- 执行全局动作;
- 使用已授权的截图能力。
从攻击者角度看,这是一种“减少暴露特征,但保留核心控制”的折中。
5. 目标应用专用节点
解密 DEX 中还存在目标支付客户端 PIN 输入控件的资源 ID,以及目标 Activity 的显式组件字符串。这证明它不是只提供通用远控,而是对具体支付页面做了适配。
这部分公开文章不放真实包名、资源 ID 和操作顺序,避免形成可直接复用的支付自动化代码。
五、关键问题:这些动作真的能绕过 App Access Risk 吗
答案是:当前还不能证明。
这是整个分析中最重要、也最容易被写错的地方。
已经证明的事实
- 支付客户端会在 Pay Flow 请求 Standard Integrity Token;
- 请求没有通过
verdictOptOut 排除 App Access Risk;
- 服务端 Risk Engine 可以返回
RISK_DECLINE;
- 运行时已经观察到要求处理未知辅助应用的交易阻断页;
- 木马设置
isAccessibilityTool=true;
- 木马伪装成系统辅助功能风格包名;
- 木马会在目标应用前台动态清除
FLAG_INCLUDE_NOT_IMPORTANT_VIEWS;
- 木马仍能操作目标支付客户端。
尚未证明的因果关系
官方定义中的 UNKNOWN_CONTROLLING 关注的是“正在运行、权限已启用、能够控制设备”的应用,并没有说明 FLAG_INCLUDE_NOT_IMPORTANT_VIEWS 是唯一判定条件。
相反,该样本仍然声明:
android:canRetrieveWindowContent="true"
android:canPerformGestures="true"
android:canTakeScreenshot="true"
从能力上看,它仍然很像 controlling/capturing app。
isAccessibilityTool=true 也不等于通过增强审核。官方文档明确区分了“声明为无障碍工具”和“经过增强审核、被 Play 识别的 verified service”。
因此,下面这些说法都不能作为结论:
- “清除
0x2 就能绕过 Play Integrity”;
- “使用系统风格包名就会被当成官方服务”;
- “设置
isAccessibilityTool=true 就能进入白名单”;
- “支付客户端只在本地查询 AccessibilityServiceInfo flags”。
为什么实测现象仍然可能不同
目前有几种需要验证的可能性:
-
App Access Risk 没有被评估。
官方文档列出了 verdict 为空的条件,例如设备不满足要求、商店版本过旧、账号没有许可、请求版本不支持或请求 opt out。当前 APK 请求没有 opt out,但其他条件仍需记录。
-
测试没有命中同一个服务端 challenge。
Integrity 检查不是应用启动时固定执行,而是 Pay Flow/服务端 challenge 驱动。不同账号、交易路径、金额、版本或服务端配置可能不同。
-
服务端存在缓存或分层处置。
同一设备可能先允许、后挑战,或者风险结果在账号/设备维度缓存。需要比较每次请求时间与服务器返回。
-
样本在关键时刻改变了更多状态。
已确认它能切换触摸探索和 INCLUDE_NOT_IMPORTANT_VIEWS。是否还会暂停覆盖层、屏幕投射或其他模块,需要结合关键交易时刻的进程和权限状态继续验证。
-
样本身份可能被平台侧特殊识别。
这一点目前证据最弱。只有服务端实际 verdict 或增强审核记录才能证明。包名和证书本身不足以支持该结论。
六、下一步怎么把结论做实
这类问题不能继续靠改包名猜。最有效的是建立同设备、同账号、同版本、同交易路径的对照矩阵。
1. 记录客户端请求,而不是修改 Token
建议在授权环境中记录:
requestIntegrityToken 调用时间;
requestHash 的关联业务请求,不记录或公开完整生产 Token;
verdictOptOut Set 大小;
- Token 请求成功/失败;
- 风控 challenge 类型;
- 服务端返回的
RiskEngineResponse.Status;
- 阻断页模型和 remediation 类型。
不要尝试伪造 Integrity Token。正确方向是把“哪个状态导致什么 verdict”测出来。
2. 记录无障碍运行时快照
在以下时刻记录服务状态:
服务连接完成
桌面前台
支付客户端启动
进入交易流程
Integrity Token 请求前后
进入敏感输入页
离开支付客户端
快照字段至少包括:
info.flags
info.eventTypes
info.feedbackType
info.getCapabilities()
info.packageNames
enabled_accessibility_services
当前前台包
MediaProjection 状态
overlay 状态
3. 单变量测试
建议顺序:
| 组别 |
变量 |
| A |
无任何下载的无障碍服务 |
| B |
经 Play 增强审核的合法读屏服务 |
| C |
侧载最小测试服务,不具备手势能力 |
| D |
侧载服务,开启窗口内容读取 |
| E |
侧载服务,开启手势能力 |
| F |
与 E 相同,但目标前台清除 0x2 |
| G |
与 F 相同,分别关闭覆盖层和屏幕采集 |
如果合法 verified service 正常,而侧载服务被阻断,强烈指向 App Access Risk 的 known/unknown 与 verified exclusion。
如果 E 被阻断、F 不阻断,才能证明 0x2 在特定环境中影响结果;在做出这个对照之前,它只能叫“样本端目标感知的动态降权行为”。
七、开发侧可以直接落地的防守思路
不要把本地包名枚举当成主防线
客户端读取 enabled_accessibility_services 有几个问题:
- 包名可以伪装;
- 应用可以动态修改 ServiceInfo;
- 不同 Android 版本返回行为存在差异;
- 本地黑名单更新慢;
- 合法无障碍工具容易被误伤。
包名和组件名适合做遥测,不适合作为唯一拒绝条件。
交易附近重新评估
风险应用可能只在目标应用前台时改变自身状态。防守方应在高价值动作附近重新获取环境 verdict,而不是只在启动页检查一次。
这也是 Play Integrity 官方建议的用法:靠近被保护的动作请求 verdict,并使用 requestHash 绑定关键请求内容。
使用多信号分层处置
一个更合理的策略是联合:
- App Access Risk;
- Play Protect verdict;
- app/device integrity;
- 通话和屏幕共享状态;
- overlay;
- 设备历史风险;
- 交易行为和服务端账户风险。
响应也不应只有允许/拒绝,可以是:
允许
限制额度
增加生物识别
要求关闭风险应用后重试
临时阻断
人工复核
敏感节点仍要做降暴露
即使已经接入平台风险判断,敏感输入页仍应该:
- 使用
importantForAccessibility=no/noHideDescendants;
- 使用
importantForAutofill=no;
- 设置
FLAG_SECURE;
- 避免把 PIN 明文写入普通节点;
- 对自定义键盘、复制粘贴和屏幕共享做额外测试。
环境检测和数据最小暴露解决的是不同问题,两层都需要。
八、这次分析最终得到的结论
支付客户端并不是靠一个隐藏的本地无障碍包名黑名单完成交易保护。
本地 Java/Smali 中能确认的无障碍列表检查,主要服务于 promotion 展示兼容性;敏感支付布局通过 importantForAccessibility 和 FLAG_SECURE 减少数据暴露。真正能解释运行时交易阻断的,是 Pay Flow 中的 Play Integrity Standard Token、App Access Risk、Risk Client Metadata 和服务端 Risk Engine。
恶意样本确实存在一套针对目标应用的动态行为:识别前台包、设置目标状态、重新应用 AccessibilityServiceInfo,并清除 FLAG_INCLUDE_NOT_IMPORTANT_VIEWS。它还使用系统辅助功能风格包名和 isAccessibilityTool=true,并通过 AB 包与加密 DEX 隐藏真实控制代码。
但从现有证据看,这些动作只能证明“样本在主动降低可观察特征”,不能证明它已经因此绕过 Play Integrity。尤其是:isAccessibilityTool=true 不等于 verified accessibility service,系统风格包名也不能替代官方签名或增强审核。
本分析最大的收获不是找到一个“万能绕过开关”,而是把三类经常被混淆的东西拆开了:
本地 UI 兼容性检查
≠ 敏感节点保护
≠ 交易级 App Access Risk 风控
同样,样本端的动态 flag 变化:
证明存在目标感知的特征降级
≠ 已证明能够绕过平台环境 verdict
对于逆向分析来说,这种“不把相关性写成因果”的克制,比多找到几个敏感字符串更重要。
完毕!参考绕过代码:https://github.com/goldenfish689/android-reverse/blob/main/apks/GPayForegroundAccessibilityController.java
参考资料