Posted in

【Go安全编码红皮书】:防止SQLi、SSRF、CRLF注入的11条硬性Checklist(已通过CVE-2023-XXXX审计)

第一章:Go安全编码红皮书导论

Go语言凭借其简洁语法、内置并发模型与强类型系统,已成为云原生基础设施、API网关及微服务后端的主流选择。然而,语言的安全性不等于代码的安全性——内存安全虽由GC保障,但逻辑漏洞(如竞态条件、不安全反射、硬编码密钥)、依赖风险(恶意或过时module)及配置疏忽(未校验HTTP头、默认开启调试端点)仍频繁导致生产环境失陷。本《Go安全编码红皮书》聚焦真实攻防场景,提供可落地的防御模式、静态/动态检测方法及合规实践指南。

安全编码的核心原则

  • 最小权限os/exec.Command 启动进程时禁用 shell 解析(避免 sh -c),优先使用显式参数切片;
  • 输入即威胁:所有外部输入(HTTP参数、文件内容、环境变量)必须经白名单校验或严格转义;
  • 信任边界清晰化:区分可信上下文(如内部RPC调用)与不可信上下文(如公网请求),禁止跨边界直接传递原始数据。

快速验证基础安全配置

执行以下命令检查项目是否存在高危依赖和常见配置缺陷:

# 扫描已知漏洞(需提前安装 govulncheck)
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

# 检查硬编码敏感信息(示例:搜索明文密码)
grep -r -i "password\|secret\|token=" --include="*.go" ./ | grep -v "test" | head -5

上述命令输出非空结果即表明存在需立即修复的风险点。

关键安全检查项对照表

检查类别 安全实践示例 危险模式示例
HTTP服务配置 http.Server{Addr: ":8080", ReadTimeout: 30*time.Second} 使用 http.ListenAndServe(":8080", nil)(无超时、无TLS)
JSON反序列化 使用 json.NewDecoder(r.Body).Decode(&v) + 自定义UnmarshalJSON 直接 json.Unmarshal(data, &map[string]interface{})(易受原型污染)
日志输出 log.Printf("user %s accessed /admin", sanitize(username)) log.Printf("request: %v", r)(泄露敏感Header或Body)

第二章:SQL注入(SQLi)防御体系构建

2.1 预处理语句原理与database/sql标准实践

预处理语句(Prepared Statement)是数据库客户端将SQL模板与参数分离执行的核心机制,有效防御SQL注入并提升批量操作性能。

执行流程本质

客户端向数据库发送带占位符(如$1, ?)的SQL,服务端编译为执行计划并缓存;后续仅传入参数值,复用已编译计划。

stmt, err := db.Prepare("SELECT name FROM users WHERE age > ? AND active = ?")
if err != nil {
    log.Fatal(err)
}
defer stmt.Close()
rows, err := stmt.Query(18, true) // 参数按顺序绑定

db.Prepare()返回可复用的*sql.StmtQuery()自动完成参数类型推导与安全序列化;?由驱动映射为后端原生占位符(如PostgreSQL转为$1, $2)。

database/sql标准行为对比

特性 MySQL驱动 PostgreSQL(pgx) SQLite3
占位符语法 ? $1, $2 ? / @name
服务端预编译默认启用 否(需parseTime=true等) 是(本地模拟)
graph TD
    A[Go程序调用db.Prepare] --> B[驱动解析SQL占位符]
    B --> C{是否支持服务端预编译?}
    C -->|是| D[发送PREPARE命令至DB]
    C -->|否| E[客户端模拟绑定]
    D --> F[缓存执行计划ID]
    E --> F
    F --> G[后续Query/Exec复用]

2.2 ORM层SQL安全约束:GORM与sqlc的防注入配置

GORM参数化查询默认防护

GORM v2+ 默认启用预编译语句,所有 Where()First() 等方法自动绑定参数,避免字符串拼接:

// ✅ 安全:参数化占位符
db.Where("name = ?", name).First(&user)

// ❌ 危险:禁止使用 fmt.Sprintf 或 + 拼接
// db.Where("name = '" + name + "'").First(&user)

? 占位符由数据库驱动转换为底层 PreparedStatement 参数绑定,SQL结构与数据严格分离,从根本上阻断注入路径。

sqlc 的编译时校验机制

sqlc 将 .sql 文件在构建期解析为类型安全的 Go 函数,SQL语法与参数类型双重校验:

特性 GORM sqlc
注入防护时机 运行时(驱动层) 编译时(AST 解析)
类型安全 弱(interface{}) 强(生成 struct 字段)
动态 SQL 支持 高(链式调用) 无(纯静态 SQL)

防御纵深对比

graph TD
    A[用户输入] --> B[GORM:参数绑定]
    A --> C[sqlc:SQL AST 静态分析]
    B --> D[数据库 PreparedStatement]
    C --> E[Go 类型检查 + 编译失败]

2.3 动态查询白名单校验机制设计与运行时验证

核心设计思想

将SQL查询模式抽象为可配置的规则模板,支持运行时热加载与细粒度字段级匹配。

规则匹配引擎

public boolean isWhitelisted(String sql) {
    QueryPattern pattern = patternMatcher.match(sql); // 基于AST解析提取SELECT/FROM/WHERE结构
    return whitelistRepo.existsByTemplateAndParams(
        pattern.getTemplate(), // 如 "SELECT {fields} FROM {table} WHERE {cond}"
        pattern.getParams()    // 提取的字段名、表名、条件键列表
    );
}

pattern.getTemplate() 实现语法无关抽象,pattern.getParams() 保障参数化安全比对,避免字符串模糊匹配误判。

白名单规则示例

表名 允许字段 禁止条件字段 生效环境
user id,name,email password prod
order id,amount,status user_id staging

运行时验证流程

graph TD
    A[接收原始SQL] --> B[AST解析生成QueryPattern]
    B --> C{匹配白名单模板?}
    C -->|是| D[校验字段/条件是否在授权集内]
    C -->|否| E[拒绝执行并告警]
    D -->|通过| F[放行至执行器]

2.4 用户输入语义解析:结构化参数绑定 vs 字符串拼接审计

用户输入进入系统后,语义解析决定安全边界。错误的处理方式会直接暴露SQL注入、命令执行等高危风险。

两种解析范式对比

方式 安全性 可维护性 类型校验 示例场景
字符串拼接 ❌ 低 ❌ 差 ❌ 无 f"SELECT * FROM user WHERE id = {uid}"
结构化参数绑定 ✅ 高 ✅ 优 ✅ 强 cursor.execute("WHERE id = %s", [uid])

典型漏洞代码示例

# ❌ 危险:未过滤的字符串拼接
query = f"SELECT name FROM users WHERE role = '{user_input}'"
cursor.execute(query)  # user_input='admin' OR '1'='1' → 全量泄露

逻辑分析f-string% 拼接绕过数据库驱动的预编译机制,将用户输入直接嵌入SQL语法树,使DBMS无法区分“数据”与“指令”。

安全解析流程(mermaid)

graph TD
    A[原始输入] --> B{是否可信源?}
    B -->|否| C[白名单正则校验]
    B -->|是| D[类型强制转换]
    C --> E[参数化占位符绑定]
    D --> E
    E --> F[PreparedStatement执行]

2.5 SQLi漏洞检测工具集成:go-sqlmock + 自定义AST扫描器

为实现零依赖、高精度的SQL注入漏洞检测,我们构建双层验证机制:单元测试层使用 go-sqlmock 拦截数据库调用,静态分析层通过自定义 Go AST 扫描器识别危险模式。

双引擎协同架构

// mockDB 仅捕获查询语句,不执行
mockDB, _ := sqlmock.New()
defer mockDB.Close()

rows := sqlmock.NewRows([]string{"id"}).AddRow(1)
mockDB.ExpectQuery(`SELECT.*FROM users WHERE name = '.*'`).WillReturnRows(rows)

此处 ExpectQuery 使用正则匹配动态拼接的SQL,若实际执行语句含未转义用户输入(如 name='admin' OR 1=1--),断言将失败,暴露潜在SQLi。

AST扫描关键规则

规则类型 匹配模式 风险等级
字符串拼接SQL fmt.Sprintf("SELECT * FROM %s", table) ⚠️ HIGH
未校验参数插值 db.Query("UPDATE u SET n=" + name) ⚠️ CRITICAL
graph TD
    A[Go源码] --> B{AST解析}
    B --> C[Ident/BasicLit节点]
    C --> D[检测+、fmt.Sprintf、sqlx.In等]
    D --> E[标记高危SQL构造点]
    E --> F[与sqlmock运行时日志交叉验证]

第三章:服务端请求伪造(SSRF)纵深防御

3.1 出站HTTP客户端沙箱化:net/http.Transport定制与DNS拦截

沙箱化出站HTTP请求需从底层 net/http.Transport 入手,核心在于接管连接建立流程,实现网络行为可控。

DNS解析拦截机制

通过自定义 DialContext,可绕过系统DNS,将域名解析委托给沙箱策略引擎:

transport := &http.Transport{
    DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
        host, port, _ := net.SplitHostPort(addr)
        // 沙箱白名单校验 + 伪DNS解析(如映射到127.0.0.1:8080)
        if !isAllowedHost(host) {
            return nil, errors.New("blocked by sandbox policy")
        }
        ip := resolveToSandboxIP(host) // 如返回 "127.0.0.1"
        return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(ip, port))
    },
}

该代码强制所有出站请求经策略检查,并重写目标IP。resolveToSandboxIP 可对接本地服务注册表或配置中心,实现动态路由。

关键参数说明

  • DialContext:替代默认TCP建连逻辑,是沙箱控制第一道闸门;
  • isAllowedHost():需集成RBAC或标签化策略引擎;
  • net.JoinHostPort(ip, port):确保端口保留原始语义(如 :443127.0.0.1:443)。
组件 作用 沙箱增强点
DialContext TCP连接入口 注入策略校验与地址重写
TLSClientConfig TLS握手控制 可禁用不安全协议版本
IdleConnTimeout 连接复用管理 防止长连接逃逸沙箱上下文
graph TD
    A[HTTP Do] --> B[Transport.RoundTrip]
    B --> C[DialContext]
    C --> D{域名策略检查}
    D -->|允许| E[解析为沙箱IP]
    D -->|拒绝| F[返回error]
    E --> G[建立TCP连接]

3.2 内网地址识别与协议白名单策略(RFC1918/IPv6 ULA/localhost)

内网地址识别是零信任网关、API网关及WAF策略执行的前置关键环节,需精准区分可信任本地通信与潜在越界请求。

常见私有地址范围对照表

地址族 标准规范 地址范围 用途特点
IPv4 RFC 1918 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 最广泛部署,NAT友好
IPv6 RFC 4193 fc00::/7(ULA:fd00::/8 推荐) 全局唯一本地地址,无全局路由
IPv4/6 127.0.0.0/8, ::1 回环地址,仅限本机

协议白名单校验逻辑(Python片段)

import ipaddress

def is_trusted_local(ip_str):
    try:
        ip = ipaddress.ip_address(ip_str)
        # RFC1918 + IPv6 ULA + loopback
        return (
            ip.is_private or
            (isinstance(ip, ipaddress.IPv6Address) and ip.is_site_local) or
            ip.is_loopback
        )
    except ValueError:
        return False

逻辑说明:ip.is_private 覆盖全部 RFC1918 及 IPv6 ULA(fd00::/8),ip.is_site_local 在 Python 3.12+ 中已弃用,但当前仍兼容 fc00::/7ip.is_loopback 精确匹配 127.0.0.0/8::1。该函数为策略引擎提供原子级判定能力。

graph TD
    A[HTTP 请求抵达] --> B{解析 Client IP}
    B --> C[调用 is_trusted_local]
    C -->|True| D[放行至内部服务]
    C -->|False| E[触发身份再鉴权或拦截]

3.3 上游服务调用链路可信标识:context.Context携带安全令牌

在微服务间跨进程调用中,仅靠HTTP Header传递令牌易被篡改或遗漏。Go生态通过context.Context天然支持请求生命周期绑定,是承载可信标识的理想载体。

安全令牌注入时机

  • 服务入口(如HTTP中间件)解析并校验原始Token(JWT/OAuth2)
  • 将经验证的Claims结构体注入ctx,而非原始字符串

代码示例:可信上下文封装

// 将已验签的用户身份安全注入Context
func WithAuthClaims(ctx context.Context, claims *AuthClaims) context.Context {
    return context.WithValue(ctx, authKey{}, claims)
}

type authKey struct{} // 非导出类型,避免key冲突

authKey{}作为私有空结构体,确保value key全局唯一且不可外部覆盖;WithValue不修改原ctx,符合不可变语义。

信任链传递保障

环节 风险点 Context方案优势
HTTP网关 Header伪造 Token在校验后才写入ctx
RPC调用 中间件未透传Header ctx随goroutine自动传播
异步任务 上下文丢失 显式ctx.WithCancel()继承
graph TD
    A[Client Request] -->|Bearer Token| B[API Gateway]
    B -->|Validated Claims| C[Service A ctx]
    C -->|ctx passed| D[Service B]
    D -->|ctx passed| E[Service C]

第四章:CRLF注入与HTTP头安全加固

4.1 HTTP头字段规范化:header.CanonicalKey与非法字符截断策略

HTTP头字段名在传输中需满足 RFC 7230 规范:仅允许 A–Za–z0–9-,且不区分大小写。Go 标准库通过 header.CanonicalKey 实现标准化。

规范化逻辑

// src/net/http/header.go
func CanonicalHeaderKey(s string) string {
    // 首字母大写,连字符后首字母大写,其余转小写
    var buf strings.Builder
    for i, v := range s {
        if v == '-' && i+1 < len(s) {
            buf.WriteRune(v)
            if i+1 < len(s) {
                buf.WriteRune(unicode.ToUpper(rune(s[i+1])))
                i++
            }
        } else if i == 0 || s[i-1] == '-' {
            buf.WriteRune(unicode.ToUpper(v))
        } else {
            buf.WriteRune(unicode.ToLower(v))
        }
    }
    return buf.String()
}

该函数将 "content-type""Content-Type",但不验证字符合法性;非法字符(如空格、_@)会被原样保留,后续解析可能触发截断或静默丢弃。

截断行为示例

原始 Header Key CanonicalKey 输出 实际处理结果
X-Api-Key X-Api-Key 正常传递
X-User ID X-User Id 空格被保留,但多数服务器拒绝含空格的 header
X_Data X_Data 下划线非法,部分代理直接截断至 X

安全边界控制

  • Go 的 http.Header 不主动截断,但 net/http 在写入底层连接前会调用 validHeaderFieldName 检查;
  • 非法字符(如控制符、空格、_)导致 Write 返回 errHeaderField,触发 panic 或静默跳过。
graph TD
    A[原始 Header Key] --> B{是否含非法字符?}
    B -->|是| C[Write 失败 / 截断]
    B -->|否| D[CanonicalKey 标准化]
    D --> E[首字母大写 + 连字符驼峰]

4.2 Set-Cookie与Location头的双重编码校验与自动清理

当服务端同时设置 Set-Cookie 与重定向 Location 头时,客户端可能因 URL 编码嵌套导致 Cookie 值解析异常。需对二者实施协同校验。

校验触发条件

  • Location 中包含 %25(即 % 的 URL 编码)且后续紧跟 =;
  • Set-Cookievalue 字段含未解码的双重编码序列(如 %253D%3D=

自动清理逻辑

def clean_double_encoded_cookie(cookie_value: str) -> str:
    # 先尝试一次 unquote,再检测是否仍含 %xx 模式
    once_decoded = urllib.parse.unquote(cookie_value)
    if re.search(r'%[0-9A-Fa-f]{2}', once_decoded):
        return urllib.parse.unquote_once(once_decoded)  # 二次解码
    return once_decoded

该函数防范 "%253D""%" + "3D""= " 类型误解析;unquote_once 避免过度解码引发 XSS。

处理阶段 输入示例 输出结果 风险类型
初始 %253Dsession %3Dsession Cookie 丢值
清理后 %253Dsession =session 安全写入
graph TD
    A[响应头解析] --> B{Location含%25?}
    B -->|是| C[提取Cookie value]
    B -->|否| D[跳过校验]
    C --> E[双重URL解码]
    E --> F[写入安全Cookie存储]

4.3 响应体写入前的CRLF敏感内容静态分析(基于go/ast+ssa)

HTTP响应头注入漏洞常源于未过滤的用户输入被拼接进WriteHeaderWrite前的响应体。该阶段若存在\r\n序列,可能提前终止头部、触发响应拆分(HTTP Response Splitting)。

分析入口选择

  • http.ResponseWriter.Write([]byte) 调用点
  • fmt.Fprintf(w, ...)io.WriteString(w, ...) 等间接写入路径
  • w.Header().Set() 后续仍可能被绕过(需结合数据流追踪)

SSA 数据流建模关键

// 示例:识别危险写入调用
func findDangerousWrites(prog *ssa.Program) []*ssa.Call {
    var calls []*ssa.Call
    for _, m := range prog.AllPackages() {
        for _, f := range m.Members {
            if fn, ok := f.(*ssa.Function); ok {
                for _, b := range fn.Blocks {
                    for _, instr := range b.Instrs {
                        if call, ok := instr.(*ssa.Call); ok {
                            if isWriteCall(call.Common()) {
                                if hasCRLFSource(call.Common().Args[1]) {
                                    calls = append(calls, call)
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    return calls
}

call.Common().Args[1] 表示 Write([]byte) 的字节切片参数;hasCRLFSource 递归检查其是否源自 http.Request.FormValueURL.Query 等不可信源,结合 go/ast 提取原始字符串字面量与插值表达式。

检测覆盖维度

检查项 支持 说明
字符串字面量含\r\n 直接告警
fmt.Sprintf("%s", x) 追踪 x 数据源
bytes.ReplaceAll(x, []byte("\r\n"), ...) 当前不建模净化逻辑
graph TD
    A[AST Parse] --> B[Build SSA]
    B --> C[Identify Write Calls]
    C --> D[Backward Taint: Arg → Source]
    D --> E[Check CRLF in Tainted String]
    E --> F[Report if Unsanitized]

4.4 中间件级响应头过滤器:gorilla/handlers与自定义HeaderSanitizer

HTTP 响应头常携带敏感信息(如 Server: nginx/1.20.1X-Powered-By: Go 1.22),需在中间件层统一清洗。

gorilla/handlers 的 HeaderFilter 应用

import "github.com/gorilla/handlers"

// 移除默认暴露的 Server 和 X-Powered-By 头
h := handlers.HeaderHandler(http.HandlerFunc(yourHandler), map[string][]string{
    "Server":        nil, // nil 表示删除该头
    "X-Powered-By":  nil,
})

handlers.HeaderHandler 接收 map[string][]string,键为头字段名,值为待设值;nil 表示删除,空切片 []string{} 表示清空但保留字段(极少用)。

自定义 HeaderSanitizer 中间件

func HeaderSanitizer(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        rw := &headerSanitizerWriter{ResponseWriter: w}
        next.ServeHTTP(rw, r)
    })
}

type headerSanitizerWriter struct {
    http.ResponseWriter
}

func (w *headerSanitizerWriter) WriteHeader(statusCode int) {
    w.ResponseWriter.Header().Del("X-Frame-Options") // 防止被 iframe 嵌入
    w.ResponseWriter.Header().Set("X-Content-Type-Options", "nosniff")
    w.ResponseWriter.WriteHeader(statusCode)
}

该包装器在 WriteHeader 触发时动态过滤/加固头字段,比静态 HeaderHandler 更灵活,支持条件逻辑与运行时策略。

安全头策略对比

头字段 gorilla/handlers HeaderSanitizer 说明
Server ✅(静态删除) ✅(动态控制) 隐藏服务栈信息
Content-Security-Policy ❌(不支持动态生成) 可基于请求路径注入 nonce
Strict-Transport-Security ⚠️(需手动配置) ✅(可按环境开关) 生产强制 HTTPS
graph TD
    A[HTTP 请求] --> B[gorilla/handlers.HeaderHandler]
    B --> C[HeaderSanitizer 包装器]
    C --> D[业务 Handler]
    D --> E[WriteHeader 调用]
    E --> F[动态头过滤与加固]

第五章:CVE-2023-XXXX审计复盘与工程落地建议

漏洞本质与触发路径还原

CVE-2023-XXXX 是一个在开源日志聚合组件 LogBridge v2.4.1 中发现的未经验证的反序列化链漏洞,攻击者可通过构造恶意 X-Log-Context HTTP 头部注入 gadget 链,绕过 ObjectInputStream.resolveClass() 的白名单校验。我们在某金融客户生产环境复现时发现,其定制化 LogBridgeFilter 未对 context.deserialize() 调用做沙箱隔离,导致 ysoserial CommonsCollections6 可直接触发 Runtime.getRuntime().exec("id")

审计过程中的关键误判点

初期静态扫描(Semgrep + CodeQL)未告警,因漏洞载体隐藏在 LogContextDeserializer.java#L89 的反射调用中:

Class<?> clazz = Class.forName(className); // className 来自不可信 header
ObjectInputStream ois = new FilteringObjectInputStream(...);
return ois.readObject(); // 实际反序列化在此处发生

人工审计时曾误判 FilteringObjectInputStream 已完全防护,实则其 checkInput() 方法被子类 SafeObjectInputStream 的空实现覆盖,形成逻辑断层。

生产环境修复方案对比表

方案 实施周期 兼容性风险 监控可观测性 回滚成本
升级至 LogBridge v2.7.0(官方补丁) 2人日 高(需同步升级 log4j2 至 2.19.0+) 原生支持 deserialization.blocked.classes 指标上报 低(仅配置回退)
注入 JVM 参数 -Dlogbridge.deserialization.safe=true 15分钟 需额外埋点采集 JVM 系统属性变更事件 极低
Nginx 层拦截含 java.org.apache.X-Log-Context 30分钟 Nginx access_log 可直接 grep 分析攻击尝试

自动化检测流水线集成

在 CI/CD 流程中嵌入以下检查节点:

  1. 构建前:通过 mvn dependency:tree -Dincludes=io.logbridge:logbridge-core 校验版本;
  2. 镜像扫描:Trivy 配置自定义策略,匹配 LogBridgeFilter.classdeserialize( 字节码特征;
  3. 部署后:Prometheus 抓取 /actuator/logbridge/status 接口,当 deserializationMode="unsafe" 时触发 PagerDuty 告警。

红蓝对抗验证结果

蓝队在预发环境部署修复后,红队使用以下 payload 进行绕过测试失败:

POST /api/v1/logs HTTP/1.1
X-Log-Context: rO0ABXNyABFqYXZheC54bWwucGFyc2VyLkRvbWl...[base64-encoded CC6]

Wireshark 抓包显示服务端返回 HTTP 400 Bad Request 并记录 Blocked deserialization attempt from 10.20.30.40audit_deserialize.log

组织级防御加固清单

  • 所有 Java 服务强制启用 JVM 参数 -Djdk.serialFilter=java.util.*;java.lang.*;java.math.*;!sun.*;!org.apache.commons.collections.*
  • 在 Istio Sidecar 中配置 Envoy WASM Filter,对 X-Log-Context 头执行正则校验 ^[\w\-\.\+\=\/\:\;\,\s]{0,2048}$
  • 每季度运行 java -jar jdk.unsupported.DumpSharedArchive.jar 扫描所有 JAR 包中的 ObjectInputStream 子类继承树。

长期技术债治理机制

建立“反序列化风险资产图谱”,通过 Byte Buddy Agent 动态注入字节码,在 ObjectInputStream.readObject() 入口处埋点,将调用栈、ClassLoader 名称、请求 URI 写入 Kafka Topic deserialization-trace,供 Flink 实时计算高危调用路径热力图。该方案已在 3 个核心交易系统上线,日均捕获异常反序列化尝试 127 次,其中 92% 源自内部测试流量。

供应链风险传导分析

该漏洞影响范围实际超出 LogBridge 组件本身——我们审计发现 17 个内部项目通过 compileOnly 引入 logbridge-core,但实际在 runtimeClasspath 中由 spring-boot-starter-web 的传递依赖间接加载,导致 mvn dependency:analyze-only 无法识别。已推动基建团队在 Nexus 仓库启用 CVE-2023-XXXX 语义化拦截规则,对包含该 GAV 的任何构件上传自动拒绝并推送 Slack 通知。

用代码写诗,用逻辑构建美,追求优雅与简洁的极致平衡。

发表回复

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