攻克 12306 前端加密算法
郑重声明: 本文仅供学习使用,禁止用于非法用途,否则后果自负,如有侵权,烦请告知删除,谢谢合作!

开篇明义

本文针对自主开发抢票脚本在抢票过程中常常遇到的请求无效等问题,简单分析了 12306 网站的前端加密算法,更准确的说,是探究 RAIL_DEVICEID 的生成过程.
因为该 cookie 值是抢票请求的核心基础,没有它将无法正确发送请求,或者一段时间后就会到期失效需要重新获取,或者明明更改了浏览器用户代理(navigator.userAgent)标识却还是被限制访问...
因为它并不是真正的客户端标识,只是迷惑性战术,浏览器唯一标识其实是 RAIL_OkLJUJ 而它却被 12306 网站设计者故意没有添加到 cookie ,因此造成了很强的欺骗性,编程真的是一门艺术!
你以为你的爬虫已经可以正常模仿浏览器,殊不知,只要没搞懂谁才是真正的浏览器标识,那么再怎么换马甲也难逃造假事实.
12306-algorithm-web-js-index-cookie-id.png
上图展示了 RAIL_OkLJUJ 的存在位置,可能是为了兼容市面上绝大数浏览器,也可能是为了联合各种前端缓存技术作为特征码,总是除了 cookie 之外,RAIL_OkLJUJ 存在于 Local Storage , Session Storage , IndexedDBWeb SQL 等.
值得注意的是,cookie 中故意没有设置 RAIL_OkLJUJ ,如果清空全部缓存后再次刷新网页,你就会发现 RAIL_DEVICEID 已经发生变化了而 RAIL_OkLJUJ 依旧没变!
12306-algorithm-web-js-index-cookie-RAIL_DEVICEID.png
下面简单验证一下说明谁才是真正的浏览器唯一标识:
    step 1 : 复制当前获取到的 RAIL_DEVICEIDRAIL_OkLJUJ 的值
打开控制台(Console),通过 js 代码方式取出本地存储(localStorage) 的值:
1
localStorage.getItem("RAIL_DEVICEID");
2
3
localStorage.getItem("RAIL_OkLJUJ");
Copied!
控制台会立即返回该值,接下来需要手动复制到其他地方等待和第二次结果作比较.
但是程序员总是喜欢能偷懒就偷懒,手动复制也懒得复制怎么办?
当然,继续使用js 代码复制了啊!
1
copy('雪之梦技术驿站欢迎您的访问,https://snowdreams1006.cn');
Copied!
比如这句代码就会把文本'雪之梦技术驿站欢迎您的访问,https://snowdreams1006.cn'复制到剪贴板,接下来选择文本编辑器右键粘贴就能看到效果啦!
所以改造一下代码就能复制第一次访问 12306 网站获取到的 RAIL_DEVICEIDRAIL_OkLJUJ 的值.
12306-algorithm-web-js-index-copy-cookie-id.png
1
copy("RAIL_DEVICEID:::"+localStorage.getItem("RAIL_DEVICEID"));
2
// RAIL_DEVICEID:::E5BDkKrPkZ6nuZruqUj9-3lUG1LBM7t9aTDbZwFSdrboaFG6odrWZ9yuphnas4Jwq5E_FXIwwqlRoSXFbJULUiBNwNGt61Ow6Zv0GFXRABipaeDJJ0Ub7G2g_B_aGwMF5DNZ5KJR4eWVl-P3zSHGKbczLB3WN0z-
3
4
copy("RAIL_OkLJUJ:::"+localStorage.getItem("RAIL_OkLJUJ"));
5
// RAIL_OkLJUJ:::FGFOJ75VdD8dQc2yh3yTJf2RBWES6uGI
Copied!
    step 2 : 等待 5 min 后再次获取 RAIL_DEVICEIDRAIL_OkLJUJ 的值
1
copy("RAIL_DEVICEID:::"+localStorage.getItem("RAIL_DEVICEID"));
2
// RAIL_DEVICEID:::VUye37EEUdGHgrpJGo9J95hWMNSIUFPeYBjabDgCiYJbQIr53iVzIPQJwcLhbijL4OyPVGmzolsVEK8Pw7_DG_oPrUDpfbnRe7HvMWMJvU2MAbk-7EwNEePAlpnVb9QVZz4dtOUSCRVbS2zlwgS0xe2BOThpR9oy
3
4
copy("RAIL_OkLJUJ:::"+localStorage.getItem("RAIL_OkLJUJ"));
5
// RAIL_OkLJUJ:::FGFOJ75VdD8dQc2yh3yTJf2RBWES6uGI
Copied!
或者清空网站 cookie 后再次刷新当前网页,总之就是想办法触发浏览器再次运行相关逻辑重新生成 RAIL_DEVICEIDRAIL_OkLJUJ .
    step 3 : 对比第一次和第二次获取到的 RAIL_DEVICEIDRAIL_OkLJUJ 的值
1
RAIL_DEVICEID:::E5BDkKrPkZ6nuZruqUj9-3lUG1LBM7t9aTDbZwFSdrboaFG6odrWZ9yuphnas4Jwq5E_FXIwwqlRoSXFbJULUiBNwNGt61Ow6Zv0GFXRABipaeDJJ0Ub7G2g_B_aGwMF5DNZ5KJR4eWVl-P3zSHGKbczLB3WN0z-
2
3
RAIL_OkLJUJ:::FGFOJ75VdD8dQc2yh3yTJf2RBWES6uGI
4
5
RAIL_DEVICEID:::VUye37EEUdGHgrpJGo9J95hWMNSIUFPeYBjabDgCiYJbQIr53iVzIPQJwcLhbijL4OyPVGmzolsVEK8Pw7_DG_oPrUDpfbnRe7HvMWMJvU2MAbk-7EwNEePAlpnVb9QVZz4dtOUSCRVbS2zlwgS0xe2BOThpR9oy
6
7
RAIL_OkLJUJ:::FGFOJ75VdD8dQc2yh3yTJf2RBWES6uGI
Copied!
显而易见,肉眼直接就能看出两次请求时 RAIL_OkLJUJ 的值并没有变化而 RAIL_DEVICEID 的值很大可能会发生改变.
因此,RAIL_DEVICEID 应该并不是浏览器唯一标识,而 RAIL_OkLJUJ 才是真正的唯一标识!
本文并不适合全部读者,如果你属于以下情况之一,那么本文对你绝对帮助甚多,否则对你来说只能算是浪费生命.
    适合对自主抢票或者脚本抢票有需求的天涯游子
    适合拥有一定 web 前端开发相关知识的开发者
    适合耐得住寂寞能够独自研究加密算法的孤独人
最后的核心前提是有网,当然WiFi更佳,否则流量真的吃不消啊!

故事背景

1
独在异乡为异客 每逢佳节要抢票
2
手动自动一起上 时常掉线心好伤
3
动手实践出真理 原来身份是唯一
4
想要封你没商量 只能动手来伪装
5
加密请求在前端 后端返还控制权
6
还原算法改身份 稳定抢票不担心
7
多种途径齐上阵 车票速速快现身
Copied!
不知道你是否遭遇过一票难求的困境,尽管网络上关于第三方工具的加速包是否加速有过辟谣,但是每逢节假日总是会遇到抢不到车票的问题,大部分人还是会选择买个心理安慰吧!
目前为止,12306 官方线上售票渠道仅仅包括 12306 网站以及手机 app 客户端,因此市面上流行的第三方抢票软件均为非正常途径,而这些第三方渠道中最简单的实现方式应该就算是爬虫技术了.
不论是网页端还是手机端,统统称为客户端,客户端的作用仅仅是传声筒,真正负责执行命令的人就是服务端.
当你提交购票需求时,客户端会把这些车票信息一起打包发送给服务端,如果服务端有票的话,那么有可能就会返回给客户端成功信息,恭喜你订票成功.
但是尽管有票也不一定会给你,唯一确定的是无票一定会失败,总之不管结果如何服务端和客户端总是按照既定的约定协议在默默交流着.....
尽管官方渠道最可靠也最准确,可官方也还是没能给你买到车票啊!
所以想要抢票还是得亲自动手,不能完全依靠官方,这里就诞生了爬虫技术来冒充客户端,想要成功骗过服务端就要先了解真正的客户端到底有哪些特征?
由于本文篇幅有限,暂时不做关于抢票方面的相关论述,直奔重点,讲解 RAIL_DEVICEID 的请求过程,带你一步一步还原 12306 网站的前端加密算法的实现逻辑!

效果预览

事实证明,12306 算法虽然在变但都是小打小闹,根本没有伤筋动骨,所以自己动手改改又能满血复活了哟!
1
{
2
"key": "&FMQw=0&q4f3=zh-CN&VPIf=1&custID=133&VEek=unknown&dzuS=0&yD16=0&EOQP=c227b88b01f5c513710d4b9f16a5ce52&jp76=52d67b2a5aa5e031084733d5006cc664&hAqN=MacIntel&platform=WEB&ks0Q=d22ca0b81584fbea62237b14bd04c866&TeRS=777x1280&tOHY=24xx800x1280&Fvje=i1l1o1s1&q5aJ=-8&wNLf=99115dfb07133750ba677d055874de87&0aew=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.87 Safari/537.36&E3gR=9f7fa43e794048f6193187756181b3b9",
3
"value": "owRJc8M4EkFMvcTkzibRFJoDSkUKCx6N9ictZIJLIeY"
4
}
Copied!
    step 1 : 使用 Chrome 浏览器打开 12306 网站并清空该站点全部缓存数据.
请确保当前正在使用的是谷歌 Chrome 浏览器,IE和 firefox 等浏览器暂未测试.
12306-algorithm-web-js-website-clear-storage.png
    step 2 : 手动清空 window.name 属性,保证浏览器处于首次打开 12306 网站状态.
因为非首次加载会携带上一次的请求信息,不方便学习验证,经过分析试验发现历史状态还保存在 window 对象的 name 属性,因此仅仅清空缓存还不够,还需要手动清空 name 属性的值.
12306-algorithm-web-js-website-clear-name.png
    step 3 : 强制刷新当前页面并保持记录请求信息,过滤请求类型 js ,找到 /otn/HttpZF/logdevice 请求.
在找到该请求保存查询参数名为 hashCode: owRJc8M4EkFMvcTkzibRFJoDSkUKCx6N9ictZIJLIeY ,方便和之后的计算方式生成的结果做对比.
12306-algorithm-web-js-website-find-logdevice.png
除了查询请求信息外,更为重要的是查看响应信息,当初次请求 /otn/HttpZF/logdevice 时除了返回过期时间 expdfp 设备信息之外,还会返回 cookieCode 设备唯一标识.
如果等到过期时间或手动清空站点缓存后,/otn/HttpZF/GetJS 脚本中的相关逻辑会再次发起 /otn/HttpZF/logdevice 请求,那时候的响应内容再也没有 cookieCode 参数了.
让我们再好好看一看初次请求的响应信息吧!
1
callbackFunction('{"exp":"1581948102442","cookieCode":"FGHcXsVmjf3oV0zm5qTDPFt-VcNhuDA-","dfp":"QNCYH1J5E9M7rl97uo_PUR1OSwRTcCe1xdnbX7h2V6Ewcq6kML0qzXD5y11rLv3FPX1ndOnhL_bjVkwwgtWTsHMFums60_4H9Lr-vJzJGq4tkaUEGfRNXN9IJlvptReSBa5PP7N5gxpSOBo-YlF5Ac98f-YlNlxi"}')
Copied!
如果将 callbackFunction() 回调函数去掉,不难发现其实返回数据是 json 格式,格式化后发现响应内容如下:
1
{
2
"exp": "1581948102442",
3
"cookieCode": "FGHcXsVmjf3oV0zm5qTDPFt-VcNhuDA-",
4
"dfp": "QNCYH1J5E9M7rl97uo_PUR1OSwRTcCe1xdnbX7h2V6Ewcq6kML0qzXD5y11rLv3FPX1ndOnhL_bjVkwwgtWTsHMFums60_4H9Lr-vJzJGq4tkaUEGfRNXN9IJlvptReSBa5PP7N5gxpSOBo-YlF5Ac98f-YlNlxi"
5
}
Copied!
这里不得不佩服 12306 的设计思路了,故布疑阵,当你误以为自己已经更新了 RAIL_DEVICEID 的值,实际上 cookieCode 的值才是唯一标识而它恰恰没有设置到 cookie 中去,仅仅作为本地缓存保持了,用于再次请求 RAIL_DEVICEID.
12306-algorithm-web-js-website-cache-OkLJUJ.png
    step 4 : 复制源码实现到控制台,输入 chromeHelper.prototype.encryptedFingerPrintInfo() 获取请求 /otn/HttpZF/logdevice 的查询参数,提取出其中的 value 值和真正的请求参数作对比.
假设真正请求参数 hashcode 的值已设置成变量,chromeHelper.prototype.encryptedFingerPrintInfo().value === hashcode 返回结果 true 说明复现算法实现还在正常运行,否则很可能是相关算法又更新了!
12306-algorithm-web-js-website-generate-compare.png

直奔重点

如果你正在学习自动抢票或者打算研究如何自动抢票,那么我可以负责任得告诉你,RAIL_DEVICEID 的值绝对是绕不过去的坎,堪称 12306 反爬虫技术的最精华手段!
现在目标已经锁定,赶紧动手和我一起去探究 12306 到底是如何处理 RAIL_DEVICEID 的值吧!

无痕模式下访问网站

众所周知,谷歌 Chrome 浏览器是程序员专属的浏览器,是因为提供了强大的开发调试能力,简单网站请求甚至根本不需要借助第三方专业的抓包工具就能独立完成分析整过程.
如果你还没有听说过 Chrome 浏览器或者正在使用其他浏览器,那么建议你先自行下载最新版 Chrome 浏览器,和文章使用一样的工具有助于顺利复现相关步骤,否则遇到莫名奇怪的问题只能自己研究了.
首先打开 Chrome 浏览器的无痕模式,处于无痕模式最大的特点就是不会保存 cookie,在一定程度上对目标网站而言是新用户(主要指的是新的客户端终端).
12306-algorithm-web-js-non-trace-mode.png
输入 12306 官网后,打开开发者控制台(F12或右键检查),选择网络(network)选项卡,确保一直处于监听网咯请求并实时记录状态.
具体而言,最左边的监听状态圆心是红色,保持日志(Preserve log)的复选框已勾选,禁用缓存(Disable cache) 的复选框已勾选,这三者是分析所有网络请求的基础.
12306-algorithm-web-js-network-listen.png
准备工作就绪后开始完整走一遍购票流程,即从首页进入登录页,登录并买票等过程,请求步骤越完整可提供分析的资料越多,也就基本上不会遗漏重要步骤,离真相越逼近.
12306-algorithm-web-js-query-ticket.png
凡是涉及到登录操作,建议先故意输出错误的账号密码等信息,这样有利于登录成功后重定向次数过多而导致无法找到之前的登录请求,如果网络(network)选项卡中的保持日志(Preserve log)的功能没有开启的话,这一现象将会更加严重!
12306-algorithm-web-js-login-wrong.png
再次输入正确的登录信息成功登录后进行买票行为等操作,但是无需付款,只要正常操作到下单完成即可视为整个购票流程.
12306-algorithm-web-js-purse-ticket.png
12306-algorithm-web-js-order-ticket.png
下单成功后整个购票流程已经基本完成,接下来开始全局搜索关键字 RAIL_DEVICEID 查看在哪里生成又在何处使用?

全局模糊查找关键字

现在整个购票流程基本上已经完成,接下来开始全局搜索全部请求中是否包含关键字 RAIL_DEVICEID 吧!
首先打开网络(network)选项卡,从左往右数第四个放大镜图标就是搜索功能,输入搜素关键字 RAIL_DEVICEID 会过滤符合条件的网络请求.
12306-algorithm-web-js-network-search.png
不搜不要紧,一搜一大把,只能看出来大部分网络请求都会自动携带该 cookie,反而淹没了到底是哪个网络请求生成的 cookie?
12306-algorithm-web-js-network-search-result.png
所以必须想办法精确搜索,过滤出生成该 cookie 的网络请求,所以接下来的问题就变成了如果 RAIL_DEVICEID 属于后端直接设置的行为,那么这样的网络请求应该长啥样的?
最好的学习就是模仿,假设并不知道真实的设置过程如何,但是我们可以查看其它 cookie 的设置过程啊!
同样地,在网络(network)选项卡选择第三个过滤器漏斗图标,展开网络请求类型,大致分为 All|XHR|JS|CSS|Img|Media|Font|Doc|WS|Manifest|Other等类型.
简单说一下网络请求类型的相关含义,整理出表格直观感受一下:
类型
名称
描述
代码
XHR
XHR adn Fetch
ajax异步请求
X-Requested-With: XMLHttpRequest
JS
Scripts
js脚本
Sec-Fetch-Dest: script
CSS
Stylesheets
css样式
Sec-Fetch-Dest: style
Img
Images
图片
Sec-Fetch-Dest: image
Media
Media
音视频媒体
Sec-Fetch-Dest: audio
Font
Fonts
字体
Sec-Fetch-Dest: font
Doc
Documents
html文档
Sec-Fetch-Dest: document
WS
WebSockets
长连接通信
暂无
Manifest
Manifest
版本文件
暂无
由于 12306 暂未包括后两种请求类型,所以无法判断该请求有什么特点,除了 ajax 异步请求外,其余类型的网络请求都是通过请求头 Sec-Fetch-Dest 属性标识的,当然设置浏览器的 cookie 也不例外,只不过大多数是通过服务端进行设置的,也就是网络请求的响应头标志了如何设置 cookie 的行为.
在前端 web 开发的过程中并不是一上来就前后端分离的,很长一段时间内前端页面也是由后端人员完成的,因此好多网站至今为止还保留着新旧交替的痕迹.
在上述网络请求类型中,最能体现这种变化特点就是 XHR 和 Doc 请求,XHR的常见封装实现 之一就是风靡全球的 ajax 异步请求,用于实现无刷新局部更新网页内容,而 Doc 是文档类型,无论是直接输出原生 html 还是使用模板技术动态渲染页面,最终输出展现结果一律是 html 文档,这一类的网络请求最容易设置 cookie 之类的请求,体现了上一代技术的一贯风格,恨不得一个人一次性把全部的活都干完!
但是随着技术的发展进步旧技术暴露的问题越来越多,引起了包括开发者在内的业内重视,各大企业已经逐步开始转变,也就是各司其职,物尽其用.
总结来说,XHR 和 Doc 有如下特点:
    XHR 请求的数据绝大多数工作是在前端方面完成的,后端把相关数据返回给前端接口调用者,前端取到数据后进行业务组装展现.
    XHR 请求绝大多数是异步ajax 请求,优点是当前页面不需要刷新就能看到最新内容,缺点是一旦涉及到相互依赖的业务就会出现请求等待噩梦.
    XHR 请求是又前端发起也就是浏览器主动发出给后端,等后端服务器返回数据后继续由前端完成相关业务,这部分数据传输量极少但非常重要.
    Doc 请求大多数是后端在控制,方便设置各种页面元素的表现形式,但是也不排除前端使用相关的模板引擎结合 XHR 数据在控制生成文档.
    Doc 请求设置包括 cookie 在内的一系列网络行为,转发和重定向更是权限控制的常用做法.
下面我们已包括 RAIL_DEVICEID 关键字的网络请求,简单感受一下两者的差异性.
XHR 请求重点在于如何请求数据和接收数据,主要体现在 Request Data 和 Response Data 两方面,至于请求头一般都是默认设置.
12306-algorithm-web-js-network-search-result-xhr.png
Doc 请求的重点就不一样了,绝大多数请求就是输入网址后自动跳转页面,因而关注的重点应该放在请求头和响应头信息上,因为 cookie 的值就是通过请求头进行发送到后端服务器,后端如需新增或修改 cookie 值就是通过响应头进行设置的.
12306-algorithm-web-js-network-search-result-doc.png
柿子还要先捏软柿子,现在无法判定 RAIL_DEVICEID 到底是服务端直接设置还是客户端自行设置的,而客户端的行为不太直观,所以相对而言还是先捏服务端软柿子吧!
回顾刚才讲解的 Doc 网络请求,不难发现设置 cookie 的行为代码类似如下:
1
Set-Cookie: JSESSIONID=D4CE095F5A21B38DF3389070F1E01FE6; Path=/otn
Copied!
现在找到了学习对象,开始模仿查找类似请求的关键字应该是: Set-Cookie: RAIL_DEVICEID=
12306-algorithm-web-js-network-search-cookie-RAIL_DEVICEID.png
查无结果!
一般情况下出现无结果很可能是以下原因之一:
    恭喜您,真的查无结果,可以换条思路继续探索了.
    很遗憾,当前网络请求数据不足刚好缺失符合条件的请求.
    日了狗,操作不当误输多余符号或者本是关键字搜索实际上却开启了正则匹配等
所以逐一排查以上原因,首先考虑换一个关键字 Set-Cookie: JSESSIONID 能否查找出相应的结果.
12306-algorithm-web-js-network-search-cookie-JSESSIONID.png
事实胜于雄辩,查询过程并没有任何问题而是查询结果真的不存在,如此一来一次性排查两个原因,那么很有可能生成逻辑在于前端而非后端.
接下来只能在众多请求中碰碰运气寻找前端到底是在何处生成的 RAIL_DEVICEID ,然而请求众多还是要稍微讲究一下策略方向的.
既然是前端在控制 cookie 的生成逻辑,那么很有可能是某个 js 文件在起作用,当然也不排除其他类型的文件有操作浏览器行为的能力.
因此,通过分析进一步缩小范围:在请求类型为 JS 的网络请求中查找包括关键字 RAIL_DEVICEID 的全部请求.
12306-algorithm-web-js-network-search-js-cookie-RAIL_DEVICEID.png
理想很丰满,现实太骨感,本以为选中 js 再搜索关键字能够一起生效,结果并没有,请求类型依旧没有过滤出去,还是一大片的请求!
同时随便选中任意请求可以看到此时网络选项卡搜索匹配大结果其实是请求头这些基本信息,应该并没有包括 js 或者 doc 源码,方向错了,走再多路也是浪费时间.
12306-algorithm-web-js-network-search-js-cookie-analysis-RAIL_DEVICEID.png
虽然我不知道你从哪里来,中间经历了什么,但是我知道你的最终归宿一定会落到网络文件系统.
Chrome 浏览器除了可以看出网络请求也能看到最终呈现给用户的文件系统,既然中间过程找不到你,那么我直接到目的地去搜索吧!
打开源码(Source)选项卡,整个面板大致分为三部分,左侧文件数,中间文件区,右侧调试区.
12306-algorithm-web-js-source-workspace-pannel.png
其中左侧的文件结构可以清楚看到当前所处的层级结构,有利于快速掌握项目轮廓.右侧的调试区针对心中有想法但还不确定是否正确提供了非常好的验证工具,调试别人的代码就如本地开发那样边预言边验证.
中间的文件区域面积最大,功能自然也不能太弱,选中左侧具体文件后可以显示源码,方便查看,进而去调试验证心有所想.
所以问题来了,这一切的一切都要源于心有所想才能有行动,那么应该去哪里搜索包括关键字 RAIL_DEVICEID 的文件呢?
一般而言,良好的用户体验是不需要告诉你用户手册的,给你一大堆详尽的说明文档也未必会耐得住去看一篇,先用用再说!
人生苦短,不要浪费太多时间放在无聊的事情上面,简短的三行提醒富含哲理性,第一行告诉你怎么打开文件,第二行告诉你如何运行命令,第三行告诉你如何操作实现什么效果.
如果三行代码还不足以解决你的问题,阅读更多自己慢慢琢磨说明文档吧!
当然,这里和在文件系统中查找包含关键字 RAIL_DEVICEID 的需求最为接近的应该就是第二行,运行命令了,那就试试看吧!
12306-algorithm-web-js-source-command-search.png
输入搜索 search 后果然弹出了相关搜索命令,于是点击开发工具(DevTools)后出现了搜索框,顺理成章输入关键字 RAIL_DEVICEID 进行搜索了啊!
12306-algorithm-web-js-source-search-result.png
终于等到你,还好我没放弃,你就是我的唯一,看样子和 RAIL_DEVICEID 有关的处理逻辑全部都在这么一个 js 文件里,看你还往哪里跑!

直捣黄龙还往哪里跑

找到该文件后点击查看,红蓝黑密密麻麻一大片js 代码,绝对不是给人看的而是给机器看的,想要给人阅读还需要美化一下,将源码丑化混淆成难以阅读的代码也是防止他人偷窥复制拷贝自己的劳动成果,同时也能减少文件大小,加速网络传输数据,让你的网站速度更快一些.
12306-algorithm-web-js-source-getjs-pretty.png
点击中间区域的左下角格式化图标进行美化代码,然后在文件中搜素关键字 RAIL_DEVICEID 定位到具体代码.
非常人性化的是,搜索功能是通用的快捷键 Ctrl + F,现在定位到具体代码,截图留念下,接下来才是真正考验技术的时刻!
12306-algorithm-web-js-source-getjs-search.png
1
$a.getJSON("https://kyfw.12306.cn/otn/HttpZF/logdevice" + ("?algID\x3drblubbXDx3\x26hashCode\x3d" + e + a), null, function(a) {
2
var b = JSON.parse(a);
3
void 0 != lb && lb.postMessage(a, r.parent);
4
for (var d in b)
5
"dfp" == d ? F("RAIL_DEVICEID") != b[d] && (W("RAIL_DEVICEID", b[d], 1E3),
6
c.deviceEc.set("RAIL_DEVICEID", b[d])) : "exp" == d ? W("RAIL_EXPIRATION", b[d], 1E3) : "cookieCode" == d && (c.ec.set("RAIL_OkLJUJ", b[d]),
7
W("RAIL_OkLJUJ", "", 0))
8
})
Copied!

本地备份js方便复现

既然已经找到关键文件,自然需要留存快照进行存档操作,否则哪一天文件更新了都不知道哪里发生变化了,难不成还要从头再分析一遍,我选择差量更新而不是全量覆盖!
选中源文件右键弹出菜单,选择任意一款喜欢的方式复制源文件到本地留作学习备份,准备工作就绪后准备大干一场.
12306-algorithm-web-js-source-getjs-backup.png

高楼大厦寻关键线索

Js文件中关于网络请求最典型的就是异步回调,将原本简单的操作复杂化,非要你等我,我等他,他还等着他的她.
最终直接结果就是整个请求流程反过来了,假设正常流程:是 A->B->C-D-E-F,那么异步请求很可能陷入这样的陷阱: F <- E <- D <- C <- B <- A
所以一层又一层的回调函数真的是难以维护,这种技术也在慢慢淘汰更新成更容易维护的方式,还是不再展开了,回到正题上来,还是先找到程序到底什么时候开始调用的吧!
1
ja.prototype = {
2
initEc: function(a) {
3
var b = ""
4
, c = this
5
, d = void 0 != a && void 0 != a.localAddr ? a.localAddr : "";
6
c.checkWapOrWeb();
7
this.ec.get("RAIL_OkLJUJ", function(a) {
8
b = a;
9
c.getDfpMoreInfo(function() {
10
if (!(9E5 < F("RAIL_EXPIRATION") - (new Date).getTime() & null != F("RAIL_DEVICEID") & void 0 != F("RAIL_DEVICEID") & !c.NeedUpdate())) {
11
for (var a = "", e = "", g = c.getpackStr(b), m = [], q = [], t = [], k = [], n = 0; n < g.length; n++)
12
"new" != g[n].value && -1 == Fb.indexOf(g[n].key) && (-1 != Gb.indexOf(g[n].key) ? q.push(g[n]) : -1 != Ib.indexOf(g[n].key) ? t.push(g[n]) : -1 != Hb.indexOf(g[n].key) ? k.push(g[n]) : m.push(g[n]));
13
g = "";
14
for (n = 0; n < q.length; n++)
15
g = g + q[n].key.charAt(0) + q[n].value;
16
q = "";
17
for (n = 0; n < k.length; n++)
18
q = 0 == n ? q + k[n].value : q + "x" + k[n].value;
19
k = "";
20
for (n = 0; n < t.length; n++)
21
k = 0 == n ? k + t[n].value : k + "x" + t[n].value;
22
m.push(new l("storeDb",g));
23
m.push(new l("srcScreenSize",q));
24
m.push(new l("scrAvailSize",k));
25
"" != d && m.push(new l("localCode",pb(d)));
26
e = c.hashAlg(m, a, e);
27
a = e.key;
28
e = e.value;
29
a += "\x26timestamp\x3d" + (new Date).getTime();
30
$a.getJSON("https://kyfw.12306.cn/otn/HttpZF/logdevice" + ("?algID\x3drblubbXDx3\x26hashCode\x3d" + e + a), null, function(a) {
31
var b = JSON.parse(a);
32
void 0 != lb && lb.postMessage(a, r.parent);
33
for (var d in b)
34
"dfp" == d ? F("RAIL_DEVICEID") != b[d] && (W("RAIL_DEVICEID", b[d], 1E3),
35
c.deviceEc.set("RAIL_DEVICEID", b[d])) : "exp" == d ? W("RAIL_EXPIRATION", b[d], 1E3) : "cookieCode" == d && (c.ec.set("RAIL_OkLJUJ", b[d]),
36
W("RAIL_OkLJUJ", "", 0))
37
})
38
}
39
})
40
}, 1)
41
}
42
}
Copied!
核心代码最外层函数是 initEc 函数,而该函数的写法明显是传统 js 的属性方法,因此判断挂载于该对象的属性方法应该都是完成某些相同的功能.
暂时先不着急继续寻找谁在调用 initEc 函数,先搞懂整个函数结构是什么轮廓.
1
function ja() {
2
this.ec = new evercookie;
3
this.deviceEc = new evercookie;
4
this.cfp = new aa;
5
this.packageString = "";
6
this.moreInfoArray = []
7
}
8
9
ja.prototype = {
10
getScrWidth: function() {
11
return new l("scrWidth",r.screen.width.toString())
12
},
13
...
14
,
15
checkWapOrWeb: function() {
16
return "WindowsPhone" == Ha() || "iOS" == Ha() || "Android" == Ha() ? !0 : !1
17
}
18
}
Copied!
如果熟悉 web 开发,那么你一定不难发现这是标准的面向对象的写法,ja 函数作为构造函数内置了一大堆成员变量,并且在原型链上继承了一大堆方法.
更何况,对象属性中还有三个带有 new 关键字的构造函数,估计也是类似于 ja 这种设计思路,高楼大厦平地起,还原相关算法之路预期并不简单!
但是想一想车票真难抢还动不动访问错误,是可忍孰不可忍,还是要研究算法一劳永逸搞定 RAIL_DEVICEID 的生成逻辑,自己用算法计算实现完美伪装浏览器!
现在以 initEc 函数名继续搜素,寻找到底是谁在调用,轻而易举又找到了新的函数名: getFingerPrint
1
ja.prototype = {
2
getFingerPrint: function() {
3
var a = this;
4
r.RTCPeerConnection || r.webkitRTCPeerConnection || r.mozRTCPeerConnection ? nb(function(b) {
5
a.initEc(b)
6
}) : a.initEc()
7
}
8
}
Copied!
同样地,不再过多停留,继续以 getFingerPrint 为关键字搜索,找到了 Pa 函数,终于不再是 ja 的方法了.
1
function Pa() {
2
if (-1 == F("RAIL_EXPIRATION"))
3
for (var a = 0; 10 > a; a++)
4
G(function() {
5
(new ja).getFingerPrint()
6
}, 20 + 2E3 * Math.pow(a, 2));
7
else
8
(new ja).getFingerPrint();
9
G(function() {
10
r.setInterval(function() {
11
(new ja).getFingerPrint()
12
}, 3E5)
13
}, 3E5)
14
}
Copied!
与此同时,Pa 函数也是 js 文件的第一行代码,来都来了,那就顺便看一眼 js 的整体结构代码吧!
1
(function() {
2
3
})();
Copied!
自执行的匿名函数实现的闭包,这样的好处在于函数内的变量不会污染其他文件,更何况混淆之后的变量名称充斥着大量的变量 a,b,c,d,e,f之类的,不用闭包也不行啊!
现在继续以 Pa 为线索搜索,最终发现了函数入口,除此之外再无其他.
1
var mb = !1;
2
u.addEventListener ? u.addEventListener("DOMContentLoaded", function b() {
3
u.removeEventListener("DOMContentLoaded", b, !1);
4
Pa()
5
}, !1) : u.attachEvent && u.attachEvent("onreadystatechange", function c() {
6
mb || "interactive" != u.readyState && "complete" != u.readyState || (u.detachEvent("onreadystatechange", c),
7
Pa(),
8
mb = !0)
9
})
Copied!
js 是典型的事件驱动型编程语言,当发生什么什么事件后我要干这个,页面加载时我要开始工作了,按钮被点击了我要登录了,页面关闭时我要下班了等等诸如此类的逻辑.
上述代码实现的就是页面元素加载成功后开始执行 Pa() 函数,而 Pa 函数又会执行 (new ja).getFingerPrint() ,紧接着又会执行 initEc 函数.
现在基本流程已经大致清楚了,总结一下基本代码逻辑如下:
1
(function() {
2
var mb = !1;
3
u.addEventListener ? u.addEventListener("DOMContentLoaded", function b() {
4
u.removeEventListener("DOMContentLoaded", b, !1);
5
Pa()
6
}, !1) : u.attachEvent && u.attachEvent("onreadystatechange", function c() {
7
mb || "interactive" != u.readyState && "complete" != u.readyState || (u.detachEvent("onreadystatechange", c),
8
Pa(),
9
mb = !0)
10
})
11
12
function Pa() {
13
if (-1 == F("RAIL_EXPIRATION"))
14
for (var a = 0; 10 > a; a++)
15
G(function() {
16
(new ja).getFingerPrint()
17
}, 20 + 2E3 * Math.pow(a, 2));
18
else
19
(new ja).getFingerPrint();
20
G(function() {
21
r.setInterval(function() {
22
(new ja).getFingerPrint()
23
}, 3E5)
24
}, 3E5)
25
}
26
27
function ja() {
28
this.ec = new evercookie;
29
this.deviceEc = new evercookie;
30
this.cfp = new aa;
31
this.packageString = "";
32
this.moreInfoArray = []
33
}
34
35
ja.prototype = {
36
getFingerPrint: function() {
37
var a = this;
38
r.RTCPeerConnection || r.webkitRTCPeerConnection || r.mozRTCPeerConnection ? nb(function(b) {
39
a.initEc(b)
40
}) : a.initEc()
41
},
42
initEc: function(a) {
43
var b = ""
44
, c = this
45
, d = void 0 != a && void 0 != a.localAddr ? a.localAddr : "";
46
c.checkWapOrWeb();
47
this.ec.get("RAIL_OkLJUJ", function(a) {
48
b = a;
49
c.getDfpMoreInfo(function() {
50
if (!(9E5 < F("RAIL_EXPIRATION") - (new Date).getTime() & null != F("RAIL_DEVICEID") & void 0 != F("RAIL_DEVICEID") & !c.NeedUpdate())) {
51
for (var a = "", e = "", g = c.getpackStr(b), m = [], q = [], t = [], k = [], n = 0; n < g.length; n++)
52
"new" != g[n].value && -1 == Fb.indexOf(g[n].key) && (-1 != Gb.indexOf(g[n].key) ? q.push(g[n]) : -1 != Ib.indexOf(g[n].key) ? t.push(g[n]) : -1 != Hb.indexOf(g[n].key) ? k.push(g[n]) : m.push(g[n]));
53
g = "";
54
for (n = 0; n < q.length; n++)
55
g = g + q[n].key.charAt(0) + q[n].value;
56
q = "";
57
for (n = 0; n < k.length; n++)
58
q = 0 == n ? q + k[n].value : q + "x" + k[n].value;
59
k = "";
60
for (n = 0; n < t.length; n++)
61
k = 0 == n ? k + t[n].value : k + "x" + t[n].value;
62
m.push(new l("storeDb",g));
63
m.push(new l("srcScreenSize",q));
64
m.push(new l("scrAvailSize",k));
65
"" != d && m.push(new l("localCode",pb(d)));
66
e = c.hashAlg(m, a, e);
67
a = e.key;
68
e = e.value;
69
a += "\x26timestamp\x3d" + (new Date).getTime();
70
$a.getJSON("https://kyfw.12306.cn/otn/HttpZF/logdevice" + ("?algID\x3drblubbXDx3\x26hashCode\x3d" + e + a), null, function(a) {
71
var b = JSON.parse(a);
72
void 0 != lb && lb.postMessage(a, r.parent);
73
for (var d in b)
74
"dfp" == d ? F("RAIL_DEVICEID") != b[d] && (W("RAIL_DEVICEID", b[d], 1E3),
75
c.deviceEc.set("RAIL_DEVICEID", b[d])) : "exp" == d ? W("RAIL_EXPIRATION", b[d], 1E3) : "cookieCode" == d && (c.ec.set("RAIL_OkLJUJ", b[d]),
76
W("RAIL_OkLJUJ", "", 0))
77
})
78
}
79
})
80
}, 1)
81
}
82
}
83
})();
Copied!
从以上代码分析中,相信你会发现相关逻辑应该兼容 IE 浏览器,同时设置了定时程序反复更新 cookie 值,并且还有远程 RTC 保持通信,不得不说做得还真不错,不愧是国民出行的代步工具啊!
精力有限,这里选择最简单的一种情况进行算法还原过程的研究,浏览器选择谷歌 Chrome 浏览器,这样就可以屏蔽关于 IE 的兼容性补丁处理,同时也不考虑 RTCPeerConnection 的情况,于是乎,代码逻辑简化成这样:
1
(function() {
2
document.addEventListener("DOMContentLoaded", Pa,false)
3
4
function Pa() {
5
(new ja).getFingerPrint();
6
}
7
8
function ja() {
9
this.ec = new evercookie;
10
this.deviceEc = new evercookie;
11
this.cfp = new aa;
12
this.packageString = "";
13
this.moreInfoArray = []
14
}
15
16
ja.prototype = {
17
getFingerPrint: function() {
18
this.initEc()
19
},
20
initEc: function(a) {
21
var b = ""
22
, c = this
23
, d = void 0 != a && void 0 != a.localAddr ? a.localAddr : "";
24
c.checkWapOrWeb();
25
this.ec.get("RAIL_OkLJUJ", function(a) {
26
b = a;
27
c.getDfpMoreInfo(function() {
28
if (!(9E5 < F("RAIL_EXPIRATION") - (new Date).getTime() & null != F("RAIL_DEVICEID") & void 0 != F("RAIL_DEVICEID") & !c.NeedUpdate())) {
29
for (var a = "", e = "", g = c.getpackStr(b), m = [], q = [], t = [], k = [], n = 0; n < g.length; n++)
30
"new" != g[n].value && -1 == Fb.indexOf(g[n].key) && (-1 != Gb.indexOf(g[n].key) ? q.push(g[n]) : -1 != Ib.indexOf(g[n].key) ? t.push(g[n]) : -1 != Hb.indexOf(g[n].key) ? k.push(g[n]) : m.push(g[n]));
31
g = "";
32
for (n = 0; n < q.length; n++)
33
g = g + q[n].key.charAt(0) + q[n].value;
34
q = "";
35
for (n = 0; n < k.length; n++)
36
q = 0 == n ? q + k[n].value : q + "x" + k[n].value;
37
k = "";
38
for (n = 0; n < t.length; n++)
39
k = 0 == n ? k + t[n].value : k + "x" + t[n].value;
40
m.push(new l("storeDb",g));
41
m.push(new l("srcScreenSize",q));
42
m.push(new l("scrAvailSize",k));
43
"" != d && m.push(new l("localCode",pb(d)));
44
e = c.hashAlg(m, a, e);
45
a = e.key;
46
e = e.value;
47
a += "\x26timestamp\x3d" + (new Date).getTime();
48
$a.getJSON("https://kyfw.12306.cn/otn/HttpZF/logdevice" + ("?algID\x3drblubbXDx3\x26hashCode\x3d" + e + a), null, function(a) {
49
var b = JSON.parse(a);
50
void 0 != lb && lb.postMessage(a, r.parent);
51
for (var d in b)
52
"dfp" == d ? F("RAIL_DEVICEID") != b[d] && (W("RAIL_DEVICEID", b[d], 1E3),
53
c.deviceEc.set("RAIL_DEVICEID", b[d])) : "exp" == d ? W("RAIL_EXPIRATION", b[d], 1E3) : "cookieCode" == d && (c.ec.set("RAIL_OkLJUJ", b[d]),
54
W("RAIL_OkLJUJ", "", 0))
55
})
56
}
57
})
58
}, 1)
59
}
60
}
61
})();
Copied!
所以现在问题的核心在于搞清楚 initEc 函数的数据流向,还原算法实现过程不是梦!

断点调试追踪调用栈

静态分析程序结构后开始断电调试观察一下数据流向,做到心中有数,同时为了该过程具有可重复性需要保持每一次操作环境一致.
具体而言,首先 Chrome 浏览器处于无痕模式,接着是每次试验时清空站点缓存,最后就可以愉快刷新当前网页等待进入下一轮断电调试了.
12306-algorithm-web-js-source-getjs-debug.png
提前在关键点打入断点(鼠标左键点击行号),然后等待程序进入调试模式,稍等一会后进入断点可以一步一步看到程序运行的值,在调试区还可以监控变量的值.
当然也可以有函数调用栈的关系,这一切只是辅助手段,最关键还是要靠自己分析弄清楚函数的调用顺序流程,原则上先大后小,先整体再细节.
12306-algorithm-web-js-source-getjs-debug-watch.png
函数最后会发送 ajax 请求获取 cookie 并写入本地以及 cookie 中,亲测数据如下:
1
{"exp":"1582097104310","cookieCode":"FGH8SO9zGaWtwuld2jrurRzwmZKeXABx","dfp":"EKLLyLS1K7tqtcuZ6LEPYoUKsxmVNyrAlWNLDi3P-gA-tJMLkTxMuhsRNHEhbk7ntCFCsIpymD57I4AyfPUoWB4D_a_Fe5usS8sfJxP_OJjoun5QjAfgDBBmDLh_m2OeRVN2NnRK0-paM6dCSVKdjFGILKUOJYWT"}
Copied!
12306-algorithm-web-js-source-getjs-debug-finally.png
一次请求完成后顺利生成了 cookie 也写入了本地缓存中,如果不清空那么进入下一次断点的流程就和这一次不一样了,所以为了可重复操作,再次断点调试时需要还原操作环境.
12306-algorithm-web-js-source-getjs-debug-write.png
首次加载时变量 a 并没有值,一不小心进入下一个过程时,这一次 a 已经有值了,多次试验后搞清楚了数据流向也明白了如何还原操作环境,保持实验结果的一致性.
12306-algorithm-web-js-source-getjs-debug-repeat.png
经过多次重复试验,先将基本数据流向还原如下:
1
(function() {
2
ja.prototype = {
3
// C:initEc
4
initEc: function(a) {
5
this.ec.get("RAIL_OkLJUJ", function(a) {
6
c.getDfpMoreInfo(function() {
7
8
})
9
}, 1)
10
},
11
// c.getDfpMoreInfo:A
12
getDfpMoreInfo: function(a) {
13
14
}
15
}
16
17
// this.ec.get("RAIL_OkLJUJ":B
18
window.evercookie = window.Evercookie = function(a) {
19
this.get = function(a, b, c) {
20
21
}
22
}
23
})();
Copied!
异步请求 C <- B <- A 换算成实际情况是 : initEc 函数依赖于 this.ec.get("RAIL_OkLJUJ" 函数,等到 window.evercookie.get 运行完成后会调用 c.getDfpMoreInfo 函数,等到 getDfpMoreInfo 函数运行结束后会调用函数核心关键代码.
除了总的来看是各种异步请求相互回调之外,不少细节中也充斥着大量的回调函数,以 getDfpMoreInfo 函数为例,居然要收集这么多信息才开始做自己的事情!
1
ja.prototype = {
2
getDfpMoreInfo: function(a) {
3
var b = this;
4
this.moreInfoArray = [];
5
b.cfp.get(function(c, d) {
6
b.moreInfoArray.push(b.getCanvansCode(c + ""));
7
for (var e in d) {
8
c = d[e].key;
9
var f = d[e].value + "";
10
switch (c) {
11
case "session_storage":
12
b.moreInfoArray.push(b.getSessionStorage(f));
13
break;
14
case "local_storage":
15
b.moreInfoArray.push(b.getLocalStorage(f));
16
break;
17
case "indexed_db":
18
b.moreInfoArray.push(b.getIndexedDb(f));
19
break;
20
case "open_database":
21
b.moreInfoArray.push(b.getOpenDatabase(f));
22
break;
23
case "do_not_track":
24
b.moreInfoArray.push(b.getDoNotTrack(f));
25
break;
26
case "ie_plugins":
27
b.moreInfoArray.push(b.getPlugins(f));
28
break;
29
case "regular_plugins":
30
b.moreInfoArray.push(b.getPlugins());
31
break;
32
case "adblock":
33
b.moreInfoArray.push(b.getAdblock(f));
34
break;
35
case "has_lied_languages":
36
b.moreInfoArray.push(b.getHasLiedLanguages(f));
37
break;
38
case "has_lied_resolution":
39
b.moreInfoArray.push(b.getHasLiedResolution(f));
40
break;
41
case "has_lied_os":
42
b.moreInfoArray.push(b.getHasLiedOs(f));
43
break;
44
case "has_lied_browser":
45
b.moreInfoArray.push(b.getHasLiedBrowser(f));
46
break;
47
case "touch_support":
48
b.moreInfoArray.push(b.getTouchSupport(f));
49
break;
50
case "js_fonts":
51
b.moreInfoArray.push(b.getJsFonts(f))
52
}
53
}
54
"function" == typeof a && a()
55
})
56
}
57
}
58
59
function aa(a) {
60
if (!(this instanceof aa))
61
return new aa(a);
62
this.options = this.extend(a, {
63
detectScreenOrientation: !0,
64
swfPath: "flash/compiled/FontList.swf",
65
sortPluginsFor: [/palemoon/i],
66
swfContainerId: "fingerprintjs2",
67
userDefinedFonts: []
68
});
69
this.nativeForEach = Array.prototype.forEach;
70
this.nativeMap = Array.prototype.map
71
}
72
73
aa.prototype = {