博客 / 詳情

返回

【驗證碼逆向專欄】螺絲帽人機驗證逆向分析

聲明

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

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

目標

目標:Luosimao 螺絲帽人機驗證逆向分析

網址:aHR0cHM6Ly9jYXB0Y2hhLmx1b3NpbWFvLmNvbS9kZW1vLw==

01

抓包分析

進入官網提供的 demo 頁面,F12 開啓抓包,首先加載 demo 頁面,這個頁面包含一個 site-key,每個網站都不一樣,會在後續用到:

02

接下來是一個 captcha.js,主要用於後續的加密參數生成,乍一看以為是個 OB 混淆,其實只是更換了變量名,然後一些值是從大數組裏面取的,沒有 OB 混淆裏的打亂數組的操作,比 OB 混淆要簡單很多,後文會利用 AST 對這三個 JS 進行解混淆,後續類似的還加載了 widget.jsframe.js,也都是和加密參數的生成有關。

03

04

然後是一個 widget 的請求,該請求返回的源碼裏面有個 data-token,也是後續要用到的。

05

接下來是一個 request 的請求,接口返回的一些參數也是後續要用到的,同時返回的 w 值,就是要點擊的文字提示信息。

06

然後是一個 frame 請求,請求帶了兩個加密參數,這個請求返回的源碼裏面包含了驗證碼圖片信息。

07

然後就加載了驗證碼圖片,注意這裏的圖片是被切割之後亂序排列了的,和極驗三代的類似,所以後文我們還要對其進行順序還原。

08

點擊圖像完成之後,就會發起校驗請求 user_verify,校驗成功的話返回的 ressuccess,相反校驗不成功就是 failed

09

點擊立即登錄,觸發最後一個 submit 請求,提交的 data 值就是上一步 user_verify 驗證成功後返回的 resp 值。

10

小結一下螺絲帽就可以分為三個比較重要的步驟:request 接口請求得到要點擊的內容,frame 接口請求拿到驗證碼圖片,user_verify 接口驗證點擊是否正確,下文將詳細分析這些步驟。

AST 解混淆

先彆着急找加密邏輯,前面抓包的時候説了,一共有三個 JS 參與了加密,分別是 captcha.jswidget.jsframe.js,這三個 JS 是被混淆了的,為了後續比較好分析,我們可以先使用 babel 將其轉換成 AST 語法樹後,進行解混淆操作。

widget.js 為例,觀察該 JS,我們可以總結出以下三個問題:

  • 開頭一個大數組,如 _0x8f24,後續變量賦值操作就是從這個大數組裏取值,如 _0x8f24[1]_0x8f24[2]
  • 所有的字符串都被轉換成了十六進制編碼的形式,不易閲讀;
  • 訪問對象屬性是 _0x3ba3x1["Number"],而不是 _0x3ba3x1.Number,不易閲讀。

所以我們只需要做三個操作:

  • 從數組取值轉為直接賦值(_0x8f24[1] => "\x63\x61\x6C\x6C");
  • 十六進制編碼的字符串還原("\x63\x61\x6C\x6C" => "call");
  • 對象屬性還原(_0x3ba3x1["Number"] => _0x3ba3x1.Number)。

11

首先是從數組取值轉為直接賦值,先將這個 JS 扔到 astexplorer.net 分別看看原始結構(如:_0x8f24[1])和替換後的結構(如:"\x63\x61\x6C\x6C"):

12

13

從上圖可以看到類似 _0x8f24[1] 取值的節點類型為 MemberExpression,這個大數組沒有像 OB 混淆那樣做了亂序操作,可以直接取值,那麼如果我們先拿到 _0x8f24 這個大數組,然後遍歷 MemberExpression 節點,再將其替換成 StringLiteral 類型的節點就行了。當然遍歷的時候也要有限制,必須是 path.node.object.name 的值和大數組的名稱一樣才能替換。然後就是我們怎麼拿到 _0x8f24 這個大數組呢?這個大數組在 AST 中的位置是 program.body[0],我們可以將其轉換成 JS 代碼然後 eval 執行一下,把大數組加載到內存裏,後續就能直接按索引取值了,當然方法不止這一種,可以按照自己的思路來實現,這一部分的 visitor 可以這麼寫:

const ast = parse(code);
eval(generate(ast.program.body[0]).code)

const visitor = {
    MemberExpression(path) {
        if (path.node.object.name === "_0x8f24") {
            path.replaceWith(types.stringLiteral(eval(path.toString())));
        }
    }
}

然後就是十六進制編碼的字符串還原,觀察前後的 AST 語法樹:

14

15

可以發現只要將 path.node.extra.raw 的值換為 path.node.extra.rawValue 或者 path.node.value即可,當然因為 NumericLiteralStringLiteral 類型的extra 節點並非必需,這樣在將其刪除時,也不會影響原節點,所以還可以直接 delete path.node.extra 或者 delete path.node.extra.raw 來還原字符串,這一部分的 visitor 可以這麼寫:

const visitor2 = {
    StringLiteral(path) {
        if (path.node.extra) {
            // 以下方法均可
            // path.node.extra.raw = '"' + path.node.extra.rawValue + '"'
            // path.node.extra.raw = '"' + path.node.value + '"'
            // delete path.node.extra
            delete path.node.extra.raw
        }
    }
}

最後就是對象屬性還原,同樣的先觀察前後的 AST 語法樹:

16

17

可以看到 _0x3ba3x1["Number"] => _0x3ba3x1.Number,是 MemberExpression 下的 property 節點由 StringLiteral 類型的變成了 Identifier 類型的,computed 值由 true 變成了 false,這一部分的 visitor 可以這麼寫:

const visitor = {
    MemberExpression(path){
        if (path.node.property.type === "StringLiteral" && path.node.property.value !== "") {
            path.node.computed = false
            path.node.property = types.identifier(path.node.property.value)
        }
    }
}

前面抓包的時候也説了,一共有三個 JS 參與了加密,分別是 captcha.jswidget.jsframe.js,他們的混淆都是一樣的,所以綜上所述我們的 AST 解混淆代碼完整版可以是這樣的:

const fs = require('fs');
const types = require("@babel/types");
const parse = require("@babel/parser").parse;
const traverse = require("@babel/traverse").default;
const generate = require("@babel/generator").default;


function deconfusion(code, arrName) {
    const ast = parse(code);
    eval(generate(ast.program.body[0]).code)

    const visitor1 = {
        MemberExpression(path) {
            if (path.node.object.name === arrName) {
                path.replaceWith(types.stringLiteral(eval(path.toString())));
            }
        }
    }

    const visitor2 = {
        StringLiteral(path) {
            if (path.node.extra) {
                // 以下方法均可
                // path.node.extra.raw = '"' + path.node.extra.rawValue + '"'
                // path.node.extra.raw = '"' + path.node.value + '"'
                // delete path.node.extra
                delete path.node.extra.raw
            }
        },
        MemberExpression(path){
            if (path.node.property.type === "StringLiteral" && path.node.property.value !== "") {
                path.node.computed = false
                path.node.property = types.identifier(path.node.property.value)
            }
        }
    }


    traverse(ast, visitor1);
    traverse(ast, visitor2);
    delete ast.program.body[0]

    return generate(ast, {jsescOption: {"minimal": true}}).code
}


const widget = fs.readFileSync('widget.js', 'utf-8');
const newWidget = deconfusion(widget, "_0x8f24")
fs.writeFileSync('newWidget.js', newWidget, 'utf-8');


const captcha = fs.readFileSync('captcha.js', 'utf-8');
const newCaptcha = deconfusion(captcha, "_0x2d28")
fs.writeFileSync('newCaptcha.js', newCaptcha, 'utf-8');

const  frame = fs.readFileSync('frame.js', 'utf-8');
const newFrame = deconfusion(frame, "_0x3f7b")
fs.writeFileSync('newFrame.js', newFrame, 'utf-8');

解混淆之後,將代碼替換掉原始代碼,然後就可以愉快的進行分析了。

獲取驗證碼信息

首先來看 request 接口,POST 請求,params 有 k 和 l 兩個參數,data 有 bg 和 b 兩個加密參數,如下圖所示:

18

k 參數通過直接搜索可以發現就存在於頁面的 html 裏,如下圖所示的 data-site-key 就是 k 的值,從這個名字也可以看出應該是每個網站分配的一個 key。

19

bg 和 b 參數搜索不到,且每次都是變化的,通過觀察可知這是一個 XHR 請求,那麼就可以通過 XHR 斷點,或者直接跟棧的方式來找加密入口,好在棧也不多,直接跟進去下斷點,在 ajax send 方法這裏,就可以看到 bg 和 b 已經生成。

20

21

繼續往上跟棧,就很容易發現 bg 和 b 的生成位置,如下圖所示:

22

"bg=" + _0x3ba3xc.encryption(_0x3ba3x1) + "&b=" + _0x3ba3xc.encryption(_0x3ba3x3),先來看 _0x3ba3x1_0x3ba3x3 是怎麼生成的:

23

var _0x3ba3x1 = _0x3ba3xc.env.us + "||" + _0x3ba3xc.getToken() + "||" + _0x3ba3xc.env.sc.w + ":" + _0x3ba3xc.env.sc.h + "||" + _0x3ba3xc.env.pf.toLowerCase() + "||" + _0x3ba3xc.prefix.toLowerCase(),
_0x3ba3x3 = _0x3ba3xc.path[0] + ":" + _0x3ba3xc.timePoint[0] + "||" + _0x3ba3xc.path[1] + ":" + _0x3ba3xc.timePoint[1];
  • _0x3ba3xc.env.us:User-Agent;
  • _0x3ba3xc.env.sc.w:屏幕寬度;
  • _0x3ba3xc.env.sc.h:屏幕高度;
  • _0x3ba3xc.env.pf.toLowerCase():platform(如 win32) 小寫;
  • _0x3ba3xc.prefix.toLowerCase():瀏覽器引擎(如 webkit)小寫。

_0x3ba3xc.getToken() 是一個函數,跟進去可以看到是取 widget 請求返回的 html 裏面的 data-token 值,如下圖所示:

24

25

widget 請求還有個 i 參數,也是加密生成的,直接全局搜索 i:,可以發現在 captcha.js_0x7125x5.id 就是 i 的值,如下圖所示:

26

跟進去,generateID() 方法 return "_" + Math.random().toString(36).substr(2, 9); 就可以生成這個值了。

27

然後是 _0x3ba3x3,主要由 path 和 timePoint 組成,反覆對比你會發現,path = [鼠標第一次進入點擊區域的座標,鼠標點擊時的座標]timePoint = [頁面加載完畢的時間,開始點擊的時間],如下圖所示,可以在左上角和右下角都點一下看看這個點擊的區域座標範圍是啥,然後隨機構建一下就行了。

28

總結下來,_0x3ba3x1_0x3ba3x3 就可以通過以下代碼實現:

function randomNum(min, max) {
    return Math.floor(Math.random() * (max - min + 1) + min);
}

const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
const screen = {width: 1920, height: 1080};
const platform = "Win32";
const prefix = "Webkit";
//[鼠標第一次進入點擊區域的座標,鼠標點擊時的座標]
const path = [
    `${randomNum(60, 200)},${randomNum(0, 3)}`,
    `${randomNum(60, 200)},${randomNum(10, 20)}`
];
// [頁面加載完畢的時間,開始點擊的時間]
const time = +new Date();
const timePoint = [time, time + randomNum(1000, 6000)];

const _0x3ba3x1 = ua + "||" + token + "||" + screen.width + ":" + screen.height + "||" + platform.toLowerCase() + "||" + prefix.toLowerCase();
const _0x3ba3x3 = path[0] + ":" + timePoint[0] + "||" + path[1] + ":" + timePoint[1];

最後一步加密 "bg=" + _0x3ba3xc.encryption(_0x3ba3x1) + "&b=" + _0x3ba3xc.encryption(_0x3ba3x3);,跟進 encryption 方法熟悉的 iv、mode、padding,但他這裏寫的卻是 SHA3,很明顯是騙人的,對比測試一下加密結果,發現是 AES 加密,直接引庫就完事兒了。

29

至此 request 接口就分析完畢了。

獲取驗證碼圖片

然後是獲取驗證碼圖片,直接搜索圖片的名稱,可以發現是在 frame 請求返回的 html 源碼裏面,如下圖所示:

30

這個 captchaImage 對象包含兩個值,p 是驗證碼亂序的圖片,有三個圖片,這個應該是防止宕機,有多個節點,實際三張圖都是一樣的內容,而 l 則是用來還原亂序圖片的。

var captchaImage = {
    p:['https://i5-captcha.luosimao.com/22/aa27352e782eb74ccccef04eb91bc23c.png',
        'https://i2-captcha.luosimao.com/22/aa27352e782eb74ccccef04eb91bc23c.png',
        'https://i1-captcha.luosimao.com/22/aa27352e782eb74ccccef04eb91bc23c.png'],
    l: [["40","80"],["220","0"],["280","0"],["200","80"],["100","0"],["40","0"],
        ["0","80"],["180","0"],["20","0"],["120","80"],["220","80"],["240","0"],
        ["180","80"],["0","0"],["280","80"],["140","80"],["140","0"],["200","0"],
        ["160","0"],["260","0"],["20","80"],["240","80"],["100","80"],["60","80"],
        ["120","0"],["260","80"],["160","80"],["80","0"],["80","80"],["60","0"]]
};

我們查看圖片的源碼,可以發現這個 l 的座標就是 css background-position 屬性的值,如下圖所示:

31

邏輯也很簡單,圖片尺寸 300x160 px,切割的亂序圖片,分為上下兩部分,每一部分又被分為 15 個小片段,那麼上半部分從左至右,每一片段的左上角座標為:[0, 0][20, 0][40, 0] ...,以此類推,下半部分則是 [0, 80][20, 80][40, 80] ...,以此類推,而前面的 l 的值,就表示原始圖片第 N 個位置,對應亂序圖片的某個片段的左上角的座標,例如 l 的第一個值為 ["40","80"],則表示原始圖片第一個位置是亂序圖中座標為 [40, 80] 的片段,換句話説,也就是原始圖片第一個位置,應該是亂序圖中下半部分從左至右的第三個片段。圖片的還原在 Python 中可以用以下代碼實現:

from PIL import Image


section = [["40","80"],["220","0"],["280","0"],["200","80"], ......]
image = Image.open("亂序圖片.png")
canvas = Image.new("RGBA", (300, 160))

for index in range(len(section)):
    x = int(section[index][0])
    y = int(section[index][1])
    slice_ = image.crop(box=(x, y, x + 20, y + 80))
    canvas.paste(slice_, box=(index % 15 * 20, 80 if index > 14 else 0))

canvas.save("正確圖片.png")

然後就是這個 frame 請求,包含了一個 s 參數,這個是前面 request 請求返回的,如下圖所示:

32

33

發送驗證

然後就是點擊發送驗證請求了,user_verify 包含三個參數 h、v 和 s,h 是前面 request 接口返回的,v 和 s 是需要我們逆向的,如下圖所示:

34

同樣也直接跟棧,如下圖所示 _0xaaefx15.toString() 就是最終的 s 值,而 s 是最終的 v 值:

35

先來看 s,s = _0xaaefx11.toString();,而 _0xaaefx11 和前面一樣也是 AES 加密,其中 key 是前面 request 接口返回的 i 的值,待加密的值是 _0xaaefx5,而 _0xaaefx5 = _0xaaefx3.dots.join("#")_0xaaefx3.dots 就是點擊的座標,不過這個座標要注意,他的 x 和 y 座標是反着排列的,整個數組也是倒序的,直觀點兒來講就是 _0xaaefx3.dots = ["第三次點擊的 y,第三次點擊的 x", "第二次點擊的 y,第二次點擊的 x", "第一次點擊的 y,第一次點擊的 x"],如下圖所示:

36

然後就是 _0xaaefx15,經過 MD5 加密得到最終的值,如下圖所示:

37

注意事項

請求會校驗 header 的 Host 字段,frame 接口和其他接口的 Host 是不一樣的,注意觀察替換,Host 不正確會導致請求失敗。

至此所有流程就都分析完畢了。

結果驗證

38

user avatar witchan 頭像
1 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.