优学院 APP 签到分析
时间有限,仅研究了定位签到,二维码签到后面再说。
抓包
使用 Reqable 进行抓包。
请求头
Authorization:一串 32 位由大写字母和数字构成的 token;User-Agent:UA,默认值为App ulearning Android。
登录
POST https://apps.ulearning.cn/login/v2
请求头不需要 Authorization,但是需要带上 uversion: 2(只有登录需要这个,很神秘)。
请求内容形如:
{
"y": "cRludrswiDTfezufQ37onz9rdG/2mSco6oRS99GxMUsikwslTjP5Bfpp6zRQmNEwHOOspSEbk3drLVDaOBdU+8kujAtr9LFGWUd8KKTAciQK0QR3BEuzEbj586hMtHTiXpJBQQjj+lxHD5q0cGEKrgj9vvXwYLLYGlilcAlwTK0hs935BikHFNb0FxHrNexAeh4L0BBPedjzXy2yZ0YHHXfEyxpwlZz/GqOcYkbQvX84e2FxYOFZPmzuuYlA6gCIwjFEQUhBQ1wsTUQ0skDbX/PnwCkZJlgHBQc2h8Dnpce60Jac="
}
响应内容形如:
{
"result": "F1fBEWkR8xwIIu+X29rmTQbmcnDE5lcfJ/zhegl3ShZUfLxDAkxPkakYWsyz4K3E7fpnr+0M5XeLreqwL\nqeLLufCYUOaaZj9S3I/2djn6TFRiOvdZrIR/E3VtFyoXPWgubv6dBInO5OpMUhoZfK2qN0tjq1oj\nu8I9QvlShWtONt7G/J3PC4X6GMKQemXyi8lxs3dmmKFqLodvHrSKLqFekwn82+vtVsTYpdwSgjU8\nvCE5EjWz5cHim0Iy4AtlDgX2cONzKxqq+J5b2YeCmdpf4kWTx6aHFyztjx6U5WjgzdbN7pAocGLC\ndukaTIEd8euPIB25BaBxRojbHGzEVM4EXlEwm0gjgHkLYmQZzPhgvT/PtNjnzXRH481Ceiq7vHLY\nCtt/ODMyl60KCoMM+6p26JbFZ0sDr0FZT2oD3Fqfw9V1tOPavdBHTPHrWVPnCJLRDhkw/2Kd/hnk\nDoG5gO9tyEg4Xy6Efnfj0dVnSWJL4Y25/Nc=",
"code": 200,
"message": "success"
}
乱码内容后文分析。
课程列表
GET https://courseapi.ulearning.cn/courses/students?publishStatus=1&pn=1&ps=20&type=1
获取当前登录用户的课程列表。
响应内容形如:
{
"pn": 1,
"ps": 20,
"total": 2,
"courseList": [
{
"id": 153876,
"name": "课程名称",
"cover": "https://obscloud.ulearning.cn/resources/web/17566091081699114.png",
"courseCode": "33763821",
"type": 1,
"classId": 900310,
"className": "班级名称",
"status": 1,
"teacherName": "老师名称",
"learnProgress": 0,
"totalDuration": 0,
"publishStatus": 1,
"creatorOrgId": 3201,
"creatorOrgName": "组织名称",
"classUserId": 40870768
}
]
}
courseList中的id为该课程 ID,classId为课堂 ID。
课程本周活动
POST https://courseapi.ulearning.cn/appHomeActivity/v4/<courseID>
courseID 为课程 ID。用于获取某一课程本周的活动,签到就在其中。
响应内容形如:
{
"allCount": 2,
"courseActivityCount": 0,
"courseActivityDTOList": [],
"miniCourseActivityCount": 0,
"miniCourseActivityDTOList": [],
"otherActivityCount": 2,
"otherActivityDTOList": [
{
"relationType": 1,
"relationId": 853851,
"title": "2025-09-15 15:48 点名",
"startDate": 1757922536000,
"status": 2,
"publishStatus": 0,
"scoreType": 0,
"classes": [
"默认班级"
],
"personStatus": 0,
"waitMarkingCount": 0
},
{
"relationType": 1,
"relationId": 853823,
"title": "2025-09-15 15:15 点名",
"startDate": 1757920558000,
"status": 2,
"publishStatus": 0,
"scoreType": 0,
"classes": [
"默认班级"
],
"personStatus": 1,
"waitMarkingCount": 0
}
]
}
relationId为活动 ID,relationType为 1 表示该活动为签到;status代表当前签到状态,2 表示签到正在进行,3 表示签到已结束;personStatus表示当前用户签到状态,1 表示已签到,0 表示未签到。
签到信息
GET https://apps.ulearning.cn/newAttendance/getAttendanceForStu/<attendanceID>/<userID>
attendanceID 为签到 ID,userID 为用户 ID。
用于获取某一签到的具体信息。
响应内容形如:
{
"currentDate": 1762592053024,
"classez": [
{
"classID": 900310,
"className": "班级名称"
}
],
"userName": "老师名称",
"type": 0,
"title": "2025-11-05 14:00 点名",
"userID": 8092300,
"attendanceID": 880176,
"location": "114.414056,30.514763",
"state": "0",
"time": "0",
"createDate": 1762322435000,
"status": 1
}
type为签到类型,0 表示位置签到,1 表示二维码签到;location疑似是教师开启签到所在的位置。
签到
POST https://apps.ulearning.cn/newAttendance/signByStu
请求内容形如:
{
"attendanceID": 881582,
"classID": 915687,
"userID": 13895667,
"location": "114.514,31.415",
"address": "位置名称",
"enterWay": 1,
"attendanceCode": ""
}
attendanceID为签到 ID,classID为班级 ID;location为经纬度,address为地址名称;userID为用户 ID。
值得注意的是,似乎后端并没有判断当前用户是否与 userID 匹配,因此可以直接通过修改 userID 帮别的同学签到。
响应内容形如:
{ // 成功
"msg": "签到成功",
"newStatus": 1,
"status": 200
}
{ // 失败
"message": "你距离老师太远啦!如果你在教室里,可以尝试切换不同的网络后重新签到。如果多次尝试后仍然定位不准确,请告知老师,由老师手动更改你的签到状态",
"status": 208
}
乱码是什么?
肉眼看肯定是看不出来的,逆向分析 apk 安装包吧。
最新的安装包被混淆过了,目前能找到的最晚的没被混淆的是 v1.9.47。用 GDA 反编译后阅读代码。
cn.ulearning.yxy.activity.account.LoginActivity.login 函数片段:
AccountApi.login(this.binding.loginView.getUserName(), EncryptUtils.md5Encrypt(this.binding.loginView.getPassword()), new ApiUtils.ApiCallback() { // from class: cn.ulearning.yxy.activity.account.LoginActivity.1
@Override // cn.ulearning.yxy.api.utils.ApiUtils.ApiCallback
public void success(int i, String str, String str2) {
JSONObject jSONObject;
Log.d("zb2:", "登录成功:" + str2);
LoginActivity.this.hideProgressDialog();
if (i == 200) {
try {
jSONObject = new JSONObject(str2);
} catch (JSONException e) {
e.printStackTrace();
jSONObject = null;
}
String rStr = StringUtil.getRStr(jSONObject.optString("result"));
Log.d("zb2:", "登录成功:" + rStr);
AccountApi.parseAccountInfo(rStr, LoginActivity.this.mAccount, false);
UmengRecordUtil.startRecord(ApplicationEvents.UM_EVENT_ACTION_LOGIN_SUCCESS);
LoginActivity loginActivity = LoginActivity.this;
UserInfoSave.saveUser(loginActivity, loginActivity.binding.loginView.getUserName());
LoginActivity.this.onLoginSucceed();
Log.d("zb", "登录流程完成");
}
}
@Override // cn.ulearning.yxy.api.utils.ApiUtils.ApiCallback
public void fail(String str) {
MainApplication.getInstance().schemeUrl = "";
LoginActivity.this.hideProgressDialog();
LoginActivity.this.showErrorToast(str);
}
});
cn.ulearning.yxy.api.AccountApi.login:
public static void login(String str, String str2, ApiUtils.ApiCallback apiCallback) throws NullPointerException {
Account account = Session.session().getAccount();
account.setLoginName(str.trim());
account.setPassword(str2);
String str3 = String.format("%s/login/v2", URLConstants.HOST);
HashMap map = new HashMap();
map.put("loginName", str);
map.put("password", str2);
map.put("ut", StringUtil.getLoginString(str, str2));
map.put("device", Build.MODEL);
map.put("appVersion", Integer.valueOf(AppUtils.version(MainApplication.getInstance())));
map.put("webEnv", Integer.valueOf(cn.ulearning.core.utils.AppUtils.getNetStatus()));
map.put("registrationId", MainApplication.getInstance().registrationId);
Log.d("zb", "当前正在登录使用的registrationId:" + MainApplication.getInstance().registrationId);
HashMap map2 = new HashMap();
map2.put("y", StringUtil.getCStr(new Gson().toJson(map)));
Log.d("zb", new Gson().toJson(map2));
ApiUtils.getInstance().post(str3, new Gson().toJson(map2), apiCallback);
}
cn.ulearning.yxy.util.StringUtil:
public class StringUtil {
private static final String CIPHER = "AES/ECB/PKCS5Padding";
private static String a1() {
return Constant.DEFAULT_PWD;
// public static final String DEFAULT_PWD = "ulearning";
}
private static String a2() {
return "2021" + a3();
}
private static String a3() {
return "331";
}
public static String getLoginString(String str, String str2) {
try {
String strValueOf = String.valueOf(System.currentTimeMillis());
String strMd5Encrypt = EncryptUtils.md5Encrypt(str);
String strMd5Encrypt2 = EncryptUtils.md5Encrypt(strValueOf);
String strMd5Encrypt3 = EncryptUtils.md5Encrypt("**Ulearning__Login##by$$project&&team@@");
String strMd5Encrypt4 = EncryptUtils.md5Encrypt(strMd5Encrypt + str2.toLowerCase() + strMd5Encrypt2 + strMd5Encrypt3);
String strMd5Encrypt5 = EncryptUtils.md5Encrypt(strValueOf);
return strMd5Encrypt5.substring(0, 18) + strMd5Encrypt4 + strMd5Encrypt5.substring(18);
} catch (Exception e) {
e.printStackTrace();
return "";
}
}
public static byte[] encrypt(String str, String str2) throws Exception {
SecretKeySpec secretKeySpec = new SecretKeySpec(str2.getBytes(), "AES");
Cipher cipher = Cipher.getInstance(CIPHER);
cipher.init(1, secretKeySpec);
return cipher.doFinal(str.getBytes("UTF-8"));
}
public static byte[] decrypt(byte[] bArr, String str) throws Exception {
SecretKeySpec secretKeySpec = new SecretKeySpec(str.getBytes(), "AES");
Cipher cipher = Cipher.getInstance(CIPHER);
cipher.init(2, secretKeySpec);
return cipher.doFinal(bArr);
}
public static String getCStr(String str) {
try {
return getCString(Base64.encodeToString(encrypt(str, a1() + a2()), 0));
} catch (Exception e) {
e.printStackTrace();
return "";
}
}
private static String getCString(String str) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < str.length(); i++) {
if (sb.length() < 10) {
sb.append((char) (((int) (Math.random() * 26.0d)) + 97));
}
sb.append(str.charAt(i));
}
return sb.toString();
}
public static String getRStr(String str) {
try {
return new String(decrypt(Base64.decode(getRString(str), 0), a1() + a2()), "utf-8");
} catch (Exception e) {
e.printStackTrace();
return "";
}
}
private static String getRString(String str) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < str.length(); i++) {
if (i >= 10 || (i + 1) % 2 == 0) {
sb.append(str.charAt(i));
}
}
return sb.toString();
}
}
分析
首先将密码进行 md5 加密,然后生成如下的一个字典:
hash_map = {
'loginName': username,
'password': password_hashed,
'ut': StringUtils.getLoginString(username, password_hashed),
'device': 'android', # 设备类型
'appVersion': '20250903', # 版本号,可随便填,例如 0
'webEnv': '1', # 网络环境,可填 1,2,3,4 中任意值
'registrationId': '' # 不知道是啥,但是可以随便填
}
然后将这个字典经过 StringUtils.getCStr(),即可得到登录 api 中请求内容的那一串乱码了。
而对于响应内容中的乱码,通过调用 StringUtils.getRStr() 即可解密得到一个包含个人信息的 JSON:
{
"role": 9,
"org": {
"orgName": "组织名称",
"orgLogo": "",
"orgID": 3201
},
"loginname": "<用户名>",
"sex": "1",
"userID": <用户 ID>,
"token": "<32 位的由大写字母和数字构成的字符串>",
"studentID": "<学号>",
"password": "<md5 加密后的密码>",
"registerMode": 3,
"name": "<姓名>",
"freeUser": 0,
"tel": "<电话号>",
"email": "",
"timestamp": 1762614463582 // 时间戳
}
发现我们需要的 token 就在其中,问题解决。
自动签到脚本实现
将前文需要用到的核心代码从 java 翻译成你需要的语言,然后根据抓包得到的接口进行相应的网络请求交互即可。
https://github.com/Aestas16/ulearning-helper