浅谈前端水印

又是一个有关安全的问题。

一般情况下,我们说的水印是指图片角落上的平台用户名水印。类似于下方图片上的这种,通常只要将图片上传到平台上,平台就会在图片上嵌入水印,当然,有些平台也会提供设置是否需要显示这种水印的开关,或者设置保存的时候才会加上水印。

image.png

明水印

这种水印的实现其实是比较简单的,就是将两张图片合成一张,或者是直接在原图上绘制内容就行了:

<img id="pic" src="//p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f3c3c98ebfce4ae28db981dfabedc1d8~tplv-k3u1fbpfcp-zoom-1.image" alt="原始图片" height="500" crossorigin="anonymous">
<div>Photo by Claudio Schwarz | @purzlbaum on Unsplash</div>
window.onload = () => {
    const pic = document.querySelector('#pic');
    const canvasNode = document.createElement('canvas');
    const picWithWatermark = createImageWithWatermark(pic, canvasNode);
    pic.src = picWithWatermark;
}


/**
 * 创建带水印的图片
 * create image with watermark.
 * @param {HTMLImageElement} img 图片结点 - image element.
 * @param {HTMLCanvasElement} canvas canvas结点 - canvas element.
 * @returns 处理后的图片 base64 - pic with watermark.
 */
const createImageWithWatermark = (img, canvas) => {
    const imgWidth = img.width;
    const imgHeight = img.height;
    canvas.width = imgWidth;
    canvas.height = imgHeight;

    const ctx = canvas.getContext('2d');
    ctx.drawImage(img, 0, 0, imgWidth, imgHeight);
    ctx.font = '16px YaHei';
    ctx.fillStyle = 'black';
    ctx.fillText('Photo by Claudio Schwarz | @purzlbaum on Unsplash', 20, 20);

    return canvas.toDataURL('image/jpg');
}

以上就是完整的代码了,更详细的代码可以访问github链接查看

普通用户所说的水印就是上面这种了,但是对于开发者来说,水印所包含的分类还是比较多的。

如我们在公司内网的部分系统(也可能是所有)上就能看到这种水印。

image.png

这里水印颜色选择黑色只是为了能更直观的看到效果,真实使用这种水印的时候,都会选用白色透明的。

这种水印就有点类似之前所说的,将两张图片合成一个的那种方式,只不过,在前端页面上,我们是使用一个透明的canvas容器覆盖整个页面,然后在canvas中绘制这个“标识”,用来标识访问当前页面的用户身份,这样一来,无论是你截图还是拍照,只要图片上能看到水印,我们就能根据这个水印去追踪到泄露这部分信息的人。

那可能会有人问,那我知道这个水印是一个dom结点了,打开控制台找到他,删了不就好了?

明水印的防御

这确实是好问题,不过也不是什么大的问题,你想删,这是完全可以的。

我控制不了你的行为,但是我可以检测到你操作了这个dom结点,那不好意思,我不管你怎么操作的这个结点,为了安全,我肯定都要重新绘制这个水印的。

但光重新绘制水印我觉得还不够,这可能会让你跟我拼速度的,那不行啊,我必须给你点教训的,还不能让你得偿所愿,怎么办?只要你操作了我的dom,那么我直接让页面白屏,然后再重载页面。这也就达成了禁止用户操作dom结点的方式了。

要实现这个,我们需要借助js提供的MutationObserver函数,这个函数可以监听容器的变化。

代码如下:

// 容器监听的回调
const cb = function (mutationList, observer) {
    for (const mutation of mutationList) {
        if (mutation.type === 'childList') {
            const { removedNodes = [] } = mutation;
            // 如果监听到水印容器变化,那么就清空页面并重载
            const node = Array.prototype.find.apply(removedNodes, [(node => node.id === 'page-watermark')])
            if (node) {
                targetNode.innerHTML = '';
                window.location.reload();
            }
        }
    }
}
// 目标DOM结点
const targetNode = document.querySelector('#watermark-body');
// 创建监听
const observer = new MutationObserver(cb);
observer.observe(targetNode, {
    attributes: true,
    childList: true
});

MutationObserver是DOM3 Event规范的一部分,用于替代旧的Mutation Events,可以放心使用。

虽然上面的是全局水印,但是你也可以只对一部分内容加水印,只不过全局水印实现成本更低,代价小,对于内网系统来说,牺牲这点用户体验,并不能算是什么非常严重的问题,是可以接受的。

可能有人又要说了,我都打开dom,那我研究一下这个dom结构,写个爬虫去爬数据,或者直接复制dom里面的内容不就好了,你这水印还有啥存在的意义吗?

无法反驳,但是要说明一点的是,爬数据这个是违法的,要负法律责任,而且你爬虫肯定是要运行在某个电脑上的,这就不需要水印了,我们可以直接查ip,追踪到对应的人就行了,而我们加的水印不过就是一个方便追踪的工具而已。

其次,前端和爬虫斗智斗勇,你从网页爬数据,那我就想办法不直接生成文字,而是把一些关键词给替换成图片,这样一来,你爬虫爬到的结果,就是一串没有用的文字。

这就扯到反爬虫的事情上了。言归正传,到目前为止,我们一直都在讨论明水印,对于内网来说,使用这种水印肯定是没什么问题的,但是对外的网站怎么办呢?如果也加上这种明水印,显然不太合适,想要在这里牺牲用户体验就是不能接受的。

所以我们就开始考虑,能不能加上一个肉眼看不见的水印呢?

暗水印

当然是没问题的,这就是我们下面要说的暗水印。

听名字就知道,暗水印和明水印是刚好相反的,我们看不见这种水印,而且这种水印无论是原理还是实现,和明水印的差别都是比较大的。

先看看原理。

不知道你有没有听说过,隐写术[1]。对于这个比较玄幻的名词,wiki是这么描述的“隐写术是一门关于信息隐藏的技巧与科学,所谓信息隐藏指的是不让除预期的接收者之外的任何人知晓信息的传递事件或者信息的内容。”,究其本质,还是密码学那一套。

追加文件内容

我们可以通过各种方式将信息写到图片,最常见的应该是将需要隐写的内容以二进制的形式写入图片中,咱们在这里举个简单的例子,以下面的图片为例:

image.png

这是我们开篇引用的图片,记为原始图像,将图片保存在本地后(original.png),执行命令:

tail -c 50 1.png

image.png

可以看到执行结果里面是一串乱码(用Hex查看器可以看到文件的二进制码流,这里是utf-8,乱码是正常的),对该文件执行命令:

cat original.png > result.png
echo testWrite >> result.png
tail -c 50 result.png

我们生成一张新的图片之后,将一串字符追加到图片末尾,可以看到图片依旧是正常显示的,同时查看图片的内容,可以看到刚才写入的testWrite字符串:

image.png

另外,将字符串加到文件头部是不行的,因为文件头部包含了文件格式等信息。如果你把信息插入到文件头部,市面上的软件就无法正确的识别文件的类型。

当然了,你可以自己设计编码解码器来创建新的文件类型。

这只是一种方式,而且手段十分暴力,处理之后的图片文件较原来的文件是有一定的大小变化的(不过比较小,可以按字节计算)。更聪明的做法是将加密的信息按照某种模式写入图片的二进制流中,这样一来,就只有加密方才能拿到对应的信息了。

但即使有复杂的加密方式,也还是不够的,因为这只能保证别人在使用原始图片的时候,我们可以鉴别图片的来源、流传路线,但要是通过屏幕截图或者拍照的方式,我们就无法拿到这个数据,因为此时相对于我们做过处理的图片,他已经是一张全新的图片了。

修改RGB分量值

来看另一个例子,RGB分量值的小量变动:在图片上覆盖一层肉眼看不见的图片,简单来说就是我可以在图片的某个单通道(如rgb中的b通道)内将水印信息写入,其实这么说也还是很难懂,举个例子:

image.png

现在要将左右两侧的图片组合,但是不能让右侧的图片内容在左侧的图片上观察到,这时候我们要做的就是按照一定规则将水印图片写进这张图片的rgb通道内。

预处理,先生成右侧的水印图

编码
1. 通过canvas获取到两张图片的rgba数据
2. 将左侧图片的b(蓝色)通道值-1,即,b & 0xfffffffe
3. 读取右侧b通道数据,遇到大于0的值,就将左侧对应位置处的b通道值 +1,即,b | 0x00000001

解码
1. 获取图片的rgba数据
2. 读取b通道数据,遇到 b & 0x00000001 > 0 的数据,说明有水印信息,将其置为255,除a通道(alpha通道不是颜色通道)外,其余通道的数据全部置为0


// +1,-1 是因为量级的变化极小,并不会影响到图片的显示

其实黑底蓝字的图片就是解码出来的水印数据,详细代码:

好像这种方式可以在用户截图时也能够保留我们的水印?其实并没有。

image.png

这是解码截图的结果,可以明显的看到,QQ截图之后的图片并没有能够解码出来我们所需要的水印内容,甚至于将图片压缩之后,可能就会失去我们的水印,所以说这其实也并不是一个可靠的水印方式。

那如何才能保证我们的水印至少在截图的时候也能发挥作用呢?

也不是不行,首先确定我们水印要加在哪里(确定需求),因为图片来源无非是网页搜索结果,或者说我们截得图多数来自于网页,所以我们考虑的是在网页上覆盖一层水印,保证用户从网页上截取的图片可以被我们追踪到来源。

这个通用的解决方案依旧是写css,只不过这时候我们将背景图置顶,同时将其透明度设置的很低。

代码很简单,其实就是将一张背景图片铺满整屏就可以了,然后将opacity设置到肉眼无法观察到的程度就OK了:

window.onload = () => {
    const width = document.body.clientWidth;
    const height = document.body.clientHeight;

    const maskDiv = document.createElement('div');
    maskDiv.id = 'mask_watermark';
    maskDiv.style.position = 'absolute';
    maskDiv.style.backgroundImage = 'url(./1.jpg)';
    maskDiv.style.backgroundRepeat = 'repeat';
    maskDiv.style.visibility = '';
    maskDiv.style.left = '0px';
    maskDiv.style.top = '0px';
    maskDiv.style.overflow = "hidden";
    maskDiv.style.zIndex = "9999";
    maskDiv.style.pointerEvents = "none";
    maskDiv.style.opacity = 0.005;
    maskDiv.style.fontSize = '20px';
    maskDiv.style.color = '#000';
    maskDiv.style.textAlign = "center";
    maskDiv.style.width = `${width}px`;
    maskDiv.style.height = `${height}px`;
    maskDiv.style.display = "block";
    document.body.appendChild(maskDiv);
}

image.png

左侧是从网页上接下来的图片,右侧是在PS工具中处理之后的图片[2],明显可以看到我们设置的水印。

而生成图片的方式就有很多种了,可以是前端生成,也可以是将信息发给后端,后端生成一张图片,然后前端将图片作为背景图。

想要得到右侧的结果,未必需要PS进行处理,可以通过其他的方式进行处理。

到这里,前端部分就结束了,但可能有人还觉得这不太行,我截网页的图现在是加上了水印,但是我要是保存原图呢?那可以用之前说的RGB分量那个方式。

那我下载图片之后在原图上截取呢,不就失效了?确实,到这里前端能做的工作已经很少了。我们已经处理不到了,但是在图像暗水印,或者说盲水印这个领域,还有更加有效的抵抗攻击(去水印)的方式,比如频域、空域的变换。这个变换可以说是老生常谈的了,我就不过多解释了。

补充两句

水印的概念是泛化的,并不是说只有显示在图片某个角落的信息才能被称为水印。

上面选择将信息追加到文件末尾是有原因的,不是瞎选的。任何一种文件都包含文件结束符,就如文件头部约定存放文件的格式信息一样,即使你改了后缀,我也能通过读取这个文件头部的内容来识别文件真实的格式。

另外我们知道,文件后缀名是可以随意更改的,如果只通过文件后缀名进行检测,那么绝对是可以绕过的,进而出现任意文件上传的安全问题。

如果改变图层混合模式没能成功,不妨试下修改图像的RGB曲线

参考文章


  1. 不能说的秘密——前端也能玩的图片隐写术 | AlloyTeam ↩︎

  2. 阿里巴巴内网的不可见水印用的是什么算法? – Mize的回答 – 知乎 ↩︎

Tags: