很久沒有寫博客了,主要是最近換了個地打工,開始對一些反自動化的工作開始進行研究;這是一篇學習筆記,歡迎交流~
背景與網站介紹
Bot Challenge是專門的web bot檢測的網站:https://bot.incolumitas.com/#botChallenge
該網站對用戶行為的檢測手段很完整,值得學習;
用戶行為數據
總體收集的event
this.recordedEvents = ["mousemove", "mousedown", "mouseup", "dblclick", "contextmenu", "scroll", "resize", "keydown", "keyup", "touchstart", "touchmove", "touchcancel", "touchend", "load", "DOMContentLoaded", "visibilitychange", "pagehide", "beforeunload", "unload"],
this.newRecordedEvents = ["copy", "paste", "deviceorientation", "devicemotion"]
this.onlyWindowEvent = ["scroll", "keydown", "keyup", "resize", "copy", "paste", "deviceorientation", "devicemotion", "visibilitychange", "load", "DOMContentLoaded", "pagehide", "beforeunload", "unload"],
this.recordNewEvents && (this.recordedEvents = this.recordedEvents.concat(this.newRecordedEvents))
具體行為收集可以分為以下幾類,主要分析下收集的具體數據和觸發收集的事件
鼠標動作(MouseEvent)
getMetaKeysBitstring: function(e) {
var t = "";
return t += !0 === e.ctrlKey ? "1" : "0",
t += !0 === e.shiftKey ? "1" : "0",
t += !0 === e.altKey ? "1" : "0",
t += !0 === e.metaKey ? "1" : "0"
}
getMouseFrame: function(e, t) {
return [t, e.clientX, e.clientY, e.screenX, e.screenY, e.button, this.getMetaKeysBitstring(e)]
},
mousemoveListener: function(e, t) {
return e.getMouseFrame(t, "m")
},
mousedownListener: function(e, t) {
return e.getMouseFrame(t, "md")
},
mouseupListener: function(e, t) {
return e.getMouseFrame(t, "mu")
},
dblclickListener: function(e, t) {
return e.getMouseFrame(t, "dc")
},
contextmenuListener: function(e, t) {
return e.getMouseFrame(t, "cm")
},
數據(列表形式,都是以事件名簡寫打頭,后面是收集的具體數據,下同):
clientX:double(原為long);鼠標在事件觸發時的應用瀏覽器內的水平坐標
clientY:double(原為long);鼠標在事件觸發時的應用瀏覽器內的垂直坐標
screenX:double(原為long);鼠標在事件觸發時全局(屏幕)中的水平坐標
screenY:double(原為long);鼠標在事件觸發時全局(屏幕)中的垂直坐標
-
button: number;代表事件觸發時按下的鼠標按鍵:
-
0
:主按鍵,通常指鼠標左鍵或默認值(譯者注:如document.getElementById('a').click()這樣觸發就會是默認值)
-
1
:輔助按鍵,通常指鼠標滾輪中鍵2
:次按鍵,通常指鼠標右鍵3
:第四個按鈕,通常指瀏覽器后退按鈕4
:第五個按鈕,通常指瀏覽器的前進按鈕MetaKey:String;收集觸發事件時對應按鍵是否被按下;'0'與'1'組成的字符串
事件:
- mousemove:鼠標移動
- mousedown:鼠標按鈕按下時觸發
- mouseup:鼠標按鈕松開時觸發
- dblclick:鼠標雙擊時觸發
- contextmenu:打開上下文菜單時觸發,例如在頁面右鍵打開菜單
鍵盤動作(KeyboardEvent)
getKeyFrame: function(e, t) {
return [t, e.code, e.key, e.location, e.repeat, this.getMetaKeysBitstring(e)]
},
keydownListener: function(e, t) {
return e.getKeyFrame(t, "kd")
},
keyupListener: function(e, t) {
return e.getKeyFrame(t, "ku")
},
數據:
- code:String;鍵盤上的物理鍵(與按鍵生成的字符相對)。換句話說,此屬性返回一個值,該值不會被鍵盤布局或修飾鍵的狀態改變。如QWERTY布局鍵盤上的“q”鍵返回的code是“
KeyQ
” - key: String;返回用戶按下的真實邏輯輸入。它還與
shiftKey
等調節性按鍵的狀態和鍵盤的區域 / 和布局有關。 - location:
unsigned long
,表示按鍵在鍵盤或其他設備上的位置, 主要針對ctrl/shift等鍵盤上有多個的按鍵,以及數字/enter等按鍵:- 0: 表示不區分或者無法區分
- 1: 來自左邊的ctrl/shift/alt...
- 2: 來自右邊的按鍵
- 3: 來自數字小鍵盤的按鍵
- 其他值已廢棄
- repeat: Bool;如果按鍵被一直按住,返回值為
true
- Metakey: 與鼠標事件一樣
事件:
- keydown:鍵盤按下觸發
- keyup:鍵盤松開觸發
觸摸動作(TouchEvent)
getTouchFrame: function(e, t) {
for (var n = [], i = 0; i < e.touches.length; i++) {
var a = e.touches[i]
, o = [this.round2(a.clientX), this.round2(a.clientY), this.round2(a.screenX), this.round2(a.screenY), a.identifier];
this.mobileExperimental && (o = o.concat([this.round2(a.radiusX), this.round2(a.radiusY), a.rotationAngle, a.force])),
n.push(o)
}
return [t, n, this.getMetaKeysBitstring(e)]
},
touchstartListener: function(e, t) {
return e.getTouchFrame(t, "ts")
},
touchmoveListener: function(e, t) {
return e.getTouchFrame(t, "tm")
},
touchcancelListener: function(e, t) {
return e.getTouchFrame(t, "tc")
},
touchendListener: function(e, t) {
return e.getTouchFrame(t, "te")
},
數據:
- touches: List;是一個touchList,一個觸摸平面上所有觸點的列表。例如,如果一個用戶用三根手指接觸屏幕(或者觸控板),與之對應的
TouchList
會包含每根手指的[Touch](https://developer.mozilla.org/zh-CN/docs/Web/API/Touch)
對象,總共三個- touch.clientX/Touch.clientY/Touch.screenX/Touch.screenY:double (之前為long);同鼠標事件同名屬性
- touch.identifier:long;返回一個可以唯一地識別和觸摸平面接觸的點的值. 這個值在這根手指(或觸摸筆等)所引發的所有事件中保持一致, 直到它離開觸摸平面;主要是touchmove中
底下的事件將是Experimental功能:
- touch.radiusX:float;手指與屏幕接觸面的橢圓水平軸半徑
- touch.radiusY:float;手指與屏幕接觸面的橢圓垂直軸半徑
- touch.rotationAngle: float;返回以度為單位的旋轉角. 由
radiusX
和radiusY
描述的正方向的橢圓,通過順時針旋轉這個角度后,能最精確地覆蓋住用戶和觸摸平面的接觸面的角度. 這個值可能從0到90 - touch.force:float;手指擠壓觸摸平面的壓力大小, 從0.0(沒有壓力)到1.0(最大壓力)
事件:
touchstart: 當用戶在觸摸平面上放置了一個觸點時觸發
touchmove: 當用戶在觸摸平面上移動觸點時觸發; 當觸點的半徑、旋轉角度以及壓力大小發生變化時,也將觸發此事件
-
touchcancel: 當觸點由于某些原因被中斷時觸發。有幾種可能的原因如下(具體的原因根據不同的設備和瀏覽器有所不同):
- 由于某個事件出現而取消了觸摸:例如觸摸過程被彈窗打斷。
觸點離開了文檔窗口,而進入了瀏覽器的界面元素、插件或者其他外部內容區域。
當用戶產生的觸點個數超過了設備支持的個數,從而導致
[TouchList](https://developer.mozilla.org/zh-CN/docs/Web/API/TouchList)
中最早的[Touch]
對象被取消。touchend: 當一個觸點被用戶從觸摸平面上移除(即用戶的一個手指或手寫筆離開觸摸平面)時觸發。當觸點移出觸摸平面的邊界時也將觸發。例如用戶將手指劃出屏幕邊緣
元素移動相關
scrollListener: function(e, t) {
return ["s", e.round2(document.scrollingElement.scrollLeft), e.round2(document.scrollingElement.scrollTop)]
}
resizeListener: function(e, t) {
return ["r", window.innerWidth, window.innerHeight]
},
- ScrollEvent:文檔視圖或者一個元素在滾動時,會觸發; 主要是收集滾動條數據
- scrollingElement.scrollLeft:integer(有比例縮放的系統可能為float);滾動條到最左邊的距離
- scrollingElement.scrollTop:integer(有比例縮放的系統可能為float);滾動條到最頂端的距離
- resizeEvent:調整視窗大小時觸發該事件
- window.innerWidth:integer;返回以像素為單位的窗口的內部寬度。如果垂直滾動條存在,則這個屬性將包括它的寬度。
- window.innerHeight:integer;返回以像素為單位的窗口的內部高度度。如果有水平滾動條,也包括滾動條高度。
頁面相關事件
主要是頁面加載,tab切換等:
loadListener: function(e, t) {
return ["lo"]
},
DOMContentLoadedListener: function(e, t) {
return ["dcl"]
},
visibilitychangeListener: function(e, t) {
return ["vc", document.visibilityState]
},
pagehideListener: function(e, t) {
return ["ph", t.persisted]
},
beforeunloadListener: function(e, t) {
return ["bu"]
},
unloadListener: function(e, t) {
return ["ul"]
},
load:當整個頁面及所有依賴資源如樣式表和圖片都已完成加載時,將觸發
DOMContentLoaded:當純HTML被完全加載以及解析時,事件會被觸發,而不必等待樣式表,圖片或者子框架完成加載
-
visibilitychange:當其選項卡的內容變得可見或被隱藏時,會在文檔上觸發
- document.visibilityState:String;返回document的可見性, 即當前可見元素的上下文環境. 由此可以知道當前文檔(即為頁面)是在背后, 或是不可見的隱藏的標簽頁,或者(正在)預渲染.可用的值如下:
-
'visible'
: 此時頁面內容至少是部分可見. 即此頁面在前景標簽頁中,并且窗口沒有最小化.
-
- document.visibilityState:String;返回document的可見性, 即當前可見元素的上下文環境. 由此可以知道當前文檔(即為頁面)是在背后, 或是不可見的隱藏的標簽頁,或者(正在)預渲染.可用的值如下:
'hidden
' : 此時頁面對用戶不可見. 即文檔處于背景標簽頁或者窗口處于最小化狀態,或者操作系統正處于 '鎖屏狀態' .'prerender'
: 頁面此時正在渲染中, 因此是不可見的 (considered hidden for purposes ofdocument.hidden
). 文檔只能從此狀態開始,永遠不能從其他值變為此狀態.注意: 瀏覽器支持是可選的.-
pagehide:當瀏覽器在隱藏當前頁面時, 頁面隱藏事件會被發送到一個window 。例如,當用戶單擊瀏覽器的“后退”按鈕時,當前頁面在顯示上一頁之前會收到一個頁面隱藏事件。
- persisted:代表一個頁面是否從緩存中加載的,可以判斷隱藏頁面是否已緩存以進行可能的重用時執行特殊處理
beforeunload:window、document 和它們的資源即將卸載時觸發,例如可以彈窗確定是否關閉選項卡
unload:window、document 和它們的資源正在卸載時觸發
用戶操作相關
- Copy & paste
copyListener: function(e, t) {
var n = document.getSelection()
, i = ["co"];
return n && i.push(Math.abs(n.anchorOffset, n.focusOffset)),
i
},
pasteListener: function(e, t) {
return ["pa", (t.clipboardData || window.clipboardData).getData("text").length]
},
- getSelection:返回一個選中對象
- selection.anchorOffset: integer;返回選中元素在DOM節點中起始位置(按下鼠標)偏移
- selection.focusOffset:integer;返回選中元素在DOM節點中終止位置(松開鼠標)偏移
例子:
<text>abcdefg<text>
若選中該text元素內的"bcd",則anchorOffset = 1,focusOffset = 3
clipboardData. getData("text").length: integer;粘貼板上字符串長度
Deviceorientation: 設備(指手機,平板等移動設備)在瀏覽頁面時物理旋轉的信息;注意safari未實現
deviceorientationListener: function(e, t) {
if (!(Math.abs(e.rotateDegrees - t.alpha) < 2 || Math.abs(e.leftToRight - t.gamma) < 1 || Math.abs(e.frontToBack - t.beta) < 1)) {
e.rotateDegrees = t.alpha,
e.frontToBack = t.beta,
e.leftToRight = t.gamma;
t = t.absolute;
return null !== e.rotateDegrees && null !== e.frontToBack && null !== e.leftToRight ? ["do", e.round2(e.rotateDegrees), e.round2(e.frontToBack), e.round2(e.leftToRight), t] : void 0
}
},
收集邏輯以1度為精度,若誤差小于一度則不記錄
alpha:double;一個表示設備繞z軸旋轉的角度(范圍在0-360之間)的數字
beta:double:一個表示設備繞x軸旋轉(范圍在-180到180之間)的數字,從前到后的方向為正方向
gamma:double;一個表示設備繞y軸旋轉(范圍在-90到90之間)的數字,從左向右為正方向。
absolute:boolean;表示該設備是否提供絕對定位數據 (這個數據是關于地球的坐標系) 或者使用了由設備決定的專門的坐標系.
devicemotion:關于設備在瀏覽頁面時的位置和方向的改變速度的信息;同樣Safari不支持
devicemotionListener: function(e, t) {
var n = e.round2(t.acceleration.x)
, i = e.round2(t.acceleration.y)
, e = e.round2(t.acceleration.z)
, t = (t.rotationRate,
t.interval);
if (null !== n && null !== i && null !== e && (1 < Math.abs(n) || 1 < Math.abs(i) || 1 < Math.abs(e)))
return ["dm", n, i, e, t]
}
- acceleration.x/acceleration.y/acceleration.z: double;x, y, z方向上的加速度信息
- rotationRate.alpha/rotationRate.beta/rotationRate.gamma: double;三個方向上旋轉的加速度信息
- Interval: integer;返回從底層硬件獲取數據的時間間隔(單位:毫秒)。可以使用它來確定運動事件的粒度
其他公共信息
getTimestamp: function() {
return "performance"in window && "now"in window.performance ? this.round(performance.now(), 3) : (new Date).getTime() - 1e3 * this.startedAt
},
getPassiveSupported: function() {
let t = !1;
try {
var e = {
get passive() {
return !(t = !0)
}
};
window.addEventListener("test", null, e),
window.removeEventListener("test", null, e)
} catch (e) {
t = !1
}
return t
},
- Timestamp:觸發時間戳,可以看到此處優先使用window.performance.now()函數
- PassiveSupported:用于檢查addEventlistener時是否支持使用passive模式:設置為true時,可以優化收集滾屏事件的性能,可查看https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener#%E4%BD%BF%E7%94%A8_passive_%E6%94%B9%E5%96%84%E7%9A%84%E6%BB%9A%E5%B1%8F%E6%80%A7%E8%83%BD
- event.isTrusted:boolean;當事件是由用戶行為生成的時候,這個屬性的值為
true
,而當事件是由腳本創建、修改、通過[EventTarget.dispatchEvent()](https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/dispatchEvent)
派發的時候,這個屬性的值為false
。
收集
開始recording:
Record接口提供開始行為記錄收集
getFrameHandler: function(n, i) {
return function(e) {
var t = i(n, e)
, e = 1 == e.isTrusted ? 1 : 0
, t = t.concat([e, n.getTimestamp()]);
n.frames.push(t),
n.pdFlag && n.frames.length >= n.push_after && (e = new Event("musPushData"),
window.dispatchEvent(e),
n.pdFlag = !1),
n.onFrame && n.onFrame instanceof Function && n.onFrame(t)
}
},
record: function() {
if (!this.recording) {
0 == this.startedAt && (this.startedAt = (new Date).getTime() / 1e3),
document.scrollingElement && this.frames.push(["s", this.round2(document.scrollingElement.scrollLeft), this.round2(document.scrollingElement.scrollTop), this.getTimestamp()]);
for (var e = 0; e < this.recordedEvents.length; e++) {
var t = this.recordedEvents[e]
, n = "scroll" === t
, i = null
, i = this.onlyWindowEvent.includes(t) && this.listenNode !== window ? window : this.listenNode;
"visibilitychange" === t && (i = document);
var a = this.passiveSupported ? {
passive: !0,
capture: n
} : n
, n = this.getFrameHandler(this, this[t + "Listener"]);
this.eventListenerParams[t] = [i, t, n, a],
i.addEventListener(t, n, a)
}
this.recording = !0
}
},
本段代碼主要用來逐一注冊事件的listener(Line27-29):
- 記錄開始時間 (Line 15)
- 當開始記錄時會首先記錄一次當前滾動條的位置(Line 16)
-
addEventListener
的capture
設置為true是用來阻止事件向上冒泡的,只有對scroll阻止冒泡:例如針對一個iframe開啟了scroll listener,該事件不會觸發window側scroll listener(Line19) -
onlyWindowEvent
主要記錄只有window擁有的事件,由于該腳本支持設置監聽DOM中某個node的event,所以此時若監聽node非window則應該去對應監聽window下的事件,即運行到29行時,i == window
(Line 21) - 優先使用passive模式進行監聽(Line 23)
-
使用了
**eventListenerParams**
列表來保存了所有監聽的事件,用于后續stop,該條值得學習 - Line 4 - 5,每次收集都包含的公共信息
- 可以設置
push_after
來控制收集多少條信息后觸發上報,所有收集的信息沒有分類,全部放在frame列表中;觸發上報的本質是通過dispatchEvent
觸發一個事件,該事件的處理函數將發起上報,后面將講述具體觸發上報的時機 (Line 7) -
recording
設置為1,表示開始數據收集
Stop
stop: function() {
for (var e in this.finishedAt = (new Date).getTime() / 1e3,
this.eventListenerParams) {
var t = this.eventListenerParams[e];
t[0].removeEventListener(t[1], t[2], t[3])
}
this.recording = !1
},
記錄下停止的時間后,將record時記錄的事件全部remove掉,recording置為0表示當前未收集數據
上報觸發時機
以下事件觸發時,將發起數據上報;其中"musPushData"事件即為上文描述的主動控制收集多少條數據后進行上報
document.addEventListener("visibilitychange", function(e) {
"hidden" === document.visibilityState && (t = !0,
i("vc"))
}),
window.addEventListener("pagehide", function(e) {
!1 === t && (t = !0,
i("ph"))
}),
window.addEventListener("beforeunload", function(e) {
!1 === t && (t = !0,
i("bu"))
}),
window.addEventListener("unload", function(e) {
!1 === t && (t = !0,
i("un"))
}),
window.addEventListener("musPushData", function(e) {
i("pd"),
mus.pdFlag = !0
})
DeviceData收集
該腳本同樣會收集當前瀏覽器的信息,此處只列出部分值得學習的部分
Sayswho
用于識別當前瀏覽器及其版本;通常會注冊在navigator中,非標準接口;參考代碼:
navigator.sayswho= (function(){
var ua= navigator.userAgent, tem,
M= ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
if(/trident/i.test(M[1])){
tem= /\brv[ :]+(\d+)/g.exec(ua) || [];
return 'IE '+(tem[1] || '');
}
if(M[1]=== 'Chrome'){
tem= ua.match(/\b(OPR|Edge)\/(\d+)/);
if(tem!= null) return tem.slice(1).join(' ').replace('OPR', 'Opera');
}
M= M[2]? [M[1], M[2]]: [navigator.appName, navigator.appVersion, '-?'];
if((tem= ua.match(/version\/(\d+)/i))!= null) M.splice(1, 1, tem[1]);
return M.join(' ');
})();
console.log(navigator.sayswho);
我們可以用此條快速解決UA解析版本的問題
String.prototype.toSource異常檢測
主流瀏覽器都會發生異常,除非是特別低版本的瀏覽器,可以快速定位低版本瀏覽器,參考代碼:
getErrorFF: function() {
try {
throw "a"
} catch (e) {
try {
return e.toSource(),
!0
} catch (e) {
return !1
}
}
},
Audio/Video解碼能力測試
利用canPlayType接口,若大概率可以播放,則返回"probably",若確定無能力則返回空字符串; 不同的主流瀏覽器及版本會有比較顯著的特性,低版本瀏覽器將全部為空
audioCodecs: function() {
var e = document.createElement("audio")
, t = {}
, n = {
ogg: 'audio/ogg; codecs="vorbis"',
mp3: "audio/mpeg;",
wav: 'audio/wav; codecs="1"',
m4a: "audio/x-m4a;",
aac: "audio/aac;"
};
if (e.canPlayType)
for (var i in n)
t[i] = e.canPlayType(n[i]);
return t
},
videoCodecs: function() {
var e = document.createElement("video")
, t = {}
, n = {
ogg: 'video/ogg; codecs="theora"',
h264: 'video/mp4; codecs="avc1.42E01E"',
webm: 'video/webm; codecs="vp8, vorbis"',
mpeg4v: 'video/mp4; codecs="mp4v.20.8, mp4a.40.2"',
mpeg4a: 'video/mp4; codecs="mp4v.20.240, mp4a.40.2"',
theora: 'video/x-matroska; codecs="theora, vorbis"'
};
if (e.canPlayType)
for (var i in n)
t[i] = e.canPlayType(n[i]);
return t
},
window.eval hook檢測
不同瀏覽器長度會有所不同,firefox為37,chrome類的為33,同時eval中會包含'native code'關鍵字
u.deviceData.emptyEvalLength = eval.toString().length
網絡相關檢測
僅chrome支持,獲取網絡環境信息
navigator && navigator.connection && (r = navigator.connection,
u.deviceData.connection = {
effectiveType: r.effectiveType,
rtt: r.rtt,
downlink: r.downlink
})
webAssembly能力檢測
本條是在查閱資料過程中發現了還有類似功能的一個開源項目friendly challenge:GitHub - FriendlyCaptcha/friendly-challenge: The widget and docs for the proof of work challenge use,其中發現的一個檢測點;關于該項目一些相關點后續可以再總結
檢測方法其實比較簡單,使用一串可以被編譯的字串,使用webAssembly.compile進行編譯,嘗試捕獲異常,若捕獲則檢測失敗:
const A = WebAssembly.compile(function(A) {
const C = A.length;
let t = 3 * C >>> 2;
A.charCodeAt(C - 1) === I && t--, A.charCodeAt(C - 2) === I && t--;
const B = new Uint8Array(t);
for (let I = 0, t = 0; I < C; I += 4) {
const C = g[A.charCodeAt(I + 0)],
Q = g[A.charCodeAt(I + 1)],
e = g[A.charCodeAt(I + 2)],
r = g[A.charCodeAt(I + 3)];
B[t++] = C << 2 | Q >> 4, B[t++] = (15 & Q) << 4 | e >> 2, B[t++] = (3 & e) << 6 | 63 & r
}
return B
}("一個base64編碼的可編譯webAssembly源碼"))