chore(tools): 入库汽水签名工具链(独立签名服务与逆向记录)
- tools/mssdk-sign/:C++ 实现的签名服务,附 mssdk 内部结构记录、MinGW 与 MSVC 两套构建脚本、调用示例 client.mjs 与自检入口 - signer/server.mjs:Node 实现的等价签名服务,面向「主服务不在 Windows 上」 的场景——bdms.node 是 Windows PE 原生模块,只能由 Windows 版 Node 加载, 故把「加载官方模块」关进独立进程,主服务经 HTTP 取 X-Helios / X-Medusa 两者都不被 src/sdk 引用:运行期走 sdk/qs/mssdk.ts 直接加载官方 bdms.node, 它们是组件升级、签名失效时重新定位入口并验证的手段,放在仓库里才能跟着 代码一起维护。 专有二进制(mssdk-sign.exe、metasecml.dll)由 tools/mssdk-sign/.gitignore 排除,不随仓库分发。
This commit is contained in:
1 parent
dd28ba157c
commit
14f7295fd4
8 files changed
+1392
No files matched your search
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env node
|
||||
// 汽水音乐外部签名服务 —— 在 Linux 上用 Wine 跑官方 bdms.node,对外只提供签名
|
||||
//
|
||||
// 为什么需要它:bdms.node 是 Windows PE 原生模块,只能由 Windows 版 Node/Electron 加载。
|
||||
// 把「加载官方模块」这件事单独关进一个容器(Wine + Windows Node),主服务就能跑在任何平台,
|
||||
// 通过 HTTP 拿 X-Helios / X-Medusa —— 契约与 sodahub-org/libmssdk、libresoda、
|
||||
// Meting-API 的远程签名服务互通(三者 JSON 可互换)。
|
||||
//
|
||||
// 路由:
|
||||
// GET /healthz → { ok, service, version, bdmsAvailable, deviceId(脱敏), auth, platform }
|
||||
// GET /config → 需鉴权:设备身份(调用方据此对齐 URL 里的 device_id/iid)
|
||||
// POST /sign → { url, method?, headers, body?, device_id? } → { ok, headers:{X-Helios,X-Medusa}, device_id, elapsed_ms }
|
||||
// POST /selftest → 用样例请求自检(不联网)
|
||||
//
|
||||
// 鉴权:QS_SIGNER_TOKEN(Authorization: Bearer <token>)。未设 token 时除非
|
||||
// QS_SIGNER_ALLOW_ANONYMOUS=1,否则拒绝启动 —— 签名能力等价于账号取流能力,别裸奔。
|
||||
// 环境变量:QS_SIGNER_BIND(127.0.0.1) / QS_SIGNER_PORT(8899) / QS_SIGNER_TOKEN /
|
||||
// QS_SIGNER_DEVICE_ID / QS_SIGNER_BDMS_MODULE(默认 ./vendor/bdms.node) / QS_SIGNER_MAX_BODY
|
||||
// 日志只记请求形状与耗时:Cookie、X-Helios、X-Medusa 一律不落盘。
|
||||
'use strict'
|
||||
|
||||
const http = require('node:http')
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const VERSION = '0.1.0'
|
||||
const SERVICE = 'qs-signer'
|
||||
const BIND = process.env.QS_SIGNER_BIND || '127.0.0.1'
|
||||
const PORT = Number(process.env.QS_SIGNER_PORT) || 8899
|
||||
const TOKEN = process.env.QS_SIGNER_TOKEN || ''
|
||||
const ALLOW_ANONYMOUS = process.env.QS_SIGNER_ALLOW_ANONYMOUS === '1'
|
||||
const DEFAULT_DEVICE_ID = process.env.QS_SIGNER_DEVICE_ID || ''
|
||||
const MAX_BODY = Number(process.env.QS_SIGNER_MAX_BODY) || 256 * 1024
|
||||
const MODULE_PATH = path.resolve(process.env.QS_SIGNER_BDMS_MODULE || path.join(__dirname, 'vendor', 'bdms.node'))
|
||||
|
||||
if (!TOKEN && !ALLOW_ANONYMOUS) {
|
||||
console.error('[qs-signer] 拒绝启动:未设置 QS_SIGNER_TOKEN(本机调试可显式 QS_SIGNER_ALLOW_ANONYMOUS=1)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const maskDevice = (id) => {
|
||||
const value = String(id || '')
|
||||
return value.length <= 6 ? value : `${value.slice(0, 3)}***${value.slice(-3)}`
|
||||
}
|
||||
|
||||
// —— 官方模块加载:一次 require,按设备 init(换设备要重新 init)——
|
||||
let bdms = null
|
||||
let loadError = ''
|
||||
let initedDevice = ''
|
||||
const loadBdms = () => {
|
||||
if (bdms) return bdms
|
||||
if (loadError) return null
|
||||
try {
|
||||
if (!fs.existsSync(MODULE_PATH)) throw new Error(`找不到 ${MODULE_PATH}`)
|
||||
const mod = require(MODULE_PATH)
|
||||
if (typeof mod.init !== 'function' || typeof mod.generateHttpSignatureHeaders !== 'function') {
|
||||
throw new Error('导出不符合预期(需要 init / generateHttpSignatureHeaders)')
|
||||
}
|
||||
bdms = mod
|
||||
console.log(`[qs-signer] bdms 模块已加载: ${MODULE_PATH}`)
|
||||
return bdms
|
||||
} catch (err) {
|
||||
loadError = String((err && err.message) || err)
|
||||
console.error(`[qs-signer] bdms 模块加载失败: ${loadError}(/sign 将返回 503,/healthz 仍可用)`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const deviceIdFromRequest = (url, headers, explicit) => {
|
||||
if (explicit) return String(explicit).trim()
|
||||
try {
|
||||
const parsed = new URL(String(url))
|
||||
for (const key of ['device_id', 'did', 'fp']) {
|
||||
const value = parsed.searchParams.get(key)
|
||||
if (value) return value.trim()
|
||||
}
|
||||
} catch {
|
||||
// URL 不合法就继续往下找
|
||||
}
|
||||
for (const name of ['x-device-id', 'device-id', 'device_id']) {
|
||||
if (headers && headers[name]) return String(headers[name]).trim()
|
||||
}
|
||||
return DEFAULT_DEVICE_ID
|
||||
}
|
||||
|
||||
const ensureInit = (deviceId) => {
|
||||
const mod = loadBdms()
|
||||
if (!mod) throw new Error(loadError || 'bdms 模块不可用')
|
||||
const target = String(deviceId || DEFAULT_DEVICE_ID || '').trim()
|
||||
if (!target) throw new Error('缺少 device_id:请求里带 device_id(或 URL 查询参数 device_id/did/fp),或设置 QS_SIGNER_DEVICE_ID')
|
||||
if (initedDevice !== target) {
|
||||
mod.init({ deviceId: target })
|
||||
initedDevice = target
|
||||
console.log(`[qs-signer] 已绑定设备 ${maskDevice(target)}`)
|
||||
}
|
||||
return mod
|
||||
}
|
||||
|
||||
const flattenHeaders = (headers) => {
|
||||
const lines = []
|
||||
for (const [name, value] of Object.entries(headers || {})) {
|
||||
if (!name || value === undefined || value === null) continue
|
||||
const items = Array.isArray(value) ? value : [value]
|
||||
for (const item of items) lines.push(`${name}\r\n${item}`)
|
||||
}
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
const parseSignaturePairs = (raw) => {
|
||||
const fields = String(raw || '').split('\r\n').filter((t) => t.trim())
|
||||
const signed = {}
|
||||
for (let i = 0; i + 1 < fields.length; i += 2) signed[fields[i]] = fields[i + 1]
|
||||
return signed
|
||||
}
|
||||
|
||||
/** 签名一次请求:返回需追加/覆盖的请求头(含服务端补的 x-ss-stub) */
|
||||
const signRequest = ({ url, headers = {}, body, deviceId }) => {
|
||||
if (!url) throw new Error('url 不能为空')
|
||||
const target = ensureInit(deviceIdFromRequest(url, headers, deviceId))
|
||||
const outgoing = { ...headers }
|
||||
// 有 body 却没给 x-ss-stub 时补上(它是签名输入,也要随请求发出),大小写保持官方写法
|
||||
if (body && !Object.keys(outgoing).some((k) => k.toLowerCase() === 'x-ss-stub')) {
|
||||
outgoing['x-ss-stub'] = crypto.createHash('md5').update(String(body)).digest('hex').toUpperCase()
|
||||
}
|
||||
const signed = parseSignaturePairs(target.generateHttpSignatureHeaders(String(url), flattenHeaders(outgoing)))
|
||||
if (!signed['X-Helios'] && !signed['x-helios']) throw new Error('签名结果缺少 X-Helios')
|
||||
return { signed: { ...signed, ...(body ? { 'x-ss-stub': outgoing['x-ss-stub'] } : {}) }, deviceId: initedDevice }
|
||||
}
|
||||
|
||||
const json = (res, status, payload) => {
|
||||
const text = JSON.stringify(payload)
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text) })
|
||||
res.end(text)
|
||||
}
|
||||
|
||||
const readBody = (req) => new Promise((resolve, reject) => {
|
||||
let size = 0
|
||||
const chunks = []
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length
|
||||
if (size > MAX_BODY) {
|
||||
reject(Object.assign(new Error('请求体过大'), { status: 413 }))
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
||||
req.on('error', reject)
|
||||
})
|
||||
|
||||
const authorized = (req) => {
|
||||
if (!TOKEN) return true
|
||||
const header = String(req.headers.authorization || '')
|
||||
const bearer = header.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : ''
|
||||
const alt = String(req.headers['x-signer-token'] || '')
|
||||
return bearer === TOKEN || alt === TOKEN
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const started = Date.now()
|
||||
const url = String(req.url || '')
|
||||
const pathname = url.split('?')[0]
|
||||
|
||||
const log = (status) => console.log(`[qs-signer] ${req.method} ${pathname} → ${status} (${Date.now() - started}ms)`)
|
||||
|
||||
try {
|
||||
if (pathname === '/healthz') {
|
||||
log(200)
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
service: SERVICE,
|
||||
version: VERSION,
|
||||
bdmsAvailable: Boolean(bdms) || (!loadError && fs.existsSync(MODULE_PATH)),
|
||||
deviceId: maskDevice(initedDevice || DEFAULT_DEVICE_ID),
|
||||
auth: Boolean(TOKEN),
|
||||
platform: `${process.platform} ${process.arch}`,
|
||||
node: process.version,
|
||||
})
|
||||
}
|
||||
|
||||
if (!authorized(req)) {
|
||||
log(401)
|
||||
return json(res, 401, { ok: false, error: 'unauthorized', code: 'unauthorized' })
|
||||
}
|
||||
|
||||
if (pathname === '/config') {
|
||||
log(200)
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
service: SERVICE,
|
||||
version: VERSION,
|
||||
bdmsModule: MODULE_PATH,
|
||||
bdmsLoaded: Boolean(bdms),
|
||||
loadError,
|
||||
deviceId: maskDevice(initedDevice || DEFAULT_DEVICE_ID),
|
||||
identity: { device_id: initedDevice || DEFAULT_DEVICE_ID, source: initedDevice ? 'request' : (DEFAULT_DEVICE_ID ? 'env' : 'unset') },
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === '/selftest') {
|
||||
try {
|
||||
const sampleUrl = 'https://api.qishui.com/luna/pc/track_v2?aid=386088&device_id=1&iid=2'
|
||||
const { signed } = signRequest({ url: sampleUrl, headers: { 'user-agent': 'LunaPC/selftest' }, deviceId: DEFAULT_DEVICE_ID || '1' })
|
||||
log(200)
|
||||
return json(res, 200, { ok: true, headers: Object.keys(signed), lengths: Object.fromEntries(Object.entries(signed).map(([k, v]) => [k, String(v).length])) })
|
||||
} catch (err) {
|
||||
log(503)
|
||||
return json(res, 503, { ok: false, error: String((err && err.message) || err), code: 'signer_error' })
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/sign' && req.method === 'POST') {
|
||||
const raw = await readBody(req)
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(raw || '{}')
|
||||
} catch {
|
||||
log(400)
|
||||
return json(res, 400, { ok: false, error: 'invalid JSON', code: 'bad_request' })
|
||||
}
|
||||
const { url: target, headers = {}, body = '', device_id: deviceId } = payload
|
||||
try {
|
||||
const { signed } = signRequest({ url: target, headers, body, deviceId })
|
||||
log(200)
|
||||
// headers 嵌套给标准调用方;顶层扁平给 Meting 风格调用方
|
||||
return json(res, 200, { ok: true, headers: signed, ...signed, device_id: initedDevice, elapsed_ms: Date.now() - started })
|
||||
} catch (err) {
|
||||
log(503)
|
||||
return json(res, 503, { ok: false, error: String((err && err.message) || err), code: 'signer_error' })
|
||||
}
|
||||
}
|
||||
|
||||
log(404)
|
||||
return json(res, 404, { ok: false, error: 'not found' })
|
||||
} catch (err) {
|
||||
const status = err && err.status ? err.status : 500
|
||||
log(status)
|
||||
return json(res, status, { ok: false, error: String((err && err.message) || err) })
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(PORT, BIND, () => {
|
||||
console.log(`[qs-signer] 监听 http://${BIND}:${PORT}(鉴权: ${TOKEN ? 'Bearer token' : '匿名(仅调试)'})`)
|
||||
loadBdms()
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
# 专有二进制与构建产物不入库
|
||||
metasecml.dll
|
||||
*.exe
|
||||
*.log
|
||||
*.obj
|
||||
@@ -0,0 +1,186 @@
|
||||
# mssdk-sign —— 汽水音乐 metasec 签名 HTTP 服务
|
||||
|
||||
把汽水 PC 客户端自带的 `metasecml.dll`(字节 metasec,官方签名主体 *Douyin Vision Co., Ltd.*)
|
||||
关在一个进程里,对外只暴露一个 HTTP `/sign`。于是:
|
||||
|
||||
- **Windows**:直接跑。
|
||||
- **Linux**:丢进 **Wine** 跑,宿主机的服务(MusicServer 等)用 HTTP 调它 —— 专有 DLL 不出 Wine 前缀,宿主机不需要 Windows 运行时。
|
||||
|
||||
对应地,MusicServer 侧 `sdk/qs/mssdk.ts` 里那套「进程内 require bdms.node」可以换成「HTTP 调本服务」,
|
||||
跨平台部署就不用装官方客户端了(二进制仍需自己从官方客户端里取,不入库、不再分发)。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| `mssdk-sign.cpp` | 全部实现(单文件,无第三方依赖:Win32 + Winsock + 自带 MD5/JSON) |
|
||||
| `build.bat` | Windows/MSVC 构建 |
|
||||
| `build-mingw.sh` | Linux 交叉编译成 exe(给 Wine 跑) |
|
||||
| `client.mjs` | Node 客户端示例(可直接 `node client.mjs --send` 验证) |
|
||||
| `REVERSE.md` | **逆向记录**:DLL 身份/壳/真实调用 ABI/行为规则/升级后如何重新定位偏移 |
|
||||
|
||||
## 构建
|
||||
|
||||
Windows(MSVC,需要 VS BuildTools):
|
||||
|
||||
```bat
|
||||
build.bat
|
||||
:: 生成 mssdk-sign.exe
|
||||
mssdk-sign.exe --selftest --dll .\metasecml.dll
|
||||
```
|
||||
|
||||
Linux → Wine(MinGW-w64 交叉编译,静态链接,不依赖 libstdc++/winpthread DLL):
|
||||
|
||||
```bash
|
||||
sudo apt install mingw-w64
|
||||
./build-mingw.sh # 生成 mssdk-sign.exe
|
||||
```
|
||||
|
||||
> 注意:源码含中文注释/字符串,**MSVC 必须带 `/utf-8`**(`build.bat` 已带),否则会报一堆莫名其妙的语法错误。
|
||||
|
||||
## 运行
|
||||
|
||||
Windows:
|
||||
|
||||
```bat
|
||||
mssdk-sign.exe --port 16889 --dll .\metasecml.dll --token 你的口令 -v
|
||||
```
|
||||
|
||||
Wine(建议放在自己的 prefix 里,和 DLL 同目录):
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/mssdk-sign && cd /opt/mssdk-sign
|
||||
cp mssdk-sign.exe metasecml.dll .
|
||||
WINEPREFIX=/opt/wine-mssdk wine mssdk-sign.exe --selftest --dll ./metasecml.dll # 先自检
|
||||
WINEPREFIX=/opt/wine-mssdk wine mssdk-sign.exe --port 16889 --bind 0.0.0.0 --token 你的口令 -v
|
||||
```
|
||||
|
||||
systemd 单元示例:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=mssdk-sign (metasec signer in Wine)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Environment=WINEPREFIX=/opt/wine-mssdk
|
||||
Environment=WINEDEBUG=-all
|
||||
WorkingDirectory=/opt/mssdk-sign
|
||||
ExecStart=/usr/bin/wine /opt/mssdk-sign/mssdk-sign.exe --port 16889 --bind 127.0.0.1 --token CHANGE_ME
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `--port <n>` | 监听端口,默认 16889 |
|
||||
| `--bind <addr>` | 默认 `127.0.0.1`;容器/跨机调用才用 `0.0.0.0`(务必配 token) |
|
||||
| `--dll <path>` | `metasecml.dll` 路径,默认 exe 同目录 |
|
||||
| `--token <t>` | 启用鉴权(`Authorization: Bearer <t>` 或 `X-Auth-Token: <t>`) |
|
||||
| `--sign-rva <hex>` | 手动指定签名函数偏移(默认按 DLL 版本表,见下) |
|
||||
| `--selftest` | 只做「加载 DLL + 签名自检」后退出(部署前必跑) |
|
||||
| `-v` | 打印每个请求的签名耗时 |
|
||||
|
||||
## 接口
|
||||
|
||||
### `GET /healthz`(免鉴权)
|
||||
|
||||
```json
|
||||
{"ok":true,"service":"mssdk-sign","version":"0.1.0",
|
||||
"signer":"D:/MusicServer/soda-mssdk/metasecml.dll",
|
||||
"dllVersion":"4.6.1.0","signRva":"0x52af0","auth":true}
|
||||
```
|
||||
|
||||
### `POST /sign`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"url": "https://api.qishui.com/luna/pc/track_v2?aid=386088&…", // 必填,带完整 query
|
||||
"body": "{\"track_id\":\"…\"}", // 可选:给了且没带 x-ss-stub 时,服务端自动补
|
||||
"headers": { // 必填:真实要发出去的那一份
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"user-agent": "LunaPC/3.7.0(452316191)",
|
||||
"x-luna-background-type": "foreground",
|
||||
"x-tt-trace-id": "00-…-…-01"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"headers": {"X-Helios":"…","X-Medusa":"…","X-Neptune":"…"},
|
||||
"added": {"x-ss-stub":"DBA20AB94302CB03AC25A216DD953258"},
|
||||
"elapsed_ms": 43
|
||||
}
|
||||
```
|
||||
|
||||
失败时:`{"ok":false,"error":"…"}` + 400(入参错)/ 401(token)/ 500(签名失败)。
|
||||
|
||||
### 三条硬性约定(踩过坑)
|
||||
|
||||
1. **`headers` 必须是真实要发出的那一份**,一个都不能少(尤其 `cookie`、`x-ss-stub`)。签名覆盖 URL + 全部请求头,
|
||||
少一个服务端就回 `HTTP 200 + 空 body`。
|
||||
2. **`x-ss-stub` = body 的 MD5(大写)**,既是签名输入也要随请求发出。本服务在你传了 `body` 时会自动算并回传
|
||||
(`added.x-ss-stub`),记得把它一起发出去。
|
||||
3. **设备要成套**:URL 里的 `device_id` / `iid` / `fp` 必须是你自己的设备;签名和 URL 里的 `device_id` 绑死,
|
||||
改了 URL 设备号 → 服务端 `200 + 空 body`(实测)。
|
||||
|
||||
### 客户端(Node)
|
||||
|
||||
```js
|
||||
import { signRequest } from './client.mjs'
|
||||
|
||||
const { headers, added } = await signRequest(url, headersToSend, body)
|
||||
await fetch(url, { method: 'POST', headers: { ...headersToSend, ...headers, ...added }, body })
|
||||
```
|
||||
|
||||
集成到 MusicServer 时,把 `sdk/qs/mssdk.ts` 的 `signQishuiRequest()` 换成上面这次 HTTP 调用即可
|
||||
(签名覆盖的 headers 与请求实际发出的 headers 保持同一份、同一顺序)。
|
||||
|
||||
自检:
|
||||
|
||||
```bash
|
||||
node client.mjs # healthz + 签一次(不发真实请求)
|
||||
node client.mjs --send # 顺带发给 api.qishui.com 验证签名被接受
|
||||
```
|
||||
|
||||
## 它是怎么调 DLL 的(逆向结论,2026-10-02)
|
||||
|
||||
- 导出表里的 `MSBridgeML` / `MSBridgeOV` / `o0o0` 都是幌子(桩 / 线程状态初始化),
|
||||
**真正的签名函数在 `image base + 0x52af0`**,原型:
|
||||
|
||||
```c
|
||||
char* sign(const char* url, const char* headers_lines); // headers: "名\r\n值\r\n…"
|
||||
```
|
||||
|
||||
- 返回 `"名\r\n值\r\n…"` 的签名头串,由 DLL 自己的释放函数(`base + 0x1d8c0`)回收。
|
||||
- **不需要 init**;DLL 版本(本机样本)= `metasecbaseml 4.6.1.0`。
|
||||
- 调用是**串行**的(内部有全局/TLS 状态),服务端用互斥锁包住,实测 20 并发全部成功。
|
||||
|
||||
## 客户端升级了怎么办(DLL 换了,偏移会变)
|
||||
|
||||
`--selftest` 会**大声失败**,不会静默出错。定位新偏移:
|
||||
|
||||
1. 从新版客户端里取 `metasecml.dll`(解包方式见 MusicServer 的 `sdk/qs/mssdk.ts` 注释)。
|
||||
2. 拿真实请求自检,确认签名是否被接受(`node client.mjs --send`)。
|
||||
3. 找新偏移:
|
||||
- 先在 `KNOWN_VERSIONS` 里加版本,用旧偏移试;不行就:
|
||||
- 用 MusicServer 仓库里的脚本脱壳 + 定位:`tmp/metasec-rebuild.py`(内存脱壳 + 重建 PE)、
|
||||
`tmp/metasec-call-test.py`(直接调用试偏移)、`tmp/bdms-abi.py`(读 `bdms.node` 运行时 IAT 槽,
|
||||
那里躺着真实地址)。找到后用 `--sign-rva 0xXXXXX` 验证,再写进 `KNOWN_VERSIONS`。
|
||||
|
||||
## 已知限制 / 未验证项
|
||||
|
||||
- **Wine 下未实测**(本机没有 Wine):DLL 依赖的都是 Wine 常规实现的 Win32 API(kernel32/advapi32/ole32/
|
||||
winhttp/ws2_32/imm32/…),但 metasec 有设备指纹采集,行为可能和原生不同。部署前请先在目标机跑
|
||||
`wine mssdk-sign.exe --selftest` 和 `node client.mjs --send`。若加载失败,先看 `WINEDEBUG=+loaddll`
|
||||
的缺失依赖,必要时 `winetricks corefonts vcrun2019`、`winecfg` 里把 Windows 版本设为 Win10。
|
||||
- `metasecml.dll` 是**官方专有二进制**(签名主体抖音视界),不入库、不再分发;本目录只放源码与构建脚本,
|
||||
DLL 自己从官方客户端取,放 exe 同目录即可。
|
||||
- 签名服务本身不联网、不存 cookie;但仍建议只监听 `127.0.0.1` 并设置 `--token`,不要暴露公网。
|
||||
@@ -0,0 +1,97 @@
|
||||
# metasec 签名组件逆向记录
|
||||
|
||||
> 面向维护者:**汽水客户端升级后签名失效时,先看这里。**
|
||||
> 相关代码:`mssdk-sign.cpp`(签名服务);构建与接口用法见同目录 `README.md`。
|
||||
> 记录时间:2026-10-02,样本:`soda-mssdk/{metasecml.dll, bdms.node}`。
|
||||
|
||||
## 1. 速览
|
||||
|
||||
| 项 | 结论 |
|
||||
|---|---|
|
||||
| 组件身份 | 字节 metasec 官方组件,内部名 `metasecbaseml` **4.6.1.0**(2024-09-04) |
|
||||
| 数字签名 | Authenticode 主体 **Douyin Vision Co., Ltd.**(DigiCert G4 Code Signing) |
|
||||
| 保护 | 自解密壳 + 运行期解密字符串表;`LoadLibrary` 后内存里即为明文镜像 |
|
||||
| 真实签名入口 | `metasecml.dll + 0x52af0`(**本版本**),`char* sign(const char* url, const char* headers)` |
|
||||
| 需要 init 吗 | **不需要**,冷进程直接调即可出有效签名 |
|
||||
| 有效性 | 已端到端验证:签名被 `api.qishui.com` 接受;不签名 / 设备不匹配 → `HTTP 200 + 0 字节` |
|
||||
| 产出能力 | `X-Helios` + `X-Medusa`(部分场景附 `X-Neptune`);**不含** Argus/Ladon/Khronos/Gorgon/a_bogus |
|
||||
|
||||
## 2. 两个文件的关系
|
||||
|
||||
- `metasecml.dll`(8.8MB):算法本体。导出表只有 3 个符号,且**都是幌子**:
|
||||
- `o0o0`(0x191c0):线程局部状态初始化(读 TEB→TLS 槽,条件成立时 memset 一个 0x148 字节结构)。
|
||||
- `MSBridgeML` / `MSBridgeOV`(同为 0x1d010):同一个桩 —— 调一次内部函数后**无条件返回 true**,签名结果不经它返回。
|
||||
- `bdms.node`(164KB):官方客户端的 N-API 胶水。对 JS 暴露 `init` / `generateHttpSignatureHeaders` / `report`;
|
||||
通过**延迟导入** `metasecml.dll` 的 `o0o0`、`MSBridgeML` 来用 DLL。
|
||||
|
||||
## 3. 真实调用 ABI(核心)
|
||||
|
||||
```c
|
||||
// headers: 官方展平形式 —— 每行 "名字\r\n值",行间也用 "\r\n" 连接
|
||||
char* sign(const char* url, const char* headers_lines);
|
||||
```
|
||||
|
||||
- 地址:`image base + 0x52af0`(该处是 `jmp` 跳板,实体在 0x52b00)。
|
||||
- 返回:`"名字\r\n值\r\n…"` 的签名头串,堆分配;释放函数 = `image base + 0x1d8c0`。
|
||||
- 线程:内部有全局/TLS 状态,**串行调用**(服务端已加互斥锁)。
|
||||
- addon 是怎么拿到这个地址的:运行时 DLL 改写了它的 IAT 槽(`bdms.node` 的 `.data` 偏移 `0x25e38`),
|
||||
槽里躺着的就是 `base+0x52af0` —— 这解释了「导出表说是 0x1d010,实际却在 0x52af0」的矛盾。
|
||||
- 壳的形态:磁盘上只有 PE 头 + 单个 8.8MB 加密节 `.xs2`(熵≈8.0),`.text/.rdata/.data/.xs0` 均无 raw 数据;
|
||||
加载时解密进内存 → **内存 dump 即得完整镜像**(重定位已修好)。
|
||||
|
||||
## 4. 行为规则(实测)
|
||||
|
||||
| 场景 | 服务端反应 |
|
||||
|---|---|
|
||||
| 不带签名 | `HTTP 200 + 0 字节` |
|
||||
| 带正确签名 | `HTTP 200` + 正常 JSON(曲名/时长/档位齐全) |
|
||||
| 签名后改 URL 里的 `device_id` | `HTTP 200 + 0 字节` → **签名与 URL 设备参数绑死** |
|
||||
| headers 少一个(尤其 `cookie`、`x-ss-stub`) | `HTTP 200 + 0 字节` → 签名覆盖**全部**请求头 |
|
||||
|
||||
- **`x-ss-stub` = body 的 MD5(大写)**,既是签名输入也必须随请求发出。
|
||||
- **场景决定头集合**:传完整真实头 → `X-Helios` + `X-Medusa`(有时附 `X-Neptune`);
|
||||
只传一个 `user-agent` 这类极简头 → **只给 `X-Neptune`**(网页兜底场景)。
|
||||
所以调用方必须把「真实要发出的那一份头」整套传进来。
|
||||
- 输出含时间戳/nonce,**每次不同**属正常。
|
||||
- 签名有效 ≠ 能拿整曲:匿名仍只有 30 秒试听(实测 236.6s 曲目 `stream_duration=30`),
|
||||
**瓶颈是登录 sessionid,不是签名**。
|
||||
|
||||
## 5. 内部结构(RTTI 符号泄漏)
|
||||
|
||||
| 类 / 符号 | 作用 |
|
||||
|---|---|
|
||||
| `IMSHttpReqSignModule` / `MSHttpReqSignModule` | HTTP 请求签名(本服务的调用目标) |
|
||||
| `IMSWebsocketReqSignModule` | WebSocket 请求签名 |
|
||||
| `MSBlackBox` / `IMSBlackBoxItem` | 黑盒数据采集 |
|
||||
| `IMSSecDeviceIDModule` | 设备指纹(device id) |
|
||||
| `IMSRiskInspectModule` / `IMSRiskInspector` | 风控检测 |
|
||||
| `IMSReportModule` | 数据上报 |
|
||||
| `MSPBSInt32/64`、`MSPBString(List)`(`__ms__`) | protobuf 序列化 |
|
||||
| `MSNetInterceptor::FuncInterceptor` + `Cronet_UrlRequest_Create`、`Cronet_Engine_SetMD5Header` | 注入 Chromium Cronet 网络栈(`x-ss-stub` 的 MD5 由它算) |
|
||||
| `MSConfig` / `MSJSON` / `MSHttpClient` / `MSTaskManager` | 基础设施;日志用 spdlog,源路径 `utils/matrix/os/windows/ms_log.cc` |
|
||||
|
||||
## 6. 保护强度评估
|
||||
|
||||
- 壳是「自解密 + 字符串按需解密」,**没有代码虚拟化**;脱壳后是正常的 MSVC x64 代码,可读可跟。
|
||||
- 敏感字符串(如 `X-Helios`)静态搜不到,运行期才解密;镜像里没找到明文标准 AES S-box
|
||||
(只有 CRC32 表、Base64 表、TEA/Blowfish 类常量)。
|
||||
- 结论:**算法级复刻不划算**——黑盒调用的集成成本≈0,而且能随官方升级同步。
|
||||
|
||||
## 7. 客户端升级后怎么重新定位偏移
|
||||
|
||||
`--selftest` 会**大声失败**,不会静默签出无效头。定位步骤:
|
||||
|
||||
1. 从新版客户端取 `metasecml.dll`(解包方式见 `sdk/qs/mssdk.ts` 头部注释)。
|
||||
2. 跑 `mssdk-sign.exe --selftest --dll <新dll>`:会打印它读到的 DLL 版本;失败即偏移变了。
|
||||
3. 找新偏移(仓库 `tmp/` 下现成脚本):
|
||||
- `tmp/metasec-rebuild.py` —— 内存脱壳 + 重建可分析 PE;
|
||||
- `tmp/bdms-abi.py` —— 读 `bdms.node` 运行时 IAT 槽,直接拿到真实签名函数地址(**最省事**);
|
||||
- `tmp/metasec-call-test.py` —— 用候选偏移直接调用,看返回是否形如 `X-…\r\n…`。
|
||||
4. 用 `--sign-rva 0xXXXXX` 验证,通过后写进 `mssdk-sign.cpp` 的 `KNOWN_VERSIONS`。
|
||||
5. 复验:`node client.mjs --send` 应得到 `HTTP 200` + 非空 body。
|
||||
|
||||
## 8. 边界
|
||||
|
||||
- `metasecml.dll` / `bdms.node` 是**官方专有二进制**,不入库、不随代码分发;各自从自己的官方客户端取,放 exe 同目录。
|
||||
- 用途限于个人自用/互操作性研究;不要对外提供签名服务,也不要用于批量刷接口。
|
||||
- 签名服务本身不联网、不落 cookie;部署仍建议只监听 `127.0.0.1` 并设置 `--token`。
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Linux 上交叉编译成 Windows exe(给 Wine 跑)。
|
||||
# 依赖: apt install mingw-w64 (Debian/Ubuntu)
|
||||
set -euo pipefail
|
||||
CXX=${CXX:-x86_64-w64-mingw32-g++}
|
||||
command -v "$CXX" >/dev/null || { echo "[x] 找不到 $CXX,请先装 mingw-w64"; exit 1; }
|
||||
|
||||
"$CXX" -std=c++17 -O2 -Wno-unknown-pragmas \
|
||||
-static -static-libgcc -static-libstdc++ \
|
||||
-o mssdk-sign.exe mssdk-sign.cpp \
|
||||
-pthread -lws2_32 -lversion
|
||||
|
||||
echo "[ok] 生成 mssdk-sign.exe(静态链接,不依赖 libstdc++/winpthread DLL)"
|
||||
echo " 自检: wine ./mssdk-sign.exe --selftest --dll ./metasecml.dll"
|
||||
@@ -0,0 +1,16 @@
|
||||
@echo off
|
||||
rem MSVC native build (Windows). Source has UTF-8 Chinese comments -> /utf-8 is required.
|
||||
setlocal
|
||||
set VCVARS=C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat
|
||||
if not exist "%VCVARS%" (
|
||||
echo [x] vcvars64.bat not found, edit VCVARS in this script
|
||||
exit /b 1
|
||||
)
|
||||
call "%VCVARS%" >nul
|
||||
cl /nologo /O2 /EHsc /std:c++17 /utf-8 /W3 mssdk-sign.cpp /link /out:mssdk-sign.exe ws2_32.lib version.lib
|
||||
if errorlevel 1 (
|
||||
echo [x] build failed
|
||||
exit /b 1
|
||||
)
|
||||
echo [ok] built mssdk-sign.exe
|
||||
echo selftest: mssdk-sign.exe --selftest --dll .\metasecml.dll
|
||||
@@ -0,0 +1,73 @@
|
||||
// mssdk-sign 的 Node 客户端示例(免依赖,Node 18+)
|
||||
//
|
||||
// 用法:
|
||||
// node client.mjs # 自检:打 /healthz + 签一次 track_v2(不发真实请求)
|
||||
// node client.mjs --send # 顺带把签名后的请求发给 api.qishui.com 验证
|
||||
//
|
||||
// 集成到 MusicServer 时,把 signQishuiRequest() 换成这个 HTTP 调用即可
|
||||
// (签名输入必须与真实发出的头完全一致,含 x-ss-stub)。
|
||||
|
||||
const BASE = process.env.MSSDK_SIGN_URL ?? 'http://127.0.0.1:16889'
|
||||
const TOKEN = process.env.MSSDK_SIGN_TOKEN ?? ''
|
||||
|
||||
const headers0 = (extra = {}) => (TOKEN ? { authorization: `Bearer ${TOKEN}`, ...extra } : extra)
|
||||
|
||||
/** 调本地签名服务;返回需要追加到请求上的头 */
|
||||
export async function signRequest(url, headers, body) {
|
||||
const res = await fetch(`${BASE}/sign`, {
|
||||
method: 'POST',
|
||||
headers: headers0({ 'content-type': 'application/json' }),
|
||||
body: JSON.stringify({ url, headers, body }),
|
||||
})
|
||||
const json = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }))
|
||||
if (!json.ok) throw new Error(json.error ?? `签名失败 HTTP ${res.status}`)
|
||||
return { headers: json.headers, added: json.added ?? {}, elapsed_ms: json.elapsed_ms }
|
||||
}
|
||||
|
||||
export async function healthz() {
|
||||
const res = await fetch(`${BASE}/healthz`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const health = await healthz()
|
||||
console.log('healthz:', health)
|
||||
|
||||
// 与 sdk/qs/mssdk.ts 的 PC 通道同构:URL 带 device_id,headers 是真实要发出的那份
|
||||
const deviceId = process.env.QS_DEVICE_ID ?? '1219822735468811019'
|
||||
const iid = process.env.QS_IID ?? '1219822735468829019'
|
||||
const body = JSON.stringify({ track_id: '7501674235158431760', media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' })
|
||||
const q = new URLSearchParams({
|
||||
aid: '386088', app_name: 'luna_pc', region: 'cn', geo_region: 'cn', os_region: 'cn', sim_region: '',
|
||||
device_id: deviceId, cdid: '', iid, version_name: '3.7.0', version_code: '30070000',
|
||||
channel: 'official', build_mode: 'master', network_carrier: '', ac: 'wifi', tz_name: 'Asia/Shanghai',
|
||||
resolution: '', device_platform: 'windows', device_type: 'Windows', os_version: 'Windows 11', fp: deviceId,
|
||||
})
|
||||
const url = `https://api.qishui.com/luna/pc/track_v2?${q}`
|
||||
const headers = {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'user-agent': 'LunaPC/3.7.0(452316191)',
|
||||
'x-luna-background-type': 'foreground',
|
||||
'x-luna-is-background-req': '0',
|
||||
'x-luna-is-local-user': '0',
|
||||
'x-tt-trace-id': `00-${'0'.repeat(16)}-${'0'.repeat(16)}-01`,
|
||||
}
|
||||
|
||||
const signed = await signRequest(url, headers, body)
|
||||
console.log('签名头:', Object.keys(signed.headers), `(${signed.elapsed_ms}ms)`, signed.added)
|
||||
|
||||
if (process.argv.includes('--send')) {
|
||||
const r = await fetch(url, { method: 'POST', headers: { ...headers, ...signed.headers, ...signed.added }, body })
|
||||
const t = await r.text()
|
||||
console.log(`真实请求: HTTP ${r.status} ${t.length} 字节`)
|
||||
if (t) {
|
||||
const j = JSON.parse(t)
|
||||
const vm = typeof j?.track_player?.video_model === 'string' ? JSON.parse(j.track_player.video_model) : j?.track_player?.video_model
|
||||
console.log(` 曲目: ${j?.track?.name} 曲长 ${j?.track?.duration}ms 试听 ${vm?.video_duration}s`)
|
||||
} else {
|
||||
console.log(' 空 body = 签名没被接受')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
// mssdk-sign —— 汽水音乐(metasec)签名 HTTP 服务
|
||||
//
|
||||
// 为什么是它:汽水 PC 客户端自带的 metasecml.dll 提供应用级签名(X-Helios/X-Medusa,
|
||||
// 部分场景附 X-Neptune)。本程序把该 DLL 关在一个进程里,对外只暴露一个 HTTP /sign,
|
||||
// 于是:Windows 上直接跑;Linux 上丢进 Wine 跑,宿主机的服务(如 MusicServer)用 HTTP 调。
|
||||
//
|
||||
// 关于 DLL 的调用方式(2026-10-02 逆向确认,版本 metasecbaseml 4.6.1.0):
|
||||
// * 导出表里的 MSBridgeML / MSBridgeOV / o0o0 都是幌子(桩/初始化),
|
||||
// 真正的签名函数在 image base + 0x52af0,原型 char* f(const char* url, const char* headers)。
|
||||
// * headers 是官方展平形式:每个头 "名字\r\n值",头与头之间也用 "\r\n" 连接。
|
||||
// * 返回 "名字\r\n值\r\n..." 的签名头串,由 DLL 自己的释放函数(base + 0x1d8c0)回收。
|
||||
// * 不需要 init;签名覆盖 URL + 全部请求头,且与 URL 里的 device_id 绑死
|
||||
// (改 URL 设备号 → 服务端 200 + 空 body)。
|
||||
// * 偏移随客户端版本变化:内置版本表 + 启动自检,不匹配会明确报错(见 README「升级怎么办」)。
|
||||
//
|
||||
// 构建(任选其一):
|
||||
// Windows/MSVC : build.bat
|
||||
// Linux/MinGW : x86_64-w64-mingw32-g++ -std=c++17 -O2 -static -static-libgcc -static-libstdc++ \
|
||||
// -o mssdk-sign.exe mssdk-sign.cpp -lws2_32 -lversion -lwinpthread
|
||||
// 运行:
|
||||
// mssdk-sign.exe --port 16889 --dll .\metasecml.dll
|
||||
// wine mssdk-sign.exe --port 16889 --dll ./metasecml.dll
|
||||
//
|
||||
// 依赖:仅 Win32 API + Winsock(无第三方库,无 C++ 异常跨模块问题)。
|
||||
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include <windows.h>
|
||||
#include <winver.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#ifdef _MSC_VER
|
||||
#pragma comment(lib, "ws2_32.lib")
|
||||
#pragma comment(lib, "version.lib")
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------- 工具
|
||||
|
||||
static std::string toLower(std::string s) {
|
||||
for (auto &c : s) c = (char)tolower((unsigned char)c);
|
||||
return s;
|
||||
}
|
||||
|
||||
static std::string trim(const std::string &s) {
|
||||
size_t a = s.find_first_not_of(" \t\r\n");
|
||||
if (a == std::string::npos) return "";
|
||||
size_t b = s.find_last_not_of(" \t\r\n");
|
||||
return s.substr(a, b - a + 1);
|
||||
}
|
||||
|
||||
static std::string jsonEscape(const std::string &s) {
|
||||
std::string o;
|
||||
o.reserve(s.size() + 8);
|
||||
for (unsigned char c : s) {
|
||||
switch (c) {
|
||||
case '"': o += "\\\""; break;
|
||||
case '\\': o += "\\\\"; break;
|
||||
case '\n': o += "\\n"; break;
|
||||
case '\r': o += "\\r"; break;
|
||||
case '\t': o += "\\t"; break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
char b[8];
|
||||
snprintf(b, sizeof b, "\\u%04x", c);
|
||||
o += b;
|
||||
} else {
|
||||
o += (char)c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- MD5(用于 x-ss-stub = body 的 MD5 大写)
|
||||
|
||||
namespace md5impl {
|
||||
struct Ctx {
|
||||
uint32_t a = 0x67452301, b = 0xefcdab89, c = 0x98badcfe, d = 0x10325476;
|
||||
uint64_t len = 0;
|
||||
uint8_t buf[64];
|
||||
size_t n = 0;
|
||||
};
|
||||
|
||||
static const uint32_t K[64] = {
|
||||
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
|
||||
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
|
||||
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
|
||||
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
|
||||
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
|
||||
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
|
||||
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
|
||||
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391};
|
||||
static const uint8_t S[64] = {7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
||||
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
||||
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
||||
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21};
|
||||
|
||||
static inline uint32_t rotl(uint32_t x, int c) { return (x << c) | (x >> (32 - c)); }
|
||||
|
||||
static void block(Ctx &ctx, const uint8_t *p) {
|
||||
uint32_t m[16];
|
||||
for (int i = 0; i < 16; i++)
|
||||
m[i] = (uint32_t)p[i * 4] | ((uint32_t)p[i * 4 + 1] << 8) | ((uint32_t)p[i * 4 + 2] << 16) |
|
||||
((uint32_t)p[i * 4 + 3] << 24);
|
||||
uint32_t a = ctx.a, b = ctx.b, c = ctx.c, d = ctx.d;
|
||||
for (int i = 0; i < 64; i++) {
|
||||
uint32_t f, g;
|
||||
if (i < 16) { f = (b & c) | (~b & d); g = (uint32_t)i; }
|
||||
else if (i < 32) { f = (d & b) | (~d & c); g = (uint32_t)(5 * i + 1) & 15; }
|
||||
else if (i < 48) { f = b ^ c ^ d; g = (uint32_t)(3 * i + 5) & 15; }
|
||||
else { f = c ^ (b | ~d); g = (uint32_t)(7 * i) & 15; }
|
||||
uint32_t t = d;
|
||||
d = c;
|
||||
c = b;
|
||||
b = b + rotl(a + f + K[i] + m[g], S[i]);
|
||||
a = t;
|
||||
}
|
||||
ctx.a += a; ctx.b += b; ctx.c += c; ctx.d += d;
|
||||
}
|
||||
|
||||
static void update(Ctx &ctx, const void *data, size_t len) {
|
||||
const uint8_t *p = (const uint8_t *)data;
|
||||
ctx.len += len;
|
||||
while (len) {
|
||||
size_t take = 64 - ctx.n;
|
||||
if (take > len) take = len;
|
||||
memcpy(ctx.buf + ctx.n, p, take);
|
||||
ctx.n += take; p += take; len -= take;
|
||||
if (ctx.n == 64) { block(ctx, ctx.buf); ctx.n = 0; }
|
||||
}
|
||||
}
|
||||
|
||||
static std::string hex(const Ctx &in) {
|
||||
Ctx ctx = in;
|
||||
uint64_t bits = ctx.len * 8;
|
||||
uint8_t pad = 0x80;
|
||||
update(ctx, &pad, 1);
|
||||
uint8_t zero = 0;
|
||||
while (ctx.n != 56) update(ctx, &zero, 1);
|
||||
uint8_t lenb[8];
|
||||
for (int i = 0; i < 8; i++) lenb[i] = (uint8_t)(bits >> (8 * i));
|
||||
update(ctx, lenb, 8);
|
||||
uint32_t v[4] = {ctx.a, ctx.b, ctx.c, ctx.d};
|
||||
static const char *H = "0123456789abcdef";
|
||||
std::string out;
|
||||
out.reserve(32);
|
||||
for (int i = 0; i < 4; i++)
|
||||
for (int j = 0; j < 4; j++) {
|
||||
out += H[(v[i] >> (8 * j + 4)) & 0xf];
|
||||
out += H[(v[i] >> (8 * j)) & 0xf];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} // namespace md5impl
|
||||
|
||||
static std::string md5HexUpper(const std::string &data) {
|
||||
md5impl::Ctx ctx;
|
||||
md5impl::update(ctx, data.data(), data.size());
|
||||
std::string h = md5impl::hex(ctx);
|
||||
for (auto &c : h) c = (char)toupper((unsigned char)c);
|
||||
return h;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 极简 JSON(只支持本服务需要的子集)
|
||||
|
||||
struct JVal {
|
||||
enum T { Nul, Bool, Num, Str, Arr, Obj } t = Nul;
|
||||
std::string s; // Str / Num / Bool 的原始文本
|
||||
std::vector<JVal> a; // Arr
|
||||
std::vector<std::pair<std::string, JVal>> o; // Obj(保序)
|
||||
const JVal *get(const std::string &k) const {
|
||||
for (auto &kv : o)
|
||||
if (kv.first == k) return &kv.second;
|
||||
return nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
namespace json {
|
||||
struct P {
|
||||
const std::string &s;
|
||||
size_t i = 0;
|
||||
explicit P(const std::string &src) : s(src) {}
|
||||
void ws() { while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r')) i++; }
|
||||
bool lit(const char *w) {
|
||||
size_t n = strlen(w);
|
||||
if (s.compare(i, n, w) == 0) { i += n; return true; }
|
||||
return false;
|
||||
}
|
||||
void utf8(uint32_t cp, std::string &out) {
|
||||
if (cp < 0x80) out += (char)cp;
|
||||
else if (cp < 0x800) { out += (char)(0xC0 | (cp >> 6)); out += (char)(0x80 | (cp & 0x3F)); }
|
||||
else if (cp < 0x10000) {
|
||||
out += (char)(0xE0 | (cp >> 12));
|
||||
out += (char)(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += (char)(0x80 | (cp & 0x3F));
|
||||
} else {
|
||||
out += (char)(0xF0 | (cp >> 18));
|
||||
out += (char)(0x80 | ((cp >> 12) & 0x3F));
|
||||
out += (char)(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += (char)(0x80 | (cp & 0x3F));
|
||||
}
|
||||
}
|
||||
bool hex4(uint32_t &v) {
|
||||
v = 0;
|
||||
for (int k = 0; k < 4; k++) {
|
||||
if (i >= s.size()) return false;
|
||||
char c = s[i++];
|
||||
v <<= 4;
|
||||
if (c >= '0' && c <= '9') v |= (uint32_t)(c - '0');
|
||||
else if (c >= 'a' && c <= 'f') v |= (uint32_t)(c - 'a' + 10);
|
||||
else if (c >= 'A' && c <= 'F') v |= (uint32_t)(c - 'A' + 10);
|
||||
else return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool str(std::string &out) {
|
||||
if (i >= s.size() || s[i] != '"') return false;
|
||||
i++;
|
||||
out.clear();
|
||||
while (i < s.size()) {
|
||||
char c = s[i++];
|
||||
if (c == '"') return true;
|
||||
if (c != '\\') { out += c; continue; }
|
||||
if (i >= s.size()) return false;
|
||||
char e = s[i++];
|
||||
switch (e) {
|
||||
case '"': out += '"'; break;
|
||||
case '\\': out += '\\'; break;
|
||||
case '/': out += '/'; break;
|
||||
case 'b': out += '\b'; break;
|
||||
case 'f': out += '\f'; break;
|
||||
case 'n': out += '\n'; break;
|
||||
case 'r': out += '\r'; break;
|
||||
case 't': out += '\t'; break;
|
||||
case 'u': {
|
||||
uint32_t cp;
|
||||
if (!hex4(cp)) return false;
|
||||
if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < s.size() && s[i] == '\\' && s[i + 1] == 'u') {
|
||||
size_t save = i;
|
||||
i += 2;
|
||||
uint32_t lo;
|
||||
if (hex4(lo) && lo >= 0xDC00 && lo <= 0xDFFF)
|
||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
|
||||
else
|
||||
i = save;
|
||||
}
|
||||
utf8(cp, out);
|
||||
break;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool val(JVal &v) {
|
||||
ws();
|
||||
if (i >= s.size()) return false;
|
||||
char c = s[i];
|
||||
if (c == '{') {
|
||||
v.t = JVal::Obj;
|
||||
i++;
|
||||
ws();
|
||||
if (i < s.size() && s[i] == '}') { i++; return true; }
|
||||
while (true) {
|
||||
ws();
|
||||
std::string k;
|
||||
if (!str(k)) return false;
|
||||
ws();
|
||||
if (i >= s.size() || s[i] != ':') return false;
|
||||
i++;
|
||||
JVal cv;
|
||||
if (!val(cv)) return false;
|
||||
v.o.emplace_back(std::move(k), std::move(cv));
|
||||
ws();
|
||||
if (i < s.size() && s[i] == ',') { i++; continue; }
|
||||
if (i < s.size() && s[i] == '}') { i++; return true; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (c == '[') {
|
||||
v.t = JVal::Arr;
|
||||
i++;
|
||||
ws();
|
||||
if (i < s.size() && s[i] == ']') { i++; return true; }
|
||||
while (true) {
|
||||
JVal cv;
|
||||
if (!val(cv)) return false;
|
||||
v.a.push_back(std::move(cv));
|
||||
ws();
|
||||
if (i < s.size() && s[i] == ',') { i++; continue; }
|
||||
if (i < s.size() && s[i] == ']') { i++; return true; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (c == '"') { v.t = JVal::Str; return str(v.s); }
|
||||
if (lit("true")) { v.t = JVal::Bool; v.s = "true"; return true; }
|
||||
if (lit("false")) { v.t = JVal::Bool; v.s = "false"; return true; }
|
||||
if (lit("null")) { v.t = JVal::Nul; return true; }
|
||||
size_t b = i;
|
||||
while (i < s.size() && (isdigit((unsigned char)s[i]) || s[i] == '-' || s[i] == '+' || s[i] == '.' ||
|
||||
s[i] == 'e' || s[i] == 'E'))
|
||||
i++;
|
||||
if (i == b) return false;
|
||||
v.t = JVal::Num;
|
||||
v.s = s.substr(b, i - b);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
} // namespace json
|
||||
|
||||
// ---------------------------------------------------------------- 签名器
|
||||
|
||||
typedef char *(*SignFn)(const char *, const char *);
|
||||
typedef void (*FreeFn)(void *);
|
||||
|
||||
// SEH 必须放在不含 C++ 析构对象的函数里(否则 MSVC 报 C2712)
|
||||
#ifdef _MSC_VER
|
||||
static char *callSignRaw(SignFn fn, const char *url, const char *headers) {
|
||||
__try {
|
||||
return fn(url, headers);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct RvaPair {
|
||||
uint64_t sign;
|
||||
uint64_t freefn;
|
||||
};
|
||||
|
||||
// 已知版本 → 偏移(客户端升级后新增一行;见 README)
|
||||
static const struct {
|
||||
const char *version;
|
||||
RvaPair rva;
|
||||
} KNOWN_VERSIONS[] = {
|
||||
{"4.6.1.0", {0x52af0, 0x1d8c0}},
|
||||
};
|
||||
static const RvaPair DEFAULT_RVA = {0x52af0, 0x1d8c0};
|
||||
|
||||
class Signer {
|
||||
public:
|
||||
bool load(const std::string &dllPath, uint64_t rvaOverride, std::string &err) {
|
||||
dllPath_ = dllPath;
|
||||
HMODULE m = LoadLibraryExW(toWide(dllPath).c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH);
|
||||
if (!m) {
|
||||
char b[256];
|
||||
snprintf(b, sizeof b, "LoadLibraryExW 失败(错误码 %lu): %s", GetLastError(), dllPath.c_str());
|
||||
err = b;
|
||||
return false;
|
||||
}
|
||||
mod_ = m;
|
||||
version_ = readVersion(dllPath);
|
||||
|
||||
RvaPair use = DEFAULT_RVA;
|
||||
if (rvaOverride) {
|
||||
use = {rvaOverride, rvaOverride + (DEFAULT_RVA.freefn - DEFAULT_RVA.sign)};
|
||||
err.clear();
|
||||
} else {
|
||||
bool found = false;
|
||||
for (auto &kv : KNOWN_VERSIONS) {
|
||||
if (version_ == kv.version) { use = kv.rva; found = true; break; }
|
||||
}
|
||||
if (!found) {
|
||||
char b[256];
|
||||
snprintf(b, sizeof b,
|
||||
"DLL 版本 %s 不在已知表内(已知: 4.6.1.0)。将按默认偏移 %#llx 试跑,"
|
||||
"失败请用 --sign-rva 指定(见 README)",
|
||||
version_.empty() ? "(读不到)" : version_.c_str(), (unsigned long long)DEFAULT_RVA.sign);
|
||||
warn_ = b;
|
||||
}
|
||||
}
|
||||
|
||||
sign_ = (SignFn)((uint8_t *)mod_ + use.sign);
|
||||
free_ = (FreeFn)((uint8_t *)mod_ + use.freefn);
|
||||
rva_ = use;
|
||||
|
||||
// 启动自检:签一个探针,确认偏移正确(不发网络请求)
|
||||
if (!selfTest()) {
|
||||
err = "签名自检失败:偏移 " + hexStr(use.sign) + " 处不是有效的签名函数。"
|
||||
"客户端升级后请重新定位(README「升级怎么办」)";
|
||||
sign_ = nullptr;
|
||||
free_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ready() const { return sign_ != nullptr; }
|
||||
std::string version() const { return version_; }
|
||||
std::string warn() const { return warn_; }
|
||||
std::string dllPath() const { return dllPath_; }
|
||||
uint64_t signRva() const { return rva_.sign; }
|
||||
|
||||
// 返回 "名字\r\n值\r\n..." 的签名头串;失败返回空
|
||||
std::string sign(const std::string &url, const std::string &headersFlat) {
|
||||
if (!sign_) return "";
|
||||
std::lock_guard<std::mutex> lk(mu_); // DLL 内部有全局/TLS 状态,串行调用最稳
|
||||
#ifdef _MSC_VER
|
||||
char *r = callSignRaw(sign_, url.c_str(), headersFlat.c_str());
|
||||
#else
|
||||
// MinGW 无 MSVC 式 __try/__except;调用异常会直接终止进程(签名偏移错时会立刻暴露)
|
||||
char *r = sign_(url.c_str(), headersFlat.c_str());
|
||||
#endif
|
||||
if (!r) return "";
|
||||
std::string out = r;
|
||||
if (free_) free_(r);
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
static std::wstring toWide(const std::string &s) {
|
||||
int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, nullptr, 0);
|
||||
std::wstring w(n > 0 ? n - 1 : 0, L'\0');
|
||||
if (n > 0) MultiByteToWideChar(CP_UTF8, 0, s.c_str(), -1, &w[0], n);
|
||||
return w;
|
||||
}
|
||||
static std::string hexStr(uint64_t v) {
|
||||
char b[32];
|
||||
snprintf(b, sizeof b, "0x%llx", (unsigned long long)v);
|
||||
return b;
|
||||
}
|
||||
static std::string readVersion(const std::string &path) {
|
||||
DWORD dummy = 0;
|
||||
DWORD sz = GetFileVersionInfoSizeW(toWide(path).c_str(), &dummy);
|
||||
if (!sz) return "";
|
||||
std::vector<char> buf(sz);
|
||||
if (!GetFileVersionInfoW(toWide(path).c_str(), 0, sz, buf.data())) return "";
|
||||
VS_FIXEDFILEINFO *fi = nullptr;
|
||||
UINT len = 0;
|
||||
if (!VerQueryValueW(buf.data(), L"\\", (void **)&fi, &len) || !fi) return "";
|
||||
char b[64];
|
||||
snprintf(b, sizeof b, "%u.%u.%u.%u", HIWORD(fi->dwFileVersionMS), LOWORD(fi->dwFileVersionMS),
|
||||
HIWORD(fi->dwFileVersionLS), LOWORD(fi->dwFileVersionLS));
|
||||
return b;
|
||||
}
|
||||
bool selfTest() {
|
||||
const char *probeUrl =
|
||||
"https://api.qishui.com/luna/pc/track_v2?aid=386088&app_name=luna_pc&device_id=1";
|
||||
const char *probeHdr = "user-agent\r\nLunaPC/3.7.0(452316191)";
|
||||
std::string r = sign(probeUrl, probeHdr);
|
||||
return r.find("X-") != std::string::npos && r.find("\r\n") != std::string::npos;
|
||||
}
|
||||
|
||||
HMODULE mod_ = nullptr;
|
||||
SignFn sign_ = nullptr;
|
||||
FreeFn free_ = nullptr;
|
||||
RvaPair rva_{};
|
||||
std::string dllPath_, version_, warn_;
|
||||
std::mutex mu_;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- HTTP 服务
|
||||
|
||||
struct Config {
|
||||
std::string bind = "127.0.0.1";
|
||||
int port = 16889;
|
||||
std::string dll;
|
||||
std::string token; // 非空则要求 Bearer / X-Auth-Token
|
||||
uint64_t signRva = 0; // 0 = 用版本表
|
||||
bool selftestOnly = false;
|
||||
bool verbose = false;
|
||||
};
|
||||
|
||||
static std::string httpDate() {
|
||||
SYSTEMTIME st;
|
||||
GetSystemTime(&st);
|
||||
char b[64];
|
||||
snprintf(b, sizeof b, "%04d-%02d-%02dT%02d:%02d:%02dZ", st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute,
|
||||
st.wSecond);
|
||||
return b;
|
||||
}
|
||||
|
||||
static std::string respJson(int code, const std::string &body) {
|
||||
const char *reason = code == 200 ? "OK" : (code == 400 ? "Bad Request" : (code == 401 ? "Unauthorized" : (code == 404 ? "Not Found" : "Internal Server Error")));
|
||||
char head[512];
|
||||
snprintf(head, sizeof head,
|
||||
"HTTP/1.1 %d %s\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: %zu\r\n"
|
||||
"Connection: close\r\nServer: mssdk-sign/0.1.0\r\n\r\n",
|
||||
code, reason, body.size());
|
||||
return std::string(head) + body;
|
||||
}
|
||||
|
||||
static std::string errBody(const std::string &msg) { return "{\"ok\":false,\"error\":\"" + jsonEscape(msg) + "\"}"; }
|
||||
|
||||
static bool sendAll(SOCKET s, const std::string &data) {
|
||||
size_t sent = 0;
|
||||
while (sent < data.size()) {
|
||||
int n = send(s, data.data() + sent, (int)(data.size() - sent), 0);
|
||||
if (n <= 0) return false;
|
||||
sent += (size_t)n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 读一个完整请求(headers + body)
|
||||
static bool readRequest(SOCKET s, std::string &method, std::string &path, std::string &headersRaw,
|
||||
std::string &body) {
|
||||
std::string buf;
|
||||
char tmp[4096];
|
||||
size_t headEnd = std::string::npos;
|
||||
while ((headEnd = buf.find("\r\n\r\n")) == std::string::npos) {
|
||||
int n = recv(s, tmp, sizeof tmp, 0);
|
||||
if (n <= 0) return false;
|
||||
buf.append(tmp, n);
|
||||
if (buf.size() > 256 * 1024) return false;
|
||||
}
|
||||
headersRaw = buf.substr(0, headEnd);
|
||||
size_t lineEnd = headersRaw.find("\r\n");
|
||||
std::string reqLine = headersRaw.substr(0, lineEnd == std::string::npos ? headersRaw.size() : lineEnd);
|
||||
size_t sp1 = reqLine.find(' '), sp2 = reqLine.rfind(' ');
|
||||
if (sp1 == std::string::npos || sp2 == std::string::npos || sp2 <= sp1) return false;
|
||||
method = reqLine.substr(0, sp1);
|
||||
path = reqLine.substr(sp1 + 1, sp2 - sp1 - 1);
|
||||
|
||||
size_t contentLength = 0;
|
||||
{
|
||||
std::string h = toLower(headersRaw);
|
||||
size_t p = h.find("content-length:");
|
||||
if (p != std::string::npos) contentLength = (size_t)strtoul(headersRaw.c_str() + p + 15, nullptr, 10);
|
||||
}
|
||||
if (contentLength > 4 * 1024 * 1024) return false;
|
||||
body = buf.substr(headEnd + 4);
|
||||
while (body.size() < contentLength) {
|
||||
int n = recv(s, tmp, sizeof tmp, 0);
|
||||
if (n <= 0) break;
|
||||
body.append(tmp, n);
|
||||
}
|
||||
if (body.size() > contentLength) body.resize(contentLength);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool authorized(const Config &cfg, const std::string &headersRaw) {
|
||||
if (cfg.token.empty()) return true;
|
||||
std::string h = toLower(headersRaw);
|
||||
std::string want = toLower(cfg.token);
|
||||
if (h.find("authorization: bearer " + want) != std::string::npos) return true;
|
||||
if (h.find("x-auth-token: " + want) != std::string::npos) return true;
|
||||
if (h.find("?token=" + want) != std::string::npos) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static std::string handleSign(Signer &signer, const std::string &body, bool verbose) {
|
||||
JVal root;
|
||||
json::P p(body);
|
||||
if (!p.val(root) || root.t != JVal::Obj) return respJson(400, errBody("请求体不是 JSON 对象"));
|
||||
|
||||
const JVal *jurl = root.get("url");
|
||||
if (!jurl || jurl->t != JVal::Str || jurl->s.empty()) return respJson(400, errBody("缺少 url 字段"));
|
||||
|
||||
// 保序收集 headers
|
||||
std::vector<std::pair<std::string, std::string>> headers;
|
||||
bool hasStub = false;
|
||||
if (const JVal *jh = root.get("headers")) {
|
||||
if (jh->t != JVal::Obj) return respJson(400, errBody("headers 必须是对象"));
|
||||
for (auto &kv : jh->o) {
|
||||
if (kv.second.t != JVal::Str) continue; // 只签名字符串头
|
||||
headers.emplace_back(kv.first, kv.second.s);
|
||||
if (toLower(kv.first) == "x-ss-stub") hasStub = true;
|
||||
}
|
||||
}
|
||||
if (headers.empty()) return respJson(400, errBody("headers 不能为空(签名覆盖全部请求头,必须传真实要发出的那一份)"));
|
||||
|
||||
// 给了 body 且没带 x-ss-stub → 补上(body 的 MD5 大写),与官方客户端一致
|
||||
std::string addedStub;
|
||||
if (const JVal *jb = root.get("body")) {
|
||||
if (jb->t == JVal::Str && !hasStub) {
|
||||
addedStub = md5HexUpper(jb->s);
|
||||
headers.emplace_back("x-ss-stub", addedStub);
|
||||
}
|
||||
}
|
||||
|
||||
std::string flat;
|
||||
for (size_t i = 0; i < headers.size(); i++) {
|
||||
if (i) flat += "\r\n";
|
||||
flat += headers[i].first + "\r\n" + headers[i].second;
|
||||
}
|
||||
|
||||
auto t0 = std::chrono::steady_clock::now();
|
||||
std::string raw = signer.sign(jurl->s, flat);
|
||||
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - t0).count();
|
||||
|
||||
if (raw.empty())
|
||||
return respJson(500, errBody("签名失败(DLL 未返回内容;检查 headers 是否与将要发出的完全一致)"));
|
||||
|
||||
// 解析 "名字\r\n值\r\n..." 成对
|
||||
std::vector<std::string> f;
|
||||
{
|
||||
size_t b = 0;
|
||||
while (b <= raw.size()) {
|
||||
size_t e = raw.find("\r\n", b);
|
||||
if (e == std::string::npos) { f.push_back(raw.substr(b)); break; }
|
||||
f.push_back(raw.substr(b, e - b));
|
||||
b = e + 2;
|
||||
}
|
||||
while (!f.empty() && f.back().empty()) f.pop_back();
|
||||
}
|
||||
if (f.size() < 2 || (f.size() % 2) != 0) return respJson(500, errBody("DLL 返回格式异常"));
|
||||
|
||||
std::string out = "{\"ok\":true,\"headers\":{";
|
||||
for (size_t i = 0; i + 1 < f.size(); i += 2) {
|
||||
if (i) out += ",";
|
||||
out += "\"" + jsonEscape(f[i]) + "\":\"" + jsonEscape(f[i + 1]) + "\"";
|
||||
}
|
||||
out += "},\"elapsed_ms\":" + std::to_string(ms);
|
||||
if (!addedStub.empty()) out += ",\"added\":{\"x-ss-stub\":\"" + addedStub + "\"}";
|
||||
out += "}";
|
||||
if (verbose) printf("[sign] %s -> %zu 个签名头, %lldms\n", jurl->s.substr(0, 80).c_str(), f.size() / 2, (long long)ms);
|
||||
return respJson(200, out);
|
||||
}
|
||||
|
||||
static void handleConn(SOCKET s, const Config &cfg, Signer &signer) {
|
||||
std::string method, path, headersRaw, body;
|
||||
if (!readRequest(s, method, path, headersRaw, body)) { closesocket(s); return; }
|
||||
std::string route = path;
|
||||
size_t q = route.find('?');
|
||||
if (q != std::string::npos) route = route.substr(0, q);
|
||||
|
||||
std::string resp;
|
||||
if (route == "/healthz") {
|
||||
std::string j = "{\"ok\":true,\"service\":\"mssdk-sign\",\"version\":\"0.1.0\",\"signer\":\"" +
|
||||
jsonEscape(signer.dllPath()) + "\",\"dllVersion\":\"" + jsonEscape(signer.version()) +
|
||||
"\",\"signRva\":\"" + [&] {
|
||||
char b[32];
|
||||
snprintf(b, sizeof b, "0x%llx", (unsigned long long)signer.signRva());
|
||||
return std::string(b);
|
||||
}() + "\",\"auth\":" + (cfg.token.empty() ? "false" : "true") + "}";
|
||||
resp = respJson(200, j);
|
||||
} else if (route == "/config") {
|
||||
if (!authorized(cfg, headersRaw)) {
|
||||
resp = respJson(401, errBody("缺少或错误的 token"));
|
||||
} else {
|
||||
resp = respJson(200, "{\"ok\":true,\"signRva\":\"" + std::string("see /healthz") +
|
||||
"\",\"headersOrder\":\"按请求里的顺序签名,响应里的 headers 需原样发出\"}");
|
||||
}
|
||||
} else if (route == "/sign") {
|
||||
if (method != "POST") resp = respJson(400, errBody("请用 POST /sign"));
|
||||
else if (!authorized(cfg, headersRaw)) resp = respJson(401, errBody("缺少或错误的 token"));
|
||||
else resp = handleSign(signer, body, cfg.verbose);
|
||||
} else if (route == "/") {
|
||||
resp = respJson(200, "{\"ok\":true,\"usage\":\"POST /sign {url,headers,body?} | GET /healthz\"}");
|
||||
} else {
|
||||
resp = respJson(404, errBody("未知路径"));
|
||||
}
|
||||
sendAll(s, resp);
|
||||
closesocket(s);
|
||||
}
|
||||
|
||||
static void printUsage() {
|
||||
printf(
|
||||
"mssdk-sign —— 汽水音乐 metasec 签名服务\n"
|
||||
"用法: mssdk-sign [选项]\n"
|
||||
" --port <n> 监听端口(默认 16889)\n"
|
||||
" --bind <addr> 绑定地址(默认 127.0.0.1;容器内跨机调用用 0.0.0.0)\n"
|
||||
" --dll <path> metasecml.dll 路径(默认 exe 同目录 metasecml.dll)\n"
|
||||
" --token <t> 启用鉴权(Bearer / X-Auth-Token)\n"
|
||||
" --sign-rva <hex> 手动指定签名函数偏移(默认按 DLL 版本表)\n"
|
||||
" --selftest 只做加载+签名自检后退出\n"
|
||||
" -v 打印每个请求的签名日志\n"
|
||||
"接口:\n"
|
||||
" GET /healthz\n"
|
||||
" POST /sign {\"url\":\"https://...\",\"body\":\"可选\",\"headers\":{\"user-agent\":\"...\"}}\n"
|
||||
" → {\"ok\":true,\"headers\":{\"X-Helios\":\"...\",\"X-Medusa\":\"...\"}}\n");
|
||||
}
|
||||
|
||||
static std::string exeDir() {
|
||||
char b[MAX_PATH];
|
||||
DWORD n = GetModuleFileNameA(nullptr, b, MAX_PATH);
|
||||
std::string p(b, n);
|
||||
size_t s = p.find_last_of("\\/");
|
||||
return s == std::string::npos ? "." : p.substr(0, s);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
Config cfg;
|
||||
for (int i = 1; i < argc; i++) {
|
||||
std::string a = argv[i];
|
||||
auto next = [&]() -> std::string { return (i + 1 < argc) ? argv[++i] : ""; };
|
||||
if (a == "--port") cfg.port = atoi(next().c_str());
|
||||
else if (a == "--bind") cfg.bind = next();
|
||||
else if (a == "--dll") cfg.dll = next();
|
||||
else if (a == "--token") cfg.token = next();
|
||||
else if (a == "--sign-rva") cfg.signRva = strtoull(next().c_str(), nullptr, 16);
|
||||
else if (a == "--selftest") cfg.selftestOnly = true;
|
||||
else if (a == "-v") cfg.verbose = true;
|
||||
else if (a == "-h" || a == "--help") { printUsage(); return 0; }
|
||||
}
|
||||
if (cfg.dll.empty()) cfg.dll = exeDir() + "\\metasecml.dll";
|
||||
|
||||
// MD5 自检(防止 x-ss-stub 算错)
|
||||
if (md5HexUpper("abc") != "900150983CD24FB0D6963F7D28E17F72") {
|
||||
fprintf(stderr, "MD5 自检失败,拒绝启动\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
Signer signer;
|
||||
std::string err;
|
||||
if (!signer.load(cfg.dll, cfg.signRva, err)) {
|
||||
fprintf(stderr, "签名器加载失败: %s\n", err.c_str());
|
||||
return 3;
|
||||
}
|
||||
if (!signer.warn().empty()) fprintf(stderr, "警告: %s\n", signer.warn().c_str());
|
||||
printf("签名器就绪: %s (DLL 版本 %s, 签名偏移 0x%llx)\n", cfg.dll.c_str(),
|
||||
signer.version().empty() ? "未知" : signer.version().c_str(), (unsigned long long)signer.signRva());
|
||||
if (cfg.selftestOnly) {
|
||||
printf("自检通过。\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
WSADATA wsa;
|
||||
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
|
||||
fprintf(stderr, "WSAStartup 失败\n");
|
||||
return 4;
|
||||
}
|
||||
SOCKET srv = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (srv == INVALID_SOCKET) {
|
||||
fprintf(stderr, "socket 失败\n");
|
||||
return 4;
|
||||
}
|
||||
BOOL yes = TRUE;
|
||||
setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, (const char *)&yes, sizeof yes);
|
||||
sockaddr_in addr{};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons((u_short)cfg.port);
|
||||
inet_pton(AF_INET, cfg.bind.c_str(), &addr.sin_addr);
|
||||
if (bind(srv, (sockaddr *)&addr, sizeof addr) != 0 || listen(srv, 32) != 0) {
|
||||
fprintf(stderr, "bind/listen %s:%d 失败(错误码 %d)\n", cfg.bind.c_str(), cfg.port, WSAGetLastError());
|
||||
return 4;
|
||||
}
|
||||
printf("监听 http://%s:%d (/healthz, POST /sign)%s\n", cfg.bind.c_str(), cfg.port,
|
||||
cfg.token.empty() ? "" : " [已启用 token 鉴权]");
|
||||
fflush(stdout);
|
||||
|
||||
for (;;) {
|
||||
SOCKET c = accept(srv, nullptr, nullptr);
|
||||
if (c == INVALID_SOCKET) continue;
|
||||
DWORD tv = 15000;
|
||||
setsockopt(c, SOL_SOCKET, SO_RCVTIMEO, (const char *)&tv, sizeof tv);
|
||||
setsockopt(c, SOL_SOCKET, SO_SNDTIMEO, (const char *)&tv, sizeof tv);
|
||||
std::thread(handleConn, c, std::cref(cfg), std::ref(signer)).detach();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Reference in new issue
Block a user