企业版抖音私信发送关键加密参数 `reuqest_sign` 逆向分析记录
私信发送关键加密参数reuqest_sign逆向分析记录前言在分析私信发送接口时请求体中有几个关键字段token sdk_cert ts_sign reuqest_sign其中token、sdk_cert、ts_sign属于证书认证阶段产物而真正和私信内容强相关的本地签名字段是reuqest_sign注意字段名是源码里的拼写reuqest_sign不是request_sign。本文以私信发送请求为切入点记录reuqest_sign的分析过程重点说明它的生成字段、签名算法以及最终如何进入 protobuf 请求体。本文仅用于授权环境下的接口安全研究与协议分析。1. 私信发送请求结构发送私信时请求结构大致如下constdata{headers:{is-retry:0},body:{send_message_body:{conversation_id,conversation_short_id,conversation_type:1,content,mentioned_users:[],client_message_id,message_type:7}},cmd:100,sequence_id:{low:10012,high:0,unsigned:false},refer:3,token:...,sdk_version:1.2.8,build_number:aa0441b:master,inbox_type:0,device_platform:mp_pc,channel:mp,auth_type:3,biz:leads_platform,access:web_sdk,sdk_cert:...,ts_sign:...,iter_version:1,timestamp:{low:timestamp,high:0,unsigned:false},reuqest_sign};这里容易混淆的是token sdk_cert ts_sign reuqest_sign一开始容易误以为这些字段都是通过本地 JS 算出来的但继续跟源码后发现并不是。2.token、sdk_cert、ts_sign的来源在 IM SDK 中认证类型是auth_type:3对应源码中的AuthType.CERT_AUTH在8320.085a9e6a.js中可以看到 token 刷新逻辑rthis.ctx.option;ir.token;sr.authType;这里的ithis.ctx.option.token也就是说所谓的tokenFn实际上就是 IM SDK 初始化参数中的option.token当认证方式是CERT_AUTH时SDK 会执行awaitthis.handleCertAuthToken(i)进入handleCertAuthToken后核心逻辑是certSignRequestOrPubKeyawaitSecurityProxy.getCertSignRequest();// 或 certAuthZeroTrustUsePubKeytrue 时certSignRequestOrPubKeyawaitSecurityProxy.getPubKey();sawaittokenFn(certSignRequestOrPubKey);tsSigns.tsSign;tokens.token;sdkCerts.sdkCert;然后 SDK 会保存这些值this.ctx.option.certsdkCert;this.ctx.option.tsSigntsSign;this.ctx.cachedTokentoken;最终请求体中的对应关系为data.tokentoken;data.ts_signtsSign;data.sdk_certsdkCert;所以结论是token、sdk_cert、ts_sign 不是从 send_message data 本地计算出来的。 它们来自 CERT_AUTH 阶段的 token 函数返回值。真正与消息内容本地签名相关的是reuqest_sign3.SecurityProxy的作用SecurityProxy位于8480.b2f72c27.js中主要负责和 bd-ticket-guard 相关的证书、CSR、公钥、ticket 数据交互。可以看到这些方法getCertSignRequest(){returnthis.sdk.cryptoSDK.getCertSignRequest();}getPubKey(){returnthis.sdk.cryptoSDK.getPubKey();}setSignValue(t){this.sdk.cryptoSDK.setSignValue({sign:t,scene:this.securityScene});}也就是说SecurityProxy负责提供certSignRequest或pubKey然后业务传入的token函数拿这个值去换{token,tsSign,sdkCert}这些认证字段准备好之后发送消息时才会继续生成reuqest_sign。4.reuqest_sign的触发位置继续看8480.b2f72c27.js里的发送插件逻辑可以看到类似代码if(this.isCertAuth){const{cert,tsSign}this.ctx.option;packet.sdk_certcert||;packet.ts_signtsSign||;consttimestampMath.round(Date.now()/1000);if(this.ctx.option.certAuthZeroTrustUsePubKey){packet.iter_version1;packet.timestamptimestamp;}if(Oi.has(packet.cmd)){construleOi.get(packet.cmd);constbodyKeyrule[0];constsignFieldsrule.slice(1);// 拼接签名内容// 调 SecurityProxy.signSync 或 signpacket.reuqest_signreq_sign;}}这里的Oi是一个签名字段映射表。对私信发送来说cmd为cmd100对应SEND_MESSAGE签名字段配置是[send_message_body,content,conversation_id,conversation_short_id]如果开启certAuthZeroTrustUsePubKey则还会追加timestamp所以私信发送时参与签名的字段是content conversation_id conversation_short_id timestamp5. 签名原文拼接规则对私信发送而言签名原文大致为contentxxxconversation_idxxxconversation_short_idxxxtimestampxxx源码中的字段会先排序fields.sort();然后逐个拼接fieldvalue数组会被转成逗号拼接array.map(String).join(,)Long 类型会转成字符串String(longValue)因此构造函数可以写成functionnormalizeValue(value){if(Array.isArray(value)){returnvalue.map(vvnull?:String(v)).join(,);}if(valuenull){return;}if(typeofvalueobjecttypeofvalue.toStringfunction){returnvalue.toString();}returnString(value);}functionbuildSignData(body,signFields,timestamp){constparts[];signFields.slice().sort().forEach(field{if(fieldtimestamp){parts.push(timestamp${normalizeValue(timestamp)});}else{parts.push(${field}${normalizeValue(body[field])});}});returnparts.join();}私信发送时调用constsignDatabuildSignData(data.body.send_message_body,[content,conversation_id,conversation_short_id,timestamp],timestamp);得到类似content{text:1}conversation_id0:1:xxx:xxxconversation_short_id7619364225462403626timestamp17766778116. 签名算法确认继续追底层签名实现可以在8480.b2f72c27.js中看到this.signfunction(t){varrKEYUTIL.getKey(privateKey);varnnewKJUR.crypto.Signature({alg:SHA256withECDSA});n.init(r);n.signString(t);}所以算法是SHA256withECDSA签名结果先是 DER 格式的 hex再转 base64req_signBuffer.from(signatureHex,hex).toString(base64);在 Node.js 中可以这样实现constcryptorequire(crypto);functionsignRequest(signData,privateKeyPem){constsignercrypto.createSign(sha256);signer.update(signData);signer.end();constdersigner.sign({key:privateKeyPem,dsaEncoding:der});return{hex:der.toString(hex),base64:der.toString(base64)};}最终data.reuqest_signsignResult.base64;7. 完整构造流程整体流程可以拆成两段。第一段证书认证阶段获取三件套constcertSignRequestawaitSecurityProxy.getCertSignRequest();// 或constpubKeyawaitSecurityProxy.getPubKey();constcertAuthawaittokenFn(certSignRequest);// 返回{token:...,tsSign:...,sdkCert:...}第二段发送消息阶段本地计算reuqest_signconstsendBody{conversation_id,conversation_short_id,conversation_type:1,content,mentioned_users:[],client_message_id,message_type:7};consttimestampMath.floor(Date.now()/1000);constsignDatabuildSignData(sendBody,[content,conversation_id,conversation_short_id,timestamp],timestamp);constsignResultsignRequest(signData,privateKeyPem);constdata{headers:{is-retry:0},body:{send_message_body:sendBody},cmd:100,sequence_id,refer:3,token:certAuth.token,sdk_version:1.2.8,build_number:aa0441b:master,inbox_type:0,device_platform:mp_pc,channel:mp,auth_type:3,biz:leads_platform,access:web_sdk,sdk_cert:certAuth.sdkCert,ts_sign:certAuth.tsSign,iter_version:1,timestamp:{low:timestamp,high:0,unsigned:false},reuqest_sign:signResult.base64};最后再对data做 protobuf 编码并转 base64。8. protobuf 编码请求并不是直接 JSON 发送而是经过 protobuf 编码。核心逻辑类似functionencodeRequestBase64(request){constbytesRequest.encode(request).finish();returnBuffer.from(bytes).toString(base64);}所以最终提交的请求体不是普通 JSON而是constrequestBase64encodeRequestBase64(data);9. 总结这次私信发送关键加密分析可以总结为token、sdk_cert、ts_sign 来自 CERT_AUTH 阶段。 reuqest_sign 来自本地 ECDSA 签名。 request_base64 来自 protobuf 编码。字段来源如下字段来源tokentokenFn(certSignRequest/pubKey)返回值sdk_certtokenFn(certSignRequest/pubKey)返回值ts_signtokenFn(certSignRequest/pubKey)返回值reuqest_sign本地用 EC 私钥签名消息字段request_base64protobuf 编码后的请求其中reuqest_sign的核心是SHA256withECDSA( content...conversation_id...conversation_short_id...timestamp... )签名结果使用 DER 编码然后转 base64填入data.reuqest_sign整个链路里最关键的点是要区分认证签发参数token / sdk_cert / ts_sign 消息请求签名reuqest_sign前者需要业务侧 token 函数或页面运行时环境配合后者可以在本地根据私钥和消息字段复现。