使用前端技術(shù)破解掘金滑塊驗證碼
作者:codexu
原文:https://juejin.cn/post/7257386139849801789
玩爬蟲首先繞不過的一項就是登錄,絕大多數(shù)網(wǎng)站在登錄或注冊時都會使用驗證碼來驗證用戶是否為真實人類而不是機(jī)器人或惡意程序。我們常見的驗證碼有幾種形式,例如:數(shù)字字母驗證碼、滑塊驗證碼、算數(shù)驗證碼、圖片識別驗證碼等等,不同的方式帶來的用戶體驗和防御能力是不同的,現(xiàn)在很多網(wǎng)站為了兼顧用戶體驗都選擇滑塊驗證碼。
本文通過前端技術(shù) Puppeteer 來實現(xiàn)自動化操作,Canvas API 實現(xiàn)簡單的圖像識別,計算滑塊需要滑動距離,實現(xiàn)一個高效且識別概率很高的破解方案。
如果你對這些技術(shù)沒有了解也不用擔(dān)心,本文也兼顧了一些基礎(chǔ)知識。
為什么破解掘金滑塊驗證碼?
掘金在使用賬號和密碼組合登錄時,會出現(xiàn)滑塊驗證碼,這樣做是為了防止惡意程序順利登錄。選擇掘金有以下 3 個原因:
-
驗證碼底圖已經(jīng)包含了缺口(拼圖形狀),沒有獲取到完整的原始圖片,這樣提高了識別難度,因為直接像素對比計算即可得到缺口位置。 -
滑塊移動會記錄用戶操作軌跡,如果是機(jī)器生硬勻速拖拽,即使位置完全吻合也是無法完成校驗,這進(jìn)一步提高了難度。 -
我有些邪惡的想法,呵呵。
這里貼一張實際展示動圖:
快速上手 puppeteer
你需要掌握一點 node 知識,不需要太多,這里默認(rèn)你已經(jīng)會了。
前端爬蟲使用 puppeteer 是個不錯的選擇,它可以模擬用戶操作,你的所有代碼都是按照操作步驟一步一步的去編寫,上手非常簡單,你只需要了解幾個 API 就足夠你使用了。
每一個接口的其他配置,請自行參考文檔[1]。
安裝
npm i puppeteer
創(chuàng)建一個瀏覽器
這步相當(dāng)于你雙擊了你桌面上的 Chrome。
const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 1200,
height: 800,
},
args: [
'--no-sandbox',
'--disable-web-security',
`--window-size=1600,800`,
],
devtools: true,
});
-
headless: 設(shè)置為 false,你啟動程序后,會真的打開一個瀏覽器,這樣方便你調(diào)試。當(dāng)你程序調(diào)試通了之后你可以把它改為 'new'。 -
defaultViewport: 視口大小,太小掘金可能會自適應(yīng)為移動端。 -
devtools: 默認(rèn)打開開發(fā)者工具,方便調(diào)試,后續(xù)都可以關(guān)掉。 -
args: 一些瀏覽器參數(shù),這塊可以單獨去了解。
通過 browser 創(chuàng)建 page
這步相當(dāng)于你在 chrome 標(biāo)簽頁上單擊了 + 按鈕,創(chuàng)建了一個新的標(biāo)簽頁。
const page = await browser.newPage();
從這步開始,我們幾乎所有的操作都是在 page 對象上執(zhí)行,也就相當(dāng)于在頁面中你用鼠標(biāo)鍵盤操作一樣。
在 page 上的常見操作
幾乎所有的操作都是異步的,如果你不想看文檔,你每一步都加上 await 就行。
-
頁面跳轉(zhuǎn): page.goto()。 -
等待某個元素出現(xiàn)在頁面上: page.waitForSelector();。 -
點擊某個元素: page.click()。 -
表單填寫數(shù)據(jù): page.type(),填寫賬號密碼。 -
在瀏覽器中執(zhí)行代碼: page.evaluate(),圖像識別將在這里去做。 -
鼠標(biāo)操作: page.mouse.move()、page.mouse.down()、page.mouse.up(),拖拽滑塊。
學(xué)會這幾個 API 你就可以說已經(jīng)入門爬蟲了。
了解兩個 Canvas API
Canvas 提供了很多接口,我們只需要了解兩個即可:
-
drawImage(img,x,y,width,height) -
getImageData(x,y,width,height)
drawImage 是將圖片繪制到畫布上,我們可以獲取到驗證碼的圖片,然后繪制到畫布上,這樣我們就可以使用第二個接口 getImageData,它返回的 ImageData 可以讓我們獲取到畫布上每一個像素點的信息。
ImageData 包含了三個參數(shù):data、height、width。
-
data 是一個一維數(shù)組,按照 rgba 的順序排列,值為 0-255 整數(shù) [r,g,b,a,r,g,b,a,....,r,g,b,a]。 -
其余兩個是寬高,這里注意 data 的長度就是 4_width_height,4 就是 rgba。
拿到圖像數(shù)據(jù)后,我們就可以按照我們的需求去處理了。
開始破解
在自動破解滑塊驗證碼前,我們先捋清流程。
-
使用 puppeteer 創(chuàng)建瀏覽器和頁面,跳轉(zhuǎn)到登錄頁面,點擊賬號密碼登錄,在輸入框輸入賬號密碼,點擊登錄,這幾步操作,按照上面講過的 api 可以非常簡單的實現(xiàn)。 -
這時驗證碼圖片已經(jīng)出現(xiàn),我們在瀏覽器內(nèi)部去做圖像識別,這里用到 evaluate 方法。 -
為了提高識別概率,對圖片進(jìn)行灰度和二值化處理,然后通過識別算法計算出滑塊需要滑動的距離。 -
拖拽滑塊,模擬真人操作軌跡,完成校驗。
第一步,實現(xiàn)自動賬號密碼填寫
上文已經(jīng)提到過如何創(chuàng)建瀏覽器和頁面,我們從創(chuàng)建頁面后開始寫代碼,這時我們已經(jīng)有了 browser, page 對象。
// 跳轉(zhuǎn)到掘金登錄頁面
await page.goto('https://juejin.cn/login');
// 等待密碼登錄按鈕出現(xiàn)
await page.waitForSelector('.other-login-box .clickable');
// 點擊密碼登錄按鈕
await page.click('.other-login-box .clickable');
// 等待賬號密碼輸入框出現(xiàn)
await page.waitForSelector('.input-group input[name="loginPhoneOrEmail"]');
// 輸入手機(jī)號碼和密碼
await page.type('.input-group input[name="loginPhoneOrEmail"]', '15000000000');
await page.type('.input-group input[name="loginPassword"]', 'codexu666');
// 點擊登錄按鈕
await page.click('.panel .btn');
到這步自動登錄操作已經(jīng)完成了一半,接下來的驗證碼雖然花費時間也只占一半,但是代碼量和需要理解的程度開始顯著提高。
處理圖片并獲取到滑動距離
補(bǔ)充兩組驗證碼圖片,方便大家觀察規(guī)律:
我們可以看到缺口有白色描邊,缺口是半透明深色。
代碼:
// 等待驗證碼 img 標(biāo)簽加載(注意這里還沒有加載完成圖片)
await page.waitForSelector('#captcha-verify-image');
// 調(diào)用 evaluate 可以在瀏覽器中執(zhí)行代碼,最后返回我們需要的滑動距離
const coordinateShift = await page.evaluate(async () => {
// 從這開始就是在瀏覽器中執(zhí)行代碼,已經(jīng)可以看到我們用熟悉的 querySelector 查找標(biāo)簽
const image = document.querySelector('#captcha-verify-image') as HTMLImageElement;
// 創(chuàng)建畫布
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d');
// 等待圖片加載完成
await new Promise((resolve) => {
image.onload = () => {
resolve(null);
};
});
// 將驗證碼圖片繪制到畫布上
ctx.drawImage(image, 0, 0, image.width, image.height);
// 獲取畫布上的像素數(shù)據(jù)
const imageData = ctx.getImageData(0, 0, image.width, image.height);
// 將像素數(shù)據(jù)轉(zhuǎn)換為二維數(shù)組,處理灰度、二值化,將像素點轉(zhuǎn)換為0(黑色)或1(白色)
const data: number[][] = [];
for (let h = 0; h < image.height; h++) {
data.push([]);
for (let w = 0; w < image.width; w++) {
const index = (h * image.width + w) * 4;
const r = imageData.data[index] * 0.2126;
const g = imageData.data[index + 1] * 0.7152;
const b = imageData.data[index + 2] * 0.0722;
if (r + g + b > 100) {
data[h].push(1);
} else {
data[h].push(0);
}
}
}
// 計算每一列黑白色像素點相鄰的個數(shù),找到最多的一列,大概率為缺口位置
let maxChangeCount = 0;
let coordinateShift = 0;
for (let w = 0; w < image.width; w++) {
let changeCount = 0;
for (let h = 0; h < image.height; h++) {
if (data[h][w] == 0 && data[h][w - 1] == 1) {
changeCount++;
}
}
if (changeCount > maxChangeCount) {
maxChangeCount = changeCount;
coordinateShift = w;
}
}
return coordinateShift;
});
上面的幾十行代碼,從獲取到 imageData 以前都很好理解,即創(chuàng)建畫布,繪制圖片,獲取圖片數(shù)據(jù)。
后面兩段循環(huán)這里拆分開講:
第一段循環(huán),圖片灰度+二值化處理
這段是上面代碼的第一組循環(huán),增加了一些注釋:
// 將像素數(shù)據(jù)轉(zhuǎn)換為二維數(shù)組,處理灰度、二值化,將像素點轉(zhuǎn)換為0(黑色)或1(白色)
const data: number[][] = [];
for (let h = 0; h < image.height; h++) {
data.push([]);
for (let w = 0; w < image.width; w++) {
// ×4 是因為 rgba 4 個值,圖片是 jpg 格式,不存在透明,所以我們忽略 a 值
const index = (h * image.width + w) * 4;
// 灰度計算公式,如果要實現(xiàn)灰度效果,要把 rgb 結(jié)果相加,再賦值到 rgb
const r = imageData.data[index] * 0.2126;
const g = imageData.data[index + 1] * 0.7152;
const b = imageData.data[index + 2] * 0.0722;
// 這里做二值化處理,如果要展示圖片,rgb 賦值 255 或者 0
if (r + g + b > 100) {
data[h].push(1);
} else {
data[h].push(0);
}
}
}
灰度
通過灰度處理,能夠更好地突出圖像的形狀,去除了顏色(變?yōu)楹诎祝@些特征的干擾,可以提高處理的準(zhǔn)確性和速度。
灰度計算公式 *GRAY = 0.2126 * R + 0.7152 * G + 0.0722 *B*
二值化
通過二值化處理,可以將圖像轉(zhuǎn)換為只有黑白兩種顏色的二值圖像,突出物體輪廓,極大的減少數(shù)據(jù)量,便于后續(xù)特征提取的操作。
二值化我們可以通過 R + G + B 相加對比大于一個值,我這里設(shè)置為 100,這個值不是固定的,你可以試下修改它圖像的變化。
上述的代碼中并不能展示出上面兩張圖的效果,僅為了最后結(jié)果做出了計算,你可以按照上述的處理方式,自行處理出展示的圖片效果。
第一段循環(huán)中,我們將 imageData 轉(zhuǎn)換為了只包含 0 (黑色)和 1(白色)的二維數(shù)組,我將數(shù)據(jù)導(dǎo)出來,可以看下效果是這樣的:
這是一張用 0 和 1 排列出來的圖片,可以大概看到驗證碼缺口的位置,讓我們放大這里:
紅框內(nèi)就是缺口與底圖的交界處。
第二段循環(huán),計算識別的位置
// 計算每一列黑白色像素點相鄰的個數(shù),找到最多的一列,大概率為缺口位置
let maxChangeCount = 0;
let coordinateShift = 0;
for (let w = 0; w < image.width; w++) {
let changeCount = 0;
for (let h = 0; h < image.height; h++) {
// 對比相鄰的兩個值是否是 1 和 0
if (data[h][w] == 0 && data[h][w - 1] == 1) {
changeCount++;
}
}
if (changeCount > maxChangeCount) {
maxChangeCount = changeCount;
coordinateShift = w;
}
}
// 這就是我們最后要計算滑塊運動的距離。
return coordinateShift;
有了上面的數(shù)據(jù),我們來分析如何識別要滑動的位置。
再次放大到缺口的左上角:
(w,h)是第二個循環(huán)中的參數(shù)
現(xiàn)在可以非常方便的找到缺口開始的位置,只需判斷每個 (w,h) 像素值和前一個像素 (w-1,h)的值變化情況即可。如果某一列出現(xiàn)大量從 1 變化到 0 的像素,大概率是達(dá)到了缺口的的起始邊界,這列所在的 w 值就是要移動的距離。
這次我們獲取到在 336 列出現(xiàn)了最多的 1,0 數(shù)據(jù),那我們判斷 336 為本次滑動的距離。
拖拽滑塊,模擬真人操作軌跡
到這一步看起來后面的操作就非常簡單了,只需要拖拽到計算的結(jié)果位置即可,但是掘金還做了另一手準(zhǔn)備,就是用戶的拖拽軌跡識別。
如果使用簡單的循環(huán)拖拽過去,即使位置準(zhǔn)確也無法通過驗證,大家可以自行試一下,你也可以拖拽隨意甩幾下再拖拽到缺口位置一樣也無法通過校驗,既然這樣我們就要看一下什么樣的操作才是真人的操作。
通過記錄了一次真實的拖拽滑塊驗證碼,獲取到的運動軌跡曲線:
大概可以看出大概情況是,緩慢加速 -> 快速加速 -> 減速 -> 微調(diào),這種曲線很像 JQuery 提供的緩動動畫函數(shù)或者 css 提供的緩動動畫曲線:
軌跡和 easeOut 類型的很類似,這里我試了一下 easeOutBounce 函數(shù)都可以成功,只要位置匹配,就可以通過驗證,所以具體使用哪種類型還需要按照真實情況來選擇:
// 你無需理解參數(shù)都是什么作用
function easeOutBounce(t: number, b: number, c: number, d: number) {
if ((t /= d) < 1 / 2.75) {
return c * (7.5625 * t * t) + b;
} else if (t < 2 / 2.75) {
return c * (7.5625 * (t -= 1.5 / 2.75) * t + 0.75) + b;
} else if (t < 2.5 / 2.75) {
return c * (7.5625 * (t -= 2.25 / 2.75) * t + 0.9375) + b;
} else {
return c * (7.5625 * (t -= 2.625 / 2.75) * t + 0.984375) + b;
}
}
有了運動軌跡曲線,我們就可以實現(xiàn)拖拽邏輯了:
const drag = await page.$('.secsdk-captcha-drag-icon');
const dragBox = await drag.boundingBox();
const dragX = dragBox.x + dragBox.width / 2 + 2;
const dragY = dragBox.y + dragBox.height / 2 + 2;
await page.mouse.move(dragX, dragY);
await page.mouse.down();
await page.waitForTimeout(300);
// 定義每個步驟的時間和總時間
const totalSteps = 100;
const stepTime = 5;
for (let i = 0; i <= totalSteps; i++) {
// 當(dāng)前步驟占總時間的比例
const t = i / totalSteps;
// 使用easeOutBounce函數(shù)計算當(dāng)前位置占總距離的比例
const easeT = easeOutBounce(t, 0, 1, 1);
const newX = dragX + coordinateShift * easeT - 5;
const newY = dragY + Math.random() * 10;
await page.mouse.move(newX, newY, { steps: 1 });
await page.waitForTimeout(stepTime);
}
// 松手前最好還是等待一下,這也很符合真實操作
await page.waitForTimeout(800);
await page.mouse.up();
到這里我們就自動驗證滑塊驗證碼,實現(xiàn)了自動登錄的效果,但是識別概率不可能完全達(dá)到100%,這時我們再加入試錯機(jī)制就可以了:
try {
// 等待校驗成功的元素出現(xiàn)
await page.waitForSelector('.captcha_verify_message-success', {
timeout: 1000,
});
} catch (error) {
await page.waitForTimeout(500);
// 再次執(zhí)行上面的代碼
await this.handleDrag(page);
}
這樣即使識別錯誤,再次嘗試也就可以了。
其他方式
人工
雖然不符合本文的主題,但是也不失為一個快捷的手段。首先 headless 設(shè)為 false,我們可以人工去校驗驗證碼,然后通過保存 cookie 記錄登錄狀態(tài),可以使用在 cookie 有效期還是比較長的情況。
掘金記錄 sessionid 即可,省心的話就全存起來沒壞處。
截圖像素對比
通過圖像對比,移動獨立缺口的圖片,不斷截圖,與底圖像素對比,圖像吻合度最高的點即為滑動距離,優(yōu)點是識別率更高,缺點是速度極慢,可能要花十幾秒甚至幾十秒。
這是一張截圖與底圖像素差異的對比,我們?nèi)〔ü鹊奈恢眉纯伞?/p>
機(jī)器學(xué)習(xí)
最靠譜的方式,但這種方式成本和要求較高,有興趣的同學(xué)自行了解吧。
總結(jié)
不同的驗證碼都在于戶體驗和安全兩項抉擇,滑塊驗證碼用戶體驗好,導(dǎo)致了它也比較容易破解。使用 puppeteer 爬蟲相比于 python 更適合我們前端使用,他簡單易用,更容易繞過服務(wù)器機(jī)器人檢測,缺點是內(nèi)存使用比較高,日常使用已經(jīng)足夠。
最后奉勸大家學(xué)會了不要亂搞事情。
參考資料
http://www.puppeteerjs.com/: https://link.juejin.cn?target=http%3A%2F%2Fwww.puppeteerjs.com%2F
