在 Linux 中构建不可变备份:Restic、RustFS 与 Borg 实战

勒索软件团伙往往首先攻击备份,因为只要受害者还有一份干净的恢复点,就不会轻易付款。如果拥有备份客户端 root 权限的人能够删除快照,那些快照原本就不安全。不可变备份堵住了这个缺口:数据写入后,无论攻击者窃取了什么凭据,存储层在保留期结束前都不会允许修改或删除它。

本文将在 Linux 上搭建两个不可变备份目标,并分别对它们发起删除攻击。第一个目标是让 Restic 写入启用 S3 Object Lock、使用 COMPLIANCE 模式的 RustFS 存储桶;第二个目标是通过 SSH 使用 Borg 的 append-only 模式。较早版本曾使用 MinIO,但其社区下载现在返回 410 Gone,上游仓库也在 2026 年 4 月归档,因此本文改用 RustFS。以下命令均在 2026 年 10 月于 Ubuntu 26.04 LTS 和 Rocky Linux 10 上运行,包括删除失败和恢复过程。

选择不可变层

不可变备份意味着备份客户端可以追加数据,但不能销毁已经保存的内容。这可以由两种机制提供:存储 API,例如 S3 Object Lock;或者备份服务器进程,例如 Borg 的 append-only 模式。还有第三层防线:一个没有删除权限的备份密钥,可以在前两种机制受到测试之前挡住大多数攻击。实验展示了每一层真正能阻止什么:

攻击者持有 Restic + 仅备份 S3 密钥 Restic + RustFS 管理员密钥 Borg append-only
删除操作结果 拒绝:Access Denied 表面上成功,但留下删除标记 表面上成功,记录为事务
锁定数据是否存活 是,数据未被触碰 是,COMPLIANCE 版本拒绝删除 是,segment 仍保留在磁盘
删除攻击后的恢复工作 无 移除删除标记,执行 restic unlock 在服务器回滚事务日志
存储主机上的 root 可清空 /data/rustfs 可清空 /data/rustfs 可清空仓库
自动过期 是,按保留天数 是,按保留天数 否,需要手动处理

需要认真看最后两行。Object Lock 由 S3 API 强制执行,因此可以防御任何通过网络访问 RustFS 的人,包括管理员账号;它不能防御存储服务器上的 root,因为 root 可以直接删除 /data/rustfs 下的文件。因此备份服务器必须使用独立凭据、独立网段,最好由独立团队管理。做到这些之后,再同时运行两个目标:以 Restic + Object Lock 为主副本,以 Borg 为独立第二副本。

前置条件

实验使用两台机器,同样的分离方式也适用于生产环境。不要让备份服务器加入管理生产环境的域或目录服务,因为共享管理员账号会把“不可变”重新变成“可删除”。

  • backup01:Ubuntu 26.04 LTS,10.0.1.50,运行 RustFS 和 Borg 服务端
  • app01:Rocky Linux 10,SELinux 处于 enforcing,10.0.1.51,运行 Restic 和 Borg 客户端
  • backup01 使用 2 vCPU、4 GB 内存;RustFS 空闲时占用约 57 MB
  • backup01 的磁盘容量必须覆盖完整保留窗口,而不只是一次备份
  • 两台主机都需要 sudo 权限

测试版本为 RustFS 1.0.4、RustFS rc 客户端 0.1.36、AWS CLI 2.39.1、Restic 0.19.1、Ubuntu 上的 Borg 1.4.4,以及 Rocky 通过 EPEL 安装的 Borg 1.4.5。文中的凭据均为实验用一次性值,请自行生成。

在备份服务器安装 RustFS

如果以前按照 MinIO 教程搭建,问题会在这里出现:下载地址现在返回 HTTP 410,旧版 wget 会在安装任何内容前报错退出。手动访问 URL 即可看到服务器给出的原因:

curl -s https://dl.min.io/server/minio/release/linux-amd64/minio

MinIO 的公告同样涵盖 mc 和所有 hotfix 构建,因此没有旧地址可以回退。从镜像固定旧二进制意味着运行一个不再获得安全修复的对象存储服务,而它恰好是专门用于抵御攻击的服务器,这样的取舍不可接受。

410 Gone

The open-source MinIO Server, MinIO Client (mc) and MinIO KES projects are
archived and no longer maintained. MinIO does not provide product support,
security updates, or security advisories for them, and does not accept or
process vulnerability reports concerning them.

These files are no longer served from this site. This applies to all
community releases and to all hotfix builds for them.

这里选择 RustFS,是因为其文档明确描述了本文依赖的行为:版本化存储桶上的 Object Lock、默认保留规则,以及即使使用治理绕过也仍阻止删除的 COMPLIANCE 模式。它以 Apache 2.0 许可证发布为单个静态二进制文件,并使用 Restic 已支持的同一套 S3 API。Garage 不适用,因为它没有存储桶版本控制,根本无法实现 Object Lock。如果希望保留 MinIO 的命令集,Pigsty 维护的社区分支 SILO 仍在发布构建,但本文没有测试。

下载并验证二进制文件

RustFS 处于 1.0.x 阶段,更新很快,因此应固定测试过的版本,而不是每次重建都拉取 latest。GitHub Release 提供 SHA256SUMS,而文档中的 latest.zip 链接没有校验文件。安装 unzip,下载两个文件并验证:

sudo apt install -y unzip
VER=1.0.4  # 固定:测试过的版本;RustFS 1.0.x 更新很快,https://github.com/rustfs/rustfs/releases
mkdir -p ~/rustfs && cd ~/rustfs
curl -fsSLO https://github.com/rustfs/rustfs/releases/download/$VER/rustfs-linux-x86_64-musl-v$VER.zip
curl -fsSLO https://github.com/rustfs/rustfs/releases/download/$VER/SHA256SUMS
sha256sum --ignore-missing -c SHA256SUMS

只有 zip 校验显示 OK 才能继续。不匹配意味着下载损坏或文件被篡改,而这两种情况都不适合出现在负责保护备份的软件中:

rustfs-linux-x86_64-musl-v1.0.4.zip: OK

解压并安装。压缩包内只有一个名为 rustfs 的文件,大小约 276 MB:

unzip -o rustfs-linux-x86_64-musl-v$VER.zip
sudo install -m 0755 rustfs /usr/local/bin/rustfs
rustfs --version

版本横幅应与固定的标签一致。同时记录 git commit,因为以后核对 RustFS 安全公告时,这是匹配二进制最快捷的方式:

rustfs 1.0.4
build time   : 2026-10-25 13:46:05 +00:00
build profile: release
build os     : linux-x86_64
build target : x86_64-unknown-linux-musl
rust version : rustc 1.99.1 (58cf8f9c3 2026-10-13)
rust channel : stable-x86_64-unknown-linux-musl
git branch   :
git commit   : 46337942d55f3a8e6a97b147ca98d9ea8115555d
git tag      : 1.0.4
git status   :

RustFS 绝不应以 root 身份运行。面向网络的存储守护进程如果存在漏洞并以 root 运行,攻击者就能控制整台机器,进而控制桶内文件。创建一个不能登录的系统账号,以及它拥有的数据和日志目录:

sudo groupadd -r rustfs
sudo useradd -M -r -g rustfs -s /usr/sbin/nologin rustfs
sudo mkdir -p /data/rustfs /var/log/rustfs
sudo chown -R rustfs:rustfs /data/rustfs /var/log/rustfs
sudo chmod -R 750 /data/rustfs /var/log/rustfs

生产环境应把 /data/rustfs 放在独立磁盘上。保留窗口为 30 天时,卷必须容纳 30 天备份以及 Restic 曾尝试清理但仍被锁定的全部数据;根文件系统被占满会拖垮整个服务器。

创建环境文件

RustFS 通过环境变量读取配置。打开 systemd 单元将要加载的文件:

sudo vim /etc/default/rustfs

写入管理员凭据、数据路径和两个监听地址。端口 9000 提供 S3 API,9001 提供 Web 控制台:

RUSTFS_ACCESS_KEY=backupadmin
RUSTFS_SECRET_KEY=Ch4ngeMe-Lab-Only-2026
RUSTFS_VOLUMES="/data/rustfs"
RUSTFS_ADDRESS=":9000"
RUSTFS_CONSOLE_ENABLE=true
RUSTFS_CONSOLE_ADDRESS=":9001"
RUSTFS_OBS_LOGGER_LEVEL=error
RUSTFS_OBS_LOG_DIRECTORY="/var/log/rustfs/"

该文件包含唯一可创建删除标记的管理员密钥,因此只允许 root 和服务组读取:

sudo chown root:rustfs /etc/default/rustfs
sudo chmod 640 /etc/default/rustfs

应把管理员密钥视为应急凭据。生产主机绝不能持有它,后文的攻击实验会说明它在错误的人手中能做什么;生产主机只使用后文创建的仅备份用户。应在密码库中保存副本,以便 backup01 本身需要重建时仍能执行恢复。

创建 systemd 服务

下面使用 RustFS 文档中的单元,并调整用户、组和数据路径。创建文件:

sudo vim /etc/systemd/system/rustfs.service

粘贴完整单元。底部加固选项值得保留,因为这个进程持有你的全部备份:

[Unit]
Description=RustFS Object Storage Server
Documentation=https://rustfs.com/docs/
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
NotifyAccess=main
User=rustfs
Group=rustfs

WorkingDirectory=/usr/local
EnvironmentFile=-/etc/default/rustfs
ExecStart=/usr/local/bin/rustfs $RUSTFS_VOLUMES

LimitNOFILE=1048576
LimitNPROC=32768
TasksMax=infinity

Restart=always
RestartSec=10s

OOMScoreAdjust=-1000
SendSIGKILL=no

TimeoutStartSec=120s
TimeoutStopSec=30s

NoNewPrivileges=true
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectClock=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictRealtime=true

StandardOutput=append:/var/log/rustfs/rustfs.log
StandardError=append:/var/log/rustfs/rustfs-err.log

[Install]
WantedBy=multi-user.target

Type=notify 表示只有 RustFS 报告准备就绪后,systemd 才会把服务标记为已启动;TimeoutStartSec=120s 给它两分钟启动时间。如果启动超时,首先查看 /var/log/rustfs/rustfs-err.log。启动服务并确认两个端口都在监听:

sudo systemctl daemon-reload
sudo systemctl enable --now rustfs
systemctl status rustfs --no-pager
ss -tlnp | grep -E ':900[01]'

期望看到 active (running)、状态行 Running,以及 9000 和 9001 端口监听:

● rustfs.service - RustFS Object Storage Server
     Loaded: loaded (/etc/systemd/system/rustfs.service; enabled; preset: enabled)
     Active: active (running) since Thu 2026-10-29 17:05:08 UTC; 5s ago
 Invocation: 91dac4f51dbe49cebdc05440de7da5b4
       Docs: https://rustfs.com/docs/
   Main PID: 32997 (rustfs)
     Status: "Running"
      Tasks: 15
     Memory: 57.5M (peak: 57.6M)
        CPU: 161ms
     CGroup: /system.slice/rustfs.service
             └─32997 /usr/local/bin/rustfs /data/rustfs

Oct 29 17:05:08 backup01 systemd[1]: Starting rustfs.service - RustFS Object Storage Server...
Oct 29 17:05:08 backup01 systemd[1]: Started rustfs.service - RustFS Object Storage Server.
LISTEN 0      4096               *:9001            *:*
LISTEN 0      4096               *:9000            *:*

Ubuntu 26.04 云镜像的 ufw 默认未启用。如果启用 ufw,必须先允许 SSH,否则会把自己锁在服务器外;然后允许 S3 端口。9001 管理控制台应保持关闭,除非从管理网络访问。实验环境未启用 ufw,因此只执行了 S3 规则:

sudo ufw allow 9000/tcp

生产服务器只需要访问 backup01 的 9000 端口以使用 Restic,以及 22 端口以使用 Borg。其他端口都不应从生产网段可达,因此还应在网段间防火墙配置同样的规则。仅依赖主机防火墙,只要攻击者获得 root 并执行一次 ufw disable 就会失效。

创建启用 Object Lock 的存储桶

应在创建存储桶时就启用 Object Lock,使版本控制和锁定在 Restic 第一次写入前生效。RustFS 也能给已有版本化桶添加锁配置,但此前写入的对象不会自动被锁定。Restic 的 init 会创建普通桶,因此需要先手动建立目标桶。这里使用 AWS CLI,因为它与 RustFS 文档描述的 S3 API 一一对应,而且恢复时查看对象版本也会用到同一工具。

安装官方 AWS CLI

不要使用 Ubuntu 26.04 仓库中的 awscli。版本 2.31.35 虽然可以安装,但本项目执行的所有 s3api list-* 调用都在发送请求前失败:

badly formed help string

这会使恢复所需的 list-object-versions 无法使用。官方 AWS 构建自带 Python,不会出现该问题。如果已经安装发行版包,先删除,再安装官方版本:

sudo apt remove -y awscli
cd ~
curl -fsSLO https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip
unzip -qo awscli-exe-linux-x86_64.zip
sudo ./aws/install --update
hash -r
aws --version

版本字符串中的 exe/x86_64 标识这是官方捆绑构建:

aws-cli/2.39.1 Python/3.14.8 Linux/7.0.0-15-generic exe/x86_64.ubuntu.26

将 RustFS 管理员密钥保存为命名配置,并把端点放入变量,便于后续命令保持简短。AWS CLI 要求提供区域,us-east-1 可以正常连接 RustFS:

aws configure set aws_access_key_id backupadmin --profile rustfs
aws configure set aws_secret_access_key Ch4ngeMe-Lab-Only-2026 --profile rustfs
aws configure set region us-east-1 --profile rustfs
export AWS_PROFILE=rustfs
S3=http://10.0.1.50:9000

如果之后还会回到这个 shell,可把 export AWS_PROFILE 和 S3= 写入 backup01 的 ~/.bashrc。后文所有 aws 命令都假设两者已设置。

创建带 30 天 COMPLIANCE 默认规则的存储桶

创建存储桶时启用锁标志。成功的 create-bucket 不会输出内容,因此紧接着检查版本控制:

aws --endpoint-url $S3 s3api create-bucket --bucket immutable-backups --object-lock-enabled-for-bucket
aws --endpoint-url $S3 s3api get-bucket-versioning --bucket immutable-backups

即使没有显式请求,版本控制也会变为启用状态。Object Lock 依赖版本控制,这也是后续删除会变成删除标记而不是真正删除的原因。如果结果显示 Suspended 或没有输出,说明锁标志未生效;应删除空桶并重新创建:

{
    "Status": "Enabled"
}

现在设置默认规则,使 Restic 写入的每个对象都以 COMPLIANCE 模式锁定 30 天。Restic 自己不发送保留头,如果没有这条规则,对象只有版本控制而不会锁定:

aws --endpoint-url $S3 s3api put-object-lock-configuration --bucket immutable-backups \
  --object-lock-configuration '{"ObjectLockEnabled":"Enabled","Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Days":30}}}'
aws --endpoint-url $S3 s3api get-object-lock-configuration --bucket immutable-backups

读回的配置应与写入完全一致:

{
    "ObjectLockConfiguration": {
        "ObjectLockEnabled": "Enabled",
        "Rule": {
            "DefaultRetention": {
                "Mode": "COMPLIANCE",
                "Days": 30
            }
        }
    }
}

保留天数必须在第一次备份写入前确定。COMPLIANCE 保留期之后不能缩短,连管理员账号也无法操作;误写 365 就意味着一年内无法回收这些存储空间。30 天覆盖了大多数勒索事件在环境中的驻留时间,同时让容量成本更可预测。

为 Restic 创建仅备份用户

Object Lock 能保住数据,但拥有删除权限的密钥仍可让攻击者用删除标记把数据藏起来并迫使你进入恢复流程。没有删除权限的密钥可以在第一步直接阻止攻击。然而 Restic 需要删除自己放在 locks/ 下的锁文件,因此策略只允许删除这一前缀,除此之外不允许删除。安装 RustFS 管理客户端 rc 并校验:

cd ~/rustfs
RCVER=v0.1.36  # 固定:测试过的版本;https://github.com/rustfs/cli/releases
curl -fsSLO https://github.com/rustfs/cli/releases/download/$RCVER/rustfs-cli-linux-amd64-$RCVER.tar.gz
curl -fsSLO https://github.com/rustfs/cli/releases/download/$RCVER/rustfs-cli-linux-amd64-$RCVER.tar.gz.sha256
sha256sum -c rustfs-cli-linux-amd64-$RCVER.tar.gz.sha256
tar xzf rustfs-cli-linux-amd64-$RCVER.tar.gz
sudo install -m 0755 rc /usr/local/bin/rc
rc alias set rustfs http://10.0.1.50:9000 backupadmin Ch4ngeMe-Lab-Only-2026

校验结果必须为 OK,alias 步骤应显示成功标记。该 alias 会把管理员密钥保存在 backup01 的主目录中;这里可以接受,其他任何主机都不应这样做。

rustfs-cli-linux-amd64-v0.1.36.tar.gz: OK
✓ Alias 'rustfs' configured successfully.

将策略写入文件。RustFS 接受 AWS 风格的 IAM 策略 JSON,因此 actions 和 ARN 的写法与 S3 存储桶策略一致:

vim ~/restic-app01-policy.json

添加以下文档。列桶、读取和写入权限覆盖 restic backup、check 和 restore;第三条语句是它唯一拥有的删除权限:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:ListBucket", "s3:GetBucketLocation"],
      "Resource": ["arn:aws:s3:::immutable-backups"]
    },
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": ["arn:aws:s3:::immutable-backups/*"]
    },
    {
      "Effect": "Allow",
      "Action": ["s3:DeleteObject"],
      "Resource": ["arn:aws:s3:::immutable-backups/locks/*"]
    }
  ]
}

创建策略,并为每台生产主机创建独立用户后绑定策略。这里按用户服务的主机命名。

rc admin policy create rustfs restic-app01-write ~/restic-app01-policy.json
rc admin user add rustfs restic-app01 Restic-App01-Lab-Only-2026
rc admin policy attach rustfs restic-app01-write --user restic-app01
rc admin user info rustfs restic-app01

用户应处于启用状态,并且只绑定一个策略。如果出现多个策略,用户会继承它们的并集,测试时遗留的宽泛策略会在不知不觉中重新赋予删除权限:

✓ Policy 'restic-app01-write' created successfully.
✓ User 'restic-app01' created successfully.
✓ Policy 'restic-app01-write' attached to user 'restic-app01' successfully.
Access Key: restic-app01
Status:     enabled
Policies:   restic-app01-write
Groups:     (none)

每台主机单独使用一个用户,还能在 RustFS 日志出现拒绝删除时指出哪台机器被攻陷,而共享密钥做不到这一点。发生事件时,也可以只禁用某台主机的密钥,让其他服务器继续备份。实验只有一个客户端;在生产集群中,应为每台主机分配桶内独立前缀,例如 immutable-backups/app01,并使用限定到该前缀的独立策略。因为共享同一仓库的主机能够读取彼此数据,而且 restic check 持有的排他锁会让另一台主机在检查期间备份失败。

还要理解该密钥仍然能够做什么。本文只测试了删除;PutObject 允许被盗密钥覆盖 config、密钥文件或快照的当前版本。被锁定的旧版本仍然存在,但 Restic 会读取最新版本,因此必须手动将旧版本恢复为当前版本。每次垃圾写入同样继承 30 天 COMPLIANCE 锁,因此被盗密钥可以不断写入无法删除的数据,直到占满 /data/rustfs。应监控桶的增长并为卷预留足够空间。

在 app01 配置 Restic

本节命令全部在 app01 上执行。Restic 需要 root 读取系统文件,而 Rocky Linux 10 的 root PATH 不包含 /usr/local/bin,因此进入 root shell 后添加该路径:

sudo -i
export PATH=$PATH:/usr/local/bin

如果不执行 export,即使二进制已经安装,后续每个 restic 命令都会返回 command not found。Ubuntu 的 root PATH 已经包含 /usr/local/bin,所以这一步只针对 RHEL 系列。请在进入 Borg 部分前一直留在这个 root shell 中,因为接下来加载的环境变量只存在于当前 shell。

从 GitHub 安装 Restic

EPEL 提供 Restic,但版本落后于上游。Rocky 云镜像还只安装了 vi,因此第一行会安装 vim-enhanced 供后续编辑。查询最新标签,下载二进制和校验文件,验证后再安装:

dnf install -y bzip2 vim-enhanced
VER=$(curl -sL https://api.github.com/repos/restic/restic/releases/latest | grep tag_name | head -1 | sed 's/.*"v\([^"]*\)".*/\1/')
echo $VER
cd /tmp
curl -fsSLO https://github.com/restic/restic/releases/download/v$VER/restic_${VER}_linux_amd64.bz2
curl -fsSLO https://github.com/restic/restic/releases/download/v$VER/SHA256SUMS
sha256sum --ignore-missing -c SHA256SUMS
bunzip2 -f restic_${VER}_linux_amd64.bz2
install -m 0755 restic_${VER}_linux_amd64 /usr/local/bin/restic
restic version

实验得到 0.19.1,并通过校验。脚本总是拉取最新版本,因此生产自动化应固定测试过的版本,因为新版本可能改变 prune 行为:

0.19.1
restic_0.19.1_linux_amd64.bz2: OK
restic 0.19.1 compiled with go1.26.4 on linux/amd64

Restic 是单个静态二进制,app01 无需安装其他组件。

保存凭据并初始化仓库

将仓库密码保存在仅 root 可读的文件中,并复制一份到本机之外。没有该密码,即使有 Object Lock 也无法读取加密仓库:

openssl rand -base64 24 > /root/.restic-pass
chmod 600 /root/.restic-pass

同一个环境文件既可供当前交互 shell 使用,也可供后文的 systemd 定时器使用,使密钥只存在一个位置:

vim /etc/restic-env

使用仅备份用户,绝不要使用 RustFS 管理员密钥。任何获得 app01 root 权限的人都能读取该文件,因此这里只能放你愿意承受泄露的凭据:

AWS_ACCESS_KEY_ID=restic-app01
AWS_SECRET_ACCESS_KEY=Restic-App01-Lab-Only-2026
RESTIC_REPOSITORY=s3:http://10.0.1.50:9000/immutable-backups
RESTIC_PASSWORD_FILE=/root/.restic-pass

限制权限,加载到 shell,并在 backup01 上的桶里初始化仓库:

chmod 600 /etc/restic-env
set -a; . /etc/restic-env; set +a
restic init

Restic 会把配置和密钥对象写入存储桶,它们从这一刻起也会被锁定。应立即把 /root/.restic-pass 复制到密码库,因为锁定对象没有密码就毫无用处,而 app01 正是你准备失去的机器。

created restic repository a6d0f0f7be at s3:http://10.0.1.50:9000/immutable-backups

Please note that knowledge of your password is required to access
the repository. Losing your password means that your data is
irrecoverably lost.

仅备份密钥顺利完成了 init,证明策略在依赖它之前至少提供了正常使用所需的权限。如果 init 返回 Access Denied,请核对策略中的 ListBucket、PutObject 和存储桶名称,现在就找出缺口,而不是等到第一次夜间备份才发现。

执行首次备份

生成 50 个随机内容文件代表业务数据,执行备份并记录校验和。校验和用于证明恢复结果逐字节正确,而不只是“找回了一些文件”:

mkdir -p /srv/important-data
for i in $(seq 1 50); do echo "Critical business document $i $(date '+%F %T') $(openssl rand -hex 32)" > /srv/important-data/document_$i.txt; done
restic backup /srv/important-data
sha256sum /srv/important-data/*.txt > /root/original-checksums.txt
restic snapshots

最后表格中的快照 ID 就是攻击者下一步要删除的对象;你的 ID 会与 bae9711f 不同。注意 5.6 KiB 文件最终占用了 13.2 KiB,因为 Restic 的 pack 和索引开销在小测试数据中会占据主导;真实数据中比例会反过来:

no parent snapshot found, will read all files

Files:          50 new,     0 changed,     0 unmodified
Dirs:            2 new,     0 changed,     0 unmodified
Added to the repository: 29.002 KiB (13.238 KiB stored)

processed 50 files, 5.606 KiB in 0:00
snapshot bae9711f saved
ID        Time                 Host        Tags        Paths                Size
-------------------------------------------------------------------------------------
bae9711f  2026-10-29 17:07:13  app01                   /srv/important-data  5.606 KiB
-------------------------------------------------------------------------------------
Timestamps shown in local time
1 snapshots

该快照背后的每个对象现在都带有从写入时开始计算的 30 天 COMPLIANCE 锁。可以从 backup01 对任意对象执行 s3api get-object-retention 确认;下一节在尝试删除前也会这样做。

测试 Restic 仓库抵御删除

假设攻击者已获得 app01 的 root 权限,能够读取 /etc/restic-env 和密码文件,也就掌握 Restic 拥有的一切。实验按密钥权限从弱到强执行三种攻击,结果与本文旧版本中的描述有重要差异。

使用仅备份密钥删除

先执行最直接的操作:使用机器中已有的凭据忘记快照并清理其数据。请在当前命令和下一条命令中替换为自己的快照 ID:

restic forget bae9711f --prune

RustFS 拒绝了第一次删除,Restic 还没有触碰数据 pack 就退出。末尾的 Go 堆栈看起来吓人,但它只表示错误从何处冒出来:

[0:00] 0.00%  0 / 1 files deleted
Remove(<snapshot/bae9711f59>) failed: client.RemoveObject: Access Denied
unable to remove snapshot/bae9711f5930c8d97b04753f675d0a4ca753f8a699aa13bb820a8252105a08ab from the repository
failed to remove one or more snapshots
main.init
    /restic/cmd/restic/cmd_forget.go:67
runtime.doInit1
    /usr/local/go/src/runtime/proc.go:8103
runtime.doInit
    /usr/local/go/src/runtime/proc.go:8070
runtime.main
    /usr/local/go/src/runtime/proc.go:258
runtime.goexit
    /usr/local/go/src/runtime/asm_amd64.s:1771

随后执行 restic snapshots,快照 bae9711f 仍在列表中。这正是生产主机期望的结果;对日常安全而言,仅备份用户比锁本身更重要:攻击在第一次调用就失败,没有留下需要清理的内容。副作用是你也无法从 app01 执行 forget,因此保留策略清理应转移到存放管理员密钥的备份服务器。这个位置也更合理,因为决定删除内容的主机不应是最容易被攻陷的主机。

使用被盗管理员密钥删除

现在假设攻击者在遗忘的脚本或导出的密码管理器中找到了 RustFS 管理员密钥。覆盖两个密钥变量,重复同一条命令:

AWS_ACCESS_KEY_ID=backupadmin AWS_SECRET_ACCESS_KEY=Ch4ngeMe-Lab-Only-2026 restic forget bae9711f --prune

这次 Restic 报告 prune 正常完成。所有 pack 和索引都显示为已删除,因为每次 S3 删除调用都返回成功:

[0:00] 100.00%  1 / 1 files deleted
1 snapshots have been removed, running prune
loading all snapshots...
finding data that is still in use for 0 snapshots
[0:00]          0 snapshots
searching used packs...
collecting packs for deletion and repacking
[0:00] 100.00%  2 / 2 packs processed

to repack:             0 blobs / 0 B
this removes:          0 blobs / 0 B
to delete:            53 blobs / 11.116 KiB
total prune:          53 blobs / 11.116 KiB
remaining:             0 blobs / 0 B
unused size after prune: 0 B ( of remaining size)

rebuilding index
[0:00] 100.00%  1 / 1 indexes processed
[0:00] 100.00%  1 / 1 old indexes deleted
removing 2 old packs
[0:00] 100.00%  2 / 2 files deleted
done

之后执行 restic snapshots,结果为空。从客户端角度看备份已经消失,未经测试的部署可能在这里陷入恐慌。实际发生的是 Restic 发送了不带版本 ID 的普通删除请求。在版本化桶中,这类请求不会移除数据,而是给每个对象叠加一个删除标记,使其从普通列表中隐藏。可在 backup01 检查:

aws --endpoint-url $S3 s3api list-object-versions --bucket immutable-backups --prefix snapshots/

从下往上阅读输出。删除标记带有 IsLatest: true,因此 Restic 的普通列表无法再看到快照。真正的对象位于它上面,使用相同 key,拥有独立版本 ID 和完整的 355 字节,所以实际上没有丢失:

{
    "Versions": [
        {
            "ETag": "\"a8bcb5a535687766443c1ac43d332372\"",
            "Size": 355,
            "StorageClass": "STANDARD",
            "Key": "snapshots/bae9711f5930c8d97b04753f675d0a4ca753f8a699aa13bb820a8252105a08ab",
            "VersionId": "6d4767aa-4bd6-4c52-b035-9abab6708674",
            "IsLatest": false,
            "LastModified": "2026-10-29T17:07:14.031000+00:00"
        }
    ],
    "DeleteMarkers": [
        {
            "Key": "snapshots/bae9711f5930c8d97b04753f675d0a4ca753f8a699aa13bb820a8252105a08ab",
            "VersionId": "eb1a960d-d78a-4f68-bf00-ebb5e524b81f",
            "IsLatest": true,
            "LastModified": "2026-10-29T17:07:39.745000+00:00"
        }
    ],
    "RequestCharged": null,
    "Prefix": "snapshots/"
}

在整个桶中,攻击留下了 11 个删除标记:2 个在数据 pack,1 个在索引,1 个在快照,7 个在锁文件。全部都可以移除,这就是完整恢复过程。

尝试删除已锁定版本

更聪明的、持有管理员密钥的攻击者会绕过 Restic,直接删除对象版本。请在 backup01 中把自己的 key 和版本 ID 赋给 K、V,先读取版本保留信息:

K=snapshots/bae9711f5930c8d97b04753f675d0a4ca753f8a699aa13bb820a8252105a08ab
V=6d4767aa-4bd6-4c52-b035-9abab6708674
aws --endpoint-url $S3 s3api get-object-retention --bucket immutable-backups --key $K --version-id $V

默认规则给它打上了 COMPLIANCE 模式,以及备份运行 30 天后的日期:

{
    "Retention": {
        "Mode": "COMPLIANCE",
        "RetainUntilDate": "2026-11-28T17:07:14.031000+00:00"
    }
}

现在删除该版本的精确版本,一次普通删除,一次带治理绕过标志:

aws --endpoint-url $S3 s3api delete-object --bucket immutable-backups --key $K --version-id $V
aws --endpoint-url $S3 s3api delete-object --bucket immutable-backups --key $K --version-id $V --bypass-governance-retention

两次都失败,错误会指出锁到期日期。绕过标志没有区别,因为它只适用于 GOVERNANCE 模式,而这些对象写入时继承的是 COMPLIANCE 默认规则:

aws: [ERROR]: An error occurred (AccessDenied) when calling the DeleteObject operation: Object is under COMPLIANCE retention and cannot be deleted until 2026-11-28 17:07:14.031276215 +00:00:00

aws: [ERROR]: An error occurred (AccessDenied) when calling the DeleteObject operation: Object is under COMPLIANCE retention and cannot be deleted until 2026-11-28 17:07:14.031276215 +00:00:00

其他方法也以同样方式失败。缩短保留日期,或把版本改为可通过绕过删除的 GOVERNANCE 模式,都会让 PutObjectRetention 返回 AccessDenied。删除整个桶会返回 BucketNotEmpty,因为每个锁定版本仍被视作有效内容。我们还在另一个锁定的测试桶上尝试了 RustFS 客户端的强制删除 rc bucket remove --force;它在第一个锁定版本处停止并返回 Access Denied,桶仍然存在。测试过的所有网络路径都得到同样结果:在 30 天到期前,攻击者无法删除数据;想索取赎金就必须先在网络中隐藏一个月。

加密生产数据

回到 app01,按勒索软件的方式完成模拟:原地加密每个文件并删除原文件,这样唯一干净的副本就是刚才无法被摧毁的备份。

cd /srv/important-data
for f in *.txt; do openssl enc -aes-256-cbc -salt -pbkdf2 -in "$f" -out "${f}.encrypted" -pass pass:ransom_key_2026 && rm -f "$f"; done
ls /srv/important-data | head -5

50 个文档现在都带有 .encrypted 后缀,目录仍有 50 个条目。真实勒索软件还会放入勒索说明:

document_10.txt.encrypted
document_11.txt.encrypted
document_12.txt.encrypted
document_13.txt.encrypted
document_14.txt.encrypted

Rocky 云镜像不带 xxd,安装 vim-common 即可获得。十六进制输出显示 OpenSSL 的 Salted__ 头和随后密文:

xxd /srv/important-data/document_1.txt.encrypted | head -3

文件中已没有可读内容,没有攻击者密钥,app01 本身无法恢复。恢复必须来自备份:

00000000: 5361 6c74 6564 5f5f c656 b6dc 0fa9 195d  Salted__.V.....]
00000010: c809 f832 7099 0c43 8e0e d477 8d1d 6ccd  ...2p..C...w..l.
00000020: 66fa efbf 1159 43a8 8d64 c6f9 9f6e 3303  f....YC..d...n3.

此时 app01 只剩密文,攻击者已经用两把密钥尝试过备份。从客户端角度看似乎毫无希望:快照列表为空,文件也已消失。恢复要从 backup01 开始,因为移除标记需要管理员密钥,而该密钥绝不能出现在 app01。真实事件中应先重建 app01 再恢复;实验为缩短时间在原地恢复。

通过删除标记恢复 Restic

删除标记不受保留策略限制,管理员密钥可以移除它们;每移除一个标记,下面被锁定的版本就会重新成为当前版本。该操作只能在 backup01 执行,绝不能在已失陷主机上执行。创建一个小脚本,查找所有当前位于顶层的删除标记,并按版本 ID 删除:

sudo vim /usr/local/bin/s3-undelete.sh

桶和端点作为带默认值的参数传入,因此脚本也可用于其他桶。它只处理当前位于顶层的标记,不会遍历旧版本或被锁定数据:

#!/bin/bash
set -euo pipefail
BUCKET=${1:-immutable-backups}
EP=${2:-http://10.0.1.50:9000}
export AWS_PROFILE=${AWS_PROFILE:-rustfs}
n=0
while read -r key vid; do
  [ -z "$key" ] && continue
  aws --endpoint-url "$EP" s3api delete-object --bucket "$BUCKET" --key "$key" --version-id "$vid" >/dev/null
  n=$((n+1))
done < <(aws --endpoint-url "$EP" s3api list-object-versions --bucket "$BUCKET" \
  --query 'DeleteMarkers[?IsLatest==`true`].[Key,VersionId]' --output text | grep -v '^None')
echo "removed $n delete markers"

赋予执行权限,并以 ~/.aws 中保存 rustfs 配置的用户运行。只能在 backup01 上运行,因为所需管理员密钥绝不能输入到正在清理的主机。脚本结束时输出一个计数:

sudo chmod 755 /usr/local/bin/s3-undelete.sh
s3-undelete.sh

它移除了攻击创建的全部 11 个标记。移除标记是普通 S3 调用,整个恢复过程不需要绕过任何锁:

removed 11 delete markers

如果自上次良好状态以来的删除都来自攻击者,这种一键撤销是正确的。如果也执行过合法的保留清理,应把脚本限定为只移除事件开始时间之后的标记,而不是所有标记。完整恢复顺序如下:

  1. 在 backup01 移除删除标记。
  2. 用 restic unlock 清除过期锁。
  3. 用 restic snapshots 和 restic check 确认。
  4. 恢复到暂存目录并比对校验和。

快照列表看似正常时很容易跳过第 3 步检查,但列表只能证明快照对象回来了。check 会把索引与后端列出的 pack 对比,报告缺失对象,从而发现仍被遗漏标记隐藏的数据。若希望下载并验证每个 pack,可增加 --read-data。

错误:“repository is already locked exclusively”

回到 app01 后,第一次列快照会失败。Prune 曾获取排他锁,而其锁文件删除又变成了一个标记;移除所有标记时,这个过期锁也跟着恢复了:

repo already locked, waiting up to 0s for the lock
unable to create lock in backend: repository is already locked exclusively by PID 52637 on app01 by root (UID 0, GID 0)
lock was created at 2026-10-29 17:07:39 (3m2.602982006s ago)
storage ID 3b43c386
the `unlock` command can be used to remove stale locks

仅备份密钥允许删除 locks/ 下的内容,因此普通凭据即可执行 restic unlock。清除锁后再列出和检查仓库:

restic unlock
restic snapshots
restic check

攻击者“删除”的快照回来了,完整检查没有发现损坏。unlock 报告 7 个锁,与攻击产生的 7 个锁文件标记一致。运行数周的服务器可能产生更多旧锁,因为每次备份和检查都会删除自己的锁;一键撤销也会恢复这些旧锁,而 restic unlock 会清除过期项。其他主机在最近 30 分钟内创建的新锁不会被视作过期;restic unlock --remove-all 会连这些也移除:

successfully removed 7 locks
ID        Time                 Host        Tags        Paths                Size
-------------------------------------------------------------------------------------
bae9711f  2026-10-29 17:07:13  app01                   /srv/important-data  5.606 KiB
-------------------------------------------------------------------------------------
Timestamps shown in local time
1 snapshots
using temporary cache in /tmp/restic-check-cache-2726532435
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
[0:00] 100.00%  1 / 1 snapshots
no errors were found

只有 check 报告无错误后才能恢复。撤销删除后检查失败通常意味着漏掉了一个标记;再次运行脚本,并确认 list-object-versions 中没有剩余 DeleteMarkers 后再操作生产数据。

恢复并验证文件

把加密文件移到其他位置而不是删除,恢复到暂存目录,将数据复制回去并与攻击前的校验和比较。只有校验全部匹配后才删除隔离副本:

cd /
mv /srv/important-data /root/quarantine-encrypted && mkdir /srv/important-data
restic restore latest --target /tmp/restic-restore
cp -a /tmp/restic-restore/srv/important-data/. /srv/important-data/
sha256sum /srv/important-data/*.txt > /tmp/restored-checksums.txt
diff /root/original-checksums.txt /tmp/restored-checksums.txt && echo MATCH && rm -rf /root/quarantine-encrypted
ls /srv/important-data/*.txt | wc -l

diff 无差异并输出 MATCH,表示每个文件都与原始版本一致。恢复计数 52 是 50 个文件加两个父目录,不应理解为多出两个文件:

restoring snapshot bae9711f of [/srv/important-data] at 2026-10-29 17:07:13.314529877 +0000 UTC by root@app01 to /tmp/restic-restore
Summary: Restored 52 files/dirs (5.606 KiB) in 0:00
MATCH
50

先恢复到暂存目录,并在得到 MATCH 前保留隔离副本,是有意设计。若恢复不完整,仍保留加密副本;部分勒索软件家族后来会发布公开解密器。如果 diff 有输出,隔离数据会留在 /root/quarantine-encrypted 供调查。实验中 latest 是干净快照;真实事件中,夜间定时器可能已经备份了密文,因此应从 restic snapshots 中选择入侵前的最后快照,并按 ID 恢复。没有校验和列表时,应打开恢复文件做抽查。

配置 Borg 的 append-only 模式

Borg 提供第二个互不相关的备份目标。这里没有对象存储:备份服务器通过 SSH 强制命令运行 borg serve,--append-only 标志使它保留所有曾经写入的 segment。它比 RustFS 更容易部署,而且针对一种备份工具的可行攻击很少能同时命中另一种工具。

在两台主机安装 BorgBackup

Ubuntu 在 universe 组件中提供 Borg,云镜像默认启用该组件。在 backup01 上执行:

sudo apt install -y borgbackup
borg --version

Ubuntu 26.04 安装的是 1.4.4。服务端永远不需要比客户端更新,因此可以跟随发行版的更新节奏:

borg 1.4.4

Rocky Linux 的基础仓库不包含 Borg,因此从 EPEL 安装。在 app01 上执行:

sudo dnf install -y epel-release
sudo dnf install -y borgbackup
borg --version

EPEL 版本比 Ubuntu 高一个小版本。实验中 1.4.5 客户端与 1.4.4 服务端在所有步骤中均可配合,但两端应保持在同一 1.x 系列。Borg 2 会改变仓库格式,不能只升级一端:

borg 1.4.5

两端安装完成后,其余设置主要是 SSH。append-only 保证完全依赖服务端 authorized_keys 中的那一行,因此该文件必须重点保护和审计。任何能编辑它的人都能去掉该标志,让下一次客户端连接获得完整访问权限。

把 SSH 密钥限制为 append-only

在 backup01 创建专用账号和仓库目录。该账号只用于运行 borg serve,不进行交互登录。

sudo useradd -m -s /bin/bash borgbackup
sudo mkdir -p /backup/borg-repo
sudo chown borgbackup:borgbackup /backup/borg-repo
sudo install -d -m 700 -o borgbackup -g borgbackup /home/borgbackup/.ssh

在 app01 生成一把只用于备份的密钥。由于定时器必须无人值守使用,它没有口令;这可以接受,仅仅因为服务端把它限制为单一命令:

sudo ssh-keygen -t ed25519 -f /root/.ssh/borg_backup_key -N "" -C borg-backup-client
sudo cat /root/.ssh/borg_backup_key.pub

复制输出的公钥。不要使用 ssh-copy-id,否则它会安装一个不带限制的密钥,给客户端完整 shell。改为打开 backup01 上 borgbackup 账号的 authorized_keys:

sudo vim /home/borgbackup/.ssh/authorized_keys

在密钥前添加强制命令。无论客户端请求什么,sshd 都只运行这条命令,因此即使密钥被窃取也只能使用 Borg 协议。把密钥部分替换为 app01 生成的公钥:

command="borg serve --restrict-to-path /backup/borg-repo --append-only",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL8Ti/K4g9HuAYU42RtQpILYyrehHu4qzep6xnKEdPNI borg-backup-client

--append-only 保留所有写过的 segment,--restrict-to-path 把客户端限制在一个目录中,restrict 关闭端口转发、agent 转发和 PTY 分配。按 sshd 要求设置所有权和权限:

sudo chown borgbackup:borgbackup /home/borgbackup/.ssh/authorized_keys
sudo chmod 600 /home/borgbackup/.ssh/authorized_keys

默认 StrictModes 会让 sshd 忽略组或其他用户可写的 authorized_keys,客户端随后将完全无法用密钥登录。这样的问题会让定时任务静默失败,直到有人查看日志才发现。

初始化仓库并执行备份

回到 app01 的 root shell,指定 Borg 使用哪把密钥并设置口令。该密钥使用非默认名称,如果不设置 BORG_RSH,ssh 不会主动提供它。accept-new 会在首次连接时记录 backup01 主机密钥,而不是停在 yes/no 提示上让定时器挂起:

export BORG_RSH="ssh -i /root/.ssh/borg_backup_key -o StrictHostKeyChecking=accept-new"
export BORG_PASSPHRASE='Borg-Lab-Only-2026'
export BORG_REPO=ssh://borgbackup@10.0.1.50/backup/borg-repo
borg init --encryption=repokey-blake2

Borg 会提醒你口令和密钥同样重要。repokey 模式下密钥存储于 backup01 的仓库中,如果该服务器磁盘损坏,需要导出的副本才能读取第二副本:

IMPORTANT: you will need both KEY AND PASSPHRASE to access this repo!

Key storage location depends on the mode:
- repokey modes: key is stored in the repository directory.
- keyfile modes: key is stored in the home directory of this user.

For any mode, you should:
1. Export the borg key and store the result at a safe place:
   borg key export           REPOSITORY encrypted-key-backup
   borg key export --paper   REPOSITORY encrypted-key-backup.txt
   borg key export --qr-html REPOSITORY encrypted-key-backup.html
2. Write down the borg key passphrase and store it at safe place.

请遵照建议,把密钥导出到两台机器之外的位置。随后记录校验和,创建第一个归档并列出:

sha256sum /srv/important-data/*.txt > /root/original-checksums-borg.txt
borg create --stats ::app01-{now} /srv/important-data
borg list

归档名带时间戳,恢复时会用到。now 占位符由 Borg 展开,因此每次定时运行都能获得唯一名称,不需要 shell 日期逻辑:

------------------------------------------------------------------------------
Repository: ssh://borgbackup@10.0.1.50/backup/borg-repo
Archive name: app01-2026-10-29T17:12:00
Archive fingerprint: eef5f5f64432ae8a2dad642e4e4cb53b499e038471bf32e233f86c5ee074d413
Time (start): Thu, 2026-10-29 17:12:00
Time (end):   Thu, 2026-10-29 17:12:00
Duration: 0.04 seconds
Number of files: 50
Utilization of max. archive size: 0%
------------------------------------------------------------------------------
                       Original size      Compressed size    Deduplicated size
This archive:                5.74 kB              7.89 kB              7.89 kB
All archives:                5.74 kB              7.89 kB             11.57 kB

                       Unique chunks         Total chunks
Chunk index:                      52                   52
------------------------------------------------------------------------------
app01-2026-10-29T17:12:00            Thu, 2026-10-29 17:12:00 [eef5f5f64432ae8a2dad642e4e4cb53b499e038471bf32e233f86c5ee074d413]

请保存输出中的归档指纹,因为之后用它确认恢复的是原归档而非重建版本。5.74 kB 文件对应 7.89 kB 存储空间是小测试数据中的元数据开销,不能用于估算真实容量。

测试 Borg 抵御删除并回滚

现在在 app01 扮演攻击者,使用刚才备份时的同一环境。删除归档(使用 borg list 显示的名称),尝试销毁整个仓库,再执行 compact 释放空间:

borg delete ::app01-2026-10-29T17:12:00
borg list
borg delete --force
borg compact

归档删除退出码为 0,borg list 结果为空,因此攻击者会以为操作成功。删除整个仓库的调用则被服务端直接拒绝,traceback 包含:

ValueError: /backup/borg-repo is in append-only mode

borg compact 同样以 0 退出,但在 append-only 模式下不会释放任何空间。每个 segment 都仍在磁盘上,服务端会把每次操作记录为带编号的事务。在 backup01 查看日志:

sudo -u borgbackup cat /backup/borg-repo/transactions

事务 5 是备份,9 是删除,12 是 compact。时间戳与攻击者命令精确到秒对应,因此最后良好状态是 5。在繁忙仓库中,应结合事件时间线阅读这些记录:

transaction 5, UTC time 2026-10-29T17:12:00.708332
transaction 9, UTC time 2026-10-29T17:12:11.526014
transaction 12, UTC time 2026-10-29T17:12:13.576524

将这些时间与确信入侵开始的时刻比对。真实服务器上通常会看到一长串夜间备份,然后攻击会话突然产生一批删除;突发删除之前的内容都可以安全保留。

回滚到最后一个良好事务

Borg append-only 文档描述了回滚方法:删除可以随时重建的 hints、index 和 integrity 文件,再删除良好事务之后写入的所有 segment。先创建仓库的硬链接副本,以保留证据并能够撤销错误切割。副本中的 segment 永不改变,但 transactions 日志通过硬链接共享并持续增长,因此如果事件报告需要,应单独保存该文件的副本:

sudo cp -al /backup/borg-repo /backup/borg-repo.compromised
sudo -u borgbackup bash -c "cd /backup/borg-repo && rm -v hints.12 index.12 integrity.12 && rm -v data/0/{6..12}"

三个文件的后缀是最后事务号(这里为 12),segment 从良好事务后的下一个编号(6)开始删除。然后让 Borg 根据剩余 segment 重建索引:

sudo -u borgbackup borg check --repository-only /backup/borg-repo

它会重放 segment 并以 0 退出。完成后仓库会生成后缀为 5 的新索引,确认 Borg 将事务 5 视为最新状态:

Replaying segments. Please wait, this can take a while...

服务端已回到事务 5,但客户端尚不知道。它缓存了事务 12 之后的仓库状态,而 Borg 把任何向后移动的仓库视为可能的重放攻击,因此 app01 的下一条命令会被拒绝。

错误:“Cache, or information obtained from the security directory is newer than repository”

客户端仍记录事务 12 后的仓库状态,因此拒绝与更旧副本通信。app01 上的 borg list 报错:

Cache, or information obtained from the security directory is newer than repository - this is either an attack or unsafe (multiple repos with same ID)

这里是你自己执行了回滚,因此警告符合预期。append-only 文档和 Borg FAQ 给出相同修复方法:只删除此仓库的 manifest-timestamp 文件,并清空本地缓存。FAQ 将这一操作称为不安全且不受支持的路径,这有道理,因为它有意关闭了一层保护。不要动安全目录中的其他内容,因为其中的 nonce 文件是加密计数器,丢失它可能导致 nonce 重用。先在 backup01 获取仓库 ID:

sudo -u borgbackup borg config /backup/borg-repo id

它输出 64 个字符的 ID,同时也是客户端 /root/.config/borg/security/ 下的目录名:

3f4f96f805514bb2cc0dae1899930c42039dd56a803cf9968d5ff8bd8bc26333

回到 app01,把路径中的 ID 替换为自己的,清空缓存并再次列出:

rm /root/.config/borg/security/3f4f96f805514bb2cc0dae1899930c42039dd56a803cf9968d5ff8bd8bc26333/manifest-timestamp
borg delete --cache-only
borg list
borg check

被删除的归档重新出现,borg check 以 0 退出且无输出。指纹与原始 borg create 一致,证明恢复的是同一归档,而不是重建版本:

app01-2026-10-29T17:12:00            Thu, 2026-10-29 17:12:00 [eef5f5f64432ae8a2dad642e4e4cb53b499e038471bf32e233f86c5ee074d413]

Borg 会在下一次成功运行时重写 manifest-timestamp,而 nonce 文件仍保留在原处。只有在你自行回滚后才这样做。如果没有任何人操作服务器却出现该错误,应当把它视为 Borg 正在警告的攻击。

解压并验证 Borg 归档

解压到临时目录,仍使用自己的归档名,与攻击前的校验和比较。Borg 按当前目录解压,因此 diff 前要去掉恢复目录前缀:

mkdir /tmp/borg-restore && cd /tmp/borg-restore
borg extract ::app01-2026-10-29T17:12:00
sha256sum /tmp/borg-restore/srv/important-data/*.txt | sed "s# /tmp/borg-restore# #" > /tmp/borg-checksums.txt
diff /root/original-checksums-borg.txt /tmp/borg-checksums.txt && echo MATCH
ls /tmp/borg-restore/srv/important-data | wc -l

50 个文件全部与原始版本匹配,因此回滚没有丢失攻击前写入的任何内容:

MATCH
50

Borg 恢复比 S3 恢复需要更多手工操作,而且依赖服务器保留诚实的事务日志。这正是 Object Lock 弥补的缺口,也是本文把 Borg 作为第二副本而非唯一副本的原因。

使用 systemd 定时器运行 Restic

systemd 定时器通过 Persistent=true 在停机后补跑,并把每次运行记录到 journal,而 cron 不提供这些功能。该单元复用前面的 /etc/restic-env。在 app01 创建服务:

sudo vim /etc/systemd/system/restic-backup.service

两行缓存配置不是可选项。实验第一版遗漏它们,第一次定时运行就失败了:

[Unit]
Description=Restic backup to RustFS (Object Lock bucket)
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/restic-env
CacheDirectory=restic
Environment=RESTIC_CACHE_DIR=/var/cache/restic
ExecStart=/usr/local/bin/restic backup /srv/important-data /etc
ExecStartPost=/usr/local/bin/restic check
Nice=19
IOSchedulingClass=idle

没有 CacheDirectory 和 RESTIC_CACHE_DIR 时,journal 显示以下错误,单元状态为 1:

restic[54238]: unable to open cache: unable to locate cache directory: neither $XDG_CACHE_HOME nor $HOME are defined

系统服务运行时没有 $HOME,Restic 找不到缓存位置。CacheDirectory=restic 让 systemd 创建 /var/cache/restic,环境变量行再让 Restic 使用它。接着创建定时器:

sudo vim /etc/systemd/system/restic-backup.timer

它在 02:00 触发,并随机延迟最多 15 分钟,避免大量主机在同一秒压向备份服务器。Persistent=true 会在下次启动时补跑错过的备份,对夜间关机的机器很重要:

[Unit]
Description=Nightly restic backup

[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target

启用定时器,再手动触发一次,让问题在白天显现而不是凌晨 2 点。oneshot 单元会阻塞 systemctl start,直到备份和检查都完成,因此命令返回时本次运行已经结束:

sudo systemctl daemon-reload
sudo systemctl enable --now restic-backup.timer
sudo systemctl start restic-backup.service
journalctl -u restic-backup.service -o cat --no-pager | tail -12

备份和运行后检查均成功,systemd 报告单元正常结束。SELinux 保持 enforcing,ausearch -m avc -ts today 没有发现拒绝记录,因为 /usr/local/bin 中的二进制带有标准 bin_t 标签,不需要自定义策略:

processed 606 files, 20.389 MiB in 0:00
snapshot 2398a50d saved
using temporary cache in /var/cache/restic/restic-check-cache-96503601
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
[0:00] 100.00%  3 / 3 snapshots
no errors were found
restic-backup.service: Deactivated successfully.
Finished restic-backup.service - Restic backup to RustFS (Object Lock bucket).
restic-backup.service: Consumed 1.035s CPU time, 46.9M memory peak.

实验第一次定时运行把 20.959 MiB 的 /etc 和测试数据压缩、去重后存为 4.685 MiB。该单元有意不包含 forget:仅备份密钥无法运行它,保留策略清理属于备份服务器。

让备份服务器保持不可达

实验证明锁有效,但仍有一些需要补上的缺口,按建议处理顺序如下:

  1. 为 backup01 使用独立管理员账号,不与生产环境共享 SSO 或目录信任。
  2. 防火墙只允许生产网段访问 backup01 的 9000/tcp 和 22/tcp。
  3. RustFS 管理员密钥不放在生产主机上,每台主机使用独立仅备份用户。
  4. 对过期快照配置告警。
  5. 每月执行恢复演练并比对校验和。

第一项最重要,因为顶部表格中的“存储主机上的 root”一行说明了原因。如果攻陷的目录账号也能登录 backup01,攻击者会绕过 S3 API,直接运行 rm -rf /data/rustfs,任何保留设置都无法阻止。只有生产管理员无法触达存储主机,COMPLIANCE 模式才有实际意义。应为 backup01 配置独立本地账号、由小范围人员持有的独立 SSH 密钥,并通过独立管理网络提供控制台访问,这样即使生产身份提供方失陷,备份也不会一起丢失。

告警方面,快照年龄检查已经足够。下面的脚本读取定时器使用的同一环境文件;当最新快照超过 26 小时时以非零状态退出,为每日计划留出两小时余量。在 app01 保存:

sudo vim /usr/local/sbin/restic-snapshot-age.sh

Rocky 10 自带 python3,用于解析 JSON。使用 Python 计算日期,是因为 Restic 记录快照时间时会带客户端 UTC 偏移;按带时区值比较,才能让不运行 UTC 的服务器得到正确年龄。脚本加载与定时器相同的仅备份密钥,不需要额外凭据:

#!/bin/bash
set -a; . /etc/restic-env; set +a
export PATH=$PATH:/usr/local/bin RESTIC_CACHE_DIR=/var/cache/restic
restic snapshots --json --latest 1 | python3 -c '
import sys, json, re, datetime
t = json.load(sys.stdin)[-1]["time"]
t = re.sub(r"(\.\d{6})\d+", r"\1", t).replace("Z", "+00:00")
ts = datetime.datetime.fromisoformat(t)
age = int((datetime.datetime.now(datetime.timezone.utc) - ts).total_seconds() // 3600)
print(f"latest={ts.isoformat()} age_hours={age}")
sys.exit(0 if age < 26 else 1)
' && echo OK || { echo STALE; exit 1; }

赋予执行权限并运行一次。实验输出来自同一实验室较早的一次定时器运行,因此时间戳和上面 journal 中的快照计数差一次运行。

sudo chmod 755 /usr/local/sbin/restic-snapshot-age.sh
sudo /usr/local/sbin/restic-snapshot-age.sh

备份后不久运行,它报告 0 小时。健康每日计划中,数值会逐渐升至约 24 小时,并在每晚回落;高于 26 小时就值得告警:

latest=2026-10-29T17:14:13.367190+00:00 age_hours=0
OK

把退出码接入现有告警系统,例如 Zabbix、Prometheus textfile collector 或失败时发送邮件。尽量从另一台主机执行检查。被攻陷的 app01 可以伪造自身健康检查,但无法阻止 backup01 发现没有新对象到达。

为锁定窗口预留足够存储。Restic 清理的数据在保留期结束前不会释放,删除操作还会增加标记,因此桶在窗口期间只会增长。短测试结束后,它保存了 23 个对象版本、共 5.3 MB,而有效数据只有几 KB。对于每天变化 5% 的 100 GB 数据集,粗略需要 100 GB 加上 30 天每天 5 GB 的增量,即约 250 GB,这是去重之前的估算。应在 backup01 使用管理员密钥执行 forget 和 prune,然后在锁到期后移除非当前版本。本文没有测试 RustFS 生命周期规则,依赖它进行清理前应查阅 RustFS 文档。

最后,要保证两个目标彼此隔离。Restic 写入 backup01 的 RustFS,再加上 Borg 写入另一个房间或另一家提供商的第二台机器,意味着一把被盗密钥、一块坏磁盘或一个恶意管理员无法同时摧毁两份备份。繁忙数据库在 Restic 读取前应创建 LVM 快照以保证一致性。如果需要没有锁的普通 S3 目标,可参考 Restic 到 S3 的存储指南,了解保留与 prune;在文件级副本之外若还需要整机镜像,可用 UrBackup 补齐。

Avatar photo

关于 木子

Email: muzi@vip.rockylinux.cn 微信:VebinLee QQ: 2306867585
Founder of the Rocky Linux Chinese community, MVP、VMware vExpert、TVP, advocate for cloud native technologies, with over ten years of experience in site reliability engineering (SRE) and the DevOps field. Passionate about Cloud Computing、Microservices、CI&CD、DevOps、Kubernetes, currently dedicated to promoting and implementing Rocky Linux in Chinese-speaking regions.
用一杯咖啡支持我们,我们的每一篇[文档]都经过实际操作和精心打磨,而不是简单地从网上复制粘贴。期间投入了大量心血,只为能够真正帮助到您。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇