36

小学数学破解滑动拼图验证码

 5 years ago
source link: https://www.freebuf.com/articles/web/194628.html?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

近几年出现了很多新型验证码,在用户体验上完爆传统的字符图片验证码,而滑动验证码又是最常用的新型验证码之一,那么它的安全性如何呢?相比传统字符型图片验证码有没有提升呢?本文将给出答案。

破解滑动验证码的文章网上有很多,绝大多数使用了神经网络和视觉识别算法。这些算法是建立在复杂的数学理论基础上的,没有高数和概率论基础的同学很难理解。而本文将另辟蹊径,不用tensorflow也不用opencv,手工建立简单的数学模型,来识别滑动(拼图式)验证码。

你可能会问,放着现成的轮子不用你是不是傻。我认为,验证码的对抗跟漏洞攻防不一样,漏洞攻防是技术上的对抗,而验证码对抗更多的是成本上的博弈,如果你打算雇佣几n个人给你标记大量的样本,又花上几十个小时训练一套模型,那么你已经输在了起跑线上了,因为如果验证码换一套参数,你可能又要从新再来一遍。

步骤可以分为两部分,首先是识别缺口位置,然后操作浏览器,拖动滑块到缺口位置。

识别缺口位置

本文以某款流行的商业滑动验证码为例。如下图所示:

bQFbe2Q.jpg!web 图1 验证码图片 

缺口位置左侧边缘总是一条高度约40个像素的垂直竖线,而且缺口边沿颜色较浅,。因此识别缺口位置可以简化成识别这条竖线。

先把图片转化成灰度图像,这样一来每个像素的RGB三值转变成了一个灰度值,抛弃了一些不需要的色彩信息。

YN3yamU.jpg!web 图2 缺口边缘 

我们看看竖线边缘是什么样的,选择紧挨边缘的一个像素点,大概就是图1中红框的位置,然后以该像素点为中心读取的四周的像素的灰度值,即中心、左、左上、上、右上,右、右下、下、左下。这样我们的采样点就变成了一个3*3的矩阵。下面的数据是不同的的三张图片缺口边缘的采样数据。

niERRzA.jpg!web

图3 采样色块

第三列为竖线边缘。我们观察这几组数据,试着发现规律。

首先只看矩阵的行,发现大部分时候,每一行的第一个和第二个元素差别不大,而第二个和第三个元素有断崖式上升/下降。

然后我们看矩阵的列。第一列和第二列属于图片本身的自然内容,其灰度值有的变化大、有的变化小,没有一定规律。但是第三列就不一样了,第三列是图片缺口的边缘,灰度值变化很小。因此识别缺口左侧边缘的方法如下:

1.以3*3的方块竖向扫描整个图片,对于每一个方块,做如下操作;

2.“方差”代表了数据波动大小。求第一行、第二行、第三行的方差为A1、A2、A3

3.求第三列的方差:B3;

4.B3分别与A1、A2、A3比较大小,如果比它们大,则认为是该矩阵可能位于缺口边缘。

完整代码如下:

def is_border(pixels):
    """
    判断3*3的像素块是否位于缺口边缘
    :param pixels: 像素快,3*3矩阵
    :return: bool 是否是缺口位置
    """
    if len(pixels) != 3 or len(pixels[0]) != 3:
        print("error pixel array.")
        return False
    cov1 = np.cov(pixels[0]) # 求第一行方差
    cov2 = np.cov(pixels[1]) # 求第二行方差
    cov3 = np.cov(pixels[2]) # 求第三行方差
    cov_border = np.cov(pixels.T[2]) # 求第三列方差
    # 做比较,如果第三列方差大于三个中的两个,便认为是缺口位置
    level = 0
    if cov_border < cov1:
        level += 1
    if cov_border < cov2:
        level += 1
    if cov_border < cov3:
        level += 1
    if level >= 2:
        return True
    else:
        return False

这样扫描一轮你会发现,这类满足条件的色块有很多,即便是图片正常的部分也有很多满足的。也就是说,满足上述条件的色块,只是说明它是缺口边缘概率比较大而已,并不代表一定是缺口边缘;即便是边缘色块,也有小概率不符合上述条件。思路到这里就卡住了。

但是我们注意到一个特点,就是缺口的左边缘是一条40个像素的直线,而且是竖线。在这条直线上,会有超过50%的像素点满足边缘特征。并且很多符合特征的点都是连续的,连续的越多,越有可能是我们要得到的。

程序步骤如下:

1.遍历图片的每一列,统计这一列中符合边缘特征的色块;

2.如果第i列正色块(符合边缘条件的以下统称为正色块,不符合边缘条件的称为负色块)数量大于20,则为该列初始化一个得分Mi;

3.Mi初始化为0,如果连续出现N个正色块,得分就是Mi=Mi+N。(注意一列里面可能有多个连续的片段,得分即连续正色块的数量之和)

def scan_array(img_arr, start_x=1, start_y=1):
    """
    计算每一列的得分
    :param img_arr: 图片灰度值 
    :param start_x: 从第x列开始计算,合理选择该值可以加快运算时间
    :param start_y: 从第y行开始计算
    """
    all_score = {} # 综合分数
    for x in range(start_x, img_arr.shape[1]):
        col_result = []
        for y in range(start_y, img_arr.shape[0]):
            # 取色块
            pix = select_pixels(X=x, Y=y, img=img_arr)
            if pix is None:
                continue
            # 判断是否符合边缘条件
            col_result.append(is_border(pix))
        temp_str = ""
		# T代表正色块,F代表负色块
        for elem in col_result:
            if elem:
                temp_str += "T"
            else:
                temp_str += "F"
        temp_ar = temp_str.split("F")
        for index in range(0, len(temp_ar)):
            # 边缘条件色块大于20的时候开始计算得分
            if len(temp_ar[index]) > 20:
                all_score[x] = len(temp_ar[index])
                if index-1 >= 0:
					# len(temp_ar[index-1]) 是连续的正色块的个数
                    all_score[x] += len(temp_ar[index-1])
                else:
                    all_score[x] += len(temp_ar[index+2])
                if index+1 < len(temp_ar):
                    all_score[x] += len(temp_ar[index+1])
                else:
                    all_score[x] += len(temp_ar[index-2])
    print(all_score)
	...

因此理论上得分越高,它越有可能是缺口边缘,然而测试情况却不理想。原因在于图片的正常部分,也会有物体边缘,因此如果只是简单的判断得分最高的是边缘,会有很多误报。我们还需要其它条件来补充我们的证据。

通过观察,缺口是3列像素,在这3列像素中,正色块数量差不多,找了几个图片测试了一下,这三列像素中的连续正色块数量的方差不会超过44。(这个值可以根据不同的验证码进行自由调整,总体来说,这个值越小,误报就越低但漏报会增加)。

def select_best(all_score, exclude=[], func_type="recursion"):
    """
    获取缺口边缘坐标
    :param all_score: 所有列的得分。 格式为map[横坐标数字] = 分数
    :param exclude: 不检查的列。格式为数字数组
    :param func_type: 不同的执行模式
    :return: 缺口位置的横坐标
    """
    max = 0
    key = 0
    for x_key in all_score:
        # x_key为图片横坐标
        # 由于缺口是40个像素,如果发现超过40个像素的正色块,说明不是缺口边缘
        if all_score[x_key] > 40:
            continue
        # exclude中的横坐标不做检查,主要为了后面做递归
        if x_key in exclude:
            continue
        if all_score[x_key] > max:
            # 寻找得分最大的列
            max = all_score[x_key]
            key = x_key
    if max > 0:
        if func_type == "max":
            return key
        # key 是得分最大的列的横坐标
        # 然后判断key和key右侧以及右侧的右侧的列的坐标是否符合缺口条件,且三者得分(即连续正色块数量)的方差在44以内。符合条件的话直接return
        if key+1 in all_score.keys() and key+2 in all_score.keys():
            if np.cov([all_score[key], all_score[key + 1], all_score[key + 2]]) < 44:
                return key
        # 同上,检测key与key左侧和右侧
        if key + 1 in all_score.keys() and key -1 in all_score.keys():
            if np.cov([all_score[key], all_score[key + 1], all_score[key - 1]]) < 44:
                return key
        # 同上,检测key与key左侧和左侧的左侧
        if key - 2 in all_score.keys() and key - 1 in all_score.keys():
            if np.cov([all_score[key], all_score[key - 1], all_score[key - 2]]) < 44:
                return key
        # 如果key不满足条件,key则加入exclude列表,然后进行递归,直到找到符合条件的列
        exclude.append(key)
        return select_best(all_score, exclude=exclude)
    else:
        return None

返回值就是缺口位置。

操作浏览器

通过测试发现,这款验证码对于拖动滑块的速度是有严格校验的,如果按如下行为操作滑块是无法通过验证的:

1.直接将滑块瞬间拖至缺口位置。

2.将滑块以匀速拖至缺口位置。

以上两类行为有明显的程序操作特征,而人类操作滑块有如下特征:

1.滑块速度先快后慢,但加速度不恒定。

2.在接近缺口的时候,滑块可能会左右摇摆来适应缺口位置。

3.滑动过程中,鼠标的Y坐标不是恒定不变,会有上下浮动。

操作浏览器我准备了三套方案:

1.直接在验证码页面中执行js。虽然能执行绑定在元素上的各种js事件。但是模拟程度有限,例如无法模拟真实的鼠标滑动轨迹以及鼠标点击。且权限较低,可被验证码的js检测到,易陷入攻防战。

2.使用按键精灵。灵活权限高,可以模拟任何真实的的鼠标操作。但是无法多开浏览器并行操作(除非使用多个虚拟机)。

3.使用selenium。相当于浏览器“外挂”,即可以多开,在输入模拟方面不如按键精灵灵活。

因此最终选择了selenium方案,代码如下:

# 获取滑块元素
elem = driver.find_elements_by_class_name("yidun_slider")[1]
# 下载验证码图片,保存为临时文件
network.download(img.get_attribute("src"), "./temp/cc.jpg")
# 分析缺口位置
move_x = pic.get_x_file("./temp/cc.jpg")
#action.click_and_hold(elem).move_by_offset(elem_x + move_x, elem_y).release().perform()
# 模拟鼠标操作,点击滑块不松开
mouse_action = action.click_and_hold(elem)
# 由于图片在页面中进行了缩放32/33,因此缺口位置也要等比例缩小
# move_x为要移动的总距离
move_x = int(move_x * 32/33)
# selenium有限制(或者bug),鼠标最少只能移动4个像素
# 因此要将滑块移动至指定位置,最多需要执行move_steps步
move_steps = int(move_x/4)
for i in range(0, move_steps):
    # 路程前半部分速度较快
    if i < int(move_steps/2):
        # sleep(random.randint(1, 10) / 500)#
        # 滑块每次向右移动四个像素,鼠标Y坐标在上下5个像素内随机摆动
        mouse_action.move_by_offset(4, random.randint(-5, 5)).perform()
    else:
        # 在路程的后半段,越接近终点速度越慢
        # 每次移动之前sleep一段时间,时间为总距离与已移动距离方差的倒数
        seed = 90.0/(pow(move_steps, 2) - pow(i, 2))
        sleep(seed)
        mouse_action.move_by_offset(4, random.randint(-5, 5)).perform()
    print(elem.location)
    action = ActionChains(driver)
    mouse_action = action.click_and_hold(elem)
# 到达终点时,左右摆动,假装做调整。
#sleep(0.1)
#mouse_action.move_by_offset(5, random.randint(-5, 5)).perform()
#sleep(0.2)
#mouse_action.move_by_offset(-4, random.randint(4,5)).perform()
sleep(0.1)
# 松开鼠标
mouse_action.release().perform()

以上代码步骤如下:

1)下载图片,分析缺口位置,得到缺口横坐标,由于图片一般不会在网页中以原始尺寸显示,而是按一定比例放大或缩小,因此缺口横坐标也要等比放大或缩小;

2)计算总共要移动多少步。

在路程的前半段,保持较快的速度。路程后半段,约接近终点,速度越慢。设总距离为D,已经移动了x,K是一个人工设置的常量(代码中为90),那么滑块速度为:

aEVNz2B.jpg!web

测试结果

经测试,缺口位置识别的准确率在80%以上,而拖曳操作这一步的通过率只有60%左右,分析原因,由于selenium每次只能拖曳最小4个像素,无法模拟出鼠标那种平滑流畅的拖曳操作,因此与正常的操作行为差别较大。以下是屏幕录像,可见虽然拖曳成功,但是卡顿感较强。

谁更安全

理论上所有形式的验证码都是可破解的,我们在讨论其安全性的时候,无非就是在讨论破解成本。

笔者曾用LSTM+CNN破解过变长加噪音的图片验证码,虽然也达到了90%以上的准确率,但是付出的代价是14天+700元打码费用,反观这次破解,用时只有两天,“训练数据”只有6张图片,准确率稍逊但成本方面完胜。

经过本次研究,可以得出结论:滑动拼图式验证码相比传统的字符图片验证码,在安全性上并没有什么提升,甚至不如传统方案。

*本文作者:山东星维九州安全技术有限公司,转载请注明来自FreeBuf.COM


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK