纷享销客考勤打卡分析

又到一年一度的剁手节了,正当各位紧张地买买买的时候,我还在搬砖。

事出有因,我们公司是用纷享销客来做考勤管理的,每个月统计一次考勤数据出来,各自签字画押。

就在昨天,人事妹子拿表来给我核对自己的考勤情况,我当时那是胸有成竹波澜不惊啊,我平时都这么准时上班,肯定不会迟到啦。正所谓百密一疏有容乃大,天网恢恢甜而不腻,上班倒是没迟到,可那个醒目的【缺卡】是怎么回事!

打开销客APP看一看上个月的记录,还真是有一天没签退,本月也有一次没签退……

我当时那个气啊,怎么就这么健忘!于是……

首先了解一下基本的考勤规则,从人事妹子发出的考勤规则公告得知:早上8:30打卡签到,可以提早2小时;下午18:00打卡签退,可以1小时之内签退;打卡地点必须在公司方圆200米内才可以打得上。

然后就是想一想有什么解决方案,第一时间想到的就是当年用三婶的时候Android有模拟位置的Xposed,把自己手机所在位置任意改…

想法是好的,先不说iOS有没有位置模拟,重要的是你要对症下药呀,假如你下班超过了1小时都想不起来自己没签退,那即使可以模拟位置也是回天乏术啦!

所以嘛,要的是下班到点就自动签退对不对?想要自动签到的都是坏人,我这种不迟到的好员工怎么可能需要这种东西!

抓包分析

于是我就试试看能不能实施MitM,分析一下iOS版本的纷享销客APP它自己是怎么打卡的,要是不能,就认命吧!

在macOS上,我是用Charles来抓HTTP包的。如何抓手机的包这个基础我就不提了,要注意的是抓HTTPS的包需要安装Charles的根证书到手机里,不仅描述文件需要添加,证书信任设置里也要打开完全信任。

还好销客APP没用Cert Pinning,但是遇到小坑,Charles抓到的请求居然直接显示IP,而不是fxiaoke.com的域名……

随便看一个请求

先看看我昨天的打卡情况嘛…

发出的请求:

POST /FHE/EM1AKaoQinV2/dataAppService/getDailyInfo/iOS.602001?_vn=602001&_ov=10.3.3&_postid=284647704&traceId=E-E.zsdj.1008-071898E1-9A9E-47F4-A797-C2BA4FC24343 HTTP/1.1
Host: www.fxiaoke.com
Accept: */*
Content-Type: application/xml
Connection: keep-alive
Cookie: FSAuthXC=0G6092DPPG40001; _ga=GA1.2.1657137018.1503036404; fs_token=4mDcDbE6DaDJbY; FSAuthXC=0G6092DPPG40001uXKotdDNCLSqk; sso_token=7a5c3355-f184-4125-1234-f6121e6664e7; guidm=d20ebc39-e28d-b6b3-b816-915e94292042; FSAuthX=0G6092DPPG40001uXKotdDNCLSqk; FSAuthXG=F6azwBSTET2xVUjnvv5Hx4nXVF1
Accept-Language: zh-cn
Content-Length: 154
Accept-Encoding: gzip, deflate
User-Agent: FaciShare/6.0.2.2 CFNetwork/811.5.4 Darwin/16.7.0

<?xml version="1.0"?>
<FHE><Tickets/><PostId>4237847003</PostId><Data DataType="Json/P">{
"M10" : "2017-11-09",
"M11" : 0,
"M4" : -1
}</Data></FHE>

收到的响应:

HTTP/1.1 200 OK
Date: Sat, 11 Nov 2017 09:43:28 GMT
Content-Type: application/xml;charset=UTF-8
Vary: Accept-Encoding
Server: FS_SRV
Content-Encoding: gzip
Transfer-Encoding: chunked
Connection: Keep-alive

<?xml version="1.0" encoding="UTF-8"?>
<FHE><Result Status="0" Code="0" /><UserInfo EID="1008" EA="zsdj" /><Data DataType="Json/P">{"M1":1510393408233,"M10":{"M1":"59f29cb02ae21a14ff025945","M10":1,"M11":600000,"M12":0,"M13":0,"M2":"ZSDJSY公司","M3":0,"M4":[{"M1":"chuangke","M2":"aa:bb:cc:dd:ee:ff","M3":"创客WIFI"}],"M5":[{"M1":113.4022903442383,"M2":22.50924873352051,"M3":"盛景坊","M4":200},{"M1":113.4022750854492,"M2":22.50972175598145,"M3":"盛景尚峰5座","M4":200}],"M7":1,"M8":3600000,"M9":21600000},"M11":{"M1":"2017-11-09","M2":1,"M3":[{"M1":"08:30","M2":"18:00","M3":0,"M4":1,"M5":1,"M6":0,"M7":"5a039e2aade89bab3888e519","M8":"5a042a01466299d3ff249fb1","M9":0}],"M4":28800000},"M12":[{"M1":"5a039e2aade89bab3888e519","M10":0,"M11":0,"M12":0,"M13":0,"M14":0,"M17":"","M18":"","M19":1,"M2":0,"M20":0,"M21":0,"M22":false,"M23":0,"M25":0,"M3":1510186500000,"M4":"2017-11-09","M5":113.4040679931641,"M6":22.51223373413086,"M7":"盛景尚峰5座","M8":"","M9":""},{"M1":"5a042a01466299d3ff249fb1","M10":0,"M11":0,"M12":0,"M13":0,"M14":0,"M17":"","M18":"","M19":1,"M2":1,"M20":0,"M21":0,"M22":false,"M23":0,"M25":0,"M3":1510222320000,"M4":"2017-11-09","M5":113.402702331543,"M6":22.50938606262207,"M7":"盛景坊","M8":"DeliteIT_5G","M9":"11:22:33:44:55:66"}],"M13":[],"M14":true,"M15":-1,"M16":true,"M17":30420000,"M18":"2017-11-09","M19":0.5,"M20":0,"M21":0,"M22":{"M1":"function matchWifi(p){var w=JSON.parse(p);var lw=w.local;var sw=w.server;for(var i=0;i&lt;sw.length;i++){for(var j=0;j&lt;lw.length;j++){var smacs=sw[i].mac.split(\":\");var lmacs=lw[j].mac.split(\":\");if(smacs.length!=lmacs.length||smacs.length==0){continue}var count=0;for(var k=0;k&lt;smacs.length;k++){if(parseInt(smacs[k],16)!=parseInt(lmacs[k],16)){count++}}if(count&lt;2){var result={\"code\":1,\"serverSsid\":sw[i].ssid,\"serverMac\":sw[i].mac,\"desc\":sw[i].desc,\"localSsid\":lw[j].ssid,\"localMac\":lw[j].mac};return JSON.stringify(result)}}}var result={\"code\":0};return JSON.stringify(result)};","M2":1},"M26":0}</Data></FHE>

请求头带有一堆cookie,就是验证用户身份用的咯。而请求url参数有4个,凭我的无级英语猜一下,vn应该是暗夜猎手的简称,ov也许是蓝绿手机厂的统称,traceId应该是用来跟踪用户这次操作的(用来做操作日志审计什么的吧),_postid就不知道了,不过不要紧我们多观察几次请求,看看不同的请求,参数会有什么变化。

请求体就像黑夜中的萤火虫一样,你一眼就能看出来是个xml,有个PostId节点,但是和请求url参数那个不是亲戚,没什么关系;有趣的是DataType的内容,明显是我们的行为相关的参数,我这个请求是查看11-09的打卡情况。

我们来看看响应体,没错,我们关心的是DataType,是一段JSON,大意就是我们的签到情况咯。

分析签退请求

好,我们来看重点,我这是下班之后才测试的签退请求。

POST /FHE/EM1AKaoQinV2/dataAppService/createCheckins/iOS.602001?_vn=602001&_ov=10.3.3&_postid=1659419951&traceId=E-E.zsdj.1008-8E59C9B7-6525-480B-9277-4850DC33DD27 HTTP/1.1
Host: www.fxiaoke.com
Accept: */*
Content-Type: application/xml
Connection: keep-alive
Cookie: FSAuthXC=0G6092DPPG40001; _ga=GA1.2.1657137018.1503036404; fs_token=4mDcDbE6DaDJbY; FSAuthXC=0G6092DPPG40001uXKotdDNCLSqk; sso_token=7a5c3355-f184-4125-1234-f6121e6664e7; guidm=d20ebc39-e28d-b6b3-b816-915e94292042; FSAuthX=0G6092DPPG40001uXKotdDNCLSqk; FSAuthXG=F6azwBSTET2xVUjnvv5Hx4nXVF1
Accept-Language: zh-cn
Content-Length: 359
Accept-Encoding: gzip, deflate
User-Agent: FaciShare/6.0.2.2 CFNetwork/811.5.4 Darwin/16.7.0

<?xml version="1.0"?>
<FHE><Tickets/><PostId>683032757</PostId><Data DataType="Json/P">{
"M16" : "11:22:33:44:55:66",
"M11" : 1,
"M25" : 0,
"M20" : "",
"M19" : "2017-11-10",
"M14" : "&#x76DB;&#x666F;&#x574A;",
"M17" : 0,
"M12" : 22.50913429260254,
"M15" : "DeliteIT_5G",
"M21" : 0,
"M18" : "",
"M13" : 113.4022064208984
}</Data></FHE>

哇塞,url参数里面的暗夜猎手和蓝绿厂都没变嘛,要是你有我这么聪明的话,就猜出他们的真正含义了:

  • _vn指的是APP的版本号(version number)
  • _ov指的是系统版本号(os version)
  • _postid是个9至10位的随机数,还有可能是负数
  • traceId是含有公司代号和用户账号,后面跟一个随机guid组合而成的

再看看请求体里面的JSON:

  • M16是签到WiFi的mac地址
  • M111表示签退,为0表示签到(请不要问好员工如何得知签到值的)
  • M19就是签到日期,M14就是签到地点POI的名称Unicode表示了,这里是【盛景坊】
  • M12和M13就是纬度经度
  • M15是签到WiFi的SSID(无线热点名称)

其他参数就猜不出了,真想搞懂的话,请反汇编APP。

收到的服务器响应倒是平平无奇:

HTTP/1.1 200 OK
Date: Fri, 10 Nov 2017 10:13:51 GMT
Content-Type: application/xml;charset=UTF-8
Vary: Accept-Encoding
Server: FS_SRV
Content-Encoding: gzip
Transfer-Encoding: chunked
Connection: Keep-alive

<?xml version="1.0" encoding="UTF-8"?>
<FHE><Result Status="0" Code="0" /><UserInfo EID="1008" EA="zsdj" /><Data DataType="Json/P">
{"M1":1,"M10":{"M1":"2017-11-10","M2":1,"M3":[{"M1":"08:30","M2":"18:00","M3":0,"M4":1,"M5":1,"M6":0,"M7":"5a04eecd27ad91520ca23718","M8":"5a057bdfade89b24eb2b3737","M9":0}],"M4":28800000},"M11":[{"M1":"5a04eecd27ad91520ca23718","M10":0,"M11":0,"M12":0,"M13":0,"M14":0,"M17":"","M18":"","M19":1,"M2":0,"M20":0,"M21":0,"M22":true,"M23":0,"M25":1,"M3":1510272660000,"M4":"2017-11-10","M5":113.4048309326172,"M6":22.51242065429688,"M7":"盛景尚峰5座","M8":"","M9":""},{"M1":"5a057bdfade89b24eb2b3737","M10":0,"M11":0,"M12":0,"M13":0,"M14":0,"M17":"","M18":"","M19":1,"M2":1,"M20":0,"M21":0,"M22":true,"M23":0,"M25":0,"M3":1510308780000,"M4":"2017-11-10","M5":113.4022064208984,"M6":22.50913429260254,"M7":"盛景坊","M8":"DeliteIT_5G","M9":"11:22:33:44:55:66"}],"M12":0,"M13":[],"M14":true,"M15":30720000,"M17":{"M1":"59f29cb02ae21a14ff025945","M10":1,"M11":600000,"M12":0,"M13":0,"M2":"DJ科技公司","M3":0,"M4":[{"M1":"chuangke","M2":"aa:bb:cc:dd:ee:ff","M3":"创客WIFI"}],"M5":[{"M1":113.4022903442383,"M2":22.50924873352051,"M3":"盛景坊","M4":200},{"M1":113.4022750854492,"M2":22.50972175598145,"M3":"盛景尚峰5座","M4":200}],"M7":1,"M8":3600000,"M9":21600000},"M2":"有志者,事竟成","M26":0,"M3":1510308831373}
</Data></FHE>

感兴趣的就是Result节点,code为0表示一切正常,否则会伴随一个Msg来提示错误。

然后就是JSON数据了,格式化之后上百行,关注最外层的M2提示语和M3日期时间就好了。

测试手工签退请求

分析完之后,就应该手动地用Charles模仿一个请求发给服务器看看能不能成功嘛!

在Charles里右击这个签退请求,点击Compose,然后把url参数的_postid随便搞10个数字,把traceId后面的guid段随便改几个数字或字母,把请求体xml里面的PostId也随便搞9个数字,把M12和M13坐标的小数点后第5位之后的数字随便改改,让打卡地点稍微偏移个十来米,最后点击Execute,在电光火石的那么几十ms之后服务器就返回响应了,M2的值让我看了忍不住热血沸腾,什么【无论走多远,不要忘初心】【“抵达”是一种勇敢】之类的。

这充分证实了请求参数全部蒙对,接下来只要把上面这个手动的过程变得自动化,我的大胆想法就可以实现了!

代码实现签退

我的大胆想法是:

  1. 每逢周一至周五下午18:05,自动帮我签退
  2. 即使我没开电脑
  3. 即使我手机没电

多么淳朴好员工!多么单纯的想法啊!

第2第3点决定了我需要一台24小时运行的电脑,那就需要一台服务器呗?
第1点决定了我需要一个可以被计划任务周期性执行的程序。

想起手头还有朋友的一台Amazon EC2免费VPS,再加上人生苦短……

目前还不知道纷享销客有没有一种检查签到客户端IP所在地的机制,比如服务器在米国发起一次打卡,那么可以肯定这次打卡是异常的。如果真的有这种检测,要么就用国内的免费云服务器,要么就自己电脑本地计划任务跑程序了(导致想法第2点被放弃)。

但是话说回来,要是我挂了梯子再签到,理论上我也是他国IP打卡的,不应该算异常,所以嘛,等把程序部署上去不就知道了?

题外话说太多了,下面来简单看看如何实现,面向Google编程。

代码都是Python3的,我用requests来做网络请求,用xml.etree.ElementTree做XML解析。

请求头

由于我没有实现销客的登录,所以用户身份信息的cookie当然是直接从抓包的请求里面提取出来,然后要长得像APP发出的请求,User-Agent必不可少啦!

# 请求头,自行拦截获取cookie然后填上来吧
def getHeader():
headers = {}
cookie = "FSAuthXC=0G6092DPPG40001; _ga=GA1.2.1657137018.1503036404; fs_token=4mDcDbE6DaDJbY; FSAuthXC=0G6092DPPG40001uXKotdDNCLSqk; sso_token=7a5c3355-f184-4125-1234-f6121e6664e7; guidm=d20ebc39-e28d-b6b3-b816-915e94292042; FSAuthX=0G6092DPPG40001uXKotdDNCLSqk; FSAuthXG=F6azwBSTET2xVUjnvv5Hx4nXVF1"
headers["User-Agent"] = "FaciShare/6.0.2.2 CFNetwork/811.5.4 Darwin/16.7.0"
headers["Content-Type"] = "application/xml"
headers["Cookie"] = cookie
return headers

请求URL参数

请求参数我需要生成N位随机数的方法,Google来一个:

# 生成指定长度随机数
def random_with_N_digits(n):
range_start = 10**(n-1)
range_end = (10**n)-1
return random.randint(range_start, range_end)

然后就可以愉快地拼接参数了:

# 请求参数
def setURLWithParams(url):
traceID = "E-E.zsdj.1008-" + str(uuid.uuid4()) # 前面那一截应该是账号所属公司标识, 1008应该是用户账号
postID = ["", "-"][random.randint(0,1)] + str(random_with_N_digits(9))
vn = version # 销客软件版本
ov = "10.3.3" # 手机系统版本 os_version
return f"{url}{version}?_ov={ov}&_vn={vn}&_postid={postID}&traceId={traceID}"

请求体

请求体就只要注意一下经纬度小范围随机变动,还有获取今天的日期,公司WiFi的SSID,mac地址什么的(可以从本文最开头的那个获取打卡信息的接口响应拿到)

# 请求体 (forCheckIn 是否签到,默认签退)
def getBody(forCheckIn = False):
# 打卡类型 - 0签到 1签退
checkIn = 0 if forCheckIn else 1
# 打卡日期 2017-11-10
date = datetime.datetime.now().strftime("%Y-%m-%d")
# 纬度 22.50913429260254
latitude = 22.5091 + random.random() / 10000
# 经度 113.4022064208984
longitude = 113.4022 + random.random() / 10000
# 随机取一个WiFi热点
randomSSID = random.randint(0,2)
# WiFi热点名称
ssid = ["chuangke", "DeliteIT_5G", "DeliteIT_2.4G"][randomSSID]
# WiFi热点路由器的mac地址
mac = ["aa:bb:cc:dd:ee:ff", "11:22:33:44:55:66", "11:22:33:44:55:66"][randomSSID]
content = {
"M16" : mac,
"M11" : checkIn,
"M25" : 0,
"M20" : "",
"M19" : date,
"M14" : "&#x76DB;&#x666F;&#x574A;", # 打卡地点名称Unicode,这里是 盛景坊
"M17" : 0,
"M12" : latitude,
"M15" : ssid,
"M21" : 0,
"M18" : "",
"M13" : longitude
}
return '<?xml version="1.0"?><FHE><Tickets/><PostId>' + str(random_with_N_digits(9)) + '</PostId><Data DataType="Json/P">' + json.dumps(content) + '</Data></FHE>'

发起请求

把一个请求所需的所有东西准备好,就可以开始啦!

# 签到、签退 - type: 1签到 2签退 3重新签到 4重新签退
def checkInOut(type):
urls = { 'createCheckIn': 'http://www.fxiaoke.com/FHE/EM1AKaoQinV2/dataAppService/createCheckins/iOS.', #签到签退
'updateCheckIn': 'http://www.fxiaoke.com/FHE/EM1AKaoQinV2/dataAppService/updateCheckins/iOS.' #重签
}
if type < 3:
url = urls['createCheckIn']
else:
url = urls['updateCheckIn']
# 是否签到
isCheckIn = (type % 2 == 1)
url = setURLWithParams(url) # 拼接请求参数
body = getBody(forCheckIn=isCheckIn) # 请求体
print("发出请求:" + body)
r = requests.post(url, data=body, headers=getHeader())
result = parseCheckInOutResponse(r.text)
print(result)

分析响应

请求成功的话,服务器会返回东西给你咯,我提取M2和时间出来显示:

# 分析响应
def parseCheckInOutResponse(xml):
print("\n返回:" + xml)
root = ET.fromstring(xml)
jsonStr = root.find('Data').text # 返回的JSON
resDict = json.loads(jsonStr)
resultInfo = root.find('Result')
# 如果没有M2,那就是有错误,错误在Result节点的Msg属性里
slogan = resDict.get("M2", resultInfo.get('Msg'))
timestamp = int(resDict.get("M3", 0)) / 1000
timeArray = time.localtime(timestamp)
datetimeStr = time.strftime("%Y-%m-%d %H:%M:%S", timeArray)
return f"打卡结果:{datetimeStr} {slogan}"

跑起来

写上main就可以跑起来啦:

if __name__ == "__main__":

# 立即打卡 1签到 2签退 3重新签到 4重新签退
# 3和4 暂时未实现(懒),需要先获取考勤信息,然后提取出你需要重签那个记录的M1赋值到重签请求体的M10
# checkInOut(type = 1)
if len(sys.argv) != 2:
print("请用 $ xxx.py n 执行,n取值: 1签到 2签退 ")
sys.exit(0)
print("\n运行...\n")
if sys.argv[1] == "1":
print("===== 开始签到 =====")
checkInOut(type = 1)
elif sys.argv[1] == "2":
print("===== 开始签退 =====")
checkInOut(type = 2)
else:
print("不识别的操作 - ", sys.argv[1])

优化一下

有时候临时有点事提早一个小时下班,已经手动签退,但是程序还是定时在18:05再签退一次的话,场面将会十分尴尬。

所以我想想应该先获取当天的打卡信息,看看是否已有签退了,有的话,程序就不要再多管闲事了。

获取某天的打卡信息:

# 获取某天的打卡情况,不指定日期则是今天,返回 (是否有签到, 是否有签退)
def getAttendance(date=''):
url = "http://www.fxiaoke.com/FHE/EM1AKaoQinV2/dataAppService/getDailyInfo/iOS."
url = setURLWithParams(url) # 拼接请求参数
today = datetime.datetime.now().strftime("%Y-%m-%d") if date=='' else date
body = '<?xml version="1.0"?><FHE><Tickets/><PostId>' + str(random_with_N_digits(9)) + '</PostId><Data DataType="Json/P">{ "M10" : "' + today + '", "M11" : 1, "M4" : 1 }</Data></FHE>'
r = requests.post(url, data=body, headers=getHeader())
hasCheckIn = 0 # 是否有签到
hasCheckOut = 0 # 是否有签退
try:
root = ET.fromstring(r.text) # 返回的XML
jsonStr = root.find('Data').text # 返回的JSON
resDict = json.loads(jsonStr)
resultInfo = root.find('Result')
attRecords = resDict['M12'] # 打卡数据
print(attRecords)
if len(attRecords) == 0:
return hasCheckIn == 1, hasCheckOut == 1
for att in attRecords:
if hasCheckIn == 1 and hasCheckOut == 1: break
if att['M2'] == 0:
hasCheckIn = 1
elif att['M2'] == 1:
hasCheckOut = 1
except Exception as e:
print(e)
return hasCheckIn == 1, hasCheckOut == 1

main就改成这样子:

if __name__ == "__main__":

# 立即打卡 1签到 2签退 3重新签到 4重新签退
# 3和4 暂时未实现(懒),需要先获取考勤信息,然后提取出你需要重签那个记录的M1赋值到重签请求体的M10
# checkInOut(type = 1)
if len(sys.argv) != 2:
print("请用 $ xxx.py n 执行,n取值: 1签到 2签退 ")
sys.exit(0)

print("\n运行...\n")

hasCheckIn, hasCheckOut = getAttendance() # 先查询一下,是否已经有 签到 和 签退,防止重复打卡
if sys.argv[1] == "1":
if hasCheckIn:
print("今天已经签到了,不必再签!")
else:
print("===== 开始签到 =====")
checkInOut(type = 1)
elif sys.argv[1] == "2":
if hasCheckOut:
print("今天已经签退了,不必再签!")
elif not hasCheckIn:
print("今天你没签到,谁知道你是不是放假,我不帮你签退哦!")
else:
print("===== 开始签退 =====")
checkInOut(type = 2)
else:
print("不识别的操作 - ", sys.argv[1])

部署到服务器定时执行

ssh连接到AWS EC2服务器,然后scp把脚本传过去,然后试着跑一下,没问题再设置定时任务。

crontab的命令我也不多介绍了,按套路写就是了.

每逢周一至周五的18:05执行fxiaoke.py 2(签退):

5 18 * * 1-5 /usr/local/python3/bin/python3 /home/ec2-user/fxiaoke/fxiaoke.py 2