Skip to content

fulian23/LePaoReverse

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 

Repository files navigation

LePaoReverse

CTBU步道乐跑小程序逆向

开头

本来在写脚本代理模拟器,解决位置获取失败的问题,写完后想到之前解包过乐跑小程序的源码,而这次,在请求参数与源码相互对比印证中,我成功找到了参数加密方式与记录上传的流程(当然也少不了AI的帮助)

下面让我一步步讲述这整个过程

解密data与sign

data解密

请求体中有ostypedata

data太常见,直接源码中搜ostype

if ((null === (e = t.header) || void 0 === e ? void 0 : e.flag) && (g = !!t.header.flag), g = t.header.flag, "POST" === t.method) {
    t.header = d(d({}, t.header), {}, {
        "content-type": "application/x-www-form-urlencoded"
    });
    var i = d(d({}, n), t.data),
        a = (0, s.SignMD5)(i, b),
        u = {
            ostype: 5,
            data: (0, s.Encrypt)(JSON.stringify(d(d({}, i), {}, {
                sign: a
            })), v, y)
        };
    t.data = d({}, u)
}

源码中发现类似请求,其中data参数中的(0, s.Encrypt)等价s.Encrypt,之后便是加密的数据跟参数

等价于s.Encrypt(JSON.stringify(t.data+{sign:s.SignMD5(t.data,b)}),o.esk,o.esv)

可以看出,data的数据是加密过后的t.datasign,其中bSignMD5的参数,o.esko.esv是总的加密Encrypt的参数

e.Encrypt = function(t) {
    var e = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : u,
        r = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : f;
    e = n.default.enc.Utf8.parse(e), r = n.default.enc.Utf8.parse(r);
    var i = n.default.AES.encrypt(t, e, a({
        iv: r
    }, c));
    return i.toString()
}

由于传递的参数只有一个t,所以e为默认的ur为默认的f

u = function() {
    for (var t = "".split("").reverse().join(""), e = ["W", "e", "t", "2", "C", "8", "d", "3", "4", "f", "6", "2", "n", "d", "i", "3"], r = 0; r < e["gnel".split("").reverse().join("") + "ht".split("").reverse().join("")]; r++) t += e[r];
    return t
}(),
f = function() {
    for (var t = "".split("").reverse().join(""), e = ["K", "6", "i", "v", "8", "5", "j", "B", "D", "8", "j", "g", "f", "3", "2", "D"], r = 0; r < e["gnel".split("").reverse().join("") + "ht".split("").reverse().join("")]; r++) t += e[r];
    return t
}();

将代码运行一遍便可得到Wet2C8d34f62ndi3K6iv85jBD8jgf32D

回到Encrypt函数,e应该就是keyr就是iv,加密参数有了

而加密方式就在c

 var c = {
    mode: n.default.mode.CBC,
    padding: n.default.pad.Pkcs7
}

data数据解密成功,但是要发送数据还得带上sign

sign解密

来到SignMD5函数

e.SignMD5 = function(t, e) {
    var r = Object.keys(t).sort().reduce((function(e, r) {
        return e + "".concat(r).concat(t[r])
    }), "");
    return l("".concat(r).concat(e))
};

这个函数将t中的键排序,将排序后的键跟值拼接起来,最后将re拼接起来传递给l

function l(t) {
    return n.default.MD5(t).toString()
}

l的操作就是返回传递进来字符串的MD5,所以e就是加密过程中的盐

e.rd = function() {
    return function() {
        for (var t = "".split("").reverse().join(""), e = ["r", "D", "J", "i", "N", "B", "9", "j", "7", "v", "D", "2"], r = 0; r < e["gnel".split("").reverse().join("") + "ht".split("").reverse().join("")]; r++) t += e[r];
        return t
    }()
}

e的值也就是传入SignMD5b的值,运行后得到rDJiNB9j7vD2,写个脚本验证

解密成功,与此前sign一致

至此已经完成了请求的解密与发送

OSS上传

完成了请求的解密后,继续跟踪后续请求

开始乐跑后会向/v3/api.php/WpIndex/getOssSts发送请求,看名字可知是OSS对象存储

会返回OSS对象的凭证与密钥,解密后的data如下:

{
    "SecurityToken":"CAISuAJ1q6Ft5B2yfSjIr5XyL\/bZl5t...qJG+CSAA",
    "AccessKeyId":"STS.NVGdLmzDs53ysrYyDnuJ8mZxV",
    "AccessKeySecret":"EtPzvk9HbaTkJ4ZSxkUr9TPa7RbWnW6WUAunXYjCiAgc",
    "Expiration":"2025-05-11T05:30:13Z",
    "bucket":"lptiyu-data",
    "callback":"eyJjYWxsYmFja1VybC...ybS11cmxlbmNvZGVkIn0="
}

当乐跑结束后会向https://lptiyu-data.oss-cn-hangzhou.aliyuncs.com发送post

这是将刚才跑步的路程数据上传到aliyun服务器

请求的参数有

OSSAccessKeyId: STS.NVGdLmzDs53ysrYyDnuJ8mZxV
signature: MU+NAjvkrszLepVsayH6viLuLxk=
x-oss-security-token: CAISuAJ1q6Ft5B2y...
key: Public/Upload/file/run_record/2025-05-11/853/1746940849871-69.txt
policy: eyJleHBpcmF0aW9uIjoiMjAyNS0wNS0xMVQwN...
file: xx.xx KB

对比之前getOssSts返回的内容

OSSAccessKeyId --> AccessKeyId
x-oss-security-token --> SecurityToken
key猜测为上传的文件路径

来到源码,搜索OSSAccessKeyId定位得到

uploadFile({
    url: m,
    filePath: r,
    name: "file",
    formData: {
        key: A,
        policy: y,
        OSSAccessKeyId: l,
        signature: g,
        "x-oss-security-token": h
    }
});

除去已经获得的OSSAccessKeyIdx-oss-security-token,先分析policy

policy解密

直接提取关键部分

(b = new Date).setHours(b.getHours() + 1)
v = {
    expiration: b.toISOString(),
    conditions: [
        ["content-length-range", 0, 1073741824]
    ]
}
y = a.Base64.encode(JSON.stringify(v))

b是时间对象为当前时间加一个小时,v是一个json对象,有expirationconditions,其中expirationb转为iso格式的字符串,conditions内容固定,最后的y值就是将这个json对象base64加密

{"expiration":"2025-05-11T06:20:49.855Z","conditions":[["content-length-range",0,1073741824]]}

解密后也确实如此,不过需要注意时区为UTC+00:00

key解密

接下来是key的部分

_ = r.split(".").reverse()
w = c(_, 1) 
S = w[0]
A = "".concat(i, "/").concat(Date.now(), "-").concat(Math.floor(150 * Math.random()), ".").concat(S)
//开头字符串
t.next = 2, (0, d.uploadToOSS)(r, "Public/Upload/".concat(o, "/run_record/").concat((0, i.default)().format("YYYY-MM-DD"), "/").concat("".concat(Date.now()).slice(-3)), !0);

其实根据发送的内容Public/Upload/file/run_record/2025-05-11/853/1746940849871-69.txt可知,最后的S是txt,iPublic/Upload/file/run_record/YYYY-MM-DD/后三位(毫秒数)/

Math.random()生成[0,1)之间的数,再用Math.floor()向下取整,生成[0,149]的整数,所以生成字符串应为:

Public/Upload/file/run_record/YYYY-MM-DD/后三位(毫秒数)/当前时间戳-[0,149]随机整数.txt

signature解密

然后是signature部分

d = f.AccessKeySecret
在public中已经分析出y的值
g = p(d, y)
p = function(t, e) {
    return o.default.enc.Base64.stringify(o.default.HmacSHA1(e, t))
}

可以看出,signature的值是将AccessKeySecretpublic的值进行HMAC-SHA1后再base64编码

与实际值一致

乐跑数据绑定

通过以上步骤,乐跑已经完成了对数据的记录与上传,接下来的操作就是对本次的跑步数据与云端的绑定

小程序中是通过v3/api.php/Run/stopRunV278接口上传

请求体中的data字段解密后如下:

{"uid":1xxxxxx,"token":"F1E404A93D740F31E3B3A9ADDC8749AD","school_id":201,"term_id":1,"course_id":0,"class_id":0,"student_num":"2xxxxxxxxx","card_id":"2xxxxxxxxx","timestamp":1746943887,"version":1,"nonce":"870449","ostype":5,"game_id":2,"start_time":1746942771,"end_time":1746943886,"distance":2.42,"record_img":"","log_data":"[{\"latitude\":29.502963053385418,\"longitude\":106.56841986762153,\"distance\":0.33,\"point_id\":\"4\",\"time\":1746942898,\"longtitude\":106.56841986762153},{\"latitude\":29.50262424045139,\"longitude\":106.5675439453125,\"distance\":0.93,\"point_id\":\"5\",\"time\":1746943141,\"longtitude\":106.5675439453125},{\"latitude\":29.502111273871527,\"longitude\":106.56868679470486,\"distance\":1.72,\"point_id\":\"11\",\"time\":1746943523,\"longtitude\":106.56868679470486}]","file_img":"","is_running_area_valid":1,"mobileDeviceId":1,"mobileModel":"SM-E5260","mobileOsVersion":1,"step_info":"{\"interval\":60,\"list\":[]}","step_num":1,"used_time":1092,"record_file":"run_record/2025-05-11/993/1746943887003-83.txt","sign":"32c2ef7b016840fa0144e743ed3203b5"}

响应中的data字段解密后如下:

{"record_id":"259xxx","start_time":1746942771,"uid":1xxxxxx,"game_id":2,"time":1092,"distance":2.4199999999999999,"log_num":3,"exp":0,"points":0,"extra_money":0,"record_img":"","prize_list":[],"record_status":0,"record_failed_reason":"当天关联成绩次数已达到上限","calDesc":"消耗了2片面包","calNum":"2","calUrl":"https:\/\/siteproxy.ruqli.workers.dev:443\/https\/data.lptiyu.com\/Public\/Upload\/pic\/cal_icon\/bread.png","point_list":[{"point_id":"4","longtitude":106.56841986761999,"latitude":29.502963053384999,"point_index":2},{"point_id":"5","longtitude":106.56754394531001,"latitude":29.502624240450999,"point_index":3},{"point_id":"11","longtitude":106.5686867947,"latitude":29.502111273872,"point_index":4}],"pass_tit":"重庆工商大学(兰花湖校区)","pass_intro":"","pass_tips":"","complain_check_status":2}

请求体中发现record_file,也就是上传到云端的跑步路径,就是在这时候完成了用户本次跑步与云端路径的关联

那既然是直接关联文件,可以重复关联之前的文件吗?

事实上乐跑并没有限制文件的一对一关联,手动构造请求,重复关联同一个文件也是可以的(这也算是乐跑的一点小疏忽)

经过测试,timestamp必须为当前时间,end_time必须为要在当前时间之前

end_time时间设置成昨天的呢?很遗憾,虽然成绩能上传,但会被当成无效成绩,因为只能上传当天的成绩

总结

这次逆向分析,得到了乐跑绑定记录的方式:

/v3/api.php/WpIndex/getOssSts获得OSS的认证信息,再通过https://lptiyu-data.oss-cn-hangzhou.aliyuncs.com上传路径数据,最后由v3/api.php/Run/stopRunV278绑定云端数据到本次的记录

v3/api.php/Run/stopRunV278上传时只需要保证存在路径文件,也就是说不需要每次都上传路径文件,直接绑定之前的文件也是可以的(不过应该会增加被发现的概率)

至此,可以手动发包瞬间完成乐跑了✌️

About

CTBU步道乐跑小程序逆向

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages