如果你做过任何一个带账号体系的系统,迟早会被用户教育一次:你以为不会有人用的密码,偏偏每天都有真实用户填进去。我在后端岗位这些年,见过形形色色的弱口令,其中出场率最高的永远是那串老面孔——12345,还有它的加长版 123456。最近我专门搭了一个完全隔离的本地实验环境,拿这串数字当靶子,用 Python 写了一套模拟攻击脚本,把"破解一个五位数纯数字密码"的完整过程跑了一遍,包括字典攻击、穷举攻击、加盐前后破解成本的对比,以及事后日志复盘。
这个项目本身不算复杂,但很适合三类人看:一是对安全方向刚入门的新人,可以借它理解攻击者视角;二是后端和全栈开发者,看完会重新审视自己系统里的密码存储方案;三是所有觉得自己密码"没那么弱"的普通用户。整篇内容就是我把这个实验从设计到落地的完整记录,包括踩过的坑和最终的改进建议。
1. 项目背景与整体设计
1.1 为什么拿"12345"开刀
先聊点背景。每年国内外都有安全机构或密码管理平台发布"年度最差密码"榜单,不管榜单怎么换,前十名里永远有几个位置被同一组数字霸着:12345、123456、123456789,偶尔还有 qwerty 和 password 混进来。这说明什么?说明弱密码不是某个群体的个例,而是跨越年龄、地域、职业的普遍现象,哪怕安全提醒已经喊了十几年,还是有人觉得"我的账号没那么重要"。
我选择 12345 作为研究对象,看中的恰恰是它的"普通"。它足够短、足够常见、没有任何随机性,是理解密码破解问题最典型的样本。如果连这种密码都能在毫秒级被攻破,那用户脑子里的"复杂一点"、"加个生日"、"用个名字缩写"到底有没有用,也顺便能验证一遍。项目目标很明确:用可控的实验数据回答一个问题,一个五位数纯数字密码在攻击者面前的生存时间到底有多长,以及什么样的密码设计能让这个时间从秒级拉长到不可接受。
1.2 技术方案选型:为什么用 Python 而不是现成工具
真要破解密码,现成工具有很多,比如 hashcat、John the Ripper,这些工具拿到哈希就能直接开跑,效率比我自己写的脚本高几个数量级。但我这次没有直接用它们,原因有两个。
第一,工具是黑盒。hashcat 跑完只给你一个结果,中间发生了什么、每一步尝试了多少组合、字典命中为什么比穷举快,全都看不到。而我的目的是做教学演示和自我理解,需要把过程透明化,哪怕效率低一点也要让人看得明白。第二,Python 生态里正好有 itertools、hashlib、time 这几个标准库,不需要装任何第三方依赖就能把字典攻击和穷举攻击完整实现。代码量不大,逻辑直白,适合作为安全入门的脚手架。
当然,方案选型也做了必要的妥协。Python 跑哈希的速度远不如 C 或 GPU 工具,但恰恰是这个"慢",让每一步尝试都能被观察和记录,反而更像一个可复现的实验。真正在生产环境做渗透测试还是建议用专业工具,我这个项目定位是"教学与自检",两者目标不同,工具选择自然也不同。
1.3 实验边界:合法、合规、可复现
做任何和"攻击"沾边的内容,第一件事就是把边界划清楚。我这个项目有几个硬性前提:所有实验都在本地虚拟机里完成,网络完全隔离;所有目标哈希都由我自己生成,没有采集任何第三方数据;所有脚本只用于教育演示和防御自检,不针对任何真实系统。
这一点必须反复强调。很多人看完破解代码容易上头,转头就去拿它测别人的系统,这是极其危险的行为。在中国和相关法规框架下,未经授权访问或尝试破解他人系统属于明确违法行为。安全研究的正确姿势永远是先在自己的环境里验证,然后把结论转化为防御建议。我在项目 README 里也写了一段同样的说明,每次跑实验前提醒一下自己:我们是站在防守方的角度学攻击,目的是把门修得更牢,而不是学会破门而入。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心实现与代码拆解
2.1 破解的计算模型:先算账再动手
动手写代码之前,我先把账算清楚了。一个五位数纯数字密码,每一位有 0-9 共 10 种可能,组合总数是 10 的 5 次方,也就是 10 万种。10 万这个数字看起来不小,但对于计算机来说基本等于白给。作为对比,普通单核 CPU 每秒可以计算几百万次 MD5 哈希,10 万次尝试也就是几十毫秒的事。
这里有一个核心概念必须讲清楚:破解的难度不是由密码本身决定的,而是由"密码组合空间"和"单次尝试成本"共同决定的。组合空间越大,需要尝试的次数越多;单次尝试成本越高,单位时间内能尝试的次数越少。密码安全的所有措施,本质上都是在扩大组合空间或者提高单次尝试成本。
我在实验里做了一个更直观的对照。5 位纯数字是 10 万种组合,6 位纯数字是 100 万种,8 位纯数字是 1 亿种。看起来已经很大了,但如果把字符集从小写字母扩展到大小写字母加数字再加符号,8 位的组合空间会膨胀到大约 6.6 乘 10 的 15 次方。这就是为什么安全建议永远在强调"长度"和"复杂度",因为每多一位、每多一种字符,破解成本的提升都是指数级的。
2.2 第一版脚本:字典攻击秒杀弱口令
先实现的是字典攻击。它的逻辑非常简单:攻击者不凭空猜,而是直接用一份"最常见密码列表"逐一尝试。现实世界里,超过一半的弱密码攻破事件靠的不是算力,而是这种最土的办法,因为用户太喜欢用常见组合了。
我在本地准备了一份 top_passwords.txt,里面放了常见的弱密码,包括 12345、123456、123456789、password、qwerty、admin 这一类的老面孔,大概几十行。然后写了下面这个脚本:
python复制import hashlib
import time
# 目标哈希,假设用户设置的密码是 123456
target_hash = "e10adc3949ba59abbe56e057f20f883e"
def md5_hash(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
with open("top_passwords.txt", "r", encoding="utf-8") as f:
dictionary = [line.strip() for line in f if line.strip()]
start = time.perf_counter()
for candidate in dictionary:
if md5_hash(candidate) == target_hash:
elapsed = time.perf_counter() - start
print(f"字典命中: {candidate}")
print(f"耗时: {elapsed:.6f} 秒")
break
else:
print("字典未命中")
实测结果毫无悬念:脚本运行后几乎是瞬间就命中了 123456,耗时才几毫秒。这背后的逻辑是,123456 是一个长期霸榜的弱密码,必然出现在任何一份像样的字典里。字典攻击最恐怖的地方在于,它不需要任何算力,只需要一份不错的字典,就能在眨眼间干掉大量真实账号。很多系统只靠口令登录、没有频率限制、也没有二次验证,碰到这种攻击基本是裸奔状态。
2.3 第二版脚本:全量穷举五位数
字典攻击打不中的情况怎么办呢?比如用户设置了一个不那么常见的五位数字组合,比如 38571。这时候就需要穷举攻击,把从 00000 到 99999 的所有组合全部试一遍。Python 的 itertools.product 可以完美胜任这个工作:
python复制import itertools
import hashlib
import time
target_hash = "582fc884d1e5fb4e5a2f3b6f6b9c0d1e" # md5(38571) 的示例值
def md5_hash(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
start = time.perf_counter()
total_attempts = 0
for length in range(1, 7):
for digits in itertools.product("0123456789", repeat=length):
candidate = "".join(digits)
total_attempts += 1
if md5_hash(candidate) == target_hash:
elapsed = time.perf_counter() - start
print(f"穷举命中: {candidate}")
print(f"长度: {length} 位")
print(f"总尝试次数: {total_attempts}")
print(f"耗时: {elapsed:.4f} 秒")
raise SystemExit(0)
这里有个很关键的细节:我用了 itertools.product 而不是提前生成一个包含所有组合的列表。10 万个组合看起来不多,真要放进内存也没多大,但一旦把长度扩展到 8 位甚至 9 位,组合数会立刻爆炸,如果还习惯性地把所有组合塞进内存,程序会直接卡死。product 是惰性求值,每调用一次就生成一个组合,内存占用是常数级别,这才是处理大规模枚举的正确姿势。
跑完这段代码,结果同样没有悬念:目标五位纯数字密码被找到时,总耗时不到 0.1 秒。如果我只精确到毫秒,它几乎是瞬间命中的。这就是纯数字密码的真实处境,长度小于 8 位、不带任何符号的情况下,穷举的成本低到可以忽略不计。
2.4 为什么加盐之后难度完全不同
暴力破解能这么快,有一个隐藏前提:目标哈希是未加盐的。我先用 MD5 直出做演示,是为了让逻辑更简单,但这不代表现实中的系统应该这样做。现实中一个合格的密码存储方案,至少要满足两点:使用慢哈希算法,并且每个用户加独立随机盐。
我用一段对比代码演示了盐的作用:
python复制import hashlib
import os
password = "12345"
# 未加盐:同样的密码永远得到同样的哈希
h1 = hashlib.md5(password.encode()).hexdigest()
h2 = hashlib.md5(password.encode()).hexdigest()
print("MD5(12345) 第一次:", h1)
print("MD5(12345) 第二次:", h2)
# 加盐:每次盐不同,哈希完全不同
salt1 = os.urandom(16)
salt2 = os.urandom(16)
p1 = hashlib.pbkdf2_hmac("sha256", password.encode(), salt1, 100000)
p2 = hashlib.pbkdf2_hmac("sha256", password.encode(), salt2, 100000)
print("PBKDF2(12345) 盐1:", p1.hex())
print("PBKDF2(12345) 盐2:", p2.hex())
从输出可以看到,未加盐时同一个密码两次计算的哈希值完全一样,攻击者一旦拿到这个哈希,就可以直接拿彩虹表去查,一查一个准。而加盐之后,即使两个用户设置了一模一样的密码,存储的哈希也完全不同,攻击者无法通过批量预计算来加速破解,必须针对每一条记录单独跑一遍运算。
更关键的是慢哈希带来的成本提升。MD5 之所以快,是因为它本来就是为速度设计的,几百万甚至上亿次每秒都很正常。而 PBKDF2、bcrypt、Argon2 这类算法故意设计成"慢",单次计算就要花费几十到几百毫秒。同样是猜一个五位数字密码,MD5 环境下每秒能试几百万次,bcrypt 环境下每秒只能试几十次,破解成本直接被拉高了几万倍。这也是为什么后端开发者选型时看到还在裸用 MD5、SHA1 存密码的项目,第一反应都是"赶紧改"。
3. 实测结果与数据复盘
3.1 现场运行记录:12345 在多长时间内暴露
实验跑完之后,我把两轮运行的数据整理了出来。字典攻击那轮几乎没法计时,因为它是毫秒级以下命中的,我甚至怀疑 time.perf_counter 的精度都被浪费了。穷举攻击那轮,目标是五位单纯数字组合,全量跑完 10 万种组合并命中目标,耗时在 0.1 秒级别。
这里要说明一下,我的测试环境只是普通开发笔记本的 CPU,没有调 GPU,没有做任何并行优化。如果换成哈希猫这类专业工具加上一块普通显卡,同样破解任务的耗时还能再降几个数量级。换句话说,我测出来的已经是偏保守的数据,真实攻击者的速度只会更快。
更值得强调的是,穷举过程中的输出日志显示了一个有意思的现象:脚本前几秒跑的都是个位数和两位数的组合,直到尝试到第五位区间才命中目标。这说明攻击者并不假设你知道密码有多长,而是从 1 位开始一路往上扫。扩展点在于,很多系统强制用户设置 6 到 8 位密码,攻击者就会直接从 6 位开始扫,命中时间只会更短,不会更长。
3.2 不同长度与字符集的破解成本对照
光测一个五位数还不够,我顺手把不同密码形态的组合空间和预估破解耗时做了一张对照表。这样能更直观地看到"长度"和"字符集"对破解成本的影响。
| 密码形态 | 组合空间估算 | 单核CPU跑MD5预估耗时(约) |
|---|---|---|
| 5位纯数字 | 10万 | 0.01秒 |
| 6位纯数字 | 100万 | 0.1秒 |
| 8位纯数字 | 1亿 | 10秒 |
| 8位纯小写字母 | 约2088亿 | 约5.8小时 |
| 8位混合字符(大小写+数字+符号) | 约6.6乘10的15次方 | 约21年 |
| 12位混合字符 | 约5.4乘10的23次方 | 远超人类文明时间尺度 |
注意,这张表用的是单核 CPU 的保守估算,而且假设哈希未加盐。如果换成 GPU 集群,8 位混合字符的数百亿年估算会被大幅压缩,但只要密码足够长、字符集足够大,被穷举的时间成本仍然高到让攻击者放弃。真正能从这张表里得出的结论是:8 位纯数字只是杯水车薪,8 位小写字母勉强能挡住业余选手,12 位混合字符才是可以安心睡觉的级别。
3.3 现实攻击比你想象中更聪明
实验做完之后,我最想强调的是:现实中的攻击者根本不会像我这样老老实实从头穷举。纯数字穷举只是他们工具箱里最简陋的一件工具。真正的高效攻击路径通常是这样的。
第一步,用泄露数据库里的庞大字典做撞库,所谓撞库就是把别的平台泄露的账号密码批量拿来试你的系统,因为大量用户在不同平台复用同一个密码。第二步,针对特定目标做社工推测,比如把生日、手机号、姓名拼音、键盘相邻按键组合(qwerty、1qaz2wsx 这类)混进字典。第三步,才是对字典没覆盖到的少量账号做定向穷举或 Hash 破解。这三步下来,能撑住的账号少之又少,撑不住的账号里就包含大量 12345 用户。
所以我在复盘时给自己提了个醒:单看破解 12345 需要多少毫秒,其实低估了问题的严重性。真正的问题是,这样的密码同时存在于字典、撞库样本、社工字典里,它是三重攻击共同的靶子。任何系统只要允许用户设置这类密码,就等于在所有防线最薄弱的地方开了一扇永远关不上的侧门。
4. 常见问题与安全加固落地
4.1 实验中踩过的坑与排查实录
写脚本的过程不算顺利,中间踩了几个小坑,整理出来给大家当个排查手册。
第一个坑是哈希不匹配。最开始我用的目标哈希是从别处复制的,结果字典明明命中了 123456,程序却始终报"未命中"。排查到最后发现是编码问题,别处生成的哈希可能用了不同的编码方式,而我在本地脚本里用的 utf-8。这里提醒大家,做哈希比较时,双方必须统一编码,而且比较的是 hexdigest 字符串,不是原始 bytes。第二个坑是内存爆炸。我先天真地写了一个生成全部组合的列表,5 位才 10 万条感觉没事,但一旦把长度改到 8 位、9 位,程序直接卡死。换成 itertools.product 的惰性迭代之后,内存占用才稳定下来。第三个坑是计时不准确,第一次用 time.time 测耗时,数据忽高忽低,换成 time.perf_counter 并多次取均值后才拿到稳定结果。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 哈希总是不匹配 | 编码不一致或比较对象不对 | 统一 utf-8,比较 hexdigest 字符串 |
| 程序卡死或内存爆掉 | 把所有组合提前装进内存 | 用 itertools.product 惰性生成 |
| 耗时数据忽高忽低 | time.time 精度不够 | 改用 time.perf_counter 并取均值 |
| 字典攻击跑得太慢 | 字典文件太大或每行没 strip | 去掉空行和首尾空白,按需压缩字典规模 |
这些坑单看都不大,但组合在一起会浪费不少排查时间。实验类项目尤其如此,记录好每个坑,下次复现就能直接跳过。
4.2 开发者视角:密码存储的正确姿势
实验跑完,落到实际开发里,我认为最值得分享的是密码存储的正确姿势。现在的行业共识已经很成熟,照着做基本不会有问题。
第一,密码绝不能明文存储,也不能用 MD5、SHA1 这类快速哈希直接存储。正确的做法是用专门的密码哈希算法,比如 bcrypt、scrypt、Argon2id,这类算法的共同特点是计算成本可控地"慢",让暴力破解的代价变得极高。第二,每个用户必须有独立随机盐,哪怕两个人设置了一模一样的密码,存储的哈希也要完全不同,这是对抗彩虹表和批量破解的基础。第三,密码存储之外,登录接口还要有频率限制、异常检测和多因素认证。口令被破解不可怕,可怕的是拿到口令之后还能畅通无阻。多因素认证可以把"密码泄露"从致命问题降级成"仅获取半个凭证"。
还有一个经常被忽略的点:要做弱密码拦截。用户在注册或修改密码时,系统应该实时校验密码是否在常见弱密码黑名单里,命中就直接拒绝。这个成本很低,只需要维护一份几千条规模的字典,但能从根本上杜绝 12345 这类密码进入你的系统。
4.3 普通用户视角:从改掉 12345 开始
最后聊聊普通用户怎么落地。我不是安全专家,但做完这个实验之后,我自己确实把密码策略改了一遍。不再指望"加个生日"或者"把字母首字母换成大写"能带来多少安全感,因为这些模式攻击者全都门儿清。
最有效的做法是两条。第一,用密码管理器生成并保存随机密码,这样每个站点都能用上十几位大小写加符号的独立密码,不需要记忆,只需要记住一个主密码。第二,实在不想用密码管理器的,至少学会用"口令短语",找一句只有自己知道、长度超过四五个词、和你的公开信息无关的话,比如某个童年场景的完整描述,再穿插几个特殊符号。这种密码的长度天然超过 15 位,穷举成本高到攻击者直接放弃。
做完这个"12345"实验,我的一个深刻体会是:安全建设最贵的往往不在技术,而在用户习惯。技术手段再完备,一个 12345 就能让所有努力归零。所以无论是开发者还是普通用户,第一步都是承认弱密码的存在,然后从自己能改的地方开始改。改完第一个弱密码,后面就好办了。
