欢迎光临
我们一直在努力

Spring Boot 安全实战:从 MD5 裸奔到 BCrypt 平滑升级(附真实踩坑复盘)

/* 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,老用户全部登不进来。要么全量重置,要么做兼容层,两条路都要排期。

但"难"和"没办法"是两回事。兼容的正解至少有三种,当年就在各种迁移项目里被反复用过:

  • 首次登录强制改密,旧密码只活一次。
  • 惰性升级,登录成功那一刻把 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老炮踩坑录」,真实项目复盘,让你少走弯路。

    赞(0)
    未经允许不得转载:171主机测评 » Spring Boot 安全实战:从 MD5 裸奔到 BCrypt 平滑升级(附真实踩坑复盘)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址