文本隐写
首先我们需要了解一下,什么是隐写术(Steganography)
隐写术:将信息隐藏在多种载体中,如:视频、硬盘和图像,将需要隐藏的信息通过特殊的方式嵌入到载体中,而又不损害载体原来信息的表达。旨在保护需要隐藏的信息不被他人识别。
当然信息隐蔽技术肯定不止隐写术,大概有:1)隐写术、2)数字水印、3)隐蔽信道、4)阀下信道、5)匿名信道 …
实现原理
通过将字符串中每个字符
转换为只有 1 和 0 的表示
,然后通过零宽字符
表示 0 和 1,就能实现通过零宽字符来表示一串看不见的字符串
首先我们了解一下什么是零宽字符?
顾名思义: 就是字节宽度为 0 的特殊字符。🙄 这解释…那我走?
😀 零宽字符: 是一种不可打印的 Unicode 字符, 在浏览器等环境不可见, 但是真是存在, 获取字符串长度时也会占位置, 表示某一种控制功能的字符.
零宽字符主要有以下六种:
- 零宽度空格符 (zero-width space) U+200B : 用于较长单词的换行分隔
- 零宽度非断空格符 (zero width no-break space) U+FEFF : 用于阻止特定位置的换行分隔
- 零宽度连字符 (zero-width joiner) U+200D : 用于阿拉伯文与印度语系等文字中,使不会发生连字的字符间产生连字效果
- 零宽度断字符 (zero-width non-joiner) U+200C : 用于阿拉伯文,德文,印度语系等文字中,阻止会发生连字的字符间的连字效果
- 左至右符 (left-to-right mark) U+200E : 用于在混合文字方向的多种语言文本中,规定排版文字书写方向为左至右
- 右至左符 (right-to-left mark) U+200F : 用于在混合文字方向的多种语言文本中,规定排版文字书写方向为右至左
考虑到 Unicode 中有所谓的Surrogate Pair
的情况,所以这里我们处理字符串的核心方法是codePointAt
和fromCodePoint
Surrogate Pair
:是 UTF-16 中用于扩展字符而使用的编码方式,是一种采用四个字节(两个 UTF-16 编码)来表示一个字符。
Unicode 编码单元(code points)的范围从 0 到 1,114,111。开头的 128 个 Unicode 编码单元和 ASCII 字符编码一样。 如果指定的 index 小于 0 或大于字符串的长度,则 charCodeAt 返回 NaN
例如:汉字“𠮷”(非"吉")的码点是 0x20BB7,而 UTF-16 编码为 0xD842 0xDFB7(十进制为 55362 57271),需要 4 个字节储存。 对于这种 4 个字节的字符,JavaScript 不能正确处理,字符串长度会误判为 2,"𠮷".length // 2
而且 charAt()方法无法读取整个字符,charCodeAt()方法只能分别返回前两个字节和后两个字节的值。 ES6 提供了 codePointAt()方法,能够正确处理 4 个字节储存的字符,返回一个字符的码点。
//一个字符由两个字节还是由四个字节组成的最简单方法。
"我".codePointAt(0) > 0xFFFF;//false
"A".codePointAt(0) > 0xFFFF;//false
"3".codePointAt(0) > 0xFFFF;//false
"𠮷".codePointAt(0) > 0xFFFF;//true
console.log(String.fromCodePoint(0x20bb7)); // "𠮷"
//或者十进制
console.log(String.fromCodePoint(134071)); // "𠮷"
//也可以多个参数
console.log(String.fromCodePoint(25105,29233,0x4f60)); // "我爱你"
String.fromCodePoint
方法是 ES6 新增加的特性,ES6 提供了 String.fromCodePoint()方法,可以识别大于 0xFFFF 的字符,弥补了 String.fromCharCode()方法的不足。一些老的浏览器可能还不支持。可以通过使用这段 polyfill 代码来保证浏览器的支持
当然,如果不需要处理复杂的字符,你也可以用charCodeAt
和fromCharCode
两个方法来对字符进行处理
好了,铺垫完了,开干~
先说加密
我们将文本通过codePointAt()
方法得出每个字符的Unicode编码点
值,然后将它们转为2进制
,每个字符间通过空格
分隔开,然后我们用零宽字符​
来表示1
,用零宽字符‌
来表示0
,用零宽字符‍
来表示空格
,这样就可以得到一段完全用零宽字符表示的看不见的字符串
多了不说,少了不唠 ~ 先看代码!
// 字符串转零宽字符串
function encodeStr(val = "隐藏的文字") {
return (
val
.split("")
.map((char) => char.codePointAt(0).toString(2))
.join(" ")
.split("")
.map((binaryNum) => {
if (binaryNum === "1") {
return ""; // 零宽空格符​
} else if (binaryNum === "0") {
return ""; // 零宽不连字符‌
} else {
return ""; //空格 -> 零宽连字符‍
}
})
.join("")
);
}
console.log("str:",encodeStr(),"length:",encodeStr().length)//大家可以把这段copy到控制台执行以下看看
这一块一开始return
出去的都是直接写在双引号中的零宽字符,但是你们复制到浏览器中估计也不方便看,反正它是看不到的,平台要是给我过滤了,那更尴尬
想在 vscode
或者atom
中看到代码里是否有零宽字符
,可以下载Highlight Bad Chars
插件来实现零宽字符的高亮(如上图所示)
为了方便大家容易区分空字符串与零宽字符,这里我把代码改了一下(这里是用 vue 做的,以后我的所有例子都会在这个仓库下当然它也有线上的体验地址,欢迎大家来指点~)
// 字符串转零宽字符串
encodeStr() {
this.cipherText = this.text.split("");
//在字符串中的随机一个位置插入加密文本
this.cipherText.splice(
parseInt(Math.random() * (this.text.length + 1)),
0,
//加密的文本
this.hiddenText
.split("")
//['荣', '顶' ]
.map((char) => char.codePointAt(0).toString(2))
// ['1000001101100011','1001100001110110']
.join(" ")
//"1000001101100011 1001100001110110"
.split("")
/* [ '1', '0', '0', '0', '0', '0', '1', '1', '0', '1', '1', '0', '0', '0', '1', '1', ' ',
'1', '0', '0', '1', '1', '0', '0', '0', '0', '1', '1', '1', '0', '1', '1', '0'] */
.map((binaryNum) => {
if (binaryNum === "1") {
return String.fromCharCode(8203); // 零宽空格符​
} else if (binaryNum === "0") {
return String.fromCharCode(8204); // 零宽不连字符‌
} else {
return String.fromCharCode(8205); //空格 -> 零宽连字符‍
}
})
//对上面所有的数组元素进行处理,生成一个新的数组['', '', ''......]其中每一个元素都是零宽字符,分别代表0和1以及
.join(String.fromCharCode(8206))
// 用左至右符‎来把上面的数组相连成一个零宽字符串=>""
);
this.cipherText = this.cipherText.join("");
console.log(this.cipherText, "cipherText");
}
其中随机混入零宽字符串主要是这段伪代码
let str = "qwe12345789".split("");
//在字符串中的随机一个位置插入加密文本
str.splice(parseInt(Math.random()*(str.length+1)),0,"加密文本").join("")
加密后的文本通过 trim 或者通过网络发送都是没问题的
"中秋节快乐".trim().length//114
大家都了解如何加密后,我们再看以下如何对字符串进行解密
解密首先需要了解如何提取零宽度字符
"点赞鼓励~😀".replace(/[^\u200b-\u200f\uFEFF\u202a-\u202e]/g, "");
解密的过程就是加密的反向操作,我们先提取文本中的零宽字符串,用 1
来表示零宽字符​
,用0
来表示零宽字符‌
,用空格
来表示零宽字符‍
,这样就可以得到一段由 1 和 0 组成空格分隔的字符串,我们将每段 1/0 表示的字符串转成十进制后,通过String.fromCharCode
方法将其转回为可以看得见的文字即可~
// 零宽字符转字符串
decodeStr() {
if (!this.tempText) {
this.decodeText = "";
return;
}
let text = this.tempText.replace(/[\u200b-\u200f\uFEFF\u202a-\u202e]/g, "");
let hiddenText = this.tempText.replace(/[^\u200b-\u200f\uFEFF\u202a-\u202e]/g, "");
console.log(text, "text");
console.log(hiddenText, "hiddenText");
this.decodeText = hiddenText
.split("") //不是空字符串,是 ‎
.map((char) => {
if (char === "" /* 不是空字符串,是​ */) {
return "1";
} else if (char === "" /* 不是空字符串,是‌ */) {
return "0";
} else {
/* 是‍时,用空格替换 */
return " ";
}
})
.join("")
//转数组
.split(" ")
//根据指定的 Unicode 编码中的序号值来返回一个字符串。
.map((binaryNum) => String.fromCharCode(parseInt(binaryNum, 2)))
.join("");
console.log(text + hiddenText);
},
演示一波
核心实现方法就是以上,现在我们来演示一下,大家看图前,首先可以把这小段文字复制到任何地方打印下(就 F12 控制台看最方便)console.log("中秋快乐123456".length)
长度一定不是 10😀
这里的 demo 我已经上传至我的 github,你可以点击这里体验一下~
应用场景
- 数据防爬
将零宽度字符插入文本中,干扰关键字匹配。爬虫得到的带有零宽度字符的数据会影响他们的分析,但不会影响用户的阅读数据。 - 隐藏文本信息
将自定义组合的零宽度字符插入文本中,用户复制后会携带不可见信息,达到隐藏文本信息的作用。 - 逃脱敏感词过滤
你在游戏里骂人,你妈*
肯定是发不出去的,但是你发你xxxxx妈yyyyy*
就不一样了 😀,这个东西还是研究起来很有意思的。 - 嵌入隐藏的代码还有很多等等等…
试想当在电子书籍 PDF 或者一些正版影视音乐作品中嵌入购买人的个人信息,那还有多少人敢直接传给别人看呢?
像下面这种明水印,加了和没加没啥区别,很容易就被别人扣掉了
而且还被别人更换了水印,打上了广告,啧啧啧…如果一本电子书里嵌入了大量的隐写内容,对于盗版的传播行为,其实是可以起到很好的追责作用的(各大出版商,电子书可以搞起来 😀 买电子书都有购买者的联系方式和个人信息的)
诸如像 toG 的项目中,有很多敏感内容都是有个人信息的明水印,和隐写的个人信息,传出去了就知道是谁传的 (至少对于不懂技术的人来说,隐写是真的很难被发现的)
预防零宽字符的植入
说了这么多如何对字符进行零宽字符的植入和提取,那么像掘金哪天不允许你们加这玩意在里面了,他们对所有的文本用正则这么搞一遍就没了
预防用户上传的文本中含有零宽字符,一般我们先做一次替换即可
str.replace(/[\u200b-\u200f\uFEFF\u202a-\u202e]/g, "");
图片隐写
什么是图片隐写?
lsb 隐写很实用,算法简单,能存储的信息量也大,更何况是 CTF 比赛中的常客(起源于 1996 年 DEFCON 全球黑客大会)
实现原理
图片的隐写有很多方式,我这里只用最简单的方式 (LSB:二进制最低位,不是"老色 X"😅) 做演示,当然也是因为我菜 😥 数学不好,要不然我也可以用傅里叶变换通过处理图片的色波 (图像本质上就是各种色彩波的叠加 这个概念的具体解释可以看阮一峰老师的这篇文章 ) 来做,那样又帅又飘~害!
二进制最低位
什么是二进制最低位?就是二进制最后面一位
好了,多了不说少了不唠 ~ 我们一起看图说话 ~
每个二进制的值的最低位都可以表示一个 1bit 的数据,一个像素需要 RGBA,4 个值共 4 * 8=32 个 bit 所以至少需要八个像素 (32 个值) 的最低位才能表示一个像素的 RGBA 值 (这里的 2560000 / 4 = 640000 个像素可以用来储存 2560000 / 32 = 80000 个像素的 RGBA 值)
即:每 32 个值的二进制最低位可以用来表示一个标准像素的 RGBA 值(为方便理解,如下图所示 👇)
拿到需要隐藏的数据
这一步,我们可以隐藏图片,隐藏文字,也可以自己手绘一些东西在画布上
将我们已经在主画布上绘制或加载的图片转换为 URL ,通过创建一个临时小画布, 将主画布生成的 URL 通过drawImage
的方式,缩小的绘制到临时小画布上
(这里为什么要这么做?还是因为上面提到的 主画布的 640000 个像素值的最低位只能储存 80000 个像素的 RGBA 值)
//将画布上的信息绘制到小画布上保存起来
saveHiddenImageData() {
const tempCanvas = document.createElement("canvas");
const tempCtx = tempCanvas.getContext("2d");
//小画布的长宽=大画布的像素/8后再开平方
//因为需要八个像素的最低位才可以表示一个小画布的像素的RGBA值
tempCanvas.width = Math.floor(Math.sqrt((this.canvas.width * this.canvas.height) / 8));
tempCanvas.height = Math.floor(Math.sqrt((this.canvas.width * this.canvas.height) / 8));
var image = new Image();
image.src = this.canvas.toDataURL("image/png");
image.onload = () => {
//绘制图像到临时的小画布
tempCtx.drawImage(image, 0, 0, tempCanvas.width, tempCanvas.height);
this.hiddenData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
this.hiddenData.binaryList = Array.from(this.hiddenData.data, (color) => {
color = color.toString(2).padStart(8, "0").split("");
return color;
});
console.log(this.hiddenData, "hiddenData");
this.$message({
type: "success",
message: "保存成功!请选择目标图片~",
});
this.canvas.clear();
};
},
再拿目标图的数据
在拿目标图(你要把隐藏的数据隐写进的目标图)的数据时候,我们需要预先处理一下所有的颜色值
这一步很关键,可以看到,我上面的图,画布加载完目标图片时,我们可以通过getImageData
方法读取到页面的所有像素值,我这里将所有像素的二进制最低位全部归零,即把所有的颜色值都处理成偶数/非奇非偶数(0)
我们通过操作每个值的最低位,用来储藏要保存的数据, 肉眼是无法看出你对最低位的更改的
//获取画布像素数据
getCanvasData() {
this.targetData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
//将数字化为非奇数
function evenNum(num) {
num = num > 254 ? num - 1 : num;
num = num % 2 == 1 ? num - 1 : num;
return num;
}
//存一个二进制的数值表示
this.targetData.binaryList = Array.from(this.targetData.data, (color, index) => {
this.targetData.data[index] = evenNum(this.targetData.data[index]);
color = evenNum(color).toString(2).padStart(8, "0").split("");
return color;
});
console.log(this.targetData);
this.loading = false;
},
写入隐藏的数据
到这里,我们已经拿到了隐藏的数据,和目标图像的数据,接下来我们需要做的就是将这 318,096 个颜色值写入到目标图片的 2560000 个颜色值的二进制最低位中,即可实现图片的隐写
再次需要注意的是:通过最低位来做的话,我们隐写到图片中的数据是有限的,即 图片的整体像素 / 8 = 图片中可以隐藏的像素(个)
这里我们用的是800*800
的画布,有640000
个像素,其中可以隐藏 640000 / 8 = 80000个
像素
所以我们隐藏的数据只能绘画到 Math.floor(Math.sqrt((this.canvas.width * this.canvas.height) / 8))
这里是 282 的宽高的 canvas 中(79,524 个像素,318,096 个颜色值),这里求出的数只能向下取整,要不然会溢出,导致丢失隐藏的数据!
这里的操作就像是我们藏头诗一样,我们把数据藏在尾部
RGB 分量值的小量变动,是肉眼无法分辨的,不影响对图片的识别。你看不出来这个+1 的区别
多了不说少了不唠~先看代码
//将隐写的资源图片数据存到目标图片的二进制最低位中
drawHiddenData() {
//将隐藏的数据的二进制全部放到一个数组里面
let bigHiddenList = [];
for (let i = 0; i < this.hiddenData.binaryList.length; i++) {
bigHiddenList.push(...this.hiddenData.binaryList[i]);
}
console.log(bigHiddenList, "bigHiddenList");
this.targetData.binaryList.forEach((item, index) => {
bigHiddenList[index] && (item[7] = bigHiddenList[index]);
});
this.canvas.clear();
this.targetData.data.forEach((item, index) => {
this.targetData.data[index] = parseInt(
this.targetData.binaryList[index].join(""),
2
);
});
const tempCanvas = document.createElement("canvas");
tempCanvas.width = 800;
tempCanvas.height = 800;
let ctx = tempCanvas.getContext("2d");
ctx.putImageData(this.targetData, 0, 0);
fabric.Image.fromURL(tempCanvas.toDataURL(), (i) => {
this.canvas.clear();
this.canvas.add(i);
this.canvas.renderAll();
});
this.$message({
type: "success",
message: "加密成功!",
});
},
可以看到,这里已经将一张图片隐藏到了另一张图片中,(我们这里将一个月饼图隐写进了月亮图中)
解析加密后的图片
完成了图片加密后我们接下来需要做的是解析加密后的图片
首先我们选取本地图片后,将其渲染到画布上,在通过getImageData
获得画布的像素数据,建立一个二进制的存储表示,后面将通过它的最低位取出隐藏在目标图中的图片
//获取画布像素数据
getCanvasData() {
this.targetData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
//存一个二进制的数值表示
this.targetData.binaryList = Array.from(this.targetData.data, (color, index) => {
color = color.toString(2).padStart(8, "0").split("");
return color;
});
console.log(this.targetData);
this.loading = false;
},
冲图片中抽取隐藏的颜色值
这里我们主要将主画布的二进制颜色值的最低位抽离出来,组装成隐藏图片的像素值数组,最后通过putImageData
来绘制出隐藏其中的图片
这里非常需要注意的是: putImageData
的第一个参数data
的长度必须为两个边的乘积的4的倍数
否则就会报错
所以,这里我们取像素的时候,需要这么取Math.pow(Math.floor(Math.sqrt(2560000 / 32)), 2) * 4
,由于我这里是 800 * 800的所以是2560000个值,你直接写成变量canvas.width * canvas.height *4
即可
//解析图片
decryptImage() {
const c = document.getElementById("decryptCanvas");
const ctx = c.getContext("2d");
let decryptImageData = [];
for (let i = 0; i < this.targetData.binaryList.length; i += 8) {
let tempColorData = [];
for (let j = 0; j < 8; j++) {
tempColorData.push(this.targetData.binaryList[i + j][7]);
}
decryptImageData.length < Math.pow(Math.floor(Math.sqrt(2560000 / 32)), 2) * 4 &&
decryptImageData.push([...tempColorData]);
}
decryptImageData = Uint8ClampedArray.from(decryptImageData, (z) => {
z = parseInt(z.join(""), 2);
return z;
});
console.log(decryptImageData, "decryptImageData");
//需要注意的是putImageData的data的长度必须为两个边的乘积的4的倍数
ctx.putImageData(
new ImageData(
decryptImageData,
Math.floor(Math.sqrt(2560000 / 8 / 4)),
Math.floor(Math.sqrt(2560000 / 8 / 4))
),
0,
0
);
},
经过这一步后,我们可以看到,就能够从主画布中提取出隐藏在其中的图片!!!
哇 ~ 是不是非常的有意思?
还需要注意的点 :LSB 方式的隐写图片只能存储为 PNG 或者 BMP 图片格式,并且不可以再用有损压缩(比如 JPEG),否则会丢失隐写的数据!
**不要用隐写做违法犯罪的坏事!!!**因为它是可以防(某墙)监控的,比如你把坏孩子图藏进了好孩子图里边,我去太坏了 😢
但是你身为程序员,你可以用它去给你心爱的宝贝表白撒,不失为一种浪漫的方式~
以上是文本和图片的隐写相关内容,当然隐写的媒介不可能只局限于此,还有很多中,只要是计算机能用数字表达一切东西,按道理来说都是可以用来做隐写的,例如音频的隐写
最后
隐写术是一门很深、应用很广泛的学问,这里讲的很泛,权当做抛砖引玉。图片和文字的隐写只是其中最简单的一部分,有兴趣的同学可以看一本叫《数据隐藏技术揭秘》的书。需要的小伙伴也可以加我,我发给你!
给大家的福利
零基础入门
对于从来没有接触过网络安全的同学,我们帮你准备了详细的学习成长路线图。可以说是最科学最系统的学习路线,大家跟着这个大的方向学习准没问题。
同时每个成长路线对应的板块都有配套的视频提供:
因篇幅有限,仅展示部分资料
朋友们如果有需要全套《网络安全入门+进阶学习资源包》,可以扫描下方二维码免费领取(如遇扫码问题,可以在评论区留言领取哦)~
网络安全面试题
绿盟护网行动
还有大家最喜欢的黑客技术
网络安全源码合集+工具包
所有资料共282G,朋友们如果有需要全套《网络安全入门+黑客进阶学习资源包》,可以扫描下方二维码免费领取(如遇扫码问题,可以在评论区留言领取哦)~