审计公共边缘的信任
读取边缘的每个信任信号,从 CSP 头到证书链再到被授权的签发者。
读取边缘的每个信任信号,从 CSP 头到证书链再到被授权的签发者。
记录期望的指纹,使用斟酌过的 StrictHostKeyChecking 策略,并把变化当作事故来对待。
SSH 密钥很方便,而方便正是在不知不觉中失控的原因。实践上的关键纪律并不是少生成几把密钥,而是让每把密钥都成为有清晰用途、有限生命周期、并且能在"谁被允许做什么"的文件里留下记录的一个具名身份。 一把密钥应被当作一个身份 一把 SSH 密钥应当能读作一个身份,而不是一句无密码的便利。给它起名:为了它属于谁、为什么存在,而不是为了它恰好躺在那台笔记本;给它一个简短的注释,并在你的工具支持时给私钥配一个口令,让一份只拿到了文件的人无法直接使用。一把「一看就知道属于谁、可以做什么」的密钥,能让后续的运维者不假思索就应用最小权限原则——这正是「上了锁的凭据」和「一堆谁也认不出的无名钥匙」之间的分水岭。 范围才是关键 范围才是密钥的要害。尽量让一把 key 只能碰到它该碰的东西:在服务端,通过账户与 authorized_keys 的设置,只接收该接收的 key,并限制每把 key 具体能做哪些——别一股脑把整台机器的大权交给一个起自自动化的角落。遇到自动化用途,更要按命令、源或目录把它钉死在一个边界内;一把范围被钉死的 key,在泄漏时,泄漏时,损失的也只是「这个被定义好的边界」,而不是一把「每一扇门都能开」的总钥匙。 轮换与审计线索 旧钥匙不会自己走。一套轮换的节奏,才是让清单长久诚实的办法:删掉已经没人再用的 key,把剩余的交实际负责人重新签发,并过一段时间再确认一次——「现在被接受的这些 key,是不是真的对应我要的那些人、那些用途」。authorized_keys 本身,就是把这份「谁允许做什么」变成可复核的文件的实体。这样一个定期、便宜又明确的检查,抵得上一百次在事故后茫然翻找。 坏掉的样子 没有口令的私钥,会让每一个副本都变成一次登录。入职克隆笔记本、把个人配置推到共享仓库、备份软件同步家目录,都会顺带复制 ~/.ssh。只要有人能读到其中一份,就能立刻登录,而服务端日志分辨不出小偷和主人。全程没有任何口令提示,故障是静默的,往往要等日志里出现陌生地址才会被发现。 长期残留的 authorized_keys 条目,是另一种同样安静的失效。工程师离职、合作到期、定时任务下线,公钥却还留在服务器上,因为没人负责清理。几个月后,那一行被遗忘的记录依然有效,而且大多没有 from=、command= 这类限制,等于没有使用边界。只发新钥而不删旧钥的轮换,每转一圈都在扩大攻击面。 第三种失效是算法老旧。ssh-dss 和短长度的 ssh-rsa 在宽松配置的服务端上依然能登录,尽管主流建议早已转向 Ed25519。连接一旦成功就不会告警,而攻击者截获的每次握手,都能在弱算法上拿到大得多的杠杆。正确做法是生成新类型密钥并删掉旧行,而不是让两种长期并存。 第四种是代理转发劫持。用 ssh -A 登录时,本地代理套接字会暴露到远端主机,有足够权限的人可在会话期间让你的代理替他签名。私钥文件纹丝未动,登录却以你的名义扩散到更多主机。症状是:横向移动发生了,现场却找不到被盗密钥,只剩转发打开期间产生的一串签名。 快速验证 把下面的脚本保存为 ssh_audit.py,运行 python3 ssh_audit.py。它只用 Python 标准库,只读当前用户 ~/.ssh,不做任何修改;没有密钥文件的机器报告 SKIP 而非报错,新机器也能干净跑完。 ...
防火墙在网络栈的特定位置执行报文策略。一条有用规则因此不只包含端口,还要明确地址族、挂钩、方向、连接状态和相对其他规则的位置。nftables 基础链按指定优先级接入 Netfilter 挂钩。报文实际经过的路径,决定它会遇到哪些裁决,而不是运维文档中描述的先后顺序。审阅策略必须保留这些执行上下文。 坏掉的样子 规则位于错误挂钩时,预期流量可能完全不受影响。INPUT 处理交付给当前命名空间本地的流量,FORWARD 处理经过它转发的流量,OUTPUT 面向本地产生的流量。宿主的容器转发路径,不一定就是本地输入路径。假定宿主级规则保护所有附近服务之前,应先查清实际流量经过哪个命名空间和挂钩。 地址族和方向错误也容易被掩盖。允许服务 TCP 目的端口,不同于匹配通常临时分配的客户端来源端口。只覆盖 IPv4 的策略不会自动覆盖 IPv6;IPv6 通信还依赖邻居发现等 ICMPv6 功能,丢弃所有非 TCP 报文,可能让 TCP 端口明明被允许的连接仍然失败。端口规则不能脱离链路和协议所需控制流量解释。 nftables 的 accept 不一定是所有基础链中的最终裁决。同一挂钩上更晚优先级的另一条链,仍能丢弃报文;drop 则会结束其遍历。只审阅一个表或一条链,可能看到允许计数器增长,却无法解释客户端为何失败。应查看全部适用规则集及其挂钩优先级,不能把第一条可见 accept 当成系统已经最终放行的证明。 快速验证 将脚本保存为 check-firewall.py,准备 Docker 后执行 python3 check-firewall.py。固定环境使用 nftables 1.0.6 和 Python 3.11.16。两个嵌套命名空间通过一次性 veth 同时进行 IPv4 与 IPv6 通信,容器获得实验所需 NET_ADMIN 和 SYS_ADMIN。规则不会修改宿主防火墙或现有服务,实验结束后随隔离环境移除: ...
服务加固应该把一种明确不需要的操作变为被拒绝的操作,同时保留应用必须完成的工作。systemd 为文件系统视图、命名空间、权限和其他资源提供执行设置,但这些是边界各不相同的独立控制。添加四条熟悉的指令,并不意味着所有可读文件、可接触进程、继承的文件描述符或允许的系统调用都会消失。判断依据应是配置的真实语义与进程的实际行为。 坏掉的样子 ProtectSystem=strict 让服务挂载命名空间内的大部分文件系统层级变为只读。只读不代表不可读取,而且该设置有文档明确的例外,例如还需要单独控制的接口文件系统。服务仍然可能泄露它能够读取的信息,也仍然可以破坏可写目录中的数据。因此应该先使用专用服务身份,列清必要访问,再选择对应限制,而不是把一个只读开关当成覆盖所有资源的隔离墙。 PrivateTmp=yes 给服务提供私有临时目录。如果应用原本依赖另一个服务通过普通 /tmp 交换文件,这项设置可能直接打断协作。PrivateNetwork=yes 创建拥有独立回环接口的网络命名空间,因此宿主回环上的监听器不再是里面的同一个端点。需要直接连接数据库或公共接口的程序,若没有有意安排所需连接,就不适合直接套用这一设置。应先查真实调用路径,不能假定网络隔离不会影响正常功能。 NoNewPrivileges=yes 阻止执行程序时通过设置用户身份的二进制文件、文件能力等机制取得新的特权,并不删除进程已经拥有的权限。同样,单独写一条 IPAddressAllow,不意味着其他地址全部被拒绝:地址过滤有允许与拒绝的优先关系,还依赖内核功能支持。选项名称不是完整安全策略,每项控制都需要按其契约理解,尤其不能把未验证的限制写成已经成立的保证。 快速验证 在一次性的 Linux 系统中以管理员身份运行下面的 Python 脚本,要求 PID 1 为 systemd,并具备 Python 和标准用户管理工具。记录中的环境是 Debian 12、systemd 252.39。脚本创建唯一命名的系统账号、运行时服务、状态目录与无害样本文件,并在结束时删除。前后两阶段使用同一个非 root 身份和相同工作负载: from pathlib import Path from tempfile import TemporaryDirectory import json,shutil,socket,subprocess,uuid suffix=uuid.uuid4().hex[:8];name='xbcatm-hardening-'+suffix unit=name+'.service';unit_path=Path('/run/systemd/system')/unit state=Path('/var/lib')/name;marker=Path('/tmp')/(name+'-marker');scratch=Path('/tmp')/(name+'-scratch') assert not unit_path.exists() and not state.exists() and not marker.exists() def ctl(*args): result=subprocess.run(['systemctl',*args],capture_output=True,text=True,timeout=10) assert result.returncode==0,(args,result.stderr) return result.stdout.strip() with TemporaryDirectory(prefix=name+'-',dir='/run') as directory, socket.socket() as listener: root=Path(directory);root.chmod(0o755);outside=root/'outside';outside.write_text('initial\n');outside.chmod(0o666) marker.write_text('host namespace marker\n');listener.bind(('127.0.0.1',0));listener.listen(2) worker=root/'worker.py' worker.write_text(r"""from pathlib import Path import json,socket,sys root=Path(__file__).parent;state=Path(sys.argv[1]);marker=Path(sys.argv[2]);scratch=Path(sys.argv[3]);port=int(sys.argv[4]);phase=sys.argv[5] result={} try: with (root/'outside').open('a') as output:output.write(phase+'\n') result['outside_write']=True except OSError as error:result['outside_write']=False;result['outside_errno']=error.errno result['host_tmp_visible']=marker.exists() scratch.write_text(phase);result['tmp_write']=scratch.read_text()==phase try: with socket.create_connection(('127.0.0.1',port),timeout=0.5):pass result['network_connect']=True except OSError as error:result['network_connect']=False;result['network_errno']=error.errno status=dict(line.split(':',1) for line in Path('/proc/self/status').read_text().splitlines() if ':' in line) result['no_new_privs']=status['NoNewPrivs'].strip() (state/'result.json').write_text(json.dumps(result)) """) definition=f'''[Service] Type=oneshot User={name} Group={name} StateDirectory={name} StateDirectoryMode=0700 ExecStart=/usr/bin/python3 {worker} {state} {marker} {scratch} {listener.getsockname()[1]} PHASE ''' subprocess.run(['useradd','--system','--no-create-home','--user-group','--shell','/usr/sbin/nologin',name],check=True) try: unit_path.write_text(definition.replace('PHASE','baseline')) ctl('daemon-reload');ctl('start',unit) baseline=json.loads((state/'result.json').read_text()) assert baseline=={'outside_write':True,'host_tmp_visible':True,'tmp_write':True,'network_connect':True,'no_new_privs':'0'},baseline connection,_=listener.accept();connection.close() original=outside.read_text() settings='ProtectSystem=strict\nPrivateTmp=yes\nPrivateNetwork=yes\nNoNewPrivileges=yes\n' unit_path.write_text(definition.replace('PHASE','hardened')+settings) subprocess.run(['systemd-analyze','verify',str(unit_path)],check=True) ctl('daemon-reload');ctl('start',unit) hardened=json.loads((state/'result.json').read_text()) assert hardened['outside_write'] is False and hardened['outside_errno']==30,hardened assert hardened['host_tmp_visible'] is False and hardened['tmp_write'] is True assert hardened['network_connect'] is False and hardened['network_errno']==111,hardened assert hardened['no_new_privs']=='1' assert outside.read_text()==original and scratch.read_text()=='baseline' print('baseline: outside write, host tmp visibility, loopback connection permitted; NoNewPrivs=0') print('hardened: outside write=EROFS; host tmp hidden; host listener refused; NoNewPrivs=1') print('managed state directory and private temporary writes still succeed') finally: ctl('stop',unit) if ctl('show',unit,'-p','ActiveState','--value')=='failed':ctl('reset-failed',unit) unit_path.unlink(missing_ok=True);ctl('daemon-reload') marker.unlink(missing_ok=True);scratch.unlink(missing_ok=True) if state.exists():shutil.rmtree(state) subprocess.run(['userdel',name],check=True) 基线阶段故意让该账号能够写入一个样本文件,同时在管理器看到的临时目录里创建标记,并在其回环接口启动监听器。尚未加限制时,工作进程能够追加文件、看到标记、连接监听器,且读取到零值的 NoNewPrivs 标志。这一步排除了普通文件权限本来就禁止写入、或者监听器根本不存在等混淆因素,之后的拒绝才有明确对照。 ...