文章是本站的原创长文。每篇都保持无脚本、来源可溯,并与本站记录的系统现实一致:一个小型、静态、公网服务如何被构建、加固并保持诚实。
只读根在其范围内限制文件系统修改
只读根文件系统限制对服务打包代码和配置所在文件系统层的普通写入,明确挂载仍可提供运行数据的可写位置。这能减少意外修改和部分持久化机会,但边界是文件系统变更本身。它不会单独移除进程权限、阻止读取可访问数据,也不会禁止与其他服务通信,需要按实际作用范围理解其价值。 坏掉的样子 应用经常在主要数据目录之外写入临时文件、缓存、锁文件、生成配置或运行状态。只读根会让这些假设在启动或不常见操作中以 EROFS 暴露。只测健康检查端点,可能漏掉生成报表、刷新缓存或关停清理时才经过的写入路径。因此必须沿真实工作流程检查需求,不能把进程成功启动当成全部功能可用。 即使容器根层只读,可写挂载仍然可写,其中状态仍可能被故障或受损进程改坏。把大块可写目录覆盖到打包路径,也可能抵消这些路径原本需要的保护。应检查最终挂载布局,而不是根据一个根层选项推断整棵路径树的行为。路径看起来位于只读根之下,并不证明中间没有另一个可写挂载。 只读与保密也是不同属性。进程仍可能读取打包文件、执行已有程序,并通过允许的网络路径发送数据。该设置也不保证容器逃逸之后仍受相同保护,更不约束能够改变挂载安排的权限。应结合凭据、能力、隔离和应用行为理解它的贡献,避免把一项文件系统限制描述成完整安全边界。 快速验证 在一次性 Linux 容器内以 root 运行下面 Python 脚本。容器应使用只读根,在 /tmp 挂可写 tmpfs,在 /work 挂独立可写卷,镜像内预先安装 Python 3。实测使用 Linux 6.12.76 和 Python 3.11.2,没有宿主机绑定挂载、特权模式或外部网络: from pathlib import Path from tempfile import TemporaryDirectory import errno,os,socket,subprocess,sys,uuid assert os.geteuid()==0,'run inside a disposable read-only-root container as root' assert os.statvfs('/').f_flag&os.ST_RDONLY name='xbcatm-readonly-'+uuid.uuid4().hex for base in [Path('/'),Path('/usr'),Path('/etc')]: path=base/name try:fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) except OSError as error:assert error.errno==errno.EROFS,error else: os.close(fd);path.unlink();raise AssertionError('root filesystem accepted a new file') print('even this root process receives EROFS on paths in the read-only root, usr and etc tree') for base in ['/tmp','/work']: assert not os.statvfs(base).f_flag&os.ST_RDONLY with TemporaryDirectory(prefix='xbcatm-write-',dir=base) as directory: path=Path(directory)/'data';path.write_bytes(b'allowed');assert path.read_bytes()==b'allowed' path.write_bytes(b'changed');assert path.read_bytes()==b'changed' print('explicit tmpfs and data-volume mounts remain writable, including overwriting their contents') assert Path('/etc/os-release').read_text() result=subprocess.run([sys.executable,'-c','print("executed")'],capture_output=True,text=True,check=True) assert result.stdout.strip()=='executed' with socket.socket() as listener,socket.socket() as client: listener.bind(('127.0.0.1',0));listener.listen(1);client.connect(listener.getsockname()) connection,_=listener.accept() with connection: client.sendall(b'local');assert connection.recv(5)==b'local' print('reading packaged files, executing an existing program, and loopback network I/O still work') 脚本先检查根文件系统报告的只读标志,再尝试在根、usr 和 etc 下排他创建唯一名字的文件。即使采用 root 身份,三次都得到 EROFS。唯一名称与排他创建保证探测不会覆盖既有配置文件。结果来自当前文件系统真正拒绝操作,而不是只看配置中出现了某个只读选项。 ...