简介:面向Python2.x学习者与安卓安全爱好者的手势密码破解工具,演示了从已root设备中导出系统密钥文件,并通过SHA1哈希比对还原九宫格解锁路径的完整过程。示例数据中给出了加密后的哈希值、最终解锁密码与耗时,方便对照验证,例如某个加密串对应的解锁路径为“00-01-02-04-06-03-05-08-07”,单次破解不足五秒。整个资源共12个文件,以Python主脚本和Markdown说明文档为核心,附有示例密钥文件、手势示意图,再加上若干IDE项目配置文件,压缩包整体仅61KB,轻量且结构清晰。目前已有1221人浏览学习,适合希望动手实践安卓逆向或Python算法应用的读者。通过阅读主脚本和说明文档,可掌握密钥文件解析、哈希碰撞枚举与九宫格路径生成思路,并能直接运行脚本验证自己的数据,或在此基础上扩展成图形化破解工具。
1. 破解 Android 锁屏手势密码,真正的成本在拿到那个 20 字节的文件
很多人以为破解 Android 锁屏手势密码需要跟屏幕上的九宫格较劲,或者写一个自动化测试工具在真机上反复试。实际上反直觉的点在于:系统根本没打算把手势明文存起来,但它的存储方式又给离线破解留了一扇门——当你调试过的设备、找回的旧手机、或者自己留下的备份里存在/data/system/gesture.key时,你的手势已经被变成了一段固定长度的哈希。你只需要拿到这个文件,用 Python 把所有合法轨迹全部算一遍,做哈希比对,剩下的就是几秒钟到几分钟的事。
我最初接触这个方案,是为了帮 A 同学从一台忘记锁屏密码的旧测试机上把相册捞回来。当时最大的启发是:整个项目里没有任何暴力输屏的操作,纯离线枚举就够用。它也适合安全评估人员做设备密码强度测试,或者取证场景下对镜像文件做密码恢复。当然,前提是这台设备属于你,或者你已被明确授权。
2. gesture.key 里存的是什么:点编号、序列化与哈希约定
2.1 3×3 网格的 9 个点如何映射成一段编号序列
Android 的手势锁屏界面是一个 3×3 的点阵,系统给每个点分配了一个编号。编号规则按坐标从左到右、从上到下排列:第一行是 0、1、2,第二行是 3、4、5,第三行是 6、7、8。你在屏幕上画出的每一个点,最终都会按你手指经过的先后顺序变成一个由这些编号拼接而成的字符串,比如先点左上角,再点右上角,再点中间,就得到"021"。
这个编号顺序很容易被忽略,但它直接决定了枚举结果是否有效。网上有些工具把第一行写成 1、2、3,第二行写成 4、5、6,最后生成的轨迹在你自己设备上永远对不上。所以拿到手的第一步应该是确认设备商有没有做特殊映射。最常见做法是用系统默认的 AOSP 方案,也就是row * 3 + col这类线性编号映射。你在代码里也要严格按这个顺序来。
接下来是这个字符串如何变成文件里的二进制内容。Android 早期实现里,系统拿到"021"这样的串之后,会对它的字节做一次哈希运算,结果写入/data/system/gesture.key。文件名很直白,内容就是哈希摘要。大多数设备上这个文件是 20 字节,对应 SHA-1;少数老设备或定制 ROM 可能是 16 字节,对应 MD5。这里没有加密,没有加盐,纯摘要,所以拿到文件后只要枚举所有轨迹并哈希,就能做精确匹配。
你可以先用下面命令确认文件的基本信息:
xxd /data/system/gesture.key # 如果输出 20 个字节,比如 5a 8a 1c ...,说明是 SHA-1 # 如果输出 16 个字节,说明设备用 MD5注意:这里说的“没有加盐”是大多数 AOSP 方案的默认行为。定制 ROM 完全可以加盐或换算法,所以拿到文件第一步是观察长度,而不是先跑枚举。
2.2 把 gesture.key 抠出来:三种常见访问路径
既然核心是离线比对,第一步就是把文件从设备里复制出来。这里有个绕不开的前提:/data/system/属于系统数据目录,普通 app 无权读取,shell 用户没 root 也读不了。常见的做法有三种,按成功率和晋级路径排序。
第一种,设备已经 root,那么直接通过 adb 用 root 权限导出,最省事:
adb exec-out su -c "cat /data/system/gesture.key" > gesture.key这里有两个细节需要说明。第一,用exec-out而不是adb shell,因为adb shell在 Windows 上会把输出里的换行符做转换,破坏二进制内容;exec-out会让输出按原始字节流透传。第二,su -c后面的命令要用双引号包住,避免 shell 把空格拆成多个参数。执行完检查文件大小,SHA-1 就是 20 字节,MD5 就是 16 字节,多了少了都有问题。
第二种,设备没有 root 但能进 recovery,常见于你现在还能控制引导流程的情况。进入第三方 recovery 后,挂载 data 分区,再用 adb pull 取文件。具体挂载路径在 AOSP 系和部分定制 ROM 上有差异,但通常都在/data/system/gesture.key,命令也很直接:
adb pull /data/system/gesture.key ./gesture.key第三种,你手里拿到的不是真机,而是之前做的数据分区镜像,比如userdata.img。这种情况不需要 adb,直接用工具把镜像挂载到本地,再从挂载目录里拷贝文件:
# 假设 userdata.img 是 ext4 镜像 sudo mount -o loop userdata.img /mnt/android_data cp /mnt/android_data/system/gesture.key .这条路径特别适合取证:镜像文件本身不会变,你可以在不接触设备的情况下反复试验,失败了也不会触发设备的锁屏失败策略。我的习惯是,能走镜像尽量走镜像,因为它给你保留了最完整的现场,不会因为一次错误尝试导致设备进入锁死状态。
3. 用 Python 枚举合法轨迹:生成、哈希、并行比对
3.1 先写轨迹生成器:把“不能跳过未访问点”变成一条边规则
拿到gesture.key后,剩下的核心就是一个生成器问题:把所有长度 4 到 9 的合法轨迹都生成出来。为什么从 4 开始?因为 Android 系统本身不允许设置少于 4 个点的图案,少于 4 的轨迹在用户设置阶段就会被拒绝。
生成轨迹最大的坑是“跨点规则”。从点 0 直接滑到点 2,系统其实会经过中间的点 1,并把 1 也记录进轨迹;如果点 1 还没有被访问过,这次滑动就不合法,系统会提示你“不能跳过未连接的点”。反过来,如果点 1 已经被访问过,你从 0 滑到 2 是可以的,因为中间点已经被点亮。这个规则不写进代码,你的候选轨迹集合会膨胀很多,而且那些看起来能匹配的序列在真机上根本画不出来。
我通常用一张小小的预计算表来处理点之间的必经点,然后从每个起点做深度优先遍历:
# 点编号到坐标,用于判断两点之间是否存在“必经中间点” COORDS = { 0: (0, 0), 1: (1, 0), 2: (2, 0), 3: (0, 1), 4: (1, 1), 5: (2, 1), 6: (0, 2), 7: (1, 2), 8: (2, 2), } # 记录 from -> to 之间必须经过的中间点,没有则为 None MID = {} def build_mid_table(): for f in range(9): for t in range(9): if f == t: continue x1, y1 = COORDS[f] x2, y2 = COORDS[t] mids = [] # 遍历两个端点之间的整数坐标点 for x in range(0, 3): for y in range(0, 3): if (x, y) == COORDS[f] or (x, y) == COORDS[t]: continue # 判断该点是否恰好落在线段 f-t 上 if (x - x1) * (y2 - y1) == (y - y1) * (x2 - x1): if min(x1, x2) <= x <= max(x1, x2) and min(y1, y2) <= y <= max(y1, y2): mids.append(x * 3 + y) MID[(f, t)] = mids[0] if len(mids) == 1 else None build_mid_table() def gen_all(min_len=4, max_len=9): res = [] for start in range(9): stack = [(start, [start], {start})] while stack: cur, path, visited = stack.pop() if min_len <= len(path) <= max_len: res.append(path) if len(path) == max_len: continue for nxt in range(9): if nxt in visited: continue mid = MID[(cur, nxt)] # 中间点存在且未被访问,说明这条边非法 if mid is not None and mid not in visited: continue stack.append((nxt, path + [nxt], visited | {nxt})) return res这个生成器的关键在于build_mid_table()。它把所有可能的点对都算了一遍“直线是否经过第三个格点”,从而把跨点规则变成边级约束。深度优先搜索每次只维护当前路径和已访问集合,不会生成重复轨迹。合法轨迹的总数不多,长度 4 到 9 全部加起来大约 38.9 万个,内存里放得下,几分钟就能跑完。
3.2 哈希比对与多进程加速
生成器就绪后,比对逻辑非常简单。把轨迹序列转成字符串,再做哈希,和gesture.key里的字节数组做比对:
import hashlib def match_path(data: bytes, alg: str, target=[]): for path in gen_all(): seq = ''.join(map(str, path)).encode() digest = hashlib.new(alg, seq).digest() if digest == data: return path return None # 根据文件长度选择算法:20 字节 SHA-1,16 字节 MD5 with open('gesture.key', 'rb') as f: raw = f.read() alg = 'sha1' if len(raw) == 20 else 'md5' recovered = match_path(raw, alg) print(recovered)这里有一个常见性能误区:如果你把生成器展开成 list 再去哈希,理论上可行,但gen_all()本身是纯 Python 循环,在单核上跑完整枚举大约需要几秒到几十秒。真正的瓶颈不是哈希,而是 Python 解释器在逐条生成和比较时的速度。常见做法是把候选路径分片后交给多个进程去处理,而不是用多线程——hashlib计算时会持有 GIL,线程加速基本无效。
分片并行可以这样写:
import multiprocessing as mp def worker(args): chunk, data, alg = args for path in chunk: seq = ''.join(map(str, path)).encode() if hashlib.new(alg, seq).digest() == data: return path return None def parallel_match(data, paths, workers=4): part = len(paths) // workers chunks = [paths[i*part:(i+1)*part] for i in range(workers - 1)] chunks.append(paths[(workers-1)*part:]) with mp.Pool(workers) as pool: for ret in pool.imap_unordered(worker, [(c, data, alg) for c in chunks]): if ret: return ret return Noneimap_unordered是这类任务里比map更合理的选择:它不要求所有任务按顺序返回,某一个 worker 先命中了就直接结束外层循环。这里每台机器的 CPU 核心数决定 workers 的取值,不是越大越好,超过物理核心数反而会因为上下文切换损失吞吐量。一般取 4 到 8 就够。
4. 先验排序:用常见手势字典把命中时间从秒级再压缩
4.1 建立你的常用手势库:形状、起点与对称性
全枚举已经能覆盖所有合法轨迹,但如果你手里有大量设备要处理,或者设备厂商对文件做了某种速度限制,行为习惯上先把“人更可能画什么”这层筛选出来会更舒服。所谓常见手势库,其实不是某个现成的付费字典,而是从经验里沉淀出的先验:人倾向于画对称图形,倾向于把 8 个外围点都包进去,倾向于从左上角或者右上角起笔。
我常用的一个最小集合大概长这样,你可以按自己的测试样本不断增补:
COMMON_PATTERNS = [ [0, 1, 2, 5, 8, 7, 6, 3], # 全包围 [0, 3, 6, 7, 8, 5, 2], # 左侧竖线加底部横线 [0, 4, 8, 5, 2], # 斜线入底 [2, 5, 8, 7, 6, 3, 0], # 从右上角起笔的包围 [0, 1, 2, 4, 6, 7, 8], # 梳子形 [6, 3, 0, 4, 8, 5, 2], # 反 L ]这个库的意义不在于它有多全,而在于它帮你把匹配顺序调整成“先查人最容易设置的偏好,再查冷门轨迹”。对于手机厂商预置的演示图案,或者出厂测试时留下的固定手势,这类字典经常一击命中。
4.2 把字典优先嵌进枚举顺序:留好 fallback 参数
实现上不要单独写两套逻辑,直接在入口处做一个开关就行。先跑字典,再跑全枚举,并且允许调用方指定起点,比如用户只记得图案是从右上角开始的,那就可以把[0,3,6...]这一类左下角起手的候选砍掉:
def recover_with_hints(data, *, start_points=None, dict_first=True): candidates = [] if dict_first: candidates += COMMON_PATTERNS all_paths = gen_all(min_len=4, max_len=9) if start_points is not None: all_paths = [p for p in all_paths if p[0] in start_points] candidates += all_paths for path in candidates: seq = ''.join(map(str, path)).encode() digest = hashlib.sha1(seq).digest() if digest == data: return path return None两个参数值得说明。dict_first默认打开,让常见图案优先进去比较,一旦命中就能立刻结束;start_points则是给那些记得一部分手感的用户准备的,限制候选集合后,全枚举时间按数量级下降。注意这里的all_paths仍然是在内存里展开的列表,如果设备数量很多,你可以把gen_all()改成生成器,或者按起点分片放进parallel_match。
这一整套排序逻辑加进去之后,大多数常见图案在几十毫秒内就会命中,真正需要跑到全枚举的情况反而少见。能让用户体验从“等待几秒”变成“瞬间出结果”,这在批量测试场景里非常重要。
5. 排查避坑:手势恢复跑不通的五个典型场景
5.1 Windows 下 pull 出来的 gesture.key 永远是坏的
现象:用adb shell cat /data/system/gesture.key > gesture.key导出的文件,大小变成 21 或 22 字节,枚举怎么跑都匹配不上。
原因:Windows 的终端层在输出重定向时会把二进制里的\n当成换行符做转换,插入了额外的\r,导致摘要内容被污染。这类问题在 Linux 和 macOS 下通常不存在,但在 Windows 上非常常见。
解决:统一使用adb exec-out,它设计出来就是为了透传原始二进制,不做任何字符集转换:
adb exec-out su -c "cat /data/system/gesture.key" > gesture.key执行后立刻检查文件大小,SHA-1 必须是 20 字节,MD5 必须是 16 字节。多一个字节都说明导出过程出了文章。
5.2 全枚举不匹配:多半是“跳过点”规则没写对
现象:枚举程序跑完了所有候选,一个匹配都没有,但文件大小和哈希算法明明都对。
原因:我见过最多的场景是,有的人把轨迹生成写成了“所有排列组合”,完全忽略了 Android 的跨点约束。比如用户实际画出的轨迹是从 0 到 2,但中间经过点 1,系统记录的是0, 1, 2;如果你允许从 0 直接跳到 2,并把它作为一条候选去哈希,那它就是一条永远不可能被系统记录的幻影轨迹。
解决:严格实现“两点连线若经过未访问中间点,则该边非法”的规则,就是我前面MID表做的事情。一个简单的验证方法是:在生成器里把0 -> 2这类跨点边去掉,再确认生成总数接近 38.9 万这个量级。如果数量明显多出很多,说明规则没生效。
5.3 文件长度既不是 20 也不是 16
现象:读到的gesture.key有 32 字节、40 字节,或者干脆是文本格式。
原因:厂商定制 ROM 对存储方式做了改造。有的改成了 SHA-256,有的在摘要后面附加了设备 ID,有的把文件挪到了别的路径下,比如/data/system/users/0/gesture.key,你读到的其实是残留的默认模板。
解决:不要硬套 SHA-1/MD5。先用xxd观察文件内容,如果明显是十六进制文本,尝试解码后与哈希结果比对;如果是 32 字节,优先考虑 SHA-256。这类定制度太高的设备,我会直接放弃哈希路线,改用清除文件或厂商工具的方案,因为逆向它的自定义格式投入产出比太低。
5.4 哈希字符串和二进制数组来回转换,比对永远“差一点”
现象:自己把哈希输出成了十六进制字符串,文件里的字节也是十六进制字符串,两边看着一样,但digest == data永远返回 False。
原因:hashlib.new(alg, seq).hexdigest()返回的是字符串,文件里存的是原始字节,你拿hexdigest()和raw比,当然永远匹配不上。
解决:统一用.digest()产生二进制摘要,和文件内容保持同一种类型。不要在中间环节做进制转换,一旦做了一次,后面就得做第二次,很容易在某个环节丢失字节:
# 正确 digest = hashlib.sha1(seq.encode()).digest() if digest == raw: ... # 错误示范,不要这样做 digest_str = hashlib.sha1(seq.encode()).hexdigest() if digest_str.encode() == raw: ...5.5 匹配到序列了,但重新画一遍还是进不去
现象:程序返回了一个轨迹,比如[0, 4, 8, 5, 2],但你在解锁界面照画,手机提示错误。
原因:最常见的是你枚举出来的轨迹把“自动添加的中间点”当成了显式轨迹。比如用户实际手指从 0 滑到 8,系统自动把 4 加进了轨迹,最后记录是0, 4, 8;但你的生成器可能又生成了一条0, 4, 8,与前者一致,这没问题。问题往往出在起始方向或者旋转后的等价图案上——屏幕是 3×3,但用户可能把手机倒过来画,方向反了。
解决:对程序返回的轨迹做一次“方向镜像”校验。把所有点的编号做水平镜像映射,比如 0 变成 2、3 变成 5、6 变成 8,再生成一组候选去比对。如果你的程序没有做这类等价变换,那就手动把命中的轨迹在脑子里镜像一下再试画。
6. 验证与收尾:怎么确认这段轨迹就是你要的那一个
破解出轨迹之后,离真正解锁还差半步:你需要确认它不是一次碰巧的哈希碰撞。别笑,哈希碰撞在 39 万个候选里概率极低,但更常见的问题是设备记录的手势序列和你找出来的序列存在“等价但显示不同”的情况,比如 0-3-6 和 6-3-0 是完全相反的绘画方向,但哈希值不同。如果你拿到的结果是倒序,就在代码里加一个反向检查:
def check_reversed(data, path): fwd = ''.join(map(str, path)) rev = ''.join(map(str, reversed(path))) for seq in (fwd, rev): digest = hashlib.sha1(seq.encode()).digest() if digest == data: return seq return None这段代码的思路是:同一个图案,从起点往终点画和从终点往起点画,是两种不同的手势,但用户记忆里可能是同一个图形。如果你只按正向枚举,可能漏掉反向的那一半。这个验证函数可以放在主流程之后,专门用来处理“为什么程序命中了但设备不认”的隐性场景。
当你靠哈希比对找回了密码,还有一件值得做的事:评估这台设备的密码强度。你可以把自己找出来的轨迹扔进刚才的枚举器,看它出现在第几个位置。如果它在字典前十条里,说明这个密码在离线攻击面前几乎没有防护力;如果它在全枚举的最后百分之一里,那说明用户设置了一个还算冷门的图案。这一信息对于安全评估报告非常有用,比单纯说“复杂度够不够”更有说服力。
我自己的固定做法是:每次处理完一台设备,会把它的gesture.key文件大小、哈希算法、命中位置三个信息记录到一个文本表格里,作为后续判断“该品牌是否改过存储格式”的样本。一段时间之后,你会形成一种直觉:看到某个文件长度是 32 字节,就知道要换思路了,而不是继续在标准枚举器上浪费时间。
最后说一个小习惯。早期我帮人恢复过一次设备,枚举程序跑了一个多小时,最后发现是导出的文件路径不对,那个gesture.key根本不是目标用户设置的锁屏文件。从那以后,我每次拿到文件都会先xxd看一眼内容,再顺手记录文件的修改时间。这个习惯帮我避开了很多次“枚举一切”却拿不到结果的尴尬场面。希望帮到你。
本文还有配套的精品资源,点击获取