Kubernetes 的 securityContext 配置进程身份及部分操作系统约束,可以缩小被攻陷应用允许执行的操作,但本身不会建立完整安全边界。应先确定应用实际需要什么操作,再配置对应字段,并在最终容器中同时测试允许与拒绝的行为。只有一份看起来严格的清单,还不能说明运行中的进程已经按预期受到限制,也不能代替应用本身的权限设计。

把设置放在正确层级

Pod 级配置可以提供用户和组身份、共享卷的组权限行为,以及 seccomp 配置。容器级字段可以覆盖适用的默认值,并设置 capabilities、权限提升和只读根文件系统。需要核对字段真实 API 范围,把熟悉的名称写在错误位置,并不会获得等价约束。相同的 securityContext 名称之下,不代表所有字段都能互换放置。

runAsUser 选择数值用户身份,runAsNonRoot 则要求容器符合非 root 策略。这些设置补充镜像元数据,不能仅凭一个用户名看起来普通,就信任实际权限。卷权限也必须允许所选身份完成必要工作。下面的 emptyDir 通过 fsGroup 提供组访问,使应用能够写入指定工作目录,而不必因此获得对整个根文件系统的写权限。

Linux capabilities 把传统上广泛关联于 root 的权限拆成不同能力。删除 ALL 会移除容器配置中的这些能力。普通容器默认并不继承全部 Linux 能力,而容器内以 root 运行,也不等于已经成功逃逸到宿主机。应审阅真实有效权限,避免用这两种过度简化替代证据。减少权限很有价值,但其效果需要准确描述。

坏掉的样子

allowPrivilegeEscalation false 会为进程设置 no_new_privs,限制通过执行程序获取新权限,例如 set-user-ID 或文件能力引起的身份与权限提升。它不会撤销进程已经拥有的全部权限,也不代替业务授权。应检查实际进程标志和有效能力,不能把每一次被拒绝的操作都归因于这个字段,否则可能误解其他身份或文件权限设置的作用。

readOnlyRootFilesystem 让容器根文件系统只读,其他挂载仍可能可写,包括明确提供的工作卷。这并不是全面禁止执行代码:解释器仍可以读取并运行可写卷中的程序。它也不会阻止网络请求,或撤销对已经挂载数据的读取。只读存储约束的是一类操作,不能直接证明攻击无法继续,更不能把“不能改根目录”扩写成“不能写入任何内容”。

Seccomp 过滤系统调用,但 RuntimeDefault 取决于运行时提供的默认配置。进程报告过滤模式,说明存在实际过滤机制,不证明所有不期望的调用和攻击路径都被阻断。普通 Linux 容器运行时也能执行这些字段,不是必须采用特殊沙箱它们才生效。更强隔离有独立设计要求,应按威胁模型讨论,不能把是否有效与某一个产品名称绑定。

互相冲突的身份设置可能通过 API 准入,却阻止容器启动。镜像也可能原本需要在根文件系统中写缓存或临时文件。应检查启动状态和被拒绝路径,只提供实际所需工作存储,不要用恢复 root、允许权限提升或打开整个根文件系统来处理所有权限报错。必要写入与过大权限需要通过真实调用区分。

快速验证

准备临时 Linux Kubernetes 集群、kubectl,以及节点可用的 python:3.11-slim-bookworm 镜像。保存为 security-context.py,执行 python3 security-context.py KUBECONFIG_PATH CONTEXT,明确指定测试集群。账号需要创建、删除命名空间。实测服务端为 Kubernetes 1.34.11,使用 kind 的标准运行时配置,所有对象均属于本次临时实验。

import json
import subprocess
import sys
import time
from uuid import uuid4

assert len(sys.argv) == 3, 'usage: python3 example.py DISPOSABLE_KUBECONFIG CONTEXT'
base = ['kubectl', '--kubeconfig', sys.argv[1], '--context', sys.argv[2], '--request-timeout=30s']
image = 'python:3.11-slim-bookworm'


def k(*args, payload=None, expected=0):
    result = subprocess.run(base + list(args), input=json.dumps(payload) if payload is not None else None,
                            capture_output=True, text=True, timeout=150)
    if expected is not None:
        assert result.returncode == expected, result.stderr
    return result


def obj(namespace, kind, name=None, selector=None):
    args = ['get', kind, '-n', namespace, '-o', 'json']
    if name:
        args.append(name)
    if selector:
        args += ['-l', selector]
    return json.loads(k(*args).stdout)


def apply(namespace, data):
    return k('apply', '-n', namespace, '-f', '-', payload=data)


def wait_for(read, ready, seconds=120):
    until = time.monotonic() + seconds
    while time.monotonic() < until:
        value = read()
        if ready(value):
            return value
        time.sleep(0.5)
    raise AssertionError('expected Kubernetes condition did not arrive')


def pod_ready(pod):
    return any(c['type'] == 'Ready' and c['status'] == 'True'
               for c in pod.get('status', {}).get('conditions', []))


def spec(script='import time; time.sleep(600)'):
    return {'automountServiceAccountToken': False, 'terminationGracePeriodSeconds': 1,
            'containers': [{'name': 'app', 'image': image, 'imagePullPolicy': 'IfNotPresent',
                            'command': ['python3', '-c', script],
                            'resources': {'requests': {'cpu': '10m', 'memory': '24Mi'},
                                          'limits': {'cpu': '200m', 'memory': '96Mi'}}}]}

namespace = 'notes-security-' + uuid4().hex[:8]
k('create', 'namespace', namespace)
try:
    probe = '''from pathlib import Path
import errno,json,os,subprocess,time
status=dict(line.split(':',1) for line in Path('/proc/self/status').read_text().splitlines() if ':' in line)
try:
 Path('/tmp/forbidden-write').write_text('x')
 raise AssertionError('root filesystem unexpectedly writable')
except OSError as error:
 assert error.errno==errno.EROFS
try:
 os.setuid(0)
 raise AssertionError('unexpected UID escalation')
except OSError as error:
 assert error.errno==errno.EPERM
work=Path('/work'); (work/'data').write_text('writable')
(work/'example.py').write_text("print('interpreted from the writable volume')")
ran=subprocess.check_output(['python3',str(work/'example.py')],text=True).strip()
result={'uid':os.getuid(),'gid':os.getgid(),'cap_eff':int(status['CapEff'].strip(),16),
        'no_new_privs':int(status['NoNewPrivs']),'seccomp':int(status['Seccomp']),
        'root_write_errno':errno.EROFS,'uid_zero_denied':True,
        'volume_value':(work/'data').read_text(),'writable_volume_code':ran}
assert result['uid']==result['gid']==10001
assert result['cap_eff']==0 and result['no_new_privs']==1 and result['seccomp']==2
print(json.dumps(result),flush=True); time.sleep(600)
'''
    podspec = spec(probe)
    podspec['securityContext'] = {'runAsNonRoot': True, 'runAsUser': 10001,
        'runAsGroup': 10001, 'fsGroup': 10001, 'seccompProfile': {'type': 'RuntimeDefault'}}
    podspec['volumes'] = [{'name': 'work', 'emptyDir': {}}]
    c = podspec['containers'][0]
    c['securityContext'] = {'allowPrivilegeEscalation': False, 'readOnlyRootFilesystem': True,
                             'capabilities': {'drop': ['ALL']}}
    c['volumeMounts'] = [{'name': 'work', 'mountPath': '/work'}]
    apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'restricted'}, 'spec': podspec})
    wait_for(lambda: obj(namespace, 'pod', 'restricted'), pod_ready)
    observed = json.loads(wait_for(lambda: k('logs', '-n', namespace, 'restricted').stdout.strip(), bool))
    conflicting = spec()
    conflicting['securityContext'] = {'runAsNonRoot': True, 'runAsUser': 0}
    apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'conflict'}, 'spec': conflicting})
    refused = wait_for(lambda: obj(namespace, 'pod', 'conflict'), lambda p:
        any(c.get('state', {}).get('waiting', {}).get('reason') == 'CreateContainerConfigError'
            for c in p.get('status', {}).get('containerStatuses', [])))
    waiting = refused['status']['containerStatuses'][0]
    assert 'runAsUser breaks non-root policy' in waiting['state']['waiting']['message']
    assert waiting['restartCount'] == 0
    print(json.dumps({'runtime_observation': observed,
                      'conflicting_uid_accepted_by_api_but_container_start_refused': True,
                      'container_escape_resistance_tested': False}))
finally:
    k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')

受约束 Pod 报告 UID 和 GID 均为 10001,有效能力为零,NoNewPrivs 为一,seccomp 模式为二。在根文件系统的 /tmp 下写入返回 EROFS,尝试切换到零号用户返回 EPERM。这些结果反映组合身份和权限设置的效果,不能单独归因为其中某个标志,也没有逐一隔离测量每个字段的独立贡献。

进程成功写入 /work 中的数值,并调用 Python 执行刚写在该卷里的文件。这个允许操作刻意展示工作卷仍然可用,以及只读根声明的边界。第二个 Pod 同时声明 runAsNonRoot 和 runAsUser 零,API 接受对象,但 kubelet 在容器启动前报告 CreateContainerConfigError,说明对象保存成功和进程成功启动是不同阶段。

全部断言通过,命名空间随后删除。实验验证这些具体运行时约束和启动失败,没有测试容器逃逸抵抗能力、每一条 seccomp 规则、敌对多租户隔离或应用授权模型。真实交付需要将这些设置与对应访问需求一起审阅,并保留允许与拒绝操作各自的实际证据。

延伸阅读

来源