学习通是目前在大学中最广泛使用的一款软件,很多老师喜欢用它来进行线上小测验、随堂小练习,但是随之而来的就是数不尽的麻烦和困扰,毕竟某些课真的有必要花时间精力去学习吗?有执行力的朋友会直接学自己认为重要的东西,没有的也不妨碍我们玩耍休息。
但是大部分水课老师为了让我们“重视”他们这些无比无用无聊的课程,总是设计比专业课严苛得多的考勤标准和考试规则。经过一点点技术手段,我对其进行了较为完善的技术性分析,因为本人没有经过专业训练,并非科班出身,只是进行技术探讨,错漏在所难免,多多包涵,
1. 摊开"检测面"
在讨论绕过之前,必须先知道对方到底在检测什么。通过观察教师端发布考试的设置页,我们可以得知考试时主要阻碍科技手段的判据有八。为了看清本质,我们按“客户端判定”和“服务端/被动判定”将其拆解:
【完全在客户端主动检测】
- 需人脸识别 + 持续比对:通过
com.chaoxing.facedetection.widget.a实现持续检测。 - 屏幕抓拍权限被占/关闭:通过
MediaProjection.stop监听授权中断。 - 切屏 / 离开作答页:依靠
onWindowFocusChanged配合生命周期实现。 - 在线监考(抓拍监控):定时在后台悄悄开启摄像头获取预览帧。
【依靠服务端或系统底层“防守”】
- 禁止多终端:服务端会话互踢(校验同 captchaId),客户端无接口。(服务端行为)
- 人脸不通过不许进:基于原生核验页的被动检测结果。(服务端校验)
- 屏幕检查(分屏/悬浮窗):录屏取帧后交由服务端进行图像审查。
- 截屏次数:答题页开启了系统的
FLAG_SECURE防护。
关键: 八条判据里,只有一半是真正在手机 App 端做逻辑判断的。其余的要么裁判权在云端(服务端审图),要么就是系统底层的物理级防御(如 FLAG_SECURE 防黑屏)。
这就像考场的安保系统,一半是站在你旁边的“监考员”可以被你忽悠;另一半则是单向玻璃和“云端指挥中心”,你在本地怎么折腾都不太好骗过去
2. 低版本 Android
网上流传"低版本安卓能绕过监考",结论是这条基本不成立,原因在 API 语义而不是实现细节。
2.1 FLAG_SECURE 的古老
WindowManager.LayoutParams.FLAG_SECURE 自 Android 1.0 存在,作用是"本窗口内容禁止被任何截图/录屏捕获"。它的画面剥离与合成发生在系统底层的 SurfaceFlinger,根本不归应用层管。
这就像是操作系统的“物理防窥膜”,无论你用多老的系统版本、什么截图软件,底层的图形合成器交出的照片一定是黑的。
- 低版本不会让
FLAG_SECURE失效。 - 高版本也没有新增"更强"的 secure 机制。
- 我们在答题页执行底层截屏
screencap,输出的 377 个采样点全部是(0,0,0)纯黑像素:
$ adb shell screencap -p /sdcard/a.png
$ python -c "from PIL import Image; import numpy as np; \
a=np.asarray(Image.open('a.png').convert('L')); print((a<10).mean())"
1.0 # 100% 黑像素
2.2 真正随版本变化的是"授权模型"
录屏 API(MediaProjection)的授权复用规则确实随版本收紧了:
-
Android 5–13:一次授权后,实例可以在进程内复用较长时间。(好比办了一张长期门禁卡,随进随出)
-
Android 14+:每次
createVirtualDisplay都要求重新授权,强制弹系统确认窗。(门禁卡改成了一次性访客卡,每次进门都要人工审批)
这意味着低版本对第三方"持续录屏类"应用更友好,但对"绕过检测"没有任何帮助,因为录屏权限是独占的,学习通自己拿到的授权在哪个版本都是会独占录屏权限。
2.3 低版本反而会更危险
低版本 Android 的 SELinux 域(如 untrusted_app)和 su 权限策略更宽松。正因如此,学习通对低版本的兼容分支可能更激进。服务端甚至会直接校验上报的 versionCode,教师端有一键开关可判定低版本手机为"环境异常"。
3. 低版本 App
因为检测代码本身存放在客户端,所以这一个路线比"低版本 Android"有效
3.1 为什么低版本 App 是盲点
监考能力是随版本迭代加上去的。以本文的目标apk版本 7.0.3 为例,它已经具备:
- 原生
CXCaptcha滑块(v1.1.22,学习通自研) MediaProjectionService前台服务(声明了types=0x00000020媒体投影权限)- 前后摄像头交替无感抓拍(前置录制 10–20s、后置抓 1.3s、间隔 2–4 分钟轮询)
FLAG_SECURE+ 切屏计数 + 服务端图片巡检
而早期的 5.x 时代根本没有这些能力。降级安装,等于对防守方进行降维打击,直接拿掉对方几年的检测积累。 安装一个远古版本的学习通,就像是穿越回了那个考场里连摄像头都没装的年代,属于“降维打击”。
3.2 三个不受控制的阻碍
这条路不需要任何技术门槛,但生杀大权完全掌握在官方手里,但是这不是为了给作弊提供便利,而是为了学习通软件的版本兼容性,所以一般默认关闭这些检测:
-
服务端版本校验:
receiveExamLogs接口会强制上报versionCode,教师端后台一个开关就能卡死老版本。 -
强制升级:App 启动时有强校验,低于特定版本值直接锁死在首页。
-
接口不兼容:新版考卷接口一旦变更,老版本进去根本刷不出题。
结论:这是唯一不需要懂底层技术就能操作的“降级盲点”,但命门捏在服务端手里,适合临时应急,绝非长久之计。
4. Root 后 Hook
这是本文实测最深的一条路(也是笔者最多使用的惯用伎俩)。我们通过 Xposed/LSPosed 劫持应用逻辑,看看"绕过"的真实边界在哪里。
4.1 判定是"取帧失败"
我假设"学习通注册了屏幕捕获的监听,投影被抢时收到 onStop 回调就计数"。有了以下的设计思路:当录屏权限被抢走时,学习通会收到一个叫 “onStop” 的中断信号,然后上报警报。于是写了如下代码,像门卫一样把这个 onStop 信号偷偷扔掉。
XposedHelpers.findAndHookMethod(
"android.media.projection.MediaProjection$Callback", cl, "onStop",
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam p) {
XposedBridge.log("[camfeed][proj] onStop swallowed");
p.setResult(null); // 强行阻断方法执行
}
});
看本地日志,信号确实被我们拦截了,但 0.2 秒后,教师端的后台依然弹出了“屏幕权限被占用”的告警。
日志显示 Hook 确实被触发了:onStop swallowed (com.chaoxing.mobile.course.shot.MediaProjectionService$a)
这有点诡异
经过对比时间线发现:
02:59:32.809 onStop swallowed
02:59:33 教师端:屏幕权限被占用(第1次) (仅延迟 0.2 秒)
这说明:告警就是跟着 onStop 走的,我们的 Hook 代码成了虚假的摆设。
4.2 为什么没拦住:抽象父类 vs 子类实现的 Java 分派
MediaProjection.Callback 是一个抽象类,onStop() 只是个没有实体的抽象方法。学习通底层是这样写的:
class MediaProjectionService$a extends MediaProjection.Callback {
@Override public void onStop() { /* 真实的上报报警逻辑 */ }
}
真相:我拦住了“通知”,但没拦住“具体干活的人” 这涉及到一个“父类与子类”的坑。
打个比方: 学习通的底层代码里有一个通用的广播喇叭叫“接口声明”,但实际负责上报异常的是下面具体的“行动小队”。
我们最初的代码,只是拔掉了广播喇叭的线,但底层的“行动小队”依然通过自己的对讲机把警报发了出去。 所以必须精确找到底层到底叫什么名字,精准拦截,才能真正堵住这封告警信。
Java 在底层走的是子类的 vtable(虚函数表)动态分派。Hook 抽象父类只是改了“空壳”,根本拦截不到子类重写的实际代码。正确的做法是扒出混淆后的具体实现类(底层的“行动小队”),直接 Hook 它:
String[] impls = {
"com.chaoxing.mobile.course.shot.MediaProjectionService$a",
"com.chaoxing.mobile.course.shot.MediaProjectionService$b",
};
for (String name : impls) {
Class<?> c = XposedHelpers.findClass(name, cl);
XposedBridge.hookAllMethods(c, "onStop", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam p) {
// before 阶段 setResult(null) 会短路真正的上报逻辑
p.setResult(null);
}
});
}
4.3 完整的四层封锁(逻辑欺骗)
为了不让它报警,我们需要在系统里伪造一个“岁月静好”的假象,一共需要做四件事:
第一层:阻止它主动断开重建
拦截 MediaProjection.stop()。学习通在发现异常时会尝试“自杀重启”,自己调 stop 也会触发报警回调,必须先掐掉自触发路径。
第二层:关闭子类实现的报警信号(即上述的 4.2)
第三层:伪造屏幕实例缓存
当录屏被抢走,学习通调 getMediaProjection() 会拿到 null 并报错;也就是当 App 向系统索要屏幕录像画面时,系统会因为报错给不出来。
我们要给它塞一个旧的实例对象:把以前正常的画面缓存起来塞给它,让它以为“监控一直在运行”。
第四层:把"截图失败"强行盖章为"成功"
追踪底层混淆代码,我们找到最终判定的函数 q():当它返回 false 时,触发“无法获取截图”报警。
XposedHelpers.findAndHookMethod(
"com.chaoxing.mobile.course.shot.MediaProjectionService", cl, "q",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam p) {
if (Boolean.FALSE.equals(p.getResult())) {
p.setResult(Boolean.TRUE); // 强制篡改布尔返回值
}
}
});
意思是App 底层有个判定逻辑,发现没抓拍到画面,就会判定为“失败”。我们通过强行把这个结果篡改为“成功”。
一顿操作后,教师端完全不再出现“屏幕截图异常”告警。即使后台主动抓拍也无法抓到你的屏幕画面(按钮直接被禁用),你可以自由的利用搜题软件搜题,不用担心截屏权限检测的问题了。
4.4 这条路的局限
(1)骗过了日志,却骗不过画面(业务状态不一致)
把 q() 翻成 true,只是镇压了报警。但真实的录像流已经断了,导致存到服务端的抓拍图是全黑的空白帧 但是学习通官方显然没有注意到这种情况的发生(doge),所以目前教师端没有任何报警、提示、记录,大部分水课老师都是文科背景,他们也根本不会怀疑是你动了手脚。
虽然通过 Hook 剪断了银行警报器的线,但第二天经理看监控录像,发现画面里只剩下空荡荡的房间。这种“无警报+无画面”的反常状态,在服务端排查时甚至比直接违规更可疑。
(2)Hook 框架本身的暴露风险
学习通套了爱加密企业壳,具备反 Frida、监控 TracerPid 与 ArtMethod 状态的能力。LSPosed 的调用栈在 dumpsys 中清晰可见。
(3)KernelSU 对应用的底层权限隔离
实测即使白名单全开,在 App 内部执行 su 的返回码依然是 rc=1(拒绝),但在 adb shell 中正常。这种 SELinux 级的隔离使得我们需要 Root 权限的特定注入操作无法形成闭环。
5. 定制系统内核
前三条路都是在“用户层面”和 App 斗智斗勇,而定制系统内核,则是直接去“地下室”改造整栋楼的水管和电线。在 App 看来,系统一切正常。
要在这一层动手脚,可以做到学生非常喜闻乐见的事情:
-
攻破防黑屏:直接修改底层的图形合成器(SurfaceFlinger),让系统在截屏时直接无视那层“防偷窥涂层”。
-
替换摄像头画面:在系统底层的相机驱动(Camera HAL)层面,直接把你的前置摄像头数据替换成一段事先录好的正常考试视频。App 看到的绝对是真实的人脸,但你本人其实不在看手机。这是唯一能彻底解决“抓拍图片为空”的手段。
5.1 三个可下手的深水区
(1)SurfaceFlinger 层(干掉黑屏限制)
FLAG_SECURE 的渲染在 SurfaceFlinger。只要有系统级权限(ACCESS_SURFACE_FLINGER),我们可以针对指定 Layer 发送 SurfaceControl.Transaction#setSkipCapture(true)。
即:不通过应用层做对抗,而是直接向图形显卡下发指令,将特定窗口在录屏渲染管线中剔除,画面截出来自然就干净了。
(2)Camera HAL 层(从驱动替换摄像头画面)
抓拍走的是 Camera API1。在底层硬件抽象层(HAL)将真实的传感器数据直接替换为准备好的“替身视频帧”。这是唯一能彻底补足“4.4 空白帧问题”的方案。 App 的回调逻辑拿到的绝对是一张完美的人脸图像。
(3)SELinux 策略重写
直接修改 zygisksu/sepolicy.rule 注入 allow untrusted_app su_exec:file execute; 放行权限阻断。
5.2 局限:高昂的工程代价
-
缺乏通用性:改内核级代码意味着你要为每款手机单独编译系统固件,一旦 OTA 升级,一切心血归零。
-
解锁 Bootloader 的反噬:内核定制需要解锁 BL 锁。学习通一旦检测
ro.boot.verifiedbootstate状态字段,立马能判定你的设备“被开过瓢”。 -
内核编程门槛较高:在几乎完全不懂的情况下干这种高风险的活计,犹如开车忘带方向盘、用v800猛踩刹车玩漂移、用Duo折叠屏打快板。
结论:这是能力上限最高、逻辑最完美的绕过路径,但极高的单设备定制与维护成本,决定了它无法成为普适方案。
6. 四条路径的横向工程参数对比
以下为不同维度的实测结论总结:
- 低版本 Android:技术门槛低,但基本无效。面临安全域过期的风险,没有真实收益。
- 低版本 App:技术门槛极低,属于一次性有效方案。维护成本为零,但服务端一个版本白名单开关就能让其瞬间失效,只适合短期绕过。
- Root + Xposed Hook:技术门槛中高,对“屏蔽告警”有效。会面临 App 反 Hook 壳的持续对抗检测风险。真实收益是能抹除异常事件,但会导致“数据全空”。
- 定制内核 (HAL/SurfaceFlinger):技术门槛极高,对“采集源头替换”绝对有效。面临极高风险(解锁状态暴露)。每次 OTA 都要重写刷机包。这是唯一能“彻底替换上传画面”的方案。
老系统是假的;老版本是限时限定的;Root 后改代码是“能骗过日志、却骗不过监控画面”的半吊子的;只有自己重写系统底层能完美绕过,但这等同于为了在期末考试里作弊,自己花钱盖了一座学校——属于技术上的狂欢,现实中的鸡肋。
7. 结论
综上,真正的工程级盲点只有三个核心认知:
(1)“防"与"检"的边界混淆,分清“盾牌”与“警报器”
FLAG_SECURE 的黑屏、MediaProjection 的系统强制独占,本质是操作系统的防守组件,不是应用层的检测代码。真正的突破口是 q()(取帧失败判定)和 onStop(会话中断回调)。防截屏的黑块是“盾牌”,你对抗它是没用的;App 判定你切屏、判定你取帧失败,这才是“警报器”。方向搞错,功夫白费。
(2)客户端判定 vs 服务端判定
你只能骗过客户端判定。多终端登录、服务端巡检抓拍图这些云端业务规则,除非你黑进学习通服务器,否则仅在手机端是无法对抗的。客户端绕过方案的天花板,永远受限于“服务端是否做二次状态核对”。 只要涉及到多端登录、后台图片人工审查等需要服务器判定的规则,你把手机系统改出一朵花来也没用。
(3)事件与状态的逻辑一致性最难伪造
Hook 可以抹掉异常的“事件”(如上报),但伪造不出正常的“业务状态”(如摄像头抓拍帧)。你可以把警报拦截掉(事件被吞),但教师端看的是最终的“照片回放”(状态)。你不报错,却交上去一张白卷,这种前后矛盾一旦被大范围统计排查,立刻就会原形毕露。要想实现逻辑上真正的天衣无缝,只能下沉到底层驱动(Camera HAL),强行修改业务数据源。
附录1:实测底层环境与关键日志
核心环境栈
设备: OnePlus PHP110
系统: Android 15 (SDK 35)
Root: KernelSU + SUSFS + tricky_store (用于隐藏Root特征)
Hook: LSPosed (zygisk)
目标: com.chaoxing.mobile 7.0.3
小段抓拍时间线分析
01:14:59 CAMERA_OPEN i=1 (前置) → 21s 后 release
01:18:40 CAMERA_OPEN i=1 (前置) → 12s 后 release
01:20:42 CAMERA_OPEN i=0 (后置) → 1.3s 后 release → 立刻切回前置
注:全程未调用 takePicture 落盘,直接从 setPreviewCallback 截取内存帧上传,极其隐蔽。
四层 Hook 闭环后的成功日志(不再触发服务端报警)
[camfeed][noscreen] hooked Window flags (FLAG_SECURE stripped)
[camfeed][proj] stop hook ok
[camfeed][proj] onStop hook ok
[camfeed][proj] impl hook ok MediaProjectionService$a
[camfeed][proj] impl hook ok MediaProjectionService$b
[camfeed][force] q hooked
附录2:从壳到 Hook
阶段 1:静态勘察
-
看 Manifest,找 Application 入口:
aapt dump xmltree app.apk AndroidManifest.xml | head -40关键输出:
application android:name="com.secneo.apkwrapper.AP"这一行说明壳是爱加密(SecNeo)。真正的业务
Application被藏在AP里面。 -
看 dex,确认业务代码不在里面:
unzip -l app.apk | grep classes
classes.dex 1.2 MB ← 只有壳
assets/dexopt/... 120 MB ← 加密的业务代码池
-
扫加密池里的 dex 魔数
grep -abo $'dex\n035' assets/dexopt/*.bin | head能找到 13 个
dex\n035片段,但没有可用的 class_def 表——表和字符串都被抽走了,只剩指令区。 -
确认还有没有别的壳
unzip -l app.apk | grep -Ei "bangcle|everisk|libDexHelper"
lib/arm64-v8a/libDexHelper.so ← 爱加密的 native 解密器
com/bangcle/everisk/Agent ← 梆梆(双壳叠加)
jadx 打开只能看到壳的几百个类,业务类一个都出不来。静态到此为止。
小故事环节:
你站在楼外,绕着走了一圈。 正门是电子密码锁(爱加密),锁体里还套着一道机械锁(梆梆)。 窗户全部封死,墙体是实心的。如果硬撬锁只会触发警报,而且锁芯一坏,里面的人就再也出不来了。
但你在绕圈的时候注意到一件事:楼门口贴着一张消防疏散图—— 图上写着每一层是干什么的、安全出口在哪。这张图就是 AndroidManifest。它不可以被加密,因为它必须让外来的消防员看懂。
阶段 2:动态侦察
壳能加密代码,但加密不了运行时状态。四个 dumpsys 撑起后续全部工作。
- 谁在跑?—— Activity 栈
adb shell dumpsys activity activities | grep -E "ResumedActivity|topResumed"
topResumedActivity=... com.chaoxing.mobile/.webapp.ui.WebAppCommonViewer
明文类名。 这是第一个入口点。
- 有哪些服务?—— 这是最关键的一步
adb shell dumpsys activity services com.chaoxing.mobile
ServiceRecord{... com.chaoxing.mobile/.course.shot.MediaProjectionService}
processName=com.chaoxing.mobile
types=0x00000020 ← FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
isForeground=true
MediaProjectionService 明文,未混淆。 这是整个逆向的锚点。
- 窗口带什么标志?
adb shell dumpsys window windows | grep -A2 "WebAppCommonViewer"
Window{... com.chaoxing.mobile/...WebAppCommonViewer}
fl=LAYOUT_IN_SCREEN SECURE ← FLAG_SECURE 在生效
- 谁持有录屏权限?
adb shell dumpsys media_projection
Media Projection: (com.chaoxing.mobile, uid=10312): TYPE_SCREEN_CAPTURE
- View 树长什么样?
adb shell uiautomator dump /sdcard/x.xml
adb pull /sdcard/x.xml
滑块验证码的真身全在里面:
#captcha
#eject
#cx_image_margin
#cx_imgBg ← 背景图
#cx_obstacle_canvas ← 缺口 canvas
滑块轨道
资源 id 明文,未混淆。
小故事环节:
你进不去楼,但你在街对面的咖啡馆坐了一下午,记下了三件事:
一、有个部门每天准点上班——MediaProjectionService,它的工作就是盯着楼里的每一个房间,把看到的东西往外传。牌子上写着它的名字,没遮没掩。
二、大部分房间拉上了窗帘——FLAG_SECURE。窗帘一拉,外面的人什么也拍不到。
三、楼里的家具都有编号——#cx_imgBg、#cx_obstacle_canvas。编号是给搬运工看的,从来不加密。
你今天没有进楼一步,但已经知道了:谁在盯梢、哪间房拉窗帘、屋里大概摆了什么。
阶段 3:搭 Hook 模块
3.1 拿 Xposed API
curl -o api-82.jar \
https://maven.aliyun.com/repository/public/de/robv/android/xposed/api/82/api-82.jar
3.2 写入口类
package com.lcs.camfeed;
import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.callbacks.XC_LoadPackage;
public class MainHook implements IXposedHookLoadPackage {
private static final String TARGET = "com.chaoxing.mobile";
@Override
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lp) {
if (lp == null || !TARGET.equals(lp.packageName)) return;
XposedBridge.log("[camfeed] loaded in " + lp.packageName
+ " pid=" + android.os.Process.myPid());
hookSecureStrip(lp.classLoader); // 第一阶段目标
}
}
3.3 编译打包
# 编译
javac -source 8 -target 8 -encoding UTF-8 \
-cp "android.jar;api-82.jar" \
-d classes src/com/lcs/camfeed/*.java
# 转 dex
java -cp r8.jar com.android.tools.r8.D8 \
--lib android.jar --min-api 26 --output dex classes/com/lcs/camfeed/*.class
# 打包
aapt package -f -M AndroidManifest.xml -I android.jar -F unsigned.apk
cd dex && aapt add ../unsigned.apk classes.dex && cd ..
cd p && aapt add ../unsigned.apk assets/xposed_init && cd ..
# 签名
java -jar apksigner.jar sign --ks debug.keystore \
--ks-pass pass:android --out app.apk unsigned.apk
assets/xposed_init 里只有一行,写入口类全名:
com.lcs.camfeed.MainHook
3.4 安装 + 配置作用域
adb install -r app.apk
adb shell am force-stop com.chaoxing.mobile
adb shell am start -n com.chaoxing.mobile/com.chaoxing.mobile.main.ui.MainTabActivity
LSPosed 只在开机时扫描模块列表,所以新装模块后必须重启设备,或者重启目标进程并确认作用域已勾选。
小故事环节:
你不能硬闯,但你有一项别人没有的本事:造一份完美的假身份。这份身份文件做得和真的一模一样——同样的格式、同样的钢印(签名)、同样的编号规则。
门卫扫一眼,放行。但你不是进去观光的。你身上藏着一台很小的录音机(hook 代码), 它不占地方,不影响楼里任何人干活,只安静地记录。xposed_init 就是接头暗号——门卫念出这串名字,卧底就知道该上岗了。
阶段 4:把 Hook 当探针用调用栈反查混淆名
这是全套流程里最关键的一步。
4.1 钩系统 API,打印调用栈
业务代码可以改名成 px5.M,但它必须调用系统 API。在系统 API 上挂钩子,栈帧会暴露调用者。
private void probeFlags(ClassLoader cl) {
XposedHelpers.findAndHookMethod("android.view.Window", cl,
"addFlags", int.class, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam p) {
int f = (Integer) p.args[0];
if ((f & 0x2000) != 0) { // FLAG_SECURE
StackTraceElement[] st = new Throwable().getStackTrace();
StringBuilder sb = new StringBuilder();
for (int i = 1; i < Math.min(st.length, 6); i++) {
sb.append(st[i].getClassName()).append('.')
.append(st[i].getMethodName()).append(" <- ");
}
XposedBridge.log("[probe] FLAG_SECURE by " + sb);
}
}
});
}
日志输出:
[camfeed][probe] WINDOW addFlags i=8192(0x2000)
<- LSPHooker_.addFlags <- px5.M <- ...
px5.M 浮出水面——它就是设置 FLAG_SECURE 的混淆方法。
4.2 同样的手法钩投影
XposedHelpers.findAndHookMethod(
"android.media.projection.MediaProjection", cl, "stop",
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam p) {
StackTraceElement[] st = new Throwable().getStackTrace();
// 打印前 6 帧
}
});
日志:
[camfeed][probe] PROJECTION MediaProjection.stop
<- LSPHooker_.stop <- MediaProjectionService.v <- MediaProjectionService.B
v 和 B 是混淆短名,但调用者 MediaProjectionService 是明文,链就有了锚点。
4.3 钩父类,打 thisObject 的真实类名
XposedBridge.hookAllMethods(
XposedHelpers.findClass("android.media.projection.MediaProjection$Callback", cl),
"onStop",
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam p) {
// 关键:thisObject 是"实际被调用的那个对象"
XposedBridge.log("[camfeed][proj] onStop swallowed ("
+ p.thisObject.getClass().getName() + ")");
p.setResult(null);
}
});
日志:
[camfeed][proj] onStop swallowed (com.chaoxing.mobile.course.shot.MediaProjectionService$a)
$a 就是这样拿到的。
4.4 枚举内部类,补齐 $b
Class<?> svc = XposedHelpers.findClass(
"com.chaoxing.mobile.course.shot.MediaProjectionService", cl);
for (Class<?> inner : svc.getDeclaredClasses()) {
XposedBridge.log("[probe] inner: " + inner.getName());
}
[probe] inner: com.chaoxing.mobile.course.shot.MediaProjectionService$a
[probe] inner: com.chaoxing.mobile.course.shot.MediaProjectionService$b
小故事环节:
卧底进楼后,第一件事不是偷文件,是找到总电闸,在上面贴一个微型记录仪。这栋楼里所有人都用化名互相称呼,只有胸口别着"07号"“13号"的牌子,外来的你根本不知道谁是谁。 但记录仪记的不是具体的人名,是"谁的手碰了电闸"这种具体动作。
比如说:07号一拉闸,记录仪就写下:“07号,拉闸,动作来自三楼东侧”。拉窗帘的 07号,叫 px5.M。 断电的 13号,叫 v;下令的 21号,叫 B。 你依然不知道他们的真名,但你已经知道他们在哪个部门,因为部门的名字写在门牌上(MediaProjectionService),而门牌不能摘。
最难碰到的是那个"真正动手的小队长” $a。 他不直接碰电闸,他深居简出的藏在部门内部。但好在你在部门门口装了一个感应器(钩父类,打 thisObject), 他一出门,感应器就记下他的编号。最后,你翻了一遍部门的花名册(getDeclaredClasses()),发现还有个小队长叫 $b。
阶段 5:反推方法语义
拿到类名还不够,MediaProjectionService 里的方法名全是 a 到 z。
5.1 全量 hook,记录调用序列
Class<?> cls = XposedHelpers.findClass(
"com.chaoxing.mobile.course.shot.MediaProjectionService", cl);
for (Method m : cls.getDeclaredMethods()) {
XposedBridge.hookMethod(m, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam p) {
XposedBridge.log("[shot] -> " + p.method.getName()
+ "(" + p.args.length + ")");
}
});
}
触发一次真实抓拍,拿到序列:
onCreate → h → l → onBind → y(2) → o → p → n → p → k → m
→ j(3) → a(2) → r → x(1) → z(1) → A → q → B → v → onDestroy
5.2 靠参数个数和位置猜语义
| 化名 | 观察到的特征 | 推断的职责 |
|---|---|---|
y(2 args) |
紧跟接线之后,带两个参数 | 领取望远镜(getMediaProjection) |
j(3 args) |
带三个参数,返回空 | 架设摄像头(createVirtualDisplay) |
q() |
队伍最后,返回 false |
打卡确认(成败判定) |
5.3 只打印异常返回,锁定失败链
@Override
protected void afterHookedMethod(MethodHookParam p) {
Object r = p.getResult();
if (r == null || Boolean.FALSE.equals(r)) {
XposedBridge.log("[shot] <= " + nm + " = " + r);
}
}
一次抓拍,失败链出现:
<= j = null ← createVirtualDisplay 失败
<= q = false ← 最终判定
q() 是"调用链上唯一返回 false 且位于链尾"的方法。
小故事环节:
你手里有一张目标花名册,32 个编号,没有名字。你的身份是卧底,而不是警察,不可能挨个审问,但你可以站在门口看他们上班。
早上第一个进门的是 onCreate,最后一个锁门的是 onDestroy。 中间有个编号 j 的人,一次扛三件设备进来(三个参数), 还有个编号 q 的人,每天下班前都会在门上贴一张条子: “今天没干成”(返回 false)。
你不需要审问任何人。你只需要知道: q 是那个盖章的人。他盖了"没干成”,上面就知道出事了。 把他优化掉,上面就永远收不到这张条子。
阶段 6:四层封锁——把上报路径逐条掐断
第一层:拦住它主动结束会话
XposedHelpers.findAndHookMethod(
"android.media.projection.MediaProjection", cl, "stop",
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam p) {
p.setResult(null); // 不执行原方法
}
});
第二层:钩具体实现类的 onStop
String[] impls = {
"com.chaoxing.mobile.course.shot.MediaProjectionService$a",
"com.chaoxing.mobile.course.shot.MediaProjectionService$b",
};
for (String name : impls) {
try {
Class<?> c = XposedHelpers.findClass(name, cl);
XposedBridge.hookAllMethods(c, "onStop", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam p) {
p.setResult(null);
}
});
} catch (Throwable ignored) {}
}
第三层:getMediaProjection 返回缓存实例
XposedBridge.hookAllMethods(MediaProjectionManager.class, "getMediaProjection",
new XC_MethodHook() {
private Object cached;
@Override
protected void afterHookedMethod(MethodHookParam p) {
Object r = p.getResult();
if (r != null) { cached = r; return; }
if (cached != null) p.setResult(cached);
}
});
第四层:把"截图失败"翻成"成功"
XposedHelpers.findAndHookMethod(
"com.chaoxing.mobile.course.shot.MediaProjectionService", cl, "q",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam p) {
if (Boolean.FALSE.equals(p.getResult())) {
p.setResult(Boolean.TRUE);
}
}
});
验证: 改之前,教师端告警紧跟 onStop:
02:59:32.809 onStop swallowed
02:59:33 教师端:屏幕权限被占用(第1次) ← 0.2 秒
改之后,四层全部生效:
[camfeed][noscreen] hooked Window flags (FLAG_SECURE stripped)
[camfeed][proj] stop hook ok
[camfeed][proj] onStop hook ok
[camfeed][proj] impl hook ok MediaProjectionService$a
[camfeed][proj] impl hook ok MediaProjectionService$b
[camfeed][force] q hooked
教师端不再新增告警。
小故事环节:
大楼的报警系统是这样工作的: 有人拉闸 → 小队长 $a 报告"线路断了" → 总台收到 → 记一笔。
你要做四件事,按顺序:
一、把总闸的开关焊死(拦 stop()),让它自己拉不了闸。
二、按住两个小队长的嘴(钩 $a/$b 的 onStop),闸真断了他们也喊不出声。
三、总台打电话问"线路还在吗",你替他接,答"在"(缓存 getMediaProjection)。
四、总台问"今天打卡成功了吗",你替他答"成功了"(翻转 q())。
四条线全断,总台那边就是一片安静。
但你要清楚:楼里的电,是真的断了。 你只是让总台以为没断。
全流程回顾
[0] 工具链 javac + d8 + aapt + apksigner
↓
[1] 静态勘察 Manifest → 壳(爱加密 + 梆梆)→ 静态死路
↓
[2] 动态侦察 dumpsys / uiautomator → 明文类名 + 资源 id
↓
[3] 搭模块 api-82.jar → 编译 → 打包 → 签名 → 安装
↓
[4] Hook 探针 钩系统 API 打栈 → px5.M / MediaProjectionService.v
钩父类打 thisObject → $a
getDeclaredClasses → $b
↓
[5] 语义反推 32 方法全钩 → 调用序列 → j(3)=createVirtualDisplay、q=判定
↓
[6] 四层封锁 stop / onStop($a,$b) / getMediaProjection / q
↓
[7] 验证 时间对齐(onStop → 0.2s 告警)→ 告警消失
本文所有测试均在自有设备与自建考试环境中完成,旨在剖析监考系统的底层安全边界与移动端防线设计逻辑,绝不构成任何工具分发或指导。