文章

优学院 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

许可协议:  CC BY 4.0