CAPTCHA 简介
CAPTCHA ((/kæp.tʃə/ ,"Completely Automated Public Turing test to tell Computers and Humans Apart" 的缩写)是一种 挑战-响应 测试,用于计算以确定用户是否是人类。该术语由 Luis von Ahn,Manuel Blum,Nicholas J. Hopper 和 John Langford 于 2003 年创造。最常见的 CAPTCHA 类型(显示为 1.0 版)最初是由两个研究组同时工作并于 1997 年发明的。第一组由 Mark D. Lillibridge,Martin Abadi,Krishna Bharat 和 Andrei Z. Broder 组成;第二组由 Eran Reshef,Gili Raanan 和 Eilon Solan 组成。这种形式的 CAPTCHA 要求用户键入失真图像的字母,有时会添加屏幕上出现的模糊字母或数字序列。因为测试是由计算机管理的,与人类施用的标准图灵测试相反,CAPTCHA 有时被描述为反向图灵测试。这种用户识别程序受到了许多批评,尤其是来自残疾人的批评,也有其他人认为他们的日常工作因难以阅读的扭曲词语而变慢。一般人需要大约 10 秒来解决典型的 CAPTCHA.
这篇博客前面的大部分内容将讨论如何用 Python 实现典型的 CAPTCHA 图像生成,后面将从颜色设计,可用性与安全性等方面讨论验证码设计的艺术。
Python 源代码
点击展开源码:# coding: utf-8
"""
captcha.image
~~~~~~~~~~~~~
Generate Image CAPTCHAs, just the normal image CAPTCHAs you are using.
"""
import os
import random
from PIL import Image
from PIL import ImageFilter
from PIL.ImageDraw import Draw
from PIL.ImageFont import truetype
try:
from cStringIO import StringIO as BytesIO
except ImportError:
from io import BytesIO
try:
from wheezy.captcha import image as wheezy_captcha
except ImportError:
wheezy_captcha = None
DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data')
DEFAULT_FONTS = [os.path.join(DATA_DIR, 'DroidSansMono.ttf')]
if wheezy_captcha:
__all__ = ['ImageCaptcha', 'WheezyCaptcha']
else:
__all__ = ['ImageCaptcha']
table = []
for i in range( 256 ):
table.append( i * 1.97 )
class _Captcha(object):
def generate(self, chars, format='png'):
"""Generate an Image Captcha of the given characters.
:param chars: text to be generated.
:param format: image file format
"""
im = self.generate_image(chars)
out = BytesIO()
im.save(out, format=format)
out.seek(0)
return out
def write(self, chars, output, format='png'):
"""Generate and write an image CAPTCHA data to the output.
:param chars: text to be generated.
:param output: output destination.
:param format: image file format
"""
im = self.generate_image(chars)
return im.save(output, format=format)
class WheezyCaptcha(_Captcha):
"""Create an image CAPTCHA with wheezy.captcha."""
def __init__(self, width=200, height=75, fonts=None):
self._width = width
self._height = height
self._fonts = fonts or DEFAULT_FONTS
def generate_image(self, chars):
text_drawings = [
wheezy_captcha.warp(),
wheezy_captcha.rotate(),
wheezy_captcha.offset(),
]
fn = wheezy_captcha.captcha(
drawings=[
wheezy_captcha.background(),
wheezy_captcha.text(fonts=self._fonts, drawings=text_drawings),
wheezy_captcha.curve(),
wheezy_captcha.noise(),
wheezy_captcha.smooth(),
],
width=self._width,
height=self._height,
)
return fn(chars)
class ImageCaptcha(_Captcha):
"""Create an image CAPTCHA.
Many of the codes are borrowed from wheezy.captcha, with a modification
for memory and developer friendly.
ImageCaptcha has one built-in font, DroidSansMono, which is licensed under
Apache License 2. You should always use your own fonts::
captcha = ImageCaptcha(fonts=['/path/to/A.ttf', '/path/to/B.ttf'])
You can put as many fonts as you like. But be aware of your memory, all of
the fonts are loaded into your memory, so keep them a lot, but not too
many.
:param width: The width of the CAPTCHA image.
:param height: The height of the CAPTCHA image.
:param fonts: Fonts to be used to generate CAPTCHA images.
:param font_sizes: Random choose a font size from this parameters.
"""
def __init__(self, width=160, height=60, fonts=None, font_sizes=None):
self._width = width
self._height = height
self._fonts = fonts or DEFAULT_FONTS
self._font_sizes = font_sizes or (42, 50, 56)
self._truefonts = []
@property
def truefonts(self):
if self._truefonts:
return self._truefonts
self._truefonts = tuple([
truetype(n, s)
for n in self._fonts
for s in self._font_sizes
])
return self._truefonts
@staticmethod
def create_noise_curve(image, color):
w, h = image.size
x1 = random.randint(0, int(w / 5))
x2 = random.randint(w - int(w / 5), w)
y1 = random.randint(int(h / 5), h - int(h / 5))
y2 = random.randint(y1, h - int(h / 5))
points = [x1, y1, x2, y2]
end = random.randint(160, 200)
start = random.randint(0, 20)
Draw(image).arc(points, start, end, fill=color)
return image
@staticmethod
def create_noise_dots(image, color, width=3, number=30):
draw = Draw(image)
w, h = image.size
while number:
x1 = random.randint(0, w)
y1 = random.randint(0, h)
draw.line(((x1, y1), (x1 - 1, y1 - 1)), fill=color, width=width)
number -= 1
return image
def create_captcha_image(self, chars, color, background):
"""Create the CAPTCHA image itself.
:param chars: text to be generated.
:param color: color of the text.
:param background: color of the background.
The color should be a tuple of 3 numbers, such as (0, 255, 255).
"""
image = Image.new('RGB', (self._width, self._height), background)
draw = Draw(image)
def _draw_character(c):
font = random.choice(self.truefonts)
w, h = draw.textsize(c, font=font)
dx = random.randint(0, 4)
dy = random.randint(0, 6)
im = Image.new('RGBA', (w + dx, h + dy))
Draw(im).text((dx, dy), c, font=font, fill=color)
# rotate
im = im.crop(im.getbbox())
im = im.rotate(random.uniform(-30, 30), Image.BILINEAR, expand=1)
# warp
dx = w * random.uniform(0.1, 0.3)
dy = h * random.uniform(0.2, 0.3)
x1 = int(random.uniform(-dx, dx))
y1 = int(random.uniform(-dy, dy))
x2 = int(random.uniform(-dx, dx))
y2 = int(random.uniform(-dy, dy))
w2 = w + abs(x1) + abs(x2)
h2 = h + abs(y1) + abs(y2)
data = (
x1, y1,
-x1, h2 - y2,
w2 + x2, h2 + y2,
w2 - x2, -y1,
)
im = im.resize((w2, h2))
im = im.transform((w, h), Image.QUAD, data)
return im
images = []
for c in chars:
if random.random() > 0.5:
images.append(_draw_character(" "))
images.append(_draw_character(c))
text_width = sum([im.size[0] for im in images])
width = max(text_width, self._width)
image = image.resize((width, self._height))
average = int(text_width / len(chars))
rand = int(0.25 * average)
offset = int(average * 0.1)
for im in images:
w, h = im.size
mask = im.convert('L').point(table)
image.paste(im, (offset, int((self._height - h) / 2)), mask)
offset = offset + w + random.randint(-rand, 0)
if width > self._width:
image = image.resize((self._width, self._height))
return image
def generate_image(self, chars):
"""Generate the image of the given characters.
:param chars: text to be generated.
"""
background = random_color(238, 255)
color = random_color(10, 200, random.randint(220, 255))
im = self.create_captcha_image(chars, color, background)
self.create_noise_dots(im, color)
self.create_noise_curve(im, color)
im = im.filter(ImageFilter.SMOOTH)
return im
def random_color(start, end, opacity=None):
red = random.randint(start, end)
green = random.randint(start, end)
blue = random.randint(start, end)
if opacity is None:
return (red, green, blue)
return (red, green, blue, opacity)
上面是 PyPI 中的 CAPTCHA 库中生成图像类型验证码图片的源码。我们先抛开与代码有关的东西,来思考一下,生成一张验证码图片需要经历哪些步骤?
- 正常图像的生成:需要随机选取图片的背景颜色,随机字符串的内容、字体、颜色等等
- 加入噪声以满足标准:通常的做法是对字体进行扭曲或者偏移,在整张图像中加入干扰点或者线
接下来则是考虑函数设计的细节,在设计随机 RGB 颜色选择函数时,我们需要设定单个通道灰度值的上下界,这样可以避免背景颜色和字体颜色过于相似。此外,我们还可以为字体颜色加入透明度(opacity)属性,增加机器识别的难度。
字符串的字符大小、字体类型也需要进行随机选择,在上面的源代码中,字符串内容是由用户给定的,而我们还可以将函数设计得更高级一些:当用户没有给定字符串输入时,可以随机生成一串字符串,长度和取字符范围都可以给定默认值,或者允许用户自定义。单个的字符可以分别进行一定的扭曲操作,且它们之间的位置不用过于固定,可以为不同的字符设置一定的 offset ,但整体阅读顺序保持一致,用于检查验证结果。
number = ['0','1','2','3','4','5','6','7','8','9']
alphabet = ['a','b','c','d','e','f','g','h','i','j','k',
'l', 'm','n','o','p','q','r','s','t','u','v','w','x','y','z']
ALPHABET = ['A','B','C','D','E','F','G','H','I','J','K',
'L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z']
def random_captcha_text(char_set = number +
alphabet + ALPHABET, captcha_size=4):
captcha_text = []
for i in range(captcha_size):
c = random.choice(char_set)
captcha_text.append(c)
return captcha_text
最后的操作则是向整张图片中加入一定的噪声,通常的操作是加入一些噪声点或者线,至此一个 CAPTCHA 图片就生成了。
验证码设计的艺术
在论文 《CAPTCHA Design Color, Usability, and Security》 中,作者通过对多种基于文本图像的验证码进行对比,得出了一个结论:
在 CAPTCHA 中使用颜色可能很棘手。 许多的 CAPTCHA,从最早的方案到最新的设计,包括广泛使用的 LinkedIn,Megaupload 和 BotDetect 方案以及一些相对鲜为人知的设计,由于不谨慎使用颜色而产生了致命的安全漏洞。 使用复杂或奇特的配色方案也引发了可用性问题。 例如,考虑到各种类型的色盲,分辨出彩色图像对于色盲而言要困难得多。 我们可以将攻击 CAPTCHAs 的常用方法总结为以下过程:将前景与背景分离; 识别前景中的连通成分,并在必要时将连接的部分拆分成单个字符;然后识别这些单个的字符。
因此作者最终在论文里给出了一些关于使用颜色的指导原则:
- 关注前景和背景的最小均匀性,这样不易让计算机进行直接区分。
- 设计人类感知上相连的但物理层面无直接联系的组件,譬如一个字符多种颜色划分。
传统验证码是否安全
以往的验证码设计,主要考虑可用性与安全性,目前常见的验证码多为图片验证码,即数字、字母、文字、图片物体等形式的传统字符验证码。这类验证码看似简单易操作,但用户体验是比较差的,随着 OCR 技术和打码平台的利用,图片很容易破解。现在在此基础上,大家更关注用户的使用体验。
而对于短信验证码,经常会出现验证的逻辑漏洞,譬如:接口没设频次上限导致短信轰炸,验证码内容包含在返回包中,修改返回包内容绕过验证等。语音验证码也与此类似,如果是电话语音验证又略显麻烦。目前来说大部分的企业还是偏向使用图形验证码,在测试过程中只有极少数的公司会动态升级自己的图片验证码,随着输错次数的上升验证码难度也随机上升。统一验证码的设计初衷是好的,即使攻击者使用了OCR技术进行破解,一旦失败数触发到阈值即自动上升图形验证码难度,增加破解成本。为了防止验证码被爬虫获取后专门进行分析,针对性的破解攻击,需要准备多套图形验证码定期进行替换。
交互体验也很重要
就算验证码背后的逻辑设计得比较合理,但也会有一些不够人性化的时候,譬如在填写注册或者登陆表单时,有时候其它内容填写正确了,却因为验证码填写错误需要重新来过,这就是比较愚蠢的设计了。为了提高用户的输入效率,经量降低用户的厌烦情绪,交互规则可以采用 “图片验证码实时校验” 和 “输入错误后自动刷新图片” 两种方式。前一种方式是给用户实时操作反馈,让用户 “知错就改”,避免用户 “千辛万苦” 输入完成后再提示错误,造成用户的挫折感;后一种方式是用户在输入错误时不用手动点击图片刷新而是图片自动刷新,尽量减少用户操作步骤,同时给予用户积极引导,用户看到图片已经刷新了,就会比较愿意再试一次。
毕竟验证码是用来挡住机器人,而不是真实人类用户的,对吧?
验证码的未来:Google reCAPTCHA
随着技术的进步,也衍生出用户体验好、安全性更高的验证码形式。比如说滑动验证码,图中点选等方式,增强了用户的体验感,采用深度学习,快速准确的进行人机判断,具有可靠的安全性。而行为式验证码则是以用户产生的行为轨迹为依据,进行机器学习建模,结合访问频率、地理位置、历史记录等多个维度信息综合判断,快速、准确的返回人机判定结果。
人们每天都在解决数以亿计的 CAPTCHAs 问题。reCAPTCHAv2 通过将解决 CAPTCHA 所花费的时间用于数字化文本,注释图像,构建机器学习数据集,积极利用这种人力资源。这反过来有助于保存书籍,改进地图和解决难以解决的 AI 问题。这很花时间!而如今,Google 的新解答就是 noCAPTCHA reCAPTCHA,不需要验证码的验证码,有点像绕口令。
只需要点选「我不是机器人」,你就会传送一组资料到 Google 的服务器中,包含 Google 偷偷记录下来的IP位址、国家、时间,以及你打勾之前的滑鼠轨迹、打勾之前的网页卷动记录等。而 Google 的 AI 系统通过每天超一亿笔 noCAPTCHA reCAPTCHA 的资料,渐渐提升判断准度,到现在已经能够非常精准的判别你到底是不是真人。不过,总有它偶尔判别不出来的时候,如果 noCAPTCHA 认为你不是真人,它就会要求你填一个传统的 CAPTCHA 字符串或更先进的字符串方式,比如从一组图片中挑选与给定样例相似的图片。
隐形验证码:reCAPTCHA v3
reCAPTCHA v3 允许用户在没有任何用户交互的情况下验证交互是否合法。它是一个纯粹的 JavaScript API,返回一个分数,使人能够在您的站点上下文中执行操作:例如,需要其他身份验证因素,发送帖子到审核,或限制可能正在抓取内容的机器人。