分析一波校统一身份认证系统,并制作一个自动健康上报的脚本

分析一波校统一身份认证系统,并制作一个自动健康上报的脚本

不提供源代码或二进制文件下载 不提供最终成品的源代码或二进制可执行文件下载,本文仅介绍工作原理和非完整实现,仅供学习用途。 新的通知又要每天填健康上报了,重复的工作真是无趣(叹气),今天

不提供源代码或二进制文件下载
不提供最终成品的源代码或二进制可执行文件下载,本文仅介绍工作原理和非完整实现,仅供学习用途。

新的通知又要每天填健康上报了,重复的工作真是无趣(叹气),今天就抽个时间分析一下健康上报系统吧。

抓取页面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!

除特殊说明以外,本站原创内容采用 知识共享 署名-非商业性使用 4.0 (CC BY-NC 4.0) 许可。转载时请注明来源,以及原文链接。
Comment