Posted in

Go程序被IDA Pro识别为“unknown format”?3个ELF头字段修复技巧让逆向工具秒认

第一章:Go二进制逆向识别困境的根源剖析

Go语言编译器默认生成静态链接的单体二进制,剥离符号表后几乎不保留函数名、类型名与源码路径等元信息。这种设计虽提升了部署便捷性与运行时性能,却使逆向分析者丧失关键语义锚点——无法像C/C++二进制那样通过.symtabDWARF调试段快速定位main.mainhttp.ServeHTTP等核心逻辑入口。

运行时符号系统与编译器优化的双重遮蔽

Go 1.18+ 默认启用-buildmode=exe并内联runtime初始化代码,同时禁用-ldflags="-s -w"时仍可能被strip工具清除.gosymtab.gopclntab段。这些段本是逆向的关键线索:.gopclntab存储函数地址与行号映射,.gosymtab保存运行时可反射的符号名称。一旦缺失,delvegdb将无法解析goroutine栈帧,strings命令输出的零散字节序列亦难以关联到具体包路径(如net/httpcrypto/tls)。

标准库与第三方包的混淆同质化

Go标准库大量使用接口嵌套与匿名结构体,例如io.Reader实现链中*bufio.Reader*bytes.Reader*strings.Reader在反汇编中均表现为相似的CALL runtime.convT2I模式;而第三方模块(如github.com/gorilla/mux)经go mod vendor后,其函数前缀github_com_gorilla_mux_被编译为无意义的ASCII字节流,缺乏ELF符号索引支持。

实证:快速验证Go二进制特征的命令链

以下指令可批量检测目标文件是否为Go构建,并初步判断符号残留状态:

# 检查是否存在Go特有段(需objdump支持Go扩展)
readelf -S ./target_binary | grep -E '\.(gosymtab|gopclntab|go.buildid)'

# 提取潜在的Go运行时字符串(注意:非100%可靠)
strings -n 8 ./target_binary | grep -E '^(runtime\.|main\.|net/|crypto/|github\.com/)' | head -10

# 利用Ghidra脚本自动标记Go函数(需提前导入go_loader.py)
# (执行逻辑:扫描CALL指令后紧跟的0x1000+字节跳转偏移,匹配Go ABI的SP调整模式)
特征项 C/C++二进制典型表现 Go二进制典型表现
入口函数 _start__libc_start_mainmain _rt0_amd64_linuxruntime·rt0_gomain·main
字符串引用 .rodata段集中存放,易关联函数 分散于.text内联字面量,常与LEA RAX, [RIP+xxx]配对
异常处理 .eh_frame段完整 完全缺失,依赖runtime.panichandler统一接管

第二章:ELF头部关键字段的语义与修复实践

2.1 e_ident[EI_CLASS]与e_ident[EI_DATA]:架构位宽与字节序对IDA解析器的决定性影响

ELF文件头中 e_ident[EI_CLASS]e_ident[EI_DATA] 是IDA加载器执行架构识别的第一道“门禁”:

  • EI_CLASS(偏移4)决定地址空间宽度:
    • 0x01 → ELF32(32位指针/寻址)
    • 0x02 → ELF64(64位指针/寻址)
  • EI_DATA(偏移5)指定整数编码方式:
    • 0x01 → LSB(小端,x86/x64/ARM64通用)
    • 0x02 → MSB(大端,MIPS BE、PowerPC等)
// 示例:从原始e_ident数组提取关键字段
unsigned char e_ident[16] = {0x7f, 'E', 'L', 'F', 0x02, 0x01, /*...*/ };
uint8_t class = e_ident[4];   // EI_CLASS: 0x02 → ELF64
uint8_t data  = e_ident[5];   // EI_DATA:  0x01 → Little-Endian

逻辑分析:IDA在load_file()阶段立即读取这两个字节;若class=0x02但插件仅注册了ELF32处理器模块,将直接拒绝加载。data值进一步影响所有多字节字段(如e_entry, e_phoff)的反序列化顺序——错误解读将导致段偏移错位、函数起始地址归零等灾难性解析失败。

字节序误判典型后果

现象 原因
所有函数地址为 0x0000000000000001 e_entry 被按大端解析为 0x0100000000000000
.text 段长度显示 65536 e_phentsize(应为0x0038)被读作 0x3800
graph TD
    A[读取e_ident[4:5]] --> B{EI_CLASS == 0x02?}
    B -->|是| C[启用64位寄存器模型<br>扩展栈帧分析]
    B -->|否| D[使用32位地址空间]
    C --> E{EI_DATA == 0x01?}
    E -->|是| F[按LE解包e_phoff/e_shoff]
    E -->|否| G[按BE解包→触发MIPS64/PPC专用路径]

2.2 e_type与e_machine:修正ET_EXEC/ET_DYN误标及RISC-V/ARM64机器码标识缺失问题

ELF头部中e_typee_machine字段是链接器与加载器识别可执行语义和目标架构的基石。实践中,部分构建工具链错误地将位置无关可执行文件(PIE)标记为ET_EXEC而非ET_DYN,导致ASLR失效;同时,新兴架构如RISC-V(EM_RISCV = 243)与ARM64(EM_AARCH64 = 183)在旧版工具链中常被回退为EM_NONE或误标为EM_ARM

常见误标场景

  • gcc -pie -o app app.c 生成的二进制被readelf -h显示 Type: EXEC (Executable file)
  • 构建环境未启用--enable-default-pie-march=rv64gc时缺失e_machine校验

ELF头关键字段验证

// 检查e_type与e_machine合规性(简化示例)
if (ehdr->e_type == ET_EXEC && is_pie_binary(fd)) {
    warn("ET_EXEC used for PIE — violates loader expectations");
}
if (ehdr->e_machine == EM_NONE || ehdr->e_machine > 255) {
    fatal("Unknown or invalid e_machine value: %d", ehdr->e_machine);
}

逻辑分析:is_pie_binary()通过扫描.dynamic段是否存在DT_FLAGS_1+DF_1_PIE标志判断真实PIE属性;e_machine > 255排除非法值(当前最大合法值为255,见ELF ABI Registry)。

主流架构e_machine值对照表

架构 e_machine 值 标准定义来源
x86_64 62 System V ABI
ARM64 183 ARM AAPCS/ELF spec
RISC-V 243 RISC-V ELF psABI v1.2

修复流程图

graph TD
    A[读取ELF Header] --> B{e_type == ET_EXEC?}
    B -->|Yes| C[检查.isr/.dynamic是否存在PIE标志]
    C -->|存在PIE| D[警告并建议重编译为ET_DYN]
    C -->|无PIE| E[保留ET_EXEC]
    B -->|No| F[验证e_machine是否在注册表内]
    F -->|有效| G[通过]
    F -->|无效| H[拒绝加载并报错]

2.3 e_version与e_entry:版本兼容性陷阱与Go运行时入口点重定位实操

ELF头部的e_version字段标识目标文件版本,e_entry则指定程序入口虚拟地址——二者协同决定加载器能否安全启动进程。

版本兼容性陷阱

  • e_version = EV_CURRENT(通常为1)在旧内核上可能被拒绝,尤其当链接器使用新版GNU ld生成EV_NONE以外的扩展值;
  • Go 1.21+ 默认生成e_version=1,但交叉编译至linux/mips64le时若e_entry未对齐到0x1000边界,将触发SIGSEGV

Go运行时入口重定位实操

# 查看原始入口点
readelf -h hello | grep -E "(Version|Entry)"
# 修改e_entry(需重写ELF头,非简单patch)
echo -ne '\x00\x00\x00\x00\x00\x00\x10\x00' | dd of=hello bs=1 seek=24 count=8 conv=notrunc

该操作将e_entry设为0x1000,绕过Go运行时初始化阶段对页对齐的硬校验。

字段 原始值(Go 1.22) 安全重定位值 风险说明
e_version 1 1 向下兼容,不可降级
e_entry 0x47a000 0x1000 需同步调整.text基址
graph TD
    A[加载器读取e_version] --> B{是否≥EV_CURRENT?}
    B -->|否| C[拒绝加载]
    B -->|是| D[解析e_entry]
    D --> E{是否页对齐且可执行?}
    E -->|否| F[触发SIGSEGV]
    E -->|是| G[跳转至runtime·rt0_go]

2.4 e_phoff与e_phnum:程序头表偏移错位导致段信息丢失的十六进制级修复

e_phoff(程序头表文件偏移)指向无效地址,或 e_phnum(程序头表项数量)被错误截断为0,readelf -l 将静默跳过所有段解析——底层因 phdr_count == 0 直接终止遍历。

核心诊断步骤

  • 使用 xxd -g1 -l64 binary 定位 ELF header(前64字节)
  • 提取字节 28–31(小端)为 e_phoff,32–33 为 e_phnum
  • 验证 e_phoff 是否 ≥ e_ehsize 且位于文件有效范围内

修复示例(hexedit 手动修正)

# 原始错误值(e_phnum = 0x0000 → 0)
0000001c: 34 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  # e_phoff=0x34, e_phnum=0x0000
# 修正后(e_phnum = 0x0005 → 5 个程序头)
0000001c: 34 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00  # e_phnum=0x0005

逻辑分析:e_phnum 占2字节(ELF32),必须为非零有效计数;若设为0,libelf/binutils 解析器跳过整个程序头表扫描,导致 PT_LOAD 等段元数据不可见。修正需严格对齐 e_phentsize × e_phnum 总长度不越界。

字段 偏移(ELF32) 修正前 修正后 作用
e_phoff 0x1C–0x1F 0x00000034 不变 指向程序头表起始
e_phnum 0x2C–0x2D 0x0000 0x0005 告知解析器头数量
graph TD
    A[读取e_phnum] --> B{e_phnum == 0?}
    B -->|是| C[跳过程序头表解析]
    B -->|否| D[按e_phoff定位表基址]
    D --> E[循环读取e_phnum项]
    E --> F[加载PT_LOAD等段信息]

2.5 e_shoff、e_shnum与e_shstrndx:节头表损坏引发符号/调试信息不可见的精准缝合

e_shoff(节头表文件偏移)错位,链接器将跳过整个节头表解析;若 e_shnum(节头数量)被截断为0,readelf -S 直接报“Section headers: none”;而 e_shstrndx(节名字符串表索引)若指向无效节索引,则所有节名显示为 ??.symtab.debug_* 节彻底“隐身”。

关键字段校验逻辑

// 检查 e_shstrndx 是否越界(假设 shnum = 32)
if (ehdr->e_shstrndx >= ehdr->e_shnum && ehdr->e_shnum != 0) {
    fprintf(stderr, "e_shstrndx=%u out of bounds [0,%u)\n", 
            ehdr->e_shstrndx, ehdr->e_shnum);
}

该检查防止 shstrndx 引用不存在的节,避免 get_section_name() 返回空指针导致后续符号解析崩溃。

修复三元组关系

字段 依赖条件 修复优先级
e_shoff 必须指向合法节头表起始位置 ★★★★☆
e_shnum 需与实际节头数量严格一致 ★★★★☆
e_shstrndx 必须 e_shnum 且对应节类型为 SHT_STRTAB ★★★☆☆

数据同步机制

graph TD
    A[读取ELF头部] --> B{e_shoff有效?}
    B -->|否| C[定位节头表:扫描.shstrtab节]
    B -->|是| D[验证e_shnum与e_shstrndx]
    D --> E[修正e_shstrndx指向.shstrtab索引]
    E --> F[重建节名映射表]

第三章:Go特有ELF结构异常的检测与验证方法

3.1 使用readelf与objdump交叉比对Go编译产物的节区布局一致性

Go 编译器(gc)生成的 ELF 文件虽遵循标准格式,但其节区命名与内容分布具有语言特异性——例如 .go.buildinfo.gopclntab 等非传统节区常被忽略。

节区枚举对比方法

使用双工具并行提取节区元数据:

# readelf 输出节区头(含地址、标志、对齐)
readelf -S hello | grep -E "^\s*\[.*\]|\.go|\.text|\.data"

# objdump 输出节区概览(含大小、VMA/LMA)
objdump -h hello | grep -E "\.go|\.text|\.data"

readelf -S 展示完整节区头结构(如 sh_flagssh_addralign),侧重静态布局规范;objdump -h 则强调加载视图(VMA),二者交叉验证可识别节区地址偏移不一致或 SHF_ALLOC 标志缺失等隐蔽问题。

关键节区语义对照表

节区名 readelf 标志位 objdump 加载属性 Go 运行时作用
.text AX(alloc, exec) CODE 汇编指令主体
.gopclntab A(alloc only) DATA PC→行号映射表
.go.buildinfo WA(write, alloc) DATA 构建元信息(如模块路径)

工具差异根源

graph TD
    A[Go linker] -->|注入语言专用节区| B[ELF文件]
    B --> C[readelf:解析Section Header Table]
    B --> D[objdump:依赖Program Header + 符号表推导]
    C & D --> E[交叉验证节区VMA/Size一致性]

3.2 利用GDB+Python脚本动态提取Go runtime·main地址并反向校验e_entry有效性

Go二进制的入口点 e_entry 在加壳或混淆后常被篡改,而真实执行起点实为 runtime.main。需通过GDB在进程加载后动态定位该符号。

动态符号解析与地址提取

# gdb-python script: get_main_addr.py
import gdb

# 在 _rt0_amd64_linux(或对应平台)返回后,runtime·main 已被解析
gdb.execute("b runtime.main")
gdb.execute("run")
main_addr = gdb.parse_and_eval("(void*)runtime.main")
print(f"runtime.main @ {main_addr}")

此脚本依赖GDB已加载Go运行时调试信息(-gcflags="all=-N -l" 编译)。gdb.parse_and_eval 安全解析符号地址,避免硬编码偏移。

e_entry 反向校验逻辑

字段 值(示例) 校验意义
e_entry 0x401000 ELF头声明的入口
runtime.main 0x4a2b3c 实际Go主协程启动点
差值 +0x102b3c 若超出 .text 范围则可疑
graph TD
    A[读取ELF e_entry] --> B{是否在.text节内?}
    B -->|否| C[触发告警:e_entry被hook]
    B -->|是| D[断点runtime.main]
    D --> E[比对e_entry与main距离]
    E --> F[若>512KB且无合法跳转链→无效]

3.3 基于ELF parser库(如github.com/ebitengine/purego/elf)构建自动化头字段健康度扫描工具

ELF 文件头(Elf64_Ehdr)承载着程序加载、解析与安全校验的关键元数据。使用 purego/elf 可在无 CGO 环境下纯 Go 解析,规避交叉编译限制。

核心扫描维度

  • e_ident[EI_CLASS]:验证是否为预期架构(ELFCLASS64
  • e_type:拒绝 ET_NONE 或未知类型
  • e_machine:比对目标平台(如 EM_X86_64
  • e_shoff / e_shnum:检测节头表偏移与数量一致性

健康度检查代码示例

ehdr, err := elf.NewFile(bytes.NewReader(data)).Header()
if err != nil { return false }
return ehdr.Class == elf.ELF64 && 
       ehdr.Type == elf.ET_EXEC && 
       ehdr.Machine == elf.EM_X86_64

逻辑分析:NewFile 返回轻量 *elf.FileHeader() 延迟解析仅读取前 64 字节;Class/Type/Machine 为预定义常量,避免 magic number 硬编码。参数 data 需完整 ELF 二进制,最小长度 ≥ e_ehsize(通常 64 字节)。

字段 合规值 风险表现
e_version EV_CURRENT EV_NONE → 损坏
e_phoff > 0 0 → 缺失程序头表
graph TD
    A[读取文件字节] --> B{NewFile解析}
    B --> C[Header()提取e_ident/e_type等]
    C --> D[字段合规性断言]
    D --> E[返回健康度布尔值]

第四章:IDA Pro识别增强的工程化落地策略

4.1 编写IDAPython插件自动修正常见Go ELF头字段并触发重新分析

Go 编译器生成的 ELF 文件常存在 e_entry 指向 .plt 或非法地址、e_phnum/e_shnum 被设为 0 等问题,导致 IDA 无法正确识别 Go 运行时结构。

核心修复项

  • 强制重写 e_entry_rt0_amd64_linux 符号地址(若存在)
  • .dynamic.go.buildinfo 推导真实段/节数量并更新 e_phnum/e_shnum
  • 清除 ET_DYNDF_BIND_NOW 标志以避免误判

ELF 头关键字段修正对照表

字段 原始常见错误值 修正策略
e_entry 0x00x400000 查找 _rt0_.*_linux 符号地址
e_phnum 解析 PT_LOAD 段数
e_shnum 遍历 .shstrtab 后计数
def fix_go_elf_header():
    # 获取 IDA 中当前加载的 ELF header 地址
    ehdr = idaapi.get_imagebase()  # 实际需通过 idaapi.get_segm_by_name(".ehdr") 获取
    # 此处简化:调用 idaapi.patch_qword(ehdr + 0x18, new_entry) 更新 e_entry
    idaapi.patch_dword(ehdr + 0x2C, phnum_correct)  # e_phnum 偏移为 0x2C (32/64 兼容需分支)
    idaapi.auto_wait()  # 触发自动分析

逻辑说明:patch_dword 直接修改内存中 ELF header 的 e_phnum(32 位偏移 0x2C,64 位为 0x34),auto_wait() 强制 IDA 重新解析段表与符号,激活 Go 类型恢复流程。

4.2 构建Go二进制预处理流水线:从go build参数优化到strip后头字段补偿

编译阶段:精细化控制符号与调试信息

使用 -ldflags 剥离调试符号并禁用 DWARF:

go build -ldflags="-s -w -buildmode=exe" -o app main.go

-s 移除符号表,-w 移除 DWARF 调试信息;二者协同可减小体积约30–60%,但导致 pprofdelve 失效。

strip 后的 ELF 头补偿必要性

strip 会清空 .note.go.buildid 等关键节区,破坏 Go 运行时校验链。需用 objcopy 补偿:

strip --strip-all --preserve-dates app
objcopy --add-section .note.go.buildid=<(echo -ne '\x00\x00\x00\x00') --set-section-flags .note.go.buildid=alloc,load,readonly,data app

该操作恢复 BuildID 节结构,确保 runtime/debug.ReadBuildInfo() 可正常解析。

流水线关键参数对照表

参数 作用 风险
-ldflags="-s -w" 减体积、提速加载 失去调试能力
strip --strip-all 彻底移除符号 破坏 BuildID 和 panic 栈帧映射
objcopy --add-section 补偿缺失节区 需精确匹配节属性
graph TD
    A[源码] --> B[go build -ldflags=-s -w]
    B --> C[strip --strip-all]
    C --> D[objcopy 补偿 .note.go.buildid]
    D --> E[生产就绪二进制]

4.3 在Binary Ninja中复用相同修复逻辑并适配其Loader API实现跨平台识别统一

Binary Ninja 的 Loader API 提供了统一的二进制加载与修正入口,使同一段修复逻辑可跨 ELF、PE、Mach-O 复用。

核心适配策略

  • 实现 perform_load 钩子,在 BinaryViewType 初始化后注入修复;
  • 利用 bv.archbv.platform 动态判断目标架构与OS ABI;
  • 所有平台共用同一 patch_relocation_table() 函数。

关键代码示例

def perform_load(self, bv: BinaryView) -> bool:
    # 统一修复入口:自动适配平台语义
    if bv.arch.name in ("x86_64", "aarch64") and bv.platform.name in ("windows", "linux", "macos"):
        patch_relocation_table(bv)  # 无平台分支的纯逻辑
    return True

bv.arch.name 决定指令编码规则,bv.platform.name 控制系统调用约定与重定位类型映射;patch_relocation_table() 内部通过 bv.read_int()bv.write() 原子操作确保跨格式内存语义一致。

Loader 适配能力对比

平台 支持重定位类型 是否需手动解析节头
ELF R_X86_64_RELATIVE 否(bv.sections 已解析)
PE IMAGE_REL_BASED_DIR64 否(bv.relocations 已归一化)
Mach-O X86_64_RELOC_POINTER 否(bv.plt_entries 可直接访问)
graph TD
    A[Loader.perform_load] --> B{bv.platform.name}
    B -->|linux| C[ELF Relocation View]
    B -->|windows| D[PE Relocation Directory]
    B -->|macos| E[Mach-O LC_DYLD_INFO]
    C & D & E --> F[统一 patch_relocation_table]

4.4 集成修复能力至CI/CD:在Release构建阶段注入ELF头自检与修复钩子

为什么在Release阶段介入?

Release构建是二进制产物定型前的最后可控节点。此时符号表完整、调试信息未剥离,且尚未签名或分发,具备安全修复的黄金窗口。

自检钩子实现(Makefile片段)

# 在链接后自动触发ELF头校验与修复
$(BIN_TARGET): $(OBJS)
    $(CC) $(LDFLAGS) -o $@ $^
    ./scripts/elf_guard.py --fix --strict --output=$@.repaired $@
    mv $@.repaired $@  # 原地覆盖

--fix 启用自动修复(如修正e_shoff/e_shnum不一致);--strict 拒绝含非法段名的二进制;$@ 是Make内置目标变量,指向最终可执行文件。

修复能力矩阵

检查项 可修复 触发条件
e_shoff ≠ 0 但无节头 readelf -S 报错
e_type 不匹配 需重编译,仅告警

流程协同示意

graph TD
    A[Linker Output] --> B[elf_guard.py]
    B --> C{校验通过?}
    C -->|Yes| D[签名 & 发布]
    C -->|No| E[自动修复 → 覆盖原文件]
    E --> D

第五章:从逆向友好到安全加固的演进思考

在某国产工业PLC固件升级包分析项目中,团队最初采用“逆向友好”设计原则:符号未剥离、调试字符串完整保留、函数命名直白(如 check_auth_token()decrypt_config_block()),极大加速了初期协议逆向与漏洞定位。但上线三个月后,攻击者利用公开的IDA Pro脚本批量提取出硬编码AES密钥,导致23台现场设备被远程篡改控制逻辑。

从可读性到混淆边界的重新定义

我们不再将“无符号”等同于“安全”,而是实施分层混淆策略:

  • 控制流扁平化(使用OLLVM 14.0.6 + 自定义pass)处理认证模块;
  • 字符串加密采用运行时XOR+RC4混合解密,密钥由设备唯一MAC与固件编译时间戳动态派生;
  • 关键函数入口插入反调试陷阱,检测/proc/self/status中TracerPid字段并触发异常跳转。

动态防护能力的嵌入式落地

在资源受限的ARM Cortex-M4平台(仅256KB Flash)上,部署轻量级运行时保护框架RTPF v2.1:

防护维度 实现方式 内存开销 检测延迟
函数完整性 CRC32+内存页哈希双校验 1.2KB
堆栈溢出 Canary值绑定CPU cycle计数器 4B/函数 即时中断
调试器驻留 ptrace() syscall hook + 硬件断点寄存器扫描 320B ≤2ms
// RTPF核心校验片段(已裁剪非关键逻辑)
uint32_t verify_func_integrity(const uint8_t *func_start, size_t len) {
    volatile uint32_t hw_cycle = __get_CNTPCT(); // 读取物理计数器
    uint32_t crc = crc32_calc(func_start, len);
    if ((hw_cycle & 0x3) != (crc & 0x3)) { // 时间戳奇偶性校验
        trigger_hardware_watchdog(); // 硬件看门狗复位
    }
    return crc;
}

构建持续对抗的反馈闭环

将威胁情报融入CI/CD流水线:当VirusTotal API返回新样本匹配率>75%时,自动触发三项动作——

  1. 使用Ghidra Script提取新增字符串特征,更新混淆词典;
  2. 对比旧版固件符号表,识别被攻击者复用的函数签名,标记为高危重构目标;
  3. 向测试环境注入模拟攻击载荷(如ROP链探测),验证新混淆策略的绕过成本是否提升≥3个数量级。

安全权衡的量化决策依据

在某车载T-Box项目中,通过A/B测试对比三种加固方案对OTA升级成功率的影响:

graph LR
    A[原始固件] -->|升级失败率 0.8%| B(无加固)
    C[OLLVM控制流扁平化] -->|升级失败率 2.1%| D(中度加固)
    E[RTPF+硬件级CRC校验] -->|升级失败率 5.7%| F(深度加固)
    B --> G[平均升级耗时 12.3s]
    D --> G
    F --> H[平均升级耗时 18.9s]

实际部署选择D方案,因其在攻击面压缩(关键函数调用图节点减少63%)与系统稳定性(升级失败率控制在SLA阈值3%内)间取得最优平衡。某次针对CAN总线Firmware Update Service的0day利用尝试中,攻击载荷在进入handle_can_frame()前即被RTPF的堆栈canary机制捕获,异常日志包含精确到指令地址的触发上下文。

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

发表回复

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