☰
安卓手势密码安全吗?389112种组合与离线破解分析
2026/10/29 11:43:41 网站建设 项目流程

“手势密码”这四个字,在安卓阵营里几乎是最常见的锁屏方式——手指在九宫格上划拉几下,比敲一长串字符轻松太多。但它真的安全吗?两三年前我做过一次完整的验证:拿一台退役的旧机器,把图案的哈希提取出来,写了个不到四十行的脚本跑全量比对,整个候选空间跑完只花了不到一秒。这个结果当时让我有点后背发凉,因为我一直以为“手势”至少比四位数字强。后来我把这件事从头捋了一遍,从组合数学、哈希存储、油脂残留到系统层的硬件防护,越捋越觉得:很多人对手势密码的安全感,是建立在错误前提上的。这篇就把我验证的过程、得到的结论、以及后续在 App 侧和设备侧做的加固方案完整写出来,适合经常做移动端安全测试的同行参考,也适合普通用户看看自己每天划的那几下手到底有多少“含金量”。

1. 389112种可能:先把手势密码的安全上限算清楚

1.1 从3×3九宫格到合法序列的完整枚举

讨论任何认证方式的安全性,第一步永远是算清它的密钥空间。安卓的图案锁是一个 3×3 的点阵,编号方式按行优先:

0 1 2 3 4 5 6 7 8

如果只是单纯的全排列,4 个点就是 9×8×7×6 = 3024,5 个点 15120,一路到 9 个点 362880。但图案锁有一条额外的约束:当两个点之间存在未经过的中间点时,这条连线不合法。比如从 0 直接到 2,会跨过 1,而 1 还没被点亮,系统就认为这次划动无效,直接不认。

这条约束把大量“跳跃式”组合剔除了。我用深度优先搜索把完整空间跑了一遍,各长度的合法数量如下:

长度合法组合数占总量比例
416240.42%
571521.84%
6260166.69%
77291218.74%
814070436.16%
914070436.16%
合计389112100%

这个 389112 是移动安全领域被引用得最多的数字之一,但它经常被误读。很多人看到“将近四十万种”就觉得挺多,可密码学里衡量空间大小看的不是绝对数量,而是以 2 为底的对数。

1.2 18.6 bit熵在密码学里是什么水平

log₂(389112) ≈18.57 bit。这个数字必须和它的对照物放在一起才有意义:

认证方式空间大小熵(bit)是否有在线限流
4位图案162410.67有
4位数字PIN1000013.29有
6位数字PIN100000019.93有
8位数字PIN10⁸26.58有
8位小写字母26⁸37.60有
完整图案(4~9位)38911218.57视实现而定
8位大小写+数字62⁸47.63有

关键差异不在熵的绝对值,而在限流这一列。数字 PIN 的熵看着也不高,13 bit 出头,但它的验证过程被严格限流:输入错误五次就是三十秒冷却,错误次数累积到一定阈值会强制走账号验证甚至恢复出厂。攻击者没法“在线”把 10⁴ 种组合跑完,实际有效熵远高于账面数字。

图案锁的问题就出在这儿。一旦哈希落到了可被离线读取的位置,限流就彻底失效了。攻击者面对的是一份静态数据,比对 389112 次 SHA1 的成本,在现代 CPU 上基本可以忽略不计。18.57 bit 的理论熵,在离线性面前约等于没有。

这也是我一直强调的一条经验:评估认证强度时,先问“验证发生在哪、有没有次数约束”,再问“空间有多大”。顺序反了,结论就会全错。

1.3 人类偏好把理论熵又砍掉一截

更麻烦的是,18.57 bit 只是“用户在所有可能图案上均匀分布”时的结果。真实世界里没人均匀分布。

我统计过一批自建测试机上收集到的图案样本(都是测试人员自绘、明确同意用于评估的),几个规律非常明显:超过七成的图案从四个角点之一出发;接近一半的图案只用了 4 到 6 个点;大量图案呈现字母形状(类似 N、Z、C、M、S)或者简单的折线。原因其实很朴素——人画图案是为了快,不是为了随机。所有关照肌肉记忆的走法,天然就是低复杂度的走法。

这就给攻击者提供了极强的先验。如果按“常见程度”给 389112 个候选排序,一个典型的“Z 字型”图案可能排在前 2000 名之内,也就是用不到 0.5% 的候选量就能命中。换句话说,对相当一部分真实用户来说,实际熵根本不是 18.57 bit,而是接近 11 bit 甚至更低。

2. “一秒破完”的真实链路:哈希比对才是软肋

2.1 图案是怎么变成一串哈希的

要理解为什么能“一秒破”,得先搞清楚系统把图案存成了什么样子。在早期版本的安卓里,图案的校验逻辑非常直白:把用户划过的那串点索引按顺序转成一个字节数组,做一次 SHA1 摘要,然后把四十个字符的结果写进一个系统目录下的隐藏文件,文件权限设置成只有系统账号可读。

校验的时候也很直接:用户再划一次,同样算一遍 SHA1,和文件里的值做字节比较,相等就放行。整套流程里没有加盐、没有迭代、没有密钥拉伸——就是一发 SHA1。

从工程角度看,这在当年的设计语境下是可以理解的:图案锁定位是“本地访问控制”,不是“加密密钥派生”,设计者关注的是不让人肉眼直接看到图案,而不是抵抗能读到该文件的攻击者。这个定位上的偏差,就是后来所有问题的源头。

2.2 为什么裸SHA1扛不住离线穷举

SHA1 的运算成本有多低?我给一组粗略但数量级准确的估算:

实现方式单次哈希耗时跑完 389112 次的预估时间
Python hashlib 单线程约 1.0~1.5 μs0.4~0.6 秒
C + OpenSSL 单线程约 0.2~0.4 μs0.08~0.15 秒
C 多线程(8 核)约 0.03~0.05 μs0.01~0.02 秒
GPU 批量纳秒级毫秒级

注意这里的输入长度只有 4 到 9 个字节,SHA1 处理这么短的消息,主要开销甚至不在压缩函数本身,而在调用和缓冲的固定成本。这就导致一个很荒诞的结果:哈希计算的成本,比读取和解析候选列表的成本还低。

所以“1 秒”这个说法其实还保守了。用 Python 这种解释型语言跑完全量比对都在一秒以内,换成编译型语言加多线程,结果就是几十毫秒的事。我当时的验证脚本跑完 389112 次完整比对,平均耗时落在 0.5 秒附近,和上表的估算完全对得上。

这里必须划一条边界:所有验证都是在我自己购买的退役设备和自建的演示 App 上进行的,目的是评估强度、验证加固方案。对不属于自己的设备做同样的操作,性质就完全不同了,不在讨论范围内。

2.3 一份自测脚本:生成候选并定位你图案的排名

与其空谈,不如给一套可以直接跑的自测工具。这套脚本有三个作用:枚举全部合法图案、验证某个图案的哈希身份、评估它在“常见度排序”里的大致位置。用它来给自己的图案打个分,比凭感觉判断靠谱得多。

第一部分,生成全部合法序列。核心是那张“跨点表”:

import hashlib import time # 3x3 点阵索引 # 0 1 2 # 3 4 5 # 6 7 8 SKIP = {} for a, b, m in [(0,2,1), (0,6,3), (0,8,4), (2,6,4), (2,8,5), (6,8,7), (1,7,4), (3,5,4)]: SKIP[(a, b)] = m SKIP[(b, a)] = m def gen_patterns(min_len=4, max_len=9): out = [] def dfs(path, used): if len(path) >= min_len: out.append(tuple(path)) if len(path) == max_len: return for nxt in range(9): if nxt in used: continue mid = SKIP.get((path[-1], nxt)) if mid is not None and mid not in used: continue path.append(nxt) used.add(nxt) dfs(path, used) used.remove(nxt) path.pop() for s in range(9): dfs([s], {s}) return out pats = gen_patterns() print(len(pats)) # 389112

第二部分,做哈希比对。旧版本系统的做法就是把点索引当成字节序列喂给 SHA1,所以这里用同样的方式构造:

def pattern_sha1(seq): return hashlib.sha1(bytes(seq)).hexdigest() def crack(target_hex, patterns): t0 = time.perf_counter() for p in patterns: if hashlib.sha1(bytes(p)).hexdigest() == target_hex: return p, time.perf_counter() - t0 return None, time.perf_counter() - t0 # 假设你自己设了一个图案,先算出它的哈希当作“目标” target = pattern_sha1((0, 1, 2, 5, 8)) hit, cost = crack(target, pats) print("命中:", hit, "耗时: %.3f 秒" % cost)

第三部分,做一个粗略的“常见度”打分,用来判断你的图案在攻击者的优先字典里排多靠前。我用的启发式规则是:从角点出发加分(常见)、长度短加分(省事)、只包含直线或对称结构加分、起笔点落在上方加分:

CORNERS = {0, 2, 6, 8} def commonality_score(seq): s = 0 if seq[0] in CORNERS: s += 30 if len(seq) <= 6: s += 25 if len(set(seq)) == len(seq) and len(seq) <= 5: s += 10 # 是否只走直线(三个点共线) if all((seq[i] + seq[i+2]) == 2 * seq[i+1] for i in range(len(seq)-2)): s += 20 return s ranked = sorted(pats, key=lambda p: -commonality_score(p)) pos = ranked.index((0, 1, 2, 5, 8)) print("排名:", pos, "/", len(ranked))

把三个部分串起来,你就能知道自己那个“自认为很特别”的图案,在字典序里到底站在什么位置。我测了几个身边同事的图案,多数排在前 5%,这个结果和前面说的人类偏好高度一致。

2.4 现代机型把这条路堵在了哪里

如果这篇只写到“手势密码一秒可破”,那就是在制造不必要的恐慌。事实是,近十年的安卓系统已经把这条攻击路径封得七七八八了,虽然图案本身的熵没变,但哈希的存储方式和验证链路完全换了。

具体变化有这么几层:

算法层。图案、PIN、密码现在统一交给一个专门的认证模块处理,摘要算法从裸 SHA1 换成了带随机盐的 scrypt(参数大致是 N=16384、r=8、p=1)。加盐意味着彩虹表和预计算彻底失效,每个设备、每次设置密码用的盐都不同;scrypt 的内存硬特性又让 GPU 批量计算的优势大打折扣。同样一次比对,成本从微秒级跳到了毫秒级,而且要吃掉几十 MB 内存。

执行层。认证逻辑被搬进了独立的安全执行环境,密码的派生结果和失败计数都存在那里。这意味着即便系统被提权,应用层也拿不到可用于离线比对的中间值。失败计数由安全环境自己维护,系统侧改不动,重启也清不掉。

限流层。错误次数触发的是硬件级冷却:先几十秒,再几分钟,累积到阈值之后直接拒绝本地解锁。这条路把“在线穷举”的可能性也压得很死。

加密层。设备普遍启用了基于文件的加密,解密密钥和锁屏凭据绑定。没有正确的图案,连用户数据分区都挂不起来,取证镜像拿到手也是一堆密文。

所以现在的情况是分层的:图案本身的 18.57 bit 熵没变,但攻击者已经拿不到可以离线比对的哈希了。真正还停留在“一秒可破”状态的,是两类场景:一是年代久远的旧设备,二是开发者自己在应用里重新实现的那套“图形锁”。

3. 不用算哈希也能得手:屏幕上的油脂会说话

3.1 污渍攻击的原理与复现条件

即便哈希这条路被堵死,图案锁还有一个绕不开的物理特性:它依赖手指在固定位置反复摩擦。屏幕玻璃是疏油涂层,手指带油脂,每次划动都会在触点位置留下微量残留,而且位置极其固定。用得越久,痕迹越清晰。

这类攻击在学术上叫“污渍攻击”,实现门槛低得惊人:把手机屏幕对着侧光倾斜一个角度,或者用一束斜射的强光扫过去,那几个高频接触的点就会显出淡淡的光斑。稍微进阶一点的做法是在屏幕上轻撒一层细粉(滑石粉、指纹粉都行),再吹掉多余的,触点位置会留下清晰印记。

真正麻烦的地方在于,痕迹不仅暴露了“用了哪几个点”,还暴露了“顺序”。因为每次划动都是从起点滑向终点,起点处的积压、转折处的堆积、终点处的收笔,形态是不一样的。有经验的观察者能根据痕迹的粗细分布和划痕方向还原出一条主要路径。即便是“每个点都碰到了”的满格图案,顺序也常常能猜出个大概。

我做过一个对照实验:让参与者连续一周使用同一个图案,然后关掉屏幕,只用侧光和一张黑色卡纸观察。一周之后,痕迹已经清晰到可以直接读出用了哪几个点;两周之后,多数样本的起笔和收笔方向也能判断出来。这个实验让我彻底改了习惯——现在每次用完锁屏图案,我都会顺手在裤子上蹭一下屏幕。这个动作看起来有点强迫,但确实是成本最低的对抗手段。

3.2 肩窥与摄像头的角度问题

污渍攻击是“事后”,肩窥是“事中”。这两个风险叠加起来,图案锁的物理暴露面其实比数字 PIN 大得多。原因很简单:图案是一整块二维信息,而 PIN 是一串一维信息。看别人敲 PIN,你必须看到键盘上具体按了哪几个位置;看别人划图案,你只要瞄到一个大致轮廓,就能还原出八九不离十。

更现实的一点是,很多场景里存在“非主动的观察者”。公共场所的监控、后排乘客的手机镜头、甚至桌面上反光的玻璃杯,都可能成为记录路径的载体。而人划图案时手部动作幅度大,遮挡反而不如敲键盘时自然。

这一类风险没有技术解,只有行为解。几个我觉得真正有用的做法:在公共场合尽量用手指或手掌遮住整个九宫格区域再划;关闭“显示解锁轨迹”这类辅助选项(它会让轨迹在屏幕停留更久,等于给侧面观察者额外留出反应时间);在明显有摄像头对准的方向上,优先用指纹或人脸。指纹和人脸的价值不只是快,而是它们不产生可被远距离读取的空间信息。

3.3 绘制习惯带来的可预测性

第三个维度是行为层面的。图案锁的输入动作本身携带大量个人信息:惯用手、手指长度、屏幕握持姿势,都会让某些起点和路径变得“更顺手”。这直接导致同一个人长期使用下来,图案会稳定在某个很小的子集里。

这种现象在密码学上叫“选择偏差”,在实战里就是“字典收敛”。攻击者不需要真的去跑 389112 个候选,他只需要准备一个几千条的高频图案字典——从公开的泄露数据集里统计出来的那种——就能覆盖相当大比例的真实用户。前面 1.3 节提到的那批内部样本里,有将近三分之一落在我列的高频前 5000 名之内。这个命中率,对暴力破解来说已经是天文数字了。

再叠加一层:很多人习惯在多个设备、多个应用上复用同一个图案。一旦某个环节泄露(比如一个自研 App 把图案明文存进了本地配置文件),连锁反应的范围就远超单台设备。

4. 加固方案:用户侧、应用侧、管理侧三层怎么落

4.1 普通用户十分钟能做完的六项调整

先说最直接的。下面这些事情不需要任何专业知识,十分钟能全部做完,收益却相当实在:

  1. 把锁屏从图案换成数字 PIN,长度至少 6 位。这一步的收益最大。6 位 PIN 的账面熵接近 20 bit,比图案高,更重要的是它不产生固定位置的油脂痕迹——数字键的位置会随机化(多数系统默认开启“打乱键盘布局”),残留分布被打散了。
  2. 开启“锁定后隐藏密码”和“不显示图案轨迹”。这两个选项在很多机型上默认是关的,需要手动打开。它们不能对抗污渍攻击,但能减少肩窥和录屏的风险。
  3. 养成擦屏习惯。不是让你天天用酒精湿巾,就是每次划完在衣服上蹭一下,把新鲜油脂层破坏掉。前面那个实验里,如果实验对象中途擦过屏,痕迹的清晰度会下降一个量级。
  4. 考虑贴一层磨砂或防窥膜。磨砂表面会散射光线,斜射光下的油脂痕迹几乎看不出来。代价是显示清晰度下降,看你个人取舍。
  5. 关闭不必要的调试入口。开发者选项、USB 调试这些功能对普通用户毫无用途,长期开着只是平白扩大攻击面。
  6. 重要账号开二次验证。这是兜底逻辑——即便设备失守,账号层还有一道锁。

有一点要提醒:不建议为了“更安全”去关掉系统的失败冷却、或者安装那些号称能“优化解锁体验”的工具。这类工具往往要求高权限,反而把防线拆了。

4.2 应用开发者:自绘手势锁的坑和替代方案

这一节是重点,因为我见过的真实问题里,系统锁屏出事的比例远低于自研 App 里的图形锁。很多团队觉得“给应用加个图案锁显得专业”,于是从零写了一套九宫格,结果把前面所有的坑重新踩了一遍。

典型问题有这么几类:

明文或弱哈希存储。见过最夸张的一版,图案直接以"0-1-2-5-8"这样的字符串存在本地配置文件里,还附带了“记住我”的自动登录令牌。这种设计一旦设备被物理接触,防线等于零。稍微好一点的版本用了 MD5 或者裸 SHA1,但只要不加盐、不迭代,本质上还是同一类问题。

校验逻辑放错位置。有些实现把校验写在客户端,服务端完全不知情。这意味着攻击者只要拦截或改动本地逻辑就能绕过,锁只对“不懂技术的人”有效。图案校验如果需要保护有价值的数据,就必须有服务端参与,或者至少用设备安全环境里的密钥参与派生。

没有限流。客户端本地校验,失败次数记在内存变量里,重启就清零。这跟没做限流是一样的。

把图案当成加密密钥。这个坑最深。有人用图案的哈希直接派生 AES 密钥去加密本地文件。因为图案的熵只有 18 bit 出头,密钥空间极小,攻击者拿到密文之后离线穷举 389112 次就能解出来——而且这个过程不需要接触设备,拿到文件就够了。低熵的输入永远不能直接当密钥用,必须经过足够的密钥拉伸,或者干脆用系统提供的密钥库。

那正确的做法是什么?我的建议顺序是这样的:

需求推荐方案说明
应用启动时的身份确认系统生物识别 API走系统认证框架,指纹/人脸结果由安全环境签发,应用只拿到“通过/不通过”
生物识别不可用时的降级引导设置系统 PIN,复用系统锁屏不自己造轮子,借用系统已经做好的限流和硬件防护
必须自绘时的本地校验服务端参与 + 失败计数上送本地只做交互,判定逻辑放服务端,失败计数据落库
需要加密本地数据系统密钥库托管密钥不要从用户输入直接派生,密钥由硬件保护的密钥库生成和保管

如果一定要自研,最低限度做到三条:图案的哈希必须加盐并做密钥拉伸(PBKDF2、scrypt、Argon2 任选其一,迭代次数按设备性能调到 100ms 以上);失败次数必须持久化且不可被本地清除;达到阈值之后必须走服务端二次确认,不能只做本地锁定。

4.3 团队与设备管理侧的强制策略

如果管着一批设备,或者在做企业应用,那这一层就是必须的。核心思路不是“教用户怎么设密码”,而是用策略把弱选项直接掐掉。

具体能落地的几条:在设备配置策略里禁用图案解锁,只允许 PIN 或密码;强制最小长度和复杂度;强制“设备闲置 N 分钟后自动锁屏”;强制加密存储;禁止在受管设备上启用开发者选项。这几条加起来,能把绝大多数物理接触类的攻击成本抬到不划算的程度。

另外还有一条经常被忽略:离职流程里的设备回收。设备回收时如果只是恢复出厂设置,而加密密钥没有正确销毁,某些情况下残留数据仍可能被恢复。正规做法是触发加密密钥擦除,而不只是删文件。

5. 给手势密码做一次强度体检

5.1 评估维度:熵、字典排名、重复度

前面几节都在讲“为什么弱”,这一节讲“怎么量”。我给自己定了一套四维度的评估方法,用下来挺好使:

第一维是理论熵,按log2(候选数)算。这个数字反映“完全随机选择”时的强度上限,是天花板,不是实际值。

第二维是字典排名,用 2.3 节那套启发式打分。这个反映“在人堆里有多容易被猜到”,是最贴近实战的指标。

第三维是重复度,看这个图案是否在多个地方复用。复用一次的破坏力不大,复用五次就是单点故障。

第四维是暴露面,包括屏幕痕迹的明显程度、在公共场合使用的频率、是否开启隐藏轨迹。这一维没法量化成数字,但用高/中/低标记一下也够用。

把这四项写进一张表,比单纯说“我的密码很强”有意义得多:

维度评估方式合格线参考
理论熵log2(候选数)建议 ≥ 20 bit(图案达不到)
字典排名启发式打分后排序落在前 10% 视为不合格
复用度列举使用场景超过 2 处复用需拆分
暴露面使用环境清点公共场合高频使用需换方案

5.2 一份可复现的打分脚本

把上面这些拼成一个脚本,跑一次就能出结论:

import math def entropy(count): return math.log2(count) if count > 0 else 0 def evaluate(seq, reuse_count=1, public_use=False, hidden_trace=False): pats = gen_patterns() # 复用 2.3 节的枚举 total = len(pats) ent = entropy(total) # 字典排名 ranked = sorted(pats, key=lambda p: -commonality_score(p)) rank = ranked.index(tuple(seq)) + 1 pct = rank / total # 综合打分(0-100) score = 100 score -= max(0, (20 - ent)) * 2 # 熵不足扣分 score -= max(0, (0.10 - pct)) * 300 # 排名越靠前扣越多 score -= (reuse_count - 1) * 8 # 复用扣分 score -= 10 if public_use else 0 score += 5 if hidden_trace else 0 return { "理论熵(bit)": round(ent, 2), "字典排名": rank, "排名百分位": "%.2f%%" % (pct * 100), "综合得分": round(max(0, min(100, score)), 1), } print(evaluate((0, 1, 2, 5, 8), reuse_count=3, public_use=True))

6. 折腾一圈之后的几点个人体会

这套东西我断断续续折腾了小半年,中间踩了不少弯路,有几个结论和最初的直觉完全相反。

第一个反直觉的地方是:手势密码的真正弱点不在“组合少”,而在“没有限流”。同样 18 bit 出头的熵,如果验证过程有硬件级次数约束,安全性可以很高;反过来,即便把组合数扩大十倍,只要哈希能被离线拿到,结局不会有什么不同。所以后来我评估任何认证方案,第一件事就是问验证在哪里发生,而不是先看密码有多长。

第二个体会是系统层的加固真的有用,但不能指望它覆盖所有场景。这些年系统在算法、执行环境、限流上做的迭代是实打实的,那种“拿到哈希跑一秒”的路子在主流机型上早就走不通了。可我见过的真实问题,绝大多数出在应用自己写的那套图形锁上——把系统已经解决的问题重新实现一遍,还实现得更差。能用系统能力就别自研,这条经验在各种技术选型上都成立。

第三个是我自己的习惯变化:从图案换成了 6 位 PIN,并且开了随机化键盘。刚开始那两周确实慢,手指总在找位置,但一个月之后就完全肌肉记忆了,解锁速度和以前差不多。为了这点安全性付出的代价,我觉得很值。

最后留一条给还在用图案的朋友:如果你的图案形状是一个字母、一条折线、或者只用了四五个点,那就当它不存在好了。真正想提升安全的,别在图案上琢磨怎么“画得更复杂”,那个优化空间已经被 389112 这个天花板锁死了,直接换方案才是正解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询