如何防范黑客删除勒索:个人博客异地备份实战
如何防范黑客删除勒索:个人博客异地备份实战
本文是我给自己的博客 mashangan.com 补齐异地备份体系的完整复盘,包含踩过的每一个坑。如果你也有一台跑着个人网站的云服务器,照着做大约 30 分钟就能配好一套"黑客拿到服务器也删不掉"的备份。
一、我为什么突然认真做备份
一句话:被勒索过。
某天打开网站,数据库空了,只剩一张表,里面躺着一行勒索信息——"你的数据已转移到安全服务器,支付 X 个比特币后归还……"。
那次幸运的是数据不多,手头还留有一份手动导出的 SQL,网站才得以复活。但"全部家当悬在一台服务器上"的感觉,让我的后背凉了很久。这次我决定按商业公司的标准,把备份一次性补齐,并把过程写下来。
二、商业公司是怎么做备份的
企业的数据安全团队基本都在遵循一套公开多年的方法论——3-2-1 备份原则:
-
3 份副本:原始数据 + 至少两份备份
-
2 种介质:比如云盘 + 对象存储,不放在同一块盘上
-
1 份异地:至少一份放在不同的地理位置,防火灾、防勒索、防手滑
在此之上,企业还会加两条硬性要求:
-
不可变备份(WORM):备份写入后一段时间内谁也改不了、删不了,勒索软件加密了也白加密;
-
定期恢复演练:没实际恢复过的备份,等于没有备份。
对个人站长来说,我把它提炼成防勒索三板斧:
措施
防什么
成本
异地备份(对象存储)
服务器被一锅端
每月几毛钱
只写密钥(不给删除权限)
黑客拿到服务器后顺手删掉云端备份
免费
开启版本控制
密钥泄露后被恶意覆盖/删除
免费
这三板斧里,最核心的其实是第二条:备份的删除权,永远不要交到生产服务器手里。勒索攻击的典型动作是"先删备份、再加密数据",你的备份只要删不掉,攻击就等于失败。
三、方案选型:为什么是对象存储
我的服务器是一台腾讯云上海轻量应用服务器,博客跑的是 Docker 化的 PostgreSQL + 磁盘附件。备选方案横向对比:
-
再养一台服务器跑 rsync:要多付一台机器的钱,还要维护,贵;
-
GitHub 私有仓库:附件 100MB+ 放仓库不合适,而且严格说不算冷备份;
-
对象存储(腾讯云 COS):按量付费、同地域内网上传免流量费、自带版本控制——胜出。
算一笔账:我的数据库 gzip 后约 4.3MB,附件打包约 107MB,每天一份、云端保留 30 天,存量约 3.4GB。按标准存储单价,一个月不到一块钱。个人站选单 AZ 存储就够,不必上多 AZ。
四、动手:五步搭好备份体系
第 1 步:在 COS 控制台建桶
关键配置就四项:
-
地域:选服务器同地域(上海),内网上传不花流量费;
-
访问权限:私有读写;
-
版本控制:开启——这是防勒索的关键开关;
-
服务端加密:开启(SSE-COS,免费)。
⚠️ 避雷:轻量应用服务器控制台里也有个 COS 入口,但那是阉割版,建桶会报
RequestRoleProxy / ResourceUnavailable。请直接去 COS 主控制台操作。
第 2 步:建子账号,只给"只写"权限
千万不要把主账号的 API 密钥放到服务器上——主账号密钥泄露等于整个云账号被端。
正确姿势是到 CAM(访问管理)建一个子用户,只挂这一条自定义策略(把 <UID>、<APPID>、<桶名> 换成你自己的):
{
"version": "2.0",
"statement": [
{
"effect": "allow",
"action": ["cos:PutObject"],
"resource": ["qcs::cos:ap-shanghai:uid/<UID>:<桶名>/*"]
},
{
"effect": "allow",
"action": ["cos:GetBucket"],
"resource": ["qcs::cos:ap-shanghai:uid/<UID>:<桶名>"]
}
]
}
两个 action 的讲究:
-
cos:PutObject作用在桶名/*上——只能往里写; -
cos:GetBucket作用在桶本身——用来列目录,做"落桶自检"; -
故意不给
GetObject(密钥即使泄露也读不到备份内容)、不给任何 Delete 动作。
另外把子账号的"控制台访问"关掉,只保留 API 密钥——控制台密码的泄露面直接归零。
⚠️ 避雷:
cos:ListBucket是 AWS S3 的 action 名,腾讯云里不存在。列桶要用cos:GetBucket。我照着 S3 的习惯写,排查了半天"能上传、不能列举"。
第 3 步:服务器上的每日备份脚本
备份内容 = 数据库逻辑备份 + 附件目录打包,产物落地前必须自检。脚本核心逻辑:
#!/usr/bin/env bash
# 每日备份:pg_dump + 附件打包 + 产物校验 + COS 异地上传
# cron: 17 3 * * * /home/ubuntu/halo/scripts/backup.sh >> /home/ubuntu/backups/halo/backup.log 2>&1
set -uo pipefail
DATE=$(date +%Y%m%d_%H%M%S)
HALO_DIR="/home/ubuntu/halo"
BACKUP_DIR="/home/ubuntu/backups/halo/daily"
DB_CONTAINER="halo-db"
RETENTION_DAYS=7
COS_ENV="${HALO_DIR}/scripts/cos.env"
mkdir -p "$BACKUP_DIR"
fail() { echo "[$(date -Is)] [ERROR] $*"; exit 1; }
# 1) 数据库逻辑备份:--clean --if-exists 让恢复时直接 psql 导入即可
docker exec "$DB_CONTAINER" pg_dump -U halo_pg -d halo --clean --if-exists \
| gzip > "$BACKUP_DIR/halo_db_$DATE.sql.gz" || fail "pg_dump failed"
# 2) 附件目录打包
tar -czf "$BACKUP_DIR/attachments_$DATE.tar.gz" -C "$HALO_DIR/halo2" attachments \
|| fail "tar attachments failed"
# 3) 产物自检:gzip 完整性 + 最小体积(防 pg_dump 空转产出"合法的空文件")
for f in halo_db_$DATE.sql.gz attachments_$DATE.tar.gz; do
[ -s "$BACKUP_DIR/$f" ] || fail "$f is empty"
gzip -t "$BACKUP_DIR/$f" || fail "$f gzip check failed"
done
DB_SIZE=$(stat -c%s "$BACKUP_DIR/halo_db_$DATE.sql.gz")
[ "$DB_SIZE" -gt 1024 ] || fail "db dump suspiciously small: $DB_SIZE bytes"
# 4) COS 异地上传(原生签名上传器,上传后自检落桶)
if [ -f "$COS_ENV" ]; then
. "$COS_ENV"
if [ -n "${COS_ACCESS_KEY_ID:-}" ] && [ -n "${COS_SECRET_ACCESS_KEY:-}" ] \
&& [ -n "${COS_BUCKET:-}" ]; then
if python3 "$HALO_DIR/scripts/cos_upload.py" \
"$BACKUP_DIR/halo_db_$DATE.sql.gz" "db/halo_db_$DATE.sql.gz" \
&& python3 "$HALO_DIR/scripts/cos_upload.py" \
"$BACKUP_DIR/attachments_$DATE.tar.gz" "files/attachments_$DATE.tar.gz"; then
echo "[$(date -Is)] [COS] uploaded db=$DB_SIZE attachments=$AT_SIZE"
else
echo "[$(date -Is)] [COS-ERROR] upload failed (local backup still valid)"
fi
fi
fi
# 5) 清理过期本地备份
find "$BACKUP_DIR" -name 'halo_db_*.sql.gz' -mtime "+$RETENTION_DAYS" -delete
find "$BACKUP_DIR" -name 'attachments_*.tar.gz' -mtime "+$RETENTION_DAYS" -delete
密钥单独放一个文件并收紧权限,绝不进代码仓库:
# /home/ubuntu/halo/scripts/cos.env (chmod 600)
COS_BUCKET=<桶名>-<你的APPID>
COS_REGION=ap-shanghai
COS_ACCESS_KEY_ID=AKIDxxxxxx
COS_SECRET_ACCESS_KEY=xxxxxx
定时任务只放一行:
17 3 * * * /home/ubuntu/halo/scripts/backup.sh >> /home/ubuntu/backups/halo/backup.log 2>&1
⚠️ 避雷:附件目录是容器以 root 身份写入的,宿主机普通用户连
tar都会Permission denied。备份 cron 直接放 root 的 crontab,别去折腾 ACL。另外定时点选 3:17 这种"错峰分钟"而不是整点,避开全云厂商的定时任务高峰。
第 4 步:上传到 COS——全场最大的坑在这里
我最初用 rclone(备份圈的事实标准工具)上传,结果掉进一个极深的坑:
-
上传命令报
Copied (new),退出码 0,看起来一切正常; -
但一列举,桶里是空的,文件根本没落进去;
-
过一会儿再测,同样的命令又变成 403 Forbidden。
我排查了 DNS 解析、TLS 证书(确认连的是真 COS、没有被劫持)、密钥、CAM 策略、代理环境变量,甚至怀疑二进制被调包。最后定位结论:问题出在 rclone 的 S3 兼容层(AWS SigV4 签名)与腾讯云 CAM 权限体系的兼容性上。同一把密钥、同一个动作:
请求方式
结果
rclone(S3 SigV4 兼容签名)
403 拒绝,甚至"报成功但不落桶"
原生 COS 签名(q-sign-algorithm=sha1)
200,对象立刻可列举
解法很干脆:扔掉 rclone,用 Python 标准库手写原生 COS 签名,30 行搞定。COS 写入是强一致的,所以上传完立刻用 GetBucket 自检"真的落桶":
import hashlib, hmac, time, urllib.request
def make_auth(sid, skey):
now = int(time.time())
keytime = f"{now - 60};{now + 600}" # 签名有效期
signkey = hmac.new(skey.encode(), keytime.encode(),
hashlib.sha1).hexdigest() # 第 1 段:SignKey
def auth(method, path):
httpstring = f"{method.lower()}\n{path}\n\n\n" # 方法/路径/参数/头
stringtosign = (f"sha1\n{keytime}\n"
f"{hashlib.sha1(httpstring.encode()).hexdigest()}\n")
sig = hmac.new(signkey.encode(), stringtosign.encode(),
hashlib.sha1).hexdigest() # 第 2 段:签名
return (f"q-sign-algorithm=sha1&q-ak={sid}&q-sign-time={keytime}"
f"&q-key-time={keytime}&q-header-list=&q-url-param-list="
f"&q-signature={sig}")
return auth
⚠️ 避雷:这段签名里
StringToSign末尾还有一个换行符,漏掉就是SignatureDoesNotMatch——我在这一步也栽过一次。
完整上传脚本在文末,每次 PUT 成功后都会列一次桶确认对象存在,双保险。
第 5 步:验证、验证、验证
备份体系最容易翻车的不是"没配",而是"以为配好了"。我的验收清单:
-
手动跑一次备份,确认产物文件和体积正常;
-
用独立网络(本地电脑)列举桶,交叉确认文件真的存在——服务器自己验证自己,可能被本地 DNS/代理之类的问题骗到;
-
负测试:用子账号密钥尝试删除对象 → 被拒(403)。这一步是在验证防勒索的核心属性:删不掉;
-
次日定时任务跑完后,再到桶里确认多出新文件。
五、避雷点清单(全是真踩过的)
-
备份脚本存在 ≠ 备份在跑。我服务器的初始化脚本里明明写了装备份的逻辑,但从未真正执行——直到这次检查才发现备份目录是空的。定期看一眼备份产物和日志。
-
备份和数据库同机 = 没有备份。勒索攻击的第一步就是全盘加密,同机备份第一个陪葬。
-
轻量服务器控制台的 COS 入口是阉割版,建桶报
RequestRoleProxy / ResourceUnavailable,去 COS 主控制台。 -
cos:ListBucket是无效 action(AWS 的命名),腾讯云要用cos:GetBucket。 -
CAM 策略改动有分钟级传播延迟。刚授权完测试会出现"一秒成功、一秒 403"的假象,等 3~5 分钟再测,否则会被假象带偏、越改越乱。
-
rclone + 腾讯云 COS 可能假成功。退出码 0 不代表落桶,务必 GetBucket 自检或用独立网络交叉验证。个人建议:对腾讯云 COS 直接用原生签名上传,别恋战。
-
容器写入的文件,宿主机普通用户备份不了(root 属主),备份 cron 放 root crontab。
-
永远不要把主账号密钥放到服务器。子账号 + 最小权限 + 关闭控制台登录,密钥泄露的损失上限 = 一个只写桶。
-
版本控制必须开。它是最后一道防线:就算哪天密钥真的泄露、对象被删,历史版本还能恢复。
-
密钥文件 chmod 600,不进 git、不进聊天记录、不进截图。任何密钥类信息都不该出现在对话和截图里,包括"看着不像密码"的密码。
-
没做过恢复演练的备份 = 没有备份。找个下午,把备份拉下来真恢复一次,你会立刻发现脚本里的问题。
-
排错先确认"错误来自谁":403、429、
SignatureDoesNotMatch的处理方向完全不同,拿着响应里的requestId对号入座,别瞎猜。
六、最终形态
-
每天 03:17:pg_dump 逻辑备份 + 附件打包 → gzip 完整性校验 → 原生签名上传 COS → GetBucket 落桶自检;
-
本地保留 7 天,云端保留 30 天(桶的生命周期规则:当前版本 30 天删除、非当前版本 7 天删除);
-
云端账单:约 3.4GB 存量,每月几毛钱;
-
极端情况:服务器被勒索 → 密钥删不掉云端备份 → 格式化重装 → 拉最近一份备份恢复,数据损失上限 24 小时。
参考链接:
七、写在最后
备份这件事,平时看着是负担,出事时才知道是命。勒索攻击者最喜欢的就是"只有一份数据、且数据就在他手上"的受害者——而一套只写密钥 + 版本控制 + 异地桶的方案,成本不到一杯奶茶,就能让这场攻击从一开始就注定失败。
如果这篇文章帮到了你,欢迎收藏备用;如果你还没配异地备份,今天就动手。
附录:完整脚本
cos_upload.py(原生 COS 签名上传器,含落桶自检)
#!/usr/bin/env python3
"""Upload one file to Tencent COS using the native XML API signature.
Usage: cos_upload.py <local_file> <object_key>
Reads credentials from cos.env next to this script (or edit COS_ENV below).
After a successful PUT it re-lists the bucket (GetBucket) to confirm the
object really landed, exiting non-zero otherwise.
"""
import hashlib
import hmac
import os
import sys
import time
import urllib.error
import urllib.request
COS_ENV = "/home/ubuntu/halo/scripts/cos.env"
def load_env():
cfg = {}
with open(COS_ENV) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
cfg[k] = v
return cfg
def make_auth(sid, skey):
now = int(time.time())
keytime = f"{now - 60};{now + 600}"
signkey = hmac.new(skey.encode(), keytime.encode(), hashlib.sha1).hexdigest()
def auth(method, path):
httpstring = f"{method.lower()}\n{path}\n\n\n"
stringtosign = f"sha1\n{keytime}\n{hashlib.sha1(httpstring.encode()).hexdigest()}\n"
sig = hmac.new(signkey.encode(), stringtosign.encode(), hashlib.sha1).hexdigest()
return (
f"q-sign-algorithm=sha1&q-ak={sid}&q-sign-time={keytime}"
f"&q-key-time={keytime}&q-header-list=&q-url-param-list=&q-signature={sig}"
)
return auth
def main():
if len(sys.argv) != 3:
print("usage: cos_upload.py <local_file> <object_key>", file=sys.stderr)
return 2
local, key = sys.argv[1], sys.argv[2].lstrip("/")
if not os.path.isfile(local):
print(f"local file missing: {local}", file=sys.stderr)
return 1
cfg = load_env()
bucket = cfg["COS_BUCKET"]
region = cfg.get("COS_REGION", "ap-shanghai")
host = f"{bucket}.cos.{region}.myqcloud.com"
auth = make_auth(cfg["COS_ACCESS_KEY_ID"], cfg["COS_SECRET_ACCESS_KEY"])
size = os.path.getsize(local)
path = f"/{key}"
try:
with open(local, "rb") as f:
req = urllib.request.Request(f"https://{host}{path}", data=f, method="PUT")
req.add_header("Authorization", auth("PUT", path))
req.add_header("Content-Length", str(size))
with urllib.request.urlopen(req, timeout=900) as resp:
status = resp.status
except urllib.error.HTTPError as e:
print(f"[UPLOAD-FAIL] {key}: HTTP {e.code}: "
f"{e.read()[:200].decode('utf-8', 'replace')}", file=sys.stderr)
return 1
if status != 200:
print(f"[UPLOAD-FAIL] {key}: HTTP {status}", file=sys.stderr)
return 1
# verify the object actually appears in the bucket listing
try:
req = urllib.request.Request(f"https://{host}/", method="GET")
req.add_header("Authorization", auth("GET", "/"))
with urllib.request.urlopen(req, timeout=60) as resp:
body = resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
print(f"[VERIFY-FAIL] GetBucket HTTP {e.code}", file=sys.stderr)
return 1
found = f"<Key>{key}</Key>" in body
print(f"{'OK' if found else 'MISSING'}: {key} ({size} bytes)")
return 0 if found else 1
if __name__ == "__main__":
sys.exit(main())
部署速查
# 1. 两个脚本放服务器,收紧权限
chmod 755 /home/ubuntu/halo/scripts/backup.sh /home/ubuntu/halo/scripts/cos_upload.py
chmod 600 /home/ubuntu/halo/scripts/cos.env
# 2. root crontab(sudo crontab -e)加一行
17 3 * * * /home/ubuntu/halo/scripts/backup.sh >> /home/ubuntu/backups/halo/backup.log 2>&1
# 3. 立刻验证一次
sudo /home/ubuntu/halo/scripts/backup.sh
# 期望输出两行 OK: db/... 和 OK: files/...,OK 表示已确认落桶
