Posted in

Go对称加密安全红线(2024年CWE-310合规清单):5个被忽略的IV/Key/Mode致命错误

第一章:Go对称加密安全红线总览与CWE-310合规框架

Go语言标准库的crypto/aescrypto/cipher等包为对称加密提供了底层能力,但其原语级接口不自动规避常见密码学误用——这正是CWE-310(Cryptographic Issues)的核心关注点。开发者需主动承担密钥管理、模式选择、IV生成与绑定、填充处理等全链路安全责任。

常见高危实践与CWE-310映射

  • 使用ECB模式(无扩散性,明文结构暴露)
  • 硬编码静态密钥或从环境变量直接读取未加保护的密钥
  • 重用IV/nonce(尤其在CBC、GCM中导致密文可预测或认证失效)
  • 忽略GCM标签验证,或在解密后未校验cipher.AEAD.Seal返回的认证标签

安全密钥派生与IV生成规范

必须使用crypto/rand.Read生成强随机IV,并与密文一同序列化传输;密钥应通过PBKDF2或scrypt派生:

// 安全派生密钥示例(salt需唯一且存储)
salt := make([]byte, 32)
_, _ = rand.Read(salt) // 实际需检查错误
key := pbkdf2.Key([]byte("user-pass"), salt, 100000, 32, sha256.New)

// 安全IV生成(AES-GCM要求96位)
iv := make([]byte, 12)
_, _ = rand.Read(iv) // GCM推荐固定12字节IV

Go标准库合规配置速查表

组件 推荐方案 禁止方案
加密模式 cipher.NewGCM(aesBlock)(AEAD) cipher.NewCBCEncrypter
填充方式 GCM无需填充 手动实现PKCS#7填充
密钥来源 PBKDF2/scrypt派生 + 随机salt 字符串字面量或[]byte{}硬编码

所有对称操作必须强制执行认证加密(AEAD),优先选用GCM或XChaCha20-Poly1305;若必须使用非AEAD模式(如CBC),须额外集成HMAC并采用Encrypt-then-MAC流程,且密钥必须分离。

第二章:IV(初始化向量)滥用的五大致命陷阱

2.1 IV重复复用:理论危害与AES-CBC模式下的密文碰撞实证

在AES-CBC中,初始向量(IV)必须唯一且不可预测。若IV重复,相同明文块将生成相同密文块,破坏语义安全性。

密文碰撞原理

CBC加密公式:
$$C_i = E_K(Pi \oplus C{i-1})\quad\text{(其中 }C_0 = \text{IV)}$$
当IV重用且$P_1$相同 → $C_1$必然相同 → 后续链式传播虽不同,但首块已泄露等价关系。

实证代码片段

from Crypto.Cipher import AES
key = b"0123456789abcdef0123456789abcdef"
iv = b"0000000000000000"  # 固定IV —— 危险!
cipher = AES.new(key, AES.MODE_CBC, iv)
c1 = cipher.encrypt(b"HELLO WORLD     ")  # 补齐16字节
cipher = AES.new(key, AES.MODE_CBC, iv)  # 重用IV
c2 = cipher.encrypt(b"HELLO WORLD     ")
assert c1 == c2  # ✅ 必然成立

逻辑分析:两次加密使用相同key、相同iv、相同明文块(P_1),故C_1 = E_K(P_1 ⊕ IV)完全一致。参数iv为16字节静态值,直接导致确定性输出,违背CBC安全前提。

风险等级对照表

场景 可推断信息 攻击可行性
IV固定 + 明文前缀已知 首块明文等价性 ⚠️ 高
IV固定 + 多消息对比 识别重复会话/令牌 ⚠️⚠️ 中高
graph TD
    A[IV重用] --> B[相同P₁ ⇒ 相同C₁]
    B --> C[明文等价性泄露]
    C --> D[填充预言攻击入口]
    C --> E[流量模式分析]

2.2 IV硬编码风险:从Go源码审计到静态分析工具(gosec)检测实践

什么是IV硬编码?

在AES-CBC等对称加密场景中,初始化向量(IV)若被静态写死(如 []byte{0,1,2,...}),将导致密文可预测、重放攻击易发,严重削弱加密安全性。

Go中典型危险模式

func encryptBad(data, key []byte) ([]byte, error) {
    iv := []byte("1234567890123456") // ❌ 硬编码IV,永远不变
    block, _ := aes.NewCipher(key)
    mode := cipher.NewCBCEncrypter(block, iv)
    ciphertext := make([]byte, len(data))
    mode.CryptBlocks(ciphertext, data)
    return ciphertext, nil
}

逻辑分析iv 为字面量切片,每次调用均复用相同值;CryptBlocks 要求IV唯一且不可预测。参数 key 未校验长度(应为16/24/32字节),iv 长度固定16字节虽满足AES-CBC要求,但缺乏随机性。

gosec检测实践

运行 gosec ./... 可捕获该问题,对应规则 G401(Use of weak cryptographic primitive)与 G404(Weak random number generation)联动识别。

检测项 gosec规则 触发条件
硬编码IV字节切片 G101 字面量[]byte{...}用于cipher.New*
静态字符串转IV G101 []byte("static-iv")

自动化防护建议

  • 使用 crypto/rand.Read(iv) 生成安全随机IV;
  • 将IV与密文拼接传输(无需保密,但需验证完整性);
  • 在CI中集成 gosec -fmt=csv -out=gosec-report.csv ./... 实现门禁卡点。

2.3 IV长度不匹配:GCM模式下12字节强制要求与crypto/cipher接口适配错误

GCM(Galois/Counter Mode)在Go标准库 crypto/cipher 中严格要求IV(初始化向量)长度为12字节。非此长度将触发 cipher.NewGCM: IV length must be 12 bytes panic。

为何是12字节?

  • GCM内部使用CTR模式计数器,12字节IV + 4字节隐式计数器构成完整16字节块;
  • 长度偏离将破坏计数器对齐,导致认证失败或解密乱码。

常见误用示例:

block, _ := aes.NewCipher(key)
// ❌ 错误:16字节IV(如AES-CBC常用)传入GCM
iv := make([]byte, 16) // ← panic!
aesgcm, _ := cipher.NewGCM(block)

逻辑分析:cipher.NewGCM 内部直接校验 len(iv) != 12 并 panic;参数 iv 仅用于构造 NonceSize() 返回值及初始计数器布局,不参与密钥派生。

正确适配方式:

  • 使用 make([]byte, 12) 显式分配;
  • 或通过 rand.Read() 安全生成。
IV长度 GCM兼容性 后果
12 正常加解密与认证
8/16 panicnil GCM
graph TD
    A[调用 cipher.NewGCM] --> B{检查 IV 长度}
    B -->|==12| C[构建 GCM 实例]
    B -->|≠12| D[panic: IV length must be 12 bytes]

2.4 IV未绑定认证:在AEAD场景中剥离IV导致的完整性绕过攻击复现

AEAD(Authenticated Encryption with Associated Data)要求IV(Initialization Vector)必须参与认证计算,否则攻击者可重放/篡改IV而不触发验证失败。

攻击核心机理

当实现错误地将IV从auth_tag计算中排除(如仅对密文+AAD哈希),攻击者可:

  • 复用同一IV加密不同明文
  • 替换IV并保持密文不变,使解密后明文错乱但认证通过

复现实例(伪代码)

# ❌ 危险实现:IV未纳入认证输入
def encrypt_bad(key, iv, pt, aad):
    ct = aes_ctr_encrypt(key, iv, pt)
    # 错误:仅对ct+aad计算tag,忽略iv
    tag = hmac_sha256(key, ct + aad)  
    return iv + ct + tag

此处hmac_sha256输入缺失iv,导致IV变更无法被检测;攻击者可任意修改iv字段,解密逻辑仍接受该密文-标签对。

安全对比表

组件 正确AEAD(如AES-GCM) 错误实现
认证输入 IV + CT + AAD CT + AAD(缺IV)
IV重放后果 tag校验失败 tag校验仍通过
graph TD
    A[攻击者截获密文] --> B[提取原IV]
    B --> C[替换为恶意IV]
    C --> D[提交新IV+原CT+原Tag]
    D --> E[解密逻辑不校验IV一致性]
    E --> F[认证通过但明文被操控]

2.5 IV随机性失效:使用math/rand替代crypto/rand引发的熵池枯竭实战分析

在AES-CBC等模式中,IV必须具备密码学意义上的不可预测性。math/rand 仅依赖种子(如 time.Now().UnixNano()),而 crypto/rand 直接读取操作系统熵源(/dev/urandom)。

常见误用场景

// ❌ 危险:IV可被预测
r := rand.New(rand.NewSource(time.Now().UnixNano()))
iv := make([]byte, 16)
r.Read(iv) // math/rand.Read 不保证密码学安全!

math/rand.Read 是伪随机序列的线性递推,若攻击者获知两个IV,即可反推种子并预测全部后续IV。

熵池压力对比

随机源 熵来源 并发安全 适用场景
crypto/rand 内核熵池 IV、密钥、nonce
math/rand 时间种子+确定算法 模拟、测试、非安全上下文

正确实现

// ✅ 安全:从内核熵池读取
iv := make([]byte, 16)
if _, err := rand.Read(iv); err != nil {
    panic(err) // crypto/rand.Read 不会返回短读
}

crypto/rand.Read 底层调用 getrandom(2)(Linux)或 BCryptGenRandom(Windows),无种子依赖,抗并发耗尽。

graph TD A[生成IV] –> B{选择随机源} B –>|math/rand| C[种子泄露 → IV可预测] B –>|crypto/rand| D[OS熵池 → 密码学安全]

第三章:密钥(Key)生命周期管理失当

3.1 密钥硬编码与内存残留:Go字符串不可变性陷阱与unsafe.Slice擦除实践

Go 中 string 类型底层为只读字节序列(struct{ptr *byte, len int}),一旦创建便不可原地修改——这使敏感密钥无法安全擦除,极易在 GC 前残留于堆/栈内存。

字符串不可变性带来的风险

  • 编译期硬编码密钥(如 const key = "s3cr3t!")会常量化至 .rodata 段,进程生命周期内始终可被 stringsgdb 提取;
  • 运行时构造的 string 即使被变量覆盖,其底层 []byte 仍受 GC 延迟回收,存在内存转储泄露风险。

unsafe.Slice 实现零拷贝擦除

import "unsafe"

func wipeString(s string) {
    b := unsafe.Slice(unsafe.StringData(s), len(s))
    for i := range b {
        b[i] = 0 // 原地覆写底层字节
    }
}

逻辑分析unsafe.StringData(s) 获取字符串底层 *byte 起始地址;unsafe.Slice(ptr, len) 构造可写切片(绕过类型系统约束),实现对只读内存的强制覆写。⚠️ 注意:仅对 string[]byte 显式转换而来且未被编译器优化为常量时有效。

场景 是否可擦除 原因
string("abc") 常量字符串,只读段
string(b[:]) 动态分配,底层字节可写
fmt.Sprintf("%s", b) 否(通常) 编译器可能优化为常量池

3.2 密钥派生弱参数:PBKDF2迭代次数

⚠️ 注意:标题中提及的 PBKDF2crypto/scrypt 实际分属不同算法族——此处是典型配置混淆风险点,需明确分离处理。

问题根源

  • crypto/scrypt 不接受迭代次数(N)低于 1,但默认 N=32768 仍低于 NIST SP 800-63B 推荐的等效强度(≥100,000次 PBKDF2 ≈ N=65536, r=8, p=1);
  • 错误复用 PBKDF2 经验参数至 scrypt,导致实际熵拉伸不足。

合规参数对照表

算法 最小推荐参数 等效抗暴力强度
PBKDF2 iter = 600,000 (2023 OWASP) ≥100 ms CPU
scrypt N=2^16=65536, r=8, p=1 ≥100 ms + memory-hard

修正代码示例

// ✅ 合规 scrypt 参数:N=65536 (2^16), r=8, p=1 → 内存占用约 256MB,时长约 120ms
key, err := scrypt.Key([]byte(password), salt, 1<<16, 8, 1, 32)
if err != nil {
    log.Fatal("scrypt key derivation failed:", err)
}

逻辑分析1<<16 即 65536,满足 NIST 低/中保障等级要求;r=8 平衡内存带宽与并行性;p=1 防止 GPU 批量破解;32 输出长度匹配 AES-256 密钥需求。

安全演进路径

  • 原始风险:N=32768 → 仅 50–70ms,易被 ASIC 加速;
  • 合规跃迁:N=65536 → 强制双倍内存访问,显著提升攻击成本;
  • 未来建议:动态调优 Ntime.Now().Year() 相关值,实现时效性防御。

3.3 密钥重用跨算法:同一密钥混用AES-GCM与ChaCha20-Poly1305的侧信道泄露验证

密钥重用在不同认证加密算法间构成隐蔽威胁——AES-GCM 依赖 GHASH 的有限域乘法,而 ChaCha20-Poly1305 中的 Poly1305 使用模 $2^{130}-5$ 运算,二者虽共享“密钥派生”表象,但底层代数结构互不兼容。

实验触发路径

# 使用同一 256-bit 密钥 k 分别初始化两种构造
k = os.urandom(32)
cipher_gcm = AES.new(k, AES.MODE_GCM, nonce=b"n"*12)  # AES-GCM
cipher_chacha = ChaCha20_Poly1305.new(key=k, nonce=b"n"*12)  # ChaCha20-Poly1305

⚠️ 注意:ChaCha20-Poly1305.new() 实际将 key 直接用于 ChaCha20 密钥流生成,而 Poly1305 的 r 参数由该密钥经 PRF 派生;AES-GCM 则用 k 直接加密 GHASH 密钥。密钥语义错位导致侧信道(如缓存计时)可区分两类运算路径。

泄露向量对比

维度 AES-GCM ChaCha20-Poly1305
核心计算单元 AES round + GF(2¹²⁸) ARX + modular reduction
缓存访问模式 高度分支、查表密集 线性访存、无S-box
典型侧信道 L1D cache timing (T-table) Memory access stride leak
graph TD
    A[同一密钥k] --> B[AES-GCM: k→AES-encrypt→H]
    A --> C[ChaCha20-Poly1305: k→ChaCha20→r/s]
    B --> D[GHASH查表触发L1D冲突]
    C --> E[Poly1305模约减旁路功耗波动]

第四章:加密模式(Mode)选型与实现反模式

4.1 ECB模式禁用:Go标准库无ECB封装的深层原因与OpenSSL兼容性误用案例

为何Go标准库彻底移除ECB?

ECB(Electronic Codebook)模式因明文块到密文块的确定性映射,无法隐藏数据模式,被NIST SP 800-38A明确弃用。Go标准库自1.0起即未提供cipher.NewECBEncrypter——不是遗漏,而是主动拒绝封装已知不安全原语。

OpenSSL误用典型案例

开发者常误用OpenSSL命令生成ECB密文,并试图在Go中“兼容解密”:

# ❌ 危险示例:OpenSSL默认ECB(无IV,不推荐)
openssl enc -aes-128-ecb -in plain.txt -out cipher.bin -K "0123456789abcdef0123456789abcdef"

Go中强行模拟ECB的风险代码

// ⚠️ 非法且不安全的ECB“模拟”(仅用于演示为何被禁)
block, _ := aes.NewCipher([]byte("0123456789abcdef0123456789abcdef"))
src := make([]byte, 16)
dst := make([]byte, 16)
block.Encrypt(dst, src) // 无填充、无链式、无随机性

逻辑分析block.Encrypt仅执行单轮AES轮函数,不处理分组边界、PKCS#7填充或错误传播。参数src必须严格为16字节,否则panic;缺失密钥派生与认证机制,完全违背AEAD现代密码学范式。

安全替代方案对比

模式 是否需IV 抗重放 Go原生支持 推荐场景
ECB ❌(禁用) 禁止使用
CBC ✅(需手动填充) 遗留系统迁移
GCM ✅+认证 ✅(cipher.AEAD 默认首选
graph TD
    A[原始明文] --> B{分组对齐?}
    B -->|否| C[PKCS#7填充]
    B -->|是| D[直接分组]
    C --> D
    D --> E[AES-GCM加密]
    E --> F[密文+认证标签]

4.2 CBC模式无填充校验:PKCS#7填充篡改检测缺失与padding oracle攻击Go复现

CBC模式下,若服务端仅验证密文完整性而跳过PKCS#7填充有效性校验,攻击者可利用解密后末字节的反馈(如500 Internal Server Error vs 200 OK)实施Padding Oracle攻击。

攻击核心逻辑

  • 每次篡改前一个分组的最后一个字节,观察解密后明文末字节是否满足0x010x02 0x02等合法填充模式;
  • 利用Oracle响应差异逐字节恢复明文。

Go关键复现片段

// 模拟服务端填充校验缺失的解密函数
func decryptCBC(ciphertext []byte, key, iv []byte) ([]byte, error) {
    block, _ := aes.NewCipher(key)
    mode := cipher.NewCBCDecrypter(block, iv)
    plaintext := make([]byte, len(ciphertext))
    mode.CryptBlocks(plaintext, ciphertext)
    // ❗️关键缺陷:未调用 isPKCS7Padded(plaintext) 校验
    return plaintext, nil
}

该函数直接返回解密结果,不检查plaintext[len(plaintext)-1]是否等于填充长度,为Oracle提供了侧信道。

攻击阶段 观察目标 所需查询次数(每字节)
字节恢复 HTTP状态码/错误消息 ≤ 256
分组破解 整个16字节明文 16 × 256 = 4096
graph TD
    A[篡改C<sub>n-1</sub>末字节] --> B[解密得P'<sub>n</sub>]
    B --> C{P'<sub>n</sub>[15] == 0x01?}
    C -->|Yes| D[确认P<sub>n</sub>[15] = 0x01 ⊕ C'<sub>n-1</sub>[15] ⊕ C<sub>n-1</sub>[15]]
    C -->|No| A

4.3 GCM标签长度裁剪:将TagSize设为8字节导致的伪造成功率跃升至2^8级实验

GCM(Galois/Counter Mode)的安全强度高度依赖于认证标签(Authentication Tag)长度。当 TagSize 被显式裁剪为 8 字节(64 bit),理论伪造成功率从标准16字节下的 $2^{-128}$ 暴增至 $2^{-64}$ —— 实际攻击中可被暴力碰撞或选择明文策略逼近 $2^8$ 次尝试即命中。

标签裁剪的典型配置

// Java Bouncy Castle 示例:强制截断TagSize为8字节
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec spec = new GCMParameterSpec(64, iv); // ⚠️ 64-bit tag
cipher.init(Cipher.ENCRYPT_MODE, key, spec);
byte[] ciphertext = cipher.doFinal(plaintext);
byte[] fullTag = Arrays.copyOfRange(ciphertext, ciphertext.length - 16, ciphertext.length);
byte[] truncatedTag = Arrays.copyOf(fullTag, 8); // 实际仅校验前8字节

逻辑分析GCMParameterSpec(64, iv) 显式指定认证标签输出长度为64位(8字节),底层GCMBlockCipherprocessBytes()doFinal()中仅取MAC计算结果的高64位参与验证。参数64直接控制macSize字段,绕过默认128位安全基线。

伪造成功率对比表

TagSize(bit) 理论伪造概率 实际可行攻击规模
128 $2^{-128}$ 不可行
96 $2^{-96}$ 量子威胁边界
64 $2^{-64}$ ≈ $2^8$ 次在线验证即可高概率成功

攻击路径示意

graph TD
    A[攻击者获取合法密文+8字节Tag] --> B[构造篡改密文]
    B --> C{发起$2^8$次并行验证请求}
    C -->|约50%概率| D[一次Tag匹配通过]

4.4 CTR模式nonce重用:计数器溢出未防护引发的密钥流重用漏洞(含go test断言验证)

CTR模式依赖 (nonce, counter) 唯一性生成密钥流;当 nonce 重复且 counter 溢出(如 uint32 达到 0xFFFFFFFF 后回绕),将导致密钥流复用。

溢出复现逻辑

// 模拟32位计数器溢出
func nextCounter(c uint32) uint32 {
    return c + 1 // 无溢出检查:0xFFFFFFFF + 1 → 0x00000000
}

nextCounter(0xFFFFFFFF) 返回 ,使 (nonce, 0) 二次出现,密钥流块重复。

安全断言验证

func TestCTR_NonceReuse_LeadsToKeystreamRepeat(t *testing.T) {
    k := make([]byte, 32)
    n1 := []byte("same-nonce") // 重用nonce
    c1, c2 := NewCTR(k, n1), NewCTR(k, n1)
    block1 := c1.XOR([]byte{0,0,0,0}) // counter=0
    for i := 0; i < 0xFFFFFFFF; i++ { _ = c1.Next() } // 溢出至0
    block2 := c1.XOR([]byte{0,0,0,0}) // counter=0 再次
    if !bytes.Equal(block1, block2) {
        t.Fatal("expected keystream repeat after counter overflow")
    }
}
风险环节 后果
nonce重用 初始块密钥流相同
counter无溢出防护 回绕后密钥流完全复用

graph TD A[Nonce固定] –> B[Counter递增] B –> C{counter == max?} C –>|Yes| D[回绕为0] C –>|No| E[继续递增] D –> F[密钥流块重复]

第五章:2024年生产环境对称加密安全加固路线图

密钥生命周期自动化管理实践

2024年,头部金融客户在Kubernetes集群中全面替换AES-128硬编码密钥为HashiCorp Vault动态派发的AES-256密钥。通过Vault Agent Sidecar注入密钥,并结合KMS(AWS KMS + CloudHSM)实现密钥加密保护。所有密钥启用自动轮换策略(90天强制更新+72小时双活窗口),轮换过程由Argo CD监听Vault事件触发滚动重启,零停机完成服务密钥切换。日志审计显示,密钥分发延迟从平均3.2秒降至47ms,密钥泄露风险下降98.6%。

加密算法与模式合规性升级清单

组件类型 原配置 2024加固配置 合规依据
数据库字段加密 AES-128-CBC AES-256-GCM NIST SP 800-38D
API通信载荷 3DES-EDE ChaCha20-Poly1305 RFC 8439
日志脱敏存储 自定义XOR混淆 AES-256-SIV (RFC 5297) FIPS 140-3 Level 2

生产环境密钥使用基线检测脚本

以下Bash脚本嵌入CI/CD流水线,在镜像构建阶段扫描Java/Python服务二进制文件中的明文密钥特征:

find ./target -name "*.jar" -exec jar -tf {} \; | grep -E "(key|secret|cipher)" | grep -v "test\|mock"
grep -r "AES\.getInstance.*ECB\|Cipher\.getInstance.*NULL" src/main/java/ --include="*.java"

检测结果实时推送至SIEM平台,触发Jira自动创建高危漏洞工单,2024年Q1共拦截17个含ECB模式的遗留模块上线。

硬件安全模块集成拓扑

graph LR
A[应用Pod] -->|TLS 1.3 + mTLS| B(Vault Agent)
B -->|gRPC over TLS| C[AWS CloudHSM Cluster]
C --> D[Hardware Root of Trust]
D --> E[密钥生成/加解密硬件指令]
C -->|HSM SDK调用| F[Java Crypto Provider]
F --> G[Spring Boot @Encrypt注解]

某电商核心订单服务接入CloudHSM后,GCM模式加解密吞吐量达82K ops/sec(对比软件实现提升3.7倍),且满足PCI-DSS 4.1条款对密钥永不离开HSM边界的强制要求。

运行时密钥访问行为监控

部署eBPF探针捕获所有sys_read系统调用中涉及/proc/self/environ/proc/*/maps的读取行为,结合OpenTelemetry追踪密钥加载路径。2024年3月发现某Go微服务通过os.Getenv("AES_KEY")直接读取环境变量,立即触发熔断器隔离该实例并推送告警至PagerDuty。

敏感数据分级加密策略

  • PII类字段(身份证号、手机号):AES-256-GCM + 每字段独立密钥(Key Per Field)
  • PCI卡号:AES-256-SIV + PAN哈希索引加速查询
  • 日志元数据:ChaCha20-Poly1305 + 时间戳绑定Nonce防止重放

某医疗SaaS平台实施该策略后,HIPAA审计中“加密粒度不足”缺陷项清零,字段级密钥泄露影响范围收缩至单条记录级别。

加密性能压测基准对比

在4核8GB容器环境下,对1MB JSON负载执行10万次加解密操作:

  • OpenSSL 3.0软件实现:平均延迟 12.4ms,CPU占用率 89%
  • Intel QAT加速卡驱动:平均延迟 1.8ms,CPU占用率 23%
  • AWS Nitro Enclaves内加密:平均延迟 3.1ms,内存隔离强度达SGX-Like

生产集群已批量部署QAT设备,API网关层加解密延迟降低至亚毫秒级。

不张扬,只专注写好每一行 Go 代码。

发表回复

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