问道
笔记
返回全部笔记

学X通线上考试检测技术讨论

学习通是目前在大学中最广泛使用的一款软件,很多老师喜欢用它来进行线上小测验、随堂小练习,但是随之而来的就是数不尽的麻烦和困扰,毕竟某些课真的有必要花时间精力去学习吗?有执行力的朋友会直接学自己认为重要的东西,没有的也不妨碍我们玩耍休息。

但是大部分水课老师为了让我们“重视”他们这些无比无用无聊的课程,总是设计比专业课严苛得多的考勤标准和考试规则。经过一点点技术手段,我对其进行了较为完善的技术性分析,因为本人没有经过专业训练,并非科班出身,只是进行技术探讨,错漏在所难免,多多包涵,


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 三个不受控制的阻碍

这条路不需要任何技术门槛,但生杀大权完全掌握在官方手里,但是这不是为了给作弊提供便利,而是为了学习通软件的版本兼容性,所以一般默认关闭这些检测:

  1. 服务端版本校验:receiveExamLogs 接口会强制上报 versionCode,教师端后台一个开关就能卡死老版本。

  2. 强制升级:App 启动时有强校验,低于特定版本值直接锁死在首页。

  3. 接口不兼容:新版考卷接口一旦变更,老版本进去根本刷不出题。

结论:这是唯一不需要懂底层技术就能操作的“降级盲点”,但命门捏在服务端手里,适合临时应急,绝非长久之计。


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:静态勘察

  1. 看 Manifest,找 Application 入口:
    aapt dump xmltree app.apk AndroidManifest.xml | head -40

    关键输出:application android:name="com.secneo.apkwrapper.AP"

    这一行说明壳是爱加密(SecNeo)。真正的业务 Application 被藏在 AP 里面。

  2. 看 dex,确认业务代码不在里面:

unzip -l app.apk | grep classes

classes.dex        1.2 MB     ← 只有壳

assets/dexopt/...  120 MB     ← 加密的业务代码池
  1. 扫加密池里的 dex 魔数

    grep -abo $'dex\n035' assets/dexopt/*.bin | head

    能找到 13 个 dex\n035 片段,但没有可用的 class_def 表——表和字符串都被抽走了,只剩指令区。

  2. 确认还有没有别的壳

unzip -l app.apk | grep -Ei "bangcle|everisk|libDexHelper"

lib/arm64-v8a/libDexHelper.so     ← 爱加密的 native 解密器

com/bangcle/everisk/Agent         ← 梆梆(双壳叠加)	

jadx 打开只能看到壳的几百个类,业务类一个都出不来。静态到此为止。

小故事环节:
你站在楼外,绕着走了一圈。 正门是电子密码锁(爱加密),锁体里还套着一道机械锁(梆梆)。 窗户全部封死,墙体是实心的。如果硬撬锁只会触发警报,而且锁芯一坏,里面的人就再也出不来了。
但你在绕圈的时候注意到一件事:楼门口贴着一张消防疏散图—— 图上写着每一层是干什么的、安全出口在哪。这张图就是 AndroidManifest。它不可以被加密,因为它必须让外来的消防员看懂。

阶段 2:动态侦察

壳能加密代码,但加密不了运行时状态。四个 dumpsys 撑起后续全部工作。

  1. 谁在跑?—— Activity 栈
adb shell dumpsys activity activities | grep -E "ResumedActivity|topResumed"

topResumedActivity=... com.chaoxing.mobile/.webapp.ui.WebAppCommonViewer

明文类名。 这是第一个入口点。

  1. 有哪些服务?—— 这是最关键的一步
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 明文,未混淆。 这是整个逆向的锚点。

  1. 窗口带什么标志?
adb shell dumpsys window windows | grep -A2 "WebAppCommonViewer"

Window{... com.chaoxing.mobile/...WebAppCommonViewer}

 fl=LAYOUT_IN_SCREEN SECURE           ← FLAG_SECURE 在生效

  1. 谁持有录屏权限?
adb shell dumpsys media_projection
Media Projection: (com.chaoxing.mobile, uid=10312): TYPE_SCREEN_CAPTURE

  1. 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 告警)→ 告警消失


本文所有测试均在自有设备与自建考试环境中完成,旨在剖析监考系统的底层安全边界与移动端防线设计逻辑,绝不构成任何工具分发或指导。

8060 次访问