【驗證碼逆向專欄】數美驗證碼全家桶逆向分析以及 AST 獲取動態引數

K哥爬蟲發表於2023-05-08

宣告

本文章中所有內容僅供學習交流使用,不用於其他任何目的,不提供完整程式碼,抓包內容、敏感網址、資料介面等均已做脫敏處理,嚴禁用於商業用途和非法用途,否則由此產生的一切後果均與作者無關!

本文章未經許可禁止轉載,禁止任何修改後二次傳播,擅自使用本文講解的技術而導致的任何意外,作者均不負責,若有侵權,請在公眾號【K哥爬蟲】聯絡作者立即刪除!

目標

  • 目標:數美全家桶,包括:滑塊、文字點選、圖示點選、語序點選、空間推理、無感驗證
  • 地址:
// 官網體驗地址
aHR0cHM6Ly93d3cuaXNodW1laS5jb20vdHJpYWwvY2FwdGNoYS5odG1s
// 官方隱藏地址
aHR0cHM6Ly9jYXN0YXRpYy5mZW5na29uZ2Nsb3VkLmNuL3ByL3YxLjAuNC9kZW1vLmh0bWw=
// 某紅書驗證頁面
aHR0cHM6Ly93d3cueGlhb2hvbmdzaHUuY29tL3dlYi1sb2dpbi9jYXB0Y2hh

數美不同型別驗證碼核心的 JS 都是一樣的,只是個別引數有微小差別,主要以滑塊為例來分析,透過 JS 程式碼以及官方文件可以看出數美是有無感驗證的,但是官網體驗地址裡並沒有放出來,官方有一個隱藏地址,裡面的 demo 是最全的,包括無感,可以去上面給出的第二個地址裡檢視;數美的加密引數包含了 DES 加密演算法,引數名以及 DES Key 不定時會變化,本文也會分析如何利用 AST 來獲取動態的引數。

01

抓包分析

conf 介面,獲取配置,主要是獲取核心的 captcha-sdk.min.js 的地址,請求引數解釋:

引數含義
organization數美分配的公司標識,一般是每個網站唯一,寫死即可
appId應用標識,區分不同應用,數美后臺可以管理
callback回撥引數
lang語言,zh-cn 簡體中文、zh-tw 繁體中文、en 英文
model模式,slide 滑塊、auto_slide 無感驗證、select 文字點選、icon_select 圖示點選、seq_select 語序點選、spatial_select 空間推理
sdkver這個 sdk 版本是 captcha-sdk.min.js 內部寫死的
channel推廣渠道,數美后臺可以管理
captchaUuid32位隨機字串,與業務方自身埋點資料配合,便於後續定位問題或進行資料統計
rversioncaptcha-sdk.min.js 版本號

02

返回結果重點看 captcha-sdk.min.js 檔案地址,如下圖所示有個 v1.0.4-171,本文中我們稱 v1.0.4 為大版本,171 為小版本,小版本不定時會更新,版本號不斷升高。

03

然後就是 register 介面,不同型別,返回的資料都大同小異,其中 bg 是背景圖片,fg 是滑塊,文字點選、空間推理中 order 是提示資訊,klrid 三個引數後續會用到。

04

05

06

最後就是 fverify 驗證介面,有類似下圖紅框中的 12 個引數,都是透過 JS 生成的,其引數名會根據 captcha-sdk.min.js 的變化而變化,其中有個最長的類似於下圖的 ep 值,包含了軌跡加密。返回值裡引數解釋:

引數含義
code1100:成功;1901:QPS超限;1902:引數不合法;1903:服務失敗;9101:無許可權操作
riskLevel處置建議,PASS:正常,建議直接放行;REJECT:違規,建議直接攔截

07

08

逆向分析

跟棧會發現核心邏輯在 captcha-sdk.min.js 裡,這個 JS 類似於 OB 混淆(以前的文章介紹過,此處不再細說):

09

這裡可以自己寫 AST 還原一下,為了方便我們直接使用 v_jstools 解混淆:

10

然後替換掉原來的 captcha-sdk.min.js,如果你測試的是官網的體驗頁面,使用 Fiddler 替換時要注意可能有跨域問題,需要利用 Filters 功能,設定響應頭 Access-Control-Allow-Origin 欄位值為當前域名:

11

12

如果你沒注意到這個跨域問題,可能會替換之後發現沒替換成功,原因是數美的資源有四個域名,其中一個宕了便會啟用另一個,你替換其中一個報錯了就會自動跳轉另一個,所以看起來你並沒有替換成功:

13

PS:若替換的 JS 格式化了,那麼你在網頁上滑動也是校驗失敗的,因為 JS 裡檢測了格式化,將 JS 壓縮成一行再替換即可,具體檢測的位置後文會講到。

captchaUuid

直接搜尋關鍵詞下斷點,經過多次除錯會發現第一個出現 captchaUuid 的地方是在 smcp.min.js,如下圖所示:

14

這裡的棧並不多,來回跟棧也沒發現是哪裡生成的,此時可以從初始位置也就是 embed.html 初始化驗證碼的地方開始單步跟:

15

單步跟進去會發現一個 getCaptchaUuid() 的方法,將此方法扣出來即可。

16

function generateTimeFormat() {
    var e = new Date()
    , t = function(n) {
        return +n < 10 ? "0" + n : n.toString();
    };
    return ((e.getFullYear().toString() + t(e.getMonth() + 1)) + t(e.getDate()) + t(e.getHours()) + t(e.getMinutes())) + t(e.getSeconds());
}

function getCaptchaUuid() {
    var c = "";
    var o = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678";
    var s = o.length;
    for (var a = 0; a < 18; a++) {
        c += o.charAt(Math.floor(Math.random() * s));
    }
    return generateTimeFormat() + c;
}

12 個加密引數

直接跟棧就很容易找到,如下圖所示的位置,D 就是生成的所有引數,此外,也可以透過搜尋關鍵字 getEncryptContent 或者直接搜尋引數名稱來定位。

17

可以發現上圖裡就有四個加密引數,都用到了 getEncryptContent 這個加密方法,加密方法傳入兩個引數,一個是待加密引數,一個是 DES Key,這四個待加密引數分別為 appId 值、channel 值、lang 值和一個 getSafeParams 方法。

18

重點跟進 getEncryptContent 方法看看,一個控制流,挑幾個重點的講一下,第一步是獲取一個 key,這個 key 是在前面設定的,後續會講到,實際上這個 key 沒啥用。

19

然後會有一個 isJsFormat 的格式化檢測函式,正常應該是 false 的,如果你格式化了就為 true,也就會導致 f 的值為時間戳加數美的域名,這個 f 值後續是 DES 的 Key,不對的話自然怎麼滑都不會透過。

20

然後就是 DES 加密了,這個 DES 是標準的加密演算法,下圖中傳入的 1 和 0 表示的是加密,0 和 0 則表示解密,解密的情況也有,後續會遇到,modeECBpaddingZeroPadding,不需要 iv,可以直接扣程式碼,或者直接引庫即可。

21

var CryptoJS = require("crypto-js")

function DESEncrypt(key, word) {
    var key_ = CryptoJS.enc.Utf8.parse(key);
    var srcs = CryptoJS.enc.Utf8.parse(word);
    var encrypted = CryptoJS.DES.encrypt(srcs, key_, {
        mode: CryptoJS.mode.ECB,
        padding: CryptoJS.pad.ZeroPadding
    });
    return encrypted.toString();
}

function DESDecrypt(key, word) {
    var key_ = CryptoJS.enc.Utf8.parse(key);
    var decrypt = CryptoJS.DES.decrypt(word, key_, {
        mode: CryptoJS.mode.ECB,
        padding: CryptoJS.pad.ZeroPadding
    });
    return decrypt.toString(CryptoJS.enc.Utf8);
}

這裡的四個值就分析完了,還有八個值是在前面生成的,如下圖所示 x 的值即為其他八個值,往前看是一個函式生成的,往裡面跟即可。

22

跟進來是一個 getMouseAction 方法,裡面先是挨個取值,後續會對這些值進行 DES 加密,下圖中的 a、c 引數就是 register 介面返回的 k、l 值,s 引數是對 register 介面返回的 k 值進行解密操作:

23

上圖中 u = this._data 裡面的值,根據滑塊、點選、無感模式的不同,也有所差異,以下程式碼中,以 baseData 來表示 this._data 的值,根據模式的不同,可分為三類,大致構成如下:

滑塊(slide):

/* 
track:滑動軌跡(x, y, t),distance:滑動距離,randomNum:生成兩數之間的隨機值,示例:
var track = [[0, -2, 0], [62, 1, 98], [73, 4, 205], [91, 3, 303], [123, -3, 397], [136, 8, 502], [160, 0, 599], [184, 0, 697], [169, 0, 797]]
var distance = 169
 */

var baseData = {}
baseData.mouseData = track
baseData.startTime = 0
baseData.endTime = track[track.length - 1][2] + randomNum(100, 500)
baseData.mouseEndX = distance
baseData.trueWidth = 300
baseData.trueHeight = 150
baseData.selectData = []
baseData.blockWidth = 40

滑塊軌跡生成程式碼:

def get_sm_track(distance):
    track_length = random.randint(4, 10)
    track = [[0, -2, 0]]
    m = distance % track_length
    e = int(distance / track_length)
    for i in range(track_length):
        x = (i + 1) * e + m + random.randint(20, 40)
        y = -2 + (random.randint(-1, 10))
        t = (i + 1) * 100 + random.randint(-3, 5)
        if i == track_length - 1:
            x = distance
            track.append([x, y, t])
        else:
            track.append([x, y, t])
    logger.info("track: %s" % track)
    return track

點選類(文字點選 select、圖示點選 icon_select、語序點選 seq_select、空間推理 spatial_select):

/*
coordinate:點選座標(x, y),randomNum:生成兩數之間的隨機值,示例:
var coordinate = [[171, 101], [88, 102], [138, 109], [225, 100]]
 */

var baseData = {}
var time_ = new Date().getTime()
coordinate.forEach(function(co) {
    co[0] = co[0] / 300
    co[1] = co[1] / 150
    co[2] = time_
    time_ += randomNum(100, 500)
})
baseData.mouseData = coordinate
baseData.startTime = time_ - randomNum(800, 20000)
baseData.endTime = coordinate[coordinate.length - 1][2]
baseData.mouseEndX = 0
baseData.trueWidth = 300
baseData.trueHeight = 150
baseData.selectData = coordinate
baseData.blockWidth = undefined

無感(auto_slide):

/*
randomNum:生成兩數之間的隨機值
*/

var baseData = {}
baseData.mouseData = [[0, 0, 0]]
baseData.startTime = 0
baseData.endTime = randomNum(100, 500)
baseData.mouseEndX = 260
baseData.trueWidth = 300
baseData.trueHeight = 150
baseData.selectData = []
baseData.blockWidth = 40

這些值生成完了之後,就是挨個透過 getEncryptContent 進行加密,前面已經分析過,實際上就是 DES 加密,可以看到分為點選、滑塊和無感三類,其中 DES Key 也是會每隔一段時間變化的:

24

再往下走還有三個加密引數,待加密值是定值,然後將 s 的值(也就是前面 register 介面返回的 k 經過 DES 解密後的值賦值給了 this._data.__key)。

25

至此所有加密引數就搞完了。

結果驗證

26

27

28

29

30

31

AST 獲取動態引數

前面說了,/v1.0.4-171/captcha-sdk.min.js 檔案地址,我們稱 v1.0.4 為大版本,171 為小版本,小版本每隔一段時間會更新,版本號會不斷升高,具體更新週期是多少?這裡推薦一個方法 document.lastModified,該方法記錄的是物理網頁的最後修改時間,我們直接訪問 JS 地址,就可以直接檢視不同版本的 JS 是啥時候更新的了,多對比幾個版本,發現更新間隔時間並沒有太明顯的規律,如下圖所示:

32

33

34

不同版本里面的 12 個加密引數的名稱和 DES 加密的 Key 都不一樣,我們可以利用 AST 來動態獲取這 12 個引數,經過測試,以下版本均可正常提取:

  • v1.0.4-148 ~ v1.0.4-171
  • v1.0.3-147 ~ v1.0.3-171
  • v1.0.1-147 ~ v1.0.1-171

截止本文釋出,小版本 171 為最新,v1.0.4 小版本從 148 開始,v1.0.3v1.0.1147 以前沒有混淆,可自行正則匹配,暫未發現其他大版本,如有遇到不能適配的,可聯絡我瞅瞅,完整的程式碼在公眾號 k哥爬蟲 中,有需要的可以點選下方連結。
【驗證碼逆向專欄】數美驗證碼全家桶逆向分析以及 AST 獲取動態引數

PS:此 AST 程式碼僅實現對動態引數的提取,並非還原所有的混淆,提取出來的結果是有序、未去重的,後續按索引取就行。

35

相關文章