某某儿科医院APP登陆与抢票分享

1. 背景

近期到了暑假,儿保的票是越来越难抢了。卡着点也不能刷得到,有天偶然打开发现某个热门门诊突然有一个票,然后就帮人挂到了。琢磨一下,这种不是秒杀的抢票,如果能把所有取消的捡漏刷到,其实问题也不算大。毕竟这软件的放票规则是一周前就放了,一周内总是有人会偶尔取消一把的。

心随所至,动手就干。一般这种医院机构的APP,都是外包实现的,而且费用预算有限,承接方甚至会逐层转包,最终能开发者能拿到的费用不会很高。费用低到一定程度的时候,他们只会考虑功能实现,不会考虑太多安全性问题。应该简单的抢票不会太难。

2. 分析

大多数的APP小程序的架构都是使用前后端分离的架构,也就是说前端界面由前端开发,后端数据由后端开发。这样的架构方便实现跨平台,APP,WEB,小程序都可以公用一套后端系统,前端代码使用的跨平台工具的话,前端也只需要实现一套代码,然后在不同的平台上发布即可。

一个很有代表性的外包前端开发利器就是UniAPP,可以实现一套前端代码,实现多端发布 iOSAndroidH5各种小程序。前端问题解决后,这里只需要把对应的与后端交互接口用Restful的方式进行简单封装,即可完成业务开发。

这里后端使用任何一种WEB APP即可实现,比如Node.jsPHPJavaPython等等,不要太多。

2.1 猜想验证

某某儿科医院这个APP在 App Store 中可以下载,这里我们可以利用 macOS 也可以安装 iPhone 应用的特点,在安装后,直接显示包内容,从而观察到APP的实现,从而大体验证我们的分析猜想是不是对的。

第一层目录中有 Pandora 文件夹的时候,熟悉 UniAPP 开发的小伙伴应该知道的大差不差了,再进行展开后,看到了UniAPP的各种前缀文件,没的跑了。

某某儿科医院APP登陆与抢票分享_第1张图片

而且这个开发团队还是懂得前后端分离的,名字起的都太具有特点了,app-service.jsapp-view.js。敢情好,其他的js也不用看了呗,前端界面的代码就是view,和后端交互的接口类不就是 app-service.js了。谢谢了。

2.2 总结

分析到这里,大体感觉应该大差不差了。接受外包任务的团队为了最低成本的实现多端发布,并且也不会有很多繁复的任务,的确和我们最开始的猜想很近似。那么我们只要通过把 Restful的内容理解清楚,知道他们怎么组包的,就知道怎么模拟APP抢票的过程了。

3. 动手

既然要了解 APP后端服务器 怎么交互,那么就一定要抓包,电脑上抓包都知道用 Wireshark 或者 tcpdump,手机咋抓包呢? 这里iOS一般推荐这个 Stream 免费好用。

某某儿科医院APP登陆与抢票分享_第2张图片

这里抓包细节就不展开了,偏离主题了。直接展现抓包结果。

某某儿科医院APP登陆与抢票分享_第3张图片
某某儿科医院APP登陆与抢票分享_第4张图片

好家伙,猜到了前后端,但没猜到这里的所有实现居然都是明文,请求头,请求体都是明文。咳咳咳。是不是我搞错了。这里重放一下请求。

#### 登陆

POST https://mobiles.zhicall.cn/mobile-web/mobile/patient/get/familyMembers/true/_这里是5个大写A到F的字符,为了安全就不展示了/10356 HTTP/1.1

Host: mobiles.zhicall.cn
Data-Sign: ab421bfd4003f09c63a6cdb344b2192a
Accept: */*
from: 0
hospitalId: 10356
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh-Hans;q=0.9
token: 13456789123  
Content-Type: application/x-www-form-urlencoded
Content-Length: 38
User-Agent: iPhone14,5(iOS/16.5.1) Uninview(Uninview/1.0.0) Weex/0.26.0 1125x2436
Connection: keep-alive

hospitalId=10356&agencyId=10356&from=0

好家伙,回了包,直接把身份证给晒出来了,有些甚至父母信息,家庭地址都有。

HTTP/1.1 200 OK
Date: Sun, 30 Jul 2023 13:26:33 GMT
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Connection: close
Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, GET, PUT, OPTIONS, DELETE
Access-Control-Allow-Headers: Origin,DNT,X-CustomHeader,X-Access-Token,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Accept,Data-Sign

{
  "data": [
    {
      "accountId": "_为了安全不晒",
      "address": "金华东阳xxxxxxxxxx为了安全不晒",
      "aesPatientId": "为了安全不晒",
      "age": 12,
      "birthday": "2011-07-23",
      "hospitalId": 10356,
      "id": 266602,
      "idCard": "3307为了安全不晒2",
      "medicalCardId": 163511,
      "medicalCardNo": "61691879",
      "medicalCardType": "省一卡通",
      "medicalCardValid": true,
      "medicalCards": [
        {
          "aesPatientId": "为了安全不晒",
          "createTime": "2019-01-28 13:49:19",
          "hospitalId": 10356,
          "id": 163511,
          "medicalCardNo": "61691879",
          "medicalCardType": "省一卡通",
          "medicalCardTypeId": 103568,
          "medicalCardValid": true,
          "mid": "EBGAEE",
          "name": "高为了安全不晒",
          "patientId": 266602,
          "updateTime": "2019-01-28 13:49:19"
        }
      ],
      "mobileNo": "138为了安全不晒",
      "name": "高为了安全不晒",
      "paperName": "身份证",
      "paperNo": "330为了安全不晒",
      "paperType": "IDENTITY_CARD",
      "patientType": 0,
      "sex": "FEMALE"
    }
  ],
  "success": true
}

细想一看不对,我都没输入任何密码或者手机验证码怎么就登陆了?

看了看这里的请求,说白了这里只有用户名,没有任何密码。只要你知道自己对应的用户名,那么你就可以登陆了。

虽然这个结果是真的有点离谱,但事实你的小孩还有你的信息就是泄露的这么彻彻底底。

那我们继续看看怎么刷票吧,以后这种APP上信息少填点总是没错的。

3.1 重放调试

这里不展开具体寻找请求报文的过程,大体思路就是打开APP,依次点抢票的几个按钮,每个按钮对应都会有一个或者多个请求链接,操作完后切换到 Stream 中去在对应域名中查找请求包的URL和内容,查看对应回复大概能猜到。

这里直接晒最关键的看是否有余票的请求链接。

请求报文如下:


POST https://mobiles.zhicall.cn/mobile-web/mobile/schedule/new/10361/dept/1037800243/info HTTP/1.1

Host: mobiles.zhicall.cn
Data-Sign: d9e69844f90031d106b5e6a28879062d
Accept: */*
from: 0
hospitalId: 10361
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh-Hans;q=0.9
Content-Type: application/x-www-form-urlencoded
Content-Length: 89
User-Agent: iPhone14,5(iOS/16.5.1) Uninview(Uninview/1.0.0) Weex/0.26.0 1125x2436
Connection: keep-alive

fetchType=SCHEDULE_REALTIME&expertType=DEPT_COMMON&hospitalId=10361&agencyId=10361&from=0

对应的回复报文如下:

HTTP/1.1 200 OK
Date: Sun, 30 Jul 2023 13:43:30 GMT
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Connection: close
Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: Thu, 01 Jan 1970 00:00:00 GMT
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, GET, PUT, OPTIONS, DELETE
Access-Control-Allow-Headers: Origin,DNT,X-CustomHeader,X-Access-Token,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Accept,Data-Sign

{
  "data": {
    "childDeptList": [],
    "doctors": [],
    "id": 1037800243,
    "leftNum": 1,
    "name": "(内分泌科)生长发育门诊",
    "regNewScheduleVOList": [
      {
        "leftNum": 0,
        "outCallType": "DEPT_COMMON",
        "price": "25.00",
        "regDate": "2023-07-31",
        "regTime": "MORNING",
        "remark": "已满",
        "rest": false,
        "scheduleId": "480556:A",
        "testField": "1037800243:",
        "totalNum": 0,
        "weekDay": "周一"
      },
      {
        "leftNum": 0,
        "outCallType": "DEPT_COMMON",
        "price": "25.00",
        "regDate": "2023-07-31",
        "regTime": "AFTERNOON",
        "remark": "已满",
        "rest": false,
        "scheduleId": "480590:P",
        "testField": "1037800243:",
        "totalNum": 0,
        "weekDay": "周一"
      },
      {
        "leftNum": 0,
        "outCallType": "DEPT_COMMON",
        "price": "25.00",
        "regDate": "2023-08-01",
        "regTime": "MORNING",
        "remark": "已满",
        "rest": false,
        "scheduleId": "481037:A",
        "testField": "1037800243:",
        "totalNum": 0,
        "weekDay": "周二"
      },
      {
        "leftNum": 0,
        "outCallType": "DEPT_COMMON",
        "price": "25.00",
        "regDate": "2023-08-01",
        "regTime": "AFTERNOON",
        "remark": "已满",
        "rest": false,
        "scheduleId": "481353:P",
        "testField": "1037800243:",
        "totalNum": 0,
        "weekDay": "周二"
      },
      {
        "leftNum": 0,
        "outCallType": "DEPT_COMMON",
        "price": "25.00",
        "regDate": "2023-08-02",
        "regTime": "MORNING",
        "remark": "已满",
        "rest": false,
        "scheduleId": "481752:A",
        "testField": "1037800243:",
        "totalNum": 0,
        "weekDay": "周三"
      },
      {
        "leftNum": 0,
        "outCallType": "DEPT_COMMON",
        "price": "25.00",
        "regDate": "2023-08-02",
        "regTime": "AFTERNOON",
        "remark": "已满",
        "rest": false,
        "scheduleId": "481850:P",
        "testField": "1037800243:",
        "totalNum": 0,
        "weekDay": "周三"
      },
      {
        "leftNum": 0,
        "outCallType": "DEPT_COMMON",
        "price": "25.00",
        "regDate": "2023-08-03",
        "regTime": "MORNING",
        "remark": "已满",
        "rest": false,
        "scheduleId": "482186:A",
        "testField": "1037800243:",
        "totalNum": 0,
        "weekDay": "周四"
      },
      {
        "leftNum": 0,
        "outCallType": "DEPT_COMMON",
        "price": "25.00",
        "regDate": "2023-08-03",
        "regTime": "AFTERNOON",
        "remark": "已满",
        "rest": false,
        "scheduleId": "482142:P",
        "testField": "1037800243:",
        "totalNum": 0,
        "weekDay": "周四"
      },
      {
        "leftNum": 0,
        "outCallType": "DEPT_COMMON",
        "price": "25.00",
        "regDate": "2023-08-04",
        "regTime": "MORNING",
        "remark": "已满",
        "rest": false,
        "scheduleId": "482733:A",
        "testField": "1037800243:",
        "totalNum": 0,
        "weekDay": "周五"
      },
      {
        "leftNum": 0,
        "outCallType": "DEPT_COMMON",
        "price": "25.00",
        "regDate": "2023-08-04",
        "regTime": "AFTERNOON",
        "remark": "已满",
        "rest": false,
        "scheduleId": "482734:P",
        "testField": "1037800243:",
        "totalNum": 0,
        "weekDay": "周五"
      }
    ],
    "regScheduleVOList": [],
    "shortPinyin": "(NFMK)SZFYMZ",
    "sortOrder": 0,
    "type": "COMMON"
  },
  "errMsg": "获得普通排班情况成功!",
  "success": true
}

这是一个典型的把相关信息序列化成json然后回复在http body中的方式。而且这json回复的基本猜猜就懂是什么意思了。

3.2 刷票请求构造

既然这个后台也没做登陆机制,也没做cookies或者jwt会话管理验证机制,那么这里的刷票可就不繁琐的。正统的刷票过程一般一定要有登陆,拿到会话token,还需要实现token的刷新机制。但这里很厉害,啥都不要。也就是你会自动化构建http请求就行了。

构建http请求的方式可不要太多,人生苦短,我用python。来吧,上代码。

import time
import requests
import json
# import utils.sms as sms
import random


def eryuan():
    '''
    This function takes a URL as an argument and returns the corresponding
    eryuan number.
    '''
    url = 'https://mobiles.zhicall.cn/mobile-web/mobile/schedule/new/10361/dept/1037800243/info'  # 替换为实际的API URL

    headers = {
    'Host': 'mobiles.zhicall.cn',
    'Data-Sign': 'b475379efffb0ae96fd17b576d9edf53',
    'Accept': '*/*',
    'from': '0',
    'hospitalId': '10361',
    'Accept-Encoding': 'gzip, deflate, br',
    'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
    'Content-Type': 'application/x-www-form-urlencoded',
    'Content-Length': '91',
    'User-Agent': 'iPhone14,5(iOS/16.5.1) Uninview(Uninview/1.0.0) Weex/0.26.0 1125x2436',
    'Connection': 'keep-alive',
    }

    payload = 'fetchType=SCHEDULE_RESERVATION&expertType=SPECIALIST&hospitalId=10361&agencyId=10361&from=0'

    response = requests.post(url,headers=headers, json=payload)


    # 解析HTTP响应
    if response.status_code == 200:  # 检查响应状态码
        data = response.json()  # 解析JSON数据
        # 提取特定字段
        if 'data' in data and 'regNewScheduleVOList' in data['data']:
            res = data['data']['regNewScheduleVOList']
            print(res)
            for item  in res:
                if item['regTime'] == 'AFTERNOON':
                    if item['regDate'] == '2023-07-12' or item['regDate'] == '2023-07-13' or item['regDate'] == '2023-07-14':
                        resNum = int(item['leftNum'])  
                        if (resNum > resNum):
                            # sms.SMSIdentify.main(('13456789123', '抢到票嘞!' + item['regDate'] + str(resNum)))
                            print(item['regDate'] + ' yes!')
                        else:
                            # sms.SMSIdentify.main(('13456789123',str(resNum)))
                            print(item['regDate'] + ' No')
        else:
            print("未找到特定字段")    
        # 处理响应数据
        # print(data)
    else:
        print('请求失败:', response.status_code)

if __name__ == '__main__':
    while True:
        eryuan()
        time.sleep(random.uniform(3.0, 5.0))

由于博主有一个短信猫,所以这里仅仅写到刷到了有余票就自动给我自己发一条短信了。没有短信猫的,可以搞个自动发邮件给自己邮箱,大部分手机也有自动代收邮件的功能,基本也能做到1分钟以内的消息推送了。

4. 进一步折腾

如果能做到自动下单,岂不是更好。的确,我们继续分析一下他们的请求包里都有哪些东西。

POST https://mobiles.zhicall.cn/mobile-web/mobile/patient/get/familyMembers/true/_这里是5个大写A到F的字符,为了安全就不展示了/10356 HTTP/1.1

Host: mobiles.zhicall.cn
Data-Sign: ab421bfd4003f09c63a6cdb344b2192a
Accept: */*
from: 0
hospitalId: 10356
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh-Hans;q=0.9
token: 13456789123  
Content-Type: application/x-www-form-urlencoded
Content-Length: 38
User-Agent: iPhone14,5(iOS/16.5.1) Uninview(Uninview/1.0.0) Weex/0.26.0 1125x2436
Connection: keep-alive

hospitalId=10356&agencyId=10356&from=0


这里请求体里东西很少,就只有一个医院的ID号,这个平台看来不止承接一家医院。主要的信息都在请求头里,唯一看起来有点麻烦的就是Data-Sign这个东西,里面看起来是一个没意义的字符串。这种大概率还是sha256或者md5值。这个值虽然在我们上面的登陆和查看余额请求中不填都可以,但因为我们没做抢票的自动化,所以也许Data-Sign 这个值在抢票的时候是需要的。

这个时候应该怎么办呢?

别忘了,我们的外包团队贴心的帮我们写好了Data-Sign相关的脚本,那个里面肯定有请求构造代码的。

// app-service.js部分代码截取
,s["hospitalId"]||d||(s["hospitalId"]=l.globalData.hospitalId);var m={"content-type":v[r]};return Object.assign(m,u),c&&(m["Data-Sign"]=encryptmd5(JSON.stringify(this.objToString(s)))),new Promise((function(i,n){uni.request({url:g,data:s,method:"POST",

这里截取部分代码,我们清晰的看到了,嗯,就是md5,这函数的名字写的是真的好。那么这个md5值是md5的什么东西呢?后面也告诉我们了,是把一个json串md5了一把。那么这里的json串到底是什么呢?我们注意下,最后uni.request,这个data就是s,然后encryptmd5这个函数也就是把body中的http url语法用json换了一下。

也就是对于上述请求中的 Data-Sign: ab421bfd4003f09c63a6cdb344b2192a 这个值,实际上是 hospitalId=10356&agencyId=10356&from=0的json表达。

验证一下猜想,构造json串

{"hospitalId":"10356","agencyId":"10356","from":"0"}

然后去md5网站随便搜一下

ab421bfd4003f09c63a6cdb344b2192a

好家伙,一模一样。所有请求的重要部分目前已经都弄通了,里面的cookies无非是服务器提供的会话缓存,后面请求的时候存一下就好,估计还是自动化下单需要缓存这些。

5. 总结

本文探究了某某某儿科医院APP的挂号请求全流程,探究了如何从猜想,到拆包APP,找到验证思路。通过抓包逐步了解问题,通过重放逐渐了解后台服务器运作逻辑,通过前端代码猜想请求内容填写的全过程。

本文遗留了自动化抢票部分的实现,但现有信息已经完全能做到自动化抢单,然后推送邮件告知手机要进行付款,即可完成自动抢票,碍于时间限制,目前暂未进行下去。

本文涉及工具推荐:

工具名 工具作用 适用平台
Stream 手机APP抓包 iOS
Finder macOS自带APP包解析 macOS
RestClient 便捷的vscode虚拟发http请求插件 macOS,Windows,linux跨平台

你可能感兴趣的:(python,抓包,重放攻击)