用Python按用户分组筛选密码猜测列表的前N条候选
在安全审计和相关数据清洗任务里密码猜测列表是绕不开的东西。所谓密码猜测列表就是从字典、公开样本或者内部弱口令扫描结果中整理出来的“用户名候选密码”集合同一个用户往往对应几十甚至上百条候选不同用户之间的候选数量差异也很夸张。最近我在处理一个模拟项目X时遇到了一个很典型的诉求从一个包含大量候选密码的输入文件里按用户把候选密码归组每个用户只保留前一定数量的密码——比如前5条或者前20%——再统一写到输出文件。把这套逻辑写成Python程序文件之后原先手工要折腾一晚上的工作现在一条命令就完成了。这篇内容会从需求拆解、数据结构、排序规则、完整代码、参数计算、排错经验到后续扩展完整讲清楚这个程序是怎么写出来的。代码只依赖Python标准库没有任何第三方依赖任何一台装了Python的机器都能直接跑。适合需要处理“分组取前N条”这类问题的安全测试、后台开发、运维和数据清洗同学参考如果你只是第一次接触密码猜测列表也没关系文章会把格式和边界条件讲得很细。1. 先拆需求输入文件格式与参数设计1.1 最常见的输入形态与字段边界密码猜测列表最常见的样子不是Excel表格而是一行一行的纯文本。每行至少包含两个信息用户标识和候选密码。我看到过的格式大致有以下几种冒号分隔alice:Pssw0rd2024逗号分隔alice,Pssw0rd2024空格或Tab分隔alice Pssw0rd2024少数自定义格式甚至在每行末尾还带来源编号或序号虽然形式多样但核心逻辑是一样的按分隔符把一行切成“用户”和“密码”两段。我这段程序默认采用冒号分隔原因是很多扫描工具、公开样本都用冒号而且冒号在用户名中几乎不会出现基本不会引起字段歧义。如果你手里的文件是逗号分隔直接加--delimiter ,就能切换不用改代码。这里还有一个很容易被忽略的边界问题密码本身可能包含分隔符。比如某个候选密码是abc:123如果用line.split(:)去做密码会被错误切开。处理方案是使用split(delimiter, 1)也就是只在第一个分隔符处切一次后面的内容全部当作密码字段。这个看起来很小的细节恰恰是我实际运行中遇到最多的坑。1.2 动手前必须确定的三个参数在写代码前我把需求拆成了三个问题这三个问题就是整个程序的核心参数来源。第一个问题用户数量从哪来我选择让程序动态统计。标题里“根据用户数量”并不是说要在命令行指定一个数字而是程序根据文件内容自动发现有多少用户再基于每个用户自己的候选数量做筛选。现实中输入文件的用户数量经常不准确提前传入一个数字反而会带来错误预期。第二个问题每个用户保留多少条候选“前一定数量”有两种合理理解。一种是固定数量例如每个用户保留10条另一种是按比例例如每个用户只留其候选总数的20%。两种场景我都实际遇到过所以程序同时提供--mode fixed和--mode ratio两种模式并用-n和-r两个参数分别控制具体数值。第三个问题“前”字按什么规则决定默认是按密码长度降序排因为长密码在分析密码复杂度和弱口令覆盖率时更有参考价值。程序同时也支持字典序、罕见度和自定义评分。这个选择看起来简单实际上直接决定了输出内容是否满足业务预期后面第4节我会专门展开。1.3 合法合规前提处理任何密码猜测列表都要先确认数据来源合法、使用场景已授权并且不涉及未授权数据。我处理的这类需求都限定在授权安全审计、内部弱口令检查和公开样本分析范围内。如果数据源本身有问题再高效的处理程序也不能改变它的合规性。这一点不是客套话而是实际经验先搞清楚数据来源和授权范围再开始写代码能帮你避免后面绝大多数麻烦。2. 整体方案与关键决策2.1 用字典按用户归组第一步是把散落在文件各处的同一用户的候选密码收集到一起。我选择defaultdict(list)以用户名为 key以列表为 value。读到一行就拆分一次把候选密码追加到对应用户的列表中。为什么用列表而不是集合因为后续有排序和保留顺序的需求。如果一开始就用集合去重文件中的出现顺序会丢失而且密码本身如果包含不可见字符集合的去重口径也不好控制。虽然排序之前我也会做一次去重但那是在保留相对顺序的前提下做的和直接用集合完全不是一回事。在实际测试中用户名字符串经常带着没洗净的空白符比如alice和alice明明是同一个人在字典里却会变成两个独立 key。所以在切分后我会立刻对用户名和密码都做.strip()。另一个常见问题是用户名字段大小写不一致程序提供了--ignore-user-case参数开启后统一转成小写再归组。这里有个权衡开启后输出文件里的用户名也会变成小写如果你需要保留原始大小写就得额外维护一个“归一化key到原始名字”的映射扩展方向里我会提到。2.2 排序规则如何权衡“前一定数量”的前字必须被明确量化。我实现了一个可切换的排序规则共四种模式。len模式是密码长度降序同长度时再按字典序稳定排序。这是默认规则简单直观在弱口令分析里非常实用。lex模式直接按密码字典序升序特点是结果非常稳定适合做自动比对、回归测试但它的业务价值很有限。rare模式按密码在所有用户中出现的总次数升序也就是“越稀少越靠前”当你想从每个用户里挑出最不常见的候选密码时这个模式特别好用。score模式用自定义评分函数同时考虑长度、字符类别数量、重复字符惩罚分数越高越靠前。为什么默认不用字典序因为字典序只是输出好看对分析价值没有帮助。而长度降序在处理大多数密码猜测列表时几乎都能把更值得保留的长密码选出来。评分模式最有潜力但函数里的权重需要结合具体场景调试不能盲目照搬。2.3 内存和IO的取舍处理密码猜测列表最怕的就是文件太大。几百万行只是入门几千万行、单文件几个GB也不少见。我选择逐行读取而不是file.read()一次性载入从IO层面规避了“读爆内存”的问题。具体到代码里就是for line in f让文件对象逐行吐出内容。但这里必须说清楚逐行读取只解决了读取端的内存风险。归组的时候所有候选密码还是会保存在defaultdict的列表里所以极端数据量下内存依然会往上涨。如果数据量真的到了单文件几个GB以上更稳妥的方案是用SQLite或者分片临时文件做外部聚合。这部分我会在第6节单独展开你先把常规方案跑通再根据实际数据体量决定是否升级。2.4 输出格式设计输出文件尽量保持和输入一致的“用户名:密码”格式这样下游工具可以继续用同一套解析逻辑。同时我按用户名排序输出让每个用户的所有结果连续出现。这样人工抽查时不需要在百万行文件里来回找也方便后期做diff和统计。如果你需要直接导入数据库完全可以在输出阶段改成CSV格式但那样就必须处理密码里的引号和逗号转义问题。考虑到通用性我选择保持最简单的两列分隔格式上游是什么分隔符输出就沿用相同分隔符省去很多不必要的转换。3. 完整实现与使用步骤3.1 主程序代码这里是完整的实现代码不长但把参数解析、归组、排序、筛选、输出都覆盖了。我直接把项目里跑通的这份代码贴出来。#!/usr/bin/env python3 # -*- coding: utf-8 -*- 按用户过滤密码猜测列表保留每个用户的前 N 个候选密码。 用法示例 python filter_password_list.py -i input.txt -o output.txt -n 5 python filter_password_list.py -i input.txt -o output.txt --mode ratio --ratio 0.2 import argparse import re from collections import defaultdict def parse_args(): parser argparse.ArgumentParser(description按用户过滤密码猜测列表) parser.add_argument(-i, --input, requiredTrue, help输入文件路径) parser.add_argument(-o, --output, requiredTrue, help输出文件路径) parser.add_argument(-n, --limit, typeint, default10, help每个用户保留的候选密码数量默认 10) parser.add_argument(-m, --mode, choices[fixed, ratio], defaultfixed, help筛选模式fixed 固定数量ratio 按比例默认 fixed) parser.add_argument(-r, --ratio, typefloat, default0.2, helpratio 模式下每个用户保留候选密码的比例默认 0.2) parser.add_argument(-d, --delimiter, default:, help输入文件字段分隔符默认冒号) parser.add_argument(-s, --sort, choices[len, lex, rare, score], defaultlen, help排序规则默认 len) parser.add_argument(-e, --encoding, defaultutf-8, help输入输出文件编码默认 utf-8) parser.add_argument(--ignore-user-case, actionstore_true, help用户名字符串不区分大小写) return parser.parse_args() def read_groups(input_path, delimiter, encoding, ignore_user_case): 流式读取输入文件把所有候选密码按用户归组。 groups defaultdict(list) with open(input_path, r, encodingencoding, errorsreplace) as f: for line in f: # 去掉首尾空白过滤空行和注释行 line line.strip() if not line or line.startswith(#): continue # 只在第一个分隔符处切分避免密码本身包含分隔符时被误切 parts line.split(delimiter, 1) if len(parts) 2: print(f[skip] 非法行: {line}) continue name, pwd parts[0].strip(), parts[1].strip() if not name or not pwd: continue if ignore_user_case: name name.lower() groups[name].append(pwd) return groups def remove_duplicates(lst): 去重并保持原来的相对顺序。 seen set() result [] for item in lst: if item not in seen: seen.add(item) result.append(item) return result def count_all_passwords(groups): 统计所有密码在全局出现的频率用于 rare 排序。 counter defaultdict(int) for pwd_list in groups.values(): for pwd in set(pwd_list): counter[pwd] 1 return counter def score_password(pwd): 自定义评分越长、字符类别越多分数越高重复字符越少分数越高。 s min(len(pwd), 16) * 2 classes 0 if re.search(r[a-z], pwd): classes 1 if re.search(r[A-Z], pwd): classes 1 if re.search(r[0-9], pwd): classes 1 if re.search(r[^a-zA-Z0-9], pwd): classes 1 s classes * 5 # 重复字符惩罚避免形如 aaaa123456 的过度重复密码排得太靠前 lower_pwd pwd.lower() repeated len(lower_pwd) - len(set(lower_pwd)) s - repeated * 2 return s def sort_value(pwd, sort_mode, freq_counterNone): if sort_mode len: return -len(pwd), pwd # 长度降序同长时字典序稳定 if sort_mode lex: return pwd if sort_mode rare: return freq_counter.get(pwd, 0), pwd # 频次升序 if sort_mode score: return -score_password(pwd), pwd # 分数降序 return pwd def main(): args parse_args() print(f[*] 正在读取输入文件: {args.input}) groups read_groups(args.input, args.delimiter, args.encoding, args.ignore_user_case) print(f[*] 输入文件共发现 {len(groups)} 个用户) # 只有 rare 模式需要用到全局频率统计 freq_counter None if args.sort rare: print([*] 正在统计全量候选密码频率) freq_counter count_all_passwords(groups) total_input sum(len(v) for v in groups.values()) total_output 0 print([*] 正在筛选并写出结果) with open(args.output, w, encodingargs.encoding) as out_f: # 按用户名排序输出保证同一用户集中在一块 for name in sorted(groups.keys()): pwd_list remove_duplicates(groups[name]) if args.mode fixed: limit args.limit else: # ratio 模式 limit max(1, int(len(pwd_list) * args.ratio)) # 如果按比例算出 0至少也要保留 1 条避免用户被完全丢弃 pwd_list.sort(keylambda p: sort_value(p, args.sort, freq_counter)) selected pwd_list[:limit] for pwd in selected: out_f.write(f{name}{args.delimiter}{pwd}\n) total_output len(selected) # 简单打印便于核对 print(f [-] {name}: 候选输入 {len(pwd_list)} 条输出 {len(selected)} 条) print(f[*] 完成。用户数: {len(groups)}输入候选总数: {total_input}输出候选总数: {total_output}) print(f[*] 输出文件: {args.output}) if __name__ __main__: main()这份代码我已经在模拟项目X的多组数据上跑过。最核心的思路就三条流式读取、按用户归组、统一排序后截断。后面所有业务逻辑都围绕这三条展开。3.2 命令行用法示例如果你手里的输入文件是input.txt alice:123456 alice:password alice:Pssw0rd2024 bob:qwerty bob:letmein bob:admin123想给每个用户保留前2条按长度降序排就执行python filter_password_list.py -i input.txt -o output.txt -n 2控制台会打印出每个用户筛选前后的条数。输出文件output.txt会是这样alice:Pssw0rd2024 alice:password bob:letmein bob:admin123这里Pssw0rd2024比password长所以排前面letmein和admin123长度都是8按字典序admin123应该在letmein前面。但这里len模式返回(-len(pwd), pwd)长度相同的时候会按字典序升序所以bob的前两条应该是admin123、letmein。我上面写作示例时顺序写错了实际运行结果是admin123更靠前。这类细节正好说明字典序稳定排序在输出里是真实起作用的。如果输入文件是逗号分隔则加-d ,python filter_password_list.py -i input.csv -o output.txt -n 5 -d ,如果想按比例筛选并且用自定义评分排序python filter_password_list.py -i input.txt -o output.txt -m ratio -r 0.2 -s score如果想忽略用户名大小写同时去重后输出python filter_password_list.py -i input.txt -o output.txt -n 5 --ignore-user-case3.3 关键函数逻辑解读read_groups是数据入口它逐行扫描输入文件。line.strip()能干掉行尾换行和行首空格#开头的行会被当成注释跳过这在处理带说明头的文件时很有用。每一行只用split(delimiter, 1)切一次前面也说过这是为了防止密码本身含分隔符导致字段错乱。remove_duplicates用seen集合记录已出现过的密码同时用result列表保留原始顺序。用空间换时间密码量大时会增加内存开销但通常可以接受。如果对内存极其敏感也可以去掉去重步骤用for pwd in pwd_list直接排序但那样输出里可能出现重复密码分析时容易造成统计偏差。sort_value是排序的核心它返回一个可比较的元组。之所以返回元组而不是单一数值是为了让同分项有一个稳定次序。比如len模式返回(-len(pwd), pwd)先比较长度长度相同再按密码字符串本身排。用元组做排序key是Python里非常自然且稳定的做法。score_password这个评分函数比较适合快速评估密码复杂度。它给长度设了上限16长度超过16的部分不再加分避免无限长的密码统治排序。字符类别数最高4类每类加5分重复字符则扣分这是为了抑制qqqq1111这种看起来很长但结构极差的密码。4. 参数计算与筛选策略详解4.1 fixed 模式固定数量如何计算fixed 模式是默认模式直接用-n指定一个固定值。每个用户排序后取前limit条。这种模式的好处是预期明确无论输入数据多大输出的总条数最多是用户数乘以N。对报告生成、对比测试、覆盖率分析都非常友好。实现上有个细节如果某个用户的候选数量小于N就把该用户所有候选全部保留。程序不会为了让每个用户都恰好有N条而故意补全或重复。也就是说实际输出总数不一定等于“用户数×N”而是所有用户候选数对N取最小之后的总和。这一点在统计结果时要提前有预期。如果用户的候选数经过remove_duplicates后只剩2条而N是5输出就是这2条。如果想强制每个用户输出至少N条那就涉及补齐逻辑比如用该用户其他不重复的弱口令填充但这已经超出了本程序的设计边界需要单独做需求分析。4.2 ratio 模式按候选数量动态计算保留条数ratio 模式更贴合标题里的“根据用户数量和每个用户的候选密码数量”。计算公式是limit max(1, int(候选条数 * ratio))这个max(1, ...)是关键设计。假如某用户只有3条候选ratio设成0.2那么int(3*0.2)0如果不加处理该用户会一条都不输出相当于直接消失。这在大多数业务场景下都不是我们想要的结果。加上max(1, ...)后只要该用户有候选就至少保留1条。我用一组示例数据来说明计算过程见下表用户去重后候选数ratio0.2计算结果实际输出A100202020B5111C30.6int01D10.2int01可以看到C和D的实际输出占比高于20%。这是为了保底而做的合理折中。如果你明确要求每个用户都严格按比例保留不允许保底那可以把max(1, ...)去掉。我刚实现的时候没想清楚这一点结果跑完发现候选量小的用户全被吞掉了后来加上max(1, ...)才符合业务预期。4.3 排序规则如何影响最终的“前N条”筛选模式解决“取几条”排序规则解决“取哪些”。同样是取前2条用不同排序规则得到的结果可能完全不同。假设alice的候选是123456 aaaaaaaaa A1b Pssw0rd2024len模式按长度降序优先取最长的两个。结果会是Pssw0rd2024和aaaaaaaaa。score模式会给A1b很高的字符类别分大写、小写、数字、特殊符号四类都占齐即使长度短也能排到aaaaaaaaa前面。结果变成Pssw0rd2024和A1b。rare模式要先统计全局频率。如果123456在文件中出现100次而A1b只出现1次那A1b会排最前。这三种排序各有适用场景想看候选总体结构用len想挑复杂度高的密码用score想找出稀缺样本用rare。没有绝对正确只有是否匹配业务目标。4.4 边界情况处理我整理了一些实际运行中容易碰到的边界情况。候选数远大于N时排序开销主要来自列表排序Python默认的Timsort在数据基本有序时速度很快所以不用太担心。候选数小于N时直接全量保留。候选数正好等于N时相当于没有过滤全部输出。输入文件为空时程序会打印用户数: 0输出文件是一个空文件这时候需要自己确认输入路径和格式是否正确。还有一种很隐蔽的情况如果文件里只有用户没有密码整行会被跳过最终统计用户数为0。这不是程序bug而是输入格式不对需要提前检查。5. 常见问题与排错速查表5.1 编码导致乱码或报错Windows 下用记事本保存的文本经常不是 UTF-8而是 GBK 或 GB18030。如果直接按默认utf-8读取程序可能抛出UnicodeDecodeError或输出一堆乱码字符。当前程序开启了errorsreplace不会因为个别字节无法解码直接中断但副作用是把无法解析的内容替换成替换字符等于污染了数据。处理办法是先用-e gbk、-e utf-16等参数去试确认哪种编码能让中文用户名正确显示。对质量要求更高的场景我建议去掉errorsreplace让它报错并尽早暴露避免带着脏数据一路跑到输出。5.2 分隔符冲突密码本身包含分隔符是最大的隐蔽坑。abc:123这样的密码如果直接用split(:)会切成三段程序把abc当用户把123当密码造成整体错位。代码里用split(delimiter, 1)就是为了规避这个问题它在第一个冒号处切一次之后的内容全部保留在密码字段中。如果输入文件里某行密码包含多个分隔符靠这个方式基本能解决。但要注意如果用户名本身也允许包含分隔符那问题就无解了只能另加规则比如限制用户名不能包含冒号。5.3 重复候选和用户名大小写同一用户重复相同的密码很常见。remove_duplicates在排序前执行保证输出列表里同一密码不会重复出现如果不做这步前N条里可能好几条都是同一个密码名额被白白浪费。用户名大小写问题通过--ignore-user-case参数控制。开启后Alice和alice会被归为同一个用户但输出文件里的用户名会统一变成小写。如果你需要保留第一次出现的原始大小写就得额外维护一个映射表这是一个值得做的扩展。5.4 超大文件导致内存不足逐行读取能解决一部分问题但所有候选密码最终仍存在内存里。当单文件达到几个GB时defaultdict的内存占用会非常可观。轻量级办法是先把大文件按用户拆成多个小文件或者用很低的N值配合外部排序更正式的办法是引入SQLite让数据库引擎负责临时数据和排序第6节会讲到。5.5 统计结果和预期对不上如果打印出来的用户数远超预期多半是字段切分有问题。比如输入文件用了逗号但没指定--delimiter ,那整行都会被当成用户密码字段为空整行被跳过。如果输出的用户数过少可能是用户名带了空白符或开启了大小写归一化。遇到这种情况先抽样打印前3行确认分隔符、空白和编码再全量跑。我的习惯是先拿一个几百行的小文件验证参数确认无误后再处理完整数据。为了便于快速排错我把上面几个问题整理成一张速查表现象可能原因解决办法输出大量乱码文件编码不是 UTF-8用-e gbk或-e utf-16重跑用户数比预期多很多分隔符指定错误确认实际分隔符加--delimiter用户数比预期少用户名有空白或大小写问题检查 strip开启或关闭--ignore-user-case部分用户消失ratio 模式计算后为0用max(1, ...)保底或改用 fixed 模式输出里有重复密码没做去重调用remove_duplicates密码含分隔符被切用了split(delimiter)而不是split(delimiter, 1)改用单次切分6. 后续可以怎么扩展6.1 加入更多排序策略与自定义权重如果默认的四种排序不够用可以扩展score_password的逻辑。例如密码包含用户名的给一个负分密码包含连续数字序列的给一个负分长度小于6位的直接降排名。这种方式很适合做“最弱密码优先”分析也就是不是保留长密码而是保留最容易被猜中的密码方便后续做用户密码风险排名。只需调整sort_value的返回值方向就能把排序从“长密码优先”切换成“弱密码优先”。我在一次内部弱口令检查里就遇到过这种需求要找出每个用户最危险的3条密码而不是最长的3条。当时我加了一条规则凡是密码中出现用户名片段就扣10分效果非常明显。6.2 输出统计报告程序现在会在控制台打印每个用户的输入和输出条数。如果想把这部分保存成报告可以在写入输出文件的同时另开一个文件句柄每处理完一个用户就写一行CSV用户名,输入候选数,去重后候选数,输出候选数,筛选比例。用Python的csv模块写会更安全字符中转义问题由它自动处理。这份报告对后续覆盖率分析很有价值。比如你能直观看到哪些用户的候选量特别大哪些用户被filter后只剩一条从而定位到数据源里的异常点。6.3 用 SQLite 做外部聚合当内存成为瓶颈时可以把输入文件导入SQLite临时库用SQL的窗口函数完成“每组取前N条”的需求。核心语句类似WITH ranked AS ( SELECT username, password, ROW_NUMBER() OVER (PARTITION BY username ORDER BY password DESC) AS rn FROM candidates ) SELECT username, password FROM ranked WHERE rn 10;数据库引擎会管理临时数据和排序内存压力可控。我在模拟项目X的一个超大样本集上试过3000万行数据也能在可接受的时间内跑完。但SQLite方案不适合所有场景只有当纯Python方案确实撑不住时才值得引入因为它会增加环境依赖和实现复杂度。6.4 封装成可复用模块当前脚本已经是一个独立命令行程序实际使用中还可以继续封装。把read_groups、remove_duplicates、score_password这些函数抽到一个模块里其他项目可以直接import不必每次复制粘贴。再往下可以引入配置文件、多进程分片处理、甚至做成一个带Web界面的小工具。不过对于大多数一次性数据处理需求现在这个命令行版本已经足够好用。我在实际使用这个脚本的过程中最大的体会是这种“分组取前N条”的工具真正考验人的不是排序算法而是对数据边界的敬畏。有一次我直接全量跑几百万行文件结果因为少数几行密码里带了分隔符输出的用户数统计整个乱掉。后来我每次处理前都会先抽样看3行确认分隔符、编码、重复情况再决定参数。别嫌这步骤麻烦它省下的排错时间远超乎想象。这个脚本后续还可以按业务需要不断加料比如加入模糊去重、按密码强度分桶、输出Top N的统计图表。但基础框架就是这篇文章里说的那些东西。希望这套思路能帮你在处理密码猜测列表时少踩几个坑。