Posted in

GO中调用OpenSSL C API的11个加密安全陷阱(密钥泄露、ECDSA签名随机数复用、BN_CTX未重置)

第一章:Go与OpenSSL混合编程的安全背景与风险全景

现代云原生系统中,Go语言因其并发模型与部署便捷性被广泛用于构建安全服务(如TLS网关、证书签发代理),但其标准库crypto/tls对某些国密算法(SM2/SM4)、PKCS#11硬件模块或自定义OpenSSL引擎的支持有限,迫使开发者通过cgo调用OpenSSL C API实现功能扩展。这种混合编程模式在提升能力的同时,引入了跨语言边界特有的安全风险面。

安全风险的多维来源

  • 内存生命周期错位:Go的GC无法管理OpenSSL分配的BIO、EVP_PKEY等C结构体,若忘记显式调用OPENSSL_freeEVP_PKEY_free,将导致永久性内存泄漏;
  • 线程状态污染:OpenSSL 1.1.1+要求每个线程调用OPENSSL_init_thread()并维护ERR_get_error()的本地错误队列,而Go goroutine与OS线程非固定绑定,错误码可能被其他goroutine覆盖;
  • 符号冲突隐患:当Go二进制链接静态libcrypto.a且运行时又动态加载含同名符号的.so(如某些HSM驱动),可能触发undefined symbol: OPENSSL_sk_num类崩溃。

典型危险操作示例

以下代码片段未正确清理OpenSSL资源,存在双重释放风险:

// openssl_wrapper.c
#include <openssl/evp.h>
void unsafe_sign(const unsigned char* data, size_t len) {
    EVP_MD_CTX* ctx = EVP_MD_CTX_new(); // 必须配对 EVP_MD_CTX_free
    EVP_PKEY* pkey = EVP_PKEY_new();     // 必须配对 EVP_PKEY_free
    // ... 签名逻辑省略
    EVP_MD_CTX_free(ctx); // ✅ 正确释放
    EVP_PKEY_free(pkey);  // ✅ 正确释放
    // 若此处遗漏释放,ctx/pkey将随CGO调用返回后悬空
}

风险缓解关键实践

措施 实施方式
资源封装 使用Go struct嵌入*C.EVP_MD_CTX,在Finalize方法中强制调用C释放函数
错误处理隔离 每次OpenSSL调用后立即执行err := C.ERR_get_error(),避免跨goroutine污染
OpenSSL初始化 init()函数中调用C.OPENSSL_init_crypto(C.OPENSSL_INIT_ATFORK, nil)

混合编程不是“能跑通”即安全,而是需将OpenSSL视为具有严格所有权语义的外部子系统——每一次C.XXX_new()都隐含着一次必须兑现的C.XXX_free()契约。

第二章:C语言层OpenSSL API调用的核心安全陷阱

2.1 BN_CTX未重置导致的内存残留与侧信道泄露

BN_CTX 是 OpenSSL 中用于临时大数运算的上下文结构,其内部缓存多组 BIGNUM 对象以避免频繁分配。若调用 BN_CTX_new() 后未在每次使用前调用 BN_CTX_start() + BN_CTX_get() + BN_CTX_end() 完整生命周期管理,或遗漏 BN_CTX_reset(),则前次计算残留的敏感中间值(如私钥参与的模幂结果)可能滞留于堆内存中。

内存残留风险示例

BN_CTX *ctx = BN_CTX_new();  // 分配上下文
BIGNUM *r = BN_new();
BN_mod_exp(r, base, exp, mod, ctx);  // 此次计算可能复用旧内存块
// 忘记 BN_CTX_reset(ctx) → 前次 r 的高位字节仍驻留 ctx->bignums[0]->d[0..n]

BN_CTX_reset() 清零所有内部 BIGNUM 的 d 数组指针指向的内存页,但不释放页本身;未调用则残留数据可被后续 malloc 分配后未初始化读取。

侧信道利用路径

攻击面 机制
共享内存容器 容器化环境共享物理页
跨线程缓存污染 多线程共用 ctx 导致交叉覆盖
内存扫描工具 /proc/[pid]/mem 直接读取
graph TD
    A[BN_CTX_new] --> B[BN_mod_exp with private key]
    B --> C{BN_CTX_reset?}
    C -- No --> D[敏感数据残留于d array]
    C -- Yes --> E[显式清零d[0..top]]
    D --> F[通过侧信道读取残留]

2.2 ECDSA签名中随机数(k值)复用的数学原理与Go调用实证

ECDSA签名安全性高度依赖于每次签名时唯一且不可预测的临时私钥 k。若 k 复用,攻击者可通过两组签名 (r, s₁)(r, s₂)(因 r = (kG).x mod n 相同)直接推导出长期私钥 d

d = (s₁⁻¹·(k·H(m₁) + r·d) − s₂⁻¹·(k·H(m₂) + r·d)) · r⁻¹ mod n
→ 简化得:d = (s₁⁻¹·H(m₁) − s₂⁻¹·H(m₂)) · (s₂⁻¹ − s₁⁻¹)⁻¹ mod n

Go 中复用 k 的危险实证

// ⚠️ 危险示例:手动固定 k(仅用于教学演示)
k := new(big.Int).SetUint64(12345) // ❌ 绝对禁止在生产中硬编码 k
priv := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
sig1 := signWithFixedK(priv, []byte("msg1"), k)
sig2 := signWithFixedK(priv, []byte("msg2"), k) // 同 k → r 相同

此处 signWithFixedK 绕过 crypto/ecdsa 默认随机源,强制复用 k。一旦 r 相同且消息哈希 H(m₁)≠H(m₂),私钥 d 可被代数求解。

攻击可行性对比表

条件 是否可恢复私钥 所需签名数量
k 完全相同,m₁ ≠ m₂ ✅ 是 2
k 偏移固定值(如 k₂ = k₁ + Δ ✅ 是(需解线性方程) 2
k 真随机(/dev/urandom) ❌ 否

核心防御机制

  • Go 的 crypto/ecdsa.Sign() 内部调用 rand.Read() 获取熵,绝不暴露 k 接口
  • 强制使用 crypto/rand(而非 math/rand),避免伪随机风险;
  • 所有标准库签名路径均通过 generateKey() 隔离临时密钥生命周期。

2.3 EVP_PKEY对象生命周期管理不当引发的密钥驻留与越界访问

EVP_PKEY 是 OpenSSL 中承载非对称密钥的核心结构,其内存生命周期若未与底层 BIGNUM/EC_KEY 等资源严格对齐,将导致双重释放或悬垂指针。

常见误用模式

  • 忘记调用 EVP_PKEY_free(),造成密钥长期驻留内存(尤其在 TLS 会话密钥复用场景);
  • EVP_PKEY_new() 后未检查返回值,直接解引用空指针;
  • 多线程中共享未加锁的 EVP_PKEY 对象并并发调用 EVP_PKEY_copy()

典型越界访问示例

EVP_PKEY *pkey = EVP_PKEY_new();
if (!pkey) return -1;
// ❌ 错误:未初始化即导出,可能读取未分配内存
unsigned char *buf = NULL;
int len = i2d_PrivateKey(pkey, &buf); // buf 可能为 NULL,len 为负

i2d_PrivateKey() 要求 pkey 已绑定有效密钥数据(如通过 EVP_PKEY_set1_RSA()),否则行为未定义,易触发越界读。

风险类型 触发条件 检测建议
密钥驻留 EVP_PKEY_free() 缺失 ASan + UBSan 内存扫描
悬垂指针访问 EVP_PKEY_free() 后继续使用 AddressSanitizer 报告
初始化不全访问 i2d_* 系列函数前置校验缺失 静态分析(Clang SA)
graph TD
    A[创建 EVP_PKEY] --> B{是否成功绑定密钥?}
    B -->|否| C[调用 i2d_* → 未定义行为]
    B -->|是| D[正确使用]
    C --> E[越界读/写/崩溃]

2.4 OpenSSL错误码忽略与ERR_get_error()未清空造成的状态污染

OpenSSL的错误队列是线程局部存储(TLS)中的栈式结构,每次调用如 SSL_connect() 失败后,错误码被压入队列,但不会自动清空

错误队列的“粘滞”特性

调用 ERR_get_error() 仅弹出队首错误,若未循环读取至返回 ,残留错误会污染后续操作:

// ❌ 危险:只取一次,残留错误未清空
if (SSL_do_handshake(ssl) <= 0) {
    unsigned long err = ERR_get_error(); // 仅取第一个
    fprintf(stderr, "Error: %s\n", ERR_error_string(err, NULL));
}
// 后续 SSL_read() 可能误报此前残留的握手错误!

逻辑分析ERR_get_error() 是“弹出并移除”,非“只读查看”。参数无输入,返回 表示队列为空;否则返回 unsigned long 错误码,需配合 ERR_reason_error_string() 解析。

正确清理模式

应循环调用直至返回

// ✅ 安全:清空整个错误队列
while ((err = ERR_get_error()) != 0) {
    log_ssl_error(err);
}
场景 是否清空队列 后果
单次 ERR_get_error() 下次调用可能复现旧错误
循环至 队列干净,状态隔离
graph TD
    A[SSL操作失败] --> B[错误压入线程本地队列]
    B --> C{调用ERR_get_error?}
    C -->|仅一次| D[残留错误滞留]
    C -->|循环至0| E[队列彻底清空]
    D --> F[后续ERR_get_error返回旧错误→误判]
    E --> G[错误上下文严格隔离]

2.5 C字符串处理中未校验NULL终止符导致的缓冲区溢出(Go传参场景)

当 Go 代码通过 C.CString() 向 C 函数传递字符串时,若原始 Go 字符串含 \x00 或被意外截断,C 层 strcpy/strlen 等函数将因缺失合法 NULL 终止符而越界读写。

典型危险调用

// C 函数(无长度参数,依赖 '\0')
void unsafe_copy(char *dst, char *src) {
    strcpy(dst, src); // 若 src 无 '\0',写入失控
}

src 来自 C.CString(s),但若 s[]byte{0x41, 0x00, 0x42} 转换后截断为 "A",后续 C 逻辑可能误读栈内存。

安全实践对照

方式 是否校验长度 是否防御 \0 中断 推荐度
C.CString() + strcpy ⚠️ 高危
C.CBytes() + memcpy(dst, src, len) ✅ 强烈推荐

防御性流程

graph TD
    A[Go string → []byte] --> B{含 \x00?}
    B -->|是| C[用 C.CBytes + 显式长度]
    B -->|否| D[可安全 C.CString]
    C --> E[C 层 memcpy with len]

第三章:Go语言层绑定与内存交互的关键脆弱点

3.1 CGO指针逃逸与GC绕过引发的密钥内存未及时擦除

CGO调用中,Go分配的密钥切片若经C.CBytes()转为*C.uchar并传入C函数,其底层内存将脱离Go运行时管理——指针逃逸至C堆,GC完全不可见

内存生命周期错位

  • Go对象被GC回收 ≠ C侧内存自动清零
  • C.free()常被遗忘或延迟调用
  • 密钥残留于物理内存,可被/dev/mem或core dump提取

典型危险模式

key := []byte("secret-32-bytes")
cKey := C.CBytes(key) // ❌ 逃逸:Go runtime失去所有权
defer C.free(cKey)    // ⚠️ 若panic发生,defer不执行
// ... 传递给C加密函数

此处C.CBytes()分配的是C堆内存(malloc),Go GC无法追踪;defer C.free()依赖执行流完整性,密钥驻留时间不可控。

安全替代方案对比

方式 GC可见 可擦除性 需手动free
unsafe.Slice + C.malloc ✅(需memset
runtime.Pinner(Go 1.22+) ✅(配合memclr
graph TD
    A[Go分配[]byte] --> B[C.CBytes → malloc]
    B --> C[指针逃逸至C堆]
    C --> D[GC忽略该内存]
    D --> E[密钥残留直至C.free或进程退出]

3.2 unsafe.Pointer转换中类型不匹配导致的BN结构体字段误读

BN(Big Number)结构体在 OpenSSL 中常以不透明指针形式暴露,Go 通过 unsafe.Pointer 桥接时易因底层内存布局差异引发字段误读。

字段偏移错位示例

type BN struct {
    d    *uint64 // 数据指针
    top  int     // 有效字长
    dmax int     // 分配容量
    neg  int     // 符号标志
    flags int
}
// 若C.BIGNUM实际布局为:[flags][top][d][neg][dmax](顺序/对齐不同),则Go结构体字段将全部错位

逻辑分析unsafe.Pointer 转换未校验目标类型的 ABI 兼容性;top 字段可能读到 flags 值,导致 BN_num_bits() 返回异常大值。参数 d 指针若被误读为 int,解引用将触发非法内存访问。

常见误读后果对比

现象 根本原因
top 值远超 dmax 字段顺序错位,读取了 flags 低字节
neg 恒为非零 符号位与 dmax 高位重叠

安全桥接建议

  • 使用 C.sizeof_struct_BIGNUM + reflect.StructField.Offset 校验字段偏移;
  • 优先采用 C.BN_bn2hex 等安全导出函数,避免直接内存映射。

3.3 Go slice与C数组双向传递时长度/容量不一致引发的越界读写

Go slice 通过 C.GoBytesunsafe.Slice 与 C 数组交互时,若忽略 lencap 的语义差异,极易触发内存越界。

数据同步机制

C 函数接收 *C.char 和显式长度 n,但 Go 侧若用 (*[1<<30]byte)(unsafe.Pointer(p))[:n:n] 构造 slice,cap 可能远超 C 分配的实际内存:

// 错误示例:cap 被设为 n,但底层 C 内存仅分配 n 字节
cBuf := C.CString("hello")
defer C.free(unsafe.Pointer(cBuf))
s := (*[5]byte)(unsafe.Pointer(cBuf))[:5:5] // cap=5 ✅,但若传入更大 cap 则危险

→ 此处 cap=5 安全;但若误写为 [:5:10],后续 append(s, 'x') 将向未分配内存写入,引发 SIGBUS。

关键差异对照

维度 Go slice C 数组
长度控制 len(s) 可安全访问 依赖传入 size_t n
容量边界 cap(s) 不受 C 约束 无对应概念,越界即 UB

安全实践要点

  • 始终以 C 侧实际分配长度作为 slice 的 lencap 上限
  • 使用 C.GoBytes(ptr, n) 复制而非共享内存,避免生命周期混淆
  • 在 CGO 函数签名中显式传递 size_t capacity 参数供 Go 侧校验

第四章:密钥全生命周期中的协同防护实践

4.1 使用Go runtime.SetFinalizer + CRYPTO_cleanup_all_ex_data实现密钥安全销毁

在CGO混合编程中,OpenSSL管理的密钥对象(如EVP_PKEY*)需在Go对象生命周期结束时确定性触发C层资源清理,避免内存泄漏与密钥残留。

Finalizer绑定与清理时机

func newSecureKey() *SecureKey {
    pkey := C.EVP_PKEY_new()
    sk := &SecureKey{pkey: pkey}
    // 绑定终结器:仅当sk被GC时调用cleanup
    runtime.SetFinalizer(sk, (*SecureKey).cleanup)
    return sk
}

runtime.SetFinalizer确保sk不可达后执行cleanup,但不保证调用时间,仅作为最后防线。

OpenSSL密钥数据清理关键步骤

  • CRYPTO_cleanup_all_ex_data() 清除所有全局ex_data槽位(含密钥敏感扩展数据)
  • 必须配合EVP_PKEY_free(pkey)释放底层结构体
  • 二者缺一不可,否则残留指针或未擦除的密钥副本
清理动作 是否必需 说明
EVP_PKEY_free(pkey) 释放主结构体及内部BIGNUM
CRYPTO_cleanup_all_ex_data() 擦除所有注册的密钥扩展数据
graph TD
    A[Go SecureKey对象不可达] --> B[runtime.SetFinalizer触发]
    B --> C[调用cleanup方法]
    C --> D[EVP_PKEY_free]
    C --> E[CRYPTO_cleanup_all_ex_data]
    D & E --> F[密钥内存彻底释放+扩展数据清零]

4.2 基于memguard或自定义locked memory allocator的密钥页锁定方案

密钥在内存中明文驻留是侧信道攻击的主要温床。memguard 提供零拷贝、不可分页(mlock + MAP_ANONYMOUS | MAP_NORESERVE)的隔离内存池,而自定义分配器可精细控制对齐、清零与释放时机。

核心机制对比

方案 内存锁定粒度 清零时机 兼容性
memguard 页面级 释放时自动擦除 Linux/macOS
自定义 locked allocator 缓冲区级 分配/释放双清零 全平台可控

memguard 初始化示例

// 创建 4KB 安全页,强制驻留物理内存且禁止 swap
pool, err := memguard.NewPool(4096)
if err != nil {
    log.Fatal(err) // 如因 RLIMIT_MEMLOCK 不足失败
}
keyBuf := pool.Alloc(32) // 分配32字节AES密钥空间
defer keyBuf.Free()      // 自动 mlock/munlock + memset_s

NewPool(4096) 调用 mmap(MAP_LOCKED) 并预设 PROT_READ|PROT_WRITEAlloc(32) 返回对齐至页面边界的指针,避免跨页泄露风险;Free() 触发恒定时间清零并解除锁定。

密钥生命周期流程

graph TD
    A[密钥生成] --> B[alloc into locked page]
    B --> C[恒定时间运算]
    C --> D[显式清零+unlock]
    D --> E[内存归还至池]

4.3 ECDSA签名流程中k值的真随机生成与跨CGO边界的熵源隔离设计

ECDSA签名安全性高度依赖于每次签名时k值的不可预测性与唯一性。若k复用或可推测,私钥将直接暴露。

熵源分层架构

  • 用户态:getrandom(2)系统调用(Linux 3.17+)提供内核CRNG输出
  • 内核态:/dev/random阻塞式熵池(已弃用),/dev/urandom非阻塞(推荐)
  • CGO边界:C代码无法直接访问Go runtime的crypto/rand,需显式桥接

跨CGO安全桥接示例

// go code: 安全生成k并传入C签名函数
func signWithK(msg []byte, priv *ecdsa.PrivateKey) ([]byte, error) {
    k := make([]byte, 32)
    if _, err := rand.Read(k); err != nil { // 使用Go标准库加密安全随机源
        return nil, err
    }
    // ⚠️ 注意:k需模n归约,且≠0
    kInt := new(big.Int).SetBytes(k)
    n := priv.Curve.Params().N
    kMod := new(big.Int).Mod(kInt, n)
    if kMod.Sign() == 0 {
        kMod.Add(kMod, big.NewInt(1)) // 防零值
    }
    return C.ecdsa_sign_raw(C.CBytes(msg), C.size_t(len(msg)),
        C.CBytes(kMod.Bytes()), C.size_t(kMod.Len())), nil
}

逻辑分析rand.Read()底层调用getrandom(2),避免用户态熵池耗尽;kMod.Bytes()输出为大端编码,长度≤32字节,需在C侧补零对齐;kMod.Sign()==0检查防止k ≡ 0 (mod n)导致签名失效。

k值生成安全约束对比

约束项 /dev/urandom rand.Seed(time.Now().Unix()) RDRAND指令
密码学安全 ✅(需验证)
CGO可直接调用 ✅(open/read) ❌(Go-only) ❌(需内联汇编)
内核熵依赖 弱(CRNG初始化后自维持) 强(硬件熵源)
graph TD
    A[Go应用调用 rand.Read] --> B[Go crypto/rand]
    B --> C{Linux内核 CRNG}
    C --> D[/dev/urandom syscall]
    D --> E[CGO边界]
    E --> F[C ecdsa_sign_raw]
    F --> G[使用kMod.Bytes()构造临时缓冲区]

4.4 OpenSSL 3.0+ Provider机制在Go绑定中的迁移路径与兼容性避坑指南

OpenSSL 3.0 引入 Provider 架构取代传统 ENGINE 接口,Go 的 crypto/tlsx509 包默认不感知 Provider,需通过 Cgo 封装显式加载。

Provider 加载示例(Cgo + OpenSSL 3.2)

// #include <openssl/provider.h>
// #include <openssl/err.h>
import "C"

func initProvider() {
    C.OSSL_PROVIDER_load(nil, C.CString("default"))
    C.OSSL_PROVIDER_load(nil, C.CString("legacy")) // 必须显式加载 legacy!
}

逻辑分析default 提供现代算法(AES-GCM、X25519),legacy 恢复 DES、MD4、SEED 等被移除算法;缺失 legacy 将导致 x509.ParseCertificate 解析含 SHA-1 签名证书失败。

兼容性关键差异

特性 OpenSSL 1.1.x OpenSSL 3.0+
算法注册方式 EVP_add_cipher() Provider 动态加载
默认启用的哈希算法 MD5, SHA1 仅 SHA2/SHA3(需 legacy)
FIPS 模式支持 外部模块 内置 fips Provider

常见陷阱清单

  • ❌ 直接调用 EVP_get_cipherbyname("des-cbc") 返回 nil(已移出 default provider)
  • ✅ 改用 EVP_CIPHER_fetch(NULL, "DES-CBC", "provider=legacy")
  • ⚠️ Go 中 tls.Config.CipherSuites 若含 TLS_RSA_WITH_DES_CBC_SHA,将静默降级为 TLS 1.0 不兼容套件
graph TD
    A[Go 应用] --> B[Cgo 调用 OpenSSL C API]
    B --> C{Provider 加载}
    C -->|default| D[SHA256, AES-GCM, X25519]
    C -->|legacy| E[SHA1, DES, RC4]
    D & E --> F[统一 EVP 接口调用]

第五章:未来演进与零信任加密架构展望

加密原语的量子韧性迁移路径

2023年NIST正式宣布CRYSTALS-Kyber(公钥封装)与CRYSTALS-Dilithium(数字签名)为首批后量子密码标准。某头部云服务商已在其全球边缘节点中完成Kyber-768的灰度部署,将TLS 1.3握手阶段的密钥交换模块替换为混合模式(X25519 + Kyber),实测性能损耗控制在8.3%以内。其核心策略并非“一刀切”替换,而是通过OpenSSL 3.2的provider机制动态加载算法,保留传统ECDH回退通道——该方案已在亚太区CDN集群中稳定运行超14个月,未触发一次协议降级。

零信任网络中的端到端加密拓扑重构

传统PKI体系在设备频繁接入/退出的IoT场景中暴露显著缺陷。某智能电网项目采用SPIFFE/SPIRE框架构建身份基础设施,所有终端(含电表、断路器固件)启动时通过TPM 2.0生成唯一密钥对,并由本地SPIRE Agent签发SVID证书。加密通信链路严格遵循“每个会话独立密钥”原则:

  • 控制指令使用AES-256-GCM(密钥由HKDF-SHA384派生)
  • 日志上传启用ChaCha20-Poly1305(适配ARM Cortex-M4低功耗芯片)
  • 所有密钥生命周期由HashiCorp Vault Transit Engine统一托管

硬件信任根的分布式协同验证

下表对比三种TEE实现方案在零信任架构中的实际表现:

方案 启动延迟 内存开销 支持加密算法 实际部署案例
Intel SGX v2 120ms 128MB AES-NI, RDRAND 金融实时风控平台
AMD SEV-SNP 85ms 64MB AES, SHA-256 医疗影像AI推理集群
ARM TrustZone+OP-TEE 45ms 32MB AES-GCM, ECDSA 工业PLC固件更新系统

某汽车制造商已将SEV-SNP与OP-TEE组合部署于车载域控制器:关键ECU固件更新包经SEV加密传输至中央网关,再由OP-TEE安全世界完成完整性校验与密钥解封,全程内存隔离,规避了传统OTA方案中BootROM漏洞利用风险。

flowchart LR
    A[终端设备] -->|SPIFFE ID认证| B(SPIRE Server)
    B --> C[签发SVID证书]
    C --> D[建立mTLS连接]
    D --> E[密钥协商:Kyber-768+X25519]
    E --> F[会话密钥派生:HKDF-SHA384]
    F --> G[应用层加密:AES-256-GCM]
    G --> H[密文直传至数据湖]

跨云环境的密钥联邦治理实践

某跨国零售企业整合AWS KMS、Azure Key Vault与阿里云KMS,构建跨云密钥联邦网。核心创新在于采用RFC 8555 ACME协议改造版:各云厂商KMS作为“CA”,为其他云环境签发短期(

机密计算与同态加密的融合接口

微软SCONE平台已支持在Intel TDX环境中运行Simple Encrypted Arithmetic Library(SEAL)的ARM64编译版本。某征信机构将用户脱敏后的信贷特征向量以CKKS方案加密,在TDX飞地内完成逻辑回归模型推理,输出结果仍为密文。整个过程内存占用峰值仅412MB,单次推理耗时1.7秒,满足监管要求的“原始数据不出域、模型参数不离线”双约束。

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

发表回复

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