编码、散列和加密是三件不同的事

编码、散列和加密有何区别,Base64、百分号编码和 HTML 转义各自到底是干什么的,以及如何一眼认出一个来路不明的字符串。

一个看上去被打乱的字符串,并不是一个受保护的字符串。有三种彼此独立的操作都会产生不可读的输出,而把它们混为一谈,正是真实安全漏洞的来源。

可逆吗?需要密钥吗?用来做什么
编码可逆,谁都能逆不需要让字节穿过一条无法原样承载它们的通道
散列不可逆不需要指纹、完整性校验、查找用的键
加密可逆,但要有密钥需要保密

编码是换一套字母表。Base64、百分号编码、十六进制和 HTML 实体只是把同样的信息换个写法,好让下游的解析器不被噎住。其中不涉及任何秘密,所以也没有什么需要保守。凯撒移位也属于这一类:凯撒密码工具能一次把 25 种移位全部暴力试出来,这相当准确地概括了固定替换能提供多少保护。散列把任意输入映射成一个定长的摘要,而且无法反向运行。加密是这三者中唯一提供机密性的,而且它总是牵涉到一个密钥。如果你指不出密钥在哪里,那就没有任何东西被加密。

Base64 及其代价

Base64 把三个字节(24 位)拆成四组六位,再把每一组写成一个来自 64 字符字母表的字符。三个字节写成四个字符,意味着输出会大三分之一。这在嵌入文件时影响最大:文件转 Base64 工具产出的是一个 data URL,于是一张 300 KB 的图片进入你的 HTML 时大约是 400 KB,而且每次加载页面都要重新下载一遍。

当输入不是三字节的整数倍时,最后一组会用 = 补齐:

"abc"  ->  YWJj      (3 bytes, no padding)
"ab"   ->  YWI=      (2 bytes, one =)
"a"    ->  YQ==      (1 byte, two =)

标准字母表以 +/ 结尾,这在 URL 里是个问题,因为在表单数据里 + 会被解码成空格,而 / 是路径分隔符。所以还存在第二套字母表:Base64url 把 + 换成 -、把 / 换成 _,通常还会去掉补齐符。这就是为什么从 URL 里复制出来的令牌,有时在设为标准字母表的解码器里会失败。Base64 编码器两套都支持,而且能正确处理 Unicode,粗糙的实现往往做不到这一点:文本必须先做 UTF-8 编码,再进入 Base64 这一步。

以上没有一样是安全措施。某个产品在 URL 里称之为“加密”标识符的值,非常经常就是一个普通整数的 Base64,任何人都能在几秒内读出来、改掉、再编码回去。JSON Web Token 的载荷也是一样:JWT 解码器不用密钥就能读出来,因为根本没有什么东西被锁上。

百分号编码,以及该用哪个函数

百分号编码把一个字节替换成 % 加两位十六进制数字。它作用在字节而不是字符上,所以非 ASCII 文本要先做 UTF-8 编码:é 变成 %C3%A9

非保留字符(A-Za-z0-9-._~)永远不需要编码。保留字符(: / ? # [ ] @ ! $ & ' ( ) * + , ; =)是赋予 URL 结构的分隔符,某个保留字符是否需要编码,取决于它此刻是被当作分隔符还是被当作数据。这正是那两个 JavaScript 函数的区别所在:

encodeURI("https://ex.com/s?q=cats & dogs")
// https://ex.com/s?q=cats%20&%20dogs   the & still splits the query

encodeURIComponent("cats & dogs")
// cats%20%26%20dogs                    safe as a single value

encodeURI 用于把一整个 URL 变成合法的形式,所以它保留分隔符不动。encodeURIComponent 用于放进路径段或查询参数里的单个值,所以它会把分隔符编码掉。凡是你要插入进去的东西都用后者,另外注意它仍然不会去动 !'()*

然后是加号。以 application/x-www-form-urlencoded 发送的表单体把空格编码成 +,这个约定又渗进了查询字符串,所以多数服务器会把查询里的 + 解码成空格。字面的加号必须以 %2B 发送,这就是为什么 alex+billing@example.com 那么频繁地变成 alex billing@example.com,标签被毁掉了。相比之下,在路径段里 + 就只是一个加号,而空格是 %20。当一个值经受住了一轮往返却在下一轮崩掉时,URL 编码器能把这件事显示出来。

转义方式取决于值落在哪里

不存在一种统一的“适用于网页的转义”形式。上下文说了算。

上下文正确处理方式
HTML 文本&<> 转成实体
带引号的属性& 以及包住它的那种引号转成实体,并且属性一定要加引号
<script> 内部用 JavaScript 的字符串转义,而不是实体
hrefsrc 里的 URL先对值做百分号编码,再做属性转义,然后检查协议
CSS 值用 CSS 转义,而且绝不要用用户输入拼出 url()

script 那一条最容易让人栽跟头。脚本内容不会被做实体解码,所以写在那里的实体会保持字面形式,而 HTML 解析器一遇到 </script 这个序列就会结束这个块,不管它在不在引号里:

<script>var s = "</script>";</script>   <!-- block ends early -->
<script>var s = "<\/script>";</script>  <!-- correct -->

对 URL 来说,光做转义也不够。一个以 javascript: 开头的值,无论编码得多么小心,被点击时都会执行,所以要拿协议去比对一份允许列表。HTML 实体工具覆盖了文本和属性这两种情况,也能把实体解码回来,这在某个 API 把东西双重转义成 &amp;amp; 之后是常见的需求。

散列:用哪种算法,做什么用

算法摘要长度现状
MD5128 位已被攻破。构造碰撞轻而易举
SHA-1160 位已被攻破。选择前缀碰撞既现实又不贵
SHA-256、SHA-512256、512 位用于完整性校验和签名没问题
bcrypt、scrypt、Argon2不固定只用于密码

MD5 和 SHA-1 在抗碰撞性上失守,意思是攻击者可以构造出两个摘要相同的不同输入。凡是用散列值代表一份文档的场合,它们都不能用:签名、证书、内容寻址,以及对攻击者提供的任何内容做去重。两者在抗原像性上都还没被攻破,所以一个老旧的 MD5 校验和仍然能发现意外的损坏,但它们都不该出现在新的工作里。请用 SHA-256,散列生成器对文件和文本都能算。

密码是另一个问题,而快速散列恰恰因为它快而成了错误的工具。普通硬件每秒能做数十亿次 SHA-256 运算,所以一份泄露的 SHA-256 密码散列表,就是一份泄露的密码表。bcrypt、scrypt 和 Argon2id 是刻意做慢的,而且可调,带有一个工作因子(后两者还有内存开销),随着硬件进步你可以把它调高,并且它们各自都会把一个唯一的随机盐存进输出里。

散列也不是签名。要证明一条消息来自持有某个密钥的人,请用 HMAC-SHA-256,而不是把密钥和消息粘在一起做散列,后者存在长度扩展攻击的风险。

散列做不到什么

散列无法通过计算被逆推。但只要可能输入的集合小到可以枚举,它就能被搜索逆推:把每一个四位 PIN 都散列一遍,一万个摘要瞬间到手,一份泄露名单里的每个邮编、电话号码或地址也一样。把标识符散列一下,并不能让它匿名化。

加盐,也就是给每条记录存一个唯一的随机值并混进输入里,并不会让单个目标更难猜中,但它迫使攻击者必须逐条记录分别攻击,也让预计算的表变得一文不值。对于低熵的值,真正有帮助的那部分是胡椒(一个保存在数据库之外的秘密密钥)。

认出一个来路不明的字符串

形态识别特征示例
十六进制只有 0-9a-f,长度为偶数。32 个字符是 MD5,40 个是 SHA-1,64 个是 SHA-2565d41402abc4b2a76b9719d911017c592
Base64大小写混合并带 +/,长度是 4 的倍数,可能以 === 结尾SGVsbG8sIHdvcmxkIQ==
Base64url同上,但用 -_,通常不补齐eyJzdWIiOiIxMjM0NSJ9
百分号编码% 后面跟两位十六进制数字a%20b%26c
JWT三段用句点分隔的 Base64url,以 eyJ 开头eyJhbGciOi...
bcrypt正好 60 个字符,以 $2a$$2b$$2y$ 加一个开销值开头$2b$12$...
Argon2$argon2id$v=19$m=...,t=...,p=...$salt$hash

eyJ 值得记住:它是 {" 的 Base64,所以任何这样开头的片段都是一个编码过的 JSON 对象,不管它是不是令牌。

能成功解码并不能证明你猜对了,因为 Base64 会把任何输入都变成字节。要检查结果是不是像样的文本,或者是否以某个已知的文件签名开头:以 iVBORw0KGgo 开头的 Base64 是 PNG,/9j/ 是 JPEG,JVBERi0 是 PDF,UEsDB 是 zip。如果那些字节看起来什么都不像,就把输出送进文本转二进制工具直接读它的编码值,或者检查一下这个值是不是在做 Base64 编码之前先做过百分号编码,这是常见的双层包裹。