Posted in

Go流量劫持攻防双视角(生产环境禁用但必须掌握的5类劫持模式)

第一章:Go流量劫持的本质与生产环境禁用的底层逻辑

Go语言中所谓“流量劫持”,并非标准术语,而是开发者对http.RoundTrippernet/http/httputil.ReverseProxynet.DialContext等底层网络行为的误称——本质是通过自定义传输层或代理逻辑,拦截、修改、重放或丢弃HTTP请求/响应流。其技术根源在于Go运行时对net.Conn接口的高度抽象与可替换性,使得任何实现该接口的结构体(如tls.Conn、自定义fakeConn)均可介入连接建立与数据读写阶段。

流量劫持的典型实现路径

  • 替换http.DefaultTransportRoundTripper,注入请求头、重写Host或URL;
  • 使用httputil.NewSingleHostReverseProxy并覆写Director函数,动态路由请求;
  • DialContext中插入TLS握手钩子(如tls.Config.GetClientCertificate),实现证书级中间人控制。

生产环境禁用的根本原因

风险类型 具体表现
TLS信任链破坏 自签名CA或证书固定绕过导致HTTPS降级,触发浏览器SEC_ERROR_UNKNOWN_ISSUER
连接复用失效 自定义RoundTripper未正确实现CloseIdleConnections,引发TIME_WAIT泛滥
上下文泄漏 context.Context未随请求透传至劫持逻辑,造成goroutine泄漏与超时失灵

禁用验证示例:检测非标准RoundTripper

// 检查默认传输是否被篡改(生产部署检查脚本)
func validateTransport() error {
    tr := http.DefaultTransport.(*http.Transport)
    if tr.TLSClientConfig != nil && len(tr.TLSClientConfig.Certificates) > 0 {
        return errors.New("custom TLS certificates detected: traffic hijacking risk")
    }
    if tr.DialContext == nil || reflect.ValueOf(tr.DialContext).IsNil() {
        return errors.New("nil DialContext: transport may be replaced illegally")
    }
    return nil
}

该函数应在应用启动时调用,返回非nil错误即触发panic,阻断异常初始化流程。任何未经审计的劫持逻辑都会破坏Go HTTP栈的并发安全模型与上下文生命周期管理,因此在生产环境中必须通过静态扫描(如go vet -shadow)、运行时校验及CI/CD流水线强制拦截。

第二章:HTTP层劫持——从Handler链到中间件注入

2.1 HTTP Handler注册机制与运行时动态替换原理

Go 的 http.ServeMux 采用键值映射实现路径到 Handler 的注册,核心是 map[string]muxEntry 结构。

注册过程本质

  • 调用 http.HandleFunc("/path", handler) 实际执行 DefaultServeMux.Handle("/path", HandlerFunc(handler))
  • 每个 muxEntry 封装 h Handlerpattern string

动态替换关键点

  • ServeMux 非线程安全,直接修改 map 会 panic
  • 安全替换需原子性:先 mu.Lock(),再更新 m[pattern],最后 mu.Unlock()
// 示例:运行时热替换 /api/v1/users 处理器
old := http.DefaultServeMux
newMux := http.NewServeMux()
newMux.HandleFunc("/api/v1/users", newUsersHandler) // 新逻辑
// 其余路由从 old 复制(省略复制逻辑)
http.DefaultServeMux = newMux // 原子指针替换

上述代码通过替换 DefaultServeMux 全局指针实现无中断切换,避免锁竞争,是生产级热更新的常用模式。

替换方式 线程安全 原子性 适用场景
直接修改 mux.map 禁止使用
全局指针替换 推荐(如上例)
使用 sync.Map 高频细粒度变更
graph TD
    A[注册 Handler] --> B[插入 mux.map]
    C[运行时替换] --> D[构建新 ServeMux]
    D --> E[原子替换全局指针]
    E --> F[新请求命中新 Handler]

2.2 基于http.ServeMux的路由劫持实战(含goroutine安全绕过)

http.ServeMux 默认仅支持精确匹配和前缀匹配,无法原生处理动态路径(如 /user/:id)。但可通过包装 ServeHTTP 方法实现路由劫持:

type HijackingMux struct {
    mux *http.ServeMux
    mu  sync.RWMutex // 保障并发安全
}
func (h *HijackingMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    h.mu.RLock()
    defer h.mu.RUnlock()
    // 动态重写 r.URL.Path 后委托原 mux 处理
    if strings.HasPrefix(r.URL.Path, "/api/v1/") {
        r.URL.Path = strings.Replace(r.URL.Path, "/api/v1/", "/v1/", 1)
    }
    h.mux.ServeHTTP(w, r)
}

该实现绕过 ServeMux 的 goroutine 不安全缺陷:所有路径改写均在只读锁保护下完成,避免 r.URL 跨协程竞态。

关键绕过策略对比

方案 是否修改 r.URL goroutine 安全 支持正则
直接修改 r.URL.Path(无锁)
包装 ServeHTTP + RWMutex ⚠️(需额外解析)
使用第三方路由器(如 chi

数据同步机制

劫持逻辑中 RWMutex 确保:

  • 读操作(路径判断/转发)高并发无阻塞;
  • 写操作(如热更新路由表)需独占 mu.Lock()

2.3 自定义RoundTripper实现客户端出向流量劫持

HTTP 客户端的 RoundTripper 是请求生命周期的核心接口,替换默认实现可无侵入式拦截、修改、记录或重定向所有出向请求。

核心原理

Go 的 http.Client 通过 Transport 字段(实现了 RoundTripper)发起底层连接。自定义实现需满足:

  • 实现 RoundTrip(*http.Request) (*http.Response, error) 方法
  • 可选择调用 http.DefaultTransport.RoundTrip() 委托原始逻辑

示例:带日志与 Host 替换的 RoundTripper

type LoggingRoundTripper struct {
    base http.RoundTripper
}

func (l *LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
    log.Printf("OUTGOING: %s %s", req.Method, req.URL.String())
    req.Host = "mock.example.com" // 劫持 Host 头
    return l.base.RoundTrip(req)  // 委托给默认传输层
}

逻辑分析req.Host 直接覆盖原始 Host(不影响 DNS 解析),l.base 保留复用连接、TLS 配置等能力;日志在请求发出前打印,确保可观测性。

典型劫持场景对比

场景 是否修改 URL 是否阻断请求 典型用途
请求头注入 认证透传、TraceID
域名映射(Host) 本地联调代理
Mock 响应拦截 单元测试桩
graph TD
    A[Client.Do] --> B[Custom RoundTripper.RoundTrip]
    B --> C{是否Mock?}
    C -->|是| D[返回伪造Response]
    C -->|否| E[委托DefaultTransport]
    E --> F[真实网络请求]

2.4 TLS握手阶段劫持:ClientHello拦截与SNI重写实践

TLS 握手起始于 ClientHello 消息,其中明文携带 Server Name Indication(SNI)扩展——这成为中间设备实施策略性劫持的关键锚点。

SNI 在 ClientHello 中的结构位置

  • 协议版本字段后紧随随机数、会话ID、密码套件列表
  • SNI 扩展位于 extensions 字段内,类型为 0x0000,长度可变
  • 关键限制:SNI 值未加密,且 TLS 1.3 仍保留该明文特性(RFC 8446 §4.2.1)

实践:基于 eBPF 的 ClientHello 拦截与重写

// bpf_prog.c:在 tcp_sendmsg 钩子中匹配 ClientHello 特征
if (buf[0] == 0x16 && buf[1] == 0x03 && buf[5] == 0x01) { // TLS handshake, type=1
    __u16 sni_offset = find_sni_offset(buf, size); // 解析 extensions 起始
    if (sni_offset > 0) {
        bpf_skb_store_bytes(skb, sni_offset + 5, "example.com", 11, 0); // 覆写域名
    }
}

逻辑分析buf[0]==0x16 标识 TLS 握手记录;buf[5]==0x01 确认消息类型为 ClientHellosni_offset + 5 跳过 SNI 扩展头(2字节类型 + 2字节长度 + 1字节名称长度),直接写入新域名。需确保目标缓冲区足够且无内存越界。

典型劫持场景对比

场景 是否依赖证书 SNI 可见性 TLS 1.3 兼容性
传统 HTTPS 代理 明文
ESNI/ECH(实验性) 加密 ❌(已弃用)
eBPF 层 SNI 重写 明文
graph TD
    A[客户端发出 ClientHello] --> B{eBPF 程序拦截 skb}
    B --> C[解析 TLS 记录头 & 扩展]
    C --> D{检测到 SNI 扩展?}
    D -->|是| E[定位域名字段并覆写]
    D -->|否| F[透传]
    E --> G[继续发送至服务端]

2.5 生产级HTTP劫持检测与反劫持防御策略(基于net/http/httputil深度审计)

HTTP劫持常表现为响应体篡改、Header注入或重定向劫持,需在服务端主动识别异常流量特征。

响应指纹校验机制

利用 httputil.DumpResponse 生成标准化响应快照,结合 SHA-256 校验原始内容完整性:

func fingerprintResp(resp *http.Response) string {
    dump, _ := httputil.DumpResponse(resp, false) // false: 不抓取 body
    return fmt.Sprintf("%x", sha256.Sum256(dump))
}

DumpResponse(..., false) 仅序列化状态行与 Header,规避流式 Body 读取副作用;校验值用于比对 CDN/中间网关返回的响应一致性。

关键防御维度对比

维度 被动检测 主动防御
Header 篡改 Content-Length 异常偏移 强制 Content-Security-Policy
重定向劫持 Location 域名校验失败 拦截非白名单跳转

流量路径审计流程

graph TD
    A[Client Request] --> B[Reverse Proxy]
    B --> C{Header/Body Hash Match?}
    C -->|No| D[Reject + Alert]
    C -->|Yes| E[Forward to Backend]

第三章:DNS与连接层劫持——Go原生net包的攻防边界

3.1 net.Resolver自定义实现与本地DNS缓存投毒实验

Go 标准库 net.Resolver 提供了可配置的 DNS 解析入口,支持自定义 DialContextPreferGo 策略,是实现本地 DNS 缓存与可控解析行为的核心扩展点。

自定义 Resolver 示例

resolver := &net.Resolver{
    PreferGo: true,
    Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
        // 强制走本地 stub resolver(如 127.0.0.1:53)
        return net.DialContext(ctx, "udp", "127.0.0.1:53")
    },
}

该代码绕过系统 getaddrinfo,将所有解析请求导向本地 UDP DNS 服务;PreferGo: true 启用 Go 原生解析器,便于拦截与注入响应。

缓存投毒关键路径

  • 修改 net.DefaultResolverLookupHost 方法代理
  • 在响应解析前注入伪造的 A 记录(如 "example.com" → "192.168.1.100"
  • 利用 TTL=0 强制每次查询均触发投毒逻辑
风险等级 触发条件 影响范围
应用未设 Timeout 全局 HTTP 客户端
PreferGo=false 仅 CGO 模式生效
graph TD
    A[应用调用 net.LookupIP] --> B[进入自定义 Resolver]
    B --> C{是否命中本地缓存?}
    C -->|是| D[返回投毒 IP]
    C -->|否| E[转发至本地 DNS 服务]
    E --> F[篡改响应包后写入缓存]

3.2 TCP连接建立前Hook:DialContext劫持与连接池污染分析

TCP连接建立前的干预点,核心在于 net/http.Transport.DialContext 字段的动态替换——它决定了底层 net.Conn 的创建行为。

DialContext 劫持原理

通过包装原始 DialContext 函数,注入自定义逻辑(如日志、超时控制、地址重写):

originalDial := http.DefaultTransport.(*http.Transport).DialContext
http.DefaultTransport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
    fmt.Printf("Dialing %s://%s\n", network, addr) // 日志钩子
    return originalDial(ctx, network, addr)
}

此处 ctx 携带超时/取消信号;addrhost:port 格式(如 "example.com:443"),是DNS解析后的真实目标。劫持后所有 http.Client 发起的连接均经过该函数。

连接池污染风险

若劫持逻辑中意外复用或缓存 net.Conn,将导致 http.Transport 连接池混入异常连接:

场景 表现 根本原因
静态返回同一 Conn 并发请求阻塞 违反 Conn 的单次使用契约
忽略 ctx.Done() 连接永不超时 未监听上下文取消信号
graph TD
    A[HTTP Client.Do] --> B[Transport.RoundTrip]
    B --> C{DialContext?}
    C -->|Yes| D[劫持函数]
    D --> E[创建新 Conn 或复用旧 Conn]
    E -->|错误复用| F[连接池污染]

3.3 UDP监听端口抢占与SO_REUSEPORT绕过劫持技术

UDP端口劫持常利用内核套接字绑定时的竞争窗口实现。当多个进程尝试绑定同一端口且未启用SO_REUSEPORT时,后绑定者将失败;但若首个进程在bind()后、recvfrom()前短暂挂起,攻击者可抢占该端口。

核心竞争点

  • bind()系统调用返回成功 ≠ 端口已进入接收就绪状态
  • 内核inet_bind_bucket分配与sk_add_node插入存在微秒级窗口

绕过SO_REUSEPORT的典型手法

// 攻击者进程:在目标进程bind后立即执行
int sock = socket(AF_INET, SOCK_DGRAM, 0);
int reuse = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
struct sockaddr_in addr = {.sin_family=AF_INET, .sin_port=htons(53), .sin_addr.s_addr=INADDR_ANY};
bind(sock, (struct sockaddr*)&addr, sizeof(addr)); // 可能成功(取决于内核版本与竞争时机)

此代码依赖SO_REUSEADDR在部分内核版本中对UDP的宽松语义——它允许重用处于TIME_WAIT外的本地地址,但现代内核(5.10+)已限制其对bind()冲突的绕过能力。关键参数:SO_REUSEADDR不等价于SO_REUSEPORT,前者不允许多个SOCK_DGRAM套接字同时监听同一<IP:Port>元组,除非显式启用后者。

内核版本 SO_REUSEADDR对UDP端口复用影响
可能成功抢占未完成初始化的端口
≥ 5.10 仅允许SO_REUSEPORT组内复用
graph TD
    A[目标进程调用bind] --> B[内核分配bind_bucket]
    B --> C[插入哈希表]
    C --> D[设置sk->sk_bound_dev_if等字段]
    D --> E[返回用户态]
    E --> F[目标进程尚未调用recvfrom]
    F --> G[攻击者bind触发竞争]
    G --> H{内核检查sk_hash冲突?}
    H -->|是,且无SO_REUSEPORT| I[bind失败]
    H -->|是,有SO_REUSEADDR但无SO_REUSEPORT| J[旧内核:可能成功]

第四章:gRPC与协议层劫持——序列化与传输协议的双重突破

4.1 gRPC Interceptor劫持:Unary与Stream拦截器的隐蔽植入

gRPC拦截器是服务治理的关键切面,Unary与Stream拦截器可于请求生命周期任意阶段注入逻辑,实现鉴权、日志、熔断等能力。

拦截器注册方式对比

类型 注册时机 支持链式调用 典型用途
Unary grpc.UnaryInterceptor 请求/响应预处理
Stream grpc.StreamInterceptor 流控、消息审计

Unary拦截器示例

func authUnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    token := grpc_auth.AuthFromMD(ctx, "bearer") // 从metadata提取token
    if !validateToken(token) {
        return nil, status.Error(codes.Unauthenticated, "invalid token")
    }
    return handler(ctx, req) // 继续调用原handler
}

该拦截器在服务端接收请求后、业务方法执行前触发;ctx携带完整元数据,req为反序列化后的请求体,handler为原始业务函数指针,返回值将透传至客户端。

Stream拦截器关键差异

func auditStreamInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
    log.Printf("stream started: %s", info.FullMethod)
    err := handler(srv, ss) // 注意:ss是包装后的流对象
    log.Printf("stream ended with error: %v", err)
    return err
}

Stream拦截需操作ServerStream接口(而非原始*grpc.ClientStream),其RecvMsg/SendMsg方法可被装饰以实现消息级审计。

4.2 Protocol Buffer编解码层Hook:自定义Unmarshaler实现字段级流量篡改

在 gRPC 生态中,proto.Unmarshaler 接口为编解码层提供了关键 Hook 点。通过实现自定义 Unmarshal 方法,可在字节流解析为结构体的瞬间劫持并修改任意字段。

数据同步机制

需确保篡改逻辑与业务语义一致,避免破坏协议兼容性。

实现要点

  • 覆盖 Unmarshal([]byte) error 方法
  • 先调用原生 proto.Unmarshal 解析基础结构
  • 再对目标字段(如 user_id, trace_id)做运行时注入或脱敏
func (m *Request) Unmarshal(data []byte) error {
    if err := proto.Unmarshal(data, m); err != nil {
        return err
    }
    // 字段级篡改:将测试环境 user_id 强制替换为灰度 ID
    if m.UserId == "test_123" {
        m.UserId = "gray_888"
    }
    return nil
}

逻辑分析:该实现复用标准解析流程,仅在解析后插入业务规则;data 是原始 wire 格式字节流,m 为已填充的 proto struct 实例;篡改发生在内存对象层面,不影响 wire 编码格式。

场景 是否影响序列化 是否可逆
修改 UserId
修改嵌套 Body 否(若未重写嵌套类型 Unmarshal)
graph TD
    A[原始PB字节流] --> B[调用自定义Unmarshal]
    B --> C[标准proto.Unmarshal]
    C --> D[字段级逻辑注入]
    D --> E[返回篡改后结构体]

4.3 HTTP/2帧级劫持:利用golang.org/x/net/http2直接操作FrameWriter

HTTP/2 的二进制帧模型为底层流量干预提供了精确控制粒度。golang.org/x/net/http2 提供的 FrameWriter 接口允许绕过标准 http.Server 抽象,直接构造并写入各类帧(DATA、HEADERS、RST_STREAM 等)。

帧写入核心流程

fw := http2.NewFramer(conn, conn)
err := fw.WriteHeaders(http2.HeadersFrameParam{
    StreamID: 1,
    EndHeaders: true,
    Headers: []hpack.HeaderField{{
        Name:  ":status",
        Value: "200",
    }},
})
  • conn 需为已升级的 TLS 连接(ALPN h2 协商完成)
  • StreamID=1 表示首条请求流;EndHeaders=true 触发 HPACK 解码终结
  • Headers 字段必须符合 RFC 7540 伪首部顺序约束,否则对端可能静默拒绝

关键帧类型对比

帧类型 可劫持场景 是否需 ACK
DATA 注入响应体片段
RST_STREAM 强制中断任意流
PRIORITY 动态篡改流权重
graph TD
A[建立TLS连接] --> B[ALPN协商h2]
B --> C[NewFramer初始化]
C --> D[WriteHeaders/WriteData]
D --> E[对端HTTP/2解析器接收]

4.4 TLS ALPN协商劫持:强制gRPC over HTTP/1.1降级与中间人构造

ALPN(Application-Layer Protocol Negotiation)是TLS握手阶段协商应用层协议的关键扩展。gRPC默认依赖h2(HTTP/2)ALPN标识,若攻击者在TLS ClientHello中篡改或清空ALPN extension,或在ServerHello中强制返回http/1.1,客户端可能回退至不安全的HTTP/1.1通道。

ALPN篡改关键点

  • 中间人需在TLS握手早期截获并重写ClientHello的extension_type = 16(ALPN)字段
  • 替换0x68 0x32h2)为0x68 0x74 0x74 0x70 0x2f 0x31 0x2e 0x31http/1.1

协议降级后果

  • gRPC流控、头部压缩、多路复用失效
  • 所有StatusTrailers被扁平化为HTTP/1.1响应头
  • 客户端grpc-status解析逻辑崩溃(因无Trailer支持)
# 模拟ALPN字段注入(仅示意,实际需在TLS层操作)
alpn_http11 = b"\x00\x08\x68\x74\x74\x70\x2f\x31\x2e\x31"
# len=8, proto="http/1.1"

此代码块模拟伪造ALPN extension payload。b"\x00\x08"为长度前缀(8字节),后续为ASCII编码的协议名。真实劫持需在TLS record解析/生成阶段介入,不可在应用层伪造。

攻击阶段 触发条件 gRPC行为
ClientHello ALPN移除 客户端未设fallback 连接失败(no application protocol)
ServerHello返回http/1.1 服务端未校验ALPN 客户端静默降级,请求被500拒绝或静默丢弃
graph TD
    A[ClientHello] -->|ALPN=h2| B[TLS Server]
    B -->|ServerHello ALPN=http/1.1| C[gRPC Client]
    C --> D[HTTP/1.1 POST /grpc.service.Method]
    D --> E[无stream frame, Trailers ignored]

第五章:总结与合规性警示——为何必须掌握却永不启用

核心矛盾的本质

在企业级渗透测试项目中,某金融客户要求对自研风控引擎进行红队评估。团队发现其API网关存在未授权SSRF漏洞,可调用内网Elasticsearch集群获取全量用户行为日志。此时,利用该漏洞直接读取敏感数据虽技术上可行,但立即触发《网络安全法》第27条及《数据安全法》第32条的合规红线——即使获得书面授权,未经脱敏处理的原始用户行为数据传输已构成违法风险。

合规性落地检查清单

  • ✅ 所有测试活动需附带客户签署的《专项授权书》,明确限定目标系统、时间窗口、数据留存方式
  • ❌ 禁止将生产环境数据库快照导出至本地工作站(2023年某券商因该操作被银保监会罚款280万元)
  • ⚠️ 敏感操作必须启用双人复核机制:一人执行命令,另一人实时审计/var/log/audit/audit.log中的SYSCALL事件

典型违规场景对比表

场景 技术可行性 合规状态 处罚案例
通过LDAP注入获取AD域控哈希 高(成功率92%) 违法(违反《个人信息保护法》第10条) 某政务云平台2022年被通报
利用JNDI注入触发远程类加载 中(需特定JDK版本) 违规(超出授权范围) 某电商平台遭工信部约谈
读取/tmp目录下临时JWT密钥文件 低(权限限制严格) 合法(授权范围内最小必要原则) 无处罚记录

实战决策树(Mermaid流程图)

flowchart TD
    A[发现高危漏洞] --> B{是否在授权范围内?}
    B -->|否| C[立即终止并书面报告]
    B -->|是| D{是否涉及原始个人信息?}
    D -->|是| E[启动PIA隐私影响评估]
    D -->|否| F[生成脱敏后PoC]
    E --> G[法务+数据安全官联合签字]
    F --> H[仅使用哈希化令牌验证]
    G --> H

2024年监管动态实录

国家网信办《生成式AI服务安全基本要求》明确要求:渗透测试中若触发AI模型训练数据回溯,必须同步启动《算法备案》补充流程。某智能投顾公司因在灰盒测试中意外调用用户历史交易向量生成对抗样本,导致算法备案失效,被迫暂停服务37天。

不可逾越的技术边界

当红队工具链检测到目标系统存在Kubernetes API Server未授权访问时,标准动作应为:

  1. 记录kubectl get nodes -v=6完整输出至加密审计日志
  2. 使用--dry-run=client参数验证权限范围
  3. 立即向客户安全团队发送CVE-2023-2728缓解方案(而非执行kubectl delete pod --all-namespaces

审计证据留存规范

所有测试过程必须生成三重证据链:

  • 时间戳水印视频(含系统时间、IP地址、操作命令)
  • script命令录制的ASCII会话日志(含PS1='[REDACTED]\$ '环境变量)
  • 区块链存证哈希值(调用公安部第三研究所eID链API)

企业级响应SOP

某央企在等保三级测评中遭遇勒索软件模拟攻击,其SOC平台自动触发预设规则:

  • 当检测到mimikatz.exe内存签名时,立即隔离主机并冻结Active Directory账户
  • 同步向网信办报送《网络安全事件应急处置单》(编号:CYBER-2024-0876)
  • 在2小时内完成MITRE ATT&CK T1558.001战术的溯源反制

法律后果量化分析

根据最高人民法院司法解释,未经授权访问计算机信息系统造成直接经济损失超5万元即构成犯罪。2023年某安全工程师因在客户测试环境中误删Redis缓存(恢复耗时12小时),被认定造成业务损失8.3万元,最终承担民事赔偿责任。

分享 Go 开发中的日常技巧与实用小工具。

发表回复

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