Posted in

/etc/passwd权限检测不准?Go unsafe.Pointer直访inode元数据,绕过stat缓存,精度达Linux内核级

第一章:Go语言基线扫描工具的设计哲学与核心定位

Go语言基线扫描工具并非通用代码质量检查器,而是聚焦于“安全合规性”与“工程一致性”的轻量级守门人。其设计哲学根植于Go生态的务实传统:拒绝过度抽象、强调可预测行为、优先保障构建链路的稳定性。工具不试图替代golangci-lintstaticcheck,而是填补关键空白——验证项目是否满足组织级最小安全基线,例如TLS配置强度、日志敏感信息掩码、依赖版本约束策略等。

设计原则的实践体现

  • 零依赖运行:编译为单二进制文件,无需外部插件或配置服务器;
  • 声明式策略定义:所有检查规则通过YAML描述,支持条件表达式(如 version >= "1.21.0");
  • 上下文感知扫描:自动识别go.mod.gitignoreDockerfile等上下文,避免误报。

核心定位的差异化价值

维度 传统Linter Go基线扫描工具
检查目标 代码风格/潜在缺陷 合规策略执行状态
配置粒度 函数/包级开关 环境级策略集(dev/staging/prod)
输出形式 行号+错误消息 策略ID+修复指引+风险等级

快速验证基线合规性

安装后执行以下命令启动默认扫描:

# 下载并安装(Linux/macOS)
curl -sSfL https://raw.githubusercontent.com/gobaseline/cli/main/install.sh | sh -s -- -b /usr/local/bin

# 扫描当前项目(使用内置default策略集)
gobaseline scan --policy default --format table

该命令将解析go.mod获取依赖树,检查main.gohttp.ListenAndServeTLS调用是否指定MinVersion: tls.VersionTLS13,并验证go.sum哈希完整性。任何违反策略的行为将以[CRITICAL][WARNING]分级输出,并附带对应CWE编号及修复示例。工具默认跳过vendor/testdata/目录,确保扫描范围严格限定在生产交付边界内。

第二章:Linux文件系统元数据的底层探秘与unsafe.Pointer实践

2.1 inode结构体在内核中的真实布局与glibc stat缓存机制剖析

Linux内核中struct inode并非裸露暴露给用户空间,而是通过VFS层抽象封装。其核心字段在include/linux/fs.h中定义:

struct inode {
    umode_t         i_mode;     // 文件类型与权限(如 S_IFREG | 0644)
    unsigned short  i_opflags;
    kuid_t          i_uid;      // 所属用户ID(经namespace转换)
    struct timespec64 i_atime;  // 最后访问时间(纳秒精度,v5.11+)
    struct inode_operations *i_op; // 操作函数集指针(关键VFS分发入口)
};

i_atime自v5.11起升级为timespec64,解决Y2038问题;i_op指向具体文件系统实现的函数表(如ext4_inode_operations),是VFS多态调度基石。

glibc的stat()调用经__xstat()路径,会检查_STAT_VER版本号并与内核statx()返回的stx_mask比对,决定是否绕过内核重查——该机制依赖/proc/sys/fs/stat_cache_timeout(默认0,即禁用用户态缓存)。

数据同步机制

  • 内核inode缓存由icache slab管理,LRU淘汰
  • glibc不维护持久stat缓存,每次stat()均触发系统调用(除非启用statx+AT_STATX_DONT_SYNC
缓存层级 生效范围 同步策略
VFS inode cache 全局 writeback线程异步回写
dentry cache 路径名→dentry映射 shrink_dcache_parent()回收
graph TD
    A[stat syscall] --> B{glibc检查_STAT_VER}
    B -->|匹配| C[返回缓存stx]
    B -->|不匹配| D[触发do_statx]
    D --> E[内核填充stx from inode]

2.2 unsafe.Pointer绕过VFS层缓存:直接读取ext4 superblock与inode table的Go实现

Linux内核VFS层对块设备I/O施加了页缓存与路径解析开销。unsafe.Pointer可将原始字节流(如mmap映射的设备文件)强制转换为结构体视图,跳过VFS抽象。

ext4超级块内存布局映射

type Ext4SuperBlock struct {
    InodesCount       uint32 // 偏移0x00:总inode数
    BlocksCount       uint32 // 偏移0x04:总块数
    BlockSize         uint32 // 偏移0x18:块大小(需左移10位得真实值)
    FirstDataBlock    uint32 // 偏移0x1C:首个数据块号
}
// 将/dev/sda1 mmap首1MB后,取偏移0x400处的superblock(ext4默认位置)
sb := (*Ext4SuperBlock)(unsafe.Pointer(&mmapBuf[0x400]))

逻辑分析:unsafe.Pointer规避了os.File.Read()的VFS路径,直接访问物理块设备映射内存;0x400是ext4标准superblock起始偏移(1KB),BlockSize字段需1024 << (sb.BlockSize & 0x3)计算实际值。

关键约束与风险

  • 必须以O_DIRECT打开设备文件并mmap对齐到4096边界
  • 结构体字段顺序/填充需严格匹配ext4磁盘格式(参考linux/ext4_fs.h
  • 无锁访问,多goroutine并发读需自行同步
字段 类型 用途
InodesCount uint32 校验文件系统完整性
FirstDataBlock uint32 定位group descriptor表起始
graph TD
    A[open /dev/sda1 O_DIRECT] --> B[mmap 1MB at offset 0]
    B --> C[unsafe.Pointer + 0x400 → Ext4SuperBlock]
    C --> D[解析BlockGroupDescTable位置]
    D --> E[遍历inode table元数据]

2.3 /etc/passwd权限检测失准的根本原因:atime/mtime更新策略与页缓存延迟实证分析

数据同步机制

Linux 默认启用 relatime 挂载选项,导致 /etc/passwdatime 仅在 mtime/ctime 更新后或首次访问时刷新,而非每次读取——这使基于 atime 的权限审计工具误判文件“未被近期访问”。

页缓存延迟实证

# 强制刷新页缓存并观测 mtime 变化
echo 3 > /proc/sys/vm/drop_caches  # 清空 pagecache、dentries、inodes
stat -c "%y %z" /etc/passwd         # 输出 mtime 和 atime(注意二者可能不同步)

该命令触发内核重载 inode 元数据,但 mtime 不变——因 read() 不修改内容,而页缓存中 stale 的 atime 可能滞留数秒,受 vm.dirty_expire_centisecs(默认3000=30s)约束。

关键参数对照表

参数 默认值 影响范围
mount -o relatime 启用 抑制频繁 atime 更新
vm.dirty_ratio 20% 触发后台回写阈值
fs.protected_regular 1 限制非特权进程 open() 时的 atime 更新
graph TD
    A[open(/etc/passwd, O_RDONLY)] --> B{是否满足 relatime 条件?}
    B -->|否| C[跳过 atime 更新]
    B -->|是| D[标记 inode I_DIRTY_TIME]
    D --> E[延迟至 writeback 周期提交]

2.4 基于mmap+unsafe.Offsetof的零拷贝inode解析器:从磁盘块到Go struct的原子映射

传统inode解析需read()系统调用 + 用户态内存拷贝,引入至少两次数据迁移。本方案利用mmap()将ext4磁盘块直接映射为Go虚拟内存页,再通过unsafe.Offsetof()精准定位结构体内字段偏移,实现字节级零拷贝访问。

核心优势对比

方式 系统调用次数 内存拷贝 随机访问延迟
read() + binary.Read ≥1 2次(内核→用户→struct) 高(需完整读块)
mmap + unsafe.Offsetof 0(映射后无调用) 0 极低(页命中即访问)
// 将4KB inode块映射为只读内存视图
data, _ := syscall.Mmap(int(fd), int64(offset), 4096, 
    syscall.PROT_READ, syscall.MAP_SHARED)
// 安全转换为ext4_inode结构指针(假设已定义)
inode := (*ext4_inode)(unsafe.Pointer(&data[0]))
// 直接读取i_size_lo字段(偏移0x28)
size := *(*uint32)(unsafe.Pointer(uintptr(unsafe.Pointer(inode)) + unsafe.Offsetof(inode.i_size_lo)))

unsafe.Offsetof(inode.i_size_lo) 在编译期计算字段相对于结构体起始地址的字节偏移(如0x28),避免运行时反射开销;uintptr 转换确保指针算术合法,*uint32 解引用直接读取映射页内原始字节——整个过程不触发任何内存复制或GC扫描。

数据同步机制

映射页修改需配合msync()保证磁盘持久化;MAP_PRIVATE适用于只读解析场景,天然规避脏页管理复杂度。

2.5 权限校验精度对比实验:stat() vs raw inode bitfield解析(rwxr-xr– → 0644)

核心差异定位

stat() 系统调用返回 st_mode 字段,经 S_IRWXU/S_IRGRP 等宏解包;而 raw inode 解析需直接位移提取低12位中的权限位(0777 & st_mode)。

实验代码对比

// 方式1:stat() + 宏判断(POSIX语义层)
struct stat sb;
stat("/tmp/test", &sb);
bool can_read = (sb.st_mode & S_IRUSR) != 0; // 依赖宏定义,可读性高但抽象一层

// 方式2:raw bitfield(内核视角)
mode_t raw_perm = sb.st_mode & 0777; // 直接截取权限位域
int octal = raw_perm; // rwxr-xr-- → 0644

S_IRUSR 展开为 0400,本质仍是位运算;但 stat() 封装隐藏了 st_mode 中混杂的文件类型位(如 S_IFREG),需掩码隔离。

性能与精度对照表

维度 stat() + 宏 Raw bitfield
精度 语义等价,无损失 位级精确,零抽象
开销 函数调用+宏展开 纯位运算(1 cycle)
可移植性 高(POSIX标准) 依赖st_mode布局
graph TD
    A[用户请求权限校验] --> B{选择路径}
    B -->|兼容性优先| C[stat→S_IRUSR等宏]
    B -->|极致性能/内核调试| D[raw st_mode & 0777]
    C --> E[返回布尔结果]
    D --> F[返回八进制整数]

第三章:高精度基线规则引擎的构建

3.1 声明式规则DSL设计:YAML Schema驱动的inode mode/uid/gid/immutable flag联合断言

为实现文件系统安全策略的可读性与可验证性,本设计采用 YAML Schema 作为声明式规则载体,将 modeuidgidchattr +i 状态统一建模为原子断言单元。

核心规则结构示例

# rules/file_integrity.yaml
- path: "/etc/shadow"
  expect:
    mode: "0600"
    uid: 0
    gid: 0
    immutable: true  # 对应 lsattr 输出中的 'i' flag

逻辑分析:该 DSL 被解析为 os.Stat() + syscall.Statx() 联合校验链。immutable 字段触发 ioctl(fd, FS_IOC_GETFLAGS, &flags) 调用;mode/uid/gid 直接比对 syscall.Stat_t 字段,避免 shell 工具(如 ls -l)的解析开销。

断言执行流程

graph TD
  A[Load YAML Rules] --> B[Parse & Validate against JSON Schema]
  B --> C[Resolve path → inode]
  C --> D[Stat + GetFlags ioctl]
  D --> E[All fields match?]
  E -->|Yes| F[Pass]
  E -->|No| G[Report delta]

校验维度对照表

属性 底层接口 权限依赖
mode st_mode
uid/gid st_uid/st_gid
immutable FS_IOC_GETFLAGS CAP_LINUX_IMMUTABLE

3.2 规则热加载与内存安全校验:利用go:embed与unsafe.Sizeof保障规则二进制一致性

规则引擎需在不重启服务的前提下动态更新策略。go:embed 将规则文件(如 rules.bin)编译进二进制,避免运行时文件 I/O 不一致;unsafe.Sizeof 则用于校验嵌入数据的结构体布局是否与运行时类型严格对齐。

数据同步机制

规则结构体定义需保证 ABI 稳定:

type Rule struct {
    ID     uint64
    Mask   [16]byte // 固定长度字段,避免指针/变长导致 size 波动
    Action uint8
}
var _ = unsafe.Sizeof(Rule{}) // 编译期断言:确保大小恒为 25 字节

逻辑分析:unsafe.Sizeof 在编译期计算结构体内存占用,若后续修改 Rule 引入 string[]byte,该值将突变,触发构建失败,从而阻断不安全的规则二进制升级。

校验关键参数

参数 说明
Rule 大小 25 uint64(8) + [16]byte(16) + uint8(1)
rules.bin 长度 必须整除 25 每条规则严格等长,支持 unsafe.Slice 安全切片
graph TD
    A[编译期 embed rules.bin] --> B[unsafe.Sizeof 校验 Rule]
    B --> C{大小匹配?}
    C -->|是| D[运行时 unsafe.Slice 解析规则数组]
    C -->|否| E[构建失败,拦截不一致发布]

3.3 内核级原子性保障:通过open(O_NOFOLLOW | O_PATH) + fstatat(AT_EMPTY_PATH)规避TOCTOU

TOCTOU(Time-of-Check to Time-of-Use)漏洞常在路径检查与后续操作间因符号链接篡改而触发。传统 stat() + open() 组合存在竞态窗口。

原子路径解析原理

内核自 2.6.39 起支持 O_PATH:仅获取文件描述符,不触达文件内容或权限检查;O_NOFOLLOW 确保拒绝符号链接解析。二者结合可安全“定位”路径而不触发副作用。

关键调用链

int fd = open("/proc/self/fd/3", O_NOFOLLOW | O_PATH); // fd 指向目标路径的打开实例
struct stat sb;
fstatat(fd, "", &sb, AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW); // 空路径 + AT_EMPTY_PATH → 对 fd 所指对象直接 stat
  • open(..., O_PATH|O_NOFOLLOW):返回一个仅用于后续 *at() 系统调用的“路径句柄”,不校验读写权限,也不跟随链接;
  • fstatat(fd, "", ..., AT_EMPTY_PATH):将 fd 视为已解析的路径终点,绕过所有用户空间路径遍历逻辑,实现内核级原子性。
机制 传统 stat+open O_PATH + fstatat(AT_EMPTY_PATH)
符号链接防护 ❌(需额外检查) ✅(O_NOFOLLOW 强制生效)
竞态窗口 存在(毫秒级) 消除(全程在内核 VFS 层完成)
权限检查时机 open 时触发 完全跳过(O_PATH 不做权限验证)
graph TD
    A[用户调用 open(path, O_NOFOLLOW \| O_PATH)] --> B[内核解析路径至dentry]
    B --> C[返回fd,不执行权限/访问检查]
    C --> D[fstatat(fd, \"\", ..., AT_EMPTY_PATH)]
    D --> E[直接对dentry执行stat,无重解析]

第四章:企业级基线扫描工具工程化落地

4.1 并发安全的inode批量采集器:基于epoll_wait+io_uring的异步元数据批处理框架

传统stat()同步调用在百万级文件扫描中成为性能瓶颈。本框架融合epoll_wait事件驱动调度与io_uring零拷贝异步I/O,实现高吞吐、低延迟的inode元数据并发采集。

核心协同机制

  • epoll_wait负责监听目录fd就绪事件,触发批量路径生成
  • io_uring提交IORING_OP_STATX请求,支持单次提交256个inode元数据获取
  • 原子引用计数+RCU保护共享inode_batch_t结构,规避锁竞争

批处理流程(mermaid)

graph TD
    A[epoll_wait检测目录就绪] --> B[构建path_batch数组]
    B --> C[预注册file paths到io_uring]
    C --> D[批量提交IORING_OP_STATX]
    D --> E[completion queue回调聚合结果]
    E --> F[无锁写入ring buffer供消费者消费]

关键代码片段

// 提交statx请求(简化版)
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_statx(sqe, dir_fd, path, AT_SYMLINK_NOFOLLOW, 
                    STATX_BASIC_STATS, &stx);
io_uring_sqe_set_data(sqe, batch_id); // 携带批次上下文
io_uring_submit(&ring); // 非阻塞提交

AT_SYMLINK_NOFOLLOW避免符号链接递归开销;STATX_BASIC_STATS精准控制字段粒度,减少内核拷贝量;batch_id用于completion handler中快速定位归属批次,实现无锁结果归集。

4.2 扫描结果的可信溯源体系:每个检测项附带btrfs subvolume ID、i_generation、crtime纳秒时间戳

为确保检测结果不可篡改、可精确回溯,系统在生成每条扫描记录时,自动绑定三项内核级元数据:

关键元数据语义

  • btrfs subvolume ID:唯一标识所属子卷,避免跨卷误关联
  • i_generation:inode 代数,文件重建/快照克隆时递增,可识别硬链接与克隆副本
  • crtime(nanosecond):纳秒级创建时间戳,由 statx() 获取,精度远超 mtime/atime

数据采集示例

struct statx stx;
if (statx(fd, "", AT_EMPTY_PATH | AT_STATX_SYNC_AS_STAT,
          STATX_BTIME, &stx) == 0) {
    uint64_t crtime_ns = stx.stx_btime.tv_sec * 1000000000ULL +
                         stx.stx_btime.tv_nsec; // 纳秒级精确创建时间
}

该调用绕过传统 stat() 时间截断缺陷,直接获取 btime(birth time),结合 stx_inostx_gen(即 i_generation),构成三元可信锚点。

元数据组合验证能力

元数据项 可防御场景
subvolume ID 跨卷重命名/挂载混淆
i_generation 文件覆盖后 inode 复用导致的误溯源
crtime(ns) 秒级时间碰撞(如批量 touch)
graph TD
    A[扫描触发] --> B[openat + O_NOFOLLOW]
    B --> C[statx 获取 stx_gen/stx_btime]
    C --> D[btrfs ioctl 获取 subvolid]
    D --> E[三元组写入审计日志]

4.3 低开销审计日志输出:ring buffer-backed syscall trace与/proc/self/status内存快照嵌入

传统系统调用审计常依赖同步写入磁盘或用户态缓冲,引入显著延迟。本方案采用内核 ring buffer(perf_event_open + PERF_TYPE_TRACEPOINT)实现零拷贝 syscall trace 捕获,并在每次 trace 记录时原子嵌入 /proc/self/status 的关键内存字段快照。

数据同步机制

  • Ring buffer 大小设为 PAGE_SIZE * 4,避免频繁唤醒;
  • 使用 perf_event_attr.sample_type = PERF_SAMPLE_RAW | PERF_SAMPLE_TIME
  • 快照仅提取 VmRSS, VmSize, Threads 三字段,通过 seq_read() 非阻塞解析。
// 在 tracepoint handler 中嵌入快照(简化示意)
char snap[256];
read_proc_status(snap, sizeof(snap)); // 内联解析 /proc/self/status
perf_event_output(event, &handle, &snap, sizeof(snap));

逻辑分析:read_proc_status() 仅扫描 1KB 内 /proc/self/status 缓存页,跳过非目标行;perf_event_output() 直接将快照 memcpy 到 ring buffer 当前 slot,全程无锁、无内存分配。

性能对比(μs/trace)

方式 平均延迟 上下文切换 内存分配
同步 write() 18.2
ring+快照 0.93
graph TD
    A[syscall entry] --> B{trace enabled?}
    B -->|yes| C[fetch /proc/self/status snippet]
    C --> D[pack into ring buffer slot]
    D --> E[notify user via mmap page]

4.4 容器环境适配层:chroot/jailfs下通过/proc/PID/fd/解析挂载命名空间的真实inode路径

chrootjailfs 等隔离环境中,/proc/PID/root 指向的是 jail 根,而非宿主机真实挂载点。此时需借助 /proc/PID/fd/ 中的文件描述符符号链接还原原始 inode 路径。

关键机制:fd 符号链接的命名空间穿透性

/proc/PID/fd/N 指向的是该进程在其挂载命名空间中解析后的实际路径,且内核保证其目标 inode 与宿主机一致(只要未被 unmount)。

# 获取某进程打开的块设备文件的真实宿主机路径
$ readlink -f /proc/1234/fd/5
# 输出示例:/mnt/storage/vol1/data.db(即使进程处于 chroot /jail 下)

逻辑分析readlink -f 递归解析符号链接,绕过 chroot 的路径限制;/proc/PID/fd/N 是内核直接暴露的 VFS 层 inode 引用,不受用户态 root 变更影响。参数 -f 启用全路径规范化,确保返回宿主机视角的绝对路径。

典型适用场景对比

场景 /proc/PID/root 是否可靠 /proc/PID/fd/N 是否可穿透
chroot 隔离 ❌(指向 jail 根) ✅(指向真实 inode)
mount --bind ⚠️(可能被覆盖) ✅(保持底层 inode 不变)
unshare --user --mount ❌(受 mount ns 限制) ✅(fd 仍映射到原 ns inode)
graph TD
    A[进程打开文件] --> B[/proc/PID/fd/N 符号链接]
    B --> C{内核 VFS 层}
    C --> D[真实 inode 号]
    D --> E[宿主机全局路径树]

第五章:总结与展望

技术栈演进的实际影响

在某大型电商平台的微服务重构项目中,团队将原有单体架构迁移至基于 Kubernetes 的云原生体系。迁移后,平均部署耗时从 47 分钟缩短至 92 秒,CI/CD 流水线失败率下降 63%。关键变化在于:

  • 使用 Helm Chart 统一管理 87 个服务的发布配置
  • 引入 OpenTelemetry 实现全链路追踪,定位一次支付超时问题的时间从平均 6.5 小时压缩至 11 分钟
  • Istio 网关策略使灰度发布成功率稳定在 99.98%,近半年无因发布引发的 P0 故障

生产环境中的可观测性实践

以下为某金融风控系统在 Prometheus + Grafana 中落地的核心指标看板配置片段:

- name: "risk-service-alerts"
  rules:
  - alert: HighLatencyRiskCheck
    expr: histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job="risk-api"}[5m])) by (le)) > 1.2
    for: 3m
    labels:
      severity: critical

该规则上线后,成功在用户投诉前 4.2 分钟自动触发告警,并联动 PagerDuty 启动 SRE 响应流程。过去三个月内,共拦截 17 起潜在服务降级事件。

多云架构下的成本优化成果

某政务云平台采用混合云策略(阿里云+本地数据中心),通过 Crossplane 统一编排资源后,实现以下量化收益:

维度 迁移前 迁移后 降幅
月度计算资源成本 ¥1,284,600 ¥792,300 38.3%
跨云数据同步延迟 3200ms ± 840ms 410ms ± 62ms ↓87%
容灾切换RTO 18.6 分钟 47 秒 ↓95.8%

工程效能提升的关键杠杆

某 SaaS 企业推行“开发者自助平台”后,各角色效率变化显著:

  • 前端工程师平均每日创建测试环境次数从 0.7 次提升至 4.3 次(支持 Storybook 即时预览)
  • QA 团队自动化用例覆盖率从 31% 提升至 79%,回归测试耗时减少 5.2 小时/迭代
  • 运维人员手动干预事件同比下降 82%,93% 的资源扩缩容由 KEDA 基于 Kafka 消息积压量自动触发

边缘计算场景的落地挑战

在智能工厂视觉质检项目中,将 TensorFlow Lite 模型部署至 NVIDIA Jetson AGX Orin 设备时,遭遇如下真实瓶颈:

  • 模型推理吞吐量仅达理论峰值的 41%,经 profiling 发现 NVDEC 解码器与 CUDA 内存池存在竞争
  • 通过修改 nvidia-container-cli 启动参数并启用 --gpus all --privileged 组合,吞吐提升至 76%
  • 最终单设备支持 12 路 1080p@30fps 视频流实时分析,误检率控制在 0.023% 以下(低于合同约定的 0.05%)
graph LR
A[边缘设备采集图像] --> B{GPU解码器负载>85%?}
B -->|是| C[启用NVJPG硬件加速]
B -->|否| D[保持CUDA默认调度]
C --> E[内存拷贝延迟↓39%]
D --> F[维持现有流水线]
E --> G[整体吞吐↑1.8倍]
F --> G

开源工具链的定制化改造

为适配国产化信创环境,某省级政务系统对 Argo CD 进行深度改造:

  • 替换 etcd 存储后端为达梦数据库,重写 12 个 DAO 层接口,兼容 SQL 标准语法差异
  • 集成麒麟 V10 系统的 SM4 加密模块,实现应用配置项端到端国密加密
  • 构建离线 Helm 仓库镜像机制,支持无外网环境下每小时自动同步上游 Chart 版本

未来技术融合方向

在车联网 V2X 场景中,Kubernetes 的边缘节点已开始承载轻量级 5G UPF 功能,通过 eBPF 程序直接处理用户面流量。某车企实测表明:在 300 个车载终端并发接入下,端到端时延稳定在 8.3±1.2ms,满足自动驾驶紧急制动指令的硬实时要求。

守护数据安全,深耕加密算法与零信任架构。

发表回复

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