前言

Web浮水印技術在資訊安全和版權保護等領域有著廣泛的應用,對防止資訊洩露或知識產品被侵犯有重要意義。
浮水印的添加根據環境可以分為兩大類:

  1. 前端瀏覽器環境添加
  • 伺服器的運算量、記憶體的減少
  • 能夠快速回應請求
  • 安全性較低,有心人很容易通過各種騷操作直接獲取到原始檔案
  1. 後端服務環境添加
  • 安全性較高,無法獲取到原始檔案
  • 當遇到大檔案密集浮水印,或是複雜浮水印,佔用伺服器記憶體、運算量,請求時間過長
    浮水印根據可見性可分為可見浮水印不可見浮水印(盲浮水印)

顯性浮水印/可見浮水印

容易處理,演算法較為簡單,攻擊者就可以通過裁剪、模糊等操作對浮水印進行攻擊消除,同時顯性浮水印也會破壞圖片的完整性。

基本實現

1
2
3
img{
background-image: url("./logo.png");
}

這裡想要的效果就是一個淺淺的 logo 平鋪展示。實現起來也比較簡單,只需製作一個半透明的 logo 圖片,設為文章或者表格的背景圖片即可。僅需一行 CSS 聲明。

實現圖片平鋪關鍵的 CSS 屬性是 background-repeat,值為 repeat時是平鋪,這也是它的預設值,所以可以省略。

全頁面浮水印

如果要給整個 Web 頁面加上浮水印,是不是給頁面的body 元素設置背景圖片平鋪展示就可以了呢?

然而通常並不會這麼處理,因為文章和表格內容多以文本為主,不會明顯遮擋浮水印,而一個完整的頁面往往還包含很多其他頁面元素,比如圖片、視頻、控制項等等,它們很可能會遮擋住背景圖片,從而影響浮水印效果。
所以,為了避免被其他元素遮擋,針對頁面的浮水印一般會使用一個層級比較高且覆蓋整個頁面的元素來承載。

1
2
3
4
5
6
7
8
9
10
div.watermark{
position: fixed;
left:0;
top:0;
width: 100vw;
height: 100vh;
background-image:url("./logo.png");
opacity: .5;
z-index: 3000;
}

這樣一來,其他元素就遮擋不住浮水印了。不過,這個 div反過來可能會遮擋頁面其他元素,影響頁面元素操作。還需要一條關鍵的CSS聲明來破解這個問題:
pointer-events:none;,這個CSS聲明會使該元素“可穿透”,“看得見、摸不著”,不再影響頁面操作。

動態浮水印

很多時候,給頁面加浮水印的目的並不是申明版權,而是為了支持溯源。此時浮水印的內容並不會只是一個logo,通常會包含使用者資訊,比如用戶名、UID、手機號等等。這就意味著,每個使用者的浮水印內容是不同的,無法通過提前準備好一張圖片來滿足了。這種場景往往需要根據使用者資訊動態生成圖片。

服務端方案

傳統的方式是在服務端生成圖片。頁面上發起的圖片請求中可以附帶使用者資訊,服務端根據這些參數動態生成圖片,並將圖片資料作為該請求的回應返給頁面,頁面拿到後將其用作浮水印。
這種方式的優點是相容性好,缺點是需要前後端配合,增加了頁面請求和服務端資源開銷,防攻擊能力也較差。

Canvas方案

HTML5 引入 Canvas特性使得流覽器自身具備了繪圖能力。經過多年的發展,主流流覽器基本都已可以提供良好的支援。通過 Canvas 可以輕鬆繪製圖片,並可將圖片資料匯出,用於頁面圖片或背景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const canvasElement = document.createElement('canvas');
const context = canvasElement.getContext('2d');
canvasElement.width = 200;
canvasElement.height = 200;
context.rotate((-30 * Math.PI) / 180);
context.font = '400 26px Arial';
context.fillStyle = '#B9C0CA';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText('浮水印文字', 70, 130);
const watermark = canvasElement.toDataURL('image/png');
````
通過上述示例代碼可拿到浮水印圖片的 data URI 資料,用作浮水印承載元素的背景圖片平鋪展示即可。
這種方式不需要服務端配合,在前端就可以完成,且有助於減少請求和服務端資源開銷。曾經面臨的流覽器相容問題現在也不再是問題,該方案已逐漸流行起來。

#### SVG方案
{% tip bell %}對於純文字的浮水印來說,有沒有辦法不生成圖片而直接實現平鋪呢?{% endtip %}
動態創建大量 DOM 節點,通過 CSS 控制排列當然可以實現,但是繁瑣且性能差,優雅更無從談起。
不妨換個角度思考,有沒有辦法讓文字不轉成圖片就可以用作 background-image 屬性的值呢?這樣就可以利用 background-repeat 實現平鋪效果了。
這時候可以考慮使用 SVG,因為 SVG 具有文本和圖像的雙重特性。看上去是文本,然而在很多場景可以當做圖片使用。
我們可以通過 SVG 的相關屬性精准控制字體位置、大小、顏色、透明度和旋轉角度等參數。如:
```HTML
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
<text x="50%" y="50%" font-size="30" fill="#a2a9b6" fill-opacity="0.3" font-family="system-ui, sans-serif" text-anchor="middle" dominant-baseline="middle" transform='rotate(-45, 100 100)'>201250005 洪子贤</text>
</svg>

考慮到瀏覽器相容性,用作背景圖片時,建議將 SVG 編碼為Base64(或轉義特定字元)


1
2
3
4
5
6
7
8
.watermark{
background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZm9udC1zaXplPSIzMCIgZmlsbD0iI2EyYTliNiIgZmlsbC1vcGFjaXR5PSIwLjMiIGZvbnQtZmFtaWx5PSJzeXN0ZW0tdWksIHNhbnMtc2VyaWYiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRyYW5zZm9ybT0ncm90YXRlKC00NSwgMTAwIDEwMCknPjIwMTI1MDAwNSDmtKrlrZDotKQ8L3RleHQ+PC9zdmc+");
z-index: 100;
position:absolute;
height: 100%;
width: 100%;
background-repeat: repeat;
}

浮水印安全

浮水印是用來保護資訊安全的。資訊要安全,首先要確保浮水印自身的安全,提高浮水印的防攻擊(篡改、刪除等)能力。可見浮水印大都是基於DOM的,找到這個DOM節點,通過流覽器外掛程式、抓包工具等在頁面上注入一段JavaScript或者CSS代碼對其進行篡改或刪除並不困難。為防止外部代碼篡改,一種思路是把浮水印元素封裝起來,與外部環境進行隔離。

Shadow DOM

谷歌的Web Components技術允許在Web中創建可重用的小部件或元件。Web Components 的一個重要特性就是“封裝”,即可以將標簽的結構、樣式和行為隱藏起來,並與頁面上的其他代碼相隔離。比如我們熟悉的 video 元素,它的進度條、按鈕等控制項都已被封裝。
Shadow DOM是“封裝”特性的關鍵所在,它可以將一個隱藏的、獨立的 DOM 附加到一個元素上。
shadow DOM.jpg
為了提高web浮水印的隱蔽性,同時避免受外部代碼影響,在一定程度上防止篡改,可以考慮把浮水印元素放在Shadow DOM中。使用 Element.attachShadow()方法來將一個shadow root附加到任何一個元素上。它接受一個配置物件作為參數,有一個 mode 屬性,值可以是 open 或者 closed 。open表示可以通過頁面內的JavaScript方法來獲取Shadow DOM。而 closed則表示不可以從外部獲取Shadow DOM 。

1
Element.attachShadow({mode:'closed'});

Shadow DOM 中的樣式本身就是隔離的,除非主動使用CSS 變量、part屬性等暴露,外部樣式是不會影響到元件內的。

監聽

除此之外,還有一種常見的人為修改攻擊場景,比如在瀏覽器控制台直接修改或刪除對應的DOM元素。
可以考慮“監聽”這種行為,一旦發生就馬上修復,比如重新插入一個。那怎麼實現這種“監聽”呢?現代流覽器中有多種觀察者(Observer),比如IntersectionObserver、PerformanceObserver、ResizeObserver、ReportingObserver、MutationObserver 等。其中,MutationObserver 就可以用來監聽 DOM 變動,DOM 的任何變動,比如節點的增減、屬性的變動、文本內容的變動,通過該 API 都可以得到通知。
所以可以使用 MutationObserver API 來監聽浮水印元素 DOM 變化,一旦監聽到 DOM 元素被修改或者刪除,就立即重新插入一個。

不可見浮水印(盲浮水印)

不可見浮水印通常具有比可見浮水印更好的隱蔽性和抗攻擊性。雖不可見,但通過一定的技術手段是可以將浮水印資訊從其載體上提取出來的,這就使得其載體具備了溯源能力,在關鍵時刻往往能發揮大作用。
不可見浮水印相對可見浮水印至少有以下三個明顯的優勢:

  1. 更好的觀感。可見浮水印總給人一種“膏藥感”,甚至會引起部分人的不適,而不可見浮水印則不會有這個問題。
  2. 更佳的隱蔽性。用戶基本感知不到浮水印的存在。
  3. 更強的抗攻擊性。可見浮水印更容易受到攻擊,而不可見浮水印除了隱蔽性比較強之外,其自身往往還具備比較強的抗攻擊能力。

在說數位浮水印之前,這裡介紹一些數位圖像的基礎知識。
數位圖像(點陣圖)是由圖元(pixel)組成。

  • 非黑即白的二值圖像,1 個 bit 即可表示 1 個圖元(黑白兩種狀態)。所以 1 個位元組(byte)可以表示 8 個圖元。
  • 灰度圖,1 個圖元有 256 種狀態(2 的 8 次方),需要 8 個 bit,即 1 個位元組。
  • 彩色的 RGB 圖,有R/G/B三個通道,每個通道 256 種狀態,使用 1 個位元組表示,共需 3 個位元組表示 1 個圖元。
  • RGBA 圖,在R/G/B三個通道基礎上增加了一個透明度通道,256 種狀態,額外需要 1 個位元組,共需要 4 個位元組表示一個圖元。
    通常,考慮到計算速度和性能,影像處理和圖像識別大都會將圖像轉成灰度圖或者選擇其中一個通道進行。

LSB浮水印

灰度圖像的一個圖元有256種狀態,通常用灰度值(0-255)表示,0 表示黑色,255 表示白色,灰度值越大表示亮度越高。
灰度可用一個位元組,即 8 比特二進位數字表示,其中最高位元對圖像的貢獻最大,最低位元對圖像的貢獻最小,稱為最低比特位(Least Significant Bit,LSB)。
如果將一個圖像所有圖元的比特位元抽出來,就構成了 8 個不同的位平面,從LSB(最低有效位0)到 MSB(最高有效位7)。位元平面從低位元到高位,圖像的特徵逐漸變得複雜,細節不斷增加,相鄰比特的相關性也越強。而比特位元越低包含的圖像資訊就越少,最低位平面類似於隨機雜訊。因此,改變低位元對圖像的成像品質影響不大。
LSB 浮水印就是利用了這一點,用浮水印資訊替換載體圖像的最低比特位元,這樣原圖像的7個高位平面就與表示浮水印資訊的最低位元平面組成了新的圖像。
LSB浮水印

LSB浮水印魯棒性(防攻擊性)較差,浮水印資訊容易被抹去。

頻域浮水印

在圖像信號的頻域(變換域)中隱藏資訊要比在空間域中隱藏資訊具有更好的魯棒性。那麼如何把圖像信號從空間域轉換到頻域呢?這裡就需要用到大名鼎鼎的傅里葉變換了。
傅里葉提出的傅里葉變換(Fourier transform)理論,表示能將滿足一定條件的某個函數表示成三角函數(正弦和/或余弦函數)或者它們的積分的線性組合,可用於把信號從時間域(或空間域)變換到頻率域。
图像頻域.jpg
傅里葉變換在數位圖像處理領域有着極為重要的應用圖像領域變換的實質是把圖像函數展開成具有不同空間頻率的正、余弦信號的疊加,也就是說任何圖像都可以分解為若干個頻率不同的亮度呈正弦變化的圖像之和。把圖像從空間域變換到頻率域後,就能夠實現對圖像資料進行不同頻率成分的提取。對於圖像信號來說,可以把灰度(亮度)看做頻率,傅裡葉變換可作為圖像灰度值形成的空間域與其頻率域的橋樑。
在頻域中隱藏資訊就是傅里葉變換在數位影像處理領域的一個典型應用場景,通常多選擇在圖像頻域的中頻部分嵌入資訊,因為高頻部分易於被各種信號處理方法破壞,而人的視覺又對低頻部分比較敏感,容易察覺低頻部分的變化。

變換域技術還包括:

  • 离散余弦变换(discretecosinetransform,DCT),DCT 变换的好处是,如果原序列是实数序列,那么变换后也是实数序列。二维DCT的最著名的应用是 JPEG 压缩。
  • 离散小波变换(discretewavelettransform,DWT)
  • 离散傅立叶变换(discretefouriertransform,DFT)
  • Mellin傅立叶变换(mellinfouriertransform)
    可以參考:隱寫术、盲水印入門知識體系

實踐一下

  1. 首先把頁面上每張圖片都加上一個點击事情的監聽器,當被點击了就跳到新頁面打開這張圖的大圖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    function addClick(){
    var photoBox = document.getElementsByClassName("box");
    console.log(photoBox);
    for(i = 0; i < photoBox.length; i++){
    var photos = photoBox[i].getElementsByTagName("img");
    for(j = 0; j < photos.length;j++){
    var img = photos[j];
    img.addEventListener("click",function(){
    var big_image_url = "../"+ this.src.replace(rootPath,"");
    console.log(big_image_url);
    window.open("./bigImg/images.html?src="+ big_image_url);
    },false);
    }
    }
    }

    圖片展示頁.png

  2. 加上SVG方案的水印

    1
    2
    3
    4
    5
    6
    7
    <body onload="getSrc()">
    <div class = autumn>
    <svg class="watermark"></svg>
    <img src = "../img/photo-1572294846914-4b5092cf5fff.jfif" alt="秋日校园">
    </div>
    <input type="button" class="changed" value="傅里叶变換" onclick="window.location.href='./steganography.html'">
    </body>

    加了水印的圖.png

  3. 在新頁面實現不可見的水印
    首先引入cryptostego.min.js,項目地址
    1
    <input type="file" value="請选择一張图片" id="file" accept="image/*" onchange="loadIMGtoCanvas('file', 'preImg', writeFunc,800)"/>
    這個函數實現讀取一個file對像,寫到canva裏,用後調函數處理完銷毀。writeFunc函數如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    function writeFunc(){
    if(writeMsgToCanvas('preImg',"201250005 洪子贤","206477",3)!=null){
    var myCanvas = document.getElementById("preImg");
    var image = myCanvas.toDataURL("image/jpeg",1.0);
    var element = document.createElement('a');
    element.setAttribute('href',image);
    element.setAttribute('download','result.jpg');
    element.style.display = 'flex';
    document.body.appendChild(element);

    var canvas = document.createElement('CANVAS');
    var ctx = canvas.getContext('2d');
    var img = new Image();
    img.onload = function(){
    canvas.width=img.width;
    canvas.height = img.height;
    ctx.drawImage(img,0,0);
    cb(canvas);
    };
    img.src = image;
    element.style.width = img.width;
    element.appendChild(canvas);
    }
    }
    隱寫後的圖.png