又是一個有關安全的問題。
一般情況下,我們說的水印是指圖片角落上的平台用戶名水印。類似於下方圖片上的這種,通常只要將圖片上傳到平台上,平台就會在圖片上嵌入水印,當然,有些平台也會提供設置是否需要顯示這種水印的開關,或者設置保存的時候才會加上水印。

明水印
這種水印的實現其實是比較簡單的,就是將兩張圖片合成一張,或者是直接在原圖上繪制內容就行了:
<img id="pic" src="https://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鏈接查看。
普通用戶所說的水印就是上面這種了,但是對於開發者來說,水印所包含的分類還是比較多的。
如我們在公司內網的部分系統(也可能是所有)上就能看到這種水印。

這里水印顏色選擇黑色只是為了能更直觀的看到效果,真實使用這種水印的時候,都會選用白色透明的。
這種水印就有點類似之前所說的,將兩張圖片合成一個的那種方式,只不過,在前端頁面上,我們是使用一個透明的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是這么描述的“隱寫術是一門關於信息隱藏的技巧與科學,所謂信息隱藏指的是不讓除預期的接收者之外的任何人知曉信息的傳遞事件或者信息的內容。”,究其本質,還是密碼學那一套。
追加文件內容
我們可以通過各種方式將信息寫到圖片,最常見的應該是將需要隱寫的內容以二進制的形式寫入圖片中,咱們在這里舉個簡單的例子,以下面的圖片為例:

這是我們開篇引用的圖片,記為原始圖像,將圖片保存在本地后(original.png),執行命令:
tail -c 50 1.png

可以看到執行結果里面是一串亂碼(用Hex查看器可以看到文件的二進制碼流,這里是utf-8,亂碼是正常的),對該文件執行命令:
cat original.png > result.png
echo testWrite >> result.png
tail -c 50 result.png
我們生成一張新的圖片之后,將一串字符追加到圖片末尾,可以看到圖片依舊是正常顯示的,同時查看圖片的內容,可以看到剛才寫入的testWrite字符串:

另外,將字符串加到文件頭部是不行的,因為文件頭部包含了文件格式等信息。如果你把信息插入到文件頭部,市面上的軟件就無法正確的識別文件的類型。
當然了,你可以自己設計編碼解碼器來創建新的文件類型。
這只是一種方式,而且手段十分暴力,處理之后的圖片文件較原來的文件是有一定的大小變化的(不過比較小,可以按字節計算)。更聰明的做法是將加密的信息按照某種模式寫入圖片的二進制流中,這樣一來,就只有加密方才能拿到對應的信息了。
但即使有復雜的加密方式,也還是不夠的,因為這只能保證別人在使用原始圖片的時候,我們可以鑒別圖片的來源、流傳路線,但要是通過屏幕截圖或者拍照的方式,我們就無法拿到這個數據,因為此時相對於我們做過處理的圖片,他已經是一張全新的圖片了。
修改RGB分量值
來看另一個例子,RGB分量值的小量變動:在圖片上覆蓋一層肉眼看不見的圖片,簡單來說就是我可以在圖片的某個單通道(如rgb中的b通道)內將水印信息寫入,其實這么說也還是很難懂,舉個例子:

現在要將左右兩側的圖片組合,但是不能讓右側的圖片內容在左側的圖片上觀察到,這時候我們要做的就是按照一定規則將水印圖片寫進這張圖片的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 是因為量級的變化極小,並不會影響到圖片的顯示
其實黑底藍字的圖片就是解碼出來的水印數據,詳細代碼:https://github.com/ai977313677/blog/blob/master/snippet/index.html
好像這種方式可以在用戶截圖時也能夠保留我們的水印?其實並沒有。

這是解碼截圖的結果,可以明顯的看到,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);
}

左側是從網頁上接下來的圖片,右側是在PS工具中處理之后的圖片[2],明顯可以看到我們設置的水印。
而生成圖片的方式就有很多種了,可以是前端生成,也可以是將信息發給后端,后端生成一張圖片,然后前端將圖片作為背景圖。
想要得到右側的結果,未必需要PS進行處理,可以通過其他的方式進行處理。
到這里,前端部分就結束了,但可能有人還覺得這不太行,我截網頁的圖現在是加上了水印,但是我要是保存原圖呢?那可以用之前說的RGB分量那個方式。
那我下載圖片之后在原圖上截取呢,不就失效了?確實,到這里前端能做的工作已經很少了。我們已經處理不到了,但是在圖像暗水印,或者說盲水印這個領域,還有更加有效的抵抗攻擊(去水印)的方式,比如頻域、空域的變換。這個變換可以說是老生常談的了,我就不過多解釋了。
補充兩句
水印的概念是泛化的,並不是說只有顯示在圖片某個角落的信息才能被稱為水印。
上面選擇將信息追加到文件末尾是有原因的,不是瞎選的。任何一種文件都包含文件結束符,就如文件頭部約定存放文件的格式信息一樣,即使你改了后綴,我也能通過讀取這個文件頭部的內容來識別文件真實的格式。
另外我們知道,文件后綴名是可以隨意更改的,如果只通過文件后綴名進行檢測,那么絕對是可以繞過的,進而出現任意文件上傳的安全問題。
如果改變圖層混合模式沒能成功,不妨試下修改圖像的RGB曲線
