1. 背景
近期到了暑假,儿保的票是越来越难抢了。卡着点也不能刷得到,有天偶然打开发现某个热门门诊突然有一个票,然后就帮人挂到了。琢磨一下,这种不是秒杀的抢票,如果能把所有取消的捡漏刷到,其实问题也不算大。毕竟这软件的放票规则是一周前就放了,一周内总是有人会偶尔取消一把的。
心随所至,动手就干。一般这种医院机构的APP,都是外包实现的,而且费用预算有限,承接方甚至会逐层转包,最终能开发者能拿到的费用不会很高。费用低到一定程度的时候,他们只会考虑功能实现,不会考虑太多安全性问题。应该简单的抢票不会太难。
2. 分析
大多数的APP小程序的架构都是使用前后端分离的架构,也就是说前端界面由前端开发,后端数据由后端开发。这样的架构方便实现跨平台,APP,WEB,小程序都可以公用一套后端系统,前端代码使用的跨平台工具的话,前端也只需要实现一套代码,然后在不同的平台上发布即可。
一个很有代表性的外包前端开发利器就是UniAPP,可以实现一套前端代码,实现多端发布 iOS
、Android
、H5
、各种小程序
。前端问题解决后,这里只需要把对应的与后端交互接口用Restful
的方式进行简单封装,即可完成业务开发。
这里后端使用任何一种WEB APP即可实现,比如Node.js
、PHP
、Java
、Python
等等,不要太多。
2.1 猜想验证
某某儿科医院这个APP在 App Store
中可以下载,这里我们可以利用 macOS
也可以安装 iPhone
应用的特点,在安装后,直接显示包内容,从而观察到APP的实现,从而大体验证我们的分析猜想是不是对的。
第一层目录中有 Pandora
文件夹的时候,熟悉 UniAPP
开发的小伙伴应该知道的大差不差了,再进行展开后,看到了UniAPP的各种前缀文件,没的跑了。
而且这个开发团队还是懂得前后端分离的,名字起的都太具有特点了,app-service.js
和 app-view.js
。敢情好,其他的js也不用看了呗,前端界面的代码就是view,和后端交互的接口类不就是 app-service.js
了。谢谢了。
2.2 总结
分析到这里,大体感觉应该大差不差了。接受外包任务的团队为了最低成本的实现多端发布,并且也不会有很多繁复的任务,的确和我们最开始的猜想很近似。那么我们只要通过把 Restful
的内容理解清楚,知道他们怎么组包的,就知道怎么模拟APP抢票的过程了。
3. 动手
既然要了解 APP
和 后端服务器
怎么交互,那么就一定要抓包,电脑上抓包都知道用 Wireshark
或者 tcpdump
,手机咋抓包呢? 这里iOS
一般推荐这个 Stream
免费好用。
这里抓包细节就不展开了,偏离主题了。直接展现抓包结果。
好家伙,猜到了前后端,但没猜到这里的所有实现居然都是明文,请求头,请求体都是明文。咳咳咳。是不是我搞错了。这里重放一下请求。
#### 登陆
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跨平台 |