跳到主要内容

工程实践:全局热加载自动接管前端加密与动态签名流程

· 阅读需 24 分钟
Yak ProjectYak Project

在前端加密、动态签名、一次性 challenge 这类场景里,单纯“会不会发包”其实不是重点。真正麻烦的是,测试链路里往往多出一段必须自动完成的前置或后置逻辑,例如:

01 发起真正业务请求之前,先去拿一段 challenge

02 把 challenge 解开,得到 nonce

03 用 nonce 计算签名,再把签名补到请求头里

04 请求成功之后,返回值本身还是密文,还要再解一遍

如果只是偶尔测试一次,这些动作手工做也不是不行。但一旦要开始反复调试、批量发包、联动 MITM 和 Web Fuzzer,这套流程如果没有一层统一能力接管,就会很快变得难用。

在这篇文章里,我们不去讨论太多架构层面的设计,而是直接拿 Vulinbox 里的一个动态挑战响应接口做演示,看看如何把这条链路真正挂到 Yak 的全局热加载中,让用户在 Web Fuzzer 和 MITM 里都能直接受益。

先从简单加解密靶场开始

启动靶场

在开始之前,先启动 Vulinbox。启动之后,可以访问:http://127.0.0.1:18080/ ,如下:

本文会用到以下几个入口:

1、靶场说明页:

http://127.0.0.1:18080/crypto/challenge-api-docs

2、获取 challenge:

http://127.0.0.1:18080/api/get-challenge

3、受保护接口:

http://127.0.0.1:18080/api/user/info

场景说明

这次使用的靶场不是“固定 AES Key 然后简单改包”的例子,而是一个更接近实际业务的动态 challenge 接口。

它的交互顺序如下:

1、请求 /api/get-challenge,服务端返回一段加密后的 challenge。、

2、客户端解密 challenge,得到 nonce。

3、使用 nonce 和约定的 HMAC Key 计算签名。

4、请求 /api/user/info 时,把签名写入 X-Auth-Signature

5、服务端校验通过后,返回的业务数据依然是 AES-CBC 加密后的内容。

换句话说,这里至少包含两段“测试前后必须先执行的逻辑”:

1、请求前的自动补签名。

2、响应后的自动解密。

这也正是全局热加载最适合切入的地方。

清理流程

在自动化之前,最好先把这条链路“手工拆开”验证一次。

1、先从 api/get-challenge 获取

HTTP/1.1 200 OK
Content-Type: application/json
{"challenge":"ifIYn2ChP6pOaedUtwRg8urjclJJazl2N8eSrcEUo1OZz7+AT+9ERWnJVGxtdQUU","iv":"tYJG4EX4pOICNfXbAT2lkg=="}

针对上面这个响应包,我们可以先写一个专门计算签名的函数:

API_AES_KEY = "YakitVulinboxAES"
API_SIGN_KEY = "YakitVulinboxHMACKey-SIGNATURE"
signChallengeResponse = func(packet) {
body = poc.GetHTTPPacketBody(packet)
params = json.loads(body)
challengeBytes = codec.DecodeBase64(params.challenge)~
ivBytes = codec.DecodeBase64(params.iv)~
nonce = codec.AESCBCDecrypt(API_AES_KEY, challengeBytes, ivBytes)~
return codec.EncodeToHex(codec.HmacSha256(API_SIGN_KEY, nonce))
}

这段代码本身并不发请求,它只做一件事:把 challenge 响应中的密文解开,然后生成真正需要放进请求头里的签名。

你可以直接在 YAK Runner 这样生成它:

API_AES_KEY = "YakitVulinboxAES"
API_SIGN_KEY = "YakitVulinboxHMACKey-SIGNATURE"
signChallengeResponse = func(packet) {
body = poc.GetHTTPPacketBody(packet)
params = json.loads(body)
challengeBytes = codec.DecodeBase64(params.challenge)~
ivBytes = codec.DecodeBase64(params.iv)~
nonce = codec.AESCBCDecrypt(API_AES_KEY, challengeBytes, ivBytes)~
return codec.EncodeToHex(codec.HmacSha256(API_SIGN_KEY, nonce))
}
challengePacket = <<<TEXT
HTTP/1.1 200 OK
Content-Type: application/json
{"challenge":"ifIYn2ChP6pOaedUtwRg8urjclJJazl2N8eSrcEUo1OZz7+AT+9ERWnJVGxtdQUU","iv":"tYJG4EX4pOICNfXbAT2lkg=="}
TEXT
println(signChallengeResponse(challengePacket))

执行之后,你会得到一段十六进制签名:

c9f36e99b46389cefc289002c02f88548403de96d0facf8a6cc99d1ded27f632

2、把签名手工填回 HTTP Raw 里发请求

拿到签名之后,可以把上一步的签名填进下面这个请求:

GET /api/user/info HTTP/1.1
Host: 127.0.0.1:18080
X-Auth-Signature: c9f36e99b46389cefc289002c02f88548403de96d0facf8a6cc99d1ded27f632

发送之后,你会拿到一段新的密文响应。格式大致如下:

HTTP/1.1 200 OK
Content-Type: application/json
{"data":"xLZ8ri0BmAqw72zNycPmzSQ1qkJ+QVASKyqy6j/D7rLjRyBwT/Tpn5BJCjLfEMVEReS9iglSFzikuQvL1q+NSwiMCHHWFRyybPyq9oUXd+xR/1xFIxCCoNM8Ud5JG+3HDlW8lJZ4Yo9dM9snojIf3Ks+dHl8kBTD8ePARUllTJ9MwXst/33X23acG27BtPJycvn/bptDTfqKyknPLdIQYwM0ozrteuCTGcjLWH0DtnH2CW8D46PuMtpgXKd9HyRhcBIu+uuY5Z+vSTPe48TwARuhX9FUG/F/odywOW5EalA=","iv":"4DqWSC1nHDF9AX183lb1DQ=="}

3、把受保护响应解成明文

拿到这段响应之后,我们继续按照同样的思路,写一个只负责解密响应的函数:

API_AES_KEY = "YakitVulinboxAES"
decryptProtectedPacket = func(packet) {
body = poc.GetHTTPPacketBody(packet)
params = json.loads(body)
dataBytes = codec.DecodeBase64(params.data)~
ivBytes = codec.DecodeBase64(params.iv)~
plain = codec.AESCBCDecrypt(API_AES_KEY, dataBytes, ivBytes)~
return string(plain)
}

同样可以把刚才抓到的响应原文直接贴进去验证:

API_AES_KEY = "YakitVulinboxAES"
decryptProtectedPacket = func(packet) {
body = poc.GetHTTPPacketBody(packet)
params = json.loads(body)
dataBytes = codec.DecodeBase64(params.data)~
ivBytes = codec.DecodeBase64(params.iv)~
plain = codec.AESCBCDecrypt(API_AES_KEY, dataBytes, ivBytes)~
return string(plain)
}
responsePacket = <<<TEXT
HTTP/1.1 200 OK
Content-Type: application/json
{"data":"xLZ8ri0BmAqw72zNycPmzSQ1qkJ+QVASKyqy6j/D7rLjRyBwT/Tpn5BJCjLfEMVEReS9iglSFzikuQvL1q+NSwiMCHHWFRyybPyq9oUXd+xR/1xFIxCCoNM8Ud5JG+3HDlW8lJZ4Yo9dM9snojIf3Ks+dHl8kBTD8ePARUllTJ9MwXst/33X23acG27BtPJycvn/bptDTfqKyknPLdIQYwM0ozrteuCTGcjLWH0DtnH2CW8D46PuMtpgXKd9HyRhcBIu+uuY5Z+vSTPe48TwARuhX9FUG/F/odywOW5EalA=","iv":"4DqWSC1nHDF9AX183lb1DQ=="}
TEXT
println(decryptProtectedPacket(responsePacket))

运行之后,你就会得到最终的明文结果:

{"email":"admin@yaklang.io","message":"Congratulations! You have successfully passed the challenge.","permission":"all","used_nonce":"bc13cef03c7d2f3427902e45300cd3d6ca0551afdb3422adb4e639431b0ae6e3","user":"admin"}

到这里为止,才算是真正把这条链路“手工验证”完毕。可以发现,还是比较繁琐的,而且很有"割裂感”,需要在不同的地方跳来跳去,整个调试过程十分不流畅。下面我们看看用全局热加载的方式,如何提升流畅度。

全局加热

既然整条链路已经清楚,那么热加载脚本最核心的内容其实只有两块:

1、自动获取 challenge 并生成签名。

2、自动解密受保护接口的响应。

1、获取 challenge 并计算签名

我们先把 challenge 获取和签名计算封装成一个函数。它做的事情非常直接:

1、从当前 HTTP 数据包里取出 Host。

2、构造一个到 /api/get-challenge 的请求。

3、解析返回的 challengeiv

4、解密出 nonce。

5、用 HMAC-SHA256 计算签名。

代码如下:

fetchChallengeSignature = func(isHttps, packet) {
host = poc.GetHTTPPacketHeader(packet, "Host")
if host == "" {
panic("global hotpatch: request host is empty")
}
challengeReq = "GET /api/get-challenge HTTP/1.1\r\n" +
"Host: " + host + "\r\n" +
"User-Agent: yak-global-hotpatch-demo\r\n" +
"Connection: close\r\n\r\n"
challengeRsp, _ = poc.HTTP(
challengeReq,
poc.https(isHttps),
poc.timeout(5),
poc.save(false),
)~
body = poc.GetHTTPPacketBody(challengeRsp)
params = json.loads(body)
challengeBytes = codec.DecodeBase64(params.challenge)~
ivBytes = codec.DecodeBase64(params.iv)~
nonce = codec.AESCBCDecrypt(API_AES_KEY, challengeBytes, ivBytes)~
return codec.EncodeToHex(codec.HmacSha256(API_SIGN_KEY, nonce))
}

在这个函数里,最重要的返回值就是最终的 signature。、

后面无论是 Web Fuzzer 还是 MITM,只要请求命中了目标接口,都可以复用这段逻辑。

2、解密受保护接口响应

第二个函数负责对响应做还原。这个函数要处理的是 /api/user/info 返回的 data + iv 结构:

decryptProtectedResponse = func(packet) {
body = string(poc.GetHTTPPacketBody(packet))
if !str.Contains(body, `"data"`) || !str.Contains(body, `"iv"`) {
return packet
}
params = json.loads(body)
dataBytes = codec.DecodeBase64(params.data)~
ivBytes = codec.DecodeBase64(params.iv)~
plain = codec.AESCBCDecrypt(API_AES_KEY, dataBytes, ivBytes)~
return poc.ReplaceHTTPPacketBody(packet, plain)
}

这段代码做了三件事:

1、取出 HTTP Body。

2、解析 dataiv

3、解密之后,把 HTTP Body 替换成明文。

这里的重点不在“会不会 AES-CBC 解密”,而在于它返回的是一个新的 HTTP 数据包。也就是说,这个函数不是只给你一个字符串,而是可以直接继续往热加载链路里传递。

把函数挂到全局热加载链路里

有了上面两个函数之后,下面就是最关键的一步:决定它们应该挂在哪些 Hook 上。

1、beforeRequest 负责自动补签名

在请求发出去之前,如果发现当前目标是 /api/user/info,就先调用 fetchChallengeSignature,然后把签名补进请求头:

beforeRequest = func(isHttps, originReq, req) {
if !isTargetRequest(isHttps, req) {
return req
}
signature = fetchChallengeSignature(isHttps, req)
req = poc.ReplaceHTTPPacketHeader(req, "X-Auth-Signature", signature)
return req
}

这个 Hook 的意义很明确:用户在 Fuzzer 里不需要手工先跑 challenge,只需要发原始业务请求即可。

2、afterRequest 负责按需解密在线响应

如果我们总是在在线链路里把响应改成明文,会有一个现实问题:浏览器前端可能本来期望收到的是密文 JSON,强行改成明文后,前端自己的解密流程反而会报错。

所以这里不适合无条件解密在线响应。更稳妥的办法是,加一个只给 YAK 自己看的开关:

PLAINTEXT_HEADER = "X-Yak-Force-Plaintext"
afterRequest = func(isHttps, originReq, req, originRsp, rsp) {
if !isTargetRequest(isHttps, req) {
return rsp
}
if poc.GetHTTPPacketHeader(req, PLAINTEXT_HEADER) != "1" {
return rsp
}
return decryptProtectedResponse(rsp)
}

这样一来:

1、Web Fuzzer 想直接看明文时,可以主动带上 X-Yak-Force-Plaintext: 1

2、普通在线流量如果没有这个标记,就不会被强行改写,而是通过下面的hijackSaveHTTPFlow 方式,在 Yakit中显示明文

此处添加 X-Yak-Force-Plaintext 只是为了让用户理解 afterRequest,本质上可以不需要额外添加这个 header

3、hijackSaveHTTPFlow 负责让 MITM 存库结果可读

MITM 的目标和 Web Fuzzer 不完全一样。对于 MITM 来说,很多时候更重要的是“保存到数据库里的流量是否容易分析”,而不是真的将明文返回给服务器。

因此这里更合适的做法是:

1、不破坏浏览器真实收到的响应。

2、只在保存 HTTP Flow 时,把响应改写成明文。

代码如下:

hijackSaveHTTPFlow = func(flow, modify, drop) {
req = codec.StrconvUnquote(flow.Request)~
if !isTargetRequest(false, req) && !isTargetRequest(true, req) {
modify(flow)
return
}
rsp = codec.StrconvUnquote(flow.Response)~
flow.Response = codec.StrconvQuote(string(decryptProtectedResponse(rsp)))
modify(flow)
}

这段设计有一个实际好处:浏览器继续按原来的协议和前端 JS 正常工作,但 MITM 数据库里保存下来的已经是可读的明文内容,它的工作流程如下:

  • 只要请求命中了 /api/user/info
  • 就把 flow.Response 取出来解密
  • 然后把解密后的内容重新写回 flow.Response

完整可用脚本

// Vulinbox 动态挑战响应 API 全局热加载示例
// 适用场景:
// 1. Web Fuzzer 直接发明文请求,由全局热加载自动补签名
// 2. Web Fuzzer 在请求头中带上 X-Yak-Force-Plaintext: 1 时,自动解密响应
// 3. MITM 不改动在线流量,但会把保存到数据库的响应改写成明文,便于观察
API_AES_KEY = "YakitVulinboxAES"
API_SIGN_KEY = "YakitVulinboxHMACKey-SIGNATURE"
TARGET_PATH = "/api/user/info"
CHALLENGE_PATH = "/api/get-challenge"
PLAINTEXT_HEADER = "X-Yak-Force-Plaintext"
isTargetRequest = func(isHttps, packet) {
u, err = str.ExtractURLFromHTTPRequestRaw(packet, isHttps)
if err != nil {
return false
}
return str.Contains(u.String(), TARGET_PATH)
}
shouldRewriteResponse = func(packet) {
return poc.GetHTTPPacketHeader(packet, PLAINTEXT_HEADER) == "1"
}
fetchChallengeSignature = func(isHttps, packet) {
host = poc.GetHTTPPacketHeader(packet, "Host")
if host == "" {
panic("global hotpatch: request host is empty")
}
challengeReq = "GET " + CHALLENGE_PATH + " HTTP/1.1\r\n" +
"Host: " + host + "\r\n" +
"User-Agent: yak-global-hotpatch-demo\r\n" +
"Connection: close\r\n\r\n"
challengeRsp, _ = poc.HTTP(
challengeReq,
poc.https(isHttps),
poc.timeout(5),
poc.save(false),
)~
body = poc.GetHTTPPacketBody(challengeRsp)
params = json.loads(body)
if !("challenge" in params) || !("iv" in params) {
panic("global hotpatch: challenge response missing challenge/iv")
}
challengeBytes = codec.DecodeBase64(params.challenge)~
ivBytes = codec.DecodeBase64(params.iv)~
nonce = codec.AESCBCDecrypt(API_AES_KEY, challengeBytes, ivBytes)~
return codec.EncodeToHex(codec.HmacSha256(API_SIGN_KEY, nonce))
}
decryptProtectedResponse = func(packet) {
body = string(poc.GetHTTPPacketBody(packet))
if !str.Contains(body, `"data"`) || !str.Contains(body, `"iv"`) {
return packet
}
params = json.loads(body)
if !("data" in params) || !("iv" in params) {
return packet
}
dataBytes = codec.DecodeBase64(params.data)~
ivBytes = codec.DecodeBase64(params.iv)~
plain = codec.AESCBCDecrypt(API_AES_KEY, dataBytes, ivBytes)~
return poc.ReplaceHTTPPacketBody(packet, plain)
}
beforeRequest = func(isHttps, originReq, req) {
if !isTargetRequest(isHttps, req) {
return req
}
signature = fetchChallengeSignature(isHttps, req)
req = poc.ReplaceHTTPPacketHeader(req, "X-Auth-Signature", signature)
return req
}
afterRequest = func(isHttps, originReq, req, originRsp, rsp) {
if !isTargetRequest(isHttps, req) {
return rsp
}
if !shouldRewriteResponse(req) {
return rsp
}
return decryptProtectedResponse(rsp)
}
hijackSaveHTTPFlow = func(flow, modify, drop) {
req = codec.StrconvUnquote(flow.Request)~
if !isTargetRequest(false, req) && !isTargetRequest(true, req) {
modify(flow)
return
}
rsp = codec.StrconvUnquote(flow.Response)~
flow.Response = codec.StrconvQuote(string(decryptProtectedResponse(rsp)))
modify(flow)
}

如何在Yakit中使用全局热加载

到这里,脚本都已经准备好了。下面就是实际启用步骤。

在 Yakit 中进入全局热加载插件管理页面:

然后,按下面的方式操作:

1、新建模板

2、名称可以填写 vulinbox-challenge-api-global

3、将上面的完整脚本粘贴进去

4、保存并启用

启用之后,这个脚本会先于模块级 HotPatch 执行。

也就是说,如果某个模块本身还有单独的 HotPatch,那么当前的执行顺序是:

全局热加载 -> 模块 HotPatch

具体使用

1、在 Web Fuzzer 中直接看明文

如果你希望在 Web Fuzzer 中直接看到明文响应,那么原始请求可以写成下面这样:

GET /api/user/info HTTP/1.1
Host: 127.0.0.1:18080
X-Yak-Force-Plaintext: 1

这里要注意,X-Yak-Force-Plaintext: 1 不是服务器要求的 Header,而是给全局热加载脚本看的控制开关。

当它存在时,脚本会自动完成两件事:

1、在 beforeRequest 中补上 X-Auth-Signature

2、在 afterRequest 中把加密响应解成明文。

效果如下:

我们点开详情,可以发现 X-Auth-Signature 已经被补上了:

2、在 MITM 中保留在线协议,但保存明文结果

MITM 里不建议粗暴地把在线响应直接改成明文。原因很简单,很多前端页面收到响应之后,本来还会执行自己的解密逻辑。如果你在中间层提前把它改成明文,前端反而可能报错。

所以这里采用的是更适合实际调试的方式:

1、浏览器真实收到的仍然是原始密文协议。

2、MITM 保存到数据库中的响应被改写成明文。

这样处理以后,浏览器的页面逻辑不会被破坏,而你在 MITM 历史记录里看到的内容又是可直接分析的。

如下:

注意:此时浏览器还是正常的密文,但 MITM 保存到数据库里的那条 flow,被改成明文

热加载联动

一些经常使用热加载的读者看到这可能会觉得,这不就是把原来模块热加载能做的事,搬到全局里做了一遍吗?

是的没错,但是全局热加载真正有说服力的地方,不是能不能做,而是职责怎么分,目前全局热加载和模块热加载会组成 pipeline,也就是 Hook 链路是按照 "全局 -> 模块" 顺序的:

  • MITM 这边,beforeRequest / afterRequest 都是先跑 global,再跑 module。
  • Web Fuzzer 这边,hook 也是串起来的,先 global,后 module。

Web Fuzzer 这里还有个细节要说清楚:Web Fuzzer 的 yak / yak:dyn tag 并不是严格意义上的串联 pipeline。在 hotpatch_chain.go:186 这里,同名 tag handle 的语义更接近:模块实现优先模块没有时回退到全局 也就是说:beforeRequest/afterRequest/mirrorHTTPFlow 这些 hook,pipeline 味道很强{{yak(handle)}} 这种 tag,更像“覆盖 + 回退”,不是两层串着一起跑

按照现在的职责划分,对于一些复杂的,加密 + SQL 的测试,可以按照全局热加载负责把协议层(加解密部分)先“抹平”,模块热加载再在明文语义层上做漏洞测试:

第一层,Global HotPatch-加解密处理

  • 自动请求 /api/get-challenge
  • 自动解密 challenge
  • 自动补 X-Auth-Signature
  • 自动把响应还原成明文
  • MITM 存库时统一转成明文

第二层,Module HotPatch-具体测试

  • SQL 注入 payload 怎么打
  • 参数怎么变异
  • 响应里的哪个字段算命中
  • 哪些业务状态应该标红、提取、打标签

启动靶场

启动 vulinbox,搜索 “全局热加载” ,打开 "全局热加载 Pipeline",向下滑动到 “前端实操台”:

场景说明

这次用到的入口有三个:

1、靶场说明页:

http://127.0.0.1:18080/api/pipeline/docs

2、获取动态会话:

http://127.0.0.1:18080/api/pipeline/bootstrap

3、订单检索接口:

http://127.0.0.1:18080/api/pipeline/orders/search

这条链路的交互过程可以概括成一句话:

业务参数明文传,协议头自动补,响应统一解密后再分析。

对应到实际顺序,就是:

1、先请求 /api/pipeline/bootstrap

2、解 ticket,拿到 session_idsession_key

3、构造原始业务请求体

4、计算签名,补三个 X-Pipeline-*

5、请求 /api/pipeline/orders/search

6、拿到加密响应,再用 session_key 解开

这里还有一个非常重要的细节:

这套靶场的签名故意不覆盖业务 body,只覆盖:

1、METHOD

2、PATH

3、TIMESTAMP

签名公式如下:

hex(HMAC-SHA256(session_key, METHOD + "\n" + PATH + "\n" + TIMESTAMP))

为什么要这么设计?

因为 Yakit 里 beforeRequest 的执行顺序本来就是:

全局热加载 -> 模块 HotPatch

如果签名把 body 也算进去,那么 global 先签名、module 再改 body,签名就会立刻失效,文章里想讲的 pipeline 反而讲不顺。

所以这个教学靶场是刻意做过取舍的:

1、Global 先补协议层头。

2、Module 再继续修改明文业务参数。

3、服务端响应回来之后,Global 先解密。

4、Module 再根据解密后的结果做命中判断。

这样,整条链路就真的能按照 global -> module 跑起来。

为了突出当前 Yakit 中 Global HotPatch 与 Module HotPatch 的 pipeline 分工,这个教学靶场刻意把请求签名约束收敛到 Header 层,让读者先看懂“全局做协议、模块做业务”的协作方式。真正覆盖 body 的签名协议,是下一层更复杂的话题,这一块会有后续文章,因为它涉及到 global 准备 -> module 变更-> global 最终改动(签名) -> send

理清链路

先取 bootstrap

启动 MITM 免配置,打开 http://127.0.0.1:18080/api/pipeline/docs 后,点击 "获取动态会话",会捕获到如下数据包:

GET /api/pipeline/bootstrap HTTP/1.1
Host: 127.0.0.1:18080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36
Referer: http://127.0.0.1:18080/api/pipeline/docs

服务端会返回一段类似下面这样的响应:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Tue, 17 Mar 2026 08:12:41 GMT
Content-Length: 299
{
"expires_in": 120,
"iv": "G0pIfn9t2WiaZA/+jG8nqw==",
"request": "plain-json-with-hmac",
"response": "aes-cbc-envelope",
"ticket": "X+zfkoBJx172ptL9rVxzxZmWEE4xn21b31mo0M8P5OjpN13oRBUO4b+EQoYnBf6sjy+dvaPt3xcinZtUYReK3nQoWk+bMBF35nihHx0DdszbBKUn41cGeITaiaxGqMzqUL+TNmU6VeGpQcEhyUjwag=="
}

这里的 ticket 不是明文,而是用固定引导密钥加密过的一段会话信息。

对应的固定引导密钥是:

YakitPipeBootKey

先把 ticket 解开

这一步先不要发业务请求,只做一件事:把 bootstrap 返回里的 ticket 解开,拿到真实的 session_idsession_key

写好处理函数后,把刚才抓到的响应包原文直接贴进去:

BOOTSTRAP_KEY = "YakitPipeBootKey"
parseBootstrapTicket = func(packet) {
body = poc.GetHTTPPacketBody(packet)
params = json.loads(body)
ticketBytes = codec.DecodeBase64(params.ticket)~
ivBytes = codec.DecodeBase64(params.iv)~
plain = codec.AESCBCDecrypt(BOOTSTRAP_KEY, ticketBytes, ivBytes)~
return json.loads(string(plain))
}
bootstrapPacket = <<<PACKET
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Tue, 17 Mar 2026 08:03:10 GMT
Content-Length: 299
{
"expires_in": 120,
"iv": "q/LXUU6HkdQ62h14UZ5anw==",
"request": "plain-json-with-hmac",
"response": "aes-cbc-envelope",
"ticket": "JCXtWLipDgOROv08Czcedowz9tZqPXuHwQ7hsJ1N7sgsS/5I3Nf8sqTjmLulfU2Xt1f2EQhpGrXqNOciVGHbiuyBzAlJHQW/5esFFrqa6Ph24HW7SWr4dNGGmwbLx8lawk7nvH7eCI5crWfl3BskoQ=="
}
PACKET
println(json.dumps(parseBootstrapTicket(bootstrapPacket), json.withIndent(" ")))

运行之后,你会得到一段真正有用的明文结果,大致如下:

{
"expires_at": 1773735281,
"session_id": "610ecc49c20b756f",
"session_key": "zKRgnbfEjP22qFWSwvZbNQ=="
}

到这里为止,你已经拿到了真正要参与后续请求的两个关键字段:

1、session_id

2、session_key

手工计算签名

接下来准备一个真正的业务 body,例如:

{"keyword":"商品4","status":"已发货","page":1,"size":10}

然后再写一个只负责生成签名的 YAK 函数,把上一步解出来的 session_key 填进去,直接生成:

buildPipelineSignature = func(method, path, sessionKey) {
timestamp = sprintf("%d", time.Now().Unix())
signRaw = method + "\n" + path + "\n" + timestamp
signature = codec.EncodeToHex(codec.HmacSha256(sessionKey, signRaw))
return {
"timestamp": timestamp,
"signature": signature,
}
}
sessionKey = codec.DecodeBase64("zKRgnbfEjP22qFWSwvZbNQ==")~
ret = buildPipelineSignature("POST", "/api/pipeline/orders/search", sessionKey)
println(json.dumps(ret, json.withIndent(" ")))

执行之后,你会得到两项结果:

{
"signature": "afcba3b7712aa9ec0a4e8817fee171db73cabbe95172efbcfcfc1bb4068627b0",
"timestamp": "1773735201"
}

它们就是你接下来要手工填回 HTTP Raw 的值。

把签名手工填回 HTTP Raw,再发送一次

拿到上面的 session_idtimestampsignature 之后,可以把它们手工填回请求里:

POST /api/pipeline/orders/search HTTP/1.1
Host: 127.0.0.1:18080
Content-Type: application/json
X-Pipeline-Session: 610ecc49c20b756f
X-Pipeline-Timestamp: 1773735201
X-Pipeline-Signature: afcba3b7712aa9ec0a4e8817fee171db73cabbe95172efbcfcfc1bb4068627b0
{"keyword":"商品4","status":"已发货","page":1,"size":10}

这时候,服务端就不会再报缺少签名头了,而是会返回一段新的密文响应:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Pipeline-Encrypted: 1
X-Pipeline-Session: 610ecc49c20b756f
Date: Tue, 17 Mar 2026 08:13:52 GMT
Content-Length: 473
{
"data": "SNCjfVKXde6ycHpWLvFDfMR+3LCguTvcND2cKex5H7YoCazaEVakRFr/8ViSMNx7u1lVsWo4FKtlLmvnMGuIE9bqeaC9Rsp55WYxhRdETV9A4Z1qoF3NOZlYYiHC20BZNz8VjD+q9UR/pbphwz3y55N8rrgQTVe2NmA1GWZlOfmAcOSdJ3wphaGThqasUpOEyLwIcIWd2/P0qhN1zh0dyj/fhg9BHm8MEtTCXlg9V+3me+1HNwuDhyJ4c1YhH7TUYHzIuJ8c92uTx1C8EGHCjtU6ybTR3wIDHtumsk4gN0L8U2/yXicUp+IVmu0l6LTuOZ/3LfAj23jLV7bgIpt5gMSsEZ0cU/Faa16J2pgvf/W9smpyBsr8jT4G/wyqYrGJ",
"iv": "IOxkag/9xBCshaOk4sApKg==",
"session_id": "610ecc49c20b756f"
}

把加密响应再解成明文,现在还差最后一步:把这段响应解开。

继续写一个只负责解密响应的 YAK 函数,然后把上一步拿到的响应原文贴进去:

decryptPipelineResponse = func(packet, sessionKey) {
body = poc.GetHTTPPacketBody(packet)
params = json.loads(body)
dataBytes = codec.DecodeBase64(params.data)~
ivBytes = codec.DecodeBase64(params.iv)~
plain = codec.AESCBCDecrypt(sessionKey, dataBytes, ivBytes)~
return string(plain)
}
sessionKey = codec.DecodeBase64("IOxkag/9xBCshaOk4sApKg==")~
responsePacket = <<<PACKET
HTTP/1.1 200 OK
Content-Type: application/json
X-Pipeline-Encrypted: 1
{"session_id":"9c0c4c1b8f0d4a6f","iv":"<把你自己抓到的 iv 粘进来>","data":"<把你自己抓到的 data 粘进来>"}
PACKET
println(decryptPipelineResponse(responsePacket, sessionKey))

正常情况下,你会得到类似下面这样的明文:

{"page":1,"row_count":1,"rows":[{"delivery_status":"已发货","order_id":4,"product_name":"商品4","quantity":5,"total_price":445,"username":"user1"}],"scene":"global-before-sign -\u003e module-before-mutate -\u003e global-after-decrypt -\u003e module-after-judge","size":10}

到这里为止,这条链路才算真正手工走通。

而且你会非常直观地感受到一个问题:

这套流程并不复杂,但很碎。

它有非常明显的“割裂感”:

1、先去取 bootstrap

2、再切到 Yak Runner 解 ticket

3、再切回来手工发 HTTP Raw

4、再把响应贴回 Yak Runner 解密

如果每次都这么来一遍,体验会非常差。

全局热加载Pipeline流程

既然整条链路已经清楚,那么全局热加载脚本最核心的内容其实只有三块:

1、自动取 bootstrap

2、自动补 X-Pipeline-*

3、自动把加密响应还原成明文

1、先看职责边界

先把 global 的职责说清楚:

1、识别目标请求是不是 /api/pipeline/orders/search

2、如果命中,就自动请求 /api/pipeline/bootstrap

3、解 ticket,拿到 session_idsession_key

4、自动补 X-Pipeline-Session

5、自动补 X-Pipeline-Timestamp

6、自动补 X-Pipeline-Signature

7、当用户显式要求看明文时,自动解密在线响应

8、在 MITM 存库时,把响应写成明文,方便观察

你会发现,这些工作几乎全部都是协议层动作,而不是漏洞 payload 层动作。

这正是全局热加载最适合接管的事情。

2、完整可用的 Global HotPatch 脚本

下面这份脚本可以直接作为全局热加载模板使用:

// Vulinbox Pipeline 教学靶场 - Global HotPatch
// 作用:
// 1. 自动请求 /api/pipeline/bootstrap
// 2. 自动补 X-Pipeline-Session / X-Pipeline-Timestamp / X-Pipeline-Signature
// 3. Web Fuzzer 携带 X-Yak-Force-Plaintext: 1 时,自动解密在线响应
// 4. MITM 不破坏浏览器真实协议,但保存到数据库的 flow 会被改写成明文
PIPELINE_BOOTSTRAP_KEY = "YakitPipeBootKey"
PIPELINE_BOOTSTRAP_PATH = "/api/pipeline/bootstrap"
PIPELINE_TARGET_PATH = "/api/pipeline/orders/search"
PLAINTEXT_HEADER = "X-Yak-Force-Plaintext"
sessionKeyCache = {}
isTargetRequest = func(isHttps, packet) {
u, err = str.ExtractURLFromHTTPRequestRaw(packet, isHttps)
if err != nil {
return false
}
return u.Path == PIPELINE_TARGET_PATH
}
parseBootstrapTicket = func(packet) {
body = poc.GetHTTPPacketBody(packet)
params = json.loads(body)
if !("ticket" in params) || !("iv" in params) {
panic("global hotpatch: bootstrap response missing ticket/iv")
}
ticketBytes = codec.DecodeBase64(params.ticket)~
ivBytes = codec.DecodeBase64(params.iv)~
plain = codec.AESCBCDecrypt(PIPELINE_BOOTSTRAP_KEY, ticketBytes, ivBytes)~
return json.loads(string(plain))
}
fetchPipelineSession = func(isHttps, packet) {
host = poc.GetHTTPPacketHeader(packet, "Host")
if host == "" {
panic("global hotpatch: request host is empty")
}
bootstrapReq = "GET " + PIPELINE_BOOTSTRAP_PATH + " HTTP/1.1\r\n" +
"Host: " + host + "\r\n" +
"User-Agent: yak-global-hotpatch-pipeline-demo\r\n" +
"Connection: close\r\n\r\n"
bootstrapRsp, _ = poc.HTTP(
bootstrapReq,
poc.https(isHttps),
poc.timeout(5),
poc.save(false),
)~
return parseBootstrapTicket(bootstrapRsp)
}
buildPipelineSignature = func(method, path, sessionKey) {
ts = sprintf("%d", time.Now().Unix())
signRaw = method + "\n" + path + "\n" + ts
signature = codec.EncodeToHex(codec.HmacSha256(sessionKey, signRaw))
return ts, signature
}
decryptPipelineResponse = func(packet, sessionKey) {
body = string(poc.GetHTTPPacketBody(packet))
if !str.Contains(body, `"data"`) || !str.Contains(body, `"iv"`) {
return packet
}
params = json.loads(body)
if !("data" in params) || !("iv" in params) {
return packet
}
dataBytes = codec.DecodeBase64(params.data)~
ivBytes = codec.DecodeBase64(params.iv)~
plain = codec.AESCBCDecrypt(sessionKey, dataBytes, ivBytes)~
return poc.ReplaceHTTPPacketBody(packet, plain)
}
beforeRequest = func(isHttps, originReq, req) {
if !isTargetRequest(isHttps, req) {
return req
}
ticket = fetchPipelineSession(isHttps, req)
sessionID = ticket.session_id
sessionKeyB64 = ticket.session_key
sessionKey = codec.DecodeBase64(sessionKeyB64)~
method = poc.GetHTTPRequestMethod(req)
u = str.ExtractURLFromHTTPRequestRaw(req, isHttps)~
ts, signature = buildPipelineSignature(method, u.Path, sessionKey)
sessionKeyCache[sessionID] = sessionKeyB64
req = poc.ReplaceHTTPPacketHeader(req, "X-Pipeline-Session", sessionID)
req = poc.ReplaceHTTPPacketHeader(req, "X-Pipeline-Timestamp", ts)
req = poc.ReplaceHTTPPacketHeader(req, "X-Pipeline-Signature", signature)
return req
}
afterRequest = func(isHttps, originReq, req, originRsp, rsp) {
if !isTargetRequest(isHttps, req) {
return rsp
}
if poc.GetHTTPPacketHeader(req, PLAINTEXT_HEADER) != "1" {
return rsp
}
sessionID = poc.GetHTTPPacketHeader(req, "X-Pipeline-Session")
sessionKeyB64 = sessionKeyCache[sessionID]
if sessionKeyB64 == "" {
return rsp
}
sessionKey = codec.DecodeBase64(sessionKeyB64)~
return decryptPipelineResponse(rsp, sessionKey)
}
hijackSaveHTTPFlow = func(flow, modify, drop) {
req = codec.StrconvUnquote(flow.Request)~
if !isTargetRequest(false, req) && !isTargetRequest(true, req) {
modify(flow)
return
}
sessionID = poc.GetHTTPPacketHeader(req, "X-Pipeline-Session")
sessionKeyB64 = sessionKeyCache[sessionID]
if sessionKeyB64 == "" {
modify(flow)
return
}
rsp = codec.StrconvUnquote(flow.Response)~
sessionKey = codec.DecodeBase64(sessionKeyB64)~
flow.Response = codec.StrconvQuote(string(decryptPipelineResponse(rsp, sessionKey)))
modify(flow)
}

3、再叠一层模块热加载

如果文章写到这里就结束,其实只能证明一件事:

Global HotPatch 很适合做协议归一化。

但还没有真正体现出 pipeline 的第二层。

所以下面再往上叠一层 module。

这层不去管 bootstrap,也不去管 AES-CBC 解密,而是只做业务层动作:

1、把 keyword 从占位符改成真正的 SQL 注入 payload

2、判断解密后的响应里 row_count 是否异常变大

module 真正应该关注的是:

1、keyword 改成什么

2、什么样的响应算命中

3、命中之后怎么标记、提取、展示

完整可用的 Module HotPatch 脚本

下面这份脚本可以直接作为模块热加载脚本使用:

// Vulinbox Pipeline 教学靶场 - Module HotPatch
// 作用:
// 1. 把 keyword 占位符替换成真正的 SQL 注入 payload
// 2. 在解密后的响应上做简单命中判断
TARGET_PATH = "/api/pipeline/orders/search"
KEYWORD_MARKER = "__AUTO_SQLI__"
SQLI_PAYLOAD = "' OR 1=1 -- "
isTargetRequest = func(isHttps, packet) {
u, err = str.ExtractURLFromHTTPRequestRaw(packet, isHttps)
if err != nil {
return false
}
return u.Path == TARGET_PATH
}
beforeRequest = func(isHttps, originReq, req) {
if !isTargetRequest(isHttps, req) {
return req
}
body = string(poc.GetHTTPPacketBody(req))
params = json.loads(body)
if !("keyword" in params) {
return req
}
if params.keyword != KEYWORD_MARKER {
return req
}
params.keyword = SQLI_PAYLOAD
return poc.ReplaceHTTPPacketBody(req, json.dumps(params))
}
afterRequest = func(isHttps, originReq, req, originRsp, rsp) {
if !isTargetRequest(isHttps, req) {
return rsp
}
body = string(poc.GetHTTPPacketBody(rsp))
if !str.Contains(body, `"row_count"`) {
return rsp
}
params = json.loads(body)
if !("row_count" in params) {
return rsp
}
if params.row_count < 5 {
return rsp
}
return poc.ReplaceHTTPPacketHeader(rsp, "X-Yak-Module-Hit", "pipeline-sqli")
}

这时候 pipeline 才真正成立,把两层脚本叠起来之后,请求和响应链路就变成了:

请求方向:
Global beforeRequest
-> 自动取 bootstrap
-> 自动补 X-Pipeline-*
-> Module beforeRequest
-> 把 keyword 从占位符改成真实 payload
响应方向:
Global afterRequest
-> 先把 AES-CBC 响应解成明文
-> Module afterRequest
-> 再对明文 row_count 做命中判断

Yakit演示

到这里为止,脚本已经准备好了。下面就是实际启用步骤。

1、启用 Global HotPatch

在 Yakit 里进入全局热加载模板管理页面:

1、新建模板

2、名称可以写成 vulinbox-pipeline-global

3、类型选择全局热加载

4、把上面的 Global 脚本粘进去

5、保存并启用

启用之后,这个脚本会先于模块级 HotPatch 执行。

也就是说,同一条请求命中 hotpatch 链路时,顺序是:

全局热加载 -> 模块 HotPatch

2、启用模块 HotPatch

ITM 中的情况如下:

注意页面中的 ,全局热加载(已启动)->MITM热加载(已启动) 标志

Webfuzzer 中的情况如下:


本文首发于 Yak Project 公众号,阅读原文