首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
Android安全
发新帖
1
7
[原创]看懂身份验证中的活体检测(一):动作活体
发表于: 2026-7-9 14:39
1730
[原创]看懂身份验证中的活体检测(一):动作活体
deltasee
2026-7-9 14:39
1730
## 开场:为什么现在要重新看活体检测 最近几年,AI 应用和基础模型的发展速度很快。一个可观察到的变化是:增长带来新体验的同时,也把账号滥用、自动化注册、合成身份和身份验证风险等问题推到了更前面的位置。 围绕 AI 产品账号验证、年龄验证和身份核验的讨论正在增多。对普通用户来说,"为什么平台要确认你是真人/本人"不再只是金融开户或 eKYC 场景里的问题;活体检测这类原本偏风控的技术,也因此被更多人看见。 但活体检测不是一个按钮,也不是一个单点能力。它是一条不断加厚的防线:每一代方案都在回答一个问题:上一代漏掉了什么?新的攻击面又转移到了哪里? 这个系列想做的不是"怎么绕过活体",而是站在防御侧,把活体检测一层一层拆开:这一层解决了什么、它的判断依据是什么、为什么还不够、下一层为什么出现。 第一篇先讲最基础也最常见的一层:动作活体(Motion Liveness)。 本文以 FaceAISDK 的公开代码流程为例。具体项目只是样本,主角不是某个 SDK,而是动作活体这一类设计的防御边界。 ## 1. 动作活体是什么 动作活体的思路很直观:系统让用户做一组动作,例如张嘴、眨眼、微笑、摇头、点头;检测端判断画面里的人脸是否完成了这些动作。 换成防御语言,动作活体是在验证: > 输入画面里的人脸特征,是否在一段时间内呈现出指定动作序列。 这句话很关键。它说明动作活体比静态照片校验更强,但它的默认验证对象仍然是"画面里的呈现",而不是"这段画面的来源"。 下面按 FaceAISDK 公开代码总结里的流程,只拆动作检测链路:参数如何进入、帧如何变成 ML Kit 的 `Face`、状态机如何读取动作特征,以及动作完成后如何推进状态。组合模式里的其他活体层不在本篇展开。 ## 2. Demo 配置:先选择活体类型、动作集合和回调 ```text [F1:87] private void initLivenessParam() { [F1:88] FaceProcessBuilder faceProcessBuilder = new FaceProcessBuilder.Builder(this) [F1:89] .setLivenessOnly(true) [F1:90] .setLivenessType(faceLivenessType) [F1:91] .setLivenessDetectionMode(MotionLivenessMode.ACCURACY) [F1:92] .setMotionLivenessTypes(motionLivenessTypes) [F1:93] .setProcessCallBack(new ProcessCallBack() { ``` 这一段是 Demo 的活体参数入口。`setLivenessOnly(true)` 表示只做活体,不做 1:1 人脸比对;`setLivenessType(...)` 决定活体类型;`setLivenessDetectionMode(...)` 选择动作检测模式;`setMotionLivenessTypes(...)` 决定动作集合;`setProcessCallBack(...)` 接收动作过程提示和最终结果。 ```text [F8:11] public void onLivenessDetected(float f, Bitmap bitmap) { [F8:17] public void onFailed(int i, String str) { [F8:20] public abstract void onProcessTips(int i); [F8:22] public void onTimeCountDown(float f) { ``` 对动作活体来说,这里最关键的是 `onProcessTips(...)`:它承载当前动作提示、动作完成和失败状态。本文只讨论动作检测相关回调,不展开组合模式里的其他活体层。 ## 3. CameraX 帧如何进入 SDK ```text [F1:138] cameraXFragment.setOnAnalyzerListener(imageProxy -> { [F1:141] faceVerifyUtils.goVerifyWithImageProxy(imageProxy); [F1:144] }); [F2:215] public void goVerifyWithImageProxy(ImageProxy imageProxy) { [F2:216] goVerify(imageProxy); ``` CameraX 每拿到一帧 `ImageProxy`,Demo 就把它交给 `faceVerifyUtils.goVerifyWithImageProxy(...)`。这是检测流程的帧入口。 ```text [F2:86] int rotationDegrees = imageProxy.getImageInfo().getRotationDegrees(); [F2:87] int width = imageProxy.getWidth(); [F2:88] int height = imageProxy.getHeight(); [F2:93] o0oo0ooo.OooOO0o = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); [F2:95] DataConvertUtils.o0oOooO0(imageProxy, o0oo0ooo.OooOO0o); [F2:97] o0oo0ooo.o0oOooO0(null, o0oo0ooo.OooOO0o); ``` 这一段做了两件事:读取帧尺寸和旋转信息;把 `ImageProxy` 转进复用的 ARGB Bitmap,再交给后面的状态机。也就是说,后续活体逻辑处理的是一张张 Bitmap 及其检测结果。 这里讨论的是防御侧内部检测流程,不是输入替换或复现路径。本文只分析 SDK 如何消费帧,不展开任何攻击侧输入方式。 ## 4. Bitmap 如何进入 ML Kit ```text [F3:211] public final void o0oOooO0(Bitmap bitmap, Bitmap bitmap2) { [F3:215] this.OooO00o = true; [F3:224] this.f59o0Oo0Ooo.o0oOooO0(false, bitmap2, new o0oOooO0(this, bitmap2, bitmap)); [F4:182] public final void o0oOooO0(boolean z, Bitmap bitmap, o0ooODOO o0ooodoo) { [F4:183] this.f80o0oOooO0.process(InputImage.fromBitmap(bitmap, 0)).addOnSuccessListener((v4) -> { [F4:184] o0oOooO0(r2, r3, r4, v4); ``` Bitmap 进入 ML Kit 的关键点是 `InputImage.fromBitmap(bitmap, 0)`。ML Kit 负责做人脸检测,然后把检测到的人脸列表交回 SDK。 这一层输出的是 `Face` 相关结果,包括人脸框、关键点、轮廓、姿态角、眼睛开合概率、微笑概率等。动作活体后面用的不是"真人证明",而是这些视觉检测特征。 ## 5. ML Kit 背后的 native 人脸检测链路 ```text L10 Google ML Kit native 检测链路: Java API com.google.mlkit.vision.face.internal.FaceDetectorImpl.process(...) MLTask com.google.mlkit.vision.face.internal.zzh.zze(InputImage) Binder client com.google.android.gms.internal.mlkit_vision_face.zzoy.zzd(...) Binder server com.google.mlkit.vision.face.bundled.internal.zza.zzb(...) JNI wrapper com.google.android.gms.vision.face.FaceDetectorV2Jni.zzd(...) native entry detectFacesImageByteBufferJni ``` 从 SDK 视角看,`process(InputImage.fromBitmap(...))` 是一个 Java API 调用;但它背后并不止停在 Java 层。根据代码总结里的 L10 索引,ML Kit 会从 `FaceDetectorImpl.process(...)` 进入内部 task,经 bundled detector 的 Binder client / server,最后通过 `FaceDetectorV2Jni.zzd(...)` 调到 native 人脸检测入口。 ```text L11 / N1 libface_detector_v2_jni.so 模型推理地址链: [N1:0x288514] detectFacesImageByteBufferJni(...) [N1:0x288608] sub_289890(detector, imagePtr, metadataBuf, result) [N1:0x2898D4] sub_289914(...) // 检测分发 [N1:0x433C98] tracker / detector 主流程 [N1:0x446568] sub_446568(...) // FssdDetector::Detect-like [N1:0x455740] sub_455740(...) // FaceDetectorTfliteClient::Detect [N1:0x455D34] DetectResizedAndRotatedFaces [N1:0x455F14] RGB 输入/推理/输出读取 [N1:0x71E1C8] TensorFlow Lite Subgraph::Invoke ``` 这条 native 链路的职责是人脸检测,不是活体打分。它把图像输入、metadata 和 detector 状态送入 bundled face detector / TFLite 推理链,再经过检测、decode、阈值、NMS、pose、tracker 等处理,最终回到 Java 层的 `Face` / `List<Face>`。 这一步对第一篇很关键:动作活体读取的 pitch、yaw、眼睛开合概率、微笑概率和嘴唇轮廓,并不是动作状态机自己从像素里直接算出来的,而是建立在上游人脸检测 native 链路输出的 `Face` 结果之上。因此,动作检测的完整防御侧链路应写成: ```text CameraX frame -> Bitmap -> ML Kit Java API -> ML Kit internal task -> bundled detector Binder -> JNI -> libface_detector_v2_jni.so / TFLite -> Java Face / List<Face> -> 动作状态机 ``` 本文只把这条链路作为"人脸检测如何产出动作状态机所需 Face 特征"来解释,不讨论任何攻击侧输入路径或复现方式。 ## 6. 多张脸时如何选择最大脸 ```text [F4:190] public final void o0oOooO0(o0ooODOO o0ooodoo, Bitmap bitmap, boolean z, List list) { [F4:191] if (list.isEmpty()) { [F4:192] o0ooodoo.o0oOooO0(null, null, 0); [F4:196] Face face = (Face) Collections.max(list, f77o0Oo0Oo); [F4:201] o0ooodoo.o0oOooO0(face, bitmap, list.size()); ``` 如果没有检测到人脸,状态机会收到空结果;如果检测到多张脸,SDK 选择最大脸继续处理,同时把人脸数量传下去。 这一步解释了动作状态机的输入:最大 `Face`、原始 Bitmap、人脸数量。后面的动作判断,都围绕这个 `Face` 输出的姿态、概率和轮廓特征展开。 ## 7. 主状态机:先做无脸、距离和多脸检查 ```text [F5:123] if (face == null) { [F5:132] state.f51o0Oo0OOO.onProcessTips(-7); [F5:135] state.f51o0Oo0OOO.onProcessTips(-3); [F5:138] return; ``` 没有人脸时,状态机会给出过程提示,严重时中止。这是质量门,不是活体模型判断。 ```text [F5:144] final int faceWidth = face.getBoundingBox().width(); [F5:145] final int minScreen = Math.min(fullBmp.getWidth(), fullBmp.getHeight()); [F5:148] if (faceWidth < state.f49o0ooODOO + 150) { [F5:150] state.f51o0Oo0OOO.onProcessTips(-9); [F5:153] } else if (faceWidth > minScreen) { [F5:155] state.f51o0Oo0OOO.onProcessTips(-8); [F5:163] if (faceCount > 1 && now - state.OooOO0O > 1000) { [F5:164] state.f51o0Oo0OOO.onProcessTips(-12); ``` 脸太小、脸太大、多脸,都会触发对应提示。这些判断依赖 `Face.boundingBox` 和 `faceCount`,主要保证检测条件可用。 ## 8. 动作状态机:读取 pitch、yaw、眼睛、微笑和嘴唇轮廓 ```text [F5:246] final int currentActionType = state.f57o0Oo0OoO[state.f54o0Oo0OOo]; [F5:247] final ArrayList<o0Oo0o00.o0ooODOO> samples = state.f58o0Oo0oOo; [F5:248] o0Oo0o00.o0oOooO0.f162o0oOooO0 = state.o0Oo0o0O.getLivenessDetectionMode(); [F5:251] switch (currentActionType) { ``` 进入动作分支后,当前动作类型来自动作序列,样本列表用于累计连续帧里的动作特征。状态机按动作类型进入不同分支。 ```text [F5:253] case 5: { [F5:254] float pitch = face.getHeadEulerAngleX(); [F5:258] samples.add(new o0Oo0o00.o0ooODOO(5, pitch)); [F5:273] actionPassed = checkRange(samples, 5, lowMin, highMin, lowMax, highMax, 11); ``` 点头读取的是头部 X 轴欧拉角,也就是 pitch 的变化。状态机会把一段时间内的 pitch 样本收集起来,再判断变化范围是否满足动作条件。 ```text [F5:279] case 4: { [F5:280] float yaw = face.getHeadEulerAngleY(); [F5:284] samples.add(new o0Oo0o00.o0ooODOO(4, yaw)); [F5:299] actionPassed = checkYawRange(samples, 4, lowMin, highMin, lowMax, highMax); ``` 摇头读取的是 Y 轴欧拉角,也就是 yaw 的变化。动作状态机并不直接理解"人",它读取的是人脸检测器输出的姿态角时间序列。 ```text [F5:304] case 3: { [F5:305] Float rightEye = face.getRightEyeOpenProbability(); [F5:307] && Math.abs(face.getHeadEulerAngleY()) < 12f [F5:308] && Math.abs(face.getHeadEulerAngleX()) < 12f) { [F5:309] samples.add(new o0Oo0o00.o0ooODOO(3, rightEye)); [F5:310] samples.add(new o0Oo0o00.o0ooODOO(3, face.getLeftEyeOpenProbability())); [F5:319] actionPassed = checkBlink(samples, openHi, 0.2f, 0.66f); ``` 眨眼读取左右眼开合概率,同时要求头部姿态在一定范围内。这个设计是合理的:头部大幅偏转时,眼睛概率本身会更不稳定。 ```text [F5:325] case 2: { [F5:326] Float smile = face.getSmilingProbability(); [F5:328] samples.add(new o0Oo0o00.o0ooODOO(2, smile)); [F5:332] actionPassed = checkSmile(samples, smileHi, 0.12f); ``` 微笑读取的是 ML Kit 输出的 `getSmilingProbability()`。状态机关注的是概率随时间变化是否满足要求。 ```text [F5:337] case 1: { [F5:340] for (FaceContour c : face.getAllContours()) { [F5:341] if (c.getFaceContourType() == 9) upperLip = c; [F5:342] if (c.getFaceContourType() == 10) lowerLip = c; [F5:347] delta = lo.get(lo.size() / 2).y - up.get(up.size() / 2).y; [F5:349] samples.add(new o0Oo0o00.o0ooODOO(1, delta)); [F5:351] actionPassed = checkMouthOpen(samples, 6f, 11f, 5f); ``` 张嘴读取上下唇轮廓点,计算中间点的纵向距离变化。这里同样是在使用 2D 人脸检测结果,不是在验证 3D 结构、采集链路或设备来源。 这一节是动作活体的核心:它的判断依据是 ML Kit `Face` 输出的 pitch、yaw、眼睛概率、微笑概率和嘴唇轮廓变化。它验证的是这些特征在时间上是否呈现出指定动作。 ## 9. 动作通过后,状态如何推进 ```text [F5:366] if (actionPassed) { [F5:367] samples.clear(); [F5:368] int nextIdx = state.f54o0Oo0OOo + 1; [F5:369] state.f54o0Oo0OOo = nextIdx; [F5:372] if (nextIdx > state.f55o0Oo0Oo0 - 1 && state.f51o0Oo0OOO != null) { [F5:376] state.f51o0Oo0OOO.onTimeCountDown(1.0f); [F5:377] state.f51o0Oo0OOO.onProcessTips(20); ``` 每个动作通过后,状态机会清空样本并推进到下一个动作。全部动作完成后,触发 `onProcessTips(20)`。在本文范围内,这个状态可以理解为动作检测链路已经完成。 动作完成后,在组合模式中可能进入其他活体层;这些后续层,尤其炫彩逻辑,留到第二篇展开。 ## 10. 结构性边界:动作活体验证呈现,不验证来源 回到动作活体本身。结合上面的代码路径,它的核心边界可以压缩成一句话: **它验证"呈现了动作",不验证"动作来自哪里"。** 动作状态机读取的是 ML Kit `Face` 的姿态角、眼睛概率、微笑概率和嘴唇轮廓等结果。只要输入画面在这些特征上呈现出符合规则的时间变化,动作状态机就能继续推进。 这不是某个项目独有的问题,而是动作活体这一层的结构性边界。 动作活体问的是: > 画面里的人脸有没有按要求动起来? 但更高强度的身份验证还需要追问: > 这段画面是否来自可信采集链路? > 当前帧是否真的来自预期采集路径? > 输入链路有没有异常中间层、重放迹象或采集环境不一致? > 单帧 / 多帧的纹理、成像、光照、深度、链路完整性是否互相一致? 如果只回答第一个问题,防线就停在"动作呈现"这一层。 注意,这里讨论的是防御边界,不是复现路径。公开分析应停在成因层:评分逻辑覆盖了动作呈现,没有覆盖来源可信。本文不提供任何攻击实现、复现步骤、参数、攻击视频或攻击样本。 ## 11. 动作活体解决了什么,又没有解决什么 动作活体比静态照片校验更强。 如果系统只检查"画面里有没有一张脸",那么打印照片、屏幕照片、低质量翻拍都可能进入检测范围。动作活体至少要求画面里的人脸随时间变化,并且完成系统指定动作。这会抬高低级呈现攻击的成本,也能过滤掉大量静态输入。 从防御演进角度看,动作活体解决的是上一层的一个问题:静态输入太容易伪装。 但这个进步仍然停留在"呈现层"。它能证明一段输入呈现了动作,不能单独证明输入来源可信。 这也是为什么后续会出现光照响应、红外 / 双目、深度、链路完整性等更多层:每一层都在补上一层没覆盖的问题,同时引入自己的新假设和新边界。 ## 12. 防御侧怎么加固动作活体 站在防御方,动作活体不该被废弃。它仍然能过滤低成本静态攻击,也能提供用户交互层的动态信号。 但它不应被当作唯一防线。 更稳的设计至少要把动作信号放进更大的验证框架里: 1. **动作只作为一类证据,不作为唯一结论。** 动作完成只能说明"输入呈现了动作",不能单独说明"来源可信"。 2. **增加来源与链路完整性校验。** 防御方需要关注输入是否来自预期采集链路,是否存在异常中间层、重放迹象或采集环境不一致。 3. **融合其他活体层和成像信号。** 动作层看的是动作序列,不应替代光照响应、成像一致性、深度或链路完整性等其他信号。 4. **把动作完成与整体验证通过拆开理解。** `onProcessTips(20)` 说明动作序列完成,但在组合模式里,它不等于所有活体层都已完成。集成方需要明确每个状态码对应哪一层。 5. **把评估从"能不能过"改成"哪一层在起作用"。** 真正有价值的 benchmark,不是简单给一个 SDK 贴好坏标签,而是拆出每一层的职责、证据、边界和失分原因。 这也是 DeltaSee 后续要持续做的事:不做攻击配方,而是建立一张活体检测防御强度地图。 ## 13. 小结:动作活体是第一层,不是最后一层 动作活体的价值很清楚:它让系统从"看见一张脸"升级到"看见一段动作"。 它的边界也同样清楚:它验证呈现,不验证来源。 这不是否定动作活体,而是给它放回正确的位置。防御方真正要问的不是"有没有活体",而是: > 这一层活体在验证什么? > 没验证什么? > 下一层应该补哪一个盲区? 第二篇进入炫彩 / 光照响应相关逻辑,讨论它试图补什么、又有什么边界。 活体检测的防御演进,就是这样一层一层长出来的。 --- 代码索引说明:文中的 `[F*]` / `[N1]` 标记来自作者对 FaceAISDK 公开代码流程与 native 人脸检测链路的整理,仅用于定位防御侧代码证据;本文不包含攻击实现、复现路径或攻击样本。
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
1
点赞
・
7
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
New对象处
谢谢你的细致分析,受益匪浅!
2026-7-30 16:59
东方玻璃
感谢你分享这么好的资源!
2026-7-18 20:41
wx_晨梦
期待更多优质内容的分享,论坛有你更精彩!
2026-7-16 06:22
欲拭泪
期待更多优质内容的分享,论坛有你更精彩!
2026-7-9 21:30
Yangser
期待更多优质内容的分享,论坛有你更精彩!
2026-7-9 14:53
wx_0.0_522
你的分享对大家帮助很大,非常感谢!
2026-7-9 14:53
wx_funcrever
感谢你的贡献,论坛因你而更加精彩!
2026-7-9 14:52
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
3
)
Yangser
雪 币:
1953
活跃值:
(4173)
能力值:
( LV4,RANK:50 )
在线值:
发帖
11
回帖
129
粉丝
217
关注
私信
Yangser
2
楼
严肃学习
2026-7-9 14:54
0
wx_插曲
雪 币:
411
活跃值:
(861)
能力值:
( LV2,RANK:10 )
在线值:
发帖
2
回帖
316
粉丝
24
关注
私信
wx_插曲
3
楼
严肃学习
2026-7-9 17:43
0
MsScotch
雪 币:
4184
活跃值:
(4916)
能力值:
( LV2,RANK:10 )
在线值:
发帖
3
回帖
394
粉丝
5
关注
私信
MsScotch
4
楼
严重学习
2026-7-18 21:09
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
deltasee
3
发帖
1
回帖
0
RANK
关注
私信
他的文章
[原创]看懂身份验证中的活体检测(三):静默活体检测
451
[原创]看懂身份验证中的活体检测(二):三色/炫彩/RGB检测
1388
[原创]看懂身份验证中的活体检测(一):动作活体
1730
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部