淺談前端水印

貪婪的君子發表於2021-05-30

又是一個有關安全的問題。

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

image.png

明水印

這種水印的實現其實是比較簡單的,就是將兩張圖片合成一張,或者是直接在原圖上繪製內容就行了:

<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連結檢視

普通使用者所說的水印就是上面這種了,但是對於開發者來說,水印所包含的分類還是比較多的。

如我們在公司內網的部分系統(也可能是所有)上就能看到這種水印。

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的回答 - 知乎 ↩︎

相關文章