Linux 密码 Hash 与盐:从 openssl 到 Ansible 全流程
/etc/shadow里的密码从来不是明文,而是经过「算法 + 盐 + 多轮迭代」计算出来的 hash。本文从原理讲到生成,再到 Ansible 批量落地,给出一份生产可用的完整方案。说明:文中所有用户名(
opsuser、appuser)、密码、hash 均为示例占位,仅用于演示;盐字符串也是示例,请在生产环境自行生成随机盐。
一、为什么 /etc/shadow 要存 hash
直接存明文密码是灾难——一旦 shadow 文件泄露(备份外泄、误配权限、容器镜像打包),所有账号立刻沦陷。存 hash 的好处:
- 不可逆:拿到 hash 也无法直接反推密码,只能暴力破解
- 加盐(salt):即使两个用户密码相同,hash 也完全不同,阻止彩虹表攻击
- 可调强度:现代算法支持多轮迭代(rounds),让暴力破解成本指数级上升
/etc/shadow 第二字段的格式:
$算法ID$盐$hash
例如:
$6$ab3.cD2e$gH8K3mPqLxRtYwZbNaCdEfGhIjKlMnOpQrStUvWxYz...
$6$→ SHA-512 算法ab3.cD2e→ 盐(8 位)- 后面 → 86 字符 base64 hash
二、/etc/shadow 支持的算法
按 crypt(3) 的 $id$ 标识排列:
| ID 标识 | 算法 | hash 长度 | 盐长度 | 安全性 | 推荐 |
|---|---|---|---|---|---|
$1$ |
MD5 crypt | 22 | 1~8 | ❌ 极弱 | ❌ 禁用 |
$2a$ / $2b$ / $2y$ |
bcrypt | 53 | 22 | ✅ 强 | ⭕ Web 应用 |
$5$ |
SHA-256 | 43 | 1~16 | ✅ 强 | ✅ 可用 |
$6$ |
SHA-512 | 86 | 1~16 | ✅✅ 很强 | ✅✅ 首选 |
$y$ |
yescrypt | 可变 | 可变 | ✅✅✅ 最强 | ✅✅✅ 新系统 |
$7$ |
scrypt | 可变 | 可变 | ✅✅✅ 最强 | ⭕ 罕见 |
DES(无 $) |
传统 crypt | 13 | 2 | ❌❌ 淘汰 | ❌ 禁用 |
选型建议
- 跨发行版通用 →
$6$SHA-512(所有 Linux 都支持) - RHEL 9 / Rocky 9 / Ubuntu 22.04+ →
$y$yescrypt(系统默认) - Web 应用密码库 →
$2b$bcrypt(业界标准) - 绝不再用 →
$1$MD5、DES
查看当前系统默认算法:
grep ENCRYPT_METHOD /etc/login.defs
# ENCRYPT_METHOD SHA512 ← 老系统
# ENCRYPT_METHOD YESCRYPT ← RHEL 9
查看现有用户使用的算法:
awk -F: '$2 ~ /^\$/ {split($2,a,"$"); print $1, "$"a[2]"$"}' /etc/shadow
# root $6$
# opsuser $y$
三、什么是盐(salt)
盐是一段随机字符串,与密码拼接后再做 hash,目的是让相同密码产生不同 hash。
密码: Secret123
盐: ab3.cD2e hash: $6$ab3.cD2e$A1B2C3...
盐: Xy9.zW8v hash: $6$Xy9.zW8v$Z9Y8X7... ← 同密码,不同结果
盐的合法字符集
/etc/shadow 的盐只接受 [A-Za-z0-9./],其它字符(@ $ ! - * #)会被截断或报错。新手常踩坑:
# ❌ 错误:盐含 @ 会被截断
-salt 'Abc@123' → 实际只用 'Abc'
# ✅ 正确:用合法字符
-salt 'Abc.123XY'
-salt 'Xyz789ab'
-salt 'Pqr/stu'
盐长度建议
- 最短 1 位(不安全),最长 16 位
- 生产建议 8~16 位随机字符
- 不要复用盐,每个用户一个独立盐
四、生成 $6$(SHA-512)hash 的方法
方法 1:openssl passwd -6(推荐,OpenSSL 1.1.0+)
# 随机盐(生产推荐,每次输出不同)
openssl passwd -6 'YourPassword'
# 固定盐(测试/可复现)
openssl passwd -6 -salt 'Abc.123XY' 'YourPassword'
输出示例:
$6$Abc.123XY$gH8K3mPqLxRtYwZbNaCdEfGhIjKlMnOpQrStUvWxYz0123456789+/abc...
⚠️ 版本坑:CentOS 7 自带的 OpenSSL 1.0.2 不支持
-6参数,会报错Usage: passwd...。这种情况用下面的方法 2 或方法 3。
方法 2:python3(最通用,所有 Linux 都能用)
# 固定盐
python3 -c "import crypt; print(crypt.crypt('YourPassword', '\$6\$Abc.123XY\$'))"
# 随机盐(用 mksalt 自动生成)
python3 -c "import crypt; print(crypt.crypt('YourPassword', crypt.METHOD_SHA512))"
方法 3:mkpasswd(whois 包,算法最全)
# 安装
yum install -y whois # RHEL / Rocky
apt install -y whois # Debian / Ubuntu
# 生成 SHA-512,固定盐
mkpasswd -m sha-512 -S 'Abc.123XY' 'YourPassword'
# 生成 SHA-512,随机盐
mkpasswd -m sha-512 'YourPassword'
# 生成 bcrypt(cost=12,强度可调)
mkpasswd -m bcrypt-a -R 12 'YourPassword'
方法 4:grub-crypt(系统自带,无需装包)
grub-crypt --sha-512
# 交互式输入密码两次,输出 hash(盐随机)
五、一键生成脚本
把上面几种方法封装成一个跨版本脚本:
#!/bin/bash
# gen-sha512-hash.sh — 生成 $6$ SHA-512 hash
# 用法: ./gen-sha512-hash.sh <密码> [盐]
PW="$1"
[ -z "$PW" ] && { echo "用法: $0 <密码> [盐]"; exit 1; }
# 用户给盐 → 过滤非法字符; 不给盐 → 随机生成 16 位
if [ -n "$2" ]; then
SALT=$(echo "$2" | tr -cd 'A-Za-z0-9./' | head -c 16)
else
SALT=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9./' | head -c 16)
fi
# 优先用新版 openssl,失败再回退 python
if openssl passwd -6 "$PW" >/dev/null 2>&1; then
# 新版 openssl:用固定盐或随机盐
if [ -n "$2" ]; then
openssl passwd -6 -salt "$SALT" "$PW"
else
openssl passwd -6 "$PW"
fi
else
python3 -c "import crypt; print(crypt.crypt('$PW', '\$6\$$SALT\$'))"
fi
运行:
chmod +x gen-sha512-hash.sh
# 随机盐(生产)
./gen-sha512-hash.sh 'YourPassword'
# 固定盐(测试)
./gen-sha512-hash.sh 'YourPassword' 'Abc.123XY'
六、Ansible 批量创建用户并设置密码
手动逐台改 shadow 不可持续,Ansible 是生产标准方案。下面是经过脱敏的完整 playbook,可直接套用。
目录结构
set_user/
├── create_users.yml ← 主 playbook
└── templates/
└── sudoers_opsuser.j2 ← sudoers 模板
主 playbook:create_users.yml
---
# ============================================================
# Playbook: 批量创建运维用户、设置加密密码、下发 sudoers 提权规则
# 适用场景:新建机房/批量装机后统一初始化运维账号
# ============================================================
- name: Ensure ops users exist with secure password and sudo access
hosts: all # 在 inventory 中所有主机上执行
gather_facts: no # 不收集远程主机 facts,加速执行(本 playbook 用不到 fact)
become: yes # 提权为 root(创建用户、改 sudoers 必需)
vars:
# ----- 用户列表:每个用户配一个预先算好的 $6$ hash -----
# 生产环境强烈建议把这段抽到 group_vars/all/vault.yml
# 并用 ansible-vault 加密,避免 hash 进 git
users:
- name: opsuser
password_hash: "$6$Abc.123XY$gH8K3mPqLxRtYwZbNaCdEfGhIjKlMnOp..." # 示例占位
- name: appuser
password_hash: "$6$Xy9.zW8v$Z9Y8X7W6V5U4T3S2R1Q0P9O8N7M6..." # 示例占位
# ----- 需要下发 sudoers 的用户名(对应 templates/sudoers_<name>.j2) -----
sudoers_templates:
- name: opsuser
tasks:
# ----- 任务 1:循环创建每个用户,设置 shell/家目录/密码 -----
- name: Ensure users exist and are properly configured
user:
name: "{{ item.name }}" # 用户名
state: present # present=确保存在(absent=删除)
shell: /bin/bash # 登录 shell
system: no # no=普通用户(yes=系统账号,UID<1000)
create_home: yes # 同时创建 /home/<name>
password: "{{ item.password_hash }}" # 写入 /etc/shadow 的 hash
update_password: always # always=每次运行都同步(改密用)
loop: "{{ users }}" # 对 users 列表逐项执行
no_log: true # ⛔ 必加:防止 hash 被打印到日志
notify: Validate sudoers syntax # 用户变更后触发 sudoers 校验
# ----- 任务 2:确保 /etc/sudoers.d 目录存在且权限正确 -----
# (放在下发 sudoers 文件之前,否则 template 任务会因目录不存在失败)
- name: Ensure /etc/sudoers.d directory exists with correct permissions
file:
path: /etc/sudoers.d
state: directory
mode: "0750" # sudoers.d 标准权限
changed_when: false # 目录通常已存在,不算状态变更
run_once: yes # 只在第一台主机执行一次(目录创建无需每台都做)
# ----- 任务 3:把 jinja 模板渲染成 sudoers 文件并下发 -----
- name: Create sudoers files from template
template:
src: "templates/sudoers_{{ item.name }}.j2" # 控制端模板路径
dest: "/etc/sudoers.d/{{ item.name }}" # 远端目标路径
mode: "0440" # sudoers 文件必须 0440
validate: "visudo -cf %s" # ⛔ 写入前先用 visudo 校验,
# 语法错就回滚,避免把 sudo 弄崩
loop: "{{ sudoers_templates }}"
notify: Validate sudoers syntax # 下发后再触发一次全文件校验
# ----- handlers:被 notify 触发,只在任务标记 changed 时执行,且每 play 末尾只跑一次 -----
handlers:
- name: Validate sudoers syntax
command: visudo -c -f /etc/sudoers # 校验整个 sudoers 主文件 + include 链
register: visudo_result # 保存命令输出供下面判断
changed_when: false # 校验命令不改系统状态
ignore_errors: yes # 先放行,交由 failed_when 精确判断
failed_when: "visudo_result.rc != 0 or 'syntax check failed' in visudo_result.stderr"
# 返回码非 0 或 stderr 含 'syntax check failed' 才判定失败,整 play 回滚
sudoers 模板:templates/sudoers_opsuser.j2
{# opsuser 可免密执行所有命令(生产环境请按需收紧,如只允许 systemctl/yum) #}
opsuser ALL=(ALL) NOPASSWD: ALL
几个关键设计点
| 点 | 说明 |
|---|---|
no_log: true |
user 模块会把 password_hash 打到日志,必须关掉,否则 hash 泄露 |
update_password: always |
每次运行都同步密码,方便改密(设为 on_create 则只首次设置) |
validate: "visudo -cf %s" |
template 模块自带校验,语法错就回滚,不会留下坏的 sudoers 把 sudo 弄崩 |
notify: Validate sudoers syntax |
双保险:handler 再跑一次全文件 visudo -c |
mode: "0440" |
sudoers 文件必须 0440,否则 visudo 报错 |
run_once |
/etc/sudoers.d 目录只需创建一次 |
七、用 Ansible Vault 加密敏感变量
上面的 password_hash 还是明文写在 playbook 里。生产环境必须用 vault 加密:
1. 把敏感变量拆到单独文件
# group_vars/all/vault.yml
users:
- name: opsuser
password_hash: "$6$示例hash..." # 用 vault 加密此文件
2. 加密
# 交互式输入 vault 密码
ansible-vault encrypt group_vars/all/vault.yml
# 或用密钥文件(CI/CD 友好)
echo 'MyVaultPass2026' > ~/.vault_pass
chmod 600 ~/.vault_pass
ansible-vault encrypt --vault-password-file ~/.vault_pass group_vars/all/vault.yml
加密后文件以 $ANSIBLE_VAULT;1.1;AES256 开头,cat 出来全是密文。
3. 运行时解密
ansible-playbook create_users.yml --ask-vault-pass
# 或
ansible-playbook create_users.yml --vault-password-file ~/.vault_pass
八、让 Ansible 自己生成 hash(无需手动算)
如果不想预先算 hash,可以让 Ansible 现场生成(注意:这样密码会以明文出现在变量里,必须配合 vault):
- name: Ensure user with on-the-fly hash
user:
name: opsuser
password: "{{ user_password | password_hash('sha512', 'Abc.123XY') }}"
shell: /bin/bash
vars:
user_password: "YourPassword" # 用 vault 加密
no_log: true
password_hash filter 支持的算法:
| 算法名 | 输出标识 |
|---|---|
sha512 |
$6$ |
sha256 |
$5$ |
md5 |
$1$(禁用) |
blowfish |
$2b$ |
九、验证与排查
验证密码正确性
# 方法 1:用 python 验证 hash 与密码是否匹配
python3 -c "
import crypt, sys
h = crypt.crypt('YourPassword', '\$6\$Abc.123XY\$')
print('OK' if h == sys.argv[1] else 'FAIL')
" '$6$Abc.123XY$xxx...'
# 方法 2:查 shadow 里 opsuser 的 hash
sudo getent shadow opsuser
# opsuser:$6$Abc.123XY$...:20349:0:99999:7:::
常见问题
| 现象 | 原因 | 解决 |
|---|---|---|
openssl passwd -6 报 Usage |
OpenSSL < 1.1.0 | 用 python3 或 mkpasswd |
盐含 @ 被截断 |
盐只接受 [A-Za-z0-9./] |
改用合法字符 |
| Ansible 日志泄露 hash | 没加 no_log: true |
加上 no_log |
| 改完密码登录失败 | hash 没用单引号,被 shell 转义 | YAML 里 hash 用双引号包住 |
| sudo 提示密码错误 | sudoers 文件权限非 0440 | chmod 440 /etc/sudoers.d/xxx |
| visudo 报语法错 | 模板内容有误 | 看 validate 的报错,修模板 |
检查 shadow 中是否有弱算法
安全合规常见检查项:
# 找出仍在用 $1$ (MD5) 或 DES 的用户
awk -F: '$2 ~ /^\$1\$/ || ($2 !~ /^\$/ && $2 != "*" && $2 != "!" && $2 != "!!") {print $1, $2}' /etc/shadow
发现弱算法立即升级:
# 升级单个用户
sudo passwd opsuser
# 批量升级(Ansible)
ansible all -m user -a "name=opsuser password={{ '新密码' | password_hash('sha512') }}"
十、总结
| 步骤 | 工具/命令 |
|---|---|
| 生成 hash | openssl passwd -6 / python3 -c "import crypt..." / mkpasswd -m sha-512 |
| 选择盐 | 随机 8~16 位 [A-Za-z0-9./] |
| 单机应用 | chpasswd -e / usermod -p |
| 批量应用 | Ansible user 模块 + no_log: true |
| 保护 hash | Ansible Vault 加密变量文件 |
| 验证 | getent shadow / python crypt 比对 |
核心原则:
- 只用
$6$(SHA-512)或$y$(yescrypt),禁用$1$MD5 和 DES - 每个用户独立随机盐,长度 8~16
- Ansible playbook 一定加
no_log: true,hash 别进日志 - 明文密码用 Vault 加密,别提交到 git
- sudoers 文件用
validate校验,避免语法错锁死 sudo
按这套方案,从单机到上千台机器批量管理用户密码,既安全又可追溯。