前一篇逆的是BOSS直聘,搜索接口每个请求都由 native libyzwg.so 出 sp/sig,离设备复现要先把加签算法啃出来。这一篇换一个职场社交 App,本想按同样的套路找它的加签栈,结果找了个遍,没有。请求的鉴权全压在 access_token 上,其余是一串明文设备参数。反倒省了最难的那一步。
样本 v6.6.84(com.taou.maimai),arm64-v8a。抓包看请求:URL query 里一长串参数(version/channel/device/u/access_token/density…),没有密文签名字段,POST 请求体是普通表单或 JSON,响应是明文 JSON。要搞清的就一件事:一条请求凭什么被服务器接受,这个「凭什么」能不能在 PC 上离设备造出来。
包体里能看到 libnativelib_maimai.so、libghostlib_maimai.so、libdexvmp.so(DEX VMP 壳)、一整套 React Native / Hermes、cronet。Java 层几乎每个类都挂着美团 Robust 热修的 ChangeQuickRedirect/PatchProxy,jadx 反编译出来带一层样板,但不影响读逻辑。
环境:
| 层 | 用什么 |
|---|---|
| 静态 | jadx-headless MCP(反编译 dex) |
| 复现 | 纯 Python(requests) |
| 验证 | 单测对拍 URL 装配 / 通用参数 / 登录 RSA |
一、请求装配的骨架
先找请求是怎么拼出来的。所有业务请求都继承一个基类 vb.AbstractC7495(BaseRequest.java),parameters() 用反射把请求对象的字段转成参数 map,URL 由 api() 给出:
public abstract class AbstractC7495 { public abstract String api(Context context);
public Map<String, Object> parameters() { Map<String, Object> map = C6563.m15554(this); // 反射:字段名(@SerializedName)->值 Map<String, Object> common = this.mCommonParameters; if (common != null) map.putAll(common); return map; } public String parameterString() { // key=Uri.encode(value),'&' 拼接 ... sb.append(entry.getKey()); sb.append('='); sb.append(Uri.encode(String.valueOf(entry.getValue()))); }}一个具体请求长这样,只声明字段 + 给出端点,没有任何加签动作:
public static class Req extends AbstractC7495 { public String mobile, cptoken, cpval, voice, yidun_validate, yidun_captcha_id; public int yidun_error_code, refresh_captcha; public String api(Context context) { return C7497.getNewApi(context, "account", "v5", "send_reg_login_code_v3"); } public Map<String,Object> parameters() { /* 只 put 上面这些业务字段 */ }}端点由 vb.C7497.getNewApi(module, version, name) 拼,规则就是路径拼接:
public static String getNewApi(Context context, String module, String version, String name, String base) { StringBuilder sb = new StringBuilder(); sb.append(base); // NetworkConstants.NEW_BASE_URI = https://open.taou.com/maimai if (!empty(module)) sb.append("/").append(module); if (!empty(version)) sb.append("/").append(version); sb.append("/").append(name); C6526.m15418().mo7957(sb, context); // <- 关键:这一步往 URL 追加通用参数 return sb.toString();}https://open.taou.com/maimai/<module>/<version>/<name>,最后那句 mo7957(sb, context) 是唯一往 URL 里加东西的地方。如果有签名,只可能在这里。
二、通用参数层:把签名找了个遍,没有
C6526.m15418() 走 ARouter 拿到 IApp 服务,mo7957 的实现在 tl.C6998,转一手到 df.C2665.m11054。这个方法就是通用参数装配器,把设备 / 会话 / 鉴权参数全拼进 URL query:
public static void m11054(StringBuilder sb2, Context context) { LoginInfo loginInfo = LoginInfo.getInstance(context); sb2.append(sb2.indexOf("?") >= 0 ? "&" : "?"); sb2.append("version=").append(GlobalConstants.VERSION_NAME); sb2.append("&ver_code=android_").append(GlobalConstants.VERSION_CODE); sb2.append("&channel=").append(GlobalConstants.getChannel()); sb2.append("&vc=").append(Uri.encode(GlobalConstants.MM_SYSTEM_INFO)); sb2.append("&net=").append(C6794.f19377); sb2.append("&appid=3"); sb2.append("&device=").append(Uri.encode(GlobalConstants.MM_DEVICE_INFO)); sb2.append("&udid=").append(Uri.encode(C6544.m15448())); sb2.append("&isEmulator=").append(GlobalContext.getIsEmulator() ? "1" : "0"); sb2.append("&rn_version=").append("0.69.0"); sb2.append("&android_id=").append(Uri.encode(PrivacyDataManager.INSTANCE.getAndroidId())); m11055(sb2, "&oaid=", C5930.f17561); m11055(sb2, "&u=", String.valueOf(loginInfo.getIdentity())); // uid m11055(sb2, "&access_token=", String.valueOf(loginInfo.accessToken)); sb2.append("&density=").append(...); sb2.append("&screen_width=").append(...); sb2.append("&screen_height=").append(...); sb2.append("&launch_uuid=").append(Uri.encode(GlobalContext.getLaunchUuid())); sb2.append("&session_uuid=").append(Uri.encode(...)); // ...}从头读到尾,没有 sign / sig / sp,也没有对参数排序后做 MD5、把 body 送去哈希或时间戳参与的校验串。全是明文的设备指纹 + u + access_token。m11055(sb, "&k=", v) 只是「v 非空才追加」的小工具,也不做任何编码或哈希。
再确认这些值本身不是伪装的签名:
public static final String MM_DEVICE_INFO = Build.MANUFACTURER + " " + Build.MODEL; // "Xiaomi 22011211C"static { MM_SYSTEM_INFO = "Android " + Build.VERSION.RELEASE + "/" + Build.VERSION.SDK_INT; } // "Android 13/33"device、vc 就是明文机型和系统版本。为排除签名藏在别处,把 okhttp 拦截器链也过了一遍:com.taou 下实现 Interceptor(混淆成 tt.InterfaceC7189)的只有三个:urlconnection 兼容垫片、只对 update/update_bg 两个端点补参数的 AppendPostParamInterceptor、异常处理拦截器。没有一个补签名头。请求体也没有签名字段。
这个 App 的请求鉴权 = access_token + u,外加一组给风控看的明文设备参数,全程靠 TLS。签名这一层根本不存在。
三、唯一的 native 加密在登录
libnativelib_maimai.so 的 Java 封装 com.taou.maimai.nativelib.NativeLib 只有三个 native 方法:
public final class NativeLib { static { C6951.m16038("nativelib_maimai"); } public final native String getKey(); public final native Object getAndroidId(ContentResolver contentResolver); public final native Object getRunningAppProcesses(ActivityManager activityManager);}getAndroidId / getRunningAppProcesses 是风控绕 hook 的设备读取。getKey() 返回一个字符串 key。追它的调用点,六处里两处叫 encrypt,其中登录的 verifyRegLoginCodeV2 是这样用的:
if (pwdLogin) { String value = this.password.getValue(); req.epassword = C6584.m15599(value, d.f24624a, NativeLib.f7005.getKey());}d 是 alipay SDK 的 com.alipay.sdk.m.n.d,d.f24624a = "RSA"。C6584 是 DigestUtils,它的算法分派里 RSA 分支:
new-instance v3, Ljava/security/spec/X509EncodedKeySpec;invoke-static {key, 2}, Landroid/util/Base64;->decode(...) # key = getKey() 的 base64invoke-static {"RSA"}, Ljava/security/KeyFactory;->getInstance(...)invoke-virtual {...}, Ljava/security/KeyFactory;->generatePublic(...) # X.509 公钥const-string v3, "RSA/ECB/PKCS1Padding"invoke-static {v3}, Ljavax/crypto/Cipher;->getInstance(...)... Cipher.init(ENCRYPT, pubkey) ; doFinal ; Base64.encodeToString(..., NO_WRAP)即 epassword = base64(RSA/ECB/PKCS1(pubkey = getKey(), password))。getKey() 返回的是一段 RSA 公钥(X.509 DER 的 base64),公钥不是机密,抽一次就能用。而且这只在密码登录时用,短信验证码登录那条路径不加密:
// MMVerifyRegLoginCode.Req 的字段:短信码走 code,密码走 epasswordpublic String code; // 短信验证码,明文public String epassword; // 密码,RSA 加密public String reg_way = "1"; public int dev_type = 3, info_type = 2, new_fr = 1;所以短信验证码登录整条链路不碰任何 native 密钥,纯参数拼装就能复现。
四、端点族与响应格式
C7497 里一排 get*Api 把模块前缀固定下来,端点族一目了然:
account/v5/* 登录注册 feed/v5|v6/* 信息流gossip/v3/* 职言 contact/v3|v5/* 人脉user/v3|v4|v5/* 用户 job/v3、talent/v3、tools/v3、imad/*登录两步,都从请求 POJO 抄得到(注意 send 没有 usePost() 覆写,是 GET;verify 覆写了 usePost()=true,是 POST):
GET account/v5/send_reg_login_code_v3 mobile [+网易易盾字段] -> 响应带 tokenPOST account/v5/verify_reg_login_code_v3 mobile, token, code, reg_way=1, dev_type=3, info_type=2, new_fr=1 (+ &need_script=1) -> access_token + uidverify 的 api() 还有个细节:它把 &need_script=1 直接拼在没有 ? 的路径尾巴上,之后通用参数层才补 ?,于是 wire 上是 .../verify_reg_login_code_v3&need_script=1?version=...。看着别扭,但 App 就是这么发的,复现照抄。
响应统一是 BaseResponse(vb.C7496):
public class C7496 { public int code = -1; public int error_code; public String error_msg; public String result; public boolean isSuccessful() { return "ok".equalsIgnoreCase(result) || code == 0; }}code == 0 或 result == "ok" 为成功,错误在 error_code/error_msg。响应体是明文 JSON,不用解密。
五、离设备复现:一条请求就够
有了上面几点,离设备发请求就是把 URL 按 getNewApi + m11054 拼出来。通用参数层里 Uri.encode 用的是 Android 单参重载,保留 A-Za-z0-9 和 _-!.~'()*,其余按 UTF-8 转 %XX(大写),空格 %20。这点要照 Android 实现,不能用 Python 默认 quote:
_URI_SAFE = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-!.~'()*")def uri_encode(s): out = [] for ch in str(s): out.append(ch if ch in _URI_SAFE else "".join("%%%02X" % b for b in ch.encode("utf-8"))) return "".join(out)一条 GET 装配出来(user/v4/settings,会话参数用占位值):
https://open.taou.com/maimai/user/v4/settings?version=6.6.84&ver_code=android_60684&channel=develop&vc=Android%2013%2F33&push_permit=0&net=wifi&open=icon&appid=3&device=Xiaomi%2022011211C&udid=udid1&is_push_open=0&isEmulator=0&rn_version=0.69.0&launched_by_user=1&android_id=aid1&u=12345678&access_token=AT_demo&density=3.0&screen_width=1080&screen_height=2340&launch_uuid=...&session_uuid=...&has_read_contacts_permission=0access_token + u 是唯一从登录设备取一次的账号态输入,其余设备参数从抓到的那条请求 query 里直接抄。因为没有签名,同一份 session 可以重放任意读端点,把请求装配封装成一个 CLI,mm get <path> 打任何 /maimai 下的接口就行。整套复现(端点构建 api.py、通用参数 common.py、登录 RSA crypto.py、装配发送 client.py、登录 login.py)加确定性单测放在 maimai-cli 仓库里,端点 URL / 通用参数装配(断言里显式验证「不含任何签名参数」)/ 登录 RSA 往返都对拍通过。
登录同理:send_reg_login_code_v3 发码(man/machine 是网易易盾,离设备要先解出 yidun_validate 传进去),verify_reg_login_code_v3 拿 access_token + uid 写回 session。之后所有读接口都用这份 session。
六、动态验证:真机上 hook 一下 m11054
静态读代码得出「无签名」,还是拿真机流量坐实一下。Pixel 6 / Android 16,art-runtime-srv 17.16.4(Morphida 反检测那套,进程名被遮,按 PID 或 spawn 走)。frida-17 断代,Java 桥不再默认全局,脚本要 import Java from 'frida-java-bridge' 再 frida-compile 打包。
hook 点选通用参数装配器 df.ൡ.അ(就是 C2665.m11054,运行时类名是混淆的 Unicode)。它把参数拼进传入的 StringBuilder,调完原方法读一下 sb.toString() 就是这条请求的完整 query:
Java.use('df.ൡ')['അ'].overload('java.lang.StringBuilder','android.content.Context') .implementation = function (sb, ctx) { this['അ'](sb, ctx); var url = '' + sb.toString(); send({ url: url, hasSig: /[?&](sign|sig|sp|signature)=/.test(url) }); };spawn 起来抓匿名启动流量(不登录、不发短信),一批 config 请求都从这里过,输出如下:
url=https://open.taou.com/maimai/pbs/check_version?version=6.6.84&ver_code=android_60684 &channel=MyAPP&vc=Android%2016%2F36&push_permit=1&net=wifi&open=icon&appid=3 &device=Google%20Pixel%206&udid=fd82870c-…&is_push_open=1&isEmulator=0&rn_version=0.69.0 &launched_by_user=1&android_id=&webviewUserAgent=…&density=2.625&screen_width=1080 &screen_height=2209&launch_uuid=…&session_uuid=… hasSig=falseurl=https://api.taou.com/sdk/global/config?…&new_device=0&rom_version=16&rom_name=GOOGLE &webviewUserAgent=…&vender=google&install_uuid=…&language=_&package_name=com.taou.maimai&… hasSig=falseurl=https://open.taou.com/maimai/pbs/global_config?… hasSig=falseurl=https://maimai.cn/sdk/global/share_config?… hasSig=false每条 hasSig=false,参数集和顺序跟静态读出来的 m11054 一字不差。sdk/global/config 那条还多带了 new_device/rom_version/rom_name/vender/install_uuid/language/package_name,正是 m11054 里对这个路径的条件分支,动静态对上。
再看 native key 和登录。hook NativeLib.getKey(),走一次密码登录(假号假密码,本地算 epassword 时触发,登录被服务器拒、不给任何人发短信),拿到:
GETKEY MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDbJGJuNnD/h1Cnrjw4rwjQ9JXZssXgpsjPrhPz…IDAQAB (len=216)URL https://open.taou.com/maimai/account/v5/verify_reg_login_code_v3?version=6.6.84&… &session_uuid=…&need_script=1 hasSig=falseMIGf…IDAQAB 是标准的 base64 X.509 SubjectPublicKeyInfo,AQAB 就是指数 65537,128 字节模长,即 1024-bit RSA 公钥,坐实了 getKey() 返回公钥、只用来加密登录密码这一判断。
另外纠了一个静态时看走眼的细节:verify 的 need_script=1 是拼在通用参数之后(verify_reg_login_code_v3?<common>&need_script=1),不是塞在路径尾巴上。原因前面说过:getNewApi 返回时通用参数已经拼好了,api() 再往整串 query 后面追加。离设备复现按抓包这个顺序对齐即可。
七、复盘:和 BOSS 那套的对照
同样是国内头部 App 的接口逆向,这两个的安全模型正好是两端:
- BOSS 把成本压在每请求加签:
sp/sig出自 nativelibyzwg.so,签名输入含 body 的 CRC,响应还要 native 解密。离设备复现的门槛是把 native 算法(内嵌盐 + 混淆 RC4 + LZ4)啃出来。 - 这个职场社交 App 把成本压在账号态:请求本身零签名,鉴权就是
access_token+u走 TLS。离设备复现的门槛几乎为零,抓一次 session 就能重放,真正的门槛前移到「怎么拿到并保住access_token」(登录风控、网易易盾、设备指纹一致性)。
两种取舍各有代价。前者逆向一次成本高、但一劳永逸;后者请求层几乎裸奔,安全性全靠 token 的下发与风控兜底,一旦 token 泄漏,服务端只能靠设备指纹参数和行为风控识别异常,而那些参数在本例里都是明文、可随意伪造。
- 先证否再动手:本来准备找加签栈,
getNewApi→m11054读完确认没有签名,比假设有签名再去逆一个不存在的东西省时间。判据也硬,把通用参数装配器逐行读完、把 okhttp 拦截器链过一遍,两处都没有哈希/排序/body 摘要,才敢下这个结论。 - native 里出现
getKey不等于有请求签名:这里的getKey()是 RSA 公钥,只服务于登录密码加密,跟每请求鉴权无关。追调用点(六处,两处encrypt,落在登录和 JS bridge)比看方法名猜用途可靠。 - 短信验证码登录是最干净的复现入口:不碰 native 密钥,
send+verify两个 HTTP 请求就能离设备签发access_token。
关键结论与端点(v6.6.84 arm64-v8a)
base = https://open.taou.com/maimaiurl = getNewApi(module, version, name) + "?" + m11054(通用参数) [+ "&" + 业务参数]鉴权 = access_token + u(uid) # 明文 query,无 per-request 签名通用参数 = version/ver_code/channel/vc/device/udid/android_id/oaid/u/access_token/density/screen_*/launch_uuid/session_uuid ...(全明文)登录 = GET account/v5/send_reg_login_code_v3 (mobile + 网易易盾) POST account/v5/verify_reg_login_code_v3 (mobile+token+code, reg_way=1,dev_type=3,info_type=2,new_fr=1,need_script=1)密码加密 = epassword = base64(RSA/ECB/PKCS1(pubkey=NativeLib.getKey(), password)) # 公钥,非机密;短信登录不用响应 = 明文 JSON,code==0 或 result=="ok" 为成功,错误在 error_code/error_msgnative = NativeLib.{getKey(RSA公钥), getAndroidId, getRunningAppProcesses(风控设备读取)}Uri.encode 用 Android 单参重载(保留 _-!.~'()*,空格 %20),复现时别用 Python 默认 quote,否则 ~、空格编码不同、参数对不上。