/etc/shadow 里的密码从来不是明文,而是经过「算法 + 盐 + 多轮迭代」计算出来的 hash。本文从原理讲到生成,再到 Ansible 批量落地,给出一份生产可用的完整方案。

说明:文中所有用户名(opsuserappuser)、密码、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 比对

核心原则:

  1. 只用 $6$(SHA-512)或 $y$(yescrypt),禁用 $1$ MD5 和 DES
  2. 每个用户独立随机盐,长度 8~16
  3. Ansible playbook 一定加 no_log: true,hash 别进日志
  4. 明文密码用 Vault 加密,别提交到 git
  5. sudoers 文件用 validate 校验,避免语法错锁死 sudo

按这套方案,从单机到上千台机器批量管理用户密码,既安全又可追溯。

文章作者: emporer
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Emporer-Linux
喜欢就支持一下吧