实战今日校园自动填报
由于众所周知的原因,目前我校学生每天都要在今日校园上进行健康信息填报。每天填报的内容都是同样的的 “正常””无症状” 云云。于是,本人便萌发出了写个服务每天自动进行填报的念头。
动机
如果只是每天动手填报一下其实还好,只是麻烦一点。然而今日校园这一应用的可用性实在不怎么样。以下列举几宗 “罪行”:
- 问卷页面不支持滑动懒加载。填写时需要将页面滑动到最底部,再点按 “加载更多” 按钮。填报一次要重复这个动作四次,而且前三次加载出来的问题都不需要填写。这个与学校的问卷设计也有一定关系。
- 轰炸式推送通知。每个小时都要推送通知,提醒填写问卷;运用初期甚至还要发短信提醒。每天不定期还有活动广告等推送。
- 应用获取大量权限,收集巨量数据。这点其实现在已是常态,但仍然让人很不舒服。尤其是,这一产品的安全性我并不十分信任。
如果可以实现自动填报,以上第一个问题就自然解决了。而且此后就可以将应用程序卸载或塞入冰箱,在一定程度上也能解决后面两个问题。
登录
这种爬虫类程序的实现思路基本是固定模式。首先模拟登录,然后向服务器发送提交问卷的请求即可。通过抓包获得中间各个请求的格式。唯一的难处就是服务器不可控,说不定哪天遇到意外情况,程序就挂掉了。因此只能说 “能浪一天是一天”。
今日校园在我校利用学校的统一身份认证机制进行身份验证。抓包得登录请求中的关键信息如下 (为了求生,以下凡涉及到学校名称皆用 DLU 代替):
1 | --> |
1 | <-- |
没错,我校统一身份认证没有 TLS.
不难发现这就是一个 OAuth 机制。向认证服务器发送认证所需的信息和一个回调地址,认证通过后认证服务器会用 302 将用户重定向到指定的回调地址,并给出一个令牌参数。根据这个令牌参数就可以确定用户身份了。我校的这个统一身份认证是有服务白名单的,所以不能指望让自己的程序接入。😂
于是首先需要确定要发送到认证服务器的几个参数。经过重复试验,发现请求中 dllt
, _eventId
, rmShown
三个参数是固定的。execution
与 lt
两个参数存在于服务器提供的登录页面中的两个隐藏 input 中,可以正则匹配页面 HTML 获取。另外,其中 execution
似乎是尝试登录的次数,在浏览器中每进行一次登录,字母 e 后面的值就会加一,因此这里也可以取固定的 e1s1
. username
源自用户输入,而 password
则需要对用户输入进行加密计算。
认证服务的代码未混淆,文件名为 login-wisedu.js
, 因而怀疑这部分代码是今日校园开发商发行的。(或者整个统一身份认证系统都是他家的?) 用浏览器的 F12 工具定位到登录按钮的事件处理函数,从中找到以下代码:
1 | var password = casLoginForm.find("#password"); |
开发人员还贴心地为我们添加了注释,五星好评。encryptPassword()
函数定义如下:
1 | function encryptPassword(pwd0) { |
注意函数不返回值,而是直接修改了表单。这一函数基本上就是调用了 encryptAES()
函数,并多传递了一个 pwdDefaultEncryptSalt
作为盐。这个盐是固定的,明文嵌入在统一身份认证页面的 HTML 中。因此可以推测这个盐在每个学校有不同的值。
encryptAES()
及其它几个相关函数定义如下 (删去了部分注释)。由于认证系统无 TLS, 这段代码便是在传输过程中对我们密码唯一的防护。
1 | function encryptAES(data, aesKey) { |
从这些代码中可以看出,名字叫 “盐” 的参数实际上是加密的密钥,完全就是公开的秘密。这也是为什么我个人认为在 HTTP 请求中对密码进行加密有些多此一举。如果没有合适的密钥交换机制,加密的安全性根本无从谈起;而要实现合适的密钥交换机制,不妨直接使用 TLS 的内置机制好了。这已经是 2020 年了,我们真的没有什么理由拒绝使用 HTTPS. 当然,有人会指出二次加密可以防范中间人攻击。不过,来自外部的攻击,正确部署的 TLS 完全可以防范;而如果有人在用户设备上安装了根证书,则用户与服务器的所有通信都可以被第三方看到,我想自行实现的各种机制也作用有限。
另外注意到 IV 并没有发送到服务器。CBC 模式中,每一个块在加密前都会与 (加密后的) 前面一个块先做 XOR. 解密时,将密文解密,再与前面一个块做 XOR 就可以得到最终的明文。IV 在该模式中的作用是作为虚拟的第零个块,在第一个块加密前与其做 XOR. 因此,在没有 IV 的情况下,除第一块以外的块仍然可以成功解密。这里,开发人员就选择了将前 64 字节数据填充为可丢弃的随机值,而非发送 IV.
接下来编写 C# 代码对密码进行同样的加密。这里踩了个坑。虽然密钥与 IV 字符串转换成字节组时都是用的 UTF-8 编码,实际加密时用的编码却是 GB2312. 修改后测试两个版本输出一致。
至此登录所需的参数全部就绪,发送请求后即得到成功跳转的响应。具体到今日校园这一情况,回调的请求大致如下:
1 | --> |
1 | <-- |
即将原有的 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 | { |
又通过在 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 方式发送以下信息,可以获得最新的 formWid
与 collectWid
(后者在返回模型中键为 wid
). 注意 collectWid
与 collectorWid
语义完全相同,但通过这一接口可以获得最新值。
1 | { |
collectorWid
即为刚才提到的辅导员号码。
接下来,向 https://dlu.cpdaily.com/wec-counselor-collector-apps/stu/collector/detailCollector
发送同样的信息,可以获得 schoolTaskWid
.
最后,向 /wec-counselor-collector-apps/stu/collector/getFormFields
发送以下数据,可以获得问卷的第一个问题,其中包含了 wid
与 itemWid
信息。
1 | { |
通过这三次请求,我们就获得了提交问卷所需的所有数据。于是发送如下请求:
1 | POST https://dlu.cpdaily.com/wec-counselor-collector-apps/stu/collector/submitForm HTTP/1.1 |
这就是本人踩坑的时候了…… 服务器返回错误信息,提示 “今日校园版本过低”。本人首先想到要去手机上抓个包看看,不料提交接口开启了证书校验,客户端检测到错误的证书后拒绝连接。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 | // 客户端 |
客户端函数在每天 04:00 (UTC) 被触发。首先读取得到所有的用户信息,然后触发协调器, 并将用户信息作为输入。协调器开始运行后,客户端函数即结束运行。
1 | // 协调器 |
协调器首先读得输入的用户信息,而后遍历用户集合,对每个用户调用活动函数,而后等待十秒。协调器每次通过 await
等待时即终止,直到客户端发送完成消息,或自己发送的延时消息到期变为可见时,再次运行。函数运行时会检查协调器的运行历史,遇到已执行的活动则直接返回先前结果,遇到未执行的部分则执行调用,而后终止,等待下次运行。
1 | // 活动函数 |
活动函数首先读取协调器传来的输入,然后根据输入的单个用户信息为其进行自动填报。填报过程中活动一直处于运行状态。
由此,两次填报之间等待的 10 秒内没有任何函数执行,因而并不会被计价。如果我们直接使用定时器触发的单个函数进行同样的操作,不仅会在等待期间被计价,在用户数量多时还有超时中断的危险。
至此,我们就获得了今日校园的自动填报服务。希望这篇文章能为有相似需求的人带来启发。