/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
老炮踩坑录 · FIN-F01 · 翻车现场系列 基于「协作金融项目」真实源码,复盘一次我自己放行的 review 关键词:默认密码 123456 · 裸 MD5 · 无盐 · review 盲区 · 惰性升级
👋 欢迎阅读
🏠个人主页: 知守观 📘我的专栏: 老炮踩坑录 💻当前内容:一次 review 的复盘
文章目录
-
- 引子
- 现场:三个问题挤在两行代码里
- 破解这个 MD5 值要多久
- 我当时为什么放行
- 迁移的两难,正解一直都有
- 改法:新密码 BCrypt,存量惰性升级
- 自查清单:五个能直接搜的信号
- 老炮点评
引子
我记得安全测评报告寄到公司那天,前同事把标红的那页拍给我。三项:默认密码、MD5、无盐,全在同一个方法里。
他问:当年操作员模块是谁写的?
我盯着截图看了很久。写的人我记得,但那不是重点。重点是这段代码当年的合并请求,是我点的通过。
2022 年那个迁移项目,操作员模块的 review 我参与了。这行代码当时就在我眼前,我扫了一眼,就放行了。
这篇文章不是复盘别人,而是复盘我自己当时为什么放行。
现场:三个问题挤在两行代码里
方法就是 AccountController.add,原文没动:
/**
* 操作员设置(移植旧 AccountSetController/AccountSetDao,/orga/accountSet)
*/
@PostMapping
public Result<Void> add(@RequestBody Map<String, Object> body, HttpServletRequest request) {
String accCode = str(body.get("accCode"));
String accLoginName = str(body.get("accLoginName"));
String accName = str(body.get("accName"));
if (accCode == null || accCode.isBlank()) {
throw new BizException(400, "操作员编码不能为空");
}
if (accLoginName == null || accLoginName.isBlank()) {
throw new BizException(400, "登录名不能为空");
}
if (systemMapper.findAccountByCode(accCode) != null) {
throw new BizException(400, "操作员编码已存在");
}
if (systemMapper.countAccountByLoginName(accLoginName, null) > 0) {
throw new BizException(400, "登录名已存在");
}
String pwd = str(body.get("password"));
Map<String, Object> a = new HashMap<>();
a.put("accCode", accCode);
a.put("accLoginName", accLoginName);
a.put("accPwd", Md5Util.md5(pwd == null || pwd.isBlank() ? "123456" : pwd.trim()));
a.put("accName", accName);
a.put("administrator", str(body.getOrDefault("administrator", "0")));
a.put("enabled", str(body.getOrDefault("enabled", "1")));
a.put("orgCode", str(body.get("orgCode")));
a.put("tel", str(body.get("tel")));
systemMapper.insertAccount(a);
return Result.success("新增成功,默认密码 123456", null);
}
三个问题挤在两行里。
密码那行 a.put("accPwd", Md5Util.md5(pwd == null || pwd.isBlank() ? "123456" : pwd.trim())):前端不传密码就用 123456,长度和字符种类没有任何校验,传个 "1" 也照收。哈希是标准 MD5,无盐。
返回值那行 Result.success("新增成功,默认密码 123456", null):接口自己把默认密码写进响应体。会调这个接口的人,不用猜,不用撞,响应里就有答案。
Md5Util 的类注释把动机写得很明白:与旧系统 com.util.md5.MD5#MD5Encode 保持一致,标准 MD5、小写十六进制,旧库中已有的 MD5 密码可以直接校验通过。
实体类的字段注释是同一句话:登录密码(MD5 小写十六进制)。
Controller、工具类、实体,三层口径一致。包括我在内,没人提过异议。
破解这个 MD5 值要多久
我跑过一个 demo,纯好奇。
public class Md5CrackDemo {
// 库里的值,add 方法原样算出来的
static final String TARGET = DigestUtils
.md5DigestAsHex("123456".getBytes(StandardCharsets.UTF_8));
public static void main(String[] args) {
System.out.println("库里的哈希值: " + TARGET);
String[] dict = {
"123456", "12345678", "111111", "123123", "admin123",
"password", "qwerty", "abc123", "88888888", "666666",
"zhangsan123", "test123", "root123", "a123456", "1234567890",
"000000", "iloveyou", "1qaz2wsx", "1234567", "123456789"
};
long start = System.currentTimeMillis();
for (String pwd : dict) {
String hash = DigestUtils.md5DigestAsHex(pwd.getBytes(StandardCharsets.UTF_8));
if (hash.equals(TARGET)) {
System.out.println("命中: " + pwd);
}
}
System.out.println("耗时: " + (System.currentTimeMillis() – start) + " ms");
}
}
库里的哈希值: e10adc3949ba59abbe56e057f20f883e
命中: 123456
耗时: 0 ms
e10adc3949ba59abbe56e057f20f883e 这个值,在任何在线 MD5 反查站点输入进去,秒出 123456。不需要跑代码。
无盐还有一层后果:相同密码的哈希值相同。拿到全表的攻击者连撞都不用撞:
SELECT acc_pwd, COUNT(*) AS cnt FROM tb_account GROUP BY acc_pwd ORDER BY cnt DESC
排第一那行,大概率就是默认密码。反查一次,用这个密码的账号全部出来。
我当时为什么放行
这部分是这篇文章的正题。
那次 review 我的注意力全在功能等价上。编码唯一性查了,登录名唯一性查了,空值判了,和老系统的接口字段对得上。我核对的是"新系统能不能干老系统的活"。
密码那行我看到的时候,目光落在的是类头那句注释上——“移植旧 AccountSetController/AccountSetDao”。
有注释说明,我就默认这是有意为之。有意为之,背后就有原因。有原因,就不该在 review 里拦。
这套心理路径我现在能一步一步复述出来,因为当时不止这一处,整个迁移项目我都是这么审的。看到"与旧系统保持一致"就放行的地方,这个模块里至少还有三处,数据源配置那处单独写一篇:《数据源密码明文放在 application.yml 第 8 行》。
问题出在哪,现在看得很清楚:注释解释的是"代码为什么长这样",它回答不了"这样写对不对"。"移植旧系统"是个事实陈述,它说明这行代码有来历,不能说明这个来历在今天还成立。
旧系统在它的年代用 MD5,那是当时的常规水平。2022 年的新系统照搬,等于把旧系统的历史包袱原封不动搬进新代码。这个道理我 2022 年就懂,review 的时候就是没想到要套用。
评审的盲区从来不在技术储备上,在于把 ‘有解释 ’ 当成了 ‘有理由’。
迁移的两难,正解一直都有
替当年的自己也替写代码的人说句公道话:迁移项目换哈希算法,确实有真实约束。
旧表 tb_account 里存着存量用户的 MD5 值。新系统直接换 BCrypt,老用户全部登不进来。要么全量重置,要么做兼容层,两条路都要排期。
但"难"和"没办法"是两回事。兼容的正解至少有三种,当年就在各种迁移项目里被反复用过:
三种做法的成本都不算高。这套代码选了第四条路:原样照搬,然后在二期优化里等排期。一期上线,二期没排期。
改法:新密码 BCrypt,存量惰性升级
分两层。
// spring-security-crypto 单独引就行,不用引整个 Spring Security
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
// 存储
a.put("accPwd", encoder.encode(rawPwd));
// 校验
if (encoder.matches(rawPwd, storedHash)) { ... }
// 存量的兼容层,加在登录环节
if (isLegacyMd5(storedHash)) {
// 旧格式:MD5 校验通过后,立刻用新算法重存
if (legacyMd5Matches(rawPwd, storedHash)) {
accountMapper.updatePwd(accCode, encoder.encode(rawPwd));
return loginSuccess(user);
}
return loginFailed();
}
// 新格式:BCrypt 直接校验
BCrypt 自带随机盐,慢因子默认 10,金融系统可以调到 12。同一个密码 encode 两次结果不一样,比对交给 matches。
存量那块不用动表结构。新哈希统一加 {bcrypt} 前缀做格式标记,没前缀的按旧格式走。再配一份改密期限公告,到期没登录过的账号旧 MD5 作废。期限一到,库里的 MD5 自然清零。
接口层配套四件事:补密码复杂度校验,长度加字符种类;删掉 123456 兜底,改成生成随机初始密码走线下渠道给机构管理员;删掉返回值里那句"默认密码 123456";加上首次登录强制改密。
有个细节我没完全确认:BCrypt 慢因子从 10 调到 12,单次校验耗时会翻几倍,登录高峰期的接口耗时影响我手头没有实测数据。我做的项目登录并发不高,调完没感觉。并发高的系统建议先在测试环境压一遍再定,有实测过的兄弟评论区报个数。
自查清单:五个能直接搜的信号

自查打卡:在你的项目里搜一下 "123456",看看命中在哪几个文件里。数字打在评论区,我猜中后台系统的命中率不会低。
老炮点评
这次复盘对我最有用的部分,是搞明白了自己当时怎么想的。
评审看到注释就放行的毛病,我相信不止我一个人有。注释在 review 里的作用很容易错位:它本来是写给维护者看的背景说明,到了评审者眼里,它变成了"作者已经想过这个问题"的信号。想过,就等于想对了。
这个等式不成立。"与旧系统保持一致"只证明作者知道自己写了什么,证明不了他写的东西该存在。
迁移项目的 review 我现在的做法多了一步:凡是带着"移植"、“兼容旧系统”、"保持一致"这类注释的代码,单独拎出来多问一句——这个旧做法在今天的环境里还成立吗。问这一句只要十秒,2022 年我一次都没问过。
测评报告上那三项标红,写在代码里的是写代码的人,放进生产的是我这个点通过的人。这笔账一人一半。
最后问一句:你 review 别人的代码时,看到 “移植旧系统”、“保持兼容” 这类注释,会直接放行还是会多问一句?有没有放行之后又后悔的经历?评论区聊聊。
如果本文对你有帮助,欢迎:
👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言
下期预告:《数据源密码明文放在 application.yml 第 8 行》
操作员密码这处是我放行的第二处,第一处是数据源配置。同一个项目的配置文件里,数据库密码明文躺着,跟着 Git 仓库发给了每一个有代码权限的人。
下期讲配置文件里的密钥该怎么管,以及已经进了仓库的密码该怎么收场。
我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,真实项目复盘,让你少走弯路。






