msToken 请求里的 strData:一包指纹是怎样变成密文的
/web/r/token和/web/common的请求里都有一段很长的strData。它和 msToken 经常被放在一起说,但两者不是同一个东西:strData 是浏览器整理数据后算出来、放进请求里的;msToken 则来自服务端响应,这几个场景中可以看到响应头x-ms-token。
要看明白这条计算链,先把注意力放在发出去的那包数据上。
先顺着19版的一包数据往下看,再对照18、20、30版。范围以已覆盖的普通 Chrome / Windows 路径为主;common 讨论刷新后的设备首包,里面的msgType为1。
外面很短,里面很长
19版的请求正文长这样,时间和密文用变量代替:
{magic:538969122,version:1,dataType:8,strData,tspFromClient,ulr:0}magic、version、dataType由 SDK 直接写入,放在加密范围之外。
tspFromClient是加密、编码完成之后另读的时间。当前19版这两种请求的ulr都为0,但来源不同:r/token 直接写0,common 对应普通 XHR 发送分支。30版已分析的 common 正文没有这一项。
真正占篇幅的是 strData。进入加密之前,它是一份 JSON 字符串,里面混着几类东西:
- 浏览器原生读数及其处理结果,例如尺寸、插件、WebGL、音频和 Math;
- 页面 SDK 的配置,例如 aid、pageId;
- 身份和本次运行状态,例如 UUID、采集耗时、时间;
- 环境检查与行为计算的结果。
前三版正常调用时需要的页面参数是aid、pageId、href,30当前只用aid、href。这几项来自目标页面的 SDK 配置和地址,浏览器指纹则由原生 API 另外采集。两份输入各有来源,独立采集 HTML 负责的是后一份。
href 指的是产生这包数据的网页或 iframe 的完整地址,不是上报接口地址。它在 common 里会写进window.location;r/token 没有这个顶层字段,但地址仍会参加环境判断。
先装 nWID,里面远不止 UA
19版先记录一次performance.now(),然后收集扩展信息。整理好的 nWID 按下面的顺序放字段:
navigator, canvas, screen, extra, webgl, audio, video, math, envCode, ubCode, ms_accid, custom, ms_version这是13个键,其中 audio、webgl 等对象里还有各自的字段。这一版当前组包中的ms_accid写空串,ms_version写"0.0.0.1";custom 则要等身份和耗时信息齐了再填。
navigator:既看值,也看对象长什么样
这里会用到 UA、vendor、cookieEnabled、cpuClass、oscpu、插件和 hardwareConcurrency。处理方式并不统一:有的加空串转成文字,有的做类型分类,有的在真值判断后取整。
navToString更能说明它在看什么。它先取:
Reflect.ownKeys(navigator.__proto__)保留枚举顺序,过滤名称含vendorSubs的项,再读取navigator[原键],取它的 ObjectTag,组成:
键名:[object 类型]最后得到的字符串数组保留原枚举顺序,Symbol 键也在其中。
这份结果里还可能出现页面脚本自己加的属性。19版当前页面会把SDKNativeWebApi、pemrissions两项带进去;它们不是 Chrome 原生属性。只拿一份“标准 navigator 字段表”照着填,少的可能正是这类内容。
插件也不是直接塞入 Plugin 对象。程序逐个取 Plugin 和 MimeType,把文件名、类型、后缀拼成:
filename|type|suffixesnWID 内再用逗号连接各行,重复项和原顺序都会保留。
Canvas:叫 crc32,却没有在这里再算一次 CRC32
这一块会创建画布,检查2D能力,画矩形、文字、混色圆形,并做evenodd路径测试。固定绘图参数决定了要画什么,浏览器实际渲染决定得到什么。
后面的crc32字段容易让人看走眼。它的处理是:
toDataURL() 得到 PNG → 去掉 data:image/png;base64, 前缀 → atob 解码 → slice(-16, -12) 取四个字符 → 字符码转大写十六进制,依次拼接没有再调用一遍 CRC32 去散列整张图。
连补零条件也得按原式看:字符码小于15才先补"0",不是小于16。把它顺手改成常见的“两位十六进制格式化”,就已经改了处理规则。
这一组的输出由isCanvasSupported、canvasWinding和crc32三个字段组成。
WebGL、屏幕和 extra:各自保留自己的信息
WebGL 读取颜色位数、纹理上限、着色器版本等参数,再通过扩展取得各向异性能力及 renderer、vendor。这里整理成20个字段,没有把所有结果再压成一个 hash。
nWID.screen 保存八项屏幕值,包括宽高、色深、可用区域及位置,按原值写入。common 后面还会再生成另一份顶层 screen,两份的字段和转换方法不同。
extra 里有时区、Storage 与 IndexedDB 的存在性,以及强制颜色、减少动态效果、对比度、色域等媒体查询结果。
architecture的取法比较特别:让 Float32Array 和 Uint8Array 共用缓冲区,写入 Infinity,再用 Infinity 减自身,把结果写回,最后读取索引3处的字节。这个字段保存的是浏览器数值表示中的一个字节,而非电脑架构名称。
音频:格式支持是一部分,渲染结果是另一部分
audio 里有三路数据:
一条读取 AudioContext、destination 和 Analyser 的属性,例如采样率、状态、声道和 FFT 设置。这些是实际读数,suspended和running也会作为不同状态留下来。
一条对32项预设音频格式调用canPlayType,按原顺序拼接:
MIME:返回值|MIME:返回值|……返回空字符串的项照样保留,最后一项也有|。video 另用八项格式做同类查询,两边保存的都是能力查询结果。
还有一条是离线音频渲染。19版使用OfflineAudioContext(1, 44100, 44100),三角波振荡器频率设为10000,接入压缩器:
threshold = -50 knee = 40 ratio = 12 attack = 0 release = 0.25各次参数设置使用当时的context.currentTime。渲染完成后,取第0声道的slice(4500, 5000),得到500个样本,再做:
letsum=0;for(constsampleofsamples){sum=sum+Math.abs(sample);}constaudioFp=sum.toString();500是截取后的样本数,整段渲染长度是44100。累加用普通 Number,顺序也保留;换成 Float32 累加或先取固定小数位,都会改变这一步的数值处理。
最后 audio 的键序是audioFormats、audioContext、frequencyAnalyser、audioFp。虽然原生属性先读,组装时却把格式结果放在前面。
Math:小数尾数也会进包
这部分会调用浏览器的 acos、acosh、sin、tan、log、exp 等方法,包括sin(-1e300)、pow(Math.PI, -100)这样的输入。19版这条路径有29次原生调用,整理出24项数值结果。
有的结果直接保存,有的还要做后处理。比如名为sinhPf的那项,实际运算顺序相当于:
exp(1) - (1 / exp(1)) / 2除以2只作用于后面的倒数项。两个 exp 分别调用,两个返回值各自参与计算。
这些数值最后参与 JSON 序列化。已有 Chrome 与 Node 对照中,部分 Math 返回出现过末位舍入差异;用 Node 的同名函数重新算,不保证拿到原浏览器的同一份结果。
custom 里还套着一层 JSON
nWID 收集接近结束时,19版会整理下面四项:
{version:"0.0.0.1",fxgDid,uuid,collectTime}随后对它单独执行JSON.stringify,把字符串写进nWID.custom。外面整包序列化时,字符串里的引号还会再转义。
这里若改放普通嵌套对象,少掉的那层引号和转义就会改变加密输入。
fxgDid在当前普通 Chrome、没有 webviewBridge 的路径中取到空串。
UUID 则有跨次复用。19版先读 sessionStorage 的__msuuid__,没有真值才读 localStorage;都没有才生成。模板是:
xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx每个 x/y 位置各读一次随机数:
r=(Math.random()*16)|0;x=r;y=(r&3)|8;替换为十六进制文字,固定的4和连字符不读随机数,所以首次生成消耗31次。新值先写 local,再写 session;已有 UUID 直接复用。后面的加密密钥另行生成,两者的生命周期不同。
collectTime用第二次performance.now()减第一次,不取整,计量的是这段收集处理的耗时。此时还没开始发送请求,自然也不包含网络往返时间。
这里的"0.0.0.1"是字段自己的版本文字,与 SDK 资源中的1.0.1.19-fix.01分开使用。
r/token 可以组包了,common 还没结束
两种接口共用后面的加密函数,但给它的明文不同。
19版 r/token 把 aid、pageId 追加进刚才那份 custom 的内部对象,顺序变成:
version, fxgDid, uuid, collectTime, aid, pageId顶层只有:
{nWID,wID:{msgType:3}}顶层虽然只有两个键,nWID 里已经装进了前面那一大组内容。
common 的 nWID.custom 不加 aid、pageId。它还要取得电池信息,再生成顶层的 document、navigator、plugins、screen、webgl、window,计算这一层自己的 envCode、ubCode,并加入 custom.WebId 和完整 wID。
完成后的顶层顺序是:
battery, document, navigator, plugins, screen, webgl, window, envCode, ubCode, nWID, custom, wID这些同名字段各有分工。nWID 内的插件是连接后的字符串,顶层插件是带plugin数组和pv:"0"的对象;两份 screen 一份保留八项原值,一份还包含窗口、页面尺寸与方向信息。WebGL 和环境检查也各有自己的读取位置。
这里还能看到一些直接影响明文的转换:
- 电量先乘100再四舍五入,电池时间值加空串;
- 部分 navigator 属性加空串,数组形式的 languages 因此变成逗号串;
- 顶层 screen 的数值用
>> 0转成有符号32位整数; - document.all、Storage 等对象先分类,写入对应的类型码。
例如原始 undefined 加空串后会成为"undefined",JSON 里会保留这个字符串。document.all则即使 typeof 为 undefined,ObjectTag 仍能是 HTMLAllCollection,在这条路径中得到类型码12。
输出里的orientaionType、orientaionAngle、indexDB也保留原拼写。两个页面偏移字段实际都读取 pageYOffset,按名字把其中一个改成 pageXOffset,同样会改变输入来源。
WebId 不等于用户 ID
common 顶层的custom.WebId还是一个 JSON 字符串,主要来自三组检查:
- atob、btoa、alert 的函数文本长度;
- console.memory 及其原型的键列表;
- 新建一个 MouseEvent 后的 isTrusted 和自有键数量。
这里仅新建事件并读取属性,没有派发 click,也没有读取用户的真实点击轨迹。WebId 装的是这三组检测结果。
它与 nWID.custom 是两个位置、两种内容。
完整 wID 还有一组能力测试
common 的 wID 基础字段按这个顺序排列:
browserType, jsFontsList, jsv, load, magic, msgType, nap, nativeLength, privacyMode, timestamp, timezone其中几个字段值得展开:
browserType把七项浏览器特征按索引置位,得到一个数字。其中一项是原逻辑直接写入的 false。
jsFontsList按固定顺序做29项document.fonts.check。第 i 项为 true,就执行mask |= 1 << i,最后转十六进制文字。这不是枚举出一份已安装字体清单。
load使用一张固定的1像素 GIF。加载、画到 Canvas 后,读取像素 alpha,按2 + (alpha === 0)得到结果。GIF 是固定素材,渲染后的像素才是浏览器返回。
magic测 localStorage 和 sessionStorage 的写删能力。前者成功贡献1,后者成功贡献2,再按位或。这是 wID 内的能力码,外层的magic=538969122则是另一项固定字段。
nap对20项权限名称执行查询,按名称表的顺序连接结果码。已覆盖路径中,granted 对应"2",prompt 对应"1";拒绝消息中is not a valid enum value of type PermissionName的位置大于0时为"4",其他已观察的拒绝消息为"3"。异步完成先后不会改变最终排列。这里列出的状态不含尚未覆盖的 denied 分支。
nativeLength取 eval 的函数文本长度,不执行 eval。timestamp则在这一阶段另读 Date.now 并转成字符串;时区另按-Math.floor(offset / 60)计算。
当前路径中jsv="1.5"、msgType=1、privacyMode=0。最后再给 wID 追加 aid、pageId,这就是 common 两项页面配置的落点。
切换这两种包,除了请求 URL 和 msgType,还涉及字段层级、配置位置及 wID 的整段处理。r/token 没有完整 wID,也少了这次 wID 时间读取。
envCode 和 ubCode,别拿来当风险分数
这两个字段在 nWID 里出现一次,在 common 顶层还会各出现一次。两处独立计算,再分别写入。
19版的正常对象检查通过之后,envCode 从1开始:
code=1;code|=(+nativeResult)<<1;code|=(+headlessResult)<<2;code|=(+phantomResult)<<3;code|=(+nodeResult)<<4;code|=(+locationResult)<<5;code|=(+consistencyResult)<<6;依次对应原生函数与插件特征、Headless/WebDriver 等提示、Phantom、Node、地址,以及 UA 与 platform 是否冲突。
每项内部还可能有组合条件。比如 rtt 为0、brands 数组为空、特定窗口尺寸这三项提示,在前面的相关检查都没触发时,必须三项全为真才达到数量阈值3。单独一个尺寸条件为 true,还不足以让这一项成立。
envCode 等于1,只说明其他位这次没有置上。底层某个提示仍可能为 true,只是没有满足组合条件。这个数反映前端检查结果,服务端怎样评分并不由它直接说明。
ubCode 读取的是移动、按下和键盘三类队列。19版已覆盖的空队列路径很直接:
移动为空 → 1 << 1 → 2 按下为空 → 1 << 2 → 4 键盘为空 → 1 << 3 → 8 2 | 4 | 8 = 1414是这三个结果的位组合。这些首包把行为计算后的码写进明文,原始坐标轨迹留在计算过程里。所以包里没有一串坐标,仍然可以带有行为特征。
18、19、20各接口的既有五轮刷新样本中,相关三队列均为空。30另有一组十轮刷新,九次得到14,一次得到12。那次移动队列有六个点,实际计算的是:
每段速度 = sqrt((x2-x1)^2 + (y2-y1)^2) / (ts2-ts1) 平均速度 = 各段速度之和 / (点数-1)平均值为0.523386467115188,与阈值18比较,没有超过,移动部分返回0;另外两项仍为4和8,合起来得到12。
这说明12还依赖点之间的位置和时间,光看“六个点”得不出结果。这里展开的是30版那条非空路径,18、19、20的非空分支不作同样推论。
九次14、一次12只是这组操作的频数。把它用于本地90%/10%的抽样,是模拟方案;浏览器原本读取的是当时的真实队列,并没有先抽一个数字决定用户动没动。
明文齐了,才轮到加密
把前面的顺序连起来,19版 common 是:
记录收集开始时间 → 收集 nWID,计算它的环境码和行为码 → 取得 UUID、计算 collectTime,写入 nWID.custom → 收集顶层环境,再算这一层的环境码和行为码 → 生成 custom.WebId 和完整 wID → 在 wID 追加 aid、pageId → JSON.stringify 整包 → 随机密钥、RC4、前缀拼接、自定义编码 → 再读时间,组成外层请求正文JSON 字段顺序也是加密输入的一部分。{"a":1,"b":2}和{"b":2,"a":1}可以表达同样的键值集合,却是两条不同的字符串。前面几处字符串套 JSON、原值转文字、覆盖已有键但保留键的位置,都要算到这一层来。
这段末端封装用的是 RC4 和自定义编码,没有额外的 SM3、压缩、IV 或分组填充。
这次的密钥只有一个字符
先生成标记:
marker=String.fromCharCode((1<<6)|1);// "A"再读取一次随机数,得到密钥字符:
key=String.fromCharCode(Math.floor(Math.random()*256));结果的长度为1,字符码取值0~255。后面的置换使用这个字符码,而不是把数字转成十进制文字来用。
RC4 的状态表从0~255顺序初始化。先做256轮密钥置换:
S[i] = i,i = 0..255 j = 0 依次遍历 i = 0..255: j = (j + S[i] + key.charCodeAt(i % key.length)) % 256 交换 S[i]、S[j]这次 key.length 为1。初表固定,置换完成后的表则由本次密钥决定。
随后把 i、j 归零,对明文逐个位置处理:
i = (i + 1) % 256 j = (j + S[i]) % 256 交换 S[i]、S[j] stream = S[(S[i] + S[j]) % 256] cipherCharacter = String.fromCharCode(plain.charCodeAt(position) ^ stream)原实现按 JavaScript 的 UTF-16 编码单元读取字符串,低8位处理留到后面的编码阶段。默认接收 UTF-8 的 RC4 库会先走另一套字符转换;ASCII 输入容易掩盖这个区别,非 ASCII 时处理单位就不同了。
密钥就跟在标记后面
RC4 返回字符形式的密文后,拼接顺序是:
"A" + 本次密钥字符 + 密文字符前两个字符没有参加刚才那次 RC4,它们随后和密文一起编码。密钥字符确实随包放了进去,这层结构里没有把它当作一个只留在客户端的秘密。
A的码值65来自(1 << 6) | 1,属于这层封装头,与项目里的版本称呼分开。
Base64 的分组,另一张字符表
编码选用名为 s1 的字符表,64个有效字符依次是:
Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe补位字符是=。它沿用 Base64 三个输入对应四个输出的分组方式,但整张字符表的排列不同,并非只改两个符号。
每三个输入字符组成一组,到这一步才对字符码取低8位:
word = ((code0 & 255) << 16) | ((code1 & 255) << 8) | (code2 & 255) index0 = (word & 16515072) >> 18 index1 = (word & 258048) >> 12 index2 = (word & 4032) >> 6 index3 = word & 63用四个索引查表,依次追加字符。最后剩一个输入字符,输出两个有效字符加==;剩两个,输出三个有效字符加=;正好分完就不加补位。
若 JSON 明文的 UTF-16 长度为 n:
RC4密文长度 = n 编码前拼接长度 = n + 2 strData长度 = 4 * Math.ceil((n + 2) / 3)19版已有一包明文长10345,拼接后10347,最终 strData 长13796。这个长度由那一包明文决定;字段或文字长度变化时,结果长度也可能变化。长度相同的两条 strData,内容仍可以不同。
到这里,才把字符串写入外层的 strData,读取tspFromClient,序列化请求正文。
再把四版放到一起看
先说最容易误判的一点:四版已分析包的末端规则相同,都是单字符随机密钥、同一套 RC4 置换与字符异或、A + key + ciphertext,再使用同一套有效编码表。
给定同一条明文字符串和同一个密钥字符,这一段会得到相同的 strData。实际请求里的结果不同,要先看送进来的明文变了什么。
18、19、20的同类包结构相近:r/token 都以 nWID 为主,common 再加顶层环境、custom.WebId 和完整 wID。具体字段里,页面配置和脚本自建标记仍各有差别。
例如,当前18版页面的 navigator 反射列表有pemrissions,19、20的对应页面还加入了SDKNativeWebApi。这些键由页面脚本添加,差别会直接留在明文里。
几个已登记场景的 SDK 配置也不同:18验证码页为2385/27032,19抖音用户页为6383/6241,20作者登录页为2503/36144。这里写的是 aid/pageId。例如18的分析页 URL 带 aid=2503,SDK 实际采用的却是2385;同名字段有各自的来源。这些数值对应各自页面,不是加密常量。
上面展开的 UUID 首次生成规则在19、20都有相应记录;18这里只比较已确认的读取、使用和字段位置,尚未取得的原 SDK 首次生成过程不并入结论。
30的改动主要在前面这包数据:
| 位置 | 18 / 19 / 20 的 common | 30 的 common |
|---|---|---|
| 顶层结构 | 12键,wID在最后 | 10键,wID在envCode、ubCode之前 |
| nWID扩展对象 | 有 | 当前首包没有 |
| custom.WebId | 有 | 当前首包没有 |
| UUID、collectTime | 在nWID.custom中 | 当前首包不使用这两项 |
| 页面配置 | wID追加aid、pageId | wID只追加aid |
| 顶层屏幕方向字段 | 有,保留orientaion原拼写 | 没有这两个输出 |
| Storage类型分类 | 当前Storage标签得到13 | 当前规则走对象回退,得到99 |
| 末端RC4与编码 | 相同规则 | 相同规则 |
30的顶层顺序具体是:
battery, document, navigator, plugins, screen, webgl, window, wID, envCode, ubCode30仍保留基础 WebGL、wID 的 GIF 像素测试、字体、权限和 Storage 能力。去掉的是前三版那整块扩展 Canvas、audio、video、math 对象,连同 nWID 的其他内容。
30页面本身还配置了 pageId=24117,但当前设备首包只写 aid=2503。页面上能看到一个配置值,和这包数据实际使用它,是两回事。
环境检查也变了。前三版正常路径有六项位组装,30是九项,加入存储额度、Firefox窗口差和嵌入式浏览器等检查,并重排部分位。例如地址检查在前三版左移5位,在30左移9位;即使同样命中,贡献的数也不同。
30的 quota 条件是!!quota && quota < 2300000000,使用此前已经完成的异步结果。本次刚发起的 estimate 要等回调完成,才会更新缓存。Firefox窗口差则在当前 Chrome 路径短路,内部判断没有进入。
地址检查连正则范围都有差别,前三版还有 location 对象身份比较。变化既发生在位的排列上,也发生在每一项具体检查什么上。
这里的“30”沿用项目称呼,对应 bdms 资源地址中的1.0.0.38。它与外层 version=1、前缀65、wID.jsv=“1.5” 各有各的用途。30目前仅有 common 的分析,r/token 暂不比较;未分析也不等于接口不存在。
回到最初那串字符
strData 最后看起来是一串密文,真正进入它的却是多种来源的数据:浏览器读数经过各自处理,页面配置和身份放到指定位置,环境与行为压成结果码,再一起序列化。RC4 和编码接在这份明文之后。
四版末端加密相同,前面的包却不完全一样;同一版本换成 r/token 或 common,也要先换对组包方式。把这一层看清楚,就能分开讨论指纹、状态和加密,而不再把所有变化都归到“msToken算法变了”。