Posted in

Go+BCC实时捕获容器逃逸行为(execveat/capset/mount syscall异常模式识别),防御窗口压缩至<800ms

第一章:Go+BCC容器逃逸实时检测体系概览

现代云原生环境中,容器逃逸攻击正日益隐蔽化、自动化。传统基于日志或静态规则的检测手段难以捕获运行时异常行为,而 Go 语言的高并发、低开销特性,结合 BCC(BPF Compiler Collection)提供的内核级可观测能力,构成了构建轻量、实时、高精度逃逸检测体系的理想技术栈。

该体系核心由三部分协同构成:

  • Go 编写的用户态控制平面:负责策略加载、事件聚合、告警分发与检测规则热更新;
  • BCC 框架编译的 eBPF 程序:在内核中无侵入式挂载 tracepoint/kprobe,实时捕获 execve, cap_capable, openat 等关键系统调用及能力提升行为;
  • 容器上下文感知模块:通过 /proc/[pid]/cgroup/proc/[pid]/status 动态关联进程与容器 ID(如 container_id),精准识别是否发生在容器命名空间内。

典型逃逸行为检测逻辑示例如下(使用 bcc-tools 中的 trace.py 快速验证):

# 检测容器内进程尝试执行宿主机敏感路径(如 /usr/bin/nsenter)
sudo /usr/share/bcc/tools/trace 't:sched:sched_process_exec "/proc/%d/cgroup" | grep -q \"kubepods\\|docker\" && (arg2 & 0x1000) == 0x1000 && (char*)arg1 == "/usr/bin/nsenter" { printf(\"[ALERT] PID %d (%s) in container exec nsenter\\n\", pid, comm); }'

上述命令利用内核 tracepoint 实时过滤:仅当进程所属 cgroup 包含 kubepodsdocker 标识(表明处于容器中),且 argv[0] 明确为 nsenter 时触发告警——避免误报宿主机常规操作。

检测覆盖的关键逃逸向量包括:

  • 宿主机二进制提权执行(nsenter, runc, chroot
  • Capabilities 异常提升(如 CAP_SYS_ADMIN 在容器中被授予)
  • /proc/sys/sys/fs/cgroup 非法写入
  • 宿主机挂载点逃逸访问(通过 /proc/[pid]/mounts 关联路径)

该体系默认以守护进程模式运行,Go 控制器每 5 秒轮询 BPF map 获取事件,经上下文富化后输出结构化 JSON 至标准输出或对接 Prometheus Exporter,支持毫秒级响应与零依赖部署。

第二章:eBPF内核探针与Go语言协同机制设计

2.1 execveat系统调用的深度钩子注入与上下文捕获

execveat 是 Linux 5.8 引入的关键系统调用,支持基于文件描述符的精确路径执行,绕过传统路径解析,为细粒度进程启动监控提供新入口。

钩子注入点选择

  • 优先拦截 __do_execveat_file 内核函数(位于 fs/exec.c
  • 避开 sys_execveat 系统调用入口——因其仅做参数预处理,上下文不完整

上下文捕获关键字段

字段 来源 用途
fd struct filename * 解析前原始参数 判定是否为 AT_FDCWD 或有效 dirfd
argv/envp struct user_arg_ptr 用户态参数地址,需 copy_from_user 安全提取
flags 系统调用第5参数 检测 AT_EMPTY_PATHAT_SYMLINK_NOFOLLOW 等语义
// 在 do_execveat_common 中插入钩子伪代码
static int hook_execveat(struct filename *filename, struct user_arg_ptr argv,
                         struct user_arg_ptr envp, int flags) {
    // 捕获 fd(来自 filename->fd,若 filename->is_fd == true)
    if (filename->is_fd) {
        trace_execveat_fd(filename->fd, current->pid); // 记录上下文
    }
    return 0; // 继续原流程
}

该钩子在 bprm(binfmt_param)初始化前触发,确保能访问原始 argv/envp 地址及 flags,避免 bprm_fill_uid() 等后续篡改干扰。filename->is_fd 标志是判断 execveat 特有语义的核心依据。

2.2 capset能力集篡改行为的BPF_MAP共享态建模与Go侧实时校验

数据同步机制

BPF程序通过bpf_map_lookup_elem()读取BPF_MAP_TYPE_HASH中以PID为key的能力快照,Go侧以100ms周期轮询同一map,比对cap_effective字段变化。

校验逻辑实现

// capCheck.go:基于eBPF map的实时能力一致性校验
func (c *CapChecker) verifyCAPSet(pid int) error {
    var caps uint64
    if err := c.capMap.Lookup(uint32(pid), &caps); err != nil {
        return fmt.Errorf("map lookup failed: %w", err)
    }
    kernelCaps, _ := getKernelEffectiveCaps(pid) // /proc/[pid]/status 解析
    if caps != kernelCaps {
        c.alert(fmt.Sprintf("capset tamper detected: pid=%d, bpf=0x%x ≠ kernel=0x%x", 
            pid, caps, kernelCaps))
        return errors.New("capability divergence")
    }
    return nil
}

该函数执行原子性比对:caps为eBPF在capset系统调用入口处捕获的原始能力位图;kernelCaps为用户态从/proc/[pid]/status解析的CapEff字段(十六进制字符串转uint64),二者不等即触发篡改告警。

状态映射表

字段 类型 来源 说明
pid u32 eBPF bpf_get_current_pid_tgid() 进程唯一标识
cap_effective u64 struct cred 实际生效能力位图(LSB对齐)
timestamp u64 bpf_ktime_get_ns() 篡改捕获纳秒级时间戳

检测流程

graph TD
    A[capset syscall entry] --> B[eBPF: 读取cred->cap_effective]
    B --> C[写入BPF_MAP_TYPE_HASH key=pid value=caps]
    C --> D[Go轮询map]
    D --> E{caps == /proc/pid/status/CapEff?}
    E -->|Yes| F[静默]
    E -->|No| G[触发审计日志+SIGUSR2]

2.3 mount命名空间越界挂载的双阶段过滤策略(cgroupv2+mountflags语义分析)

Linux内核5.14+对mount命名空间越界行为引入了双阶段过滤机制,旨在阻断非授权跨cgroup边界挂载。

第一阶段:cgroupv2路径约束

内核在mnt_set_mountpoint()中检查目标挂载点是否位于当前进程所属cgroup的/sys/fs/cgroup子树内:

// fs/namespace.c: do_new_mount()
if (mnt_ns->user_ns != &init_user_ns &&
    !cgroup_mnt_path_allowed(mnt_ns->root, path.mnt)) {
    return -EPERM; // 拒绝越界挂载
}

cgroup_mnt_path_allowed()遍历挂载点路径,比对cgroup_root->kf_root与当前进程cgrp->kn->parent链,确保路径严格归属本cgroup。

第二阶段:mountflags语义校验

仅当MS_REC | MS_BIND组合出现时,才触发深度挂载传播检查。关键标志语义如下:

flag 允许越界 触发第二阶段
MS_BIND
MS_PRIVATE
MS_SHARED

执行流程

graph TD
    A[发起mount系统调用] --> B{是否为bind/remount?}
    B -->|是| C[阶段1:cgroup路径归属校验]
    B -->|否| D[放行]
    C --> E{通过?}
    E -->|否| F[EPERM]
    E -->|是| G[阶段2:mountflags语义匹配]
    G --> H[最终决策]

2.4 Go运行时与BCC共享ring buffer的零拷贝内存映射实践

为实现eBPF程序与Go用户态进程间高效数据传递,需绕过传统read()系统调用的内核→用户态拷贝开销。

ring buffer内存映射原理

BCC提供libbpf兼容的ring_buffer接口,其底层通过mmap()将内核预分配的环形缓冲区直接映射至Go进程虚拟地址空间。

Go侧映射示例

// 创建ring buffer并映射(需预先加载eBPF程序并获取fd)
rb, err := ringbuf.NewReader(bpfMapFD)
if err != nil {
    log.Fatal(err)
}
// 启动轮询:零拷贝消费内核写入的数据
for {
    record, err := rb.Read()
    if err != nil { continue }
    // record.Raw is direct mmap'd byte slice — no copy!
}

rb.Read()返回的record.Raw[]byte切片,底层数组地址直指内核ring buffer物理页,由MAP_SHARED | MAP_POPULATE保障页表同步与预热。

关键参数说明

参数 作用
ringbuf.NewReader(fd) 绑定eBPF map fd,初始化用户态ring buffer结构体
record.Raw 指向mmap区域的unsafe.Slice视图,生命周期由ring buffer管理器自动维护
graph TD
    A[eBPF程序] -->|bpf_ringbuf_output| B[内核ring buffer]
    B -->|mmap映射| C[Go进程虚拟内存]
    C --> D[record.Raw直接访问]

2.5 高频syscall事件流的Go协程池化分发与毫秒级响应调度

在高吞吐 syscall 监控场景(如 eBPF tracepoint 事件流),裸 go f() 易引发协程雪崩。需构建带容量控制与优先级感知的分发层。

协程池核心结构

type SyscallPool struct {
    workers  chan func()         // 限容工作队列(缓冲通道)
    priority *heap.PriorityQueue // 毫秒级延迟敏感事件优先调度
}

workers 容量设为 runtime.NumCPU()*4,避免 Goroutine 泄漏;priority 基于事件时间戳实现 O(log n) 插入/弹出。

分发策略对比

策略 平均延迟 GC 压力 适用场景
无池直启 goroutine >8ms 低频调试
固定大小池 ~1.2ms 均匀负载
优先级动态池 0.8ms 混合 syscall 类型

事件入池流程

graph TD
    A[syscall event] --> B{是否 latency-critical?}
    B -->|是| C[Push to priority queue]
    B -->|否| D[Send to workers channel]
    C --> E[Scheduler picks top-N by deadline]
    D --> F[Worker executes handler]

关键保障:所有 handler 执行前注入 runtime.Gosched() 防止单任务饥饿,确保 P99 响应 ≤3ms。

第三章:容器逃逸异常模式识别引擎构建

3.1 基于进程谱系与命名空间拓扑的逃逸路径图谱建模

容器逃逸的本质是跨命名空间边界的非法控制流跃迁。需将进程树(/proc/[pid]/status 中的 PPidNSpid)与命名空间关联关系(/proc/[pid]/ns/* 的 inode 号)联合建模为有向图。

图谱构建核心逻辑

def build_escape_graph(pid: int) -> nx.DiGraph:
    G = nx.DiGraph()
    for p in get_process_tree(pid):  # 递归获取进程谱系
        ns_map = read_namespace_inodes(p)  # { 'mnt': 12345, 'pid': 67890 }
        G.add_node(p, namespaces=ns_map)
        if p.ppid != 0:
            G.add_edge(p.ppid, p)  # 谱系边
        for ns_type, inode in ns_map.items():
            G.add_edge(f"ns:{ns_type}:{inode}", p, type="bind")  # 命名空间绑定边
    return G

该函数构建双模态图:进程节点含命名空间指纹,边分为父子谱系边与命名空间绑定边;ns_map 中每个 inode 唯一标识一个命名空间实例,是判断跨命名空间调用的关键锚点。

关键逃逸模式映射表

模式类型 触发条件 图谱特征
PID namespace 提权 子PID ns中存在父ns的进程引用 pid inode 的 ptrace
Mount ns 逃逸 /proc/[pid]/root 指向宿主机根目录 mnt inode 与 init 进程一致
graph TD
    A[init进程] -->|共享mnt inode| B[恶意容器进程]
    C[宿主机特权进程] -->|ptrace到| D[子PID ns内进程]
    B -->|挂载覆盖| E[/dev/kvm]

3.2 Capabilities降权后非法提权的时序特征提取(capset→execveat→openat链式判定)

在容器逃逸与特权提升检测中,capset 后紧随 execveatopenat 的微秒级时序模式具有强指示性——表明进程正尝试绕过 capabilities 限制加载恶意模块或读取敏感文件。

关键系统调用链语义约束

  • capset():主动放弃 CAP_SYS_ADMIN 等高权能(降权),常用于“伪装可信”;
  • execveat(AT_EMPTY_PATH):以 O_PATH 打开的 fd 为基准执行,规避路径审计;
  • openat(AT_FDCWD, "/proc/self/fd/...", O_RDONLY):回溯已打开 fd 内容,常用于读取 /proc/self/mem 或内核模块符号。

典型 eBPF 追踪逻辑(简化)

// 在 tracepoint/syscalls/sys_enter_capset 处捕获降权事件
if (caps->effective == 0 && caps->inheritable == 0) {
    ctx->cap_dropped_ts = bpf_ktime_get_ns(); // 记录精确时间戳
}

该代码判断是否清空 effective/inheritable 能力集;bpf_ktime_get_ns() 提供纳秒级精度,为后续时序窗口匹配(≤5ms)提供锚点。

三元组时序窗口判定表

调用顺序 最大允许间隔 触发风险等级
capset → execveat ≤ 3 ms
execveat → openat ≤ 2 ms 中高
capset → openat(跳过 execveat) ≤ 1 ms 极高(暗示预置 fd)
graph TD
    A[capset: effective=0] -->|Δt ≤ 3ms| B[execveat: flags & AT_EMPTY_PATH]
    B -->|Δt ≤ 2ms| C[openat: pathname contains “/proc/self/fd/”]
    C --> D[告警:疑似capabilities绕过提权链]

3.3 Mount逃逸的FS类型白名单违例与挂载点污染检测算法

检测核心逻辑

基于/proc/[pid]/mountinfo实时解析挂载树,比对白名单(如 ext4,xfs,btrfs),拦截非常规FS类型(如 9p,overlay,fuse.lxcfs)在非特权容器内的非法挂载。

白名单违例判定代码

def is_fs_whitelisted(fs_type: str) -> bool:
    WHITELIST = {"ext4", "xfs", "btrfs", "tmpfs"}  # 仅允许生产环境可信文件系统
    return fs_type in WHITELIST
# 参数说明:fs_type 来自 mountinfo 第10字段;tmpfs 允许用于内存临时挂载,但需校验挂载选项(noexec,nosuid)

挂载点污染识别流程

graph TD
    A[读取 /proc/1/mountinfo] --> B{FS类型在白名单?}
    B -- 否 --> C[标记违例事件]
    B -- 是 --> D[检查挂载路径是否嵌套于 /var/lib/kubelet/pods/]
    D -- 否 --> E[触发污染告警:非标准挂载点]

关键检测维度

维度 合法值示例 违例示例
FS类型 xfs, ext4 fuse.nfs, cifs
挂载源 /dev/sdb1, none 10.0.1.5:/export
挂载选项 rw,relatime rw,bind,shared

第四章:防御窗口压缩至

4.1 BPF程序JIT编译优化与eBPF verifier绕过关键路径裁剪

eBPF JIT编译器在x86_64平台通过寄存器分配与指令融合显著缩短执行路径。关键优化之一是冗余边界检查消除(RBCD):当verifier已证明skb->len ≥ offset + size,JIT可安全省略运行时cmp/ja分支。

JIT优化前后的指令对比

场景 汇编片段(节选) 说明
Verifier未裁剪 cmp $66, %r9; ja bad_end 强制校验IP头长度
JIT优化后 # elided 编译期确认无越界,直接移除
// JIT后端关键裁剪逻辑(kernel/bpf/core.c)
if (insn_is_helper_call(insn) && 
    helper_id == BPF_FUNC_skb_load_bytes) {
    if (can_prove_access(skb, off, len, &env)) // 利用verifier的range_map
        emit_noop(); // 跳过运行时校验
}

该代码判断是否可通过env->reg_state中预计算的寄存器范围推导出内存访问安全性,若成立则生成空操作而非校验指令。

绕过路径依赖链

  • verifier必须完成符号执行+区间传播
  • JIT需启用CONFIG_BPF_JIT_ALWAYS_ON
  • 内核需≥5.10(支持bpf_jit_harden=0下仍保留RBCD)
graph TD
    A[Verifer: range_map构建] --> B[JIT: can_prove_access]
    B --> C{校验可裁剪?}
    C -->|Yes| D[emit_noop]
    C -->|No| E[emit_cmp_ja]

4.2 Go侧事件聚合延迟控制:基于tsc时间戳的硬件级时序对齐

在高吞吐事件流场景中,Go运行时调度抖动与系统时钟漂移常导致聚合窗口偏移。直接依赖time.Now()(基于CLOCK_MONOTONIC)无法满足亚微秒级对齐需求。

TSC时间戳采集与校准

// 使用x86平台TSC(Time Stamp Counter)获取高精度周期计数
func readTSC() uint64 {
    var hi, lo uint32
    asm("rdtscp", &lo, &hi, nil, nil) // 带序列化,避免指令重排
    return uint64(lo) | (uint64(hi) << 32)
}

rdtscp指令确保TSC读取原子性且同步于CPU流水线;返回值为自复位以来的精确周期数,需结合cpuid校验TSC是否恒定(Invariant TSC)。

延迟控制核心流程

graph TD
    A[事件到达] --> B{是否首次采样?}
    B -->|是| C[记录TSC基线 + 系统时钟]
    B -->|否| D[计算TSC delta → 转换为纳秒]
    D --> E[对齐至最近100μs聚合窗口边界]

校准参数对照表

参数 典型值 说明
tsc_khz 3200000 CPU基准频率(kHz)
tsc_to_ns 0.3125 每TSC周期对应纳秒数
window_ns 100000 聚合窗口宽度(100μs)

该机制将端到端聚合延迟抖动压缩至±83ns(实测P99)。

4.3 容器运行时上下文(CRI-O/containerd)元数据低开销注入方案

为避免 Pod 生命周期事件监听带来的延迟与资源争用,CRI-O 与 containerd 均支持通过 RuntimeHandler 扩展点注入轻量级上下文元数据。

数据同步机制

采用 io.containerd.runtimes.v2.TaskCheckpoint 接口,在 CreateTask 阶段将 annotations 透传至 shim 进程环境变量,规避 IPC 调用:

// 注入示例:runtime handler 配置片段
{
  "handlers": [{
    "name": "nri-ctx",
    "runtimeType": "io.containerd.runc.v2",
    "options": {
      "BinaryName": "runc",
      "Annotations": ["io.k8s.cri.metadata.namespace", "io.k8s.cri.metadata.uid"]
    }
  }]
}

该配置使 containerd 在调用 runc 创建容器前,自动将指定 annotation 注入 runc create --no-pivot --preserve-fds=3 的环境变量中,开销低于 15μs/容器。

元数据映射策略

字段来源 注入位置 访问方式
Kubernetes Pod /run/containerd/io.k8s.cri.meta os.Getenv()proc/self/environ
Node topology node-labels 仅限 privileged runtime
graph TD
  A[Kubelet CreatePodSandbox] --> B[CRI-O PreCreateHook]
  B --> C[containerd Task.Create with annotations]
  C --> D[runc shim: inject as env vars]
  D --> E[容器内进程读取 /proc/1/environ]

4.4 异步告警通道的gRPC流式推送与eBPF tail call动态热更新

核心架构演进

传统轮询告警存在延迟高、资源冗余问题。本方案融合 gRPC Server Streaming 与 eBPF tail call,实现毫秒级响应与零重启热更新。

gRPC 流式推送示例(服务端)

func (s *AlertServer) StreamAlerts(req *pb.AlertRequest, stream pb.AlertService_StreamAlertsServer) error {
    // 基于 ring buffer + channel 实现无锁事件分发
    for alert := range s.alertChan {
        if err := stream.Send(&pb.AlertEvent{Id: alert.ID, Level: alert.Level}); err != nil {
            return err // 自动触发流重连
        }
    }
    return nil
}

stream.Send() 非阻塞写入 HTTP/2 数据帧;alertChan 由 eBPF 程序通过 perf_event_output 触发填充,解耦内核探测与用户态通知。

eBPF tail call 动态切换逻辑

旧策略函数 新策略函数 切换条件
filter_cpu_high filter_cpu_high_v2 CPU 使用率 > 95% 持续10s
drop_by_pid drop_by_cgroup 容器环境自动识别
graph TD
    A[eBPF probe] --> B{tail_call map lookup}
    B -->|index 0| C[filter_cpu_high]
    B -->|index 1| D[filter_cpu_high_v2]
    C --> E[perf_submit]
    D --> E

热更新保障机制

  • tail call 目标程序需同属同一 ELF section,且 verifier 兼容
  • 更新时原子替换 map value,旧程序在当前调用栈执行完毕后自然退役

第五章:生产环境落地挑战与演进方向

多集群配置漂移引发的灰度发布失败

某金融客户在Kubernetes 1.24+多云环境中部署AI推理服务时,因各集群的kube-proxy模式不一致(部分为iptables,部分为nftables),导致Service ClusterIP解析在灰度流量中出现5%的随机超时。运维团队通过Prometheus指标kube_proxy_sync_proxy_rules_duration_seconds_bucket定位到差异,并采用GitOps策略统一注入ConfigMap校验脚本,在CI流水线中强制校验集群基线配置:

kubectl get configmap -n kube-system kube-proxy -o jsonpath='{.data.config\.conf}' | \
  grep -q "mode: nftables" || { echo "ERROR: nftables mode required"; exit 1; }

混合云网络策略冲突

跨AZ容灾架构下,AWS EKS集群与本地OpenShift集群通过IPSec隧道互联。当启用Calico NetworkPolicy后,发现Pod间TCP连接偶发RST包。抓包分析显示:Calico默认的felix组件未同步处理IPSec加密后的TTL字段,导致conntrack表误判连接状态。解决方案是升级Calico至v3.26.1并启用FELIX_IPINIPENABLED=true与自定义conntrack超时策略:

组件 旧策略(秒) 新策略(秒) 触发场景
nf_conntrack_tcp_timeout_established 432000 86400 长连接保活
nf_conntrack_tcp_timeout_close_wait 60 30 FIN-WAIT状态快速回收

模型服务冷启动延迟超标

电商大促期间,TensorFlow Serving实例在自动扩缩容后首次请求耗时达8.2s(SLA要求≤1.5s)。根因分析发现:容器镜像内嵌的模型权重文件未启用mmap加载,且Kubernetes未配置memory.swappiness=0。通过构建分层镜像将模型权重置于/models:ro只读层,并在Deployment中添加:

securityContext:
  sysctls:
  - name: vm.swappiness
    value: "0"

同时利用kubectl debug临时挂载perf工具捕获CPU周期热点,确认92%时间消耗在memcpy模型参数拷贝阶段。

边缘节点GPU资源隔离失效

在NVIDIA GPU Operator v22.9部署的边缘集群中,多个AI质检任务共享同一块A10显卡时出现CUDA内存越界错误。经nvidia-smi dmon -s u监控发现:nvmlDeviceGetUtilizationRates()返回的GPU利用率突增但无对应进程。最终定位为容器运行时未启用nvidia-container-toolkit--no-opengl-libs参数,导致OpenGL库劫持CUDA上下文。修复后通过以下命令验证隔离效果:

kubectl exec -it pod-a -- nvidia-smi -q -d MEMORY | grep -A5 "FB Memory Usage"

持续可观测性能力断层

生产集群日志链路存在三个断点:Fluent Bit采集丢失23%的stderr日志、Loki索引未对齐OpenTelemetry TraceID、Grafana面板无法关联Prometheus指标与Jaeger Span。采用eBPF技术在节点侧注入bpftrace探针,捕获write()系统调用失败路径,确认是Fluent Bit的buffer_full事件未触发告警。最终通过修改values.yaml启用buffer_metrics: true并对接Alertmanager实现毫秒级异常检测。

模型版本回滚原子性缺失

某风控模型V2.3上线后触发批量误拒,执行kubectl rollout undo仅回退Deployment镜像,但未同步回退Redis中缓存的特征工程元数据。通过Argo CD钩子机制,在PreSync阶段执行SQL脚本清理:

DELETE FROM feature_meta WHERE model_version = 'v2.3' AND updated_at > '2024-06-15 08:00:00';

并在PostSync阶段校验SELECT COUNT(*) FROM feature_meta WHERE model_version = 'v2.2'确保一致性。

安全合规审计盲区

等保2.0三级要求记录所有kubectl exec操作,但集群审计日志未包含命令行参数。通过修改/etc/kubernetes/manifests/kube-apiserver.yaml,增加审计策略:

- level: RequestResponse
  resources:
  - group: ""
    resources: ["pods/exec"]
  omitStages:
  - RequestReceived

并部署Logstash过滤器提取requestObject.spec.containers[0].args字段入库。

跨团队协作流程割裂

SRE团队维护基础设施而MLOps团队管理模型生命周期,导致Helm Chart版本与模型注册表(MLflow)版本号不一致。建立双向同步机制:当MLflow创建新模型版本时,触发Webhook调用Jenkins Pipeline生成带modelVersion: "2.3.1"标签的Helm Chart,并推送至Harbor仓库;反之,Helm Release成功后回调MLflow API标记该版本为deployed_to_prod

从 Consensus 到容错,持续探索分布式系统的本质。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注