明水印
明水印
(watermark)是一种容易识别、被夹于纸内,能够透过光线穿过从而显现出各种不同阴影的技术。水印的类型有很多,有一些是整个覆盖在图层上的水印,还有一些是在角落。
可以通过绝对定位,来将水印覆盖到我们的页面之上。不过,直接覆盖上去是无法触发底下图层的事件的,要使用到 css 属性
pointer-events。
pointer-events
属性指定在什么情况下 (如果有) 某个特定的图形元素可以成为鼠标事件的target。当它的被设置为
none
的时候,能让元素实体虚化,虽然存在这个元素,但是该元素不会触发鼠标事件。
明水印的生成方式主要可以归为两类,一种是
纯 html 元素(纯div)
,另一种则为
背景图(canvas/svg)
。
div 实现
user-select
属性控制用户能否选中文本。除了文本框内,它对被载入为 chrome 的内容没有影响。
首先生成一个水印块,包括设置透明度和旋转角度,以及使用
userSelect
属性,让此时的文字无法被选中:
function cssHelper(el, prototype) {
for (let i in prototype) {
el.style[i] = prototype[i];
}
}
const item = document.createElement('div');
item.innerHTML = 'edemao';
cssHelper(item, {
position: 'absolute',
top: `50px`,
left: `50px`,
fontSize: `16px`,
color: '#000',
lineHeight: 1.5,
opacity: 0.1,
transform: `rotate(-15deg)`,
transformOrigin: '0 0',
userSelect: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
})
然后通过计算屏幕的宽高,以及水印的大小来计算需要生成的水印个数:
const waterHeight = 100;
const waterWidth = 180;
const { clientWidth, clientHeight } = document.documentElement || document.body;
const column = Math.ceil(clientWidth / waterWidth);
const rows = Math.ceil(clientHeight / waterHeight);
for (let i = 0; i < column * rows; i++) {
const wrap = document.createElement('div');
cssHelper(wrap, Object.create({
position: 'relative',
width: `${waterWidth}px`,
height: `${waterHeight}px`,
flex: `0 0 ${waterWidth}px`,
overflow: 'hidden',
}));
wrap.appendChild(createItem());
waterWrapper.appendChild(wrap);
}
document.body.appendChild(waterWrapper);
背景图实现
canvas
主要是利用
canvas
绘制一个水印,将它转化为 base64 的图片,然后通过
canvas.toDataURL()
来拿到文件流的 url,将获取的 url 填充在一个元素的背景中,最后设置背景图片的属性为重复。
.watermark {
position: fixed;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
pointer-events: none;
background-repeat: repeat;
}
function createWaterMark() {
const angle = -20;
const txt = 'edemao';
const canvas = document.createElement('canvas');
canvas.width = 180;
canvas.height = 100;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, 180, 100);
ctx.fillStyle = '#000';
ctx.globalAlpha = 0.1;
ctx.font = `16px serif`
ctx.rotate(Math.PI / 180 * angle);
ctx.fillText(txt, 0, 50);
return canvas.toDataURL();
}
const watermakr = document.createElement('div');
watermakr.className = 'watermark';
watermakr.style.backgroundImage = `url(${createWaterMark()})`;
document.body.appendChild(watermakr);
svg
和 canvas 类似,主要还是生成背景图片:
function createWaterMark() {
const svgStr =
`<svg xmlns="http://www.w3.org/2000/svg" width="180px" height="100px">
<text x="0px" y="30px" dy="16px"
text-anchor="start"
stroke="#000"
stroke-opacity="0.1"
fill="none"
transform="rotate(-20)"
font-weight="100"
font-size="16"
>
edemao
</text>
</svg>`;
return `data:image/svg+xml;base64,${window.btoa(unescape(encodeURIComponent(svgStr)))}`;
}
const watermakr = document.createElement('div');
watermakr.className = 'watermark';
watermakr.style.backgroundImage = `url(${createWaterMark()})`;
document.body.appendChild(watermakr);
破解与防御
上述方式生成的明水印的破解方法比较简单:打开
Chrome Devtools
找到对应的元素,直接按
delete
即可删除。
如何防御这种呢?JS 有一个方法叫做
MutationObserver
,能够监控元素的改动。
MutationObserver 对现代浏览的兼容性还是不错的,MutationObserver是元素观察器,字面上就可以理解这是用来观察Node(节点)变化的。
需要使用 MutationObserver 监控以下三种情况:
- 水印元素本身是否被移除;
- 水印元素属性是否被篡改(display: none …);
- 水印元素的子元素是否被移除和篡改 (element生成的方式 )。
监听对象为
document.body
, 一旦监听到水印元素被删除,或者属性修改,就重新生成一个。比如,监听水印元素被删除:
/** 观察器的配置(需要观察什么变动)*/
const config = { attributes: true, childList: true, subtree: true };
/** 当观察到变动时执行的回调函数 */
const callback = function (mutationsList, observer) {
for (let mutation of mutationsList) {
mutation.removedNodes.forEach(function (item) {
if (item === watermakr) {
document.body.appendChild(watermakr);
}
});
}
};
/** 监听元素 */
const targetNode = document.body; */
/** 创建一个观察器实例并传入回调函数
const observer = new MutationObserver(callback);
/** 以上述配置开始观察目标节点 */
observer.observe(targetNode, config);
此方法依然可以被破解:
-
打开
Chrome Devtools
,点击设置 – Debugger – Disabled JavaScript,然后再打开页面
delete
水印元素; - 复制一个 body 元素,然后将原来 body 元素的删除;
- 打开代理工具,将生成水印相关的代码删除。
比如某设计网站的水印内容是以 div 的方式实现的。打开控制台,
Ctrl + F
搜索
watermark
相关字眼。发现直接删除,若没有办法删除水印元素,肯定是利用了
MutationObserver
方法。使用第一个破解方法,将 JavaScript 禁用,再将元素删除。
有一种水印叫暗水印。虽然将一些可见的水印去除了,但是还会存在一些不可见的保护版权的水印。(这就是防止一些坏人拿去作另外的用途)。
暗水印
暗水印是一种肉眼不可见的水印方式,可以保持图片美观的同时,保护资源版权。暗水印的生成方式有很多,常见的为通过修改
RGB 分量值的小量变动
、DWT、DCT 和 FFT 等等方法。
图片都是有一个个像素点构成的,每个像素点都是由 RGB 三种元素构成。当把其中的一个分量修改,人的肉眼是很难看出其中的变化,甚至是像素眼的设计师也很难分辨出。
解码过程
首先拿到图片,进行解码。解码其实很简单,需要根据设定的规律去解码。解密规则是对 R 通道进行处理,R 的分量是奇数则该像素设为红色,R 的分量偶数则该像素设为黑色,
首先创建一个
canvas
标签:
<canvas id="canvas" width="256" height="256"></canvas>
const ctx = document.getElementById('canvas').getContext('2d');
const img = new Image();
let originalData;
img.onload = function () {
/** canvas像素信息 */
ctx.drawImage(img, 0, 0);
originalData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
processData(ctx, originalData)
};
img.src = 'edemao.png';
打印出这个 originalData 数组,一共有 256 * 256 * 4 = 262144 个值,包括RGBA,A是常用的透明度(alpha 通道)。解码处理函数如下:
const processData = function (ctx, originalData) {
var data = originalData.data;
for (let i = 0; i < data.length; i++) {
if (i % 4 == 0) {
/** R 分量 */
if (data[i] % 2 == 0) {
data[i] = 0;
} else {
data[i] = 255;
}
} else if (i % 4 == 3) {
/** alpha通道不做处理 */
continue;
} else {
/** 关闭其他分量,不关闭也不影响解密结果 */
data[i] = 0;
}
}
/** 将结果绘制到画布 */
ctx.putImageData(originalData, 0, 0);
}
最后在
img.onload
调用
processData(ctx, originalData)。
function decodeImg(src) {
const ctx = document.getElementById('canvas').getContext('2d');
const img = new Image();
let originalData;
img.onload = function () {
/** 获取指定区域的canvas像素信息 */
ctx.drawImage(img, 0, 0);
originalData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
processData(ctx, originalData)
};
img.src = src;
}
编码过程
加密呢,首先需要获取加密的图像信息:
let textData;
const ctx = document.getElementById('canvas').getContext('2d');
ctx.font = '30px Microsoft Yahei';
ctx.fillText('edemao', 60, 130);
textData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height).data;
然后提取加密信息在待加密的图片上进行处理:
const mergeData = function (ctx, newData, color, originalData) {
let oData = originalData.data;
/** offset的作用是找到alpha通道值 */
let bit, offset;
switch (color) {
case 'R':
bit = 0;
offset = 3;
break;
case 'G':
bit = 1;
offset = 2;
break;
case 'B':
bit = 2;
offset = 1;
break;
}
for (let i = 0; i < oData.length; i++) {
if (i % 4 == bit) {
/** 只处理目标 R 通道 */
if (newData[i + offset] === 0 && (oData[i] % 2 === 1)) {
/** 没有信息的像素,该通道最低位置0(奇数),但不要越界超过 255 */
if (oData[i] === 255) {
oData[i]--;
} else {
oData[i]++;
}
} else if (newData[i + offset] !== 0 && (oData[i] % 2 === 0)) {
/** 有信息的像素,该通道最低位置 1(偶数) */
oData[i]++;
}
}
}
ctx.putImageData(originalData, 0, 0);
}
在有像素信息的点,将 R 偶数的通道值 +1。在没有像素点的地方将 R 通道奇数值值转化成偶数。最后在
img.onload
调用 mergeData。
function encodeImg(src) {
var textData;
var ctx = document.getElementById('canvas').getContext('2d');
ctx.font = '30px Microsoft Yahei';
ctx.fillText('edemao', 60, 130);
textData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height).data;
var img = new Image();
var originalData;
img.onload = function () {
/** 获取指定区域的canvas像素信息 */
ctx.drawImage(img, 0, 0);
originalData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
mergeData(ctx, textData, 'R', originalData)
};
img.src = src;
}
但是实际过程需要更专业的加密方式,例如利用傅里叶变化公式,来进行频域制定数字盲水印。
对于网页上展示的图片,一是图片生成的过程中加入的水印,即前端加入的水印。二是物料图本身含有水印。
如何判断是哪一种?可以通过
dom-to-image
这个库,在前端直接进行下载原图,和网页上展示的图片对比是否有大小改变。
攻击暗水印
可以通过以下方式对加了暗水印的图片进行攻击:
- 加一些元素
- 截图
- 加蒙层
- 改变大小
- 拍照(最强大的攻击方式,拒绝一切花里胡哨)
其中,只有加蒙层会整体改变通道值,会使得解码后的图片的水印识别能力大大下降,以此达到破解水印的效果。但是,
可见暗水印的抵抗攻击性还是蛮强的,是一种比较好的抵御攻击的方式。