Posted in

【私密披露】某金融系统Go上传模块被利用导致RCE全过程:从Content-Disposition解析缺陷到shellcode注入链

第一章:Go上传模块安全漏洞的全景概览

Go语言因其并发模型与静态编译特性被广泛用于构建高吞吐API服务,其中文件上传功能常通过r.ParseMultipartForm()配合r.FormFile()实现。然而,这一看似简洁的流程在生产环境中暴露出多维度安全风险:未经约束的内存分配、路径遍历、恶意文件类型绕过、以及临时文件残留等,均可能被攻击者利用触发RCE、DoS或横向渗透。

常见漏洞类型与成因

  • 未限制上传大小导致内存耗尽:ParseMultipartForm(0)启用无限解析,攻击者发送超大multipart/form-data可迅速耗尽服务器内存;
  • 文件名未净化引发路径遍历:直接拼接filepath.Join(uploadDir, filename)且未校验filename(如../../../etc/passwd),造成任意文件写入;
  • Content-Type绕过执行恶意代码:仅依赖HTTP头Content-Type判断文件类型,忽略实际文件魔数(magic bytes),导致.jpg.php类双扩展名文件被执行;
  • 临时文件未清理引发磁盘占满:FormFile生成的临时文件若未显式os.Remove(),在异常退出或高并发下持续累积。

典型危险代码示例

func uploadHandler(w http.ResponseWriter, r *http.Request) {
    r.ParseMultipartForm(0) // ⚠️ 无大小限制,极易OOM
    file, _, err := r.FormFile("file")
    if err != nil { panic(err) }
    defer file.Close()

    // ⚠️ 未校验filename,存在路径遍历风险
    dst, _ := os.Create("/uploads/" + r.FormValue("filename"))
    io.Copy(dst, file) // 直接写入,无类型/内容检查
}

安全加固关键实践

  • 设置硬性内存与磁盘配额:r.ParseMultipartForm(32 << 20)(32MB上限);
  • 使用filepath.Base()剥离路径组件,并白名单校验扩展名;
  • 读取文件前512字节校验魔数(如net/http.DetectContentType);
  • 上传后立即重命名文件为UUID+安全扩展名,并设置0600权限;
  • 在defer中确保临时文件清理,或使用io.Pipe流式处理避免落盘。
风险点 推荐检测方式 修复优先级
无大小限制 检查ParseMultipartForm参数是否为0 高
路径遍历 strings.Contains(filename, "..") 高
类型伪造 file.Header.ContentType vs 实际魔数 中
临时文件残留 lsof -p <PID> \| grep /tmp 中

第二章:Go标准库与第三方库文件上传机制深度剖析

2.1 net/http中multipart/form-data解析流程源码级逆向

net/http 对 multipart/form-data 的解析始于 Request.ParseMultipartForm(),其核心委托给 mime/multipart.Reader。

解析入口与边界提取

func (r *Request) ParseMultipartForm(maxMemory int64) error {
    r.multipartForm = nil
    r.form = make(url.Values)
    r.multiparts = make(map[string][]*multipart.Part)
    mr, err := r.MultipartReader() // ← 构建 multipart.Reader,从 Header 中提取 boundary
    if err != nil { return err }
    // ...
}

MultipartReader() 调用 mime/multipart.NewReader(r.Body, boundary),其中 boundary 来自 Content-Type: multipart/form-data; boundary=----xyz。该边界字符串被严格校验并用于分割 part。

Part 解析状态机

graph TD
    A[Read boundary line] --> B{Valid?}
    B -->|Yes| C[Parse headers]
    B -->|No| D[EOF or error]
    C --> E[Stream body until next boundary]
    E --> A

关键字段映射表

Header 字段 Go 结构体字段 用途
Content-Disposition Part.Header 提取 name, filename
Content-Type Part.Header 决定 io.Reader 类型
Content-Transfer-Encoding 忽略(HTTP 不使用) 兼容性保留字段

2.2 mime/multipart.Reader边界处理缺陷的实证复现与PoC构造

复现环境准备

  • Go 版本:1.21.0(含 mime/multipart v1.21.0)
  • 关键触发条件:boundary 字符串末尾缺失换行符,且紧邻 -- 终止标记

PoC 构造核心逻辑

// 构造非法 boundary:末尾无 \r\n,且后续紧跟 "--"
body := []byte(
    "--boundaryX\r\n" +      // 正常分隔头
    "Content-Disposition: form-data\r\n\r\n" +
    "test\r\n" +
    "--boundaryX" +          // ❌ 缺少 \r\n,Reader 误判为新 part 起始
    "--" +                    // 实际应为终止标记,但被吞并解析
)
r := multipart.NewReader(bytes.NewReader(body), "boundaryX")

逻辑分析:multipart.Reader 在 readLine() 中依赖 \r\n 切分 boundary 行;当 --boundaryX-- 被拆分为 --boundaryX(误作 part header)+ --(孤立字符串),导致后续 NextPart() 返回 nil, io.ErrUnexpectedEOF,但内部状态已错位,引发内存越界读或 panic。

关键触发路径

输入形态 Reader 行为 后果
--boundary\r\n 正常识别分隔符 ✅ 安全
--boundary-- 识别为终止单元 ✅ 安全
--boundary--(无换行) 将 --boundary-- 拆解为 --boundary + -- ❌ 状态错乱
graph TD
    A[readLine] --> B{ends with \\r\\n?}
    B -->|No| C[consume until \\n or EOF]
    C --> D[误截断 boundary]
    D --> E[NextPart 解析失败 + state corruption]

2.3 Content-Disposition字段解析逻辑中的Unicode归一化绕过实践

Unicode归一化与Content-Disposition的冲突点

HTTP响应头中Content-Disposition: attachment; filename="…"若含非ASCII字符,服务端常依赖NFKC归一化校验。但攻击者可混用兼容等价字符(如U+200C零宽非连接符)绕过白名单过滤。

典型绕过Payload构造

# 构造含ZWNJ的filename参数(绕过NFKC归一化检测)
payload = 'file\u200c.txt'  # U+200C未被NFKC标准化为普通空格
# 服务端normalize('NFKC', payload) → 'file\u200c.txt'(未变化)
# 但浏览器解析时忽略ZWNJ,实际保存为'file.txt'

该payload利用NFKC不移除控制字符的特性,使服务端校验与客户端渲染产生语义分歧。

绕过路径对比表

归一化方式 输入 file\u200c.txt 输出 是否触发绕过
NFKC file\u200c.txt file\u200c.txt ✅
NFC file\u200c.txt file\u200c.txt ✅

防御建议

  • 强制使用NFKD + 过滤控制字符(unicodedata.category(c) == 'Cf')
  • 对filename*参数优先采用RFC 5987编码而非直接归一化
graph TD
    A[原始Content-Disposition] --> B{是否含U+200C/U+200B?}
    B -->|是| C[服务端NFKC不变]
    B -->|否| D[正常归一化]
    C --> E[浏览器忽略ZWNJ→文件名污染]

2.4 Go 1.19+中filepath.Clean路径规范化失效场景验证

Go 1.19 引入了对 filepath.Clean 的优化,但其在跨文件系统挂载点或符号链接深度嵌套时可能返回非最简路径。

失效典型场景

  • 挂载点内含 .. 与 symlink 交错(如 /mnt/app/../real/bin → /mnt/real/bin,但实际应归一为 /opt/bin)
  • Windows 驱动器前缀混用(C:\foo\..\bar 与 \\?\C:\foo\..\bar 行为不一致)

复现代码示例

package main

import (
    "fmt"
    "path/filepath"
)

func main() {
    p := "/a/b/c/../../d/e/./f"
    fmt.Println(filepath.Clean(p)) // 输出: /a/d/e/f —— 正确
    p2 := "/mnt/link/../target/file" // link → /opt/base, target → /opt/base/target
    fmt.Println(filepath.Clean(p2)) // 输出: /mnt/target/file —— ❌ 未解析 symlink
}

filepath.Clean 仅做字符串规约,不调用 os.Stat 或 filepath.EvalSymlinks,因此无法感知真实文件系统拓扑。参数 p 为纯字符串,不携带上下文元数据。

对比行为差异(Go 1.18 vs 1.22)

输入路径 Go 1.18 输出 Go 1.22 输出 是否解析 symlink
/var/log/../www/index.html /var/www/index.html /var/www/index.html 否
/mnt/symlink/../real/file /mnt/real/file /mnt/real/file 否
graph TD
    A[输入路径] --> B{含 symlink?}
    B -->|否| C[纯字符串规约]
    B -->|是| D[返回未解析路径]
    C --> E[符合 POSIX 纯文本规则]
    D --> F[需显式调用 EvalSymlinks]

2.5 基于AST静态分析识别高危UploadHandler代码模式

核心风险模式识别原理

AST(Abstract Syntax Tree)可精准捕获 UploadHandler 类中未经校验的文件写入行为,绕过运行时沙箱限制。

典型危险代码片段

public void handleUpload(HttpServletRequest req) {
    String fileName = req.getParameter("filename"); // ❌ 未过滤路径遍历字符
    String contentType = req.getContentType();       // ❌ 未校验MIME类型
    File target = new File("/uploads/" + fileName);  // ⚠️ 直接拼接构造路径
    writeToFile(req.getInputStream(), target);       // ❌ 无扩展名白名单校验
}

逻辑分析:该方法在AST中表现为 BinaryExpression(+ 拼接)→ MemberExpression(req.getParameter)→ CallExpression(writeToFile),三者形成「输入→拼接→落地」高危链;fileName 参数未经过 Path.of().normalize() 或正则校验,易触发 ../webshell.jsp 路径穿越。

静态检测规则矩阵

AST节点类型 危险特征 检出置信度
MemberExpression .getParameter("filename") 高
BinaryExpression + 连接用户输入与固定路径 高
CallExpression new File(...) 或 FileWriter 中高

检测流程图

graph TD
    A[解析Java源码为AST] --> B{是否存在getParameter调用?}
    B -->|是| C[提取参数名并标记为污点源]
    C --> D[追踪污点是否流入File构造/IO写入]
    D -->|是| E[触发高危UploadHandler告警]

第三章:从解析缺陷到RCE的链式利用路径建模

3.1 构造恶意filename参数触发目录遍历与任意文件写入

漏洞成因分析

当服务端未校验 filename 参数,直接拼接路径并调用 fs.writeFile() 时,攻击者可利用 ../ 绕过限制。

典型恶意载荷示例

// 攻击请求中 filename 参数值
"../../../../etc/passwd"

该路径经拼接后变为 /var/www/uploads/../../../../etc/passwd,若未规范化即执行写入,将覆盖系统关键文件。

关键防御缺失点

  • 未调用 path.normalize() 或 path.resolve() 校验路径合法性
  • 未白名单限制文件扩展名(如仅允许 .jpg, .png)
  • 未验证最终绝对路径是否位于预期根目录内

安全路径校验逻辑

const safePath = path.join(uploadDir, filename);
if (!safePath.startsWith(uploadDir)) {
  throw new Error('Directory traversal attempt detected');
}

uploadDir 是预设的安全根目录(如 /tmp/uploads),startsWith 确保结果路径不逃逸。

常见危险组合(payload vs 后果)

filename 参数 实际写入路径 风险等级
../../config.json /app/config.json ⚠️ 高
shell.php /var/www/shell.php ⚠️ 中
/.ssh/authorized_keys /root/.ssh/authorized_keys 🔥 严重
graph TD
  A[客户端传入filename] --> B{服务端解析路径}
  B --> C[未规范化直接拼接]
  C --> D[写入任意位置]
  B --> E[调用path.resolve校验]
  E --> F[拒绝越界路径]
  F --> G[安全写入]

3.2 利用Go模板引擎动态编译特性实现Go代码注入

Go 的 text/template 与 html/template 本身不执行 Go 代码,但结合 go/parser + go/types + go/ast 可在运行时解析、修改并编译模板中嵌入的合法 Go 表达式片段。

模板中嵌入可编译表达式

const tmplStr = `package main
import "fmt"
func Generate() string {
    return fmt.Sprintf("Hello, {{.Name}}!") // 注意:此处 .Name 是模板变量,非注入点
}`

该模板本身安全;注入需在 AST 层面将 {{.Name}} 替换为经校验的 strconv.Itoa(42) 等表达式节点,再通过 golang.org/x/tools/go/packages 动态编译。

安全边界控制策略

  • ✅ 允许:字面量、标准库函数调用(time.Now())、简单结构体字段访问
  • ❌ 禁止:os/exec, unsafe, 方法调用链超过2层,未白名单导入
风险类型 检测手段 响应动作
外部包引用 AST ImportSpec 扫描 拒绝编译
反射或系统调用 CallExpr 函数名匹配 替换为 panic stub

编译流程示意

graph TD
    A[模板字符串] --> B[Parse AST]
    B --> C[AST 节点校验与重写]
    C --> D[生成临时 .go 文件]
    D --> E[调用 go build -o /tmp/f.so -buildmode=plugin]
    E --> F[Load plugin 并调用 Symbol]

3.3 shellcode嵌入Go二进制文件头并劫持runtime.init执行流

Go程序启动时,runtime.init函数链在main.main前被调用,其地址由.init_array节或ELF重定位项控制。攻击者可利用此机制,在二进制头部预留空间注入shellcode,并篡改_rt0_amd64_linux入口跳转逻辑。

嵌入位置选择

  • ELF头部后、.text节前的padding区域(需确保p_vaddr == p_paddr且页对齐)
  • 利用go:linkname导出runtime·addmoduledata等符号,绕过符号隐藏限制

修改init执行流的关键步骤

  1. 定位.init_array中首个函数指针(通常为runtime.main注册点)
  2. 将其覆写为指向嵌入shellcode的VA(需计算ASLR偏移)
  3. shellcode末尾需调用原init函数以维持程序逻辑
// shellcode示例(x86_64,位置无关)
mov rax, 0x15      // sys_mprotect
mov rdi, 0x400000  // target page (RIP-relative calc needed)
mov rsi, 0x1000
mov rdx, 7         // PROT_READ|PROT_WRITE|PROT_EXEC
syscall
jmp qword ptr [rip + 0x10]  // 跳转至原init函数

此汇编片段首先解除目标页内存保护,再跳转至原始init入口。rip + 0x10需在构建时动态填充真实地址,依赖objdump -d解析重定位表。

技术环节 关键约束 触发时机
shellcode注入 必须位于可执行段且页对齐 go build -ldflags="-s -w"后手动patch
init劫持点 .init_array或__libc_start_main参数 _rt0_amd64_linux返回前
地址计算 需结合readelf -l获取基址偏移 运行时通过/proc/self/maps校准

graph TD A[ELF加载] –> B[解析.init_array] B –> C[覆盖首项为shellcode地址] C –> D[shellcode执行:mprotect+跳转] D –> E[调用原init函数] E –> F[继续Go runtime初始化]

第四章:金融级上传模块加固方案与工程落地

4.1 基于WebAssembly沙箱的上传文件内容动态行为检测

传统服务端静态扫描难以捕获混淆脚本、延迟执行或环境感知型恶意载荷。WebAssembly(Wasm)沙箱提供确定性、隔离性与近原生性能,成为动态行为检测的理想执行环境。

核心检测流程

(module
  (func $analyze_entry (param $buf i32) (result i32)
    local.get $buf
    i32.load        ;; 加载文件首块(4字节)
    i32.const 0x4D5A ;; PE签名 "MZ"
    i32.eq
    if (result i32) i32.const 1 else i32.const 0 end)
  (export "analyze" (func $analyze_entry)))

该Wasm函数接收内存地址参数 $buf,读取首4字节并比对PE文件魔数 0x4D5A;返回 1 表示疑似可执行文件。沙箱内无系统调用能力,杜绝侧信道泄露。

检测能力对比

能力维度 静态扫描 Wasm沙箱动态检测
反混淆支持 ❌ ✅(可执行解密逻辑)
环境感知行为识别 ❌ ✅(模拟JS宿主API)
graph TD
  A[用户上传文件] --> B[提取二进制片段]
  B --> C[Wasm沙箱加载分析模块]
  C --> D[执行受限指令序列]
  D --> E[捕获内存/控制流异常]
  E --> F[生成行为指纹]

4.2 使用go-secure-headers与自定义MIME类型白名单双重校验

Web服务需同时防御HTTP头注入与MIME混淆攻击,单一防护存在盲区。

安全头强化:go-secure-headers配置

import "github.com/utrack/go-secure-headers"

h := secure.New(secure.Options{
  ContentTypeNosniff: true,
  XFrameOptions:      "DENY",
  ReferrerPolicy:     "strict-origin-when-cross-origin",
})

该配置强制浏览器执行内容类型嗅探禁用、帧隔离及严格来源策略,防止XSS与点击劫持。ContentTypeNosniff是MIME校验前置条件——确保浏览器不绕过响应头自行推断类型。

MIME白名单动态校验

var allowedMIMETypes = map[string]bool{
  "application/json": true,
  "text/html":        true,
  "image/png":        true,
}

func validateMIME(h http.Header) error {
  ct := h.Get("Content-Type")
  if ct == "" { return errors.New("missing Content-Type") }
  mime, _, _ := mime.ParseMediaType(ct)
  if !allowedMIMETypes[mime] {
    return fmt.Errorf("disallowed MIME type: %s", mime)
  }
  return nil
}

解析Content-Type主类型(忽略参数),严格匹配预设白名单,阻断text/html; charset=utf-8等变体绕过。

防御协同机制

层级 作用 失效场景
go-secure-headers 强制客户端遵守安全策略 旧版浏览器忽略头
MIME白名单 服务端主动拒绝非法类型响应 攻击者伪造Content-Type
graph TD
  A[HTTP响应生成] --> B[go-secure-headers注入安全头]
  B --> C[Content-Type字段校验]
  C --> D{是否在白名单?}
  D -->|是| E[返回响应]
  D -->|否| F[返回406 Not Acceptable]

4.3 静态文件服务与业务逻辑分离的零信任架构改造

传统单体架构中,静态资源(如 JS/CSS/图片)与 API 逻辑共存于同一进程,违背零信任“默认拒绝、最小权限”原则。改造核心是将静态文件服务剥离为独立可信边界网关。

静态资源路由隔离策略

Nginx 作为边缘零信任代理,仅允许 /static/** 路径直通 CDN,其余路径强制转发至业务网关:

# nginx.conf 片段
location /static/ {
    alias /var/www/static/;
    add_header X-Content-Type-Options "nosniff";
    add_header Content-Security-Policy "default-src 'self'";
    # 禁止执行、禁止上传、强制只读
}

该配置实现三层防护:路径白名单、MIME 类型锁定、CSP 限制脚本注入;alias 替代 root 防止路径遍历,/static/ 末尾斜杠确保精确匹配。

权限模型对比表

维度 改造前 改造后
认证粒度 全局 Session 每个静态资源 URL 签名验证
网络拓扑 同一 Pod 内互通 VPC 内 Service Mesh 隔离
审计日志 无资源级访问记录 S3/CDN 层细粒度审计日志

数据同步机制

静态资源经 CI/CD 流水线构建后,通过带签名的 PUT 请求推送到对象存储,业务网关不再持有任何静态文件副本:

graph TD
    A[CI Pipeline] -->|Signed Upload| B[S3 Bucket]
    B --> C[CDN Edge Nodes]
    C --> D[Browser]
    D -->|Origin Request| E[Zero-Trust Gateway]
    E -->|Validate Token| B

此架构使静态服务具备独立身份、可审计生命周期与网络微隔离能力,业务逻辑层彻底解耦文件 I/O 责任。

4.4 基于eBPF的syscall级上传行为实时审计与阻断

核心原理

通过 tracepoint/syscalls/sys_enter_write 和 kprobe/sys_sendfile 钩子捕获文件写入与网络发送路径,结合 bpf_get_current_comm() 与 bpf_get_current_pid_tgid() 关联进程上下文,实现 syscall 级细粒度观测。

关键过滤逻辑

  • 检测 write() 目标 fd 是否为 socket(bpf_probe_read_kernel(&f_type, sizeof(f_type), &file->f_mode))
  • 提取 sendfile() 的源文件路径(需 bpf_d_path() 辅助,受限于 eBPF 安全边界)

示例 eBPF 过滤代码

// 拦截 write() 调用并判断是否向 socket 写入敏感数据
SEC("tracepoint/syscalls/sys_enter_write")
int trace_write(struct trace_event_raw_sys_enter *ctx) {
    pid_t pid = bpf_get_current_pid_tgid() >> 32;
    int fd = (int)ctx->args[0];
    size_t count = (size_t)ctx->args[2];

    // 忽略小数据包(<1KB)降低开销
    if (count < 1024) return 0;

    // TODO: 实际需查 fd 对应 file 结构体类型(此处简化)
    bpf_printk("PID %d write to fd %d (%zu bytes)", pid, fd, count);
    return 0;
}

该程序在 sys_enter_write 时触发,仅记录参数不阻断;真实阻断需配合 tc 或 xdp + bpf_override_return()(5.15+ kernel),且需启用 CAP_SYS_ADMIN。bpf_printk() 用于调试,生产环境应替换为 ringbuf 输出。

审计策略对比

维度 用户态代理审计 eBPF syscall 级审计
延迟 ≥100μs
绕过风险 高(LD_PRELOAD 可绕过) 极低(内核态钩子)
阻断能力 仅日志/告警 可 bpf_override_return(ctx, -EPERM)
graph TD
    A[syscall enter] --> B{fd 是 socket?}
    B -->|Yes| C[提取进程名/路径]
    B -->|No| D[忽略]
    C --> E{匹配敏感关键词/路径?}
    E -->|Yes| F[ringbuf 日志 + 返回 -EPERM]
    E -->|No| G[放行]

第五章:事件溯源与行业防御启示

攻击链还原:从钓鱼邮件到域控沦陷

2023年某金融客户遭遇APT组织攻击,安全团队通过EDR日志、DNS流量镜像和Windows事件ID 4688(进程创建)交叉比对,完整复现攻击路径:恶意宏文档触发PowerShell无文件执行 → 下载Cobalt Strike Beacon → 利用Mimikatz抓取LSASS内存凭证 → 横向移动至域控制器。关键证据链中,DNS请求update-secure[.]top与C2服务器IP 192.168.127.12在防火墙全量日志中存在毫秒级时间关联,成为溯源定案依据。

日志留存策略的实战缺陷

某省级政务云平台因日志轮转策略设置为“仅保留7天”,导致勒索软件攻击发生后无法回溯初始入侵时间点。经审计发现,Syslog服务器未启用TLS加密传输,且日志源设备(如交换机、WAF)时间未统一NTP校准,造成同一攻击行为在不同系统日志中时间戳偏差达18分钟。以下为优化后的日志留存矩阵:

设备类型 日志类型 保留周期 存储方式 加密要求
域控制器 安全事件日志 180天 分布式ELK集群 TLS 1.3+
网络设备 NetFlow v9 90天 ClickHouse AES-256
Web应用防火墙 HTTP事务日志 30天 对象存储OSS KMS托管

威胁情报驱动的防御闭环

某能源集团将MISP平台接入SOAR系统,当TI源推送新IOC hash:8a7f9b2e3d1c4a5f6b7e8c9d0a1b2c3d时,SOAR自动执行三阶段响应:

  1. 在EDR终端批量扫描该哈希值进程;
  2. 调用防火墙API封禁关联IP 172.16.31.10/32;
  3. 向SIEM注入规则:event.code:"4688" and process.hash:"8a7f9b2e3d1c4a5f6b7e8c9d0a1b2c3d"。
    实测平均响应时间从人工处置的47分钟压缩至93秒。

攻击者TTPs映射实践

根据MITRE ATT&CK框架v13.1,对2024年Q1捕获的127起真实攻击样本进行TTPs标注,发现TOP3技术组合具有强行业特征:

flowchart LR
    A[TA0001 - 初始访问] --> B[Phishing- T1566.002]
    B --> C[TA0002 - 执行]
    C --> D[PowerShell- T1059.001]
    D --> E[TA0003 - 持久化]
    E --> F[Registry Run Keys- T1547.001]

金融行业样本中T1566.002出现频次达89%,而制造业则以T1190(漏洞利用)为主导(占比73%)。该差异直接指导了客户EDR规则权重调优:金融客户将宏文档检测规则优先级提升至P0,制造业则强化SMB协议异常连接监控。

红蓝对抗验证机制

某运营商在核心网管系统部署蜜罐节点后,发现攻击者在渗透测试中连续3次尝试利用CVE-2021-26855漏洞,但均被WAF规则SecRule REQUEST_HEADERS:User-Agent \"Exchange\" \"id:1001,deny,status:403\"拦截。进一步分析攻击载荷发现,其PowerShell混淆脚本存在固定特征-enc [A-Za-z0-9+/]{100,},据此编写YARA规则并下发至全网终端,两周内捕获23个变种样本。

浪迹代码世界,寻找最优解,分享旅途中的技术风景。

发表回复

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