由于众所周知的原因,目前我校学生每天都要在今日校园上进行健康信息填报。每天填报的内容都是同样的的 “正常””无症状” 云云。于是,本人便萌发出了写个服务每天自动进行填报的念头。

动机

如果只是每天动手填报一下其实还好,只是麻烦一点。然而今日校园这一应用的可用性实在不怎么样。以下列举几宗 “罪行”:

  • 问卷页面不支持滑动懒加载。填写时需要将页面滑动到最底部,再点按 “加载更多” 按钮。填报一次要重复这个动作四次,而且前三次加载出来的问题都不需要填写。这个与学校的问卷设计也有一定关系。
  • 轰炸式推送通知。每个小时都要推送通知,提醒填写问卷;运用初期甚至还要发短信提醒。每天不定期还有活动广告等推送。
  • 应用获取大量权限,收集巨量数据。这点其实现在已是常态,但仍然让人很不舒服。尤其是,这一产品的安全性我并不十分信任。

如果可以实现自动填报,以上第一个问题就自然解决了。而且此后就可以将应用程序卸载或塞入冰箱,在一定程度上也能解决后面两个问题。

登录

这种爬虫类程序的实现思路基本是固定模式。首先模拟登录,然后向服务器发送提交问卷的请求即可。通过抓包获得中间各个请求的格式。唯一的难处就是服务器不可控,说不定哪天遇到意外情况,程序就挂掉了。因此只能说 “能浪一天是一天”。

今日校园在我校利用学校的统一身份认证机制进行身份验证。抓包得登录请求中的关键信息如下 (为了求生,以下凡涉及到学校名称皆用 DLU 代替):

1
2
3
4
5
6
7
8
9
10
11
12
-->

POST http://authserver.dlu.edu.cn/authserver/login?service=https%3A%2F%2Fexample.com%2Fservice HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=username
&password=encrypted
&lt=LT-0000000-0000000000000000000000000000000000000000000-0000-cas
&dllt=userNamePasswordLogin
&execution=e1s1
&_eventId=submit
&rmShown=1
1
2
3
4
<--

HTTP/1.1 302 Moved Temporarily
Location: https://example.com/service?ticket=ST-0000000-000000000000000000000000000000000-0000-cas

没错,我校统一身份认证没有 TLS.

不难发现这就是一个 OAuth 机制。向认证服务器发送认证所需的信息和一个回调地址,认证通过后认证服务器会用 302 将用户重定向到指定的回调地址,并给出一个令牌参数。根据这个令牌参数就可以确定用户身份了。我校的这个统一身份认证是有服务白名单的,所以不能指望让自己的程序接入。😂

于是首先需要确定要发送到认证服务器的几个参数。经过重复试验,发现请求中 dllt, _eventId, rmShown 三个参数是固定的。executionlt 两个参数存在于服务器提供的登录页面中的两个隐藏 input 中,可以正则匹配页面 HTML 获取。另外,其中 execution 似乎是尝试登录的次数,在浏览器中每进行一次登录,字母 e 后面的值就会加一,因此这里也可以取固定的 e1s1. username 源自用户输入,而 password 则需要对用户输入进行加密计算。

认证服务的代码未混淆,文件名为 login-wisedu.js, 因而怀疑这部分代码是今日校园开发商发行的。(或者整个统一身份认证系统都是他家的?) 用浏览器的 F12 工具定位到登录按钮的事件处理函数,从中找到以下代码:

1
2
var password = casLoginForm.find("#password");
encryptPassword(password.val()); //密码加密

开发人员还贴心地为我们添加了注释,五星好评。encryptPassword() 函数定义如下:

1
2
3
4
5
6
7
8
function encryptPassword(pwd0) {
try {
var pwd1 = encryptAES(pwd0,pwdDefaultEncryptSalt);
$("#casLoginForm").find("#passwordEncrypt").val(pwd1);
} catch (e) {
$("#casLoginForm").find("#passwordEncrypt").val(pwd0);
}
}

注意函数不返回值,而是直接修改了表单。这一函数基本上就是调用了 encryptAES() 函数,并多传递了一个 pwdDefaultEncryptSalt 作为盐。这个盐是固定的,明文嵌入在统一身份认证页面的 HTML 中。因此可以推测这个盐在每个学校有不同的值。

encryptAES() 及其它几个相关函数定义如下 (删去了部分注释)。由于认证系统无 TLS, 这段代码便是在传输过程中对我们密码唯一的防护。

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
function encryptAES(data, aesKey) {
if (!aesKey) {
return data;
}
var encrypted = getAesString(randomString(64) + data, aesKey, randomString(16));
return encrypted;
}

function getAesString(data, key0, iv0) {
key0 = key0.replace(/(^\s+)|(\s+$)/g, "");
var key = CryptoJS.enc.Utf8.parse(key0);
var iv = CryptoJS.enc.Utf8.parse(iv0);
var encrypted = CryptoJS.AES.encrypt(data, key,
{
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return encrypted.toString(); // 返回的是 Base64 格式的密文
}

/**** 默认去掉了容易混淆的字符 oOLl, 9gq, Vv, Uu, I1 ****/
var $aes_chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';
var aes_chars_len = $aes_chars.length;

function randomString(len) {
var retStr = '';
for (i = 0; i < len; i++) {
retStr += $aes_chars.charAt(Math.floor(Math.random() * aes_chars_len));
}
return retStr;
}

从这些代码中可以看出,名字叫 “盐” 的参数实际上是加密的密钥,完全就是公开的秘密。这也是为什么我个人认为在 HTTP 请求中对密码进行加密有些多此一举。如果没有合适的密钥交换机制,加密的安全性根本无从谈起;而要实现合适的密钥交换机制,不妨直接使用 TLS 的内置机制好了。这已经是 2020 年了,我们真的没有什么理由拒绝使用 HTTPS. 当然,有人会指出二次加密可以防范中间人攻击。不过,来自外部的攻击,正确部署的 TLS 完全可以防范;而如果有人在用户设备上安装了根证书,则用户与服务器的所有通信都可以被第三方看到,我想自行实现的各种机制也作用有限。

另外注意到 IV 并没有发送到服务器。CBC 模式中,每一个块在加密前都会与 (加密后的) 前面一个块先做 XOR. 解密时,将密文解密,再与前面一个块做 XOR 就可以得到最终的明文。IV 在该模式中的作用是作为虚拟的第零个块,在第一个块加密前与其做 XOR. 因此,在没有 IV 的情况下,除第一块以外的块仍然可以成功解密。这里,开发人员就选择了将前 64 字节数据填充为可丢弃的随机值,而非发送 IV.

接下来编写 C# 代码对密码进行同样的加密。这里踩了个坑。虽然密钥与 IV 字符串转换成字节组时都是用的 UTF-8 编码,实际加密时用的编码却是 GB2312. 修改后测试两个版本输出一致。

至此登录所需的参数全部就绪,发送请求后即得到成功跳转的响应。具体到今日校园这一情况,回调的请求大致如下:

1
2
3
-->

GET https://dlu.cpdaily.com/wec-counselor-collector-apps/stu/mobile/index.html?ticket=ST-0000000-000000000000000000000000000000000-0000-cas HTTP/1.1
1
2
3
4
5
<--

HTTP/1.1 302 Moved Temporarily
Set-Cookie: MOD_AUTH_CAS=ST-0000000-000000000000000000000000000000000-0000-cas; path=/; Httponly
Location: https://dlu.cpdaily.com/wec-counselor-collector-apps/stu/mobile/index.html

即将原有的 ticket 查询参数设置为 Cookie. 此处有一大坑。如果使用另一个 HTTP 客户端,并自己设置 Cookie 的话,后续请求都不会成功,甚至使用 Fiddler 进行重放都会失败。只有继续使用原有的客户端才能成功进行后续请求。怀疑服务器在传输层有相关检查。

接下来就只需要带着这一 Cookie 进行请求即可了,至此模拟登录成功。

填报

接下来就是发送请求进行填报。今日校园的填报页面就是一个 WebView. 于是在手机上用 HttpCanary 应用抓包得到填报页面的 URI 为 https://dlu.cpdaily.com/wec-counselor-collector-apps/stu/mobile/index.html?collectorWid=0.

URI 中 collectWid 查询参数是与辅导员相关的一个号码,每个辅导员每发布一次问卷,就会生成一个新号码,但旧的号码仍然有效。后面很多查询都要用到这个号码,而又找不到可以获得这些号码的接口。然而,通过修改这一参数,可以访问到不同辅导员的问卷,问卷中有发布者的信息。因此,可以通过遍历访问爬取这一号码。

为了方便开发,我就改在计算机的浏览器上继续访问这个网址。

照常填写问卷,点击提交按钮,却发现没有任何反应,浏览器没有发送任何请求。使用 F12 定位到提交部分的代码,并插入断点使用单步执行进行调试,此时发现提交过程中有依赖 WebView 的代码,这些代码无法在计算机的浏览器上执行。不过此时提交请求已经准备就绪,只是没有发送出去。用控制台得到 Body 的信息如下 (问卷很长,这里只节选一个问题):

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
{
"formWid": 0,
"address": "定位失败",
"collectWid": 0,
"schoolTaskWid": 0,
"form": [
{
"wid": "0",
"formWid": "0",
"fieldType": 2,
"title": "体温是否正常",
"description": "",
"minLength": 0,
"sort": "1",
"maxLength": null,
"isRequired": 1,
"imageCount": null,
"hasOtherItems": 0,
"colName": "field001",
"value": "正常",
"fieldItems": [
{
"itemWid": "0",
"content": "正常",
"isOtherItems": 0,
"contendExtend": null,
"isSelected": null
}
]
}
]
}

又通过在 JavaScript 文件中进行搜索得到请求 URI 为 /wec-counselor-collector-apps/stu/collector/submitForm.

此时要做的就是把 JSON 中的字段填齐。正常情况下,每天填写的内容都是完全相同的,不同的只是编号等信息。具体地,要填写根中的 formWid, collectWid, schoolTaskWid 三项,form 数组中每个元素的 wid, formWid 两项,以及其中 fieldItems 数组中每一项的 itemWid 一项。

其中,两个名称为 formWid 的字段的值相同。另外,各元素的 wid, itemWid 两个字段是分别顺次排列的。因此,我们需要得到的字段有 formWid, collectWid, schoolTaskWid, 以及第一项 wid 和第一项 itemWid.

同样通过搜索 JavaScript 代码,可以找到一些能获得这些信息的接口。首先,向 /wec-counselor-collector-apps/stu/collector/queryCollectorProcessingList 以 POST 方式发送以下信息,可以获得最新的 formWidcollectWid (后者在返回模型中键为 wid). 注意 collectWidcollectorWid 语义完全相同,但通过这一接口可以获得最新值。

1
2
3
{
"collectorWid": "0"
}

collectorWid 即为刚才提到的辅导员号码。

接下来,向 https://dlu.cpdaily.com/wec-counselor-collector-apps/stu/collector/detailCollector 发送同样的信息,可以获得 schoolTaskWid.

最后,向 /wec-counselor-collector-apps/stu/collector/getFormFields 发送以下数据,可以获得问卷的第一个问题,其中包含了 widitemWid 信息。

1
2
3
4
5
6
{
"pageSize": 1,
"pageNumber": 1,
"formWid": "142",
"collectorWid": "11065"
}

通过这三次请求,我们就获得了提交问卷所需的所有数据。于是发送如下请求:

1
2
3
4
5
6
7
8
9
POST https://dlu.cpdaily.com/wec-counselor-collector-apps/stu/collector/submitForm HTTP/1.1
Cookie: MOD_AUTH_CAS=ST-0000000-000000000000000000000000000000000-0000-cas

{
"formWid": 0,
"address": "定位失败",
"collectWid": 0,
"schoolTaskWid": 0,
以下省略...

这就是本人踩坑的时候了…… 服务器返回错误信息,提示 “今日校园版本过低”。本人首先想到要去手机上抓个包看看,不料提交接口开启了证书校验,客户端检测到错误的证书后拒绝连接。HttpCanary 推荐我使用 JustTrustMe 这一 Xposed 插件在系统层面禁用掉校验,于是我又配置 EdXposed 框架,安装插件。不过由于不明原因这一插件并没有起到效果,于是我又自行搜索,换用了另一同类插件,这才抓取到请求。这一折腾费了一天多。

比较请求发现从手机客户端发出的请求多了 Cpdaily-Extension 这一 Header, 值是一个看起来是 Base64 的字符串。不过解编码后并没有得到有效的信息。稍后又想到三个月前有个朋友提到,今日校园会将遥测数据先用 GZip 压缩,再用 Base64 编码发送。于是又尝试用 GZip 解压缩,结果发现解码后的数据并非有效的 GZip 压缩数据。

于是又前往今日校园,对客户端登录过程进行抓包,希望能获得一些线索。最后发现这个 Header 是客户端产生的,也会以 CpdailyInfo 的名字存在于其它一部分请求中。更重要的是,这一 Header 在登录前就存在,在登录后值会发生改变。

既然登录前就有这个 Header, 那不妨试一下用登录前的值能否提交成功。经过测试,这个值是可以成功提交的,也就是说服务器并不会利用这个 Header 进行身份校验。也就是说,这个 Header 可以由多个用户公用。

再次发送提交问卷的请求,附带上未登录时的 Cpdaily-Extension 值。这一次服务器返回了成功的结果。

服务

有了好东西自然要与他人共享,于是本人希望将其开发成一项在线服务。通过上述分析不难发现,只要知道用户的统一身份认证账户,密码,以及其辅导员的 collectorWid 就可以帮其自动填报。于是首先要设计系统收集这三项信息。

技术选型…… 那当然是怎么便宜怎么来。前端选用纯静态页面,部署在对象存储中;后端选用 Azure Functions 的消耗方案,按调用次数与实际执行时间计价;数据库选用 Azure Cosmos DB 的免费级别,每秒钟的前 400 个 RU 免费 (RU 是 Cosmos DB 的资源计价单位,一个 RU 代表一次单点读取需要的资源量,其它类型操作相应折算)。我已采用相似的技术开发部署了多个服务,每月计费只有一刀多一点。

由于 Cosmos DB 的特殊架构,设计模型的时候也要相应考虑,这方面内容我有意向在以后专文详谈 (这 Flag 可能会倒)。后端方面就是标准 CRUD 了,只是针对 Cosmos DB 的设计做了部分处理。本人前端技术一般,于是请室友 (字节跳动前端开发实习生) 帮忙撸了一个,做出来的效果真棒,不愧是被字节翻了牌子的大佬。

收集到信息后还要每天定时填报。这里肯定是不敢一口气填完的,否则造成高并发被禁了 IP, 或者扒日志找到我头上可就不好了。因此每填写一人需要等待一段时间。然而 Azure Functions 是按执行时间计费的,每等待的一秒钟都可能是人民币在流走。

好在 Azure 为我们提供了持久函数 (Durable Functions)。持久函数允许我们编写有状态的函数服务。一个基本的持久函数会分为三部分:

  • 客户端 (Client)
    负责触发持久函数的协调函数器。由于持久函数的底层设计,协调器不能直接被定时器, HTTP 等常见的触发器触发。因此,需要用这类触发器触发一个客户端函数,客户端函数处理一些输入,然后以专门的 API 触发协调器。这一 API 会在消息队列中创建一个消息,协调器见消息后即运行。

  • 协调器 (Orchestrator)
    负责调用其它活动函数,组织持久函数的运行。运行时会将协调器的运行历史记录在一张表中。当协调器调用其它活动函数时,协调器即终止,其占用的资源可以被释放。当活动函数运行结束时,它们也会在消息队列中创建消息,使协调器重新运行。此时,运行时从头开始运行协调器代码,并对历史表进行查询。如果查询发现某部分已经在之前执行过,则直接从历史表中得到这部分执行的结果,而不重新执行,进而逐渐重建状态,直至先前中断的点。协调器也可以使用持久函数框架提供的一系列组织功能。例如,调度器可以进行等待,此时调度器在消息队列中创建一个延时消息,待延时到后,消息可见,协调器又重新运行。

  • 活动函数 (Activity)
    由于协调器的设计原理,协调器并不能直接用于有效地处理工作,尤其是不能随意调用异步方法 (文档中有相当详细的说明)。这些工作应交由活动函数处理。这些工作可以是处理一项数据,调用一个 REST API, 等等。

对应到这里的场景,我们应当使用定时器触发器,在每天固定时刻触发客户端函数,在客户端函数中查询数据库获取所有用户,并触发协调器;在协调器中遍历所有用户,对每个用户调用活动函数;在活动函数中执行实际的填报请求。简化代码大致如下:

1
2
3
4
5
6
7
8
9
10
// 客户端
[FunctionName("ReportClient")]
public async Task ReportStart(
[TimerTrigger("0 0 4 * * *")] TimerInfo timer,
[CosmosDB(databaseName: "myDatabase", collectionName: "myCollection", ConnectionStringSetting = "connSetting")] DocumentClient dbClient,
[DurableClient] IDurableOrchestrationClient starter)
{
List<User> users = await GetUsersAsync(dbClient);
await starter.StartNewAsync("ReportOrchestrator", null, users);
}

客户端函数在每天 04:00 (UTC) 被触发。首先读取得到所有的用户信息,然后触发协调器, 并将用户信息作为输入。协调器开始运行后,客户端函数即结束运行。

1
2
3
4
5
6
7
8
9
10
11
12
// 协调器
[FunctionName("ReportOrchestrator")]
public async Task ReportOrchestrator(
[OrchestrationTrigger] IDurableOrchestrationContext context)
{
var users = context.GetInput<List<User>>();
foreach (var user in users)
{
await context.CallActivityAsync("ReportEx", user);
await context.CreateTimer(context.CurrentUtcDateTime.AddSeconds(10), CancellationToken.None);
}
}

协调器首先读得输入的用户信息,而后遍历用户集合,对每个用户调用活动函数,而后等待十秒。协调器每次通过 await 等待时即终止,直到客户端发送完成消息,或自己发送的延时消息到期变为可见时,再次运行。函数运行时会检查协调器的运行历史,遇到已执行的活动则直接返回先前结果,遇到未执行的部分则执行调用,而后终止,等待下次运行。

1
2
3
4
5
6
7
// 活动函数
[FunctionName("ReportEx")]
public async Task ReportEx([ActivityTrigger] User user)
{
user.Password = await encryptor.DecryptAsync(user.Password);
await reporter.ReportAsync(user);
}

活动函数首先读取协调器传来的输入,然后根据输入的单个用户信息为其进行自动填报。填报过程中活动一直处于运行状态。

由此,两次填报之间等待的 10 秒内没有任何函数执行,因而并不会被计价。如果我们直接使用定时器触发的单个函数进行同样的操作,不仅会在等待期间被计价,在用户数量多时还有超时中断的危险。

至此,我们就获得了今日校园的自动填报服务。希望这篇文章能为有相似需求的人带来启发。