前言
由于暑期实习期间每天乘坐地铁,实习工资少的可怜,发现杭工e家可以薅波羊毛,但苦于放券减量了,手动抢完全抢不到,遂尝试逆向,自动化薅羊毛,哈哈哈🤣
请求体解密
查壳
1 | D:\IDM\Programs>ApkCheckPack_windows_amd64.exe -f 杭工e家_3.1.4.apk |
积分明细
请求包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21POST /unionApp/interf/front/U/U044
Host: app.hzgh.org.cn
Content-Type: text/plain;charset=utf-8
Content-Length: 654
Connection: Keep-Alive
Accept-Encoding: gzip, deflate, br
Cookie: JSESSIONID=226A6497327FB39CE27D4E214D50526D
User-Agent: okhttp/3.4.2
{
"channel":"02",
"timestamp":"1754281415163",
"app_ver_no":"3.1.4",
"dec_key":"gS3JdOc+0KEqXmIkDk8L9SN5BLIaDN43OFGPiCwkz3READ5fPcLTzyTenVOmm64AONPefGgA\/ZPA4yC6Jfzm6DHnzEMrQ\/6a+CZubVXEo3aLOEQ+mVClebyIWbNJ\/GmaXu+wjj4iQMEHdOFF9m5BRhkRVgbR7g4LwU6+ayb3fYM=",
"login_name":"y4UcBzOLq2Txj1gvjRga3qEDKo+WzCI9R8ep4PFNinA=",
"page_num":"1",
"ses_id":"df006b2bc4a84c149ce3814696161c14",
"page_size":"20",
"key":"channel,timestamp,app_ver_no,dec_key,login_name,page_num,ses_id,page_size",
"sign":"AjZY+Q8aGfZDAnPiRUUqAb+z08Df5+Rd5OMftslW8LuCmGGJUbW0BgAkKKqpIl4HMlMccZeGWlMK0lZg+Wu5vSwba6l\/BWazLctzOl5eIcdKOW4zmvQqTlmHB8l9vkATGq1zFO5nilWlwziICN2Xmra8O48AMikqPvx6e9N+P20="
}加密参数
1
2
3"dec_key":"gS3JdOc+0KEqXmIkDk8L9SN5BLIaDN43OFGPiCwkz3READ5fPcLTzyTenVOmm64AONPefGgA\/ZPA4yC6Jfzm6DHnzEMrQ\/6a+CZubVXEo3aLOEQ+mVClebyIWbNJ\/GmaXu+wjj4iQMEHdOFF9m5BRhkRVgbR7g4LwU6+ayb3fYM=",
"login_name":"y4UcBzOLq2Txj1gvjRga3qEDKo+WzCI9R8ep4PFNinA=",
"sign":"AjZY+Q8aGfZDAnPiRUUqAb+z08Df5+Rd5OMftslW8LuCmGGJUbW0BgAkKKqpIl4HMlMccZeGWlMK0lZg+Wu5vSwba6l\/BWazLctzOl5eIcdKOW4zmvQqTlmHB8l9vkATGq1zFO5nilWlwziICN2Xmra8O48AMikqPvx6e9N+P20="
使用jadx-gui进行逆行apk文件
搜索相关关键字,得到加密逻辑
1 |
|
其中channel和app_ver_no基本上是硬编码的
channel:02app_ver_no:3.1.4timestamp:当前系统时间戳dec_key的逻辑时通过k.c(24)来随机生成24字符全大写的字符串作为strC再判断是否访问
https://zhgh.hzgh.org/来选择采用lf.c0.c加密还是lf.c0.a加密,两者加密只有公钥的不同k.c函数实现
1 | public static final String f38860a = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; |
lf.c0类中的相关加密逻辑采用
RSA/ECB/PKCS1Padding算法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98public class c0 {
/* renamed from: a, reason: collision with root package name */
public static final String f38811a = "RSA";
/* renamed from: b, reason: collision with root package name */
public static final String f38812b = "RSA/ECB/PKCS1Padding";
/* renamed from: c, reason: collision with root package name */
public static final String f38813c = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCVXsxrrMcxNwFNYt0wMTdqc5WMa4gr7nMbWbcQCpJ2XNBMTQetknYNzCr8MMRdHBKFKjdCJE40u6UDBXQx13z7OSKyvQBwtLj5n8eIQXRtpMIjvqfR1xRuNBi5147ZXJDbKxWGRm0kjLN5UuqnDe6zu8v6MKU7KNDzHUrWqsj2LwIDAQAB";
/* renamed from: d, reason: collision with root package name */
public static final String f38814d = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC7yWoQaojBBqKI2H0j4e8ZeX/n1yip6hxrxSVth5F5n1JJ/B3liPMdz6K1chNLFTAcbI7hTL9KkphP9yQ+bPYD68Ajrt/DFrW679Zi1CoeetHVrM4sF68lYarGXwnSlKloaPWnI4Ch9cSqIvIOInlpeJqYPlJ8ZJvGCmbQoM6bewIDAQAB";
public static String a(String str) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IOException {
byte[] bArr;
try {
Cipher cipher = Cipher.getInstance(f38812b);
cipher.init(1, e(f38814d));
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(str.getBytes());
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
byte[] bArr2 = new byte[117];
while (true) {
int i10 = byteArrayInputStream.read(bArr2);
if (i10 == -1) {
return c.b(byteArrayOutputStream.toByteArray());
}
if (117 == i10) {
bArr = bArr2;
} else {
bArr = new byte[i10];
for (int i11 = 0; i11 < i10; i11++) {
bArr[i11] = bArr2[i11];
}
}
byteArrayOutputStream.write(cipher.doFinal(bArr));
}
} catch (InvalidKeyException e10) {
e10.printStackTrace();
return null;
} catch (BadPaddingException e11) {
e11.printStackTrace();
return null;
} catch (IllegalBlockSizeException e12) {
e12.printStackTrace();
return null;
} catch (Exception e13) {
e13.printStackTrace();
return null;
}
}
public static String c(String str) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, IOException {
byte[] bArr;
try {
Cipher cipher = Cipher.getInstance(f38812b);
cipher.init(1, e(f38813c));
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(str.getBytes());
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
byte[] bArr2 = new byte[117];
while (true) {
int i10 = byteArrayInputStream.read(bArr2);
if (i10 == -1) {
return c.b(byteArrayOutputStream.toByteArray());
}
if (117 == i10) {
bArr = bArr2;
} else {
bArr = new byte[i10];
for (int i11 = 0; i11 < i10; i11++) {
bArr[i11] = bArr2[i11];
}
}
byteArrayOutputStream.write(cipher.doFinal(bArr));
}
} catch (InvalidKeyException e10) {
e10.printStackTrace();
return null;
} catch (BadPaddingException e11) {
e11.printStackTrace();
return null;
} catch (IllegalBlockSizeException e12) {
e12.printStackTrace();
return null;
} catch (Exception e13) {
e13.printStackTrace();
return null;
}
}
public static String b(String str) throws Exception {
byte[] bArrA = c.a(str);
Cipher cipher = Cipher.getInstance(f38812b);
cipher.init(2, d(com.zjte.hanggongefamily.base.a.f27609p));
return new String(cipher.doFinal(bArrA));
}
--------------------------------
public static String f27609p = "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAIOBMtf2AIYQlrNy/lVPHx4R/LKI+Vtk3bKmzID8vdVnh/4WA3lczqfejM10Xfy3sNe4l5EeQTvnDgUHbIFK8FyJRpvypAmS9oyW6uwGTjZEu5Y6hsSxiGAOG5ZOlH8vOSfuaAkZ+iUlqifPE3ZOmHkqGzmukg4wCRaPLx5ioq8zAgMBAAECgYAgLOVmx677HmXxBCrMbq57agU9HZx9SyGfS4Zv7Ob5pvo0Jei1sgpyMlabEmTIp50iOu0CubdWU8MvYdCfldlXQLW7cjk8N1NyGQLFd2fJ03a7gGWnwwEdPoNTpSHnB+mDL9l7MVjion5fLojzq9Pz1gMKL01I2TfZBDL4m6EbgQJBAMfgrMKtj7f40GA3qp/y/9/eBCAu8PbtFmtATLMQRf4tGhjvn349x1b6FZj8RiaRBSrq0Owjrdo5TUxgfS7dz3MCQQCobdWk2SQhRlqEHfFEro/8ab6gn3GhBDzzKvNjhKr2MO6JWqs+Vr+/P9uYpA+G+rv74uVIGWhjuNtI5+/69DFBAkAJOQS/tuJ6yrBSwD7PQpcr7UKjeYcE3cu7ByyC1q1kHRCnNedWG+Omz8NPW9Sg0vA6GrupKbxL5Xj7nTgpgXKhAkBIVlvioAvfaqrngUClAd//RZ9EtxYDVKGkwnaj8E/Iyr04KsPPU0ypJBD5XsT4cOmZxho5PAhUhAlSJ6MvAf/BAkA64ieVhtQA1KV0pSSEJMnbPlZe+yBYGTWLMaG2zL0kKEhIs2fIHbVhLFQ8TkO5oH+mhxuuXI5+nVU2G0dqUl6D";回到原始加密逻辑
遍历
map中的键,如果键名在f5734b列表中,则将对应的值使用k.b(value.getBytes(), strC.getBytes())进行 DESede/ECB/PKCS5Padding 加密,密钥为strC,然后 Base64 编码f5734b列表
1 | public String[] f5734b = {"login_name", "login_auth_code", "auth_code", "pwd", "password", "newpwd", "amt", "tr_amt", "sms_code", "total_amount", "account_no", "mob_data", "order_amt", "before_amt", "txn_amt", "tel", "mobile", "new_mobile", "code", "cert_no", "card_no", "reserve_mobile", "reply_tel", "card_bal", "bank_card_no", "car_no", SocializeConstants.TENCENT_UID, "invite_code", "auth_code", "imgAuthCode", "imgUniCode"}; |
k.b方法
1 | public static final String f38864e = "DESede"; |
后面将所有原始的(非加密的)map 字段、RSA 加密后的 dec_key、以及 DESede 加密后的敏感字段放入一个 JSONObject
1 | public static String f27603m = "qwerqaz.-*"; |
对于key和sign的值会从jSONObjectU提取出来
至于jSONObjectU中的key和sign的生成是判断url是否以https://zhgh.hzgh.org/开头的
如果是则执行bf.d.u方法,如果不是则执行bf.d.t
bf.d类的相关逻辑1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63public static JSONObject u(JSONObject jSONObject, String str) throws JSONException {
JSONObject jSONObject2 = new JSONObject();
Iterator<String> itKeys = jSONObject.keys();
String strSubstring = "";
String str2 = "";
while (itKeys.hasNext()) {
String string = itKeys.next().toString();
if (!(jSONObject.get(string) instanceof JSONArray)) {
if (itKeys.hasNext()) {
strSubstring = strSubstring + string + ",";
} else {
strSubstring = strSubstring + string;
}
str2 = str2 + jSONObject.get(string).toString();
} else if (!itKeys.hasNext() && strSubstring.endsWith(",")) {
strSubstring = strSubstring.substring(0, strSubstring.length() - 1);
}
}
jSONObject2.put("key", strSubstring);
try {
jSONObject2.put("sign", b(o(str2 + str).toUpperCase()).toUpperCase());
} catch (Exception e10) {
e10.printStackTrace();
}
return jSONObject2;
}
------------------------------
public static JSONObject t(String str, String str2) throws JSONException {
JSONObject jSONObject = new JSONObject(str);
JSONObject jSONObject2 = new JSONObject();
Iterator<String> itKeys = jSONObject.keys();
String strSubstring = "";
String str3 = "";
while (itKeys.hasNext()) {
String string = itKeys.next().toString();
if (!TextUtils.equals("content", string) && !TextUtils.equals("link_url", string) && !TextUtils.equals("url", string) && !TextUtils.equals("pic_cont", string) && !TextUtils.equals("advice_img1", string) && !TextUtils.equals("advice_img2", string) && !TextUtils.equals("advice_img3", string) && !TextUtils.equals("photo_one", string) && !TextUtils.equals("photo_two", string) && !TextUtils.equals("photo_three", string) && !TextUtils.equals("book_img", string) && !TextUtils.equals("pimge", string)) {
if (!(jSONObject.get(string) instanceof JSONArray)) {
if (itKeys.hasNext()) {
strSubstring = strSubstring + string + ",";
} else {
strSubstring = strSubstring + string;
}
str3 = str3 + jSONObject.get(string).toString();
} else if (!itKeys.hasNext() && strSubstring.endsWith(",")) {
strSubstring = strSubstring.substring(0, strSubstring.length() - 1);
}
}
}
if (strSubstring.endsWith(",")) {
try {
strSubstring = strSubstring.substring(0, strSubstring.length() - 1);
} catch (Exception unused) {
}
}
jSONObject2.put("key", strSubstring);
try {
jSONObject2.put("sign", u.e(str3 + "zSw3MLRV7VuwT!*G", str2));
} catch (Exception e10) {
e10.printStackTrace();
}
return jSONObject2;
}其实
key也相当于是硬编码了,它是参与签名的所有字段名称"key":"channel,timestamp,app_ver_no,dec_key,login_name,page_num,ses_id,page_size"
如果走的是bf.d.u方法的话,那么sign的生成即b(o(str2 + str)
str2 是 jSONObject 中所有非 JSONArray 类型的键的值的直接拼接
str则是qwerqaz.-*,先进行o方法再b方法,最后全部转换为大写
bf.d.o()一眼MD5加密然后小写的十六进制字符串
bf.d.b()是一个 SHA-1 哈希函数转换为小写的十六进制字符串
即最后的生成逻辑如下
sign = SHA1(MD5(str2 + "qwerqaz.-*").toUpperCase()).toUpperCase()
1 |
|
另外走bf.d.t的话,sign=u.e(str3 + "zSw3MLRV7VuwT!*G", str2))
str2即是f27605n里面的私钥内容
str3 是 jSONObject 中所有参与签名字段的值的直接拼接
u.e方法是RSA 签名函数,签名算法为SHA256WithRSA
至此加密流程如下
生成会话密钥 (
strC):- 客户端通过
k.c(24)生成一个 24 字符的全大写随机字符串。 - 字符集:
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"。 - 用作 DESede (Triple DES) 对称加密的密钥。
- 客户端通过
加密会话密钥 (
dec_key):- 根据请求基础 URL (
this.f5735c.startsWith("https://zhgh.hzgh.org/")) 判断。- 如果匹配:
lf.c0.c(strC),使用硬编码的 RSA 公钥f38813c进行RSA/ECB/PKCS1Padding加密。 - 如果不匹配:
lf.c0.a(strC),使用硬编码的 RSA 公钥f38814d进行RSA/ECB/PKCS1Padding加密。
- 如果匹配:
- 加密结果再通过
c.b()进行 Base64 编码。
- 根据请求基础 URL (
敏感数据加密:
- 请求
map中包含在f5734b列表(如login_name,password,sms_code,bank_card_no等)的字段值,会使用strC作为密钥进行 DESede/ECB/PKCS5Padding 对称加密。 - 加密结果再通过
Base64.encodeToString(..., 2)进行 Base64 编码。
- 请求
请求 JSON 构建:
- 将
channel,app_ver_no,timestamp,dec_key(RSA加密后的strC),以及其他原始map字段和 DESede 加密后的敏感字段,组装成一个JSONObject。
- 将
签名生成 (
key和sign):条件分支:
同样根据
this.f5735c.startsWith("https://zhgh.hzgh.org/")判断。- 如果匹配 (使用
bf.d.u):key字段:逗号分隔的参与签名的所有键名(排除JSONArray)。- 签名原始数据:所有参与签名键的值的拼接 (
str2) + 硬编码字符串f27603m("qwerqaz.-*")。 sign字段:SHA1(MD5((str2 + "qwerqaz.-*").toUpperCase()).toUpperCase()),结果再toUpperCase()。这是一个双重哈希,不是私钥签名。
- 如果不匹配 (使用
bf.d.t):key字段:逗号分隔的参与签名的所有键名(排除特定图片/内容字段和JSONArray)。- 签名原始数据:所有参与签名键的值的拼接 (
str3) + 硬编码字符串"zSw3MLRV7VuwT!*G"。 sign字段:使用 RSA 私钥 (f27605n,PKCS#8 格式的 Base64 字符串) 对签名原始数据进行 SHA256withRSA 签名,然后通过c.b()进行 Base64 编码。
- 如果匹配 (使用
返回体解密
解密逻辑
1 | public void a(dj.e eVar, d0 d0Var) throws JSONException, IOException { |
- 从 JSON 对象中提取
data2字段的值,得到string(即data2的 Base64 字符串)。 - 将
data2字符串分割成两部分:- 密文部分:
string.substring(172)(从索引 172 到字符串末尾)。 - 密钥/IV 材料部分:
string.substring(0, 172)(从索引 0 到索引 171)。
- 密文部分:
其实也和加密流程大差不差,但少了很多步骤,在密钥部分即data2字符串中截取前172个字符,使用c0.b方法作为RSA的密文
f27609p私钥文件进行RSA 解密(PKCS1_v1_5 填充),得到str2

1 | public static final String f38812b = "RSA/ECB/PKCS1Padding"; |
使用str2来生成DESede 密钥和 IV,然后解密 data2 的剩余部分str,得到最终的明文
1 | public static final String f38864e = "DESede"; |
根据HTt0Hzsu+str2作为DESede的密钥,拼接后取24字节
再将str2取前8字节来生成IV,最后对 Base64 解码后的字节串进行 DESede 解密(CBC 模式,PKCS5Padding/PKCS7Padding)

拷打Gemini给出解密脚本,可以再优化一下,这样更鲁棒🤣
1 | import base64 |

实际测试
青龙面板
同时为了排除本地网络环境过差,可以放到阿里云上,自动抢
配置一下钉钉bot,实现执行完任务后自动推送

为了能够更快速的发请求包,遂采用JavaScript脚本执行
让Gemini写了一下,注意主程序依赖钉钉配置文件、加密脚本以及工作流的配置问,文件名需要一一对应
- AutoTicket.js
1 |
|
- encyrpt_rsa.js
1 |
|
- decrypt.js
1 | // decrypt.js |
- dingtalk_config.js
1 | /** |
- workflow_config.js
1 | /** |
另外自动签到的脚本也放上来吧,说实话AI写的代码是又臭又长,简直一坨
- workflow_sigin.js
1 | #!/usr/bin/env node |
贴一下实际运行后的效果
青龙面板可以配置定时抢,每天的上午场和下午场,其实上午场就够抢两张的了,自己够用就行😅

- 签到推送

- 留言推送

- 兑换优惠券推送

后记
当时在8月初的时候有了逆向杭工e家的想法,研究了两天还没搞定,卡在login_name那块,这是个二次加密的值,我以为还需要再次解密,遂去学习了一下frida,又是配环境又是hook生成函数,搁置了
后半个月出差加上公司各种事情,一直拖到九月中旬才开始,直到前几天我发现Github上有现成的仓库
发现login_name就是二次加密的base64值,直接再次加密签名即可😅
因为有现成的仓库,我就不重复造轮子新开一个仓库,利用大模型写了个JS版本的,性能开销小点,速度会快很多
已经PR了,等待作者合并中。。。😊