如何防范黑客删除勒索:个人博客异地备份实战

发布于
如何防范黑客删除勒索:个人博客异地备份实战

如何防范黑客删除勒索:个人博客异地备份实战

备份体系架构示意:博客服务器每日备份上云,勒索攻击删除被拒

本文是我给自己的博客 mashangan.com 补齐异地备份体系的完整复盘,包含踩过的每一个坑。如果你也有一台跑着个人网站的云服务器,照着做大约 30 分钟就能配好一套"黑客拿到服务器也删不掉"的备份。

一、我为什么突然认真做备份

一句话:被勒索过。

某天打开网站,数据库空了,只剩一张表,里面躺着一行勒索信息——"你的数据已转移到安全服务器,支付 X 个比特币后归还……"。

那次幸运的是数据不多,手头还留有一份手动导出的 SQL,网站才得以复活。但"全部家当悬在一台服务器上"的感觉,让我的后背凉了很久。这次我决定按商业公司的标准,把备份一次性补齐,并把过程写下来。

二、商业公司是怎么做备份的

企业的数据安全团队基本都在遵循一套公开多年的方法论——3-2-1 备份原则:

3-2-1 备份原则示意

  • 3 份副本:原始数据 + 至少两份备份

  • 2 种介质:比如云盘 + 对象存储,不放在同一块盘上

  • 1 份异地:至少一份放在不同的地理位置,防火灾、防勒索、防手滑

在此之上,企业还会加两条硬性要求:

  1. 不可变备份(WORM):备份写入后一段时间内谁也改不了、删不了,勒索软件加密了也白加密;

  2. 定期恢复演练:没实际恢复过的备份,等于没有备份。

对个人站长来说,我把它提炼成防勒索三板斧:

措施

防什么

成本

异地备份(对象存储)

服务器被一锅端

每月几毛钱

只写密钥(不给删除权限)

黑客拿到服务器后顺手删掉云端备份

免费

开启版本控制

密钥泄露后被恶意覆盖/删除

免费

这三板斧里,最核心的其实是第二条:备份的删除权,永远不要交到生产服务器手里。勒索攻击的典型动作是"先删备份、再加密数据",你的备份只要删不掉,攻击就等于失败。

三、方案选型:为什么是对象存储

我的服务器是一台腾讯云上海轻量应用服务器,博客跑的是 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 步:验证、验证、验证

备份体系最容易翻车的不是"没配",而是"以为配好了"。我的验收清单:

  1. 手动跑一次备份,确认产物文件和体积正常;

  2. 用独立网络(本地电脑)列举桶,交叉确认文件真的存在——服务器自己验证自己,可能被本地 DNS/代理之类的问题骗到;

  3. 负测试:用子账号密钥尝试删除对象 → 被拒(403)。这一步是在验证防勒索的核心属性:删不掉;

  4. 次日定时任务跑完后,再到桶里确认多出新文件。

五、避雷点清单(全是真踩过的)

  1. 备份脚本存在 ≠ 备份在跑。我服务器的初始化脚本里明明写了装备份的逻辑,但从未真正执行——直到这次检查才发现备份目录是空的。定期看一眼备份产物和日志。

  2. 备份和数据库同机 = 没有备份。勒索攻击的第一步就是全盘加密,同机备份第一个陪葬。

  3. 轻量服务器控制台的 COS 入口是阉割版,建桶报 RequestRoleProxy / ResourceUnavailable,去 COS 主控制台。

  4. cos:ListBucket 是无效 action(AWS 的命名),腾讯云要用 cos:GetBucket。

  5. CAM 策略改动有分钟级传播延迟。刚授权完测试会出现"一秒成功、一秒 403"的假象,等 3~5 分钟再测,否则会被假象带偏、越改越乱。

  6. rclone + 腾讯云 COS 可能假成功。退出码 0 不代表落桶,务必 GetBucket 自检或用独立网络交叉验证。个人建议:对腾讯云 COS 直接用原生签名上传,别恋战。

  7. 容器写入的文件,宿主机普通用户备份不了(root 属主),备份 cron 放 root crontab。

  8. 永远不要把主账号密钥放到服务器。子账号 + 最小权限 + 关闭控制台登录,密钥泄露的损失上限 = 一个只写桶。

  9. 版本控制必须开。它是最后一道防线:就算哪天密钥真的泄露、对象被删,历史版本还能恢复。

  10. 密钥文件 chmod 600,不进 git、不进聊天记录、不进截图。任何密钥类信息都不该出现在对话和截图里,包括"看着不像密码"的密码。

  11. 没做过恢复演练的备份 = 没有备份。找个下午,把备份拉下来真恢复一次,你会立刻发现脚本里的问题。

  12. 排错先确认"错误来自谁":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 表示已确认落桶