新的通知又要每天填健康上报了,重复的工作真是无趣(叹气),今天就抽个时间分析一下健康上报系统吧。
抓取页面URL
打开微信,点开数字烟科->疫情健康上报,微信打开了一个 Webview,显而易见这不是小程序而是一个外部网页。
点击右上角三个点,扔到浏览器里拿个 URL 分析看看。
浏览器打开后,就获得了疫情健康上报的 URL:https://ujnpl.educationgroup.cn/jksb/tb/tbIndex
在PC浏览器隐身模式下打开重定向到了校统一身份认证系统,那么说明我们需要先在系统登录才能模拟填报。
统一身份认证系统分析
表单分析
那么首先我们分析一下登录表单具体是怎么发起登录的。
首先看一下拦路虎——验证码,按下F12打开开发者工具栏,右键换一张
点击审查元素。
可以看到,图片是向 /sso/auth/genCode 发起了一个 GET 请求,点击换一张时调用了 changeCodeImg()
JS函数。
查看一下函数体:
显而易见没有什么复杂的,单纯从服务器再请求一个验证码,那说明验证码没有签名或者 code 和登录表单所关联,只要是个任意有效验证码即可。
接下来在登录按钮上邮件审查元素:
调用了一个 submitInfo()
的JS函数登录,查看一下函数体:
可以看到,JS脚本取出编辑框的值后进行了一系列数据验证,然后调用了关键代码:
$("#username").val(Base64.encode(_username));
$("#password").val(Base64.encode(_password));
fm1.submit();
从这里我们可以得知,登录就是将用户名和密码进行了 Base64 编码然后提交了整个表单。
登录流程分析
切换到网络选项卡,勾选“保留日志”以便在多级跳转中保留浏览器请求记录,输入用户名和密码点击登录按钮,登录成功。
我们立刻就看到了三个关键请求:
- 到 login 接口的 POST 请求
- 到 api 的 GET Params 传参的请求,并且 302 到了下个请求
- 到 getAccessToken 的 GET Params 传参的请求,并且 302 到了系统主页
login 接口分析
从字段名我们可知,这个请求包含了如下内容:
- 登陆成功后重定向到的 URL 地址
- 登录类型,我们为账号登录
- 用户手机号和手机短信验证码为空
- 用户名,经过 Base64 编码,明文
- 密码,经过 Base64 编码,明文
并且返回了一个 200 OK 响应。
api 接口分析
可以看到,上个接口带着的 redirect 参数的二级 redirect 参数被带进了 api 接口的 redirect_uri
里面。
检查请求头,可以看到这个地址给了一个 302 重定向到 getAccessToken 接口,并且 URL 中带着一个 code (看起来有点 OAuth 2.0 的味道),这个 code 就是到下一步兑换 Access Token 的关键。
值得一提的是,下面的 Auth-Token 是每次请求都变化的,而且登陆之前也有,暂时不清楚是怎么追踪的用户登录有效性……
getAccessToken 接口分析
到了 getAccessToken 接口,这里进行了大量的 Cookie 设置操作,并最终 302 到了门户首页,完成了整个登录流程。
现在,疫情健康上报页面终于可以顺利打开了。
Unirest 实现登录过程
使用 Unirest 主要是链式操作并且比较爽,还能自动管理 Cookies,实在是相当方便,墙裂推荐一波:
开局先初始化 Unirest 默认属性:
Unirest.config()
.addDefaultHeader("User-Agent", config.getUserAgent())
.followRedirects(true)
.cacheResponses(false)
.enableCookieManagement(true);
然后请求验证码接口,拿到验证码,并且调用百度云 OCR 免费额度进行验证码识别:
/**
* 获取一个可用的验证码
*
* @return 验证码
*/
@Nullable
public String getCaptcha() throws OCRServiceException {
int retry = 0;
while (retry < 10) {
File tmpFile = new File(System.getProperty("java.io.tmpdir"), "ytkjcaptcha.jfif");
HttpResponse<File> resp = Unirest.get("https://ujnpl.educationgroup.cn/sso/auth/genCode?random=" + Math.random())
.asFile(tmpFile.getPath(), StandardCopyOption.REPLACE_EXISTING);
if (resp.isSuccess()) {
String code = OCR.scanText(config,resp.getBody());
resp.getBody().deleteOnExit();
if (code != null)
return code;
}
Log.error("验证码验证失败,重试...");
retry++;
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
Log.error("验证码重试失败超过 10 次...");
return null;
}
最后组装成一个登录表单:
/**
* 获取请求体
*
* @return 请求体(无code)
*/
@NotNull
public Map<String, Object> getPostMapping() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("redirect", "/api?scope=base&response_type=code&state=default&redirect_uri=https%3A%2F%2Fujnpl.educationgroup.cn%2Fportal%2FoauthApi%2FgetAccessToken%3Fredirect%3D%252Findex%26authType%3Dauth&client_id=A0002");
map.put("loginType", "account");
map.put("usertel", "");
map.put("usertel_code", "");
map.put("username", Base64.getEncoder().encodeToString(config.getUsername().getBytes(StandardCharsets.UTF_8)));
map.put("password", Base64.getEncoder().encodeToString(config.getPassword().getBytes(StandardCharsets.UTF_8)));
return map;
}
加上验证代码,即可登录:
/**
* 登录烟台科技学院统一身份验证系统
*
* @throws CaptchaOCRException 百度云 OCR 错误
* @throws LoginException 登录失败
*/
public void auth() throws CaptchaOCRException, LoginException, OCRServiceException {
Map<String, Object> mapping = getPostMapping();
String captcha = getCaptcha();
if (captcha == null)
throw new CaptchaOCRException();
mapping.put("code", captcha);
Unirest.get("https://ujnpl.educationgroup.cn/sso/auth?redirect=%2Fapi%3Fscope%3Dbase%26response_type%3Dcode%26state%3Ddefault%26redirect_uri%3Dhttps%253A%252F%252Fujnpl.educationgroup.cn%252Fportal%252FoauthApi%252FgetAccessToken%253Fredirect%253D%25252Fhome%2526authType%253Dauth%26client_id%3DA0002").asString();
HttpResponse<String> result = Unirest.post("https://ujnpl.educationgroup.cn/sso/auth/login")
.fields(mapping)
.header("referer", "https://ujnpl.educationgroup.cn/sso/auth?redirect=%2Fapi%3Fscope%3Dbase%26response_type%3Dcode%26state%3Ddefault%26redirect_uri%3Dhttps%253A%252F%252Fujnpl.educationgroup.cn%252Fportal%252FoauthApi%252FgetAccessToken%253Fredirect%253D%25252Fhome%2526authType%253Dauth%26client_id%3DA0002")
.asString();
if (!result.isSuccess()) {
Log.error("无法登录:" + result.getBody());
throw new LoginException();
}
// 解析跳转目标地址
String url = "https://ujnpl.educationgroup.cn" + JsoupUtil.getUrlFromMetaRedirect(result.getBody());
HttpResponse<String> resp = Unirest.get(url).asString();
Log.info(url);
if (resp.isSuccess()) {
Log.info("登录成功!");
} else {
Log.error("登录失败:" + resp.getBody());
Log.error("登录失败:" + resp.getStatus());
Log.error("登录失败:" + resp.getHeaders().all().toString());
}
}
分析疫情健康上报页面
一般来说问卷URL都不会变,但是我校的问卷URL经常不定期突然变一下,这就需要动态解析了:
可以看到,蓝色框是我们想要的地址,分析上级 DOM 元素,并调用 jsoup 进行元素查找:
/**
* DOM 解析健康问卷上报链接地址
*
* @return 上报链接地址
*/
@Nullable
private String getReportPageUrl() {
HttpResponse<String> resp = Unirest.get("https://ujnpl.educationgroup.cn/jksb/tb/tbIndex")
.header("User-Agent", config.getUserAgent()).asString();
// 如果请求出错,则返回空地址
if (!resp.isSuccess()) return null;
Document document = Jsoup.parse(resp.getBody());
for (Element element : document.getElementsByClass("weui-cells")) {
for (Element child : element.children()) {
for (Element aElement : child.getElementsByTag("a")) {
String linkPath = aElement.attr("href");
if (linkPath.startsWith("/jksb/tb/index?id=")) for (Element linkChild : aElement.children()) {
for (Element p : linkChild.getElementsByTag("p")) {
if (p.text().contains("健康上报")) {
Log.info("已选中 DOM:" + JsoupUtil.getCssPath(p) + " => " + p);
Log.info("解析 href 地址:" + linkPath);
return "https://ujnpl.educationgroup.cn" + linkPath;
}
}
}
}
}
}
return null;
}
由此我们即可获得问卷的 URL 地址。
分析问卷页面
点进去后首先发现问卷页面充满了水印,每个水印是一个 DIV,直接F12删掉 DOM 烦人的水印就可以走开了。
点进页面除了个人信息,还有个有点奇怪的微信位置。在微信上面,这个框会请求微信定位API然后填上当前位置,所以要着手解决一下这个微信位置。
微信位置定位逻辑分析
直接查看网页源代码,搜索位置
,相关的 Javascript 代码就立刻出现了:
我们可知,首先调用了微信API,获取精确经纬度坐标,然后扔进了高德地图的API里转换成了地址文本并填入了框中,那么我们可以暂时不考虑这个地方了,理论上这样操作填个冥王星都OK。
分析表单
直接简单一填点一下提交,看看发给服务器的数据
setid, userid, id, ticket 暂时未知,下面的其他字段都对应了表格中的一项。
直接查看表格源代码:
表格 POST 请求到 /jksb/tb/save 接口,setid, userid, id, ticket 是表格中的隐藏字段,到时候直接从里面抓出来大概就行。
Jsoup 解析并 Unirest 模拟提交
/**
* 解析健康上报问卷表格的动态变化的ID数据
*
* @param reportPageUrl 健康上报问卷 URL
* @return 表格动态数据
*/
@NotNull
private SignFormData readMeta(String reportPageUrl) {
HttpResponse<String> resp = Unirest.get(reportPageUrl).asString();
if (!resp.isSuccess()) return null;
Document document = Jsoup.parse(resp.getBody());
return new SignFormData(config, document.getElementsByAttributeValue("name", "setid").get(0).val(), document.getElementsByAttributeValue("name", "userid").get(0).val(), document.getElementsByAttributeValue("name", "id").get(0).val());
// 此处还有个未使用的 ticket 变量,目前好像没什么用,先不加了
}
public class SignFormData {
private final Map<String, Object> map;
public SignFormData(Config config, String setId, String userId, String id) {
this.map = new LinkedHashMap<>();
map.put("setid", setId); // 隐藏字段
map.put("userid", userId);// 隐藏字段
map.put("id", id);// 隐藏字段
map.put("zx", config.inSchool()); // 是否在校
map.put("zx_select", config.inSchool()); // 是否在校(选中项)
// 宿舍位置
map.put("wxwz", config.getLocation()); // GPS坐标通过高德地图SDK转换的位置文本
// 随机体温 ( 36.0 ~ 36.7 )
map.put("tw", randomSafeTemp()); // 体温(早上)
map.put("tw2", randomSafeTemp()); // 体温(中午)
map.put("tw5", randomSafeTemp()); // 体温(晚上)
map.put("ks", "否"); // 是否有咳嗽、呕吐、咽痛、嗅味觉减退等症状
map.put("ks_select", "否"); // 是否有咳嗽、呕吐、咽痛、嗅味觉减退等症状(选中项)
map.put("jkm", ""); // 健康码
map.put("xcm", ""); // 行程卡
map.put("gtjz1", ""); // 共同居住人健康码
map.put("gtjz5", ""); // 共同居住人行程卡
Log.info("上报表格数据已生成:" + map.entrySet());
}
/**
* 随机生成安全体温
*
* @return 返回一个在 36.0 ~ 36.7 的体温
*/
@NotNull
public String randomSafeTemp() {
return "36." + new Random().nextInt(7);
}
public Map<String, Object> generateParams() {
return map;
}
最后再提交一下:
/**
* 提交问卷
*
* @param reportPageUrl 健康上报问卷 URL
* @return 上报结果
*/
@NotNull
private SignReport submit(String reportPageUrl) {
SignFormData formData = readMeta(reportPageUrl);
HttpResponse<String> resp = Unirest.post("https://ujnpl.educationgroup.cn/jksb/tb/save").header("User-Agent", config.getUserAgent()).contentType("application/x-www-form-urlencoded").fields(formData.generateParams()).asString();
return new SignReport(resp.getBody().contains("提交成功"), formData, resp);
}
至此,自动提交流程就结束了。
配合亿点点修改打磨,就出现了最后的成品:
尾言
说实话,我觉得这个玩意儿挺没用的,微信的位置很多软件都能随便改,体温那更是“我信则为真”,真正发烧的学生全靠自己自觉主动上报,不然学校根本不可能知道。
在这个项目最开始做的时候,我甚至考虑过套壳个 CEF 之类的浏览器内核来实现(因为可能会很复杂),不过分析下来发现大部分逻辑都很简单,也没有复杂的混淆加密之类的手段,还算是比较好分析的吧。
验证码的强度基本算是没有(哈哈!),简单的百度云免费OCR额度就可以轻易摆平,甚至不用于专门的抗干扰或者训练神经网络之类的麻烦事情 :) 要是验证码比较离谱一些的话,我大概还会挺头疼的。
算是简单练手了,以及,Unirest真的很好用!
Happy programming!