小白借助 AI 复盘某班课鉴权链路分析
提示:本文仅供参考学习,请勿用于非法用途!如有侵权联系我删除,为了保障他人权益,所以文中数据都是残缺或修改过的,更多的是思路分析。
写在前面
先说明一下,我是刚注册不久的小白,这篇帖子就是一次过程分享。
最近用到了某班课,想着就尝试用ai逆向一下他的签到流程
材料准备
- iOS 侧 HAR(看接口分工和头字段)
- Android 包(静态 + 动态)
- Frida / JADX / IDA 等常规工具
- AI 协作:gpt5.4、gpt5.3codex、claude4.6
分析过程
1) 先用 HAR 把接口分工看清
由于常用设备为ios,所以先在 iOS 上抓了两份 HAR,不急着猜签名,先确认接口职责。
签到提交接口:
/checkin
同时还能看到前置查询接口:
/xxx/{class_id}/checkins/current
/xxx/{class_id}/checkins?pageIndex=1
这一步让我先确定了“查”和“提”是分开的,checkin_id 也更可能来自前置结果而不是本地凭空生成。
2) 再看头字段,先分出两套风格
/checkin 这组头:
/ccs/{class_id}/checkins/current
/ccs/{class_id}/checkins?pageIndex=1
看到这里,其实思路已经出来一点了:
- App 不是直接无脑提
/checkin
- 它会先去查当前签到状态
checkin_id 应该不是本地乱造的,而是要从返回结果里拿
所以我自己把 HAR 这一步理解成:
先把“谁负责查、谁负责提”分清楚。
提头字段
接口路径看明白以后,最重要的就是header里面的鉴权头
1. /checkin 这条老接口的头
X-mssvc-access-id
X-mssvc-signature
X-mssvc-sec-ts
X-device-code
X-app-id
X-app-version
X-dpr
X-app-machine
X-app-system-version
Date
User-Agent
/xxx/... 这组头:
X-access-id
X-signature
X-client-app-id
X-client-version
Date
User-Agent
看到这里我就先立了一个假设:
这两类接口大概率不是同一个签名模板。
后面的动态分析基本验证了这个方向。
这里其实还有一层关键推理:
- 从头字段里反复出现
X-signature / X-mssvc-signature
- 并且它们和
Date、access-id 这类鉴权相关字段一起出现
- 结合常见 API 鉴权模式,优先怀疑是 HMAC 类签名
所以我的路线不是“直接开 Hook”,而是先做这个判断链:
HAR 头字段
-> 怀疑存在签名机制
-> 推测是 HMAC 类算法
-> 优先 Hook javax.crypto.Mac
3) 安卓动态:从“猜”切到“看运行时”
前期静态分析能拿到一些字符串和类名,但要完整串起“谁组串、谁签名、谁发请求”会比较卡。
所以我改了路线:让 App 在运行时自己把链路吐出来。实际排错后,普通 frida-server 附加在启动早期稳定性比较一般,后面切到 Zygisk Gadget 的等待注入模式,Hook 成功率明显提升。
我这里保留完整脚本,方便复现和对照(脚本不删)。
在 AI 协助下整理出的 frida_capture_all.js 如下:
'use strict';
// ======== 工具函数 ========
function bytesToHex(byteArray) {
if (!byteArray) return '(null)';
var hex = '';
for (var i = 0; i < byteArray.length; i++) {
var b = byteArray[i] & 0xff;
hex += ('0' + b.toString(16)).slice(-2);
}
return hex;
}
function bytesToUtf8(byteArray) {
if (!byteArray) return '(null)';
try {
var result = '';
for (var i = 0; i < Math.min(byteArray.length, 4096); i++) {
var b = byteArray[i] & 0xff;
if (b >= 0x20 && b < 0x7f) {
result += String.fromCharCode(b);
} else if (b === 0x0a) {
result += '\\n';
} else if (b === 0x0d) {
result += '\\r';
} else {
result += '\\x' + ('0' + b.toString(16)).slice(-2);
}
}
return result;
} catch (e) {
return '(decode error)';
}
}
var captureCount = 0;
// 用 send() 确保数据被 Python 端接收并写入日志
function log(text) {
send(text);
}
function logSection(title, data) {
captureCount++;
var lines = [];
lines.push('╔══════════════════════════════════════════════');
lines.push('║ [#' + captureCount + '] ' + title);
lines.push('╠══════════════════════════════════════════════');
for (var key in data) {
if (data.hasOwnProperty(key)) {
var val = data[key];
if (typeof val === 'string' && val.indexOf('\n') >= 0) {
lines.push('║ ' + key + ':');
val.split('\n').forEach(function(line) {
lines.push('║ ' + line);
});
} else {
lines.push('║ ' + key + ': ' + val);
}
}
}
lines.push('╚══════════════════════════════════════════════');
send(lines.join('\n'));
}
// ======== 全局 Mac key 跟踪 (修复 this 属性丢失) ========
var macKeyMap = {};
function getMacId(macObj) {
try {
return '' + Java.use('java.lang.System').identityHashCode(macObj);
} catch (e) {
return 'unknown';
}
}
// ======== MessageDigest update 数据缓存 ========
var mdUpdateMap = {};
function getMdId(mdObj) {
try {
return '' + Java.use('java.lang.System').identityHashCode(mdObj);
} catch (e) {
return 'unknown';
}
}
// ======== Android Hooks ========
Java.perform(function () {
log('================================================');
log(' TargetApp 全量捕获脚本 v3');
log(' 修复: 日志保存 + HMAC key追踪 + URLConnection捕获');
log('================================================');
// ===== 1. javax.crypto.Mac (HMAC签名捕获) =====
try {
var Mac = Java.use('javax.crypto.Mac');
Mac.init.overload('java.security.Key').implementation = function (key) {
try {
var keyBytes = key.getEncoded();
var keyHex = bytesToHex(keyBytes);
var keyStr = bytesToUtf8(keyBytes);
var macId = getMacId(this);
macKeyMap[macId] = { hex: keyHex, utf8: keyStr };
log('[Mac.init] ★ Key (hex): ' + keyHex);
log('[Mac.init] ★ Key (UTF8): ' + keyStr);
log('[Mac.init] Key length: ' + keyBytes.length + ' bytes');
log('[Mac.init] Algorithm: ' + this.getAlgorithm());
} catch (e) {
log('[Mac.init] error: ' + e);
}
return this.init(key);
};
try {
Mac.init.overload('java.security.Key', 'java.security.spec.AlgorithmParameterSpec').implementation = function (key, params) {
try {
var keyBytes = key.getEncoded();
var macId = getMacId(this);
macKeyMap[macId] = { hex: bytesToHex(keyBytes), utf8: bytesToUtf8(keyBytes) };
log('[Mac.init2] ★ Key: ' + bytesToHex(keyBytes));
} catch (e) {}
return this.init(key, params);
};
} catch (e) {}
Mac.doFinal.overload('[B').implementation = function (data) {
var result = this.doFinal(data);
var macId = getMacId(this);
var keyInfo = macKeyMap[macId] || { hex: '(unknown)', utf8: '(unknown)' };
var info = {};
info['Algorithm'] = this.getAlgorithm();
info['Key (hex)'] = keyInfo.hex;
info['Key (UTF8)'] = keyInfo.utf8;
var dataKey = 'Data (' + data.length + ' bytes)';
info[dataKey] = bytesToUtf8(data);
info['Data (hex)'] = bytesToHex(data);
info['MAC output (hex)'] = bytesToHex(result);
info['Stacktrace'] = Java.use('android.util.Log').getStackTraceString(
Java.use('java.lang.Exception').$new());
logSection('HMAC 签名', info);
return result;
};
Mac.doFinal.overload().implementation = function () {
var result = this.doFinal();
var macId = getMacId(this);
var keyInfo = macKeyMap[macId] || { hex: '(unknown)', utf8: '(unknown)' };
logSection('HMAC 签名 (update+final)', {
'Algorithm': this.getAlgorithm(),
'Key (hex)': keyInfo.hex,
'Key (UTF8)': keyInfo.utf8,
'MAC output (hex)': bytesToHex(result)
});
return result;
};
try {
Mac.update.overload('[B').implementation = function (data) {
log('[Mac.update] Data (' + data.length + ' bytes): ' + bytesToUtf8(data));
return this.update(data);
};
} catch (e) {}
log('[+] Hooked javax.crypto.Mac');
} catch (e) {
log('[-] Mac hook failed: ' + e);
}
// ===== 2. MessageDigest + update() hook =====
try {
var MessageDigest = Java.use('java.security.MessageDigest');
// Hook update() 追踪增量数据
try {
MessageDigest.update.overload('[B').implementation = function (data) {
var mdId = getMdId(this);
if (!mdUpdateMap[mdId]) mdUpdateMap[mdId] = [];
var chunk = bytesToUtf8(data);
mdUpdateMap[mdId].push(chunk);
if (data.length < 512) {
log('[MD.update] ' + this.getAlgorithm() + ' (' + data.length + 'B): ' + chunk);
}
return this.update(data);
};
} catch (e) {}
try {
MessageDigest.update.overload('[B', 'int', 'int').implementation = function (data, off, len) {
var mdId = getMdId(this);
if (!mdUpdateMap[mdId]) mdUpdateMap[mdId] = [];
var sub = [];
for (var i = 0; i < len; i++) sub.push(data[off + i]);
var chunk = bytesToUtf8(sub);
mdUpdateMap[mdId].push(chunk);
if (len < 512) {
log('[MD.update] ' + this.getAlgorithm() + ' (' + len + 'B): ' + chunk);
}
return this.update(data, off, len);
};
} catch (e) {}
MessageDigest.digest.overload('[B').implementation = function (data) {
var result = this.digest(data);
var algo = this.getAlgorithm();
if (data.length < 4096) {
var mdInfo = {};
mdInfo['Algorithm'] = algo;
var dataKey = 'Data (' + data.length + ' bytes)';
mdInfo[dataKey] = bytesToUtf8(data);
mdInfo['Digest (hex)'] = bytesToHex(result);
logSection('MessageDigest', mdInfo);
}
return result;
};
MessageDigest.digest.overload().implementation = function () {
var result = this.digest();
var mdId = getMdId(this);
var updateData = mdUpdateMap[mdId] || [];
var info = {
'Algorithm': this.getAlgorithm(),
'Digest (hex)': bytesToHex(result)
};
if (updateData.length > 0) {
info['Update data (拼接)'] = updateData.join('');
}
logSection('MessageDigest (final)', info);
delete mdUpdateMap[mdId];
return result;
};
log('[+] Hooked MessageDigest (含 update)');
} catch (e) {
log('[-] MessageDigest hook failed: ' + e);
}
// ===== 3. HttpURLConnection 完整捕获 =====
try {
var HttpURLConnection = Java.use('java.net.HttpURLConnection');
HttpURLConnection.setRequestProperty.implementation = function (key, value) {
if (key && (key.toLowerCase().indexOf('x-') === 0 ||
key.toLowerCase() === 'date' ||
key.toLowerCase() === 'content-type' ||
key.toLowerCase() === 'authorization')) {
log('[URLConn Header] ' + key + ': ' + value);
}
return this.setRequestProperty(key, value);
};
log('[+] Hooked HttpURLConnection.setRequestProperty');
} catch (e) {
log('[-] HttpURLConnection header hook failed: ' + e);
}
try {
var URL = Java.use('java.net.URL');
URL.openConnection.overload().implementation = function () {
var conn = this.openConnection();
var urlStr = this.toString();
// 替换了特征域名过滤,请按需修改关键字
if (urlStr.indexOf('targetdomain') >= 0 || urlStr.indexOf('targetkey') >= 0) {
log('[URLConn Open] ' + urlStr);
}
return conn;
};
log('[+] Hooked java.net.URL.openConnection');
} catch (e) {}
// Hook getInputStream 捕获响应体
try {
var HttpsURLConnection = Java.use('javax.net.ssl.HttpsURLConnection');
HttpsURLConnection.getInputStream.implementation = function () {
var stream = this.getInputStream();
try {
var url = this.getURL().toString();
// 替换了特征域名过滤
if (url.indexOf('targetdomain') >= 0 || url.indexOf('targetkey') >= 0) {
var code = this.getResponseCode();
var respHeaders = '';
var headerFields = this.getHeaderFields();
var iter = headerFields.entrySet().iterator();
while (iter.hasNext()) {
var entry = iter.next();
var hKey = entry.getKey();
var hVal = entry.getValue();
if (hKey !== null) {
respHeaders += hKey + ': ' + hVal.toString() + '\n';
}
}
// 读取全部响应体
var ByteArrayOutputStream = Java.use('java.io.ByteArrayOutputStream');
var ByteArrayInputStream = Java.use('java.io.ByteArrayInputStream');
var baos = ByteArrayOutputStream.$new();
var buf = Java.array('byte', new Array(4096).fill(0));
var n;
while ((n = stream.read(buf)) !== -1) {
baos.write(buf, 0, n);
}
stream.close();
var bodyBytes = baos.toByteArray();
var bodyStr = Java.use('java.lang.String').$new(bodyBytes, 'UTF-8');
logSection('HTTP 响应 ★', {
'URL': url,
'Status': '' + code,
'Headers': respHeaders,
'Body': bodyStr
});
// 返回新的 InputStream 让 app 继续正常读取
return ByteArrayInputStream.$new(bodyBytes);
}
} catch (e) {
log('[URLConn getInputStream] error: ' + e);
}
return stream;
};
log('[+] Hooked HttpsURLConnection.getInputStream (响应体捕获)');
} catch (e) {
log('[-] HttpsURLConnection.getInputStream hook failed: ' + e);
}
// Hook getOutputStream 记录请求体 URL
try {
var HttpsURLConn2 = Java.use('javax.net.ssl.HttpsURLConnection');
HttpsURLConn2.getOutputStream.implementation = function () {
var os = this.getOutputStream();
try {
var url = this.getURL().toString();
if (url.indexOf('targetdomain') >= 0 || url.indexOf('targetkey') >= 0) {
log('[URLConn getOutputStream] URL: ' + url);
}
} catch (e) {}
return os;
};
log('[+] Hooked HttpsURLConnection.getOutputStream');
} catch (e) {
log('[-] getOutputStream hook failed: ' + e);
}
// ===== 4. OkHttp 请求(安全模式,不读 body 避免消耗) =====
try {
var RequestBuilder = Java.use('okhttp3.Request$Builder');
RequestBuilder.addHeader.implementation = function (name, value) {
log('[OkHttp +Header] ' + name + ': ' + value);
return this.addHeader(name, value);
};
RequestBuilder.header.implementation = function (name, value) {
log('[OkHttp =Header] ' + name + ': ' + value);
return this.header(name, value);
};
try {
RequestBuilder.url.overload('java.lang.String').implementation = function (url) {
if (url.indexOf('targetkey') >= 0) {
log('[OkHttp URL] ' + url);
}
return this.url(url);
};
} catch (e) {}
log('[+] Hooked OkHttp Request.Builder (安全模式)');
} catch (e) {
log('[-] OkHttp Request.Builder hook failed: ' + e);
}
// OkHttp Interceptor
try {
var RealInterceptorChain = Java.use('okhttp3.internal.http.RealInterceptorChain');
RealInterceptorChain.proceed.overload('okhttp3.Request').implementation = function (request) {
var url = request.url().toString();
if (url.indexOf('targetkey') >= 0) {
var method = request.method();
var headers = request.headers();
var headerStr = '';
for (var i = 0; i < headers.size(); i++) {
headerStr += headers.name(i) + ': ' + headers.value(i) + '\n';
}
var reqBodyStr = '';
var reqBody = request.body();
if (reqBody) {
try {
var FormBody = Java.use('okhttp3.FormBody');
if (FormBody.class.isInstance(reqBody)) {
var fb = Java.cast(reqBody, FormBody);
var parts = [];
for (var j = 0; j < fb.size(); j++) {
parts.push(fb.encodedName(j) + '=' + fb.encodedValue(j));
}
reqBodyStr = parts.join('&');
}
} catch (e) {
reqBodyStr = '(非FormBody)';
}
}
logSection('OkHttp 请求', {
'URL': url,
'Method': method,
'Headers': headerStr,
'Body': reqBodyStr || '(empty)'
});
var response = this.proceed(request);
try {
var respHeaders = response.headers();
var respHeaderStr = '';
for (var k = 0; k < respHeaders.size(); k++) {
respHeaderStr += respHeaders.name(k) + ': ' + respHeaders.value(k) + '\n';
}
var source = response.body().source();
source.request(Java.use('java.lang.Long').MAX_VALUE.value);
var respBuffer = source.getBuffer().clone();
var respBodyStr = respBuffer.readUtf8();
logSection('OkHttp 响应', {
'URL': url,
'Status': response.code() + ' ' + response.message(),
'Headers': respHeaderStr,
'Body': respBodyStr
});
} catch (e) {
logSection('OkHttp 响应 (读取失败)', {
'URL': url,
'Error': e.toString()
});
}
return response;
}
return this.proceed(request);
};
log('[+] Hooked OkHttp RealInterceptorChain');
} catch (e) {
log('[-] RealInterceptorChain hook failed: ' + e);
}
// ===== 5. SharedPreferences 读写 =====
try {
var SharedPrefsImpl = Java.use('android.app.SharedPreferencesImpl');
var Editor = Java.use('android.app.SharedPreferencesImpl$EditorImpl');
Editor.putString.implementation = function (key, value) {
if (key && (key.toLowerCase().indexOf('token') >= 0 ||
key.toLowerCase().indexOf('key') >= 0 ||
key.toLowerCase().indexOf('secret') >= 0 ||
key.toLowerCase().indexOf('sign') >= 0 ||
key.toLowerCase().indexOf('access') >= 0 ||
key.toLowerCase().indexOf('user') >= 0 ||
key.toLowerCase().indexOf('pwd') >= 0 ||
key.toLowerCase().indexOf('session') >= 0 ||
key.toLowerCase().indexOf('login') >= 0 ||
key.toLowerCase().indexOf('id') >= 0)) {
logSection('SharedPrefs 写入 ★', {
'Key': key,
'Value': value ? value.toString() : '(null)'
});
}
return this.putString(key, value);
};
SharedPrefsImpl.getString.implementation = function (key, defValue) {
var result = this.getString(key, defValue);
if (key && (key.toLowerCase().indexOf('token') >= 0 ||
key.toLowerCase().indexOf('key') >= 0 ||
key.toLowerCase().indexOf('secret') >= 0 ||
key.toLowerCase().indexOf('sign') >= 0 ||
key.toLowerCase().indexOf('access') >= 0)) {
logSection('SharedPrefs 读取', {
'Key': key,
'Value': result ? result.toString() : '(null)'
});
}
return result;
};
log('[+] Hooked SharedPreferences');
} catch (e) {
log('[-] SharedPreferences hook failed: ' + e);
}
// ===== 6. 延迟 hook 解壳后的类 =====
hookDecryptedClasses();
});
function hookDecryptedClasses() {
setTimeout(function () {
Java.perform(function () {
log(' 延迟 hook (10s) - 尝试加载解壳后的类...');
// 替换了目标 App 的特有包名和类名
var classesToTry = [
'com.targetapp.network.TargetClient',
'com.targetapp.network.coreapi.CoreApiClient',
'com.targetapp.network.coreapi.signbody.BaseRequestSignBody',
'com.targetapp.network.mssvc.MssvcClient',
'com.targetapp.network.mssvc.MssvcApiClient'
];
classesToTry.forEach(function (clsName) {
try {
var cls = Java.use(clsName);
log('[+] Found: ' + clsName);
var methods = cls.class.getDeclaredMethods();
for (var i = 0; i < methods.length; i++) {
var m = methods[i];
var mName = m.getName();
if (mName.indexOf('sign') >= 0 || mName.indexOf('Sign') >= 0 ||
mName.indexOf('token') >= 0 || mName.indexOf('key') >= 0 ||
mName.indexOf('header') >= 0 || mName.indexOf('request') >= 0 ||
mName.indexOf('init') >= 0 || mName.indexOf('secret') >= 0) {
log(' ★ ' + m.toString());
}
}
} catch (e) {}
});
Java.enumerateLoadedClasses({
onMatch: function (className) {
// 替换了包名关键字过滤
if (className.indexOf('targetapp') >= 0 &&
(className.indexOf('sign') >= 0 || className.indexOf('Sign') >= 0 ||
className.indexOf('token') >= 0 || className.indexOf('Token') >= 0 ||
className.indexOf('Client') >= 0 || className.indexOf('Secret') >= 0)) {
log('[Loaded] ' + className);
}
},
onComplete: function () {
log(' 类枚举完成');
}
});
});
}, 10000);
setTimeout(function () {
Java.perform(function () {
log(' 二次延迟 hook (20s)...');
try {
var BaseSign = Java.use('com.targetapp.network.coreapi.signbody.BaseRequestSignBody');
var baseMethods = BaseSign.class.getDeclaredMethods();
log('[BaseRequestSignBody] 方法列表:');
for (var i = 0; i < baseMethods.length; i++) {
log(' ' + baseMethods[i].toString());
}
var fields = BaseSign.class.getDeclaredFields();
log('[BaseRequestSignBody] 字段列表:');
for (var j = 0; j < fields.length; j++) {
log(' ' + fields[j].toString());
}
} catch (e) {
log('[-] BaseRequestSignBody not loaded: ' + e.message);
}
try {
var CoreClient = Java.use('com.targetapp.network.coreapi.CoreApiClient');
var coreMethods = CoreClient.class.getDeclaredMethods();
log('[CoreApiClient] 方法列表:');
for (var i = 0; i < coreMethods.length; i++) {
log(' ' + coreMethods[i].toString());
}
} catch (e) {
log('[-] CoreApiClient not loaded: ' + e.message);
}
Java.enumerateLoadedClasses({
onMatch: function (className) {
if (className.indexOf('targetapp') >= 0 &&
(className.indexOf('sign') >= 0 || className.indexOf('Sign') >= 0)) {
log('[Loaded 20s] ' + className);
try {
var cls = Java.use(className);
var methods = cls.class.getDeclaredMethods();
for (var i = 0; i < methods.length; i++) {
log(' → ' + methods[i].toString());
}
} catch (e) {}
}
},
onComplete: function () {}
});
});
}, 20000);
}
脚本的第一版思路很直接,就是先把下面这些点都挂上:
Java.perform(function () {
hookMac();
hookMessageDigest();
hookHttpURLConnection();
hookOkHttpBuilder();
hookSharedPreferences();
hookInterestingUrls(['/checkin', '/xxx/.../checkins/current']);
});
这一段其实就已经够说明思路了:
Mac 看签名输入输出
MessageDigest 看摘要参与情况
HttpURLConnection / OkHttp 看头和 URL
SharedPreferences 看本地取值
因为前面从 HAR 看头字段时,我已经知道有一类头很像签名结果,所以脚本里最先重点观察的就是 javax.crypto.Mac。
function hookMac() {
var Mac = Java.use('javax.crypto.Mac');
Mac.init.overload('java.security.Key').implementation = function (key) {
log('[Mac.init] algorithm=' + this.getAlgorithm());
return this.init(key);
};
Mac.update.overload('[B').implementation = function (data) {
log('[Mac.update] data=' + bytesToUtf8(data));
return this.update(data);
};
Mac.doFinal.overload().implementation = function () {
var result = this.doFinal();
log('[Mac.doFinal] out=' + bytesToHex(result));
return result;
};
}
我当时就是靠这段,先看到了班课侧 /xxx/... 那组请求的签名串输入格式。
也正因为这里先有了运行时证据,后面我才敢判断:
/xxx/... 和 /checkin 不是一套完全相同的签名风格。
4) 再去盯 URL 和调用栈
只看 Mac 还不够,因为你只能知道“哪里在算”,但不一定知道“算的是哪条链”。
所以后面我又把 URL 构造点也挂上了。
这里也直接贴正文保留的关键思路片段:
function hookInterestingUrls(targets) {
var URL = Java.use('java.net.URL');
URL.$init.overload('java.lang.String').implementation = function (url) {
for (var i = 0; i < targets.length; i++) {
if (url.indexOf(targets[i]) >= 0) {
log('[URL.<init>] ' + url);
log(Java.use('android.util.Log').getStackTraceString(
Java.use('java.lang.Exception').$new()
));
}
}
return this.$init(url);
};
}
我自己后来回头看,真正把分析往前推了一步的,不是“搜到了路径”,而是:
- URL 出现了
- 调用栈也跟着出来了
- 这样才能知道后面该追哪个运行时网络类
5) 关键日志
1) Api_A 登录请求的签名链
[#986] MessageDigest (final)
Algorithm: MD5
Digest (hex): 208e8b8脱敏253993
[Mac.init] ★ Key (UTF8): <KEY_REDACTED>
[Mac.init] Key length: xx bytes
[Mac.init] Algorithm: HmacSHA1
[Mac.update] Data: POST|/xxx/account-login|MTANDROID|Tue, 10 Mar 2026 14:11:27 GMT||208e8b8脱敏993
[#987] HMAC 签名 (update+final)
MAC output (hex): fabe112dc543a74d脱敏cd02d345d57
[URLConn Open]/xxx/account-login
这段日志说明了三件事:
- 先对请求体做
MD5
- 再把
MD5 结果拼进签名原串
- 最终使用
HmacSHA1
2) 签到查询链(/checkins + /current)
[Mac.init] ★ Key (UTF8): <KEY_REDACTED>
[Mac.init] Key length: xx bytes
[Mac.init] Algorithm: HmacSHA1
[Mac.update] Data: GET|/xxx/<CC_ID>/checkins|<ACCESS_ID>|Tue, 10 Mar 2026 14:13:14 GMT|order=DESC&pageIndex=1&pageSize=100&sortBy=OPENTIME|
[#1201] HMAC 签名 (update+final)
MAC output (hex): edcf4458脱敏db029e
[URLConn Open] /xxx/<CC_ID>/checkins?order=DESC&pageIndex=1&pageSize=100&sortBy=OPENTIME
[Mac.update] Data: GET|/xxx/<CC_ID>/checkins/current|<ACCESS_ID>|Tue, 10 Mar 2026 14:13:17 GMT||
[#1208] HMAC 签名 (update+final)
MAC output (hex): 2b98f脱敏0ea24f5
[URLConn Open]/xxx/<CC_ID>/checkins/current
这段能直接看出常规接口的组串模板:
METHOD|PATH|ACCESS_ID|DATE|QUERY|
- 没有 query 时,
QUERY 为空,但结尾分隔符 | 仍保留
3) Api_B 链路(以某消息列表接口为样本)
[#1185] MessageDigest (final)
Algorithm: MD5
Digest (hex): 6c07f5脱敏7353
[Mac.init] ★ Key (UTF8): 脱敏
[Mac.init] Key length: xx bytes
[Mac.init] Algorithm: HmacSHA1
[Mac.update] Data: /xxxxx/index.php/ccmsg/list_unread|<ACCESS_ID>|Tue, 10 Mar 2026 14:12:58 GMT|6C07F脱敏07353
[#1186] HMAC 签名 (update+final)
MAC output (hex): 4434da38脱敏86221d02b45
[URLConn Open] /xxxxx/index.php/ccmsg/list_unread
这一条和 常规接口 的差异也很明显:
- 组串第一段是完整 URL,不是 path
MD5 参与后会转成大写十六进制再拼接
6) 还原签名生成逻辑
为了避免“只看一条日志就下结论”,我实际是按下面这条线做交叉验证:
- 先定算法:看
Mac.init 的 Algorithm,先确认主流程是不是 HmacSHA1。
- 再定原串:看同一调用链里的
Mac.update Data,把字段顺序完整抄下来。
- 定摘要参与方式:看
MessageDigest (final),确认是否先做 MD5、是否大小写转换后再入串。
- 对齐请求语义:把
Mac.update 里的每段内容和 URL/Query/Date/Header 一一对应,确认 path/full_url、access_id、date、query/body 的位置关系。
- 多样本回放:同一接口抓多次、同一时段抓不同接口,验证“固定槽位不变、变量槽位随请求变化”。
- 反证排错:排除同名但语义不同的字段,避免把无关值误当鉴权参数。
签名生成逻辑(Java层)
下面这部分是基于 Java 层日志还原出的签名流程。可以看出,签名并不是单一函数的固定模板,而是“使用同一套 HmacSHA1,但按不同接口族使用不同组串规则”。
1) 常规业务接口
输入:
method (GET/POST/PATCH...)
path (如 /xxx/<CC_ID>/checkins/current)
access_id (X-access-id)
date (Date 头)
query (URL query,无则空串)
key (运行时捕获)
1. 组串:method + "|" + path + "|" + access_id + "|" + date + "|" + query + "|"
2. UTF-8 编码组串
3. HMAC-SHA1(key, bytes)
4. 输出 20 字节摘要,转 40 位小写十六进制
5. 作为签名值写入 X-signature
2) 登录接口
输入:
body_json_compact
app_id = MTANDROID
date
key = <KEY_REDACTED>
1. body_json_compact -> MD5 -> body_md5_lower
2. 组串:"POST|/xxxxxxxx/account-login|" + app_id + "|" + date + "||" + body_md5_lower
3. UTF-8 编码组串
4. HMAC-SHA1(key, bytes)
5. 输出 20 字节摘要,转 40 位小写十六进制
3) 消息类服务接口
输入:
full_url (完整 URL)
access_id (X-xxxxx-access-id)
date (Date 头)
body_or_param
key
1. body_or_param -> MD5 -> body_md5_lower
2. body_md5_lower 转大写 -> body_md5_upper
3. 组串:full_url + "|" + access_id + "|" + date + "|" + body_md5_upper
4. UTF-8 编码组串
5. HMAC-SHA1(key, bytes)
6. 输出 20 字节摘要,转 40 位小写十六进制
7. 作为 X-xxxxx-signature
4) 关于 access_id
这一项单独说结论:access_id 来自登录接口响应,不是本地每次现算。
证据链(Java 层):
- 登录响应解析时,直接从返回值提取 accessId。
- 登录成功后,该值会写入会话并持久化到本地缓存,作为后续请求的鉴权上下文。
- 发起请求时,该值被放进特定的 Header 头中。
- HAR 可见这个头在同一会话内基本稳定,真正频繁变化的是 path/query/date/body,因此签名值会随请求内容变化。
写在最后
现在的 AI 的迭代速度真的太快了。很多以前要花很久才能摸到门槛的事情,现在通过和模型来回协作,能更快从“看不懂”走到“有证据地说清楚”。
当然,AI 不是替代思考的魔法棒,它更像一个放大器:你问得越具体、验证越扎实,得到的结果就越可靠。对我这种新手来说,这种变化很鼓舞人,也让我更愿意继续把每一次分析过程认真记录下来。
第一次发逆向分析帖,如有错误欢迎指正。
最后
本文仅用于技术研究与学习交流,请勿用于任何商业用途。