知识广场

按学科筛选:计算机科学 / 信息安全
清除筛选

«计算机科学 / 信息安全» 分类下共 12 篇帖子

用 fail2ban 阻断 SSH 暴力破解(生产实用配置)

任何挂公网的 SSH 端口每天会收到几千到几万次扫描尝试。 真正的防线是禁用密码登录 + 只用密钥,但 fail2ban 作为第二道防线 能进一步降低日志噪音、屏蔽明显的恶意 IP 段。 ## 安装 ```bash sudo apt install -y fail2ban sudo systemctl enable --now fail2ban ``` ## 配置(重点:local 覆盖,不动 .conf) **永远不要直接改 `/etc/fail2ban/jail.conf`**——升级会被覆盖。改 `.local`: `/etc/fail2ban/jail.local`: ```ini [DEFAULT] # 屏蔽时长:从 10 分钟开始指数退避,最多 1 周 bantime = 10m bantime.increment = true bantime.factor = 2 bantime.maxtime = 1w # 检测窗口:10 分钟内 findtime = 10m maxretry = 5 # 不要 ban 自己 ignoreip = 127.0.0.1/8 ::1 192.168.0.0/16 10.0.0.0/8 # 后端:systemd journal(无 /var/log/auth.log 时必选) backend = systemd # 用 nftables 而不是默认的 iptables banaction = nftables-multiport [sshd] enabled = true port = ssh logpath = %(sshd_log)s maxretry = 3 ``` `bantime.increment = true` 让累犯越关越久:第一次 10 分钟,第二次 20, 然后 40、80...... 最长 1 周。绝大多数扫描脚本几次就放弃换下一个 IP。 ## 启用 + 校验 ```bash sudo systemctl restart fail2ban sudo fail2ban-client status # Number of jail: 1 # Jail list: sshd sudo fail2ban-client status sshd # Currently failed: ... # Currently banned: ... # Banned IP list: 1.2.3.4 5.6.7.8 ... ``` ## 实时观察 ```bash sudo tail -f /var/log/fail2ban.log # 或者 journal: sudo journalctl -u fail2ban -f ``` ## 手动操作 IP ```bash # 立即解封 sudo fail2ban-client unban 1.2.3.4 # 永久 ban sudo fail2ban-client set sshd banip 1.2.3.4 ``` ## 验证 nftables 规则 ```bash sudo nft list set inet f2b-table addr-set-sshd # table inet f2b-table { # set addr-set-sshd { # elements = { 1.2.3.4, 5.6.7.8 } # } # } ``` ## 配合其它服务 fail2ban 自带几十个 jail(nginx-botsearch、postfix、recidive 等)。 常见组合: ```ini [nginx-botsearch] enabled = true filter = nginx-botsearch logpath = /var/log/nginx/access.log maxretry = 3 findtime = 2m [recidive] # 反复在多个 jail 触发的 IP,全局长期 ban enabled = true bantime = 1w findtime = 1d maxretry = 5 ``` `recidive` 是元 jail:观察 fail2ban 自己的日志,把"在多个 jail 都被 ban 过"的 IP 长期屏蔽,效果非常好。 ## 踩过的坑 - 把自己 ban 了:从 Console / out-of-band 连进去,`fail2ban-client unban <你的IP>`, 然后把 ignoreip 加上你常用的 IP 段。 - `backend = systemd` 必须在 `[DEFAULT]` 段,写错位置会被忽略,filter 看 不到任何日志,但状态看着一切正常 —— 一定要看 `failed` 数字是不是在增长。 - IPv6 时代,单 IP ban 几乎没意义;建议改 `banaction = nftables-multiport[blocktype=drop]` 按整段 ASN ban,或者干脆把 SSH 端口改成非 22,噪音少 90%+。

HashiCorp Vault:动态 secret + 自动 rotation

## 起因 应用要数据库密码 / API key / cloud credential。 反模式: - secret 写 .env 文件 commit git(最差) - 环境变量(多 deploy 多份) - AWS Secrets Manager 静态(仍要手动 rotate) - K8s Secret base64(任何 cluster admin 看见) **Vault**: - 动态生成 secret(每应用 / 每会话独立 credential) - TTL + 自动 revoke - 审计 log 每次访问 - 多 backend(DB / cloud / PKI / ...) ## 装 ```bash # dev 模式(生产不能用) vault server -dev # prod (单机) vault server -config=vault.hcl ``` ```hcl # vault.hcl ui = true storage "raft" { path = "/var/lib/vault" node_id = "node1" } listener "tcp" { address = "0.0.0.0:8200" tls_cert_file = "/etc/vault/cert.pem" tls_key_file = "/etc/vault/key.pem" } ``` init + unseal: ```bash vault operator init # 显示 unseal key + root token vault operator unseal <key1> vault operator unseal <key2> vault operator unseal <key3> ``` Shamir secret sharing:5 个 unseal key 中 3 个能解锁。 ## 静态 secret (KV) ```bash vault kv put secret/myapp/db password=secret123 username=admin vault kv get secret/myapp/db ``` 应用读: ```python import hvac client = hvac.Client(url='https://vault:8200', token='...') secret = client.secrets.kv.v2.read_secret_version(path='myapp/db') db_pass = secret['data']['data']['password'] ``` 跟 AWS Secrets Manager 类似,但 KV 不是 Vault 杀手锏。 ## 动态 secret (DB) 杀手 feature:vault 实时生成 DB credential。 ```bash # 配 PG backend vault secrets enable database vault write database/config/myapp-pg \ plugin_name=postgresql-database-plugin \ connection_url="postgresql://{{username}}:{{password}}@pg:5432/myapp" \ allowed_roles="readonly,readwrite" \ username="vault_admin" \ password="..." # 定义 role vault write database/roles/readonly \ db_name=myapp-pg \ creation_statements="CREATE USER \"{{name}}\" WITH PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \ GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \ default_ttl="1h" \ max_ttl="24h" ``` 应用请求: ```bash vault read database/creds/readonly # username = v-token-readonly-xxx # password = randompw # lease_id = ... # lease_duration = 3600 ``` vault 实时在 PG 里 CREATE USER + GRANT,返回临时 credential。 1 小时后自动 DROP USER → credential 失效。 ## 应用集成 ```python import hvac client = hvac.Client(url='https://vault:8200') client.auth.kubernetes.login(role='myapp', jwt=open('/var/run/secrets/k8s/jwt').read()) creds = client.secrets.database.generate_credentials('readonly') username = creds['data']['username'] password = creds['data']['password'] # 用 credential 连 PG db = psycopg2.connect(host='pg', user=username, password=password) ``` 每应用启动拿一份独立 credential。 应用挂了 / 重启 → 新 credential。 泄露 1 个 credential → 影响仅那 1 个,1 小时后自动废。 ## auth method 应用怎么登 Vault? - **token**:root token / 长期 token(不推荐) - **AppRole**:app + role-id + secret-id(机器登录) - **Kubernetes**:pod service account JWT 自动登 - **AWS / GCP / Azure**:cloud IAM identity - **OIDC**:人通过 SSO K8s 集成最常用: ```yaml # Pod 自动有 service account token spec: serviceAccountName: myapp ``` Vault 配 K8s auth → JWT 验证后给 Vault token。 ## PKI (cert) vault 当内部 CA: ```bash vault secrets enable pki vault write pki/root/generate/internal common_name="myorg" ttl=8760h ``` 应用请求 cert: ```bash vault write pki/issue/myapp common_name=myapp.example.com ttl=24h # 返回 cert + key + ca chain ``` 服务每天 rotate cert,过期自动失效。 比 Let's Encrypt 还自动化(内部 service 间 mTLS)。 ## audit log ```bash vault audit enable file file_path=/var/log/vault/audit.log ``` 每次 secret 访问 log: ```json { "time": "2025-03-14T...", "auth": {"display_name": "k8s-myapp-pod-xxx"}, "request": {"path": "database/creds/readonly", "operation": "read"}, "response": {...} } ``` 事后审计:谁什么时候拿了什么 secret。compliance 必要。 ## 与 AWS Secrets Manager 对比 | | Vault | AWS Secrets Manager | |---|---|---| | 动态 secret | ✅ (DB / cloud / SSH) | 弱(仅 DB rotation) | | 多 cloud | ✅ | AWS only | | PKI | ✅ | ❌ | | 自托管 | ✅ | ❌ | | 价格 | OSS 0 | $0.40 / secret / month | | 复杂度 | 高 | 低 | 简单需求 → AWS Secrets Manager 省事。 多 cloud / 动态 secret 重 / 严格合规 → Vault。 ## sealed-secret(K8s 平民方案) 不想上 Vault 但又不想 git 存 secret: ```bash # 生成 sealed-secret echo -n "secret123" | kubectl create secret generic mysecret --dry-run=client --from-file=password=/dev/stdin -o yaml | kubeseal -o yaml > sealed.yaml ``` sealed.yaml 加密,commit git 安全。 集群里 sealed-secret-controller 解密 → 创建普通 K8s Secret。 简单 + GitOps friendly,但没动态 / rotation / audit。 小项目 OK,企业级要 Vault。 ## external-secrets operator K8s + 外部 secret store(AWS SM / Vault / GCP SM 等): ```yaml apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: name: db-pass spec: refreshInterval: 1h secretStoreRef: name: vault-backend kind: ClusterSecretStore target: name: db-pass-secret data: - secretKey: password remoteRef: key: secret/myapp/db property: password ``` operator 从 Vault 同步到 K8s Secret,应用用 K8s Secret 不变。 适合:"想用 Vault 集中管 secret + 应用不用改"。 ## Vault as service mesh (Consul Connect) Vault + Consul 一起做 mTLS service mesh。 某些场景替代 Istio。 ## 真实部署 我们 prod: - Vault HA cluster(3 node Raft) - AWS RDS / Redis / Slack API / GitHub token 都 Vault 管 - K8s auth method(pod 自动 login) - DB credential 1h TTL - audit log → Loki 效果: - 0 secret in git - credential leak 不再 catastrophic(1 小时失效) - audit 自动 → SOC2 / ISO 文档自动生成 挑战: - Vault 自身要 HA(挂了应用拿不到 secret → 失败) - unseal key 灾备(多人持有) - 学习曲线(团队培训几周) ## 踩过的坑 1. **unseal key 丢**:Vault crash 后没人能 unseal → 数据丢。 3 人各持 1 把 key + 异地存。 2. **lease 不 renew**:长跑应用 credential 1h 过期后失败。要么续 lease 要么处理 reauth。 3. **policy 写错**:太宽 → app 能读其它 app secret。policy review。 4. **Vault 单点**:3 节点 HA 但 quorum 挂 → 全停。raft snapshot 备份。 5. **dev 模式记 prod**:`vault server -dev` 数据 in-memory,重启丢。 生产绝不用 dev mode。

SSH 密钥管理:从生成到 ssh-agent 到硬件 key 三阶段

SSH 密钥是日常运维 / git 协作的基础。新人常踩的坑: - 一个 key 用到所有地方 - 私钥没加密 - 失去机器后忘了删 key 的服务端授权 - 多个机器多个 key 没区分 下面是一套实用的 SSH 密钥管理流程。 ## 1. 生成密钥 ```bash ssh-keygen -t ed25519 -C 'name@laptop' -f ~/.ssh/id_ed25519 # 提示 passphrase:建议设一个强密码 ``` `-t ed25519` 推荐;`rsa 4096` 兼容性最广但比 ed25519 慢。 `-C` 是注释,写"哪个用户在哪台机器",方便服务端管理识别。 `-f` 指定文件名。我习惯: - `~/.ssh/id_ed25519` 个人主密钥(用得最多) - `~/.ssh/id_ed25519_work` 工作账号 - `~/.ssh/id_ed25519_github` GitHub 专用 ## 2. 一个 key 不要到处用 理由: - 一处泄露 → 所有地方都泄露 - 不同身份混淆(个人 git / 公司 git / VPS) - 撤销时全部更新很麻烦 按维度分: - 个人 vs 公司 - 永久机器(笔记本)vs 临时机器(VPS / 开发容器) - GitHub / GitLab 各一把(很多 git host 拒绝重复 key) ## 3. ssh-agent:缓存解锁的私钥 ```bash # 启 agent(GNOME / KDE 默认已经起好) eval $(ssh-agent) # 加 key(要输 passphrase 一次) ssh-add ~/.ssh/id_ed25519 ssh-add ~/.ssh/id_ed25519_work # 看已加载的 key ssh-add -l # 256 SHA256:xxx... name@laptop (ED25519) # 删某个 ssh-add -d ~/.ssh/id_ed25519_work # 全删(退出前清干净) ssh-add -D ``` 之后所有 ssh / scp / git 不再要密码。 ## 4. agent 自动启动 + key 自动加载 `~/.zshrc` 或 `~/.bashrc`: ```bash # 启 ssh-agent 如果没运行 if [ -z "$SSH_AUTH_SOCK" ]; then eval $(ssh-agent -s) >/dev/null fi # 自动加载尚未加载的 key if ! ssh-add -l 2>/dev/null | grep -q "$HOME/.ssh/id_ed25519"; then ssh-add ~/.ssh/id_ed25519 2>/dev/null fi ``` macOS 用 keychain 集成更优雅: ```bash ssh-add --apple-use-keychain ~/.ssh/id_ed25519 ``` 密码存系统 keychain,重启后自动加载。 ## 5. authorized_keys 服务端 每台机器的 `~/.ssh/authorized_keys` 是允许登录的公钥列表。 推送公钥的标准方式: ```bash ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server # 自动追加到 ~user/.ssh/authorized_keys + 设权限 ``` 手动方式(如果 ssh-copy-id 不可用): ```bash cat ~/.ssh/id_ed25519.pub | ssh user@server \ 'mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys' ``` ## 6. 给授权 key 加限制 服务端 `authorized_keys` 每行前面可以加 options: ``` # 只允许从某 IP from="203.0.113.5" ssh-ed25519 AAAA... user@laptop # 不允许 shell / 端口转发 / agent forward;只能跑特定命令 command="/usr/local/bin/backup.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA... backup-runner # 过期时间 expiry-time="20251231" ssh-ed25519 AAAA... contractor ``` `command` 限制是给 backup / 监控这种"只跑一个脚本"的服务账号用的。 登录的人开 shell 也只跑这个命令,比开 shell 安全得多。 ## 7. 定期审计 服务端列所有授权 key: ```bash for u in $(cut -d: -f1 /etc/passwd); do if [ -f /home/$u/.ssh/authorized_keys ] || [ -f /root/.ssh/authorized_keys ]; then echo "=== $u ===" cat /home/$u/.ssh/authorized_keys 2>/dev/null [ "$u" = "root" ] && cat /root/.ssh/authorized_keys 2>/dev/null fi done ``` 发现陌生 key / 离职同事 key → 立刻删。 ## 8. 服务端 sshd 加固 `/etc/ssh/sshd_config`: ``` # 禁密码登录(强制只用密钥) PasswordAuthentication no PubkeyAuthentication yes ChallengeResponseAuthentication no KbdInteractiveAuthentication no # 禁 root 直接登录 PermitRootLogin prohibit-password # 或 no(彻底禁) # 限制允许登录的用户 AllowUsers alice bob deploy # 或:AllowGroups sshusers # 减少协议噪音 ListenAddress 0.0.0.0 Port 22 # 客户端不活动时断开 ClientAliveInterval 300 ClientAliveCountMax 2 ``` ```bash sudo sshd -t # 语法检查 sudo systemctl reload ssh ``` ## 9. 硬件 key(YubiKey / 谷歌 Titan) 最强方案:私钥永远不在磁盘 / 内存上,存硬件芯片: ```bash # 生成 FIDO2 / U2F 密钥(保存在 YubiKey) ssh-keygen -t ed25519-sk -f ~/.ssh/id_ed25519_yubi # resident key(可以从其它机器恢复) ssh-keygen -t ed25519-sk -O resident -f ~/.ssh/id_ed25519_yubi ``` 之后每次 ssh 都需要触摸 YubiKey 金属片。 恢复 resident key 到新机器: ```bash ssh-keygen -K # 把 YubiKey 上所有 resident key 提取到当前目录 ``` 私钥本质上是在 YubiKey 里,磁盘文件只是"指针"。丢硬件 = 数字身份废掉 (但攻击者拿不到密钥)。 ## 10. SSH CA(企业规模时) 机器多了,每次给新人加 key 改几百台机器 authorized_keys 不现实。 SSH CA 系统: - 一个 CA 私钥签发用户证书(包含 username + 有效期 + 权限) - 每台机器 `TrustedUserCAKeys = /etc/ssh/ca.pub` - 用户拿到带签名的临时证书登录,到期失效 ```bash # 签发 ssh-keygen -s ca-key -I alice@2026-05 -n alice -V +1d \ alice_id_ed25519.pub # 输出 alice_id_ed25519-cert.pub # 登录时自动用 cert ssh server # ssh 会读 cert + key ``` 工具:Vault SSH backend / Smallstep / Teleport / sssd。 ## 踩过的坑 - 公钥 vs 私钥:`*.pub` 是公钥可以随便发;没后缀那个是私钥 **永远不发**。 新手把 `id_ed25519` 而不是 `id_ed25519.pub` 贴 GitHub 是经典错误。 - 私钥权限:必须 600;775 / 644 sshd 会拒绝使用。`chmod 600 ~/.ssh/id_*`。 - `.ssh/authorized_keys` 权限:必须 600;目录必须 700。否则 sshd 静默 忽略整个文件,登录失败查不到原因(auth log 才会有提示)。 - ssh-agent forwarding(`ForwardAgent yes`):跳板机 root 用户能拿你的 key 用,相当于把信任传递给跳板机。除非完全可信,否则用 ProxyJump 代替 agent forward。

JWT + Refresh Token:access 短 + refresh 长 + 安全撤销

无状态鉴权用 JWT 是行业标准,但很多实现有安全 / UX 问题: - access token 一次性给 24 小时:泄露后窗口太长 - 永远不轮换:被偷了没法停 - 把 JWT 放 localStorage:XSS 直接拿走 正确做法:**access token 短(5-15 分钟)+ refresh token 长(7-30 天)+ refresh 时轮换**。 ## 1. 流程概览 ``` 登录 → 返回 access (15min) + refresh (7d) API 请求 → 带 access token access 过期 → 用 refresh 拿新的 access + 新的 refresh(轮换) 退出 → 把 refresh token 加入服务端 blacklist ``` ## 2. 后端:FastAPI 例子 ```python from datetime import datetime, timedelta import secrets import jwt from fastapi import FastAPI, HTTPException, Depends, Response, Cookie from passlib.hash import bcrypt SECRET = 'long-random-secret-from-env' ACCESS_TTL = timedelta(minutes=15) REFRESH_TTL = timedelta(days=7) app = FastAPI() def create_access_token(user_id: str) -> str: return jwt.encode({ 'sub': user_id, 'type': 'access', 'exp': datetime.utcnow() + ACCESS_TTL, 'jti': secrets.token_urlsafe(8), }, SECRET, algorithm='HS256') def create_refresh_token(user_id: str) -> tuple[str, str]: jti = secrets.token_urlsafe(16) token = jwt.encode({ 'sub': user_id, 'type': 'refresh', 'exp': datetime.utcnow() + REFRESH_TTL, 'jti': jti, }, SECRET, algorithm='HS256') return token, jti # 登录 @app.post('/auth/login') def login(email: str, password: str, response: Response): user = db.find_user(email) if not user or not bcrypt.verify(password, user.password_hash): raise HTTPException(401) access = create_access_token(str(user.id)) refresh, refresh_jti = create_refresh_token(str(user.id)) db.save_refresh_token(user_id=user.id, jti=refresh_jti, ip=request.client.host) # Refresh token 放 HttpOnly Cookie,不让 JS 碰 response.set_cookie( 'refresh_token', refresh, httponly=True, secure=True, samesite='strict', max_age=int(REFRESH_TTL.total_seconds()), path='/auth', ) return {'access_token': access, 'expires_in': int(ACCESS_TTL.total_seconds())} ``` ## 3. 用 access token 访问 API ```python def current_user(authorization: str = Header(...)) -> User: if not authorization.startswith('Bearer '): raise HTTPException(401) token = authorization[7:] try: payload = jwt.decode(token, SECRET, algorithms=['HS256']) except jwt.ExpiredSignatureError: raise HTTPException(401, detail='token_expired') except jwt.InvalidTokenError: raise HTTPException(401) if payload['type'] != 'access': raise HTTPException(401) return db.get_user(payload['sub']) @app.get('/api/me') def me(user = Depends(current_user)): return {'id': user.id, 'email': user.email} ``` ## 4. 刷新 ```python @app.post('/auth/refresh') def refresh(response: Response, refresh_token: str = Cookie(None)): if not refresh_token: raise HTTPException(401) try: payload = jwt.decode(refresh_token, SECRET, algorithms=['HS256']) except jwt.InvalidTokenError: raise HTTPException(401) if payload['type'] != 'refresh': raise HTTPException(401) user_id, jti = payload['sub'], payload['jti'] # 关键安全检查:refresh token 还在白名单里吗? stored = db.get_refresh_token(user_id, jti) if not stored or stored.revoked: raise HTTPException(401, detail='refresh_revoked') # 轮换:把旧 refresh 撤销,发新的 db.revoke_refresh_token(jti) new_access = create_access_token(user_id) new_refresh, new_jti = create_refresh_token(user_id) db.save_refresh_token(user_id=user_id, jti=new_jti, ip=request.client.host) response.set_cookie('refresh_token', new_refresh, httponly=True, ...) return {'access_token': new_access, 'expires_in': int(ACCESS_TTL.total_seconds())} ``` 每次刷新都换新 refresh token,旧的立即作废。被攻击者偷了旧 refresh 后, 用户下次正常刷新会让攻击者那份失效 → 攻击者被踢出。 ## 5. 退出 ```python @app.post('/auth/logout') def logout(response: Response, refresh_token: str = Cookie(None)): if refresh_token: try: payload = jwt.decode(refresh_token, SECRET, algorithms=['HS256']) db.revoke_refresh_token(payload['jti']) except jwt.InvalidTokenError: pass response.delete_cookie('refresh_token', path='/auth') return {'ok': True} ``` access token 因为短(15 分钟)不需要单独撤销 —— 自然过期。 真要立即吊销,加一个"用户已注销 / 改密"的 token version 字段: ```python # 在 access token 里加 user.token_version # 修改密码 / 强制下线时 user.token_version += 1 # 校验时检查 payload['ver'] == user.token_version ``` ## 6. 前端处理 ```ts // 拦截器:401 时自动 refresh + 重试 api.interceptors.response.use( r => r, async (err) => { if (err.response?.status === 401 && err.response?.data?.detail === 'token_expired') { const r = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' }) if (r.ok) { const { access_token } = await r.json() saveAccessToken(access_token) // 重试原请求 err.config.headers.Authorization = `Bearer ${access_token}` return api.request(err.config) } // refresh 也失败 → 强制重新登录 window.location = '/login' } return Promise.reject(err) } ) ``` 注意: - access token 存内存(不要 localStorage / sessionStorage —— XSS 风险) - refresh token 走 HttpOnly cookie(JS 拿不到) - API fetch 加 `credentials: 'include'` 才会发送 cookie ## 7. 防 CSRF refresh 通过 cookie 发 → CSRF 风险。两种防御: 1. **SameSite=Strict cookie**:跨站请求不发 cookie(前面已经设了) 2. **CSRF token**:refresh 要求 header 里带 CSRF token(额外参数) SameSite=Strict 已经能挡 99% CSRF;想更严就加 CSRF token。 ## 8. 算法选择 - HS256:HMAC + 共享 secret。最简单,单后端 OK - RS256:RSA 非对称。分布式系统(验证方 ≠ 签发方)必选 - EdDSA / Ed25519:性能 + 安全比 RSA 更好(新版 PyJWT 支持) 不要用 `alg: 'none'`(无签名)—— 历史上多次造成漏洞。 ## 9. 密钥管理 ```bash # 生成强 secret openssl rand -base64 64 ``` 存 `.env`,不要进 git。 定期轮换: 1. 部署新 SECRET + 让代码支持 fallback 验证(旧 + 新两个 key) 2. 等所有老 token 过期(access 15 min + refresh 7d = 7 天后) 3. 移除老 SECRET ## 10. 单点登录 / SSO 业务复杂后用 OIDC(OpenID Connect)—— OAuth2 + 身份层。 Auth0 / Keycloak / Authelia / authentik 提供完整流程。 自己不要从零造身份系统。 ## 踩过的坑 - access TTL 设 24h:泄露窗口太长。15 分钟 + refresh 是 sweet spot。 - refresh 不轮换:被偷了攻击者长期持有。每次刷新换新 refresh + 撤销旧的。 - 把 JWT 放 localStorage:XSS 直接读走。永远内存(access)+ HttpOnly cookie (refresh)。 - 算法降级攻击:服务端用 `jwt.decode(token, key)` 但没指定 algorithms → 攻击者用 `alg: 'none'` 假签名通过。**永远指定 `algorithms=['HS256']`**。

让 Debian / Ubuntu 自动打安全补丁(unattended-upgrades 实战配置)

服务器最常见的安全事故不是 0day,而是已经有补丁但没装。 `unattended-upgrades` 让 Debian 家族的服务器自动应用安全更新, 代价只是十分钟的配置。 适用:单机 / 小集群。集群规模上去后用 Ansible / Salt 统一管。 ## 安装 ```bash sudo apt update sudo apt install -y unattended-upgrades apt-listchanges sudo dpkg-reconfigure --priority=low unattended-upgrades # 这一步会写一个最小的 /etc/apt/apt.conf.d/20auto-upgrades ``` ## 配置——只装安全更新 `/etc/apt/apt.conf.d/50unattended-upgrades` 是核心配置。 最小工作版: ``` Unattended-Upgrade::Allowed-Origins { "${distro_id}:${distro_codename}-security"; "${distro_id}ESMApps:${distro_codename}-apps-security"; "${distro_id}ESM:${distro_codename}-infra-security"; }; Unattended-Upgrade::Package-Blacklist { "linux-image-*"; // 自己挑时间重启更稳 "linux-headers-*"; }; Unattended-Upgrade::Remove-Unused-Kernel-Packages "true"; Unattended-Upgrade::Remove-Unused-Dependencies "true"; Unattended-Upgrade::Automatic-Reboot "false"; // 如果允许自动重启,把上面改为 true,再设: // Unattended-Upgrade::Automatic-Reboot-Time "04:00"; Unattended-Upgrade::Mail "[email protected]"; Unattended-Upgrade::MailReport "on-change"; ``` 只放 `*-security` 源,不会把 `-updates`(功能更新)也拉进来, 风险最小。 ## 调度 `/etc/apt/apt.conf.d/20auto-upgrades`: ``` APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Download-Upgradeable-Packages "1"; APT::Periodic::AutocleanInterval "7"; APT::Periodic::Unattended-Upgrade "1"; ``` 四个数字单位是 "天"。上面这套是:每天 update + 下载 + 跑 unattended, 每周 autoclean 一次缓存。 实际触发由 systemd timer 接管: ```bash systemctl list-timers apt-daily.timer apt-daily-upgrade.timer ``` ## 演练 + 校验 ```bash # 不实际安装,只显示会做什么 sudo unattended-upgrade --dry-run --debug | tail -30 # 看上次实际运行的日志 sudo tail -50 /var/log/unattended-upgrades/unattended-upgrades.log # 看是否需要重启(更新内核 / glibc / 某些库后会显示) ls /var/run/reboot-required 2>/dev/null && \ cat /var/run/reboot-required.pkgs ``` ## 邮件通知(可选) 要收 `MailReport` 邮件,本机需要能寄信。最轻量是 msmtp + cron-aliases: ```bash sudo apt install -y msmtp msmtp-mta bsd-mailx ``` ``` # /etc/msmtprc account default host smtp.example.com port 587 auth on user notifier password ... from [email protected] tls on tls_starttls on ``` `sudo chmod 600 /etc/msmtprc`,然后 `echo test | mail -s 'hi' [email protected]` 能收到就 OK。 ## 踩过的坑 - `Automatic-Reboot "true"` 在跑数据库或长任务的机器上是灾难。 默认关掉,自己写个监控脚本看到 `/var/run/reboot-required` 就告警。 - 某些第三方 PPA 的源 origin 字符串不是 `Ubuntu:jammy-security`,需要 把它显式加进 `Allowed-Origins`,否则永远装不上更新。可以用 `apt-cache policy <package>` 看具体的 origin。 - 关闭 SSH 期间不要触发自动重启 —— 可能升级了 openssh-server 后 sshd 没起来。 我们的做法是把 `Automatic-Reboot-Time` 设在白天有人值班的时段。

新装 Linux 服务器的 14 项安全加固 checklist

## 起因 每开一台新服务器都重复同样的"装好后第一件事"。下面这 14 项是我每次 都做的,从攻击者最常用入口 → 防御。 ## checklist ### 1. 关闭 root SSH ```bash sudo sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config sudo sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config sudo systemctl reload ssh ``` 只允许密钥 + 非 root 用户登录。改之前**确认**你有正常用户 + 密钥能登: ```bash # 新终端测试 ssh me@server # 必须能进 sudo whoami # 必须能 sudo ``` 否则改完会把自己锁外面。 ### 2. 创建非 root 用户 + 加 sudo ```bash sudo adduser me sudo usermod -aG sudo me # 拷贝你的 public key sudo mkdir -p /home/me/.ssh sudo cp ~/.ssh/authorized_keys /home/me/.ssh/ sudo chown -R me:me /home/me/.ssh sudo chmod 700 /home/me/.ssh sudo chmod 600 /home/me/.ssh/authorized_keys ``` ### 3. 启用 ufw 防火墙 ```bash sudo apt install -y ufw sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow ssh # 22 或自定义端口 sudo ufw allow 80 sudo ufw allow 443 sudo ufw enable ``` ### 4. 启用自动安全更新 ```bash sudo apt install -y unattended-upgrades apt-listchanges sudo dpkg-reconfigure -priority=low unattended-upgrades ``` `/etc/apt/apt.conf.d/50unattended-upgrades` 改成只装 security: ``` Unattended-Upgrade::Allowed-Origins { "${distro_id}:${distro_codename}-security"; }; ``` ```bash sudo systemctl enable --now unattended-upgrades ``` ### 5. fail2ban 防暴力破解 ```bash sudo apt install -y fail2ban sudo systemctl enable --now fail2ban ``` `/etc/fail2ban/jail.local`: ```ini [DEFAULT] bantime = 1h findtime = 10m maxretry = 5 ignoreip = 127.0.0.1/8 ::1 <你的家庭 IP> [sshd] enabled = true ``` ### 6. 改 SSH 端口(可选但有效) ```bash sudo sed -i 's/^#\?Port .*/Port 2200/' /etc/ssh/sshd_config sudo systemctl reload ssh sudo ufw allow 2200/tcp sudo ufw delete allow ssh ``` 不是真"安全提升",但 SSH 攻击 log 减少 99%(攻击脚本只扫 22)。 副作用:很多自动化工具默认 22,加 `~/.ssh/config` 写好端口。 ### 7. 装 SSL 证书(Let's Encrypt) ```bash sudo apt install -y certbot python3-certbot-nginx sudo certbot --nginx -d example.com -d www.example.com # 自动跑 cert + 续期 cron ``` 或用 Caddy(前面有篇)自动 HTTPS。 ### 8. systemd hardening 关键 service 主要的应用 service unit 加: ```ini [Service] User=appuser Group=appuser ProtectSystem=strict ProtectHome=true ReadWritePaths=/var/log/myapp /var/lib/myapp PrivateTmp=true NoNewPrivileges=true RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX MemoryMax=2G TasksMax=128 ``` `systemd-analyze security myapp.service` 评分 + 提示。 ### 9. 限制 sudo 命令 `/etc/sudoers.d/me`: ``` me ALL=(ALL) NOPASSWD: /bin/systemctl restart myapp, /bin/systemctl status myapp ``` 只允许特定命令免密。其它 sudo 仍要密码。CI / deploy 服务账号 强烈推荐。 ### 10. 配置 fail-fast 日志清理 ```bash # /etc/systemd/journald.conf SystemMaxUse=2G SystemMaxFileSize=200M MaxRetentionSec=1month ``` ```bash sudo systemctl restart systemd-journald ``` 防止日志失控撑爆磁盘。 ### 11. 装 logwatch / 邮件总结 ```bash sudo apt install -y logwatch echo 'Mailto: [email protected]' | sudo tee -a /etc/logwatch/conf/logwatch.conf ``` 每天自动邮件汇总:sudo 用法 / SSH 登录 / cron 跑了什么 / 错误 log。 看一眼就知道异常。 ### 12. 内核加固 sysctl `/etc/sysctl.d/99-security.conf`: ``` # 防止 IP spoofing net.ipv4.conf.all.rp_filter = 1 net.ipv4.conf.default.rp_filter = 1 # Ignore ICMP broadcast net.ipv4.icmp_echo_ignore_broadcasts = 1 # 防 SYN flood net.ipv4.tcp_syncookies = 1 net.ipv4.tcp_max_syn_backlog = 8192 # 不接 IPv4 / v6 source routing net.ipv4.conf.all.accept_source_route = 0 net.ipv6.conf.all.accept_source_route = 0 # 不发 ICMP redirect net.ipv4.conf.all.send_redirects = 0 net.ipv4.conf.default.send_redirects = 0 # kernel hardening kernel.dmesg_restrict = 1 kernel.kptr_restrict = 2 fs.protected_hardlinks = 1 fs.protected_symlinks = 1 fs.protected_fifos = 2 ``` ```bash sudo sysctl --system ``` ### 13. 监控登录 + 命令历史 ```bash # /etc/profile.d/audit-history.sh export HISTTIMEFORMAT="%F %T " export HISTSIZE=10000 export HISTFILESIZE=20000 shopt -s histappend PROMPT_COMMAND='history -a' ``` 让所有 user shell history 带时间戳 + 跨 session 共享。 事后审计时知道"谁在什么时候跑了什么"。 进阶:装 auditd 记录 syscall 级别。 ### 14. 备份 + 远程 按前面 borg / restic / kopia 配,备份到远程。 被勒索 → 还能拉回来。 ## 一键脚本 把上面集成成一个 init 脚本: ```bash #!/bin/bash # /usr/local/sbin/server-init.sh set -euo pipefail # 1. 包更新 apt update && apt upgrade -y # 2. 装基础工具 apt install -y \ ufw fail2ban unattended-upgrades apt-listchanges \ logwatch htop vim tmux git curl jq ncdu \ chrony # 3. ufw ufw default deny incoming ufw default allow outgoing ufw allow 22 ufw allow 80 ufw allow 443 ufw --force enable # 4. ssh 加固 sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config systemctl reload ssh # 5. fail2ban cat > /etc/fail2ban/jail.local <<'EOF' [DEFAULT] bantime = 1h findtime = 10m maxretry = 5 [sshd] enabled = true EOF systemctl enable --now fail2ban # 6. sysctl 加固 cat > /etc/sysctl.d/99-security.conf <<'EOF' net.ipv4.tcp_syncookies = 1 kernel.dmesg_restrict = 1 fs.protected_symlinks = 1 EOF sysctl --system # 7. unattended-upgrades systemctl enable --now unattended-upgrades echo "Done. Reboot recommended." ``` 10 分钟跑完一台新机器的基础加固。 ## 不要做的反例 1. **不要装一堆"安全套件"**:lynis / OSSEC / OpenSCAP 类工具好但 不是装就安全。要懂它们报的是什么 + 真去修。 2. **不要禁所有 ICMP**:v6 的 PMTU 依赖 ICMPv6;v4 ping 关了排错痛苦。 按需禁 (echo-request 限速即可)。 3. **不要开 root sudo passwordless**:你 deploy script 方便了, 攻击者拿到 sudo 权限也方便。 4. **不要给所有人 wheel / sudo group**:每个用户单独 sudoers.d 文件 细粒度授权。 5. **不要忘了 host key 备份**:服务器迁移时 host key 变 → 用户连接 报"key changed (MITM?)" 警告。备份 `/etc/ssh/ssh_host_*` 提前换。 ## 监督性持续工作 加固不是一次性。还有: - 定期跑 lynis / debsecan 看 CVE - nginx / nginx-mod-* 等关键包额外关注 CVE - 日志 anomaly detection(异常登录时间 / IP) - 重要服务的 systemd-analyze security 评分跟踪 ## 效果 我们一批 ~20 台服务器跑这套 checklist 后: - SSH brute force log 从每天 5000 条 → 几条 - 自动 security update 一周修 30+ CVE 不要人管 - 误操作 destroy 风险通过 systemd hardening 大幅降低 - 半年内一次"红蓝对抗演练",渗透测试团队 7 天没能从外网拿 shell

密码 hash 用 argon2id:替代 bcrypt / scrypt 的现代选择

## 起因 老项目用 bcrypt(rounds=10) 存密码。安全审计建议升级到 argon2id: > bcrypt 是 1999 年算法,对 GPU 暴力破解抗性已弱。 > argon2id 是 2015 年 Password Hashing Competition 冠军, > memory-hard,专门抗 GPU/ASIC 攻击。 OWASP 2024 推荐:新项目用 argon2id;老项目升级时 graceful 迁移。 ## 解决方案 ### 1. Python: argon2-cffi ```bash uv add argon2-cffi ``` ```python from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError, InvalidHashError # 单例 hasher ph = PasswordHasher( time_cost=3, # 迭代次数 memory_cost=65536, # 64 MB parallelism=4, # 并行度 hash_len=32, salt_len=16, ) # 注册时 def register(email: str, password: str): h = ph.hash(password) # 自动 salt db.execute('INSERT INTO users (email, password_hash) VALUES (%s, %s)', (email, h)) # 登录时 def verify(email: str, password: str) -> bool: h = db.fetchone('SELECT password_hash FROM users WHERE email=%s', (email,)) if not h: return False try: ph.verify(h[0], password) # 如果参数升级了,自动 rehash if ph.check_needs_rehash(h[0]): new_h = ph.hash(password) db.execute('UPDATE users SET password_hash=%s WHERE email=%s', (new_h, email)) return True except VerifyMismatchError: return False ``` `hash()` 输出形如: ``` $argon2id$v=19$m=65536,t=3,p=4$<salt-b64>$<hash-b64> ``` 参数(m / t / p)编码在 hash 里,verify 时不需要单独存。 ### 2. 推荐参数 OWASP 2024 推荐(适合典型 web 服务器): ```python PasswordHasher( time_cost=3, memory_cost=12288, # 12 MB(低规模服务器) parallelism=1, ) ``` memory_cost 单位 KiB。生产服务器 RAM 充足建议: - 64-128 MB / hash - time_cost 3-5 - parallelism 1(避免 thread 争抢) 调参思路:在你的服务器上测 `ph.hash('test')` 耗时,目标 250-500 ms。 太快 → 攻击者 GPU 一秒破百万;太慢 → 用户登录卡。 ### 3. 渐进迁移:bcrypt → argon2id 用户表里 hash 共存: ```python from argon2 import PasswordHasher import bcrypt ph = PasswordHasher() def verify(email, password): h = db.get_user(email).password_hash if h.startswith('$argon2'): try: ph.verify(h, password); ok = True except: ok = False elif h.startswith('$2'): # bcrypt ok = bcrypt.checkpw(password.encode(), h.encode()) else: ok = False if ok and not h.startswith('$argon2'): # 用户登录成功 + 老 hash → 趁机升级 new_h = ph.hash(password) db.update_user_hash(email, new_h) return ok ``` 不需要"全部用户重设密码",每次登录顺便升级。3-6 个月几乎全部用户 迁完。 ### 4. Go: argon2id ```bash go get github.com/alexedwards/argon2id ``` ```go import "github.com/alexedwards/argon2id" params := &argon2id.Params{ Memory: 64 * 1024, Iterations: 3, Parallelism: 1, SaltLength: 16, KeyLength: 32, } hash, err := argon2id.CreateHash(password, params) // 存 hash 进 DB // 验证 match, err := argon2id.ComparePasswordAndHash(password, hash) ``` ### 5. Node.js: argon2 ```bash npm i argon2 ``` ```js import argon2 from 'argon2' const hash = await argon2.hash(password, { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 1, }) const ok = await argon2.verify(hash, password) ``` ### 6. 用 passlib(Python 老项目) Django 也用 passlib 兼容多算法: ```python # Django settings PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.Argon2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2PasswordHasher', # 兼容老 hash 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', ] ``` Django 验密时按顺序尝试匹配;用户登录成功后自动 rehash 到第一个 hasher(argon2id)。零代码迁移。 ## 选择对比 | | bcrypt | scrypt | PBKDF2 | argon2id | |---|---|---|---|---| | 年代 | 1999 | 2009 | 2000 | 2015 | | GPU 抗性 | 弱 | 中 | 弱 | 强(memory-hard) | | 算法标准化 | 事实标准 | RFC 7914 | NIST | RFC 9106 | | 推荐参数 | rounds=12+ | N=2^15+ | iters=600k+ | t=3,m=64MB | | 库支持 | 极广 | 广 | 广 | 良好(增长中) | 2024 后新项目首选 argon2id;老项目迁移路径如上。 ## 配套安全实践 ### 1. 永远 hash + salt,不要明文 ```python # ❌ db.execute('INSERT ... VALUES (%s, %s)', (email, password)) # ❌ 简单 hash(无 salt 易 rainbow table 攻击) db.execute('INSERT ... VALUES (%s, %s)', (email, hashlib.sha256(password.encode()).hexdigest())) # ✅ db.execute('INSERT ... VALUES (%s, %s)', (email, ph.hash(password))) ``` ### 2. 等时比较 `ph.verify()` 内部用 constant-time compare。手写 `hash == expected` 有时序泄漏。 ### 3. 不要 log 密码 / hash ```python # ❌ log.info('login attempt: user=%s pw=%s', email, password) # ✅ log.info('login attempt: user=%s', email) ``` ### 4. 限制登录失败 按 IP / 按 email 限速: - 5 分钟内 5 次失败 → 拒绝 15 分钟 - 配合 captcha ### 5. 强密码策略 不强求复杂度,但建议: - 最少 12 字符 - 用 zxcvbn / haveibeenpwned API 检查"是否已泄漏" ```python import requests def is_pwned(password: str) -> bool: sha = hashlib.sha1(password.encode()).hexdigest().upper() prefix, suffix = sha[:5], sha[5:] r = requests.get(f'https://api.pwnedpasswords.com/range/{prefix}') return suffix in r.text ``` 只发 prefix 5 位(k-anonymity),不泄漏完整 hash。 ### 6. 不要"密码找回 = 显示原密码" 如果你能给用户发"你的密码是 xxx",说明密码明文存。删库跑路风险。 正确做法:reset 链接让用户重设新密码。 ## 效果 我们升级后: - 5000 用户在 4 个月内 95% 自然 rehash 到 argon2id - 安全审计 pass - 单次登录验证延迟从 50ms → 350ms(增加但仍可接受) - 服务器 RAM 占用增加 ~50MB / 并发 login(PasswordHasher 实例) ## 踩过的坑 1. **memory_cost 单位是 KiB**:写 65536 是 64 MB,不是 64 KB。 写错值参数提示模糊。 2. **每个 worker 都创建 hasher**:PasswordHasher() 实例化贵。 全局单例。 3. **rehash 升级时不告诉用户**:用户体验无感(好事)。但要 log "user X migrated to argon2id" 供审计。 4. **password 长度无限**:Argon2 自身能处理任意长度,但 DB column 设 VARCHAR(255) 限制 hash 输出(~100 字符)+ 限输入密码 ≤ 128 字符 防 DoS(超长密码 hash 时间长)。 5. **跨实例参数不一致**:你的 web server 用 64MB,别人在 mobile API 用 12MB,同密码 verify 应该都 work(参数编码在 hash 里)。 但 hash 操作如果 mobile API 太慢,统一参数到最小公倍数。

给 sudoers 加一条 NOPASSWD 规则(最小授权 + 别动 visudo 之外的)

经常需要让某用户 / 自动化脚本运行少数特权命令,但又不想 sudo 让对方做任何事, 也不想每次输密码。正确做法是写一条 **最小授权** 的 NOPASSWD 规则。 ## 三条原则 1. **永远用 `visudo`** —— 改坏 `/etc/sudoers` 没法回滚,全机 sudo 失效 2. **放到 `/etc/sudoers.d/*` 而不是主文件** —— 单文件出错只影响一个 drop-in 3. **绝对路径 + 精确命令** —— 别用 wildcard,更别 `ALL` ## 例子:让 deploy 用户重启某个特定服务 ```bash sudo visudo -f /etc/sudoers.d/deploy ``` 内容: ``` # 让 deploy 用户不需要密码就能重启 app.service,且只能这个 deploy ALL=(root) NOPASSWD: /bin/systemctl restart app.service, \ /bin/systemctl status app.service, \ /bin/systemctl reload nginx.service ``` 注意三件事: - 第一个 `ALL` 是允许在 **哪些 host** 上有效(sudoers 是网络共享时有用) - `(root)` 是允许切换为哪个用户身份 - 命令必须是 **绝对路径**,并且每个参数都精确写出 如果只想允许重启 `app-*` 系列服务: ``` deploy ALL=(root) NOPASSWD: /bin/systemctl restart app-*.service ``` 注意 `*` 在 sudoers 里是 glob,能匹配 `app-frontend.service` 但也能匹配 `app-evil.service` —— 评估清楚再用。 ## 验证 + 测试 写完保存。`visudo` 在退出时做语法检查;有错会让你重新编辑,不会 落盘错配置。 ```bash # 用 deploy 身份测试 sudo -u deploy -i sudo -l # 列出该用户被允许的命令 sudo systemctl restart app.service # 应当不要密码就成功 sudo systemctl restart sshd.service # 应当拒绝 ``` ## 常见反模式(千万别) ``` # 错: 把整个 root 让出去 deploy ALL=(ALL) NOPASSWD: ALL # 错: 用 sudo 跑 shell deploy ALL=(root) NOPASSWD: /bin/bash # 完全等价于 NOPASSWD: ALL # 错: 允许跑文本编辑器 deploy ALL=(root) NOPASSWD: /usr/bin/vim # 在 vim 里 :!bash 就提权了 # 错: 用相对路径 deploy ALL=(root) NOPASSWD: systemctl restart app.service # 攻击者改 PATH 就能换 systemctl 为他自己的脚本 ``` `sudoedit`(即 `sudo -e`)是安全的编辑器封装,它把文件 copy 到临时位置 让用户编辑,结束后再 mv 回去。要让用户编辑 `/etc/foo.conf`: ``` deploy ALL=(root) NOPASSWD: sudoedit /etc/foo.conf ``` 调用:`sudo -e /etc/foo.conf`,用 `$EDITOR` 打开编辑。 ## CI / 自动化的特殊建议 CI runner 上的 NOPASSWD 列表是攻击面,越短越好。如果可能: 1. 用 `systemctl --user` + lingering,不需要 root 2. 用 systemd 套接字激活,CI 只 push 文件,service 自动 reload 3. 把命令写成"无参数的"脚本:CI 只能跑 `/usr/local/bin/redeploy-app`, 脚本内部硬编码 systemctl,攻击面收敛到一个文件 ## 强制日志 `/etc/sudoers.d/00-logging`: ``` Defaults log_input Defaults log_output Defaults iolog_dir="/var/log/sudo-io/%{user}" Defaults !syslog Defaults logfile="/var/log/sudo.log" ``` `log_output` 会把每次 sudo 会话的输入输出记下来,需要时可以重放: ```bash sudo cat /var/log/sudo.log sudo sudoreplay -d /var/log/sudo-io deploy/00/00/01 ``` ## 踩过的坑 - 不在 `/etc/sudoers.d/` 而直接改 `/etc/sudoers`:升级时 dpkg 可能问你 "保留本地修改 / 用新版",不留神就丢配置。 - drop-in 文件名 **不能** 有 `.`(包括 `.bak`);sudo 默认忽略带点的。 我们一律 .conf 后缀也不要,比如 `01-deploy`。 - `NOPASSWD` 的命令列表里如果有逗号但漏写空格,会被解析成单个长命令名, 匹配不上。每个命令前后都加空格保险。 - 给同一用户配多条规则时,**最后一条生效**。所以放更宽松的规则在前面、 更严格的在后面要小心。最佳实践:每个用户一份 drop-in 文件,规则集中。

给 Linux 服务器设置基础防火墙(ufw 实战配置)

ufw(Uncomplicated Firewall)是 iptables/nftables 的人话封装。 新服务器装好后第一件事就该把它配上 —— 比起裸奔,门槛降到 5 分钟, 但能挡住 99% 的扫描流量噪音。 ## 安装 + 默认策略 ```bash sudo apt install -y ufw sudo ufw default deny incoming sudo ufw default allow outgoing ``` `deny incoming` 是关键 —— 默认拒绝所有进来的连接,靠白名单放行。 ## 放行你需要的 ```bash sudo ufw allow ssh # 22/tcp sudo ufw allow http # 80/tcp sudo ufw allow https # 443/tcp # 或者指定端口 sudo ufw allow 8080/tcp comment 'app dev server' # 限制来源 IP sudo ufw allow from 192.168.1.0/24 to any port 5432 comment 'pg from LAN' # 限制速率(防 SSH 暴力破解) sudo ufw limit ssh comment 'rate-limit SSH' ``` `ufw limit` 等价于 "同 IP 30 秒内 6 次连接就 deny",比 fail2ban 轻量但 粒度粗。生产服务器两个一起用最稳。 ## 启用 + 校验 ```bash # !!! 启用前确认 22 已经 allow !!! sudo ufw enable sudo ufw status verbose # Status: active # Default: deny (incoming), allow (outgoing), deny (routed) # To Action From # -- ------ ---- # 22/tcp LIMIT IN Anywhere # 80/tcp ALLOW IN Anywhere # ... ``` 如果你是 SSH 连进去配的,开 ufw 之前 **务必先 allow ssh**,否则当场断开。 ## 实际生产建议 ```bash # 改 SSH 端口(不是安全提升但能省 99% 噪音日志) sudo sed -i 's/^#\?Port .*/Port 2200/' /etc/ssh/sshd_config sudo systemctl reload ssh sudo ufw delete allow ssh sudo ufw allow 2200/tcp comment 'ssh on non-standard port' sudo ufw limit 2200/tcp # Docker 装上后会绕过 ufw(默认改 iptables 但 ufw 看不到) # 解决:用 DOCKER-USER 链 + ufw-docker(搜这个项目) ``` ## 日志 ```bash sudo ufw logging medium # off / low / medium / high / full sudo tail -f /var/log/ufw.log ``` `medium` 已经够分析;`full` 会刷爆磁盘。 ## 应用 profiles ufw 自带常见应用的端口配置: ```bash sudo ufw app list # Available applications: # Nginx Full # Nginx HTTP # Nginx HTTPS # OpenSSH sudo ufw allow 'Nginx Full' ``` 自定义 profile 放在 `/etc/ufw/applications.d/`: ``` [my-app] title=My App description=Backend API server ports=8000,8443/tcp ``` ```bash sudo ufw app update my-app sudo ufw allow my-app ``` ## 备份 / 恢复 ```bash sudo cp /etc/ufw /etc/ufw.bak -r # /etc/ufw/user.rules 和 user6.rules 是规则文件 ``` ## 踩过的坑 - 远程改防火墙最危险。规范流程: 1. 准备 rollback 脚本:`(sleep 300 && ufw --force reset) &`,5 分钟内 新规则有问题没人手动取消,自动 reset 2. 在 screen / tmux 里操作 3. 改完后开第二个 SSH 测试,确认连得上再 kill rollback - Docker 镜像启动后默认会 publish 端口到 `0.0.0.0`,**完全绕过 ufw**。 生产里所有 Docker port mapping 都写 `127.0.0.1:8080:8080` 这种显式 bind。 - KVM / LXC 桥接网络可能让 ufw 看不到流量,需要在 `/etc/ufw/sysctl.conf` 里开 `net.bridge.bridge-nf-call-iptables=1`。

SPA + cookie 鉴权下的 CSRF 防护:SameSite + token 双保险

## 起因 新做的 React SPA + Django 后端,cookie 鉴权(HttpOnly session cookie)。 QA 测试时报:用户在浏览器 A 登录后,访问恶意网站 evil.com,evil 里 有个表单: ```html <form action="https://myapp.com/api/transfer" method="POST"> <input name="to" value="attacker"> <input name="amount" value="1000000"> </form> <script>document.forms[0].submit()</script> ``` 用户访问 evil → 表单自动提交 → 浏览器把 myapp.com 的 cookie 带上 → 后端以为是合法用户操作 → 转走 100 万。 这就是 CSRF (Cross-Site Request Forgery)。任何 cookie-based auth 的 系统都要防。 ## 解决方案 ### 第一道:SameSite cookie 现代浏览器 cookie 默认 `SameSite=Lax`(Chrome 80+),意思是: 跨站请求自动**不发** cookie(除了 top-level GET navigation)。 确保你的 session cookie 设置: ```python # Django settings SESSION_COOKIE_SAMESITE = 'Lax' # 或 'Strict' SESSION_COOKIE_SECURE = True # 仅 HTTPS SESSION_COOKIE_HTTPONLY = True # JS 不能读 CSRF_COOKIE_SAMESITE = 'Lax' CSRF_COOKIE_SECURE = True ``` ```javascript // FastAPI / Starlette response.set_cookie( 'session', value=token, secure=True, httponly=True, samesite='lax' ) ``` `SameSite=Lax` 防护: - ✅ form POST / XHR / fetch 跨站不带 cookie - ✅ 主要 CSRF 路径被堵 - ⚠️ 老浏览器(IE / Safari < 13)不支持 `SameSite=Strict` 更严:连同站 navigation 都不带 cookie。会影响"从外部 链接打开本站还要重新登录"的体验。 ### 第二道:CSRF token(仍推荐配合 SameSite) 即使 SameSite 防住 99%,建议加 token 双保险: 1. 服务端 set cookie `csrftoken=abc...`(非 HttpOnly,让 JS 能读) 2. JS 每个 POST/PUT/DELETE 请求 header 加 `X-CSRFToken: <token>` 3. 服务端校验 header token vs cookie token 一致 Django 自带: ```python # settings.py CSRF_COOKIE_NAME = 'csrftoken' # middleware (默认已有) MIDDLEWARE = [ ... 'django.middleware.csrf.CsrfViewMiddleware', ] ``` 前端(fetch / axios): ```js function getCookie(name) { return document.cookie .split('; ').find(c => c.startsWith(name + '=')) ?.split('=')[1] } const csrfToken = getCookie('csrftoken') await fetch('/api/transfer', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken, }, credentials: 'include', // 带 cookie body: JSON.stringify({ to, amount }), }) ``` 恶意 evil.com 的 JS **不能读 myapp.com 的 cookie**(跨域), 所以拿不到 csrftoken → 攻击失败。 axios 全局配: ```js axios.defaults.xsrfCookieName = 'csrftoken' axios.defaults.xsrfHeaderName = 'X-CSRFToken' axios.defaults.withCredentials = true ``` ### Double Submit Cookie 模式(无需 server-side state) 如果你想避免在服务端存 token: ``` 1. 用户登录时 server set cookie csrf=<random> 2. JS 读 cookie 把同值放进 header X-CSRF-Token 3. server 校验 cookie 和 header 一致 ``` 不需要 server 记住 token(只验两边相等)。简单 + 无状态。 Django 默认就是这种模式。 ### Stateless API + JWT 用户 JWT 放 cookie:同 cookie session,按上面处理。 JWT 放 localStorage: - 没 CSRF(请求带 cookie 才有 CSRF;JWT 走 Authorization header 不自动带) - 但容易 XSS(任何注入的 JS 都能读 localStorage) **OWASP 推荐 JWT 走 HttpOnly cookie + CSRF token**: - HttpOnly 防 XSS 偷 token - CSRF token 防跨站请求 ### 后端校验 ```python # Django CsrfViewMiddleware 自动校验 # 不需要 CSRF 的 endpoint 加装饰器 from django.views.decorators.csrf import csrf_exempt @csrf_exempt def public_webhook(request): # 不需要 CSRF 校验的 endpoint(如外部 webhook) ... ``` ```python # FastAPI 没有内置 CSRF,第三方:starlette-csrf from starlette_csrf import CSRFMiddleware app.add_middleware( CSRFMiddleware, secret='your-secret', cookie_name='csrftoken', header_name='X-CSRFToken', safe_methods={'GET', 'HEAD', 'OPTIONS', 'TRACE'}, ) ``` ### Origin / Referer 校验(额外加固) ```python def check_origin(request): origin = request.headers.get('Origin') or request.headers.get('Referer') if not origin: return False parsed = urlparse(origin) return parsed.netloc in ALLOWED_HOSTS ``` CSRF 攻击的请求 Origin / Referer 来自 evil.com(浏览器自动加,无法 伪造)。检查就能挡。 注意: - 直接打开链接 (top-level navigation GET) 可能没 Referer - 一些隐私插件去掉 Referer - 只用 Origin/Referer 不够(建议组合 token) ### 实战:完整流程 ``` 登录: 1. POST /api/login (email + password) 2. server 验证 + set HttpOnly cookie 'session' + non-HttpOnly cookie 'csrftoken' 3. JS 拿到 csrftoken(读 cookie) 后续 API 调用: 4. fetch('/api/x', { headers: {'X-CSRFToken': csrftoken}, credentials: 'include' }) 5. server 校验: - cookie session 合法 - header X-CSRFToken == cookie csrftoken - Origin 在白名单 6. 通过 → 处理业务 恶意网站攻击: 7. evil.com 提交表单到 myapp.com/api/x 8. 浏览器带上 session cookie (SameSite=Lax 已经会过滤大部分) 9. evil 不知道 csrftoken(跨域读不到)→ 请求失败 ``` ### CSRF 不防什么 - **XSS(脚本注入)**:CSRF 是"跨站请求";XSS 是"在你站里执行代码", 完全不同的攻击面。CSP / 输入校验防 XSS。 - **网络中间人**:HTTPS 防的。 - **服务端逻辑漏洞**:业务校验是另一层(如 transfer 应该让用户确认 密码 / 2FA)。 ### 双重提交的危险变种 ``` 1. attacker 在 evil.com 上设 cookie .yourapp.com csrftoken=evil_value (subdomain takeover 或 set-cookie via XSS on other subdomain) 2. attacker JS fetch myapp.com 时浏览器带这个 cookie 3. attacker 用同 evil_value 当 header X-CSRFToken 4. server 校验 cookie == header → pass ``` 防: - 用 SameSite cookie(attacker JS 跨域也不能 set 受害者域的 cookie) - 不要用 `*.yourapp.com` 通配域 cookie - 子域共享时 csrf token 加 hmac 包 user id 防 forge ## 效果 - pen test 报告 CSRF 漏洞清零 - 用户无感(自动带 token) - 攻击者从其它站 POST 直接失败 - 老浏览器(占比 < 1%)能 fallback 到 CSRF token-only ## 何时不需要 CSRF 防护 - 纯 token-bearer API(Authorization header)+ token 不存 cookie - 公开 endpoint(不修改状态 / 不查私有数据) - 同域单页全 GET / 状态在 URL 里 但**只要用 cookie 鉴权 + 有改状态 API,必须 CSRF 防护**。 ## 踩过的坑 1. **`fetch` 默认不带 cookie**:要 `credentials: 'include'` (同源时 `'same-origin'`)。漏写 cookie 不发,登录态丢, 误以为是"鉴权坏了"。 2. **CSRF 中间件 + 微服务架构**:A 服务 set cookie,B 服务校验, B 不知道 secret → fail。共享 SECRET_KEY 或 token-issuing 服务集中。 3. **SameSite=Lax 不防 GET CSRF**:GET 在 Lax 下会带 cookie。 永远不要让 GET 改状态(如 `/api/delete?id=1` → 邮件里图片 src 触发执行)。GET 必须 idempotent。 4. **WebSocket / SSE 不被 SameSite 保护**:跨站打开 WebSocket 仍带 cookie。WebSocket 鉴权要在握手时校验 Origin。 5. **CDN / proxy 把 cookie 丢了**:proxy 不带 cookie 头 → 后端看不到 session。检查 `proxy_set_header Cookie $http_cookie`。

用 iptables / nftables 抗 SYN flood + 限速暴力扫描

## 起因 公网服务器 access log 每天几千条扫描尝试:SSH 暴力破解、WordPress 路径 扫描、各种 CVE 探测。一台 VPS 偶尔被低强度 SYN flood 打到延迟飙升。 fail2ban + ufw 能挡掉大部分,但更基础的内核层防护让攻击者连 TCP 握手 都建立不了,更省 CPU。 ## 解决方案:三道防线 ### 1. SYN cookies 启用(内核级 SYN flood 防护) ```bash # 临时 sudo sysctl -w net.ipv4.tcp_syncookies=1 sudo sysctl -w net.ipv4.tcp_max_syn_backlog=8192 sudo sysctl -w net.ipv4.tcp_synack_retries=2 # 持久 /etc/sysctl.d/99-syn-protect.conf cat <<EOF | sudo tee /etc/sysctl.d/99-syn-protect.conf net.ipv4.tcp_syncookies = 1 net.ipv4.tcp_max_syn_backlog = 8192 net.ipv4.tcp_synack_retries = 2 net.ipv4.tcp_fin_timeout = 15 net.core.somaxconn = 4096 net.ipv4.tcp_tw_reuse = 1 EOF sudo sysctl --system ``` `tcp_syncookies=1` 让内核在 SYN backlog 满时改用 cookie 验证客户端, 不分配真实 socket,让 SYN flood 失效(攻击者要回 ACK 才占资源)。 ### 2. iptables/nftables 限速 SYN `iptables` 版(老但通用): ```bash # 每秒最多 25 个新连接,超过的丢弃 sudo iptables -N SYN_FLOOD sudo iptables -A INPUT -p tcp --syn -j SYN_FLOOD sudo iptables -A SYN_FLOOD -m limit --limit 25/s --limit-burst 50 -j RETURN sudo iptables -A SYN_FLOOD -j LOG --log-prefix "SYN_FLOOD: " sudo iptables -A SYN_FLOOD -j DROP # 持久化 sudo apt install iptables-persistent sudo netfilter-persistent save ``` `nftables` 版(推荐,新机器都用这个): ```nft table inet filter { # 限速:每秒 25 个新 SYN,突发 50 chain input { type filter hook input priority filter; policy drop; ct state established,related accept ct state invalid drop iif lo accept ip protocol icmp accept meta nfproto ipv6 icmpv6 accept # 新 SSH 连接限速 tcp dport 22 ct state new \ limit rate over 6/minute \ counter drop tcp dport 22 accept # HTTP/S 限速 SYN(防 layer-4 DDoS) tcp dport { 80, 443 } ct state new \ limit rate over 50/second burst 100 packets \ counter drop tcp dport { 80, 443 } accept # 默认 log + drop limit rate 5/minute log prefix "nft drop: " counter drop } } ``` ### 3. 用 ipset / nft set 屏蔽已知恶意 IP 段 ```bash sudo apt install ipset # 创建 set sudo ipset create blacklist hash:net # 加 IP 段(来自公开威胁情报源) curl -s https://lists.blocklist.de/lists/ssh.txt \ | xargs -I {} sudo ipset add blacklist {} 2>/dev/null # 接到 iptables sudo iptables -I INPUT -m set --match-set blacklist src -j DROP ``` 或 nftables 的 set(更优雅): ```nft table inet filter { set blacklist { type ipv4_addr flags interval elements = { 45.95.169.0/24, 193.32.162.0/24, # ... 几千条 } } chain input { ip saddr @blacklist drop # ... 其它规则 } } ``` 更新 set 用 `nft add element`,无需重启。 ### 4. conntrack 调优 ```bash # /etc/sysctl.d/99-conntrack.conf # 默认 65536 在高并发被吃满("nf_conntrack: table full" log) net.netfilter.nf_conntrack_max = 1048576 net.netfilter.nf_conntrack_tcp_timeout_established = 7200 net.netfilter.nf_conntrack_buckets = 262144 ``` 观察当前用量: ```bash sudo sysctl net.netfilter.nf_conntrack_count cat /proc/sys/net/netfilter/nf_conntrack_max ``` 接近 max 时 conntrack 表满 → 新连接被 drop。 ### 5. 校验 / 监控 ```bash # 看 SYN 状态 ss -tan state syn-recv | wc -l ss -tan state established | wc -l # 看 conntrack 表 sudo conntrack -L | wc -l sudo conntrack -S # 统计 # iptables / nft 命中数 sudo nft list ruleset | grep counter ``` 实时看被 drop 的: ```bash sudo journalctl -k -f | grep -E 'nft drop|SYN_FLOOD' ``` ## 效果 - 之前一次 SYN flood 把 CPU 打到 60% → 现在内核层 cookie + 限速消化, CPU 几乎不动 - SSH brute force 日志从每天 5000+ 条 → < 50 条(被 nft limit 提前丢) - 已知恶意 IP 段(blocklist.de 等)直接 drop,访问日志几乎清净 - nginx 不再被无效请求打扰,正常用户体验改善 ## 性能成本 - iptables/nftables 处理一个包 nanosecond 级 - conntrack 每连接 ~300 bytes,1M 连接 ~300 MB(VPS 4GB+ 没压力) - 内核 SYN cookie 极少 CPU 公网服务器开这些防护是"几乎免费的保险"。 ## 与 Cloudflare / DDoS 服务对比 应用 / VPS 自己防只能挡到 L3-L4 中等流量(< 1 Gbps)。 大规模 DDoS(10 Gbps+)流量已经把上游带宽打满,再防火墙也无济于事。 正经防 DDoS 要前置 Cloudflare / AWS Shield 等大网络服务商, 利用它们的全球抗 D 能力。 但这些没必要 24x7 开(成本);做好本地防护 + 紧急时切到 Cloudflare "under attack" 模式是合理 trade-off。 ## 踩过的坑 1. **限速规则放错位置**:`limit rate` 必须在 `accept` 之前。我曾把 accept 写前面 → 所有连接都 accept,limit 形同虚设。 2. **`ct state new` 漏写**:限速规则没 `ct state new` 就把 established 连接的每个包都算 → 正常流量也被限速。 3. **blacklist 来源选择**:随便加几万条 IP 进 ipset 可能误封 CDN / 公有云 IP(共享网段)。用 abuseipdb / blocklist.de 这种带置信度 评分的源,confidence > 90 才加。 4. **iptables vs nftables 共存**:新 Debian/Ubuntu 用 `iptables-nft` 兼容层,老 iptables 命令实际写 nftables。混改 iptables 命令 + nft 命令导致规则错乱。统一用 nft。 5. **重启后规则丢**:iptables 改了忘 `netfilter-persistent save`; nftables 改了忘写 `/etc/nftables.conf`。养成"改完测试 →立刻 持久化"习惯。

用 LUKS 给整盘加密 + 启动时密码解锁(Debian / Ubuntu)

笔记本 / 移动机器一旦丢失,硬盘上的数据如果没加密,攻击者拆下盘 插到另一台机器就能读。LUKS(Linux Unified Key Setup)是 Linux 标准全盘加密方案。 下面分两个场景: - A. 新装系统时加密 - B. 给已有数据盘加密 ## A. 新装系统时加密(推荐) Debian / Ubuntu / Fedora 安装器都有"加密整盘"选项,勾上即可: - 设置一个开机解锁密码(passphrase) - 安装器自动建 LUKS 容器 + LVM 上挂分区 完成后启动会先停在密码输入界面,输对了才进 grub / 启动内核。 校验: ```bash lsblk # NAME TYPE MOUNTPOINT # nvme0n1 # ├─nvme0n1p1 part /boot/efi # ├─nvme0n1p2 part /boot # └─nvme0n1p3 part # └─nvme0n1p3_crypt crypt # ├─ubuntu--vg-root lvm / # └─ubuntu--vg-swap lvm [SWAP] sudo cryptsetup status nvme0n1p3_crypt # /dev/mapper/nvme0n1p3_crypt is active and is in use. # type: LUKS2 # cipher: aes-xts-plain64 # keysize: 512 bits ``` ## B. 给已有数据盘 / 第二块盘加密 ```bash # 准备空盘 /dev/sdb(数据会清!) sudo cryptsetup luksFormat /dev/sdb # WARNING! Will overwrite data on /dev/sdb irrevocably. # Are you sure? (Type 'yes' in capital letters): YES # Enter passphrase: ... # Verify passphrase: ... sudo cryptsetup open /dev/sdb data # 设备出现在 /dev/mapper/data # 在加密容器内建文件系统 sudo mkfs.ext4 /dev/mapper/data # 挂载 sudo mkdir /mnt/data sudo mount /dev/mapper/data /mnt/data ``` ## 启动时自动打开(/etc/crypttab) `/etc/crypttab`: ``` data UUID=<luks-uuid> none luks,discard ``` 获取 UUID:`sudo blkid /dev/sdb` 取 `UUID="..."`。 `/etc/fstab`: ``` /dev/mapper/data /mnt/data ext4 defaults,nofail 0 2 ``` `none` 是 keyfile 字段。如果填路径,开机用那个文件解锁(无需输密码)。 `luks,discard` 选项: - `luks`:指定为 LUKS 格式 - `discard`:允许 TRIM 给底层 SSD(提性能 + 寿命;但泄露空间使用模式) 重启验证。 ## 加多个密码 / 撤销密码 LUKS 支持最多 8 个 keyslot: ```bash # 加新密码 sudo cryptsetup luksAddKey /dev/sdb # 输旧密码 + 新密码 # 删某个 keyslot(0 是第一个) sudo cryptsetup luksKillSlot /dev/sdb 0 # 看 keyslot 状态 sudo cryptsetup luksDump /dev/sdb ``` 主密码忘记了用备用密码登录,进去 KillSlot 删主密码再 AddKey 新的。 ## 用 keyfile 而不是密码 ```bash # 生成 4K 随机 keyfile sudo dd if=/dev/urandom of=/root/luks.key bs=1024 count=4 sudo chmod 400 /root/luks.key # 加到 LUKS sudo cryptsetup luksAddKey /dev/sdb /root/luks.key # crypttab 改用 keyfile # data UUID=... /root/luks.key luks ``` 适用:服务器机器没人输密码;keyfile 存 USB / TPM。 ## YubiKey / FIDO2 解锁 ```bash sudo apt install -y libpam-u2f systemd-cryptsetup # 注册 YubiKey 到 LUKS sudo systemd-cryptenroll --fido2-device=auto /dev/sdb # /etc/crypttab 加上 # data UUID=... none fido2-device=auto,luks ``` 启动时插 YubiKey 触摸金属片就解锁。比记复杂密码方便。 ## TPM 解锁(笔记本无人值守) ```bash sudo systemd-cryptenroll --tpm2-device=auto /dev/sdb # crypttab 加 tpm2-device=auto ``` TPM 解锁的安全性: - ✅ 抗"拔盘到另一机器"攻击:TPM 绑定主板 - ❌ 抗"原机被偷":开机就自动解锁,攻击者通电就能用 要双保险加 PIN: ```bash sudo systemd-cryptenroll --tpm2-device=auto --tpm2-with-pin=yes /dev/sdb ``` ## 备份 header(关键!) LUKS header 在分区开头几 MB,如果损坏(mkfs 误操作 / 磁盘坏块), 整个加密容器无法解开(数据丢失)。备份: ```bash sudo cryptsetup luksHeaderBackup /dev/sdb --header-backup-file /secure/sdb-luks-header.bin ``` 把这个 bin 存到安全的地方(U 盘 / 加密邮箱)。还原: ```bash sudo cryptsetup luksHeaderRestore /dev/sdb --header-backup-file /secure/sdb-luks-header.bin ``` ## 加密性能 LUKS2 + AES-XTS 是当前默认。现代 CPU(带 AES-NI)几乎无性能损失: ```bash cryptsetup benchmark # aes-xts 256b 3000 MiB/s 3000 MiB/s # ... ``` 3 GB/s 远超 SSD 顺序读写速度,加密不会成瓶颈。 ## 隐藏 / SED 进阶: - **SED**(Self-Encrypting Drive):硬件加密,性能更好,但要信任厂商 实现(多家被发现有后门) - **Plausible deniability**(VeraCrypt 风格的隐藏卷):LUKS 不原生支持 通常 LUKS 已经够。 ## 踩过的坑 - 装系统时设置弱密码(如 "1234"):暴力破解几秒搞定。LUKS 用 argon2id 让暴力很慢,但密码本身弱无济于事。**最少 16 位混合字符**或用 diceware。 - 没备份 LUKS header → 一次 mkfs 失手数据全没。养成 luksFormat 后立刻 备份 header 的习惯。 - crypttab 写错让启动卡在解锁界面无限循环。从 LiveUSB 进入修复。 - 加密 swap:如果用 hibernation / suspend-to-disk,需要 swap 加密, 否则 RAM 数据写到 swap 时明文。`/etc/crypttab` 配 swap 用随机 key 每次开机重新生成(不能 hibernate)或用固定 key(可以 hibernate)。