☰
C++实现非递归密码字典生成器:从36进制计数器到暴力破解的工程实践
2026/10/6 4:05:50 网站建设 项目流程

忘掉公司路由器后台密码的那天晚上,我在网上找了一晚上“密码字典生成器”,下载下来全是带广告的 exe,不是要付费就是要联网授权。后来我干脆自己用 C++ 写了一个生成 8 位“26 个小写字母 + 10 个数字”组合的字典生成工具,非递归、直接输出、能指定从第几条开始生成,用完直接删。这篇文章就把这个思路和代码完整拆开讲。

先说明白一个很多人容易搞混的点:这个需求虽然搜的是“全排列”,但真正要生成的并不是数学意义上的排列(permutation),而是从 36 个字符里有放回地取 8 个字符的所有组合。换句话说,每个位置都可以是 36 个字符中的任意一个,所以总数量是 36 的 8 次方,也就是 2,821,109,907,456 条。这个数量级决定了算法必须足够高效,也决定了你不能真的打算把整个字典落盘到硬盘里。这篇文章适合谁看?正在学 C++ 算法的人、需要给自家设备做密码恢复测试的工程师、以及对密码学中的密钥空间建模感兴趣的朋友。我会把数学原理、完整代码、性能量级、以及我实际踩过的坑都讲清楚。

1. 认清问题本质:密码字典生成不是“全排列”

很多人在搜索引擎里输入“全排列”,拿到的却是std::next_permutation的教程,然后照着写,写完发现结果完全不对。原因很简单——next_permutation处理的是“无放回排列”,它只能把给定的 N 个元素重新打乱顺序,元素本身不会重复出现。而我们做密码字典时,每个密码位是可以重复使用同一个字符的。

1.1 先分清四个容易混淆的概念

  • 排列(Permutation):从 N 个元素中选 M 个,不考虑顺序?不,排列是考虑顺序的,但不重复使用元素。比如字符集{a, b}的所有 2 位排列只有ab、ba两种。
  • 组合(Combination):从 N 个元素中选 M 个,不考虑顺序,也不重复。{a, b}选 2 个只有{a, b}一组。
  • 笛卡尔积 / 有放回排列:从 N 个字符中取 M 位,每一位都可以重复,顺序敏感。{a, b}取 2 位是aa、ab、ba、bb四种。密码猜测用的就是这一种。
  • 多重集合排列:给定一堆元素,有些元素重复,求全排列。典型例子是aab的全排列只有aab、aba、baa三种。

密码字典、密钥候选集、暴力破解的搜索空间,统统属于第三种——笛卡尔积。如果你用std::next_permutation,根本不可能生成aaaaaaa a这种重复字符组成的候选,而它是实际密码里最常见的形态。

1.2 为什么“36 进制计数器”是最直接的做法

36 个字符,我们先把它们排好序:a 到 z,然后0 到 9,并给每个字符一个索引,0 到 35。一个 8 位密码,本质上就是一个 8 位的 36 进制数。

举个例子,00000000对应全 0 索引,也就是aaaaaaaa;00000001对应aaaaaaab;0000000 a这种说法不准确,严格来说,第 6 位索引为 10 时,对应字符是字母k。这种映射关系一旦建立,生成字典的任务就变成了:从 0 递增到 36^8 - 1,每递增一次,把当前的“36 进制数”翻译成字符串。

这个思路天然适合非递归实现。递归方案通常是 DFS 枚举每一位,虽然代码短,但它的问题在于:你没法快速跳到第 1 亿条数据继续生成,也没法轻松地把任务分片交给多个线程。而计数器法把整个状态保存在一个数组里,第 N 条数据是什么,直接通过一次“除基取余”就能算出来。

1.3 复杂度分析:这个算法到底快在哪

每次从当前排列跳到下一个排列,平均只需要做一次“加 1 进位”操作。因为 36 进制的进位概率呈指数递减:最后一位每 1 次就变化,倒数第二位每 36 次才变化一次,倒数第三位每 1296 次变化一次。所以循环内部那个 while 进位操作,均摊时间复杂度是 O(1)。整一趟生成下来,时间复杂度是 O(36^8),但每条数据的处理成本极低。

真正的瓶颈不在 CPU,而在输出。8 个字符加一个换行符,每条数据 9 个字节,2.8 万亿条数据就是 25 TB 以上的文本量。就算你有足够的硬盘空间,按普通 SSD 每秒 500 MB 的写入速度,写满也要 14 个小时以上。所以实际工程里不会有人真的把完整字典写到磁盘,而是让程序直接“边生成边消费”,比如跑在内存里做匹配测试。

2. 从零写一个可直接编译的 C++ 非递归生成器

我先把最简单的一个版本贴出来。这个版本你可以直接复制到本地编译,它会打印前 20 条,让你直观看到输出长什么样。

2.1 演示版:先学会走

#include <cstdio> #include <string> #include <cstdint> const std::string charset = "abcdefghijklmnopqrstuvwxyz0123456789"; const int LENGTH = 8; const uint64_t BASE = charset.size(); // 36 int main() { // 保存当前每一位字符在 charset 中的下标 int idx[LENGTH] = {0}; char cur[LENGTH + 1]; cur[LENGTH] = '\0'; // 只输出前 20 条做演示 for (int line = 0; line < 20; ++line) { for (int i = 0; i < LENGTH; ++i) { cur[i] = charset[idx[i]]; } printf("%s\n", cur); // 核心:36进制加 1 进位 int pos = LENGTH - 1; while (pos >= 0) { ++idx[pos]; if (idx[pos] < BASE) { break; } idx[pos] = 0; // 当前位溢出,置 0 并继续向前进位 --pos; } // 如果 pos < 0,说明第一位也溢出,所有组合都已生成完 if (pos < 0) { break; } } return 0; }

这段代码的关键点在 while 进位部分。我用一个实际例子走一遍:假设当前 idx 是{0, 0, 0, 0, 0, 0, 0, 35},对应字符串aaaaaaa9。执行加 1 时,pos 从 7 开始,idx[7]变成 36,不小于 BASE,于是置 0,pos 减到 6;idx[6]从 0 变成 1,小于 BASE,break。最终 idx 变成{0, 0, 0, 0, 0, 0, 1, 0},对应aaaaaaba。这就是从aaaaaaa9加 1 后正确进入下一轮的样子。

有些初学者会觉得这个过程像是“模拟手工进位”,没错,它就是小学加法竖式思路的翻版。这种实现的好处是,你不需要保存一整棵树,不需要递归调用栈,只需要一个 8 元素的 int 数组。即使把长度改成 20 位、50 位,内存消耗依然是常数级别。

2.2 工程版:支持断点续传、分片、自定义字符集

接下来是真正能用在实战里的版本。多了三个能力:第一,可以指定从第几条开始生成;第二,可以指定生成到第几条结束;第三,字符集和密码长度做成参数,不写死。

#include <cstdio> #include <cstring> #include <string> #include <cstdint> #include <vector> class PasswordGenerator { public: PasswordGenerator(const std::string& charset, int length) : chars_(charset), length_(length), base_(charset.size()) {} // 把编号转成字符串。第 0 条对应全 0。 std::string at(uint64_t idx) const { std::string result(length_, chars_[0]); for (int i = length_ - 1; i >= 0; --i) { result[i] = chars_[idx % base_]; idx /= base_; } return result; } // 生成 [start, end) 区间的所有字符串,写入 FILE* 指向的输出流 void generate(uint64_t start, uint64_t end, FILE* out) const { // 先根据 start 初始化状态数组 std::vector<int> idx(length_, 0); uint64_t tmp = start; for (int i = length_ - 1; i >= 0; --i) { idx[i] = tmp % base_; tmp /= base_; } const size_t BUF_LINES = 65536; std::string buf; buf.reserve(BUF_LINES * (length_ + 1)); uint64_t count = start; while (count < end) { for (int i = 0; i < length_; ++i) { buf.push_back(chars_[idx[i]]); } buf.push_back('\n'); ++count; // 缓冲满 6 万行再写入一次,减少系统调用次数 if (buf.size() >= BUF_LINES * (length_ + 1)) { fwrite(buf.data(), 1, buf.size(), out); buf.clear(); } // 36 进制加 1 int pos = length_ - 1; while (pos >= 0) { ++idx[pos]; if (idx[pos] < base_) { break; } idx[pos] = 0; --pos; } // 溢出代表所有组合都已经生成完,提前退出 if (pos < 0) { break; } } if (!buf.empty()) { fwrite(buf.data(), 1, buf.size(), out); } } private: std::string chars_; int length_; uint64_t base_; }; int main(int argc, char* argv[]) { uint64_t start = 0; uint64_t end = 20; if (argc > 1) start = strtoull(argv[1], nullptr, 10); if (argc > 2) end = strtoull(argv[2], nullptr, 10); // 可选第三个参数:输出文件路径 FILE* out = stdout; if (argc > 3) { out = fopen(argv[3], "wb"); if (!out) { perror("fopen"); return 1; } } std::string charset = "abcdefghijklmnopqrstuvwxyz0123456789"; PasswordGenerator gen(charset, 8); gen.generate(start, end, out); if (out != stdout) { fclose(out); } return 0; }

命令行用法我给你列几个:

命令作用
./gen打印前 20 条到屏幕
./gen 1000000 1000020打印第 1000000 条到第 1000019 条
./gen 0 1000000 dict.txt生成前 100 万条写入文件
./gen 5000000 10000000 chunk.txt生成 500 万到 1000 万之间的 500 万条

这个“从指定编号开始”的能力,就是一个非递归实现的巨大红利。递归函数跑起来之后,你想暂停都难,因为调用栈里的状态没法序列化;而计数器法,一个uint64_t编号就能唯一定位到任意一条数据。要断点续传,只需要把当前的count值记下来,下次启动时作为start传进去。

2.3 关于字符集顺序的一个重要提醒

字符集字符串的顺序决定了生成顺序。我写的是a-z在前面、0-9在后面,所以前面若干条全是字母开头的组合。如果你想让数字先出现,就把charset改成"0123456789abcdefghijklmnopqrstuvwxyz"。这不算 bug,但如果你拿这个工具去测试一些按字典序排序的系统,字符集顺序会影响命中速度。真正的实战中,通常把高频字符放在前面,比如先小写字母、后数字、再大写字母、最后特殊符号。

3. 量级感知:36 的 8 次方到底有多恐怖

很多人第一次写这种程序,会觉得“我挂一晚上就生成完了”。我劝你先算账再写代码。

3.1 用一张表感受指数增长

位数总量大约等效
4 位36^4 = 1,679,616168 万条,几秒钟
6 位36^6 = 2,176,782,33621.7 亿条,几分钟到几小时
8 位36^8 = 2,821,109,907,4562.82 万亿条,以亿为单位要跑 28 万轮
10 位36^10 = 3.66e153658 万亿条,已经不是单机能解决的问题

如果只是生成 6 位,程序很快跑完;一旦到 8 位,事情就变了。2.82 万亿条,每条 9 字节(8 字符 + 换行),总输出量是 25.4 TB。我见过有人真的尝试把这个文件写完,结果把一块 2TB 的移动硬盘写满了也没写完,最后只能是删掉重来。所以如果你需要全量覆盖,正确的姿势应该是“边生成边消费”,而不是“先落盘再处理”。

3.2 缓冲区设计:控制编写频次

我那个工程版本里特意做了一个 65536 行的大缓冲区,凑够一批再fwrite一次。这个优化很重要。如果你每条数据都调用一次fwrite,系统调用开销会大到一个不可接受的程度。每秒钟大概只能写几十万条;而用 64KB 级别缓冲后,实际写入瓶颈就从 CPU 转移到了磁盘本身,写入速度能提升一个数量级。

在 Linux 下,你可以把输出重定向到/dev/null来测试纯计算速度。以我的常规经验,开-O2优化后,这段代码每秒能生成 3000 万到 1 亿条组合(取决于机器)。但如果你重定向到一个真实文件,速度就会掉到每秒 500 万到 2000 万条,这就是 IO 瓶颈了。你可以做个实验:先写一个小规模数据测试,比如./gen 0 1000000 /tmp/test.txt,感受一下你的磁盘在这个粒度上的实际速度,再倒推全量需要多久。

3.3 多线程分片:为什么这个结构天生适合并行

因为每条数据都可以用编号唯一定位,所以多线程就变得极其简单。不需要任何锁,各线程管各的区间就好。

// 伪代码示意:两个线程各生成一半 // thread 1: gen.generate(0, total/2, f1); // thread 2: gen.generate(total/2, total, f2);

关键前提:generate函数里不能依赖共享的可变状态——不同区间各自维护自己的 idx 数组就行。这正好是计数器法的优势。用递归做并行,要么用任务队列,要么在每层递归拆任务,代码复杂度会明显上升。我实际测试过,四线程分片时,生成速度能接近线性扩展,前提是输出也要分散到不同文件,否则磁盘写入本身会互相竞争。

4. 性能优化与实战避坑记录

这一部分写的是我亲手踩过的几个问题,估计也是你会踩的。

4.1std::next_permutation的陷阱

我知道很多人是从这条 API 搜到这里的。如果你真的用了它,会发现生成出来的结果数量远远小于 36^8。因为它把 36 个字符当成一个集合,做的是“从 36 个字符中不重复地挑 8 个并排列”——这只是一个子集。更重要的是,它生成出来的字符串里字符都不重复,像abc12345这种会出现,但aaaa1111永远不可能出现。这跟实际密码的特征完全相反。所以,看到“全排列”搜索词时,先从需求本身校验一遍:你要的是不是“有放回排列”。

4.2 整数溢出和类型选择

36^8 = 2.82 万亿,这个数字已经超过了 32 位int的表示范围(21 亿)。如果你用int count去计数,跑到一半就会溢出变成负数,循环条件直接崩坏。我一开始也犯过这个错,在 count 输出到 21 亿附近时突然开始出现负数编号。解决办法是统一用uint64_t,它在绝大多数平台上是 64 位无符号整数,最大能表示 1.8e19,覆盖 36^8 绰绰有余。如果你以后要扩展到 12 位、13 位,也还是够的。

4.3 Windows 平台换行符的坑

在 Windows 上如果用文本模式(fopen不加b)写文件,换行符会被悄悄转换成\r\n,每条数据会变成 10 字节而不是 9 字节。如果你后续再用某些工具按\n切分,问题不大;但如果你按照“每条固定 9 字节”去做偏移定位,就会错位。我建议直接用二进制模式打开文件,也就是fopen(path, "wb"),这样 C 运行库不会做任何转换,写入的就是纯\n。同样地,统计文件行数时,最后一行可能没有换行符,如果你用wc -l统计,会发现结果比预期少一行,这是正常的。

4.4 从第 1 亿条开始,为什么我建议用编号而不是“先跑一亿次循环”

如果你只是把程序一启动,然后空转跳过前 1 亿条,当然能到指定位置,但白白浪费了时间。真正的断点续传应该利用at()函数直接定位。at()的核心就是不断除 36 取余,时间复杂度 O(8),常数极小。在我那个工程版的generate开头,我已经把start参数做成了初始状态,所以它不会浪费时间在跳过的过程上。这里的原则是:能计算出来的位置,绝不靠循环去“跑出来”。

4.5 关于密码字典工具的合法使用边界

这一条我必须写清楚。这类工具可以用在正当场景:你自己把路由器后台密码忘了,通过本地恢复测试找回;你写了一个登录系统,想验证系统对弱口令的防护能力;你在做密码学课程实验,需要理解密钥空间的量级。但我强烈不建议把它用在任何你没有授权的系统上。现实中绝大多数密码破解行为都是违法的,你在搜索工具的同时,也要清楚自己在做什么。我写这个工具,图的是一时之需和算法乐趣,不是让谁拿去搞破坏。请务必只在合规场景里使用。

5. 扩展方向:从固定字典到可定制组合

基础版本已经能干活,但如果你想把它用在更真实的场景里,下面几个改动我建议你加上。

5.1 字符集动态化

把const std::string charset改成命令行参数,这样你不用重新编译就能切换:

  • 纯数字:0123456789
  • 数字 + 小写:0123456789abcdefghijklmnopqrstuvwxyz
  • 全字符:0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*

一旦引入大写字母和特殊符号,8 位长度的总量会变成 94^8 ≈ 6.1e15,那已经是另一个量级,单个设备再怎么并行都跑不完。这也是为什么真实系统里只要密码够长、字符集够大,暴力破解基本不现实。

5.2 权重优先生成

计数器法本质上是字典序生成,但真实用户设置密码时并不是均匀分布的。比如很多人会用“生日 + 姓名缩写”这种组合,你先跑出19900101再跑zhang2020,需要等很久。更聪明的做法是写一个“规则生成器”:把常见姓名列表 + 年份列表 + 特殊符号列表做笛卡尔积,或者只对特定模式组合,比如小写字母开头、中间数字、末尾符号。这一类工具叫“字典变种”,并不是全量穷举,而是概率优先。如果你感兴趣,可以在计数器法的基础上再加一个规则映射层,让每个编号对应一个特定模式而不是字符数组下标。

5.3 与哈希验证结合

这种生成器最有价值的用法不是写字典文件,而是接到一个验证函数里。比如你有一个未知的哈希值,想测试某段密码的哈希是否匹配,那就把generate循环里的fwrite去掉,换成if (hash(current) == target) { printf("found: %s\n", cur); break; }。这样你不需要中间落盘,直接在内存里跑完整个搜索流程。对 8 位全空间来说,单机还是不够快,但对 5 位、6 位这种量级,这个方案完全可行——我自己的小工具就是这么干的,跑 6 位小写字母 + 数字的哈希匹配,几分钟内就能扫完一轮。

5.4 扩展长度长度与分文件输出

如果你想生成不同长度的所有组合,比如 1 位到 8 位全包,最简单的方式是外层再套一层循环,分别调用generate(0, 36^len, out),并且每次生成前先输出一行注释或者标记,比如# LEN=8。我实际用过这种做法,配合grep能很快定位到某个长度段。不过要提醒的是,长度范围每加 1,运行时间就乘以 36,“把所有长度都跑一遍”这句话听起来轻巧,做起来是灾难。


最后分享一个我在实际使用中的小技巧:千万别把“字符集顺序”和“字典质量”混为一谈。程序里的charset是纯粹的顺序问题,但真实密码的分布是不均匀的。如果你真的在帮自己找回某个遗忘的密码,不要傻跑全量空间,先按规则缩小候选集再上生成器,命中率会高得多。这个工具的核心价值,不在于它能把 36^8 个组合全部吐出来,而在于它给了你一个可控的、可定位的、能接进其他程序的生成核心。下次你只要把字符集、长度、起始编号这几个参数一调,换个场景又能直接用。

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

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

立即咨询