SVG绘制原理与验证码

警告
本文最后更新于 2023-08-25,文中内容可能已过时。

SVG矢量图形拥有很多优点,例如体积小、清晰度不受缩放影响、支持广泛等等。我一直是个SVG爱好者,致力于在各种地方用SVG替代位图,比如本文要介绍的验证码。

图像验证码

图像验证码是一种挑战-应答机制,通过在位图里渲染几个字母并加入很多干扰图像,来防止机器人填写表单。这种方式一般是由以下步骤组成的:

  1. 生成随机字符串
  2. 将字符串渲染到位图上
  3. 将干扰图像渲染到位图上
  4. 将位图发送到前端,服务器端存储对应的字符串答案
  5. 用户填写答案,提交表单
  6. 服务器端验证答案是否正确

我们可以发现这种验证方式比较类似于hash,即过程是不可逆的,只能利用人眼的识别能力将字符串恢复出来,只通过机器的准确计算能力无法将耦合在一起的位图信息分离,这就是图像验证码的保护原理。但是近些年随着机器学习的发展,图像识别已经变成一个很轻松的匹配任务了,图像验证码的保护能力愈发下降。不过图像验证码在某些场景下还是有用的,比如你的服务器在一些和疯狗一样的保护措施之下,甚至无法访问外网,也无法接入第三方验证码提供商服务,这个时候只能通过图像验证码来提供一些简单的保护了(没错我还在给你电信息化处擦屁股)。

最开始的想法

SVG是一种基于XML的矢量图形格式,其源文件就是一个纯文本文件,对于服务器端处理来说,SVG可比位图友好太多了(虽然客户端渲染下SVG性能远低于位图),所以我在想,能不能用SVG来代替位图实现验证码?

我试着用Inkscape绘制了一点文字,然后发现在SVG中默认使用 <text> 标签渲染文字,这样就失去验证码的意义了,脚本只需要提取一下 <text> 标签的内容就能拿到验证码,很蠢。

<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
    <text x="0" y="50" font-size="50">Hello World</text>
</svg>

即使使用多个标签分割文字并做混淆处理,也只是玩文字游戏罢了,机器处理的难度与性能损失远不及位图验证码。

因此,想要实现SVG验证码,必须要使用另一些不依赖文本的绘制技术,并在此基础上将噪音与文本信息耦合起来,使得机器无法轻松分离这些信息,而人眼可以通过渲染出来的图像轻松识别。

SVG 绘制原理

在SVG里除了 <text><circle><rect> 等绘制简单图形的标签,还有一个万能标签 <path><path> 标签可以通过一系列的指令来绘制任意复杂的图形,这些指令包括:

  • M x y 或者 m dx dy:移动到指定的坐标
  • L x y 或者 l dx dy:从当前坐标画一条直线到指定的坐标
  • H x 或者 h dx:从当前坐标画一条水平线到指定的x坐标
  • V y 或者 V dy:从当前坐标画一条垂直线到指定的y坐标
  • … 等等
  • Z 或者 z:闭合路径

在绘图的时候,SVG画布采用左上角为原点 0, 0,向右为x轴正方向,向下为y轴正方向。在上述四个指令里,大写指令意味着绝对坐标,小写指令意味着相对坐标。M 指令是用来移动画笔的,不会在画布上画出任何图形,而其余指令 L H V 以及有关贝塞尔曲线的一些指令会进行绘制。绘制完毕之后,画笔位置会停留在绘制完成的坐标上。

比如,下面的代码会绘制一个三角形:

<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
    <path d="M 0 0 L 100 0 L 50 100 Z" />
</svg>

上面的绘图指令先将画笔移动到 0, 0,然后向右侧画一条直线到 100, 0,再向左下方画一条直线到 50, 100,最后从当前点绘制一条直线回到 M 指令指定的起始点,闭合路径,这样就绘制出了一个三角形。

上述SVG图形还可以用另一种相对坐标的方式来绘制:

<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
    <path d="M 0 0 l 100 0 l -50 100 z" />
</svg>

这两种方式绘制出来的图形是完全一样的。

验证码的绘制

在绘制验证码时,我们会先生成一个随机字符串,然后将每个字符绘制到画布上。根据前面的说法,使用 <text> 标签是不行的,于是需要使用 <path> 标签来绘制每个字符。这需要一个SVG字库方便我们拼接文本。我搜了一圈,找到了这个(但其实用 Inkscape 的 对象转路径 功能手动处理一下系统字体也OK,我是懒狗),里面包括了一些字体,都被整理成 <glyph> 路径格式,看起来是能用的。但是实际准备使用的时候我发现这个字库里面的所有路径都采用绝对定位的方式进行绘制,这样在我们拼接的时候就需要去解析每条绘制指令,并使用矩阵变换将其转换到正确的位置上,这样会增加很多复杂度。

这个时候我想到的第一个方法是通过 SVG 的 transform 来实现位置变换,这样就可以很方便的在不修改path的情况下将字母移动并渲染到任何位置,但很快我发现这条路并不可行。如果每个字符都采用transform来移动,那么就会造成一个后果:同一个字符的所有绘制指令是完全相同的。因此,脚本可以通过统计与全文匹配的方式,很轻松的破解出验证码。因此,想要实现SVG验证码,必须要想办法将随机噪音添加到每个字符的绘制指令中。为了实现这一点,我们还必须使用矩阵变换的方式来进行绘制,必要情况下还需要对某几条路径进行偏移。

对于绝对坐标,所有点位都是相对于画布原点 0, 0 的,而相对坐标则是相对于上一个点的坐标。变换坐标位置时,平移只需要 x', y' = x + dx, y + dy,旋转需要 x', y' = x * cosθ - y * sinθ, x * sinθ + y * cosθ,缩放需要 x', y' = x * sx, y * sy

如果字符采用绝对坐标绘制的话,需要确定一个临时原点,比如字符的字面框中心或者字面框左上角,省事的话也可以直接使用第一个M指令的点位。将所有点位都变换到由临时点位规定的坐标系后,再进行平移、旋转、缩放等操作,最后再将所有点位变换回原来的坐标系,这样就可以实现对字符的位置变换了。如果采用相对坐标绘制的话,情况可能会稍微发生变化。相对坐标的每一条指令坐标都是相对于上一个结束点位的,因此我们会发现平移操作下,只需要移动第一个M点位即可,其他点位根本不需要改动;而在旋转操作下,所有点位都需要进行变动;在缩放操作下所有点位也都需要改动。这样以来,相对坐标的变换方式就会损失一个随机噪音插入点,即平移位置。因此,实现SVG验证码时,我们需要将所有的字符采用绝对坐标绘制,这样才能够保证噪音能够更好的耦合进文本信息中。

更进一步地混淆

上面的实现方式已经为文本信息添加了足够多的随机化,但是如果想在视觉上添加障碍,我们可能还需要加入一些随机噪音线条。然后我在实现的时候就发现了一个问题:噪音线条的的 path 相比字符的 path 会短很多很多,只要稍加过滤再渲染,然后再套个OCR什么的,破解难度大大降低。因此,我想了个更进一步的办法。

在处理过程中,我们要把每条 path 中的所有 command 都提取出来,因此每个 path 都有一个 command 列表,我们可以把一个很长的 path 切成若干个很短的 path,并在每个 path 开头补齐相应的 M 指令,这样就可以将字符和噪音线条的 path 长度统一到一个很小的范围内,这样就可以防止长度过滤了。

具体实现

戳这里:BioSVG - GitHub

我很可爱,请给我star.jpg

欢迎带伙对这个想法提出更进一步的issue和PR。

以及这个crate我发布到crates.io上了,如果想要使用的话可以直接:

cargo add biosvg

使用起来很方便:

let (answer, svg) = BiosvgBuilder::new()
    .length(4)
    .difficulty(6)
    .colors(vec![
        "#0078D6".to_string(),
        "#aa3333".to_string(),
        "#f08012".to_string(),
        "#33aa00".to_string(),
        "#aa33aa".to_string(),
    ])
    .build()
    .unwrap();
println!("answer: {}", answer);
println!("svg: {}", svg);

颜色越多越好,请至少传入四种颜色。

另外,由于SVG验证码最终的生成结果是透明背景色,因此请确保你选择的这一大堆颜色在你的网站背景色下都是能够轻松分辨的。

0%