跳转到内容
123xiao | 无名键客

《从 Cookie 签名到请求重放:中级开发者实战分析 Web 逆向中的鉴权参数生成逻辑》

字数: 0 阅读时长: 1 分钟

从 Cookie 签名到请求重放:中级开发者实战分析 Web 逆向中的鉴权参数生成逻辑

很多开发者第一次接触 Web 逆向,往往不是从“破解”开始,而是从一个非常朴素的问题开始:

为什么我把浏览器里的请求原样复制到代码里,还是会 401、403,或者返回“签名错误”?

我自己刚开始做这类分析时,也以为“请求头抄全了就行”。后来才发现,真正决定请求能不能成功的,往往不是 User-Agent,而是那些看起来不起眼、其实动态生成的参数:Cookie、签名字段、时间戳、随机串、设备指纹、加密载荷等等。

这篇文章不讲“神秘技巧”,而是从中级开发者能落地的角度,带你系统梳理:

  • Cookie 签名到底在保护什么
  • 请求重放为什么会失败
  • 鉴权参数通常在哪一层生成
  • 如何一步步定位签名逻辑
  • 如何写一个可运行的小型复现实验

说明:本文聚焦合法授权的测试、调试、联调和安全研究场景。不要将文中方法用于未授权目标。


背景与问题

在现代 Web 应用里,一个请求能否被服务端接受,通常不只取决于接口路径和业务参数。

服务端往往会验证以下信息:

  • Cookie 是否有效
  • Cookie 是否被签名
  • 请求参数是否参与签名
  • 时间戳是否在允许窗口内
  • 随机数是否重复
  • 请求体是否被篡改
  • Header 中某些字段是否参与校验
  • 是否存在一次性 token / CSRF token

所以你在浏览器里抓到一个成功请求,不代表它可以被无限次重放。失败通常有三类原因:

  1. 静态复制失败:你抄的是一次性参数
  2. 时效性失效:时间戳、nonce、session 过期
  3. 上下文缺失:浏览器环境里有 JS 动态生成逻辑,而你在脚本里没执行

一个典型场景

假设某接口请求如下:

POST /api/order/list HTTP/1.1
Host: example.com
Cookie: session=abc123; sig=9f8...
X-Timestamp: 1700000000
X-Nonce: 4d2a1c
X-Sign: 5f4dcc3b5aa765d61d8327deb882cf99
Content-Type: application/json

{"page":1,"size":20}

表面上看只是普通 JSON 请求,但真实校验链路可能是:

  • session 标识当前登录态
  • sig 是服务端签发的 Cookie 签名
  • X-Timestamp 控制请求时效
  • X-Nonce 防止重复提交
  • X-Sign = MD5(path + body + timestamp + nonce + secret) 或更复杂逻辑

如果你只复制一次请求,几分钟后再发,很可能就失效了。


前置知识

在正式动手前,建议你对下面几件事有基础认识:

  • HTTP 请求结构:URL、Header、Body、Cookie
  • 浏览器开发者工具:Network、Sources、Application
  • 常见编码与哈希:Base64、URL Encode、MD5、SHA256、HMAC
  • JavaScript 基础:对象序列化、JSON、时间戳、随机数
  • Python / Node.js 至少会一种,方便做重放验证

如果你已经做过接口联调,但对“签名为什么这样算”还不够有把握,那么本文正适合你。


核心原理

这一部分是关键。先建立一个分析框架,不然后面抓到一堆 JS 很容易迷路。

1. 鉴权参数的常见分类

我习惯把鉴权参数分成四类:

类型常见位置作用
会话态参数Cookie / Authorization标识登录身份
完整性参数sign / token / digest防止参数被篡改
时效性参数timestamp / expire限制请求可用时间
防重放参数nonce / requestId防止相同请求重复使用

它们通常不是孤立存在,而是组合使用。

flowchart TD
    A[请求发起] --> B[读取登录态 Cookie]
    B --> C[生成 timestamp]
    C --> D[生成 nonce]
    D --> E[按规则拼接 path/query/body/header]
    E --> F[使用 secret/hash/HMAC 计算 sign]
    F --> G[发送请求]
    G --> H[服务端校验 Cookie]
    H --> I[校验时间窗口]
    I --> J[校验 nonce 是否重复]
    J --> K[重算 sign]
    K --> L{是否一致}
    L -- 是 --> M[返回业务数据]
    L -- 否 --> N[返回 401/403/签名错误]

很多人把 Cookie 理解成“浏览器自动带上的字符串”,这没错,但不完整。

如果服务端直接信任客户端传来的 Cookie 值,就有被伪造的风险。所以常见做法是:

  • 将用户数据写入 Cookie
  • 再对 Cookie 内容做签名
  • 服务端收到后重新计算签名并比对

例如:

cookie_value = "uid=1001&role=user"
signature = HMAC_SHA256(cookie_value, secret_key)
最终 Cookie:
uid=1001&role=user; sig=abc123...

这样客户端即使知道 Cookie 结构,也不能随意把 role=user 改成 role=admin,因为签名会失效。

  1. 明文值 + 签名
  2. Base64 编码值 + 签名
  3. JSON 序列化后签名
  4. 加密后再签名
  5. 服务端 session ID + 服务端存储态

最后一种其实最常见:Cookie 里只是一个 session id,真正数据在服务端。逆向时别一看到长字符串就默认它是 JWT 或加密数据,也可能只是随机 session key。


3. 请求重放为什么会失败

请求重放失败,通常不是“你姿势不对”,而是服务端明确做了防护。

常见防重放机制

  • 时间戳必须在 ±300 秒内
  • 同一个 nonce 只能使用一次
  • sign 中包含 body 摘要
  • sign 中包含顺序严格的参数串
  • token 和当前 session 绑定
  • token 和设备指纹、IP、UA 绑定
sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: 请求(path, body, ts, nonce, sign)
    S->>S: 检查 ts 是否过期
    S->>S: 检查 nonce 是否已使用
    S->>S: 用相同规则重算 sign
    alt 校验通过
        S-->>C: 200 OK
    else 校验失败
        S-->>C: 401/403
    end

所以重放分析的重点是:

找到“服务端重算签名时使用的输入材料和顺序”。


4. 鉴权参数通常在哪生成

中级开发者分析时,最容易卡在“到底该看哪里”。

一般来说,生成逻辑分布在以下位置:

前端 JS

  • webpack 打包后的业务代码
  • 单独的加密工具模块
  • axios/fetch 请求拦截器
  • 登录态刷新逻辑

浏览器运行时

  • 某些值来自 localStorage / sessionStorage
  • 某些值来自 document.cookie
  • 某些值来自浏览器指纹 API

服务端下发

  • HTML 模板里内嵌配置
  • 接口返回的临时 token
  • 登录成功后写入的 Cookie

原生 / 混合容器

  • App WebView 注入参数
  • JSBridge 返回设备信息
  • wasm 模块做加密计算

环境准备

本文用一个最小可运行实验来模拟真实场景。你可以直接在本地跑起来,感受“Cookie 签名 + 时间戳 + nonce + sign”的完整链路。

需要的环境

  • Node.js 16+
  • 一个终端
  • 可选:Postman / curl / 浏览器

初始化项目

mkdir web-auth-demo
cd web-auth-demo
npm init -y
npm install express cookie-parser

实战代码(可运行)

下面我们自己搭一个服务端,再写一个客户端重放脚本。这样你不仅知道“原理上怎么回事”,还知道“排查时应该看哪里”。

新建 server.js

const express = require('express');
const crypto = require('crypto');
const cookieParser = require('cookie-parser');

const app = express();
app.use(express.json());
app.use(cookieParser());

const PORT = 3000;

// 模拟服务端密钥
const COOKIE_SECRET = 'cookie_secret_demo';
const API_SECRET = 'api_secret_demo';

// 用内存模拟 nonce 去重
const usedNonces = new Set();

function hmacSHA256(content, secret) {
  return crypto.createHmac('sha256', secret).update(content).digest('hex');
}

function buildCookieSig(sessionId) {
  return hmacSHA256(sessionId, COOKIE_SECRET);
}

function buildApiSign({ path, method, body, timestamp, nonce, sessionId }) {
  const bodyText = JSON.stringify(body || {});
  const canonical = [
    method.toUpperCase(),
    path,
    bodyText,
    String(timestamp),
    nonce,
    sessionId
  ].join('\n');

  return hmacSHA256(canonical, API_SECRET);
}

// 登录接口:签发 session + cookie 签名
app.get('/login', (req, res) => {
  const sessionId = 'sess_' + crypto.randomBytes(8).toString('hex');
  const sig = buildCookieSig(sessionId);

  res.cookie('sessionId', sessionId, { httpOnly: true });
  res.cookie('sessionSig', sig, { httpOnly: true });

  res.json({
    message: 'login success',
    sessionId,
    sessionSig: sig
  });
});

// 受保护接口
app.post('/api/data', (req, res) => {
  const sessionId = req.cookies.sessionId;
  const sessionSig = req.cookies.sessionSig;

  if (!sessionId || !sessionSig) {
    return res.status(401).json({ error: 'missing cookie' });
  }

  const expectedCookieSig = buildCookieSig(sessionId);
  if (sessionSig !== expectedCookieSig) {
    return res.status(403).json({ error: 'invalid cookie signature' });
  }

  const timestamp = req.header('X-Timestamp');
  const nonce = req.header('X-Nonce');
  const sign = req.header('X-Sign');

  if (!timestamp || !nonce || !sign) {
    return res.status(400).json({ error: 'missing auth headers' });
  }

  const now = Math.floor(Date.now() / 1000);
  const ts = Number(timestamp);

  if (!Number.isFinite(ts) || Math.abs(now - ts) > 300) {
    return res.status(403).json({ error: 'timestamp expired' });
  }

  if (usedNonces.has(nonce)) {
    return res.status(403).json({ error: 'replay detected' });
  }

  const expectedSign = buildApiSign({
    path: '/api/data',
    method: 'POST',
    body: req.body,
    timestamp: ts,
    nonce,
    sessionId
  });

  if (sign !== expectedSign) {
    return res.status(403).json({
      error: 'invalid sign',
      expectedSign
    });
  }

  usedNonces.add(nonce);

  res.json({
    ok: true,
    data: {
      user: 'demo-user',
      payload: req.body
    }
  });
});

app.listen(PORT, () => {
  console.log(`Server listening at http://localhost:${PORT}`);
});

启动:

node server.js

2. 客户端:先登录,再生成签名请求

新建 client.js

const crypto = require('crypto');

function hmacSHA256(content, secret) {
  return crypto.createHmac('sha256', secret).update(content).digest('hex');
}

function buildApiSign({ path, method, body, timestamp, nonce, sessionId }) {
  const bodyText = JSON.stringify(body || {});
  const canonical = [
    method.toUpperCase(),
    path,
    bodyText,
    String(timestamp),
    nonce,
    sessionId
  ].join('\n');

  return hmacSHA256(canonical, 'api_secret_demo');
}

function parseSetCookie(setCookieHeaders) {
  const cookies = {};
  for (const item of setCookieHeaders) {
    const pair = item.split(';')[0];
    const index = pair.indexOf('=');
    const key = pair.slice(0, index);
    const value = pair.slice(index + 1);
    cookies[key] = value;
  }
  return cookies;
}

async function main() {
  // 1) 登录拿 Cookie
  const loginResp = await fetch('http://localhost:3000/login');
  const setCookie = loginResp.headers.getSetCookie();
  const cookies = parseSetCookie(setCookie);

  const sessionId = cookies.sessionId;
  const sessionSig = cookies.sessionSig;

  console.log('login cookies:', cookies);

  // 2) 生成动态鉴权参数
  const body = { page: 1, size: 20 };
  const timestamp = Math.floor(Date.now() / 1000);
  const nonce = crypto.randomBytes(6).toString('hex');

  const sign = buildApiSign({
    path: '/api/data',
    method: 'POST',
    body,
    timestamp,
    nonce,
    sessionId
  });

  // 3) 发受保护请求
  const resp = await fetch('http://localhost:3000/api/data', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Timestamp': String(timestamp),
      'X-Nonce': nonce,
      'X-Sign': sign,
      'Cookie': `sessionId=${sessionId}; sessionSig=${sessionSig}`
    },
    body: JSON.stringify(body)
  });

  const result = await resp.json();
  console.log('protected api result:', result);
}

main().catch(console.error);

运行:

node client.js

正常情况下会返回:

{
  "ok": true,
  "data": {
    "user": "demo-user",
    "payload": {
      "page": 1,
      "size": 20
    }
  }
}

3. 故意重放一次,观察失败

client.js 稍微改一下:第一次成功后,直接再发一次完全相同的请求。

const crypto = require('crypto');

function hmacSHA256(content, secret) {
  return crypto.createHmac('sha256', secret).update(content).digest('hex');
}

function buildApiSign({ path, method, body, timestamp, nonce, sessionId }) {
  const bodyText = JSON.stringify(body || {});
  const canonical = [
    method.toUpperCase(),
    path,
    bodyText,
    String(timestamp),
    nonce,
    sessionId
  ].join('\n');

  return hmacSHA256(canonical, 'api_secret_demo');
}

function parseSetCookie(setCookieHeaders) {
  const cookies = {};
  for (const item of setCookieHeaders) {
    const pair = item.split(';')[0];
    const index = pair.indexOf('=');
    const key = pair.slice(0, index);
    const value = pair.slice(index + 1);
    cookies[key] = value;
  }
  return cookies;
}

async function sendProtectedRequest({ sessionId, sessionSig, body, timestamp, nonce, sign }) {
  const resp = await fetch('http://localhost:3000/api/data', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Timestamp': String(timestamp),
      'X-Nonce': nonce,
      'X-Sign': sign,
      'Cookie': `sessionId=${sessionId}; sessionSig=${sessionSig}`
    },
    body: JSON.stringify(body)
  });

  return resp.json();
}

async function main() {
  const loginResp = await fetch('http://localhost:3000/login');
  const setCookie = loginResp.headers.getSetCookie();
  const cookies = parseSetCookie(setCookie);

  const sessionId = cookies.sessionId;
  const sessionSig = cookies.sessionSig;
  const body = { page: 1, size: 20 };
  const timestamp = Math.floor(Date.now() / 1000);
  const nonce = crypto.randomBytes(6).toString('hex');

  const sign = buildApiSign({
    path: '/api/data',
    method: 'POST',
    body,
    timestamp,
    nonce,
    sessionId
  });

  const first = await sendProtectedRequest({
    sessionId,
    sessionSig,
    body,
    timestamp,
    nonce,
    sign
  });

  const second = await sendProtectedRequest({
    sessionId,
    sessionSig,
    body,
    timestamp,
    nonce,
    sign
  });

  console.log('first:', first);
  console.log('second:', second);
}

main().catch(console.error);

你会看到第二次返回类似:

{
  "error": "replay detected"
}

这就把“为什么抓包能发一次,第二次不行”这件事,完整演示出来了。


4. 真实项目中如何定位签名生成逻辑

上面的 demo 是我们自己写的,真实站点当然复杂得多。但分析路径其实很像。

第一步:先在 Network 面板确定“谁是动态的”

建议先抓两次同一接口,做 diff,比对:

  • URL query 是否变化
  • body 是否变化
  • Cookie 是否变化
  • 哪些 header 每次都不同
  • 哪些字段长度固定、字符集像 hex/base64

最值得优先怀疑的字段:

  • sign
  • token
  • auth
  • ts / timestamp
  • nonce
  • reqId
  • traceId
  • x-* 私有请求头

第二步:搜索字段名

在 Sources 全局搜索这些关键词:

sign
timestamp
nonce
authorization
cookie
md5
sha256
hmac
encrypt
digest

如果代码没混淆,很多时候几分钟就能摸到边。

第三步:盯住请求发送点

重点看:

  • fetch(...)
  • XMLHttpRequest.send(...)
  • axios.interceptors.request.use(...)

因为就算签名函数名字很乱,请求真正发出去之前,参数总得被组装一次

flowchart LR
    A[Network 发现动态参数] --> B[Sources 搜索参数名]
    B --> C[定位 fetch/axios/XHR 调用]
    C --> D[向上追踪参数来源]
    D --> E[确认拼接顺序与输入项]
    E --> F[在 Console/断点中验证]
    F --> G[脚本复现]

第四步:断点看“原始串”

我自己最常用的不是一上来就研究算法,而是先找:

  • 签名前的原始拼接字符串
  • 最后一次调用哈希函数的位置

因为 MD5/HMAC/SHA256 本身不重要,重要的是输入内容和顺序

举个例子,下面三个虽然看起来差不多,但签名一定不同:

md5(path + body + ts)
md5(body + path + ts)
md5(path + JSON.stringify(body) + ts)

第五步:验证边界条件

拿到疑似算法后,不要急着写脚本,先验证几个关键点:

  • body 字段顺序是否影响签名
  • 空格、换行、编码是否影响
  • query 参数是否需要排序
  • header 是否参与签名
  • 时间戳单位是秒还是毫秒
  • nonce 是否纯随机还是带时间前缀

逐步验证清单

这是我平时做这类分析时很实用的一份 checklist:

A. 先判断是不是“纯复制可重放”

  • 同一请求 10 秒内重放是否成功
  • 换 Cookie 后是否仍成功
  • 去掉某些 header 是否失败

B. 再判断签名输入项

  • path 是否参与签名
  • query 是否参与签名
  • body 是否参与签名
  • Cookie/sessionId 是否参与签名
  • timestamp/nonce 是否参与签名

C. 最后判断算法细节

  • MD5 / SHA1 / SHA256 / HMAC
  • hex 还是 base64 输出
  • 是否大小写敏感
  • 是否二次编码
  • 是否使用固定盐值或动态密钥

常见坑与排查

这部分非常重要。很多“明明算法对了还是不行”的问题,都是细节坑。

1. JSON 序列化不一致

前端可能是:

JSON.stringify({a:1,b:2})

而你的脚本可能生成了:

{"b":2,"a":1}

如果服务端按原始字符串签名,这两个就不同。

排查建议

  • 固定字段顺序
  • 明确是否需要去空格
  • 确认是否做 key 排序

示例:

function stableStringify(obj) {
  if (obj === null || typeof obj !== 'object') {
    return JSON.stringify(obj);
  }
  if (Array.isArray(obj)) {
    return '[' + obj.map(stableStringify).join(',') + ']';
  }
  const keys = Object.keys(obj).sort();
  return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}';
}

console.log(stableStringify({ b: 2, a: 1 }));

2. 时间戳单位搞错

有些接口要秒:

Math.floor(Date.now() / 1000)

有些接口要毫秒:

Date.now()

只差三位数,但签名一定错。

排查经验

我当时踩过一个坑:接口文档写的是“Unix 时间戳”,结果前端实际传毫秒。后来还是对照浏览器真实请求才发现。


3. nonce 看着像随机,实际有格式要求

有些 nonce 不是纯随机串,而是:

  • 时间戳 + 随机数
  • UUID
  • 固定长度十六进制
  • base36 编码
  • 含设备前缀

如果你只“随便生成一个字符串”,服务端可能在格式校验阶段就拒了。


有些请求除了 Cookie,还会带:

  • Authorization: Bearer xxx
  • X-CSRF-Token
  • x-xsrf-token
  • localStorage 中的 access_token

也就是说,你看到 Cookie 没变,不代表登录态没变


5. Header 名字没错,但大小写/值格式错了

HTTP header 名大小写通常不敏感,但签名计算时,前端可能按原始对象名拼接。比如:

headers['X-Timestamp']

headers['x-timestamp']

在某些自定义实现里可能影响最终字符串。


6. 浏览器环境值缺失

真实站点中常见情况:

  • window.navigator 取语言、平台
  • localStorage 取设备 ID
  • document.cookie 取某个埋点值
  • 从 canvas/webgl 计算指纹

你在 Node 脚本里重放时,这些值都不存在。

排查建议

  • 在浏览器 Console 中直接调用签名函数
  • 先在浏览器里复现成功,再迁移到脚本
  • 必要时补 mock 环境

7. 误把“加密”当“签名”

很多参数看起来像长字符串,就容易误判。

区别很关键

  • 签名:用于校验内容未篡改,通常可重算比对
  • 加密:用于隐藏明文内容,需要解密后使用
  • 编码:只是格式变换,不提供安全性

例如:

  • Base64 不是加密
  • MD5 更像摘要,不是可逆加密
  • HMAC 是带密钥的签名方式

安全/性能最佳实践

这一节从工程角度说,不只是“怎么逆向看懂”,也包括“如果你是后端/前端,应该怎么设计得更合理”。

1. 不要把长期密钥硬编码在前端

如果前端 JS 中直接写死:

const SECRET = "my_super_secret_key";

那从防护角度几乎等于公开。

更合理的方式

  • 前端只持有短期 token
  • 真正密钥保留在服务端
  • 通过会话态、一次性票据、挑战响应等机制控制访问

最佳实践:

  • 尽量存 sessionId,不存敏感业务数据
  • 设置 HttpOnly
  • 设置 Secure
  • 设置合理的 SameSite

示例思路:

Cookie = sessionId
服务端 session store 中保存用户态与权限

这样即使客户端可见性受限,也降低了伪造与泄露风险。


3. 防重放至少要有“时间窗口 + nonce”

只校验时间戳不够,因为攻击者可以在短窗口内重复请求。

建议组合:

  • 时间戳限制有效期
  • nonce 保证唯一性
  • 服务端记录已用 nonce
  • 高价值接口增加一次性 token

4. 签名原文要规范化

如果你是系统设计者,千万别让签名规则模糊不清。

建议明确:

  • query 是否排序
  • body 如何序列化
  • header 哪些参与签名
  • 是否忽略空字段
  • 字符编码统一 UTF-8

一个稳定的 canonical string 规范,比“临时拼字符串”重要得多。


5. 不要迷信前端混淆

很多团队会把前端代码混淆、压缩、拆分,觉得这样就能挡住分析。实际效果有限:

  • 混淆只能增加成本,不能阻止还原
  • 请求最终总要发出去
  • 参数最终总要落地到网络层

所以真正有效的安全策略,仍然应放在服务端校验上。


6. 性能上避免过重签名流程

如果每个请求都:

  • 大对象深度排序
  • 多次 JSON stringify
  • 多轮加密/哈希
  • 再走 wasm 计算

前端性能和接口延迟都会受影响。

建议:

  • 只对必要字段签名
  • 大 payload 使用摘要而不是全文重复计算
  • 在拦截器中复用稳定逻辑
  • 避免重复序列化

一个更贴近实战的分析思路

如果你面对的不是 demo,而是一个打包得很乱的线上站点,我建议按下面顺序来:

  1. 先抓两次成功请求做 diff
  2. 锁定动态字段
  3. 在请求发送点下断点
  4. 找签名前的原始字符串
  5. 确认 hash/HMAC 算法
  6. 确认是否依赖 Cookie/localStorage/环境值
  7. 先在浏览器 Console 验证
  8. 再迁移到 Node/Python 脚本
  9. 最后做自动化重放或联调脚本

这个顺序的好处是:
你不会一上来陷进“读一万行混淆代码”的泥潭,而是沿着请求真实执行路径倒推。


总结

从 Cookie 签名到请求重放,表面看是在“还原一个请求”,本质上是在回答三个问题:

  1. 服务端信任什么?
  2. 客户端提交了哪些会变的材料?
  3. 这些材料是按什么规则被组织和校验的?

你可以把整件事理解成一条链:

  • Cookie 解决“你是谁”
  • 时间戳解决“你是不是现在发的”
  • nonce 解决“你是不是重复发的”
  • sign 解决“你发的内容有没有被改”

只要这条链里有一环没复现到位,请求重放就会失败。

最后给几个可执行建议

  • 抓包不要只看请求值,要看值是怎么来的
  • 遇到签名先找原始拼接串,别急着猜算法
  • 两次请求 diff 是最快的切入口
  • 能在浏览器里验证,就别一开始就硬上 Node 脚本
  • 对高价值接口,始终假设“前端逻辑可被观察”,真正安全依赖服务端

边界条件也要记住

  • 如果签名在服务端生成、前端拿不到关键密钥,那么你只能做有限重放分析
  • 如果依赖原生容器、wasm、硬件指纹,复现成本会明显上升
  • 如果接口是一次性挑战响应模式,就不能简单靠“复制参数”解决

如果你已经能看懂普通接口联调日志,那么把本文的分析框架用起来,基本就能跨过 Web 逆向里最容易卡人的那道坎:不是不会发请求,而是不知道请求为什么这样发。


分享到:

上一篇
《面向中型业务的集群架构实战:从高可用设计到弹性扩缩容的落地方案》
下一篇
《从 0 到可维护:基于开源项目的二次开发与本地部署实践指南》