阅读 2217

神秘的JS字符串隐写术

作者:Gavin,未经授权禁止转载。

什么是隐写术?

隐写术是一门关于信息隐藏的技巧与科学,所谓信息隐藏指的是不让除预期的接收者之外的任何人知晓信息的传递事件或者信息的内容。

能干什么?

字符串隐写术可以通过不可见的Unicode简单实现,对可见字符串进行不可见的加密拼接,对应的还有通过秘钥进行加密,通常在浏览器端,秘钥很容易被找到,而隐写术即使找到了,不知道原理也不容易破解。

想象一个文件链接防抓取的场景

小明公司有一个应用ID为1的H5应用,应用首页包含一个后台配置的banner模块(模块ID为1),管理后台会配置相关JSON数据并将其上传到CDN,H5端获取CDN上的JSON数据进行banner数据展示,此时小明对JSON数据做了如下保存:

CDN地址如下(1_1代表:应用ID_模块ID):

/config/foo/bar/1_1.json

示例Banner模块数据

[
    {
        "img": "https://a.com/1.png",
        "url": "https://example.com/1"
    },
    {
        "img": "https://a.com/2.png",
        "url": "https://example.com/2"
    }
]
复制代码

此时有一个问题,因为类似1_1.json这种文件名很容易被人抓取到所有配置,因为完全可以再拼一个1_2.json1_3.json进行数据抓取,很容易就抓取了所有应用的配置数据。于是小明想到了另一种方式,将原始文件名+salt,然后MD5处理一下,如:

// 伪代码
md5('1_1' + 'abc'); // abc 为salt
复制代码

前端相关模块写死获取数据地址,此时得到数据地址为:

/config/foo/bar/1B0736D4BF73D0C3140DDA32ECDEECAE.json

但以上操作会带来另一个问题,如果同一个banner模块,需要在不同的城市展示不同的数据,比如需要配置如下原始文件名的数据(salt都为abc):

1_1_shanghai.json => EA1FC5DAED1A4CEF220E79B4E9B8E368.json

1_1_beijing.json => 169D630A9A62DE12A936AFC820231B23.json

1_1_hangzhou.json => 7DACCDEE3612D7DC07B9158B405FF70E.json

随着城市的增多,前端写死的地址会成比例的增多,显然此种方案不合理,有没有别的方案呢?答案是肯定的:

  1. 多请求一个接口获取当前城市的数据配置链接;
  2. 前端自己拼接文件名。

对于第1种方案,其实治标不治本,一样可以抓取第一个接口,通过参数拼接的方式调用该接口拿到所有的配置数据地址,而且还多走了一次请求,显然不靠谱;

对于第2种方案,有个很大的问题,salt保存在js端,也很容易通过断点源码的方式拿到salt。还有没有别的方法呢?答案依然是肯定的。

前端salt保存的方式

简化上面问题模型,即:知道部分链接无法进行其它数据的抓取

其实要解决上面的问题,只需要解决让用户拿不到salt的问题就行了,或者换个说法,让用户拿不到正确的salt。同样也有多种方式:

  1. 通过webassembly实现加密过程,js端负责传入原始文件名,wasm负责保存salt和隐藏加密过程;
  2. 通过混淆、压缩等方式进行代码加密;
  3. 通过字符串隐写术进行隐藏、混淆,表现的更隐蔽。

对于前两种方式本文将不做讲解。

隐写术示例

示例代码

上面代码浏览器输出

让人误解的代码

// 伪代码
md5('abc1'); // 应该输出:23734CD52AD4A4FB877D8A1E26E5DF5F
// 而上面代码却输出:73840722a304fcdab7bfd75326b511ed
复制代码

问题所在

上面输出结果与理解的结果不一致,原因是因为salt后面看似为空的字符串,其实并不是真正的空字符串,让我们打印一下它的长度

console.log($('#salt').text().length); // 15
console.log('1'.length); // 1
复制代码

继续打印一下它的Unicode

console.log(stringToUnicode($('#salt').text())); // \u31\u200c\u200d\u2062\u2063\u2064\u200c\u200d\u2062\u2063\u2064\u200d\u2062\u2063\u2064
console.log(stringToUnicode('1')); // \u31
复制代码

可以看到:看似只是把数字1转换为字符串1的一行简单代码,缺隐藏了这么大一堆你看不见的字符

如何调试

  1. 直接复制隐藏字符然后利用文本编辑工具(sublime、vim等)粘贴,注:webstorm粘贴时会将隐藏字符去掉
  2. 如果你是用Mac,可以这样复制隐藏字符,然后利用文本编辑工具粘贴:
$ echo "\u200d\u2062\u2063\u2064" | pbcopy
复制代码

真正使用

StegCloak

一个JS隐写术的实现,满足Kerckhoffs's principle原则:即使攻击者识别了加密算法的工作原理,也不可能泄密。

StagCloak的加密原理

首先要知道Unicode原理

Unicode编码简单讲就是:\u + 16进制的唯一标识,例如:

console.log(stringToUnicode('hi')); // \u68\u69
复制代码

hi都为ASCII码,ASCII在各语言的实现上通常都只占1字节,1字节=8位,'h'与'i'所以分别转换为二进制为:

01101000

01101001

注:大小写不同对应的ASCII码也不同

回到StagCloak

既然ASCII对应一个8位的二进制,可以将所有字符转换为二进制,不过将字符串全转成二进制字符串肯定不靠谱,因为这样代码的体积会成倍的增加,也达不到加密的目的,对于二进制字符串可以很简单利用前面提到的六个隐藏字符进行压缩和“伪加密”:

二进制隐藏字符(字母标识方便说明)
00200C(A)
01200D(B)
102060(C)
112062(D)
Dynamic2063(E)
Dynamic2064(F)

由于两两一组的二进制组合只存在四种,所以只需要六个隐藏字符串中的四个做压缩用,其余两个就没用了吗?其实不然,其余两个可以通过随机算法动态的替换相同的字符进行再次压缩,以减少加密后的字符串长度(代码体积)

转换及压缩过程

'hi' => 01101000 01101001 => BCCA BCCB (8 char)

如果此时动态算法得出 2063(E) 被用于替换两个 C,则可以对上面结果进行再次压缩

BCCA BCCB => BEA BEB (6 char)

加salt

与MD5 + salt类似,通常StagCloak也会配一个salt

完整加密过程示例说明

  1. stegcloak.surge.sh中配置

    SECRET: 1 PASSWORD: 1 MESSAGE: 1 1

  2. 复制结果,并打印其Unicode:

    // 复制这里的 '1 ‌⁤‍⁡‍⁡⁡⁣⁤⁢‌⁢‍⁡‍‌‍⁢⁢⁢‍⁡⁡⁡⁣⁡⁢‌‍⁡‍⁣‍1',然后控制台length一下试试 😉
    console.log(stringToUnicode('1 ‌⁤‍⁡‍⁡⁡⁣⁤⁢‌⁢‍⁡‍‌‍⁢⁢⁢‍⁡⁡⁡⁣⁡⁢‌‍⁡‍⁣‍1')); // \u31\u20\u200c\u2064\u200d\u2061\u200d\u2061\u2061\u2063\u2064\u2062\u200c\u2062\u200d\u2061\u200d\u200c\u200d\u2062\u2062\u2062\u200d\u2061\u2061\u2061\u2063\u2061\u2062\u200c\u200d\u2061\u200d\u2063\u200d\u31
    复制代码
  3. 了解1的ASCII码转换为二进制的值:0011 0001 (十六进制:31),空格的ASCII码转换为二进制位:0010 0000 (十六进制为:20)

  4. 去掉可见字符:开头\u31\u20 与 结束\u31

  5. 剩余部分(总共34个Unicode字符):\u200c\u2064\u200d\u2061\u200d\u2061\u2061\u2063\u2064\u2062\u200c\u2062\u200d\u2061\u200d\u200c\u200d\u2062\u2062\u2062\u200d\u2061\u2061\u2061\u2063\u2061\u2062\u200c\u200d\u2061\u200d\u2063\u200d

也就是说上面部分隐藏了两个1(secret: 1 与 password: 1),1的二进制分组为:00 11 00 01,你能找出来吗?显然不太可能,不光上面所有Unicode字符为隐藏字符,里面还隐藏了动态随机值和过程,并进行了动态压缩。

  1. 混淆你的提交值
// 伪代码
getBannerConfig(1, 1, 'shang ⁣‍⁡⁢‌‍⁢‌‌⁡‍⁡‌⁡‌⁡‍‌⁡⁡⁡⁤⁡‌‍⁤⁣‍‌⁢⁡⁢⁡⁣⁤⁡⁡‍⁢‌⁣⁡⁢⁡⁢⁡‌‌⁢‌⁢‍‌‌⁤‌‍⁡⁢‌⁤‌⁣⁢⁡⁡‌⁣‍⁡⁡⁡hai'); // 应用ID: 1, 模块ID: 1, 城市:上海
复制代码

用postman模拟一下请求

其实上面看似简单的'shang ⁣‍⁡⁢‌‍⁢‌‌⁡‍⁡‌⁡‌⁡‍‌⁡⁡⁡⁤⁡‌‍⁤⁣‍‌⁢⁡⁢⁡⁣⁤⁡⁡‍⁢‌⁣⁡⁢⁡⁢⁡‌‌⁢‌⁢‍‌‌⁤‌‍⁡⁢‌⁤‌⁣⁢⁡⁡‌⁣‍⁡⁡⁡hai'隐藏了一堆信息,后端通过password进行解密得到真正的数据。

其实password也可以为一堆隐藏字符,示例(secret为:shanghai):

当删掉隐藏字符时,就拿不到真正的Secret

相关文章推荐

How to Hide Secrets in Strings— Modern Text hiding in JavaScript

【译】如何在字符串中隐藏秘密 —— JavaScript 中的现代文本隐藏

📣智云健康急招聘

欢迎小伙伴来和我们一起来做分享、做开源呀😃~