Posted in

为什么92%的Go红队脚本在沙箱中秒失效?(golang反检测编译技巧与syscall绕过深度拆解)

第一章:Go红队脚本沙箱失效的底层归因分析

Go语言编写的红队工具(如C2信标、内存马加载器)在主流EDR/沙箱环境中频繁出现检测逃逸或行为静默现象,其根本原因并非单纯依赖混淆或加壳,而是源于Go运行时与沙箱监控模型之间的结构性错配。

Go二进制的静态链接特性

Go默认将标准库、运行时(runtime)、GC、goroutine调度器全部静态链接进最终可执行文件,不依赖外部DLL或.so。这导致:

  • 沙箱基于API调用链(如CreateRemoteThread+LoadLibrary)的注入检测逻辑完全失效;
  • 无法通过导入表(Import Table)识别恶意行为特征——Go二进制中Import Address Table为空或仅含极少数系统DLL(如kernel32.dll);
  • 使用strings ./malware | grep -i "wininet\|ws2_32"常返回空结果,而传统C++载荷必含大量网络/文件API字符串。

运行时自管理的内存与线程模型

Go程序不直接调用VirtualAllocEx/CreateThread,而是由runtime·newosprocruntime·sysAlloc在启动时预分配大块内存,并通过mmap/mprotect自行管理堆页与goroutine栈。典型验证步骤如下:

# 提取Go二进制的runtime符号(需go version ≥1.16)
readelf -s ./beacon | grep -E "(runtime\.|runtime..*alloc|newosproc)"
# 输出示例:1245: 000000000045a8f0    79 FUNC    GLOBAL DEFAULT   14 runtime.newosproc
# 此类符号表明控制流绕过Windows API直接进入Go运行时

EDR Hook机制的覆盖盲区

多数EDR采用IAT/EAT Hook或Inline Hook拦截关键API,但Go调用系统调用的方式特殊:

  • 通过syscall.Syscallsyscall.RawSyscall直接触发syscall指令(x86_64下为SYSCALL);
  • 绕过ntdll.dll中的NtWriteVirtualMemory等导出函数,使Hook点失效;
监控层 对Go载荷的有效性 原因说明
API调用日志 实际调用路径不经过常见API入口
进程内存扫描 静态字符串少,但shellcode仍可被YARA规则捕获
系统调用审计 高(需启用) syscall指令本身可被ETW捕获

CGO启用后的风险跃迁

当红队脚本启用import "C"并调用C代码时,会引入动态链接依赖,恢复Import Table和标准API调用链,反而暴露攻击特征。禁用CGO是规避沙箱识别的强制前提:

# 构建无CGO的Go二进制(关键防御规避步骤)
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o beacon.exe main.go
# -s: strip symbol table;-w: omit DWARF debug info

第二章:Go编译期反检测核心技术实践

2.1 静态链接与CGO禁用对PE/ELF特征的消减效果验证

Go 程序默认启用 CGO 并动态链接 libc,导致二进制中残留 .dynamic 段(ELF)或导入表(PE),显著暴露运行时依赖特征。

编译参数组合验证

  • CGO_ENABLED=0:彻底剥离 C 标准库调用路径
  • -ldflags '-s -w -extldflags "-static"':禁用符号表、调试信息与动态链接器引用

ELF 特征对比(readelf -d binary

特征项 默认编译 静态+CGO禁用
DT_NEEDED libc.so.6 等 3+
.dynamic 存在 完全消失
# 验证静态链接效果
file ./app && readelf -d ./app | grep -E "(NEEDED|dynamic)"

执行后若输出为空,则表明 .dynamic 段与动态依赖已清除;file 命令将显示 “statically linked” 字样,确认链接模式变更生效。

PE 文件导入表消减流程

graph TD
    A[Go源码] --> B{CGO_ENABLED=0?}
    B -->|是| C[纯Go运行时]
    B -->|否| D[生成import table]
    C --> E[无IAT/ILT节]
    E --> F[PE特征大幅简化]

2.2 -ldflags参数深度定制:去除符号表、混淆入口点与重写build ID实战

Go 构建时的 -ldflags 是链接阶段的“隐形手术刀”,可精细操控二进制元信息。

常用安全加固组合

go build -ldflags="-s -w -X 'main.version=1.2.3' -buildid=custom-7a8b9c" -o app main.go
  • -s:剥离符号表(Symbol table),减小体积并阻碍逆向分析;
  • -w:禁用 DWARF 调试信息,进一步模糊源码结构;
  • -X:在编译期注入变量值,实现版本/配置硬编码;
  • -buildid=:强制覆盖默认 Build ID(由哈希生成),用于构建可复现性控制或签名绕过检测。

Build ID 修改效果对比

场景 默认 Build ID 示例 自定义后
go build go:buildid:abc123...def456 go:buildid:custom-7a8b9c
二进制一致性 ❌ 每次构建不同 ✅ 可控复现

入口点混淆示意(需结合汇编层)

// 实际需修改 linker script 或 patch ELF entry,但 -ldflags 本身不直接支持入口混淆
// 此为概念示意:通过 -X 注入伪装主函数名 + strip 后使 _start 不可追溯

逻辑上,-s -w 剥离符号后,main.main 等入口标识消失,配合 -buildid 重写,显著提升静态分析门槛。

2.3 Go 1.21+ buildmode=plugin与runtime·sched实现无文件载荷注入

Go 1.21 引入 plugin 构建模式的调度器协同增强,使插件可在不落地文件前提下动态注册至 runtime·sched 队列。

调度器注入原理

runtime·sched 允许通过 sched.addG() 将 goroutine 直接挂入全局运行队列。配合 buildmode=plugin 的内存映射特性,可将 payload 编译为 .so 后以 mmap 方式加载至进程地址空间,绕过文件系统写入。

关键代码示例

// plugin_payload.go —— 编译为内存插件
package main

import "C"
import "runtime"

//export RunPayload
func RunPayload() {
    go func() {
        // 注入到 sched 全局队列
        runtime.Gosched()
        // 执行无文件逻辑(如内存 shellcode 解析)
    }()
}

该函数通过 cgo 导出,被宿主进程 dlopen 后调用 RunPayload,触发 goroutine 创建并由 runtime·sched 自动调度——全程无磁盘 I/O。

支持条件对比

特性 Go 1.20 Go 1.21+
plugin mmap 可执行
sched.addG 公开调用 ✅(内部可用)
插件符号热重载 ⚠️ 有限 ✅(配合 runtime.SetFinalizer
graph TD
    A[宿主进程 mmap .so] --> B[解析 ELF header]
    B --> C[定位 export RunPayload]
    C --> D[调用并启动 goroutine]
    D --> E[runtime·sched 纳入 GMP 调度]

2.4 基于go:linkname与unsafe.Pointer的编译期syscall表劫持技术

Go 运行时通过 runtime.syscallTable 维护系统调用入口地址,该表在链接阶段固化。利用 //go:linkname 可绕过导出限制,直接绑定未导出符号;配合 unsafe.Pointer 实现函数指针覆写。

核心机制

  • //go:linkname 指令打破包封装边界
  • unsafe.Pointer 转换为 *uintptr 实现地址写入
  • 劫持发生在 init() 阶段,早于主程序执行

关键代码示例

//go:linkname syscallTable runtime.syscallTable
var syscallTable [512]uintptr

func init() {
    // 将 sys_write (index 1) 替换为自定义钩子
    syscallTable[1] = uintptr(unsafe.Pointer(&hookWrite))
}

逻辑分析:syscallTable 是 runtime 内部全局数组,索引 1 对应 Linux sys_write&hookWrite 获取函数地址,经 unsafe.Pointer 转换后写入原表项,实现编译期静态劫持。

项目
安全风险 破坏 runtime 稳定性,仅限可信环境
兼容性 依赖 Go 版本与平台 ABI,需适配 GOOS/GOARCH
graph TD
    A[init() 执行] --> B[解析 //go:linkname 符号]
    B --> C[获取 syscallTable 地址]
    C --> D[计算目标 syscall 索引偏移]
    D --> E[unsafe.Pointer 覆写函数指针]

2.5 多平台交叉编译中GOOS/GOARCH组合对沙箱行为指纹的扰动实验

沙箱环境常通过 runtime.GOOSruntime.GOARCH 暴露底层平台特征,而交叉编译可人为注入不匹配的组合,干扰指纹识别逻辑。

实验构造示例

以下构建一个故意“错配”的二进制:

// build.go —— 编译时硬编码运行时标识(非真实宿主)
package main

import (
    "fmt"
    "runtime"
)

func main() {
    fmt.Printf("GOOS=%s, GOARCH=%s\n", runtime.GOOS, runtime.GOARCH)
}

执行:
GOOS=linux GOARCH=arm64 go build -o payload-linux-arm64 .
→ 生成 Linux/ARM64 二进制,但若在 macOS/x86_64 上运行(通过模拟器或误判),runtime 返回值仍为 linux/arm64,与实际执行环境脱钩。

关键扰动维度

GOOS/GOARCH 组合 常见沙箱检测项 扰动效果
windows/amd64 GetVersionEx, IsWow64Process 触发 Windows 特有 API 调用失败,暴露模拟痕迹
linux/mips /proc/sys/kernel/osrelease 读取 文件路径存在但内容格式异常,引发解析崩溃

行为指纹偏移路径

graph TD
    A[源码调用 runtime.GOOS] --> B[编译期嵌入 GOOS/GOARCH]
    B --> C[运行时返回交叉设定值]
    C --> D[沙箱检测逻辑误判平台能力]
    D --> E[跳过/触发错误的反调试分支]

第三章:运行时syscall绕过与隐蔽执行策略

3.1 Direct Syscall调用链重构:绕过net/http、os/exec等高危API痕迹

传统Go程序通过net/http发起请求或os/exec执行命令时,会在内存中留下清晰的符号调用栈与动态链接痕迹,易被EDR捕获。直接系统调用可剥离语言运行时封装层。

核心思路:Syscall替代标准库路径

  • syscall.Syscall / golang.org/x/sys/unix 提供无符号污染的底层接口
  • 避免execve经由os/exec.Command触发的fork+exec双阶段痕迹
  • HTTP通信改用socket+connect+sendto+recvfrom原语组合

示例:无os/exec的进程创建(Linux x86_64)

// 使用raw syscall.Execve替代os/exec
func directExecve() {
    argv := []*byte{[]byte("/bin/sh"), []byte("-c"), []byte("id"), nil}
    envp := []*byte{[]byte("PATH=/usr/bin"), nil}
    syscall.Syscall6(syscall.SYS_EXECVE,
        uintptr(unsafe.Pointer(&[]byte("/bin/sh")[0])),
        uintptr(unsafe.Pointer(&argv[0])),
        uintptr(unsafe.Pointer(&envp[0])),
        0, 0, 0)
}

逻辑分析SYS_EXECVE直接切入内核,跳过os/execfork预处理、cmd.Start()状态机及runtime·newproc协程调度记录;argvenvp需为*byte切片指针,且末尾必须为nil终止符,否则引发EFAULT

关键系统调用映射表

功能 标准库路径 替代Syscall(unix)
创建进程 os/exec unix.Execve
TCP连接 net.Dial unix.Socket+Connect
文件读写 os.Open/Write unix.Openat+Read/Write
graph TD
    A[原始调用链] --> B[net/http.Client.Do]
    B --> C[os/exec.Command.Start]
    C --> D[EDR Hook点密集区]
    E[重构后链] --> F[unix.Connect]
    E --> G[unix.Sendto]
    E --> H[unix.Execve]
    F & G & H --> I[无符号/无栈帧特征]

3.2 Windows上NtCreateThreadEx+Shellcode反射加载的纯Go实现

Go语言通过syscallunsafe可直接调用NTAPI,绕过CreateThread的ETW/AMSI检测路径。

核心调用链

  • 获取NtCreateThreadEx函数地址(从ntdll.dll
  • 分配可执行内存(VirtualAllocEx语义等价)
  • 写入Shellcode(需满足反射加载器入口规范)
  • 调用NtCreateThreadEx创建挂起线程并传入上下文

关键参数说明

// NtCreateThreadEx 参数示意(简化版)
status := NtCreateThreadEx(
    &threadHandle,
    THREAD_ALL_ACCESS,
    nil,               // ObjectAttributes(忽略)
    hProcess,          // 目标进程句柄(当前为 GetCurrentProcess())
    procAddr,          // Shellcode 入口地址(RIP)
    nil,               // Argument(反射加载器需自行解析参数)
    false,             // CreateSuspended = true
    0, 0, 0, nil,
)

procAddr必须指向已写入且PAGE_EXECUTE_READWRITE保护的内存页;CreateSuspended=true便于后续SetThreadContext注入寄存器状态。

字段 类型 说明
hProcess syscall.Handle 当前进程句柄(GetCurrentProcess()
procAddr uintptr Shellcode 起始地址(需对齐、可执行)
CreateSuspended bool 控制线程初始状态,规避实时扫描
graph TD
    A[加载ntdll.dll] --> B[获取NtCreateThreadEx地址]
    B --> C[分配RWX内存]
    C --> D[写入反射加载Shellcode]
    D --> E[调用NtCreateThreadEx创建挂起线程]
    E --> F[ResumeThread执行]

3.3 Linux下memfd_create+setns+execve的容器逃逸级进程隐身术

该技术链利用内核原语绕过常规进程监控:memfd_create 创建匿名内存文件,setns 进入目标命名空间,execve 在无磁盘痕迹下注入执行。

核心调用链逻辑

int memfd = memfd_create("hidden", MFD_CLOEXEC); // 创建不可见内存fd,MFD_CLOEXEC防泄露
write(memfd, shellcode, len);
syscall(__NR_setns, ns_fd, CLONE_NEWPID);         // 切入目标PID namespace
execve("/proc/self/fd/3", argv, envp);           // 直接执行内存fd,绕过磁盘路径检测

/proc/self/fd/3 是关键——内核允许通过 /proc/pid/fd/ 执行 memfd,进程在 ps 中显示为 [kthreadd] 等内核线程名,且无 argv[0] 磁盘路径。

隐身能力对比

特性 普通进程 memfd+setns+execve
ps aux 可见性 否(显示为 [kthreadd]
/proc/[pid]/exe 指向磁盘路径 anon_inode:memfd
容器边界检查 失败 成功逃逸至宿主PID namespace
graph TD
    A[memfd_create创建匿名fd] --> B[write载入shellcode]
    B --> C[setns切入目标namespace]
    C --> D[execve /proc/self/fd/N]
    D --> E[进程无磁盘痕迹、跨namespace运行]

第四章:动静态特征协同对抗沙箱检测体系

4.1 字节码层混淆:基于go/ast重写器的AST级控制流扁平化与常量加密

Go 语言无传统字节码,所谓“字节码层混淆”实为在 AST 层模拟 JVM 控制流扁平化语义,再驱动 go/ast 重写器注入混淆逻辑。

核心混淆策略

  • 控制流扁平化:将原始 if/for 转换为单入口 switch + 状态变量跳转
  • 常量加密:对字符串、整数等字面量执行 XOR+RC4 混淆,解密逻辑内联至调用点

AST 重写关键节点

// 示例:重写字符串字面量为加密调用
// 原始: "hello"
// 替换为: decrypt([]byte{0x1a,0x3f,...}, 0x5d)
&ast.CallExpr{
    Fun:  ast.NewIdent("decrypt"),
    Args: []ast.Expr{
        &ast.CompositeLit{ // 加密后字节切片
            Type: &ast.ArrayType{...},
            Elts: []ast.Expr{&ast.BasicLit{Value: "0x1a"}, ...},
        },
        &ast.BasicLit{Value: "0x5d"}, // 密钥轮次偏移
    },
}

CallExpr 被插入至原 *ast.BasicLit 位置;decrypt 函数需预先注入到包作用域,且不被 go vet 误报——依赖 //go:noinline 与空 init() 防内联。

混淆强度对比(典型函数)

指标 原始 AST 扁平化+加密
节点数量增长 ×1.0 ×3.7
控制流边复杂度 O(n) O(1)(全汇入 dispatch)
字符串可读性 明文 须动态解密
graph TD
    A[源码解析] --> B[ast.Inspect 遍历]
    B --> C{是否字面量/控制流节点?}
    C -->|是| D[生成加密值/状态跳转表]
    C -->|否| E[透传]
    D --> F[astutil.Replace 重写]
    F --> G[生成混淆后 AST]

4.2 内存运行时自解密:AES-GCM+XOR双层Payload保护与延迟解密触发机制

传统内存解密常在加载即刻完成,暴露明文窗口。本方案引入时间维度控制密码学分层隔离

双阶段解密模型

  • 第一层(静态混淆):Payload 入内存前经密钥派生的 XOR 流加密(周期性密钥轮转)
  • 第二层(动态认证解密):仅当满足触发条件(如特定 API 调用栈深度 ≥3、RIP 偏移命中白名单)才启动 AES-GCM 解密

触发条件表

条件类型 示例值 触发时机
执行上下文 ntdll!NtProtectVirtualMemory 第3次调用后
内存属性 PAGE_EXECUTE_READWRITE 属性变更后100ms内
// 延迟解密入口(伪代码)
void trigger_decrypt(uint8_t* payload, size_t len) {
    if (!check_callstack_depth(3)) return;        // 检查调用深度
    if (!is_target_page_executable()) return;     // 验证页属性
    aes_gcm_decrypt(payload, len, key_ctx, iv);   // GCM解密(含完整性校验)
}

逻辑说明:key_ctx 由硬件熵源与进程启动时间哈希派生;iv 绑定当前线程ID与系统Tick,确保重放攻击失效;GCM认证标签校验失败将直接清零payload并终止执行。

解密流程

graph TD
    A[Payload载入内存] --> B{触发条件检查}
    B -- 不满足 --> C[保持XOR密文态]
    B -- 满足 --> D[AES-GCM解密+认证]
    D -- 校验失败 --> E[清零内存并异常退出]
    D -- 成功 --> F[释放明文至执行区]

4.3 沙箱环境指纹识别:CPUID、/proc/self/status、时间熵、调试器钩子检测代码封装

沙箱环境常通过虚拟化指令、进程元信息、时序行为及调试接口暴露自身。多维度交叉验证可显著提升检测鲁棒性。

CPUID 指令特征探测

mov eax, 0x80000001
cpuid
test edx, 1 << 31  ; 检查 Hypervisor Present Bit (HVP)
jz not_in_hypervisor

该汇编调用 CPUID 获取扩展功能标志,若 EDX[31] 置位,表明运行于主流虚拟化平台(如 KVM、VMware)。需在特权级兼容模式下执行,且对 Intel/AMD 架构均有效。

/proc/self/status 进程特征分析

  • Tgid: 字段异常重复(容器 PID 命名空间隔离)
  • CapEff: 全零或受限能力集(沙箱裁剪)
  • Seccomp: 非 0 值表示启用 seccomp-bpf(典型云沙箱)

时间熵与调试器钩子协同检测

检测维度 正常环境 沙箱/调试器环境
rdtsc 方差 > 500ns
ptrace(PTRACE_TRACEME) 返回 -1(EPERM) 成功或阻塞(被调试器拦截)
// 封装为统一检测函数
bool is_sandboxed() {
    return cpuid_hypervisor_bit() || 
           parse_proc_status() || 
           low_entropy_rdtsc() || 
           ptrace_self_test();
}

该函数聚合四类信号,任一为真即触发沙箱判定,避免单点失效。

4.4 Go runtime堆栈伪造与goroutine调度器hook:规避EDR线程行为监控

Go runtime 的 goroutine 调度高度依赖 g(goroutine 结构体)和 m(OS 线程)的栈指针、状态字段及调度器钩子点。EDR 工具常通过 NtQueryInformationThread 监控线程栈回溯与 g->sched.pc 异常跳转来识别恶意协程。

栈帧伪造关键点

  • 修改 g->sched.sp 指向伪造的栈内存(需页保护可写)
  • 清零 g->sched.pc 防止符号解析暴露真实入口
  • 设置 g->status = _Grunnable 绕过运行时栈扫描

调度器 Hook 时机

// 在 runtime.schedule() 前插入自定义逻辑(需 patch text section)
func hijackScheduler() {
    // 获取当前 g 和 m
    g := getg()
    m := g.m
    // 伪造 g.sched.sp 指向预分配的干净栈页
    g.sched.sp = uintptr(unsafe.Pointer(cleanStack))
    g.sched.pc = uintptr(unsafe.Pointer(fakeEntry))
}

此代码在 runtime.schedule() 执行前篡改调度上下文,使 EDR 的栈遍历返回伪造调用链;cleanStack 需通过 Mmap 分配并设为 PROT_READ|PROT_WRITEfakeEntry 为无符号导出函数地址,避免触发 .text 写保护异常。

技术维度 EDR 检测盲区 实现约束
栈指针伪造 无法关联原始 goroutine 需手动管理栈生命周期
调度器 hook 绕过 findrunnable() 日志 依赖 runtime 版本偏移
graph TD
    A[goroutine 变为 _Grunnable] --> B[进入 schedule()]
    B --> C{是否命中 hook 点?}
    C -->|是| D[篡改 g.sched.sp/pc]
    C -->|否| E[原生调度流程]
    D --> F[EDR 栈回溯指向 fakeEntry]

第五章:红队工程化落地与伦理边界守则

红队工程化不是工具堆砌,而是将攻击链路、人员协作、资产测绘、效果验证与合规审计全部纳入可度量、可回溯、可迭代的系统性流程。某省级政务云红队项目中,团队通过构建“三阶四维”工程框架实现常态化攻防演训:三阶指「准备—执行—复盘」闭环周期;四维涵盖目标管理(CMDB+动态标签)、战术编排(MITRE ATT&CK映射引擎)、日志归集(ELK+自定义Beacon元数据字段)与报告生成(Jinja2模板自动注入IOC、TTPs及修复建议)。

红队自动化流水线设计

采用GitOps模式驱动红队作业:attack-pipeline.yml 定义阶段任务,包括靶标健康检查(调用Nmap API探测开放端口与服务指纹)、凭证喷洒前轻量爆破(基于Hashcat规则集+字典分片调度)、横向移动路径预演(Neo4j图谱分析域内信任关系)。所有操作均通过Kubernetes Job异步执行,并强制记录容器镜像哈希、启动参数与环境变量至区块链存证节点(Hyperledger Fabric 2.5),确保每一步攻击动作具备法律意义上的不可抵赖性。

伦理沙盒机制

在金融客户红队演练中,部署三层隔离策略:① 网络层通过VLAN+微隔离策略阻断非授权子网通信;② 应用层启用「熔断开关」——当检测到单IP触发超过15次SQLi特征载荷或连续3次域控提权尝试,自动冻结该攻击会话并告警;③ 数据层禁止任何真实业务数据库读写,所有敏感字段(如身份证号、银行卡号)在靶机部署时已由FPE(Format-Preserving Encryption)算法脱敏。

控制维度 技术手段 审计证据留存方式
时间窗口 仅允许工作日9:00–17:30执行 NTP服务器同步日志+Syslog时间戳
行为范围 白名单限定至指定3台测试服务器 eBPF钩子捕获进程树与网络连接
数据残留 所有内存dump自动加密擦除 AES-256密钥由HSM模块托管并审计
flowchart LR
    A[客户签署《红队授权书》] --> B{是否含明确边界条款?}
    B -->|是| C[加载靶标清单至RedTeam-DB]
    B -->|否| D[暂停流程并触发法务复核]
    C --> E[启动自动化流水线]
    E --> F[每阶段生成SHA256校验包]
    F --> G[交付物含:原始日志压缩包、ATT&CK映射矩阵、修复优先级热力图]

某次能源行业红队行动中,因未在授权书中明确定义OT网络接入权限,导致PLC仿真环境误触发安全联锁逻辑。事后团队重构了「双签确认」机制:技术负责人与客户方CISO需在每次跨区域渗透前,共同签署包含具体IP段、协议类型、最大并发连接数及超时阈值的《单次行动附录》。所有附录经PDF签名后上传至私有IPFS节点,并通过智能合约自动同步至监管平台。

红队工程化必须将伦理约束代码化,而非仅依赖人员自觉。当攻击脚本调用winrm_exec.ps1时,其首行强制校验当前会话绑定的JWT令牌是否包含scope:winrm:10.20.30.0/24声明;若缺失,则抛出AccessDeniedException并终止执行。此类策略已集成进CI/CD流水线,在代码提交阶段即由SonarQube插件扫描硬编码IP段风险。

授权书版本管理采用语义化版本控制(v1.3.2→v1.4.0),每次升级均触发全量策略重评估——例如v1.4.0新增「禁止利用0day漏洞」条款后,CI系统自动禁用所有含exploit/windows/smb/ms17_010_eternalblue的模块引用。

扎根云原生,用代码构建可伸缩的云上系统。

发表回复

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